Compare commits

..

3 Commits

Author SHA1 Message Date
overtrue 1085e51987 test(e2e): rename stall timing variable flagged by typos 2026-09-02 22:12:19 +08:00
overtrue aab5f86f82 test(ci): refresh darwin e2e-full selection for ODM harness 2026-09-02 21:54:18 +08:00
overtrue de29c6384b test(e2e): extend fake S3 target as an on-demand migration source
Add ListObjectsV2 paging, Range GET/HEAD, unversioned buckets, standard
and user metadata replay, ResponseStatus/TruncateBodyAt/Stall fault
actions, Range/User-Agent/prefix/continuation-token journal fields,
count_requests, direct seeding, and a configurable object cap to the
programmable fake S3 target, and add the on_demand_migration e2e
harness (OdmTestEnv, admin wrappers, source seeding, local-state
assertions, second RustFS source) with its self-test.
2026-09-02 21:46:14 +08:00
279 changed files with 5916 additions and 57889 deletions
@@ -22,17 +22,3 @@
fixtures and encrypted migration data.
- Compatibility shims use `RUSTFS_COMPAT_TODO(<task-id>)`, have a removal
condition, and default toward reading old data safely.
## Outbound targets
- A change to what the replication or migration client sends by default
(checksum policy, payload framing, headers, version-id addressing) is judged
against every target class, not the one it fixes. Name each target-side rule
the current default satisfies — checksum required with Object Lock
parameters, `aws-chunked` decoding, version-id adoption, ETag equals content
MD5 — and show which cell of
`crates/e2e_test/src/replication_target_matrix_test.rs` covers each.
- A test that asserts the fix ("no trailer header") is not evidence; the
matrix cell that asserts the target accepted and stored the object is.
- Every new environment escape hatch appears in
`docs/operations/replication-outbound-transport.md` in the same diff.
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=a881fd7d3f5cb94654221ca85b8b30cce1b95e608824a55a15339cbc294e6d34
sha256-linux=e9a8d64e73f627c4d26c236dbbba690c9ee03a9e26d42a4244515b4439365535
sha256-darwin=9dccb0cd537cf79ae70c1c20e8281d36d03f2f09f81142a5341e26e3dc18709d
sha256-linux=a8a816d7bb0e7cb5632b1863b33794bcb9fc7e765f150aa5e1bf16518e28dfb4
+1 -2
View File
@@ -1,2 +1 @@
sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
sha256=d06524b44de97ed8f62b0fd8cf9fa504e3cd520ffcaacc32691d6f890ebe7f20
-1
View File
@@ -1 +0,0 @@
sha256=87c05c46d611ea7ed3feb5f7276bda8e5a0f70d72165d305d73a457907e7ba79
+1 -1
View File
@@ -1 +1 @@
sha256=95c8adc016bbc0df9fb2afa24a108bcdf6567ec4d0518725a6cae301593ab556
sha256=8d5517f5f2fc32d561782dfccd51b7f746f5e25b2835e37e100c883f7f18777d
+1 -1
View File
@@ -1 +1 @@
sha256=a2542dc86bbff56b2177efc621785c56fa7e8d813b209b7d935e1e41a9f0ad15
sha256=db9bd8cdcb0abe43461aa6b36499b17cabd4098e5b34e300b1a0f0d0f34d9884
+11 -85
View File
@@ -69,7 +69,7 @@ filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_
setup = 'ecstore-large-stack'
[[profile.default.scripts]]
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
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|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
setup = 'ecstore-base-stack'
[[profile.default.scripts]]
@@ -210,7 +210,7 @@ filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_
setup = 'ecstore-large-stack'
[[profile.ci.scripts]]
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
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|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]]
@@ -394,32 +394,13 @@ test-group = 'ecstore-serial-flaky'
# rustfs/rustfs#5169 disabled them) have PR-lane signal, not just merge-gate.
# Single-node servers on random ports with isolated temp dirs — meets the
# admission criteria unchanged.
#
# On-demand migration GA (backlog#2163 ODM-16): three named cases join the
# lane, one per user-visible contract of the feature — a GET miss that pulls
# the object and persists it locally, a HEAD miss that answers from the source
# and stores nothing, and the admin config/status pair that must redact the
# source secret. Each spawns one single-node rustfs server plus the in-process
# fake S3 source (`fake_s3_target`, already in the first clause), so they meet
# the admission criteria unchanged; measured at 15.8 s / 15.8 s / 15.9 s, which
# is entirely the shared server startup and overlaps the lane's other tests.
# The rest of `on_demand_migration::{get_basic,interaction,backfill,
# harness_self}_test` stays in e2e-full and the fault / concurrency /
# real-source modules stay in e2e-nightly; this is an allowlist, not a module
# clause, so a new ODM test never lands here silently.
#
# Scanner authoritative usage publication (backlog#2213): data_usage_test is
# the PR-lane e2e coverage for scanner usage snapshots consumed by quota and
# admin surfaces. It uses the same single-node, random-port, isolated-temp-dir
# fixture as the existing smoke modules.
[profile.e2e-smoke]
default-filter = """
package(e2e_test) & (
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_test::|^fake_s3_target::/)
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/)
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
| test(/^reliant::lifecycle::/)
| test(/^reliant::tiering::/)
| test(/^on_demand_migration::(get_basic_test::(get_miss_pulls_inline_and_serves_locally_afterwards|head_miss_answers_from_the_source_without_persisting)|interaction_test::test_odm_admin_config_is_redacted_and_status_counts_match_the_source)$/)
)
"""
fail-fast = false
@@ -459,10 +440,6 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
# until it is explicitly promoted to the fast PR subset — no replication test
# is ever silently left out of CI.
#
# replication_target_matrix_test (the outbound target matrix: every object
# shape against every remote-target failure mode the fake target models) runs
# here in full; its expectation table pins known-red cells to open issues.
#
# #[serial] does NOT serialize under nextest (process-per-test; see the file
# header). These tests need no cross-test serialization: each spawns its own
# server(s) on random ports with isolated temp dirs, so they are parallel-safe
@@ -480,7 +457,7 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
[profile.e2e-repl-nightly]
default-filter = """
package(e2e_test)
& (test(/^replication_extension_test::/) | test(/^replication_target_matrix_test::/))
& test(/^replication_extension_test::/)
& !test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
"""
fail-fast = false
@@ -493,29 +470,15 @@ path = "junit.xml"
# ---------------------------------------------------------------------------
# e2e-nightly profile — destructive multi-process cluster fault domains
# ---------------------------------------------------------------------------
# These eight modules are deliberately outside e2e-full's merge budget. Each
# These seven modules are deliberately outside e2e-full's merge budget. Each
# starts a real multi-process or multi-disk topology and exercises node/disk
# loss, quorum, cleanup, notification fan-in, or admin-timeout behavior. The
# consolidated nightly workflow runs them serially to avoid resource
# starvation; failures are never retried.
#
# heal_erasure_disk_rebuild_test also runs in e2e-full so core heal rebuild
# regressions are caught no later than the merge/main lane. It remains here for
# nightly serial coverage with the other cluster fault domains.
#
# On-demand migration (backlog#2158 ODM-11) joins by the second clause: the
# fault matrix waits out the 30 s circuit-breaker window, the concurrency
# matrix drives 100-deep bursts, and the real-source cases start a second
# (loop guard: a third) RustFS process. They are too slow or too heavy for
# the merge budget; `on_demand_migration::{get_basic,interaction}_test` stay
# in e2e-full, which excludes exactly these three modules.
[profile.e2e-nightly]
default-filter = """
package(e2e_test)
& (
test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
| test(/^on_demand_migration::(concurrency_test|fault_test|real_source_test)::/)
)
& test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
"""
fail-fast = false
@@ -526,34 +489,6 @@ path = "junit.xml"
filter = 'package(e2e_test)'
test-group = 'e2e-cluster-nightly'
# ---------------------------------------------------------------------------
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
# ---------------------------------------------------------------------------
# backlog#2167. Report-only, scheduled, never a required check; wired by
# .github/workflows/on-demand-migration-interop.yml.
#
# The four cases in `on_demand_migration::interop_test` take their source from
# the environment (`RUSTFS_ODM_INTEROP_*`, documented on the constants in
# `crates/e2e_test/src/on_demand_migration/common.rs`), so the same bodies run
# against the in-process fake source locally and against a MinIO container or a
# real cloud provider in the lane. The cloud jobs narrow this profile with their
# own `-E` filter to the three-case minimum (GET miss, HEAD miss, merged list
# pagination) and pass `--no-tests=fail` so a rename cannot silently select
# nothing; the MinIO job runs the whole profile, backfill included.
#
# These cases are deliberately absent from every other lane: without an
# interop source they only re-prove what `get_basic_test` and
# `list_through_test` already cover in e2e-smoke and e2e-full. The committed
# selection digest is the guard against a rename dropping one of them.
[profile.e2e-odm-interop]
default-filter = 'package(e2e_test) & test(/^on_demand_migration::interop_test::/)'
fail-fast = false
[profile.e2e-odm-interop.junit]
# Emitted to target/nextest/e2e-odm-interop/junit.xml; the lane uploads it and
# reconciles it against the per-case JSON report entries.
path = "junit.xml"
# ---------------------------------------------------------------------------
# e2e-protocols profile — serial protocol lane
# ---------------------------------------------------------------------------
@@ -579,18 +514,11 @@ path = "junit.xml"
# The filter is "the whole e2e_test crate MINUS the sets owned by other lanes":
# * protocols:: — FTPS/SFTP/WebDAV, run from the dedicated protocol profile
# with one worker because the suite owns fixed ports.
# * cluster suites that spin up a RustFSTestClusterEnvironment
# * the 7 cluster suites that spin up a RustFSTestClusterEnvironment
# (cluster_concurrency, cluster_multidrive_pool, stale_multipart_cleanup_cluster,
# namespace_lock_quorum, admin_timeout_regression, object_lambda) — too
# heavy for the merge budget; they run in the e2e-nightly serial
# cluster-fault lane. heal_erasure_disk_rebuild is intentionally not
# excluded here because backlog#2213 promotes core heal rebuild coverage to
# this merge/main lane while retaining nightly coverage.
# * on_demand_migration::interop_test — the ODM-20 provider interoperability
# cases, which are meaningless without a source: they run in the dedicated
# [profile.e2e-odm-interop] lane below, where the workflow points them at a
# MinIO container or a real cloud provider. Excluding them here also keeps
# this profile's committed selection digest stable.
# namespace_lock_quorum, heal_erasure_disk_rebuild, admin_timeout_regression,
# object_lambda) — too heavy for the merge budget; they run in the
# e2e-nightly serial cluster-fault lane.
# * replication_extension_test — repl-1 already splits it into the PR
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (56 slow) lanes and reserves
# it for those, so e2e-full does not double-run it.
@@ -606,10 +534,8 @@ path = "junit.xml"
default-filter = """
package(e2e_test)
& !test(/^protocols::/)
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
& !test(/^replication_extension_test::/)
& !test(/^replication_target_matrix_test::/)
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
"""
fail-fast = false
@@ -1,100 +0,0 @@
name: On-demand migration interop report
description: >-
Merge the per-case JSON entries an on-demand-migration interop run wrote with
the nextest JUnit result into one provider report, and summarise it.
inputs:
provider:
description: Provider the run addressed (minio, aws, r2, gcs).
required: true
cases-dir:
description: Directory the cases wrote their JSON entries into.
required: true
junit:
description: nextest JUnit XML of the run.
required: true
output:
description: Path of the merged JSON report to write.
required: true
runs:
using: composite
steps:
# The JUnit file is authoritative for which cases ran and how they ended:
# a case that fails or panics never reaches its own report entry, so
# trusting the entries alone would silently shorten the report exactly when
# something went wrong. The entries only add what JUnit cannot know — the
# source request accounting and the bucket's migration counters.
- name: Merge interop case reports
shell: bash
env:
ODM_REPORT_PROVIDER: ${{ inputs.provider }}
ODM_REPORT_CASES_DIR: ${{ inputs.cases-dir }}
ODM_REPORT_JUNIT: ${{ inputs.junit }}
ODM_REPORT_OUTPUT: ${{ inputs.output }}
run: |
python3 - <<'PY'
import json
import os
import pathlib
import xml.etree.ElementTree as ElementTree
provider = os.environ["ODM_REPORT_PROVIDER"]
cases_dir = pathlib.Path(os.environ["ODM_REPORT_CASES_DIR"])
junit = pathlib.Path(os.environ["ODM_REPORT_JUNIT"])
output = pathlib.Path(os.environ["ODM_REPORT_OUTPUT"])
entries = {}
if cases_dir.is_dir():
for path in sorted(cases_dir.glob("*.json")):
entry = json.loads(path.read_text())
entries[entry["case"]] = entry
cases = []
for case in ElementTree.parse(junit).getroot().iter("testcase"):
name = case.get("name", "")
failed = [child for child in case if child.tag in ("failure", "error")]
skipped = [child for child in case if child.tag == "skipped"]
outcome = "failed" if failed else "skipped" if skipped else "passed"
entry = entries.get(name.rsplit("::", 1)[-1], {})
cases.append(
{
"name": name,
"outcome": outcome,
"junit_duration_ms": round(float(case.get("time", "0")) * 1000),
"case_duration_ms": entry.get("duration_ms"),
"source_requests": entry.get("source_requests"),
"odm_counters": entry.get("odm_counters"),
}
)
report = {
"provider": provider,
"repository": os.environ.get("GITHUB_REPOSITORY", ""),
"sha": os.environ.get("GITHUB_SHA", ""),
"run_id": os.environ.get("GITHUB_RUN_ID", ""),
"cases": cases,
"totals": {
"cases": len(cases),
"passed": sum(1 for case in cases if case["outcome"] == "passed"),
"failed": sum(1 for case in cases if case["outcome"] == "failed"),
},
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
summary = [f"### On-demand migration interop: `{provider}`", "", "| Case | Outcome | Duration | Source requests |", "|---|---|---|---|"]
for case in cases:
requests = case["source_requests"]
counted = f"{requests['total']} ({requests['counted_by']})" if requests else "not reported"
summary.append(f"| `{case['name']}` | {case['outcome']} | {case['junit_duration_ms']} ms | {counted} |")
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle:
handle.write("\n".join(summary) + "\n\n")
# A passed case with no entry of its own means the harness stopped
# writing one: the report would keep looking complete while silently
# losing its request accounting.
unreported = [case["name"] for case in cases if case["outcome"] == "passed" and case["source_requests"] is None]
if unreported:
raise SystemExit(f"passed cases wrote no interop report entry: {', '.join(unreported)}")
PY
@@ -1,315 +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.
# On-demand migration provider interop (rustfs/backlog#2167, ODM-20).
#
# The in-process fake source that the merge-gate ODM suite runs against covers
# the protocol semantics, but real implementations differ in path-style vs
# virtual-host addressing, region handling, ETag shape, list pagination and
# rate limiting. This lane runs the same case bodies
# (crates/e2e_test/src/on_demand_migration/interop_test.rs) against real
# sources; the source is injected through RUSTFS_ODM_INTEROP_* environment
# variables, so nothing about the cases is duplicated per provider.
#
# Report-only and scheduled. It is never a required check and must not be
# promoted to one: it depends on third-party endpoints and on repository
# secrets that a fork does not have.
#
# Jobs:
# * minio-source runs the whole e2e-odm-interop profile — read-through,
# HEAD passthrough, merged list pagination and a backfill — against a
# pinned MinIO container. The backfill is sized at 5,000 objects here: the
# fake source retains at most 4,096 object versions and 4,096 journal
# entries, so the merge-gate backfill coverage cannot go past that, and a
# real source is where a production-shaped batch belongs.
# * cloud-source runs the three-case minimum (GET miss, HEAD miss, merged
# list pagination) against AWS S3, Cloudflare R2 and the GCS XML
# interoperability API. Each provider is skipped with a summary note when
# its ODM_INTEROP_* repository secrets are absent, which is the normal
# state on a fork and in any clone of this repository.
#
# Every job uploads one JSON report per provider naming the cases, their
# timings and the source request accounting.
name: on-demand-migration-interop
on:
workflow_dispatch:
schedule:
# Nightly at 05:23 UTC, offset from the other nightly lanes.
- cron: "23 5 * * *"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# The three cases a cloud provider is asked for. Named individually rather
# than by module so adding a fourth case does not silently start billing a
# cloud account for it.
CLOUD_CASE_FILTER: >-
package(e2e_test) & test(/^on_demand_migration::interop_test::(interop_get_miss_pulls_from_the_source_and_serves_locally|interop_head_miss_answers_from_the_source_without_persisting|interop_list_through_pages_the_source_namespace)$/)
CLOUD_CASE_COUNT: "3"
jobs:
minio-source:
name: MinIO source (read-through, list-through, backfill)
# Skip on forks: needs this repository's runners and is not a contributor
# gate.
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
timeout-minutes: 90
env:
NO_PROXY: 127.0.0.1,localhost
# Fixed credentials of the container this job starts and throws away;
# not a secret and deliberately not read from one, so the lane runs
# unattended in any clone that enables it.
MINIO_ROOT_USER: rustfsodminterop
MINIO_ROOT_PASSWORD: rustfsodminteropsecret
RUSTFS_ODM_INTEROP_PROVIDER: minio
RUSTFS_ODM_INTEROP_ENDPOINT: http://127.0.0.1:9100
RUSTFS_ODM_INTEROP_REGION: auto
RUSTFS_ODM_INTEROP_BUCKET: odm-interop-source
RUSTFS_ODM_INTEROP_PATH_STYLE: path
RUSTFS_ODM_INTEROP_ACCESS_KEY: rustfsodminterop
RUSTFS_ODM_INTEROP_SECRET_KEY: rustfsodminteropsecret
RUSTFS_ODM_INTEROP_BACKFILL_OBJECTS: "5000"
RUSTFS_ODM_INTEROP_REPORT_DIR: ${{ github.workspace }}/artifacts/odm-interop/minio/cases
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/minio/selection.json
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: ci-odm-interop
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: 'false'
- name: Start MinIO source
run: |
set -euo pipefail
mkdir -p artifacts/odm-interop/minio
docker run -d --name rustfs-odm-interop-minio \
-e "MINIO_ROOT_USER=${MINIO_ROOT_USER}" \
-e "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" \
-p 9100:9000 \
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
for _ in $(seq 1 120); do
curl -fsS http://127.0.0.1:9100/minio/health/live >/dev/null 2>&1 && break
sleep 1
done
curl -fsS http://127.0.0.1:9100/minio/health/live
# The harness never creates a bucket, so that pointing it at a cloud
# account cannot create one there either. The source bucket for the
# container is created here instead.
- name: Create the MinIO source bucket
env:
AWS_ACCESS_KEY_ID: ${{ env.MINIO_ROOT_USER }}
AWS_SECRET_ACCESS_KEY: ${{ env.MINIO_ROOT_PASSWORD }}
AWS_DEFAULT_REGION: us-east-1
run: |
aws --endpoint-url "${RUSTFS_ODM_INTEROP_ENDPOINT}" \
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
- name: Build the RustFS binary under test
run: cargo build --locked -p rustfs --bins
# The lane selects tests by module, so a rename would quietly shrink it.
# The committed digest in .config/e2e-odm-interop-selection.txt fails
# closed on that.
- name: Verify interop lane membership
run: |
cargo nextest list --profile e2e-odm-interop -p e2e_test --message-format json > "${NEXTEST_LISTING}"
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
- name: Run the interop cases against MinIO
run: cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
- name: Build the MinIO interop report
if: always()
uses: ./.github/actions/odm-interop-report
with:
provider: minio
cases-dir: ${{ env.RUSTFS_ODM_INTEROP_REPORT_DIR }}
junit: target/nextest/e2e-odm-interop/junit.xml
output: artifacts/odm-interop/minio/report.json
- name: Collect MinIO logs
if: always()
run: |
docker logs --tail 500 rustfs-odm-interop-minio \
> artifacts/odm-interop/minio/minio.log 2>&1 || true
- name: Stop MinIO source
if: always()
run: docker rm -f rustfs-odm-interop-minio >/dev/null 2>&1 || true
- name: Upload the MinIO interop report
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: odm-interop-minio-${{ github.run_number }}-${{ github.run_attempt }}
path: |
artifacts/odm-interop/minio
target/nextest/e2e-odm-interop/junit.xml
retention-days: 14
# Unlike a production migration source, which needs read access only, the
# credentials here also seed the objects each case reads back, so they need
# write and delete on the interop bucket. Every run seeds under
# `odm-interop/<case>/<uuid>/` and deletes what it seeded when the case
# passes; give the bucket an expiration lifecycle rule so the prefixes a
# failing case leaves behind cannot accumulate.
cloud-source:
name: ${{ matrix.provider }} source (three-case minimum)
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- provider: aws
secret_prefix: AWS
path_style: virtual
- provider: r2
secret_prefix: R2
path_style: virtual
- provider: gcs
secret_prefix: GCS_HMAC
path_style: virtual
env:
RUSTFS_ODM_INTEROP_PROVIDER: ${{ matrix.provider }}
RUSTFS_ODM_INTEROP_PATH_STYLE: ${{ matrix.path_style }}
RUSTFS_ODM_INTEROP_ENDPOINT: ${{ secrets[format('ODM_INTEROP_{0}_ENDPOINT', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_REGION: ${{ secrets[format('ODM_INTEROP_{0}_REGION', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_BUCKET: ${{ secrets[format('ODM_INTEROP_{0}_BUCKET', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_ACCESS_KEY: ${{ secrets[format('ODM_INTEROP_{0}_ACCESS_KEY_ID', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_SECRET_KEY: ${{ secrets[format('ODM_INTEROP_{0}_SECRET_ACCESS_KEY', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_REPORT_DIR: ${{ github.workspace }}/artifacts/odm-interop/${{ matrix.provider }}/cases
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/${{ matrix.provider }}/selection.json
steps:
# Absent secrets are the normal state, not a failure: the lane reports
# which providers it could reach and skips the rest. An empty value is
# what an unset repository secret expands to, so it is checked, not the
# secret's existence.
- name: Check for provider credentials
id: credentials
run: |
set -euo pipefail
if [ -z "${RUSTFS_ODM_INTEROP_ENDPOINT}" ] \
|| [ -z "${RUSTFS_ODM_INTEROP_REGION}" ] \
|| [ -z "${RUSTFS_ODM_INTEROP_BUCKET}" ] \
|| [ -z "${RUSTFS_ODM_INTEROP_ACCESS_KEY}" ] \
|| [ -z "${RUSTFS_ODM_INTEROP_SECRET_KEY}" ]; then
echo "present=false" >> "$GITHUB_OUTPUT"
{
echo "### On-demand migration interop: \`${{ matrix.provider }}\`"
echo
echo "Skipped: the \`ODM_INTEROP_${{ matrix.secret_prefix }}_*\` repository secrets"
echo "(\`_ENDPOINT\`, \`_REGION\`, \`_BUCKET\`, \`_ACCESS_KEY_ID\`, \`_SECRET_ACCESS_KEY\`)"
echo "are not configured, so no real \`${{ matrix.provider }}\` source was reached."
echo
} >> "$GITHUB_STEP_SUMMARY"
else
echo "present=true" >> "$GITHUB_OUTPUT"
fi
- name: Checkout repository
if: steps.credentials.outputs.present == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Rust environment
if: steps.credentials.outputs.present == 'true'
uses: ./.github/actions/setup
with:
cache-shared-key: ci-odm-interop
cache-save-if: 'false'
install-build-packaging-tools: 'false'
- name: Build the RustFS binary under test
if: steps.credentials.outputs.present == 'true'
run: cargo build --locked -p rustfs --bins
# A filterset that matches nothing is valid, so the count is asserted
# rather than inferred from a green run.
- name: Verify the three-case minimum still selects three cases
if: steps.credentials.outputs.present == 'true'
run: |
set -euo pipefail
mkdir -p "$(dirname "${NEXTEST_LISTING}")"
cargo nextest list --profile e2e-odm-interop -p e2e_test \
-E "${CLOUD_CASE_FILTER}" --message-format json > "${NEXTEST_LISTING}"
selected="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(sum(1 for suite in d.get("rust-suites", {}).values() for test in suite.get("testcases", {}).values() if test.get("filter-match", {}).get("status") == "matches"))' "${NEXTEST_LISTING}")"
echo "cloud interop cases selected: ${selected}"
if [ "${selected}" != "${CLOUD_CASE_COUNT}" ]; then
echo "::error::CLOUD_CASE_FILTER selected ${selected} cases, expected ${CLOUD_CASE_COUNT}; the interop cases were renamed or moved. Context: rustfs/backlog#2167."
exit 1
fi
- name: Run the three-case minimum
if: steps.credentials.outputs.present == 'true'
run: |
cargo nextest run --profile e2e-odm-interop -p e2e_test \
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
- name: Build the ${{ matrix.provider }} interop report
if: always() && steps.credentials.outputs.present == 'true'
uses: ./.github/actions/odm-interop-report
with:
provider: ${{ matrix.provider }}
cases-dir: ${{ env.RUSTFS_ODM_INTEROP_REPORT_DIR }}
junit: target/nextest/e2e-odm-interop/junit.xml
output: artifacts/odm-interop/${{ matrix.provider }}/report.json
- name: Upload the ${{ matrix.provider }} interop report
if: always() && steps.credentials.outputs.present == 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: odm-interop-${{ matrix.provider }}-${{ github.run_number }}-${{ github.run_attempt }}
path: |
artifacts/odm-interop/${{ matrix.provider }}
target/nextest/e2e-odm-interop/junit.xml
retention-days: 14
alert-on-failure:
name: Alert on scheduled failure
needs: [minio-source, cloud-source]
if: >-
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+3 -138
View File
@@ -11,20 +11,9 @@ on:
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
required: false
type: string
rc_archive_url:
description: 'Exact RustFS CLI Linux archive URL.'
required: false
default: 'https://github.com/rustfs/cli/releases/download/v0.1.32/rustfs-cli-linux-amd64-v0.1.32.tar.gz'
type: string
rc_archive_sha256:
description: 'Expected SHA-256 of the RustFS CLI archive.'
required: false
default: 'ab00d937079dcb6f1c7b41d34bbfaad0eb0bd4f7218672cbcb7c33652d1c46df'
type: string
rc_sha256:
description: 'Expected SHA-256 of the extracted RustFS CLI binary.'
description: 'Optional SHA-256 for the preinstalled rc binary; mismatch is an infrastructure failure.'
required: false
default: '320bdd4223a4d1986c1a098165f2198e92c35c4042b9a4d5e6fa33e9152477df'
type: string
force_case_failure:
description: 'Diagnostic only: rewrite single-single/TIER-101 to FAIL after execution to verify artifact and final-gate behavior.'
@@ -52,9 +41,7 @@ env:
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
RUSTFS_RC_ARCHIVE_URL: ${{ inputs.rc_archive_url || 'https://github.com/rustfs/cli/releases/download/v0.1.32/rustfs-cli-linux-amd64-v0.1.32.tar.gz' }}
RUSTFS_RC_ARCHIVE_SHA256: ${{ inputs.rc_archive_sha256 || 'ab00d937079dcb6f1c7b41d34bbfaad0eb0bd4f7218672cbcb7c33652d1c46df' }}
RUSTFS_EXPECTED_RC_SHA256: ${{ inputs.rc_sha256 || '320bdd4223a4d1986c1a098165f2198e92c35c4042b9a4d5e6fa33e9152477df' }}
RUSTFS_EXPECTED_RC_SHA256: ${{ inputs.rc_sha256 || vars.RUSTFS_TIER_RC_SHA256 }}
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
TIER_ARTIFACTS_DIR: /tmp/rustfs-tier-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
@@ -99,111 +86,6 @@ jobs:
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
exit 1
- name: Prepare pinned RustFS CLI
id: rc
run: |
set -euo pipefail
umask 077
case "${RUSTFS_RC_ARCHIVE_URL}" in
https://*) ;;
*)
echo "RustFS CLI archive URL must use HTTPS" >&2
exit 1
;;
esac
EXPECTED_ARCHIVE_SHA256="$(printf '%s' "${RUSTFS_RC_ARCHIVE_SHA256}" | tr '[:upper:]' '[:lower:]')"
EXPECTED_RC_SHA256="$(printf '%s' "${RUSTFS_EXPECTED_RC_SHA256}" | tr '[:upper:]' '[:lower:]')"
if ! [[ "${EXPECTED_ARCHIVE_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
echo "invalid RustFS CLI archive SHA-256" >&2
exit 1
fi
if ! [[ "${EXPECTED_RC_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
echo "invalid RustFS CLI binary SHA-256" >&2
exit 1
fi
RC_ROOT="${RUNNER_TEMP}/rustfs-tier-rc-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
RC_ARCHIVE="${RC_ROOT}/rustfs-cli.tar.gz"
RC_BIN="${RC_ROOT}/rc"
if ! mkdir -- "${RC_ROOT}"; then
echo "refusing to reuse RustFS CLI directory: ${RC_ROOT}" >&2
exit 1
fi
curl --fail --location --retry 3 --retry-all-errors \
--connect-timeout 15 --max-time 180 \
--proto '=https' --proto-redir '=https' \
--output "${RC_ARCHIVE}" "${RUSTFS_RC_ARCHIVE_URL}"
RC_ARCHIVE_SIZE="$(wc -c < "${RC_ARCHIVE}" | tr -d '[:space:]')"
if [ "${RC_ARCHIVE_SIZE}" -eq 0 ] || [ "${RC_ARCHIVE_SIZE}" -gt 33554432 ]; then
echo "RustFS CLI archive size is outside the accepted range: ${RC_ARCHIVE_SIZE}" >&2
exit 1
fi
if ! ACTUAL_ARCHIVE_SHA256="$(openssl dgst -sha256 -r "${RC_ARCHIVE}" | awk '{print $1}')"; then
echo "failed to calculate RustFS CLI archive SHA-256" >&2
exit 1
fi
if [ "${ACTUAL_ARCHIVE_SHA256}" != "${EXPECTED_ARCHIVE_SHA256}" ]; then
echo "RustFS CLI archive SHA-256 mismatch: expected ${EXPECTED_ARCHIVE_SHA256}, got ${ACTUAL_ARCHIVE_SHA256}" >&2
exit 1
fi
ARCHIVE_MEMBERS="$(tar -tzf "${RC_ARCHIVE}")"
if ! grep -Fxq 'rc' <<< "${ARCHIVE_MEMBERS}"; then
echo "RustFS CLI archive does not contain the rc entry" >&2
exit 1
fi
if ! tar -xOzf "${RC_ARCHIVE}" rc > "${RC_BIN}"; then
echo "failed to extract the RustFS CLI binary" >&2
exit 1
fi
chmod 0700 "${RC_BIN}"
if ! ACTUAL_RC_SHA256="$(openssl dgst -sha256 -r "${RC_BIN}" | awk '{print $1}')"; then
echo "failed to calculate RustFS CLI binary SHA-256" >&2
exit 1
fi
if [ "${ACTUAL_RC_SHA256}" != "${EXPECTED_RC_SHA256}" ]; then
echo "RustFS CLI binary SHA-256 mismatch: expected ${EXPECTED_RC_SHA256}, got ${ACTUAL_RC_SHA256}" >&2
exit 1
fi
if ! RC_VERSION_OUTPUT="$(timeout 30 "${RC_BIN}" --version 2>&1)"; then
echo "failed to execute the pinned RustFS CLI" >&2
exit 1
fi
RC_VERSION="${RC_VERSION_OUTPUT%%$'\n'*}"
if [ -z "${RC_VERSION}" ]; then
echo "pinned RustFS CLI returned an empty version" >&2
exit 1
fi
jq -n \
--arg schema_version '1' \
--arg generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg archive_url "${RUSTFS_RC_ARCHIVE_URL}" \
--arg archive_sha256 "${ACTUAL_ARCHIVE_SHA256}" \
--arg archive_size "${RC_ARCHIVE_SIZE}" \
--arg path "${RC_BIN}" \
--arg version "${RC_VERSION}" \
--arg sha256 "${ACTUAL_RC_SHA256}" \
'{
schema_version: ($schema_version | tonumber),
generated_at: $generated_at,
archive: {
url: $archive_url,
sha256: $archive_sha256,
size: ($archive_size | tonumber)
},
binary: {
path: $path,
version: $version,
sha256: $sha256
}
}' > "${TIER_ARTIFACTS_DIR}/rc-bootstrap.json"
printf 'path=%s\n' "${RC_BIN}" >> "${GITHUB_OUTPUT}"
echo "RustFS CLI ready: ${RC_VERSION} (${ACTUAL_RC_SHA256})"
- name: Show environment
run: |
uname -a
@@ -264,12 +146,12 @@ jobs:
continue-on-error: true
env:
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
RC_BIN: ${{ steps.rc.outputs.path }}
RUSTFS_VERSION_INPUT: ${{ inputs.rustfs_version }}
run: |
set -euo pipefail
LOG_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier.log"
chmod +x auto-testing/rustfs-tier-test.sh
RC_BIN="$(command -v rc)"
PACKAGE_URL="${PACKAGE_URL_INPUT}"
RUSTFS_VERSION="${RUSTFS_VERSION_INPUT}"
ARGS=(
@@ -326,11 +208,6 @@ jobs:
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
if RC_BOOTSTRAP_SUMMARY="$(jq -r '.binary | "\(.version) / \(.sha256)"' "${TIER_ARTIFACTS_DIR}/rc-bootstrap.json" 2>/dev/null)"; then
:
else
RC_BOOTSTRAP_SUMMARY="missing or invalid"
fi
set +e
python3 auto-testing/rustfs_tier_report.py \
--results-dir "${TIER_ARTIFACTS_DIR}/cases" \
@@ -352,7 +229,6 @@ jobs:
echo "- Run: ${RUN_URL}"
echo "- Trigger: ${TRIGGER_NAME}"
echo "- Package: ${PACKAGE_SOURCE}"
echo "- Client bootstrap: ${RC_BOOTSTRAP_SUMMARY}"
echo "- Test Step Outcome: ${TEST_OUTCOME}"
echo "- Structured Gate Exit: ${CASE_GATE_RC}"
echo ""
@@ -403,7 +279,6 @@ jobs:
rustfs-tier-report.md \
rustfs-tier-cases.md \
rustfs-tier-gate.rc \
rc-bootstrap.json \
provenance.json; do
if [ ! -s "${TIER_ARTIFACTS_DIR}/${name}" ]; then
echo "required tier evidence is missing or empty: ${name}" >&2
@@ -451,16 +326,6 @@ jobs:
'
done
- name: Cleanup pinned RustFS CLI
if: always()
run: |
set -euo pipefail
RC_ROOT="${RUNNER_TEMP}/rustfs-tier-rc-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
rm -f -- "${RC_ROOT}/rustfs-cli.tar.gz" "${RC_ROOT}/rc"
if [ -d "${RC_ROOT}" ]; then
rmdir -- "${RC_ROOT}"
fi
- name: Enforce tier suite result
id: gate
if: always()
-2
View File
@@ -55,8 +55,6 @@ docs/*
!docs/architecture/**
!docs/operations/
!docs/operations/**
!docs/postmortems/
!docs/postmortems/**
!docs/testing/
!docs/testing/**
.codegraph/*
-6
View File
@@ -162,12 +162,6 @@ Risk and review shape:
S3-visible semantics. Cover all applicable lenses using exactly two
independent reviewers when delegation is explicitly authorized. Split the
lenses between them. Otherwise perform two fresh sequential passes.
- **Outbound client defaults:** what `TargetClient`, `PutObjectOptions`, or
the remote SDK configuration sends to every replication or migration target
is high risk for every target class even when the change fixes one. Follow
the SOP in `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`:
run the outbound target matrix, document each new env knob in the same PR,
and list verified and unverified target classes in the PR Impact section.
Available domain lenses are security, concurrency/durability, compatibility,
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
+2 -2
View File
@@ -92,7 +92,7 @@ refactors.
| Domain | Current workspace crates | Responsibility |
|--------|--------------------------|----------------|
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-metrics`, `utils` | Shared configuration, data-usage models, heal domain contracts, scanner telemetry types, utilities, and checksums. |
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-contracts`, `utils` | Shared configuration, data-usage models, heal/scanner domain contracts, utilities, and checksums. |
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `s3-client`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, I/O pipelines, and the engine-side S3 client for remote tier/transition targets. |
| Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. |
| Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. |
@@ -135,7 +135,7 @@ default build (lifecycle:
`crates/ecstore/src/bucket/replication/replication_state.rs`) — a naming
collision, not copies; renaming is tracked in rustfs/backlog#1847.
- `LastMinuteLatency` has two deliberately different implementations: the
per-second bucketed accumulator in `crates/scanner-metrics/src/last_minute.rs` and
per-second bucketed accumulator in `crates/scanner-contracts/src/last_minute.rs` and
the in-memory endpoint-health sample tracker in
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
why it stays local).
-7
View File
@@ -12,13 +12,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
- **On-Demand Migration**: Lazy, pull-style migration of an existing S3-compatible bucket into RustFS. A local bucket is attached to an external source bucket; a GET for a key that does not exist locally fetches it from the source, streams it to the client, and stores it locally in the same pass, so every later read is served locally. The module is on by default; set `RUSTFS_ON_DEMAND_MIGRATION_ENABLED=false` on every node to turn it off. A bucket with no source configured behaves exactly as before — the runtime never intervenes on its reads and makes no outbound call. Operator guide at `docs/operations/on-demand-migration.md`.
- Per-bucket configuration persisted as `on-demand-migration.json` in the bucket metadata: source provider (`s3`, `aws`, `minio`, `rustfs`, `r2`, `gcs`), endpoint, region, addressing style, credentials and TLS material, an optional key-prefix filter and source-prefix rewrite, and a policy block covering the inline size threshold, multipart part size, concurrency, queue capacity, timeouts, bandwidth limit and negative-cache TTL
- Admin routes under `/rustfs/admin/v3/on-demand-migration/{bucket}`: `PUT` (with `?dry-run=true` to validate and probe the source without saving), `GET`, `DELETE`, `GET .../status`, plus `POST .../backfill?op=start|cancel` and `GET .../backfill` for the background full-backfill job with its resumable checkpoint. Authorized by the new `admin:GetBucketOnDemandMigration` and `admin:SetBucketOnDemandMigration` actions; every response redacts `secret_key` and `session_token`
- Read paths: an object at or below `policy.inline_max_bytes` (16 MiB by default) is teed to the client and to the local store in a single source read; a larger object or a Range read streams through and a background pull stores the whole object. A HEAD miss is proxied to the source and stores nothing (`policy.head = local_only` disables it). Every source-backed response carries `x-rustfs-on-demand-migration: source`
- Protections: a per-source circuit breaker, a per-key negative cache, singleflight per key, a concurrency limit and a bounded pull queue shared by the inline and background paths, an optional bandwidth limit, an anti-loop request marker, and the shared outbound-endpoint (SSRF) policy
- Metrics under `rustfs_on_demand_migration_*` (`requests_total`, `pulled_bytes_total`, `pulled_objects_total`, `pull_failures_total`, `inflight_pulls`, `queue_depth`, `source_latency_seconds_*`, `breaker_state`), mirrored per node by the admin status route
- Limitations: listings show only local objects (the source is not merged into `ListObjectsV2`); PUT and DELETE never reach the source; a source object updated after it was pulled is not re-fetched; SSE-C source objects are unsupported and answer 424; `Last-Modified` on a pulled object is the local write time, with the source timestamp kept in metadata
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
- Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes
- Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window
Generated
+100 -111
View File
@@ -125,28 +125,13 @@ version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-no-stdlib"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib 2.0.4",
]
[[package]]
name = "alloc-stdlib"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
dependencies = [
"alloc-no-stdlib 3.0.0",
"alloc-no-stdlib",
]
[[package]]
@@ -682,9 +667,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.44"
version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844"
checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8"
dependencies = [
"compression-codecs",
"compression-core",
@@ -1039,7 +1024,7 @@ dependencies = [
"http 0.2.12",
"http 1.5.0",
"http-body 1.1.0",
"lru 0.18.4",
"lru 0.18.3",
"percent-encoding",
"regex-lite",
"sha2 0.11.0",
@@ -1328,9 +1313,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.16.0"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c054752dd9e4dc73d0b75748c99ac2d0feafbf2f25c7b0516f03a3534161223"
checksum = "954c563ce84507722d2679f07a35d21b9c6466b3872d513020d0281fc8112ac9"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -1368,9 +1353,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.6.3"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f94d16e797ec62cd999fc9d5942b48fa7050c3093ddadff48e4d7528d16fcb9"
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647"
dependencies = [
"base64-simd",
"bytes",
@@ -1743,20 +1728,9 @@ version = "8.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
dependencies = [
"alloc-no-stdlib 2.0.4",
"alloc-stdlib 0.2.4",
"brotli-decompressor 5.0.3",
]
[[package]]
name = "brotli"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
dependencies = [
"alloc-no-stdlib 3.0.0",
"alloc-stdlib 0.3.0",
"brotli-decompressor 6.0.0",
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
@@ -1765,18 +1739,8 @@ version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib 2.0.4",
"alloc-stdlib 0.2.4",
]
[[package]]
name = "brotli-decompressor"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46087801e40b785410c352bee60e9b6226312709cf52d5fff92776cdf5cf6490"
dependencies = [
"alloc-no-stdlib 3.0.0",
"alloc-stdlib 0.3.0",
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
@@ -2189,11 +2153,11 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.39"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
dependencies = [
"brotli 8.0.4",
"brotli",
"bzip2",
"compression-core",
"flate2",
@@ -2206,9 +2170,9 @@ dependencies = [
[[package]]
name = "compression-core"
version = "0.4.33"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
[[package]]
name = "concurrent-queue"
@@ -5103,9 +5067,9 @@ dependencies = [
[[package]]
name = "hickory-net"
version = "0.26.2"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "084e7bd6a377435d568f652153e571b50970d7ccc1d1eeec0519f834632287e1"
checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183"
dependencies = [
"async-trait",
"cfg-if",
@@ -5127,9 +5091,9 @@ dependencies = [
[[package]]
name = "hickory-proto"
version = "0.26.2"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d"
checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643"
dependencies = [
"data-encoding",
"idna",
@@ -5147,9 +5111,9 @@ dependencies = [
[[package]]
name = "hickory-resolver"
version = "0.26.2"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e4f9f4603319422d482738f3f6fe5aac03157fdbfed1cd85a3ff45adb09072f"
checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c"
dependencies = [
"cfg-if",
"futures-util",
@@ -5226,9 +5190,9 @@ dependencies = [
[[package]]
name = "hotpath"
version = "0.25.0"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ec7782e005cabd5eaf350febde384cd799faa3a0e624587aa8759c240e0b592"
checksum = "e2645642a23d4061ec15a4a6e74f851a3145c3125356846cfc7772ff9c6f2737"
dependencies = [
"arc-swap",
"async-channel",
@@ -5240,6 +5204,7 @@ dependencies = [
"futures-util",
"hdrhistogram",
"hotpath-macros",
"hotpath-meta",
"http 1.5.0",
"libc",
"object 0.36.7",
@@ -5259,15 +5224,30 @@ dependencies = [
[[package]]
name = "hotpath-macros"
version = "0.25.0"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "929b2285d2cd21b2733a7fb6ebc843bb4f83dbd1db0122f5f9ebb9567b1e2613"
checksum = "89a3d3cdf9b0d4d3d4f6d4a29798f3b9170401ba500eaa58dd8f890f926af0f1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "hotpath-macros-meta"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e84cd2417fa60938241cf1cd6c03e09953f5c821122dc5da9b8f27975d136c5b"
[[package]]
name = "hotpath-meta"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d2c145b67b1a4e7bcefa918995e212c30a49a85f05cc5962fe1f717878d560b"
dependencies = [
"hotpath-macros-meta",
]
[[package]]
name = "htmlescape"
version = "0.3.1"
@@ -5626,6 +5606,18 @@ dependencies = [
"tempfile",
]
[[package]]
name = "internal-russh-num-bigint"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8"
dependencies = [
"num-integer",
"num-traits",
"rand 0.10.2",
"rand_core 0.10.1",
]
[[package]]
name = "io-uring"
version = "0.7.14"
@@ -5876,20 +5868,17 @@ dependencies = [
[[package]]
name = "kafka-protocol"
version = "0.18.0"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "099d5c2f1b40cd830cbf18ca4d2a0805f2875b811ca18f0372b2433e68fe2dda"
checksum = "66292444a1cd4d430d450d472c30cba839d0724229aba2d79affffcf901516e2"
dependencies = [
"anyhow",
"bytes",
"crc",
"crc32c",
"flate2",
"indexmap 2.14.1",
"lz4",
"snap",
"paste",
"uuid",
"zstd",
]
[[package]]
@@ -6223,9 +6212,9 @@ dependencies = [
[[package]]
name = "lru"
version = "0.18.4"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
dependencies = [
"hashbrown 0.17.1",
]
@@ -6565,9 +6554,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.3"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
@@ -6674,7 +6663,7 @@ dependencies = [
"futures-sink",
"futures-util",
"keyed_priority_queue",
"lru 0.18.4",
"lru 0.18.3",
"mysql_common",
"percent-encoding",
"rand 0.10.2",
@@ -6898,8 +6887,6 @@ checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
"rand 0.10.2",
"rand_core 0.10.1",
]
[[package]]
@@ -7492,9 +7479,9 @@ dependencies = [
[[package]]
name = "pageant"
version = "0.2.3"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d8eab09a361a4afe0b1668be978cd97e4f052e927a92b0b608cf902965d49ce"
checksum = "3adadc44070da6f464b0918655a12f5792c156e088d8c4082d13e27d94c3e791"
dependencies = [
"base16ct 1.0.0",
"byteorder",
@@ -7553,7 +7540,7 @@ dependencies = [
"arrow-schema",
"arrow-select",
"base64 0.23.1",
"brotli 8.0.4",
"brotli",
"bytes",
"chrono",
"flate2",
@@ -7583,6 +7570,12 @@ dependencies = [
"phc",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "path-absolutize"
version = "4.0.1"
@@ -9221,9 +9214,9 @@ dependencies = [
[[package]]
name = "russh"
version = "0.63.2"
version = "0.63.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e134e2480f4e86f83e4aa45b4c0a9723f84beaffa694c54bdf056e74efdd7dd"
checksum = "35bab1b87d915817d5d9cc352637cd40d5f0b298a48c6309af9156a4addc3031"
dependencies = [
"aes 0.9.3",
"aws-lc-rs",
@@ -9252,12 +9245,13 @@ dependencies = [
"hex-literal",
"hmac 0.13.0",
"inout 0.2.2",
"internal-russh-num-bigint",
"keccak",
"log",
"md5",
"ml-kem",
"module-lattice",
"num-bigint 0.5.1",
"num-bigint 0.4.8",
"p256 0.14.0",
"p384 0.14.0",
"p521",
@@ -9487,7 +9481,7 @@ dependencies = [
"rustfs-s3select-api",
"rustfs-s3select-query",
"rustfs-scanner",
"rustfs-scanner-metrics",
"rustfs-scanner-contracts",
"rustfs-security-governance",
"rustfs-signer",
"rustfs-storage-api",
@@ -9733,7 +9727,7 @@ dependencies = [
"rustfs-rio-v2",
"rustfs-s3-client",
"rustfs-s3-types",
"rustfs-scanner-metrics",
"rustfs-scanner-contracts",
"rustfs-storage-api",
"rustfs-uring",
"rustfs-utils",
@@ -9948,11 +9942,11 @@ dependencies = [
[[package]]
name = "rustfs-kafka"
version = "1.3.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "759f8ddf709b497006e0f422a89890f11ac86c84c2c39df282891477c22f1a13"
checksum = "4eee0644a99743fb2f51db7fbae1a6ca2d85f064daf7595784eaa52834a68c96"
dependencies = [
"base64 0.23.1",
"base64 0.22.1",
"bytes",
"fnv",
"hmac 0.13.0",
@@ -9962,22 +9956,22 @@ dependencies = [
"pbkdf2 0.13.0",
"rand 0.10.2",
"rustls",
"rustls-native-certs",
"sha2 0.11.0",
"socket2",
"thiserror 2.0.20",
"tracing",
"twox-hash",
"uuid",
"webpki-roots 1.0.9",
]
[[package]]
name = "rustfs-kafka-async"
version = "1.3.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aa574a13d91ef529e3a08f059a5c3ee8d4c3b16cf62f62c72595a6dc4f79f8f"
checksum = "7cd1997c3116cb94ede80d9a0b828f46dd27386cc93825fce141a92eb3aa9630"
dependencies = [
"base64 0.23.1",
"base64 0.22.1",
"bytes",
"hmac 0.13.0",
"kafka-protocol",
@@ -9986,11 +9980,11 @@ dependencies = [
"rand 0.10.2",
"rustfs-kafka",
"rustls",
"rustls-native-certs",
"sha2 0.11.0",
"tokio",
"tokio-rustls",
"tracing",
"uuid",
"webpki-roots 1.0.9",
]
@@ -10089,7 +10083,7 @@ dependencies = [
"proptest",
"rustfs-config",
"rustfs-replication",
"rustfs-scanner-metrics",
"rustfs-scanner-contracts",
"rustfs-storage-api",
"s3s",
"serial_test",
@@ -10164,18 +10158,18 @@ dependencies = [
[[package]]
name = "rustfs-mimalloc"
version = "0.5.3"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a7b69356718defa4060de3059e609c9da6619c35924fefef55d902ccd94958"
checksum = "ed388b8a3d55818c32973ded41df5215d33ee0f574d9ca03f2731b3007c3284b"
dependencies = [
"rustfs-mimalloc-sys",
]
[[package]]
name = "rustfs-mimalloc-sys"
version = "0.5.3"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dd3ec9b7e7b9fed453acd8b4e32713ddc0e01e1aee31a3deec5b8025c0880c3"
checksum = "1c507265df958f6b63f8ccdd21819374186bab5dbdcc3f9c5cf8bff4b2e3f5d0"
dependencies = [
"cc",
]
@@ -10287,7 +10281,7 @@ dependencies = [
"rustfs-iam",
"rustfs-io-metrics",
"rustfs-notify",
"rustfs-scanner-metrics",
"rustfs-scanner-contracts",
"rustfs-security-governance",
"rustfs-storage-api",
"rustfs-utils",
@@ -10455,7 +10449,6 @@ dependencies = [
"base64-simd",
"bytes",
"crc-fast",
"criterion",
"faster-hex",
"futures",
"hex-simd",
@@ -10467,7 +10460,6 @@ dependencies = [
"md-5 0.11.0",
"minlz",
"pin-project-lite",
"proptest",
"rand 0.10.2",
"reqwest",
"rustfs-config",
@@ -10644,17 +10636,15 @@ dependencies = [
"rand 0.10.2",
"rmp-serde",
"rustfs-common",
"rustfs-concurrency",
"rustfs-config",
"rustfs-credentials",
"rustfs-data-usage",
"rustfs-ecstore",
"rustfs-filemeta",
"rustfs-heal-contracts",
"rustfs-lifecycle",
"rustfs-lock",
"rustfs-s3-types",
"rustfs-scanner-metrics",
"rustfs-scanner-contracts",
"rustfs-storage-api",
"rustfs-utils",
"s3s",
@@ -10674,7 +10664,7 @@ dependencies = [
]
[[package]]
name = "rustfs-scanner-metrics"
name = "rustfs-scanner-contracts"
version = "1.0.0-rc.5"
dependencies = [
"chrono",
@@ -10863,7 +10853,7 @@ version = "1.0.0-rc.5"
dependencies = [
"base64-simd",
"blake2",
"brotli 9.0.0",
"brotli",
"bytes",
"convert_case 0.12.0",
"crc-fast",
@@ -12455,9 +12445,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.13.2"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
dependencies = [
"tinyvec_macros",
]
@@ -13622,14 +13612,13 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wnaf"
version = "0.14.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa"
checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1"
dependencies = [
"ff 0.14.0",
"group 0.14.0",
"hybrid-array",
"primefield",
]
[[package]]
+11 -11
View File
@@ -52,7 +52,7 @@ members = [
"crates/s3select-api", # S3 Select API interface
"crates/s3select-query", # S3 Select query engine
"crates/scanner", # Scanner for data integrity checks and health monitoring
"crates/scanner-metrics", # Scanner metrics and cycle telemetry
"crates/scanner-contracts", # Scanner metrics and cycle contracts
"crates/security-governance", # Security governance contracts
"crates/extension-schema", # Extension schema contracts
"crates/signer", # client signer
@@ -93,7 +93,7 @@ redundant_clone = "warn"
rustfs = { path = "./rustfs", version = "1.0.0-rc.5" }
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.5" }
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.5" }
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.0-rc.5" }
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.5" }
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.5" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.5" }
rustfs-common = { path = "crates/common", version = "1.0.0-rc.5" }
@@ -145,7 +145,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.5" }
async-channel = "2.5.0"
async_zip = { default-features = false, version = "0.0.19" }
mysql_async = { default-features = false, version = "0.37.1" }
async-compression = { version = "0.4.44" }
async-compression = { version = "0.4.43" }
async-recursion = "1.1.1"
async-trait = "0.1.92"
async-nats = { version = "0.50.0", default-features = false }
@@ -165,7 +165,7 @@ http-body = "1.1.0"
http-body-util = "0.1.5"
minlz = "1.2.3"
reqwest = "0.13.4"
rustfs-kafka-async = { version = "1.3.1" }
rustfs-kafka-async = { version = "1.2.0" }
socket2 = { version = "0.6.5" }
tokio = { version = "1.53.1" }
tokio-rustls = { default-features = false, version = "0.26.4" }
@@ -244,10 +244,10 @@ aws-sdk-kms = { default-features = false, version = "1.117.0" }
aws-sdk-s3 = { default-features = false, version = "1.144.0" }
aws-sdk-sts = { default-features = false, version = "1.113.0" }
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
aws-smithy-runtime-api = { version = "1.16.0" }
aws-smithy-types = { version = "1.6.3" }
aws-smithy-runtime-api = { version = "1.15.0" }
aws-smithy-types = { version = "1.6.2" }
base64-simd = "0.8.0"
brotli = "9.0.0"
brotli = "8.0.4"
clap = { version = "4.6.6" }
const-str = { version = "1.1.0" }
convert_case = "0.12.0"
@@ -298,7 +298,7 @@ pretty_assertions = "1.4.1"
rand = { version = "0.10.2" }
ratelimit = "2.0.0"
rayon = "1.12.0"
rustfs-erasure-codec = { version = "8.0.2" }
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
reed-solomon-simd = "3.1.0"
regex = { version = "1.13.1" }
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
@@ -359,15 +359,15 @@ libunftp = { version = "0.23.0" }
unftp-core = "0.1.0"
suppaftp = { version = "11.0.0" }
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
russh = { version = "0.63.2" }
russh = { version = "0.63.1" }
russh-sftp = "2.4.0"
# WebDAV
dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.5.3" }
hotpath = { version = "0.25.0", default-features = false }
rustfs-mimalloc = { version = "0.5.1" }
hotpath = { version = "0.24.0", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.48" }
-27
View File
@@ -197,27 +197,6 @@ pub const DEFAULT_POOL_META_V3_FLEET_CONFIRMED: bool = false;
const _: () = assert!(!DEFAULT_POOL_META_V3_WRITE);
const _: () = assert!(!DEFAULT_POOL_META_V3_FLEET_CONFIRMED);
/// Maximum unpacked size accepted for one Snowball archive member.
///
/// The value is expressed in bytes. Invalid values use the default, while
/// valid values are clamped to [`MAX_SNOWBALL_ENTRY_BYTES`].
pub const ENV_SNOWBALL_MAX_ENTRY_BYTES: &str = "RUSTFS_SNOWBALL_MAX_ENTRY_BYTES";
pub const DEFAULT_SNOWBALL_MAX_ENTRY_BYTES: u64 = 1024 * 1024 * 1024;
pub const MAX_SNOWBALL_ENTRY_BYTES: u64 = 1024 * DEFAULT_SNOWBALL_MAX_ENTRY_BYTES;
/// Maximum cumulative unpacked object bytes accepted from one Snowball
/// archive request.
///
/// This does not include tar headers or bounded PAX metadata. The value is
/// expressed in bytes and is clamped to
/// [`MAX_SNOWBALL_UNPACKED_BYTES`].
pub const ENV_SNOWBALL_MAX_UNPACKED_BYTES: &str = "RUSTFS_SNOWBALL_MAX_UNPACKED_BYTES";
pub const DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES: u64 = 10 * 1024 * 1024 * 1024;
pub const MAX_SNOWBALL_UNPACKED_BYTES: u64 = 10 * 1024 * DEFAULT_SNOWBALL_MAX_ENTRY_BYTES;
const _: () = assert!(DEFAULT_SNOWBALL_MAX_ENTRY_BYTES <= MAX_SNOWBALL_ENTRY_BYTES);
const _: () = assert!(DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES <= MAX_SNOWBALL_UNPACKED_BYTES);
// =============================================================================
// Concurrent Request Fix - Timeout and Backpressure Configuration
// =============================================================================
@@ -841,10 +820,4 @@ mod remote_version_state_tests {
assert_eq!(super::ENV_POOL_META_V3_WRITE, "RUSTFS_POOL_META_V3_WRITE");
assert_eq!(super::ENV_POOL_META_V3_FLEET_CONFIRMED, "RUSTFS_POOL_META_V3_FLEET_CONFIRMED");
}
#[test]
fn snowball_limit_environment_names_are_stable() {
assert_eq!(super::ENV_SNOWBALL_MAX_ENTRY_BYTES, "RUSTFS_SNOWBALL_MAX_ENTRY_BYTES");
assert_eq!(super::ENV_SNOWBALL_MAX_UNPACKED_BYTES, "RUSTFS_SNOWBALL_MAX_UNPACKED_BYTES");
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
+1 -220
View File
@@ -22,10 +22,7 @@ mod tests {
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
ChecksumAlgorithm, ChecksumMode, ChecksumType as SdkChecksumType, CompletedMultipartUpload, CompletedPart,
ServerSideEncryption,
};
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart, ServerSideEncryption};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use md5::{Digest as Md5Digest, Md5};
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
@@ -599,222 +596,6 @@ mod tests {
Some(full_checksum.as_str()),
"Multipart object should report the same full-object CRC64NVME as direct upload"
);
assert_eq!(
multipart_head.checksum_type(),
Some(&SdkChecksumType::FullObject),
"Multipart object with a full-object checksum must report FULL_OBJECT"
);
}
/// Create a CRC32 FULL_OBJECT multipart upload and upload every part, returning
/// the upload id and the `CompletedPart` list ready for CompleteMultipartUpload.
async fn start_full_object_crc32_upload(
client: &Client,
bucket: &str,
key: &str,
parts: &[&Vec<u8>],
) -> (String, Vec<CompletedPart>) {
let create_result = client
.create_multipart_upload()
.bucket(bucket)
.key(key)
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.checksum_type(SdkChecksumType::FullObject)
.send()
.await
.expect("Failed to create multipart upload");
let upload_id = create_result.upload_id().expect("No upload_id").to_string();
let mut completed_parts = Vec::new();
for (index, part) in parts.iter().enumerate() {
let part_number = index as i32 + 1;
let uploaded = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(part_number)
.body(ByteStream::from((*part).clone()))
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.unwrap_or_else(|e| panic!("Failed to upload part {part_number}: {e:?}"));
completed_parts.push(
CompletedPart::builder()
.part_number(part_number)
.e_tag(uploaded.e_tag().expect("No etag for part"))
.checksum_crc32(uploaded.checksum_crc32().expect("No CRC32 for part"))
.build(),
);
}
(upload_id, completed_parts)
}
/// A multipart upload completed with a **full-object** checksum must report
/// `x-amz-checksum-type: FULL_OBJECT` on both GET and HEAD, the way AWS does.
///
/// `complete_multipart_upload` used to persist the object-level checksum
/// record with the pre-merge checksum type, so the MULTIPART /
/// INCLUDES_MULTIPART flags never reached disk. `rustfs_rio::read_checksums`
/// only emits the FULL_OBJECT entry inside its MULTIPART branch, so these
/// objects came back from GET and HEAD with no checksum-type header at all.
/// Found while root-causing rustfs#6825.
#[tokio::test]
async fn test_full_object_multipart_reports_full_object_checksum_type() {
init_logging();
info!("TEST: full-object multipart upload round-trips x-amz-checksum-type: FULL_OBJECT");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-full-object-checksum-type";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
const PART_SIZE: usize = 5 * 1024 * 1024;
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 241) as u8).collect();
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 29) % 241) as u8).collect();
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
// CRC32 with an explicit FULL_OBJECT type: the object checksum is the
// CRC32 of the whole object, not the composite hash of the part digests.
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
.expect("crc32 checksum")
.encoded;
let key = "full-object-multipart.bin";
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
// Restate the full-object intent and value on CompleteMultipartUpload,
// exactly as an AWS SDK client does: `x-amz-checksum-type: FULL_OBJECT`
// plus `x-amz-checksum-crc32`, with no `x-amz-checksum-algorithm`
// header (CompleteMultipartUpload has no such member).
.checksum_type(SdkChecksumType::FullObject)
.checksum_crc32(full_object_crc32.clone())
.send()
.await
.expect("Failed to complete multipart upload");
let head = client
.head_object()
.bucket(bucket)
.key(key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("Failed to head object");
assert_eq!(
head.checksum_type(),
Some(&SdkChecksumType::FullObject),
"HeadObject must report x-amz-checksum-type: FULL_OBJECT"
);
assert_eq!(
head.checksum_crc32(),
Some(full_object_crc32.as_str()),
"HeadObject must report the full-object CRC32, with no -<parts> suffix"
);
let get = client
.get_object()
.bucket(bucket)
.key(key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("Failed to get object");
assert_eq!(
get.checksum_type(),
Some(&SdkChecksumType::FullObject),
"GetObject must report x-amz-checksum-type: FULL_OBJECT"
);
assert_eq!(
get.checksum_crc32(),
Some(full_object_crc32.as_str()),
"GetObject must report the full-object CRC32, with no -<parts> suffix"
);
let body = get.body.collect().await.expect("Failed to read body").into_bytes();
assert_eq!(body.as_ref(), content.as_slice(), "GetObject body must match the uploaded content");
info!("PASSED: full-object multipart reports FULL_OBJECT on GET and HEAD");
}
/// Declaring a checksum type on CompleteMultipartUpload that contradicts the
/// one recorded at CreateMultipartUpload must be rejected, and rejected as a
/// client error (4xx), not a server error.
#[tokio::test]
async fn test_complete_multipart_rejects_contradicting_checksum_type() {
init_logging();
info!("TEST: CompleteMultipartUpload rejects a checksum type that contradicts the upload");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-checksum-type-mismatch";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
const PART_SIZE: usize = 5 * 1024 * 1024;
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 239) as u8).collect();
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 31) % 239) as u8).collect();
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
.expect("crc32 checksum")
.encoded;
let key = "checksum-type-mismatch.bin";
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
let err = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
// The upload was created as FULL_OBJECT; claiming COMPOSITE here
// contradicts it.
.checksum_type(SdkChecksumType::Composite)
.checksum_crc32(full_object_crc32.clone())
.send()
.await
.expect_err("COMPOSITE on a FULL_OBJECT upload must be rejected");
let service_err = err.into_service_error();
let code = service_err.meta().code().unwrap_or("<no code>").to_string();
let message = service_err.meta().message().unwrap_or_default().to_string();
// Before the fix the storage layer refused the combination with a generic
// error and the caller got `500 InternalError` -- "please try again" for a
// request that can only ever fail.
assert_eq!(
code, "InvalidRequest",
"a contradicting checksum type is a client error, got {code}: {message}"
);
assert!(
message.contains("FULL_OBJECT") && message.contains("COMPOSITE"),
"the message must name the recorded and requested types, got {message}"
);
// The upload is untouched by the rejected completion, so a well-formed
// retry on the same upload id still succeeds.
let listed = client
.list_parts()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.send()
.await
.expect("upload must survive the rejected completion");
assert_eq!(listed.parts().len(), 2, "both parts must still be listed after the rejection");
info!("PASSED: contradicting checksum type rejected as InvalidRequest");
}
/// Integration test for the AWS 2026-04 additional checksum algorithms
+1 -3
View File
@@ -6,12 +6,10 @@ This module is the shared failure-injection boundary for replication end-to-end
Supported data operations are HeadBucket, GetBucketVersioning, ListObjectsV2, PUT/GET/HEAD/DELETE Object, Get/Put/Delete ObjectTagging (tags live per version; Put replaces the whole set, Delete clears it), and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets created with `create_bucket` are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
`create_bucket_with_object_lock(name)` creates a versioned bucket whose GetObjectLockConfiguration reports `Enabled`; every other bucket answers `ObjectLockConfigurationNotFoundError`, the code RustFS's replication-check classifies as "not enabled". Three switches model remote-target behaviors the fleet has shown, so the outbound target matrix (`crates/e2e_test/src/replication_target_matrix_test.rs`) can replicate every object shape against each: `assign_own_version_ids(true)` ignores the source version id and mints its own (AWS S3 / Wasabi); `reject_aws_chunked_uploads(true)` refuses any PutObject or UploadPart announcing `aws-chunked` framing (`Content-Encoding: aws-chunked`, an `x-amz-trailer`, or a `STREAMING-*` payload hash) with `InvalidRequest` before the body is read (SeaweedFS 3.97, rustfs#6853); `require_checksum_for_object_lock(true)` rejects a PutObject carrying any `x-amz-object-lock-*` header unless it also carries `Content-MD5`, an `x-amz-checksum-*` header, or `x-amz-sdk-checksum-algorithm` (AWS S3 / MinIO, rustfs#7082). Independently of that switch, a `Content-MD5` header is always verified against the body and a mismatch answers `BadDigest`.
`create_bucket_with_mode(name, BucketMode::Unversioned)` models a plain migration source: PUT overwrites in place, DELETE removes the key without a delete marker, GetBucketVersioning reports no status, and no `x-amz-version-id` is returned by PUT, GET, HEAD, tagging, or multipart completion. The only `versionId` such a bucket accepts is `null`; any other value is rejected with `InvalidArgument`. The mode is fixed at creation.
ListObjectsV2 lists current versions only (a key whose newest version is a delete marker is hidden) in byte order and supports `prefix`, `delimiter`, `max-keys` (clamped to 1000), `start-after`, and `continuation-token`; common prefixes count toward `max-keys`, `IsTruncated` / `NextContinuationToken` / `KeyCount` follow S3, and continuation tokens are opaque. `encoding-type` and `fetch-owner` are accepted but ignored, and ListObjects (v1) is not implemented. GET and HEAD honor `Range` in the `bytes=first-last`, `bytes=first-`, and `bytes=-suffix` forms with a 206 status, exact `Content-Range`, and `Accept-Ranges: bytes`; unsatisfiable ranges answer 416 `InvalidRange` with `Content-Range: bytes */<length>`. PUT and CreateMultipartUpload accept `Content-Type`, `Content-Encoding`, `Content-Disposition`, `Content-Language`, `Cache-Control`, `Expires`, and `x-amz-meta-*` (names stored lowercased), and HEAD/GET replay them verbatim together with `Last-Modified` and the ETag (hex MD5 for single PUTs, `<md5-of-part-md5s>-<parts>` for multipart objects). `put_seed_object` stores an object directly, bypassing the wire, the fault script, and the journal, so a source can be seeded without polluting the assertions a scenario later makes.
Fault actions cover HTTP 401/403/503 responses (`Status`), any 4xx/5xx status paired with the matching S3 error code (`ResponseStatus`), pre-dispatch delay, holding a fully computed successful response before its first byte (`Stall`), connection abort when a logical request-body threshold is reached, GetObject bodies cut off after N bytes while `Content-Length` announces the full size (`TruncateBodyAt`), GetObject bodies delivered in fixed slices with a pause between them (`SlowSendBody`, a mid-body stall rather than a first-byte one), streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions and `count_requests(operation, key)` counts entries for one exact key. Each record journals the `Range` and `User-Agent` request headers, the ListObjectsV2 `prefix` and `continuation-token` query values, a `TransportSnapshot` — whether the body was announced as `aws-chunked`, the verbatim `Content-MD5`, the sorted `x-amz-checksum-*` / `x-amz-sdk-checksum-algorithm` header names, and whether any `x-amz-object-lock-*` header was present — and a `ProxyHeaderSnapshot` — the read-proxy anti-loop marker (`x-{rustfs,minio}-source-proxy-request`), the replication-check exemption header, and the client SSE-C header family (algorithm and key-MD5 values; for the key itself only its presence) — so proxy tests can pin the exact wire contract.
Fault actions cover HTTP 401/403/503 responses (`Status`), any 4xx/5xx status paired with the matching S3 error code (`ResponseStatus`), pre-dispatch delay, holding a fully computed successful response before its first byte (`Stall`), connection abort when a logical request-body threshold is reached, GetObject bodies cut off after N bytes while `Content-Length` announces the full size (`TruncateBodyAt`), streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions and `count_requests(operation, key)` counts entries for one exact key. Each record journals the `Range` and `User-Agent` request headers, the ListObjectsV2 `prefix` and `continuation-token` query values, and a `ProxyHeaderSnapshot` — the read-proxy anti-loop marker (`x-{rustfs,minio}-source-proxy-request`), the replication-check exemption header, and the client SSE-C header family (algorithm and key-MD5 values; for the key itself only its presence) — so proxy tests can pin the exact wire contract.
The listener is loopback-only. It admits at most 64 active connections and two concurrently buffered request bodies; authenticated multipart-complete XML collection and assembly take both body permits. Keep-alive is disabled, request-header reads are bounded to 30 seconds, a parsed request is bounded to 65 seconds, and the complete connection lifetime is bounded to 100 seconds. It retains at most 256 buckets, 4,096 journal entries, 4,096 scripted faults, 4,096 object versions, 256 multipart uploads, and 10,000 multipart parts. Retained identifiers are capped at 1 KiB, user metadata at 2 KiB, and content type and each standard object header at 1 KiB. By default a PUT or uploaded part is capped at 64 MiB and a completed multipart object and all stored object/part data are capped at 128 MiB; `FakeS3Target::start_with_options(FakeS3TargetOptions { max_object_bytes })` raises the object cap up to 256 MiB, and the total budget then becomes twice the object cap (never below 128 MiB). Body drain, body-permit waits, delay, stall, and slow-drain execution are bounded to 30 seconds; each slow-drain slice delay must be below that bound.
+6 -406
View File
@@ -18,7 +18,6 @@
//! See [`README.md`](README.md) for the supported protocol and fault surface.
use async_trait::async_trait;
use base64_simd::STANDARD as BASE64_STANDARD;
use bytes::{Bytes, BytesMut};
use futures::StreamExt;
use http::header::{CONTENT_LENGTH, ETAG, LAST_MODIFIED, RANGE, USER_AGENT};
@@ -34,12 +33,10 @@ use s3s::dto::{
AbortMultipartUploadInput, AbortMultipartUploadOutput, CommonPrefix, CompleteMultipartUploadInput,
CompleteMultipartUploadOutput, CreateMultipartUploadInput, CreateMultipartUploadOutput, DeleteMarkerEntry, DeleteObjectInput,
DeleteObjectOutput, DeleteObjectTaggingInput, DeleteObjectTaggingOutput, ETag, GetBucketVersioningInput,
GetBucketVersioningOutput, GetObjectInput, GetObjectLockConfigurationInput, GetObjectLockConfigurationOutput,
GetObjectOutput, GetObjectTaggingInput, GetObjectTaggingOutput, HeadBucketInput, HeadBucketOutput, HeadObjectInput,
HeadObjectOutput, ListObjectVersionsInput, ListObjectVersionsOutput, ListObjectsV2Input, ListObjectsV2Output, Object,
ObjectLockConfiguration, ObjectLockEnabled, ObjectStorageClass, ObjectVersionId, PutObjectInput, PutObjectOutput,
PutObjectTaggingInput, PutObjectTaggingOutput, Range, StreamingBlob, Tag, TagSet, Timestamp, TimestampFormat,
UploadPartInput, UploadPartOutput,
GetBucketVersioningOutput, GetObjectInput, GetObjectOutput, GetObjectTaggingInput, GetObjectTaggingOutput, HeadBucketInput,
HeadBucketOutput, HeadObjectInput, HeadObjectOutput, ListObjectVersionsInput, ListObjectVersionsOutput, ListObjectsV2Input,
ListObjectsV2Output, Object, ObjectStorageClass, ObjectVersionId, PutObjectInput, PutObjectOutput, PutObjectTaggingInput,
PutObjectTaggingOutput, Range, StreamingBlob, Tag, TagSet, Timestamp, TimestampFormat, UploadPartInput, UploadPartOutput,
};
use s3s::service::{S3Service, S3ServiceBuilder};
use s3s::validation::{AwsNameValidation, NameValidation};
@@ -119,7 +116,6 @@ pub const FAKE_SECRET_KEY: &str = "fake-secret";
pub enum Operation {
HeadBucket,
GetBucketVersioning,
GetObjectLockConfiguration,
PutObject,
GetObject,
HeadObject,
@@ -266,12 +262,6 @@ pub enum FaultAction {
/// body bytes, then abort the connection so the client observes a short
/// read. Ignored by every other operation.
TruncateBodyAt(usize),
/// GetObject only: deliver the response body in `chunk_bytes` slices,
/// sleeping `delay` between them. The head and the first slice leave
/// immediately, so this is a mid-body stall rather than a first-byte one;
/// a `delay` above the reader's idle budget is what a source that stops
/// pushing bytes looks like. Ignored by every other operation.
SlowSendBody { chunk_bytes: usize, delay: Duration },
/// Apply the request normally, then hold the complete response (status
/// line included) for the duration before the first byte is written —
/// the first-byte-timeout scenario. Error responses are not held.
@@ -360,9 +350,6 @@ pub struct RequestRecord {
pub consumed_bytes: Option<usize>,
pub replication_timestamps: ReplicationTimestampHeaders,
pub proxy_headers: ProxyHeaderSnapshot,
/// Integrity and framing headers of the request, so outbound-transport
/// tests can pin what the sender actually put on the wire.
pub transport: TransportSnapshot,
/// Verbatim `Range` request header, so range-forwarding tests can pin the
/// exact wire syntax a migrating server sent to its source.
pub range: Option<String>,
@@ -375,50 +362,6 @@ pub struct RequestRecord {
pub fault: Option<FaultAction>,
}
/// Integrity and framing headers of an upload. A remote target's acceptance
/// rules key off exactly these (rustfs#6853: `aws-chunked` framing stored
/// verbatim; rustfs#7082: Object Lock PUTs need `Content-MD5` or
/// `x-amz-checksum-*`), so the journal records them for every request.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TransportSnapshot {
/// The body was announced as `aws-chunked`: `Content-Encoding` names it,
/// an `x-amz-trailer` is declared, or the payload hash is `STREAMING-*`.
pub aws_chunked: bool,
/// Verbatim `Content-MD5` request header.
pub content_md5: Option<String>,
/// Sorted names of every `x-amz-checksum-*` header, plus
/// `x-amz-sdk-checksum-algorithm` when present (the header a trailer
/// checksum announces itself with).
pub checksum_headers: Vec<String>,
/// Any `x-amz-object-lock-*` header was present.
pub object_lock_params: bool,
}
impl TransportSnapshot {
fn from_headers(headers: &HeaderMap) -> Self {
let content_encoding_chunked = headers.get_all("content-encoding").iter().any(|value| {
value
.to_str()
.is_ok_and(|value| value.split(',').any(|token| token.trim().eq_ignore_ascii_case("aws-chunked")))
});
let streaming_payload_hash = header_value(headers, &["x-amz-content-sha256"])
.is_some_and(|value| value.to_ascii_uppercase().starts_with("STREAMING-"));
let mut checksum_headers: Vec<String> = headers
.keys()
.map(|name| name.as_str().to_string())
.filter(|name| name.starts_with("x-amz-checksum-") || name == "x-amz-sdk-checksum-algorithm")
.collect();
checksum_headers.sort();
checksum_headers.dedup();
Self {
aws_chunked: content_encoding_chunked || streaming_payload_hash || headers.contains_key("x-amz-trailer"),
content_md5: header_value(headers, &["content-md5"]).map(bounded_journal_value),
checksum_headers,
object_lock_params: headers.keys().any(|name| name.as_str().starts_with("x-amz-object-lock-")),
}
}
}
/// Request headers journaled with every record, captured before the fault
/// script is consulted.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -426,7 +369,6 @@ struct JournaledHeaders {
content_length: Option<u64>,
replication_timestamps: ReplicationTimestampHeaders,
proxy_headers: ProxyHeaderSnapshot,
transport: TransportSnapshot,
range: Option<String>,
user_agent: Option<String>,
}
@@ -440,7 +382,6 @@ impl JournaledHeaders {
.and_then(|value| value.parse().ok()),
replication_timestamps: ReplicationTimestampHeaders::from_headers(headers),
proxy_headers: ProxyHeaderSnapshot::from_headers(headers),
transport: TransportSnapshot::from_headers(headers),
range: header_value(headers, &[RANGE.as_str()]).map(bounded_journal_value),
user_agent: header_value(headers, &[USER_AGENT.as_str()]).map(bounded_journal_value),
}
@@ -463,14 +404,6 @@ struct StoreState {
/// transport headers instead of storing them (see
/// [`REPLICATION_SSE_TRANSPORT_PREFIX`]).
drop_unlisted_replication_headers: bool,
/// Models a target that cannot decode `aws-chunked` request bodies
/// (SeaweedFS 3.97, rustfs#6853): a PutObject or UploadPart announcing that
/// framing is refused with `InvalidRequest` before its body is read.
reject_aws_chunked_uploads: bool,
/// Models AWS S3 / MinIO / most compatible stores (rustfs#7082): a
/// PutObject carrying any `x-amz-object-lock-*` header must also carry
/// `Content-MD5` or an `x-amz-checksum-*` header.
require_checksum_for_object_lock: bool,
limits: StoreLimits,
buckets: HashMap<String, BucketState>,
uploads: HashMap<String, MultipartState>,
@@ -481,8 +414,6 @@ struct StoreState {
struct BucketState {
versioned: bool,
/// Object Lock enabled at creation; reported by GetObjectLockConfiguration.
object_lock: bool,
/// Versions per key, newest first (see `upsert_version`); an unversioned
/// bucket holds exactly one version per key.
objects: HashMap<String, Vec<ObjectVersion>>,
@@ -567,7 +498,6 @@ struct FakeBackend {
struct FaultAccess {
control: Arc<Mutex<ControlState>>,
body_limit: Arc<Semaphore>,
store: Arc<Mutex<StoreState>>,
}
#[derive(Debug, Clone)]
@@ -650,7 +580,6 @@ impl FakeS3Target {
builder.set_access(FaultAccess {
control: Arc::clone(&control),
body_limit,
store: Arc::clone(&backend.store),
});
builder.build()
};
@@ -752,7 +681,6 @@ impl FakeS3Target {
bucket,
BucketState {
versioned,
object_lock: false,
objects: HashMap::new(),
},
);
@@ -760,19 +688,6 @@ impl FakeS3Target {
}
}
/// Pre-create a versioned bucket with Object Lock enabled, the shape a
/// RustFS source with Object Lock requires of its replication target
/// (`replication-check` probes GetObjectLockConfiguration for it).
pub fn create_bucket_with_object_lock(&self, bucket: impl Into<String>) {
let bucket = bucket.into();
self.create_bucket_with_mode(bucket.clone(), BucketMode::Versioned);
lock(&self.backend.store)
.buckets
.get_mut(&bucket)
.expect("bucket was just created")
.object_lock = true;
}
/// Store an object directly, bypassing the wire, the fault script, and
/// the request journal. Seeds a migration source without polluting the
/// journal that the scenario under test later asserts on. Returns the
@@ -876,22 +791,6 @@ impl FakeS3Target {
lock(&self.backend.store).drop_unlisted_replication_headers = enabled;
}
/// Refuse every PutObject / UploadPart whose body is announced as
/// `aws-chunked` (SeaweedFS-like target, rustfs#6853). The refusal is an
/// `InvalidRequest` issued before the body is read, so a sender that
/// frames its uploads sees a hard failure instead of a corrupted replica.
pub fn reject_aws_chunked_uploads(&self, enabled: bool) {
lock(&self.backend.store).reject_aws_chunked_uploads = enabled;
}
/// Require `Content-MD5` or an `x-amz-checksum-*` header on every
/// PutObject that carries Object Lock parameters (AWS S3 / MinIO rule,
/// rustfs#7082). `Content-MD5`, when present, is always verified against
/// the body regardless of this mode.
pub fn require_checksum_for_object_lock(&self, enabled: bool) {
lock(&self.backend.store).require_checksum_for_object_lock = enabled;
}
pub fn assign_own_multipart_version_ids(&self, enabled: bool) {
lock(&self.backend.store).assign_own_multipart_version_ids = enabled;
}
@@ -1000,9 +899,6 @@ fn validate_fault_action(action: &FaultAction) {
if let FaultAction::SlowDrain { chunk_bytes: 0, .. } = action {
panic!("slow-drain chunk size must be non-zero");
}
if let FaultAction::SlowSendBody { chunk_bytes: 0, .. } = action {
panic!("slow-send chunk size must be non-zero");
}
match action {
FaultAction::Delay(duration) if *duration > MAX_FAULT_DURATION => {
panic!("fault delay must not exceed 30 seconds");
@@ -1013,9 +909,6 @@ fn validate_fault_action(action: &FaultAction) {
FaultAction::SlowDrain { delay, .. } if *delay >= MAX_FAULT_DURATION => {
panic!("slow-drain slice delay must be below 30 seconds");
}
FaultAction::SlowSendBody { delay, .. } if *delay >= MAX_FAULT_DURATION => {
panic!("slow-send slice delay must be below 30 seconds");
}
FaultAction::ResponseStatus(code) if !(400..=599).contains(code) => {
panic!("scripted response status must be a 4xx or 5xx code");
}
@@ -1049,20 +942,10 @@ impl S3Access for FaultAccess {
let parsed = parse_request(context.method(), context.uri());
let operation = operation_from_s3_name(context.s3_op().name());
let journaled = JournaledHeaders::from_headers(context.headers());
let aws_chunked = journaled.transport.aws_chunked;
let fault = record_request(&self.control, operation, context.method().clone(), parsed, journaled);
if let Some(status) = fault.as_ref().and_then(|fault| scripted_status(&fault.action)) {
return Err(scripted_status_error(status));
}
if aws_chunked
&& matches!(operation, Operation::PutObject | Operation::UploadPart)
&& lock(&self.store).reject_aws_chunked_uploads
{
return Err(s3s::s3_error!(
InvalidRequest,
"this target does not decode aws-chunked request bodies; send a plain signed payload with an exact Content-Length"
));
}
let prebody_permit = if operation == Operation::CompleteMultipartUpload {
Some(
timeout(
@@ -1090,7 +973,6 @@ fn operation_from_s3_name(name: &str) -> Operation {
match name {
"HeadBucket" => Operation::HeadBucket,
"GetBucketVersioning" => Operation::GetBucketVersioning,
"GetObjectLockConfiguration" => Operation::GetObjectLockConfiguration,
"PutObject" => Operation::PutObject,
"GetObject" => Operation::GetObject,
"HeadObject" => Operation::HeadObject,
@@ -1139,7 +1021,6 @@ fn record_request(
consumed_bytes: None,
replication_timestamps: headers.replication_timestamps,
proxy_headers: headers.proxy_headers,
transport: headers.transport,
range: headers.range,
user_agent: headers.user_agent,
prefix: parsed.prefix.map(bounded_journal_value),
@@ -1421,7 +1302,6 @@ async fn apply_non_body_fault(fault: Option<&RequestFault>, control: &Mutex<Cont
| Some(FaultAction::WrongEtag)
| Some(FaultAction::DisconnectAfterResponse)
| Some(FaultAction::TruncateBodyAt(_))
| Some(FaultAction::SlowSendBody { .. })
| Some(FaultAction::Stall(_))
| None => Ok(()),
}
@@ -1470,7 +1350,6 @@ async fn collect_stream(
Some(FaultAction::WrongEtag)
| Some(FaultAction::DisconnectAfterResponse)
| Some(FaultAction::TruncateBodyAt(_))
| Some(FaultAction::SlowSendBody { .. })
| Some(FaultAction::Stall(_))
| None => {}
}
@@ -1538,22 +1417,6 @@ fn truncated_body(body: Bytes, truncate_at: usize) -> StreamingBlob {
}))
}
/// GetObject body delivered in `chunk_bytes` slices with `delay` between
/// them. The head and the first slice are written immediately, so the client
/// starts reading and then observes the source going quiet mid-body.
fn slow_sent_body(body: Bytes, chunk_bytes: usize, delay: Duration) -> StreamingBlob {
StreamingBlob::wrap(futures::stream::unfold((body, true), move |(mut rest, first)| async move {
if rest.is_empty() {
return None;
}
if !first {
sleep(delay).await;
}
let chunk = rest.split_to(chunk_bytes.min(rest.len()));
Some((Ok::<Bytes, io::Error>(chunk), (rest, false)))
}))
}
async fn assemble_multipart(
parts: Vec<(Bytes, [u8; 16])>,
total_len: usize,
@@ -1983,34 +1846,6 @@ impl S3 for FakeBackend {
))
}
async fn get_object_lock_configuration(
&self,
req: S3Request<GetObjectLockConfigurationInput>,
) -> S3Result<S3Response<GetObjectLockConfigurationOutput>> {
let fault = request_fault(&req);
apply_non_body_fault(fault.as_ref(), &self.control).await?;
let object_lock = lock(&self.store)
.buckets
.get(&req.input.bucket)
.map(|bucket| bucket.object_lock)
.ok_or_else(|| s3s::s3_error!(NoSuchBucket, "bucket does not exist"))?;
if !object_lock {
return Err(s3s::s3_error!(
ObjectLockConfigurationNotFoundError,
"Object Lock configuration does not exist for this bucket"
));
}
Ok(apply_response_fault(
S3Response::new(GetObjectLockConfigurationOutput {
object_lock_configuration: Some(ObjectLockConfiguration {
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
..Default::default()
}),
}),
fault.as_ref(),
))
}
/// Current versions only (a key whose newest version is a delete marker
/// is hidden), keys in byte order, `delimiter` folding into common
/// prefixes that count toward `max-keys`, and `start-after` /
@@ -2161,37 +1996,17 @@ impl S3 for FakeBackend {
.map_err(|_| s3s::s3_error!(ServiceUnavailable, "fake target body limiter closed"))?;
let headers = req.headers;
let input = req.input;
let (assign_own, drop_unlisted, require_checksum, limits, versioned) = {
let (assign_own, drop_unlisted, limits, versioned) = {
let state = lock(&self.store);
(
state.assign_own_version_ids,
state.drop_unlisted_replication_headers,
state.require_checksum_for_object_lock,
state.limits,
bucket_versioned(&state, &input.bucket),
)
};
let transport = TransportSnapshot::from_headers(&headers);
if require_checksum
&& transport.object_lock_params
&& transport.content_md5.is_none()
&& transport.checksum_headers.is_empty()
{
return Err(s3s::s3_error!(
InvalidRequest,
"Content-MD5 OR x-amz-checksum- HTTP header is required for Put Object requests with Object Lock parameters"
));
}
let body =
collect_stream(input.body, input.content_length, fault.as_ref(), &self.control, limits.max_object_bytes).await?;
let mut body_permit = _body_permit;
if let Some(expected) = &transport.content_md5 {
let (digest, permit) = md5_digest(body.clone(), body_permit).await?;
body_permit = permit;
if BASE64_STANDARD.encode_to_string(digest) != expected.trim() {
return Err(s3s::s3_error!(BadDigest, "The Content-MD5 you specified did not match what we received."));
}
}
validate_stored_metadata(&input.content_type, &input.metadata)?;
let standard_headers = StandardHeaders {
cache_control: input.cache_control,
@@ -2209,13 +2024,10 @@ impl S3 for FakeBackend {
let e_tag = match source_etag(&headers)? {
Some(value) => value,
None => {
let (digest, permit) = md5_digest(body.clone(), body_permit).await?;
body_permit = permit;
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower)
}
};
// Held until the version is stored, like every other body-bearing op.
let _body_permit = body_permit;
let version = ObjectVersion {
version_id: version_id.clone(),
body,
@@ -2255,7 +2067,6 @@ impl S3 for FakeBackend {
let body = version.body.slice(served.range.clone());
let body = match fault.as_ref().map(|fault| &fault.action) {
Some(FaultAction::TruncateBodyAt(truncate_at)) => truncated_body(body, *truncate_at),
Some(FaultAction::SlowSendBody { chunk_bytes, delay }) => slow_sent_body(body, *chunk_bytes, *delay),
_ => StreamingBlob::from(body),
};
let mut response = S3Response::new(GetObjectOutput {
@@ -2872,217 +2683,6 @@ mod tests {
.await?)
}
/// A client that sends no SDK-computed checksum, the shape RustFS's
/// replication client has had since rustfs#6895.
fn client_without_sdk_checksums(target: &FakeS3Target) -> Client {
let credentials = Credentials::new(FAKE_ACCESS_KEY, FAKE_SECRET_KEY, None, None, "fake-target");
Client::from_conf(
aws_sdk_s3::Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(target.endpoint())
.force_path_style(true)
.behavior_version_latest()
.request_checksum_calculation(aws_sdk_s3::config::RequestChecksumCalculation::WhenRequired)
.retry_config(RetryConfig::standard().with_max_attempts(1))
.http_client(SmithyHttpClientBuilder::new().build_http())
.build(),
)
}
fn retain_until() -> aws_sdk_s3::primitives::DateTime {
aws_sdk_s3::primitives::DateTime::from_secs(4_102_444_800)
}
#[tokio::test]
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::ObjectLockMode;
let target = FakeS3Target::start().await?;
target.create_bucket_with_object_lock("locked");
target.require_checksum_for_object_lock(true);
let client = client_without_sdk_checksums(&target);
let locked_put = |key: &'static str| {
client
.put_object()
.bucket("locked")
.key(key)
.body(ByteStream::from_static(b"locked payload"))
.object_lock_mode(ObjectLockMode::Governance)
.object_lock_retain_until_date(retain_until())
};
// The rustfs#7082 shape: lock headers, no integrity header.
let err = locked_put("no-checksum")
.send()
.await
.expect_err("a locked PUT without Content-MD5 / x-amz-checksum-* must be rejected")
.into_service_error();
assert_eq!(err.code(), Some("InvalidRequest"));
assert!(
err.message().unwrap_or_default().contains("Object Lock parameters"),
"unexpected message: {:?}",
err.message()
);
assert!(!target.has_object("locked", "no-checksum"));
// A wrong Content-MD5 is caught even though the header is present.
let err = locked_put("wrong-md5")
.customize()
.map_request(|mut request| {
request
.headers_mut()
.insert("content-md5", BASE64_STANDARD.encode_to_string(md5_bytes(b"other payload")));
Ok::<_, std::convert::Infallible>(request)
})
.send()
.await
.expect_err("a mismatching Content-MD5 must be rejected")
.into_service_error();
assert_eq!(err.code(), Some("BadDigest"));
// The correct Content-MD5 is accepted and the body is stored intact.
locked_put("good-md5")
.customize()
.map_request(|mut request| {
request
.headers_mut()
.insert("content-md5", BASE64_STANDARD.encode_to_string(md5_bytes(b"locked payload")));
Ok::<_, std::convert::Infallible>(request)
})
.send()
.await?;
assert_eq!(
get_bytes(&client, "locked", "good-md5", None).await?,
Bytes::from_static(b"locked payload")
);
// The rule is scoped to Object Lock parameters: an unlocked PUT
// without any checksum still lands.
client
.put_object()
.bucket("locked")
.key("unlocked")
.body(ByteStream::from_static(b"plain payload"))
.send()
.await?;
assert!(target.has_object("locked", "unlocked"));
let journal = target.requests();
let rejected = journal
.iter()
.find(|record| record.operation == Operation::PutObject && record.key.as_deref() == Some("no-checksum"))
.expect("the rejected PUT is journaled");
assert!(rejected.transport.object_lock_params);
assert!(rejected.transport.checksum_headers.is_empty());
assert_eq!(rejected.transport.content_md5, None);
assert!(!rejected.transport.aws_chunked);
let accepted = journal
.iter()
.find(|record| record.operation == Operation::PutObject && record.key.as_deref() == Some("good-md5"))
.expect("the accepted PUT is journaled");
assert_eq!(
accepted.transport.content_md5.as_deref(),
Some(BASE64_STANDARD.encode_to_string(md5_bytes(b"locked payload")).as_str())
);
Ok(())
}
#[tokio::test]
async fn aws_chunked_rejecting_target_refuses_framed_uploads() -> Result<(), BoxError> {
use aws_sdk_s3::error::ProvideErrorMetadata;
let target = FakeS3Target::start().await?;
target.create_bucket("plain");
target.reject_aws_chunked_uploads(true);
let client = client_without_sdk_checksums(&target);
// An upload announcing a trailer is refused before its body is read.
let err = client
.put_object()
.bucket("plain")
.key("framed")
.body(ByteStream::from_static(b"framed payload"))
.customize()
.map_request(|mut request| {
request.headers_mut().insert("x-amz-trailer", "x-amz-checksum-crc32");
Ok::<_, std::convert::Infallible>(request)
})
.send()
.await
.expect_err("an aws-chunked upload must be refused")
.into_service_error();
assert_eq!(err.code(), Some("InvalidRequest"));
assert!(
err.message().unwrap_or_default().contains("aws-chunked"),
"unexpected message: {:?}",
err.message()
);
assert!(!target.has_object("plain", "framed"));
// A plain signed payload still lands.
client
.put_object()
.bucket("plain")
.key("unframed")
.body(ByteStream::from_static(b"plain payload"))
.send()
.await?;
assert!(target.has_object("plain", "unframed"));
let journal = target.requests();
let framed = journal
.iter()
.find(|record| record.key.as_deref() == Some("framed"))
.expect("the refused upload is journaled");
assert!(framed.transport.aws_chunked);
let unframed = journal
.iter()
.find(|record| record.key.as_deref() == Some("unframed"))
.expect("the accepted upload is journaled");
assert!(!unframed.transport.aws_chunked);
Ok(())
}
#[tokio::test]
async fn object_lock_configuration_reports_bucket_mode() -> Result<(), BoxError> {
use aws_sdk_s3::error::ProvideErrorMetadata;
let target = FakeS3Target::start().await?;
target.create_bucket_with_object_lock("locked");
target.create_bucket("plain");
let client = client(&target);
let locked = client.get_object_lock_configuration().bucket("locked").send().await?;
assert_eq!(
locked
.object_lock_configuration()
.and_then(|config| config.object_lock_enabled())
.map(|state| state.as_str()),
Some("Enabled")
);
// The code RustFS's replication-check classifies as "not enabled".
let err = client
.get_object_lock_configuration()
.bucket("plain")
.send()
.await
.expect_err("a bucket without Object Lock has no configuration")
.into_service_error();
assert_eq!(err.code(), Some("ObjectLockConfigurationNotFoundError"));
assert_eq!(
target
.requests()
.iter()
.filter(|record| record.operation == Operation::GetObjectLockConfiguration)
.count(),
2
);
Ok(())
}
#[tokio::test]
async fn journals_replication_timestamp_headers() -> Result<(), BoxError> {
let target = FakeS3Target::start().await?;
@@ -769,16 +769,6 @@ mod tests {
run_cluster_root_heal_interruption(InterruptionScenario::IsolatedTargetRestart).await
}
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart),
)
.await?
}
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
{
@@ -802,7 +792,6 @@ mod tests {
#[derive(Clone, Copy, PartialEq, Eq)]
enum InterruptionScenario {
IsolatedTargetRestart,
BackgroundTargetRestart,
BackgroundCoordinatorRestart,
TargetEndpointBlackhole,
}
@@ -810,7 +799,6 @@ mod tests {
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
let (background_enabled, interruption_node, interruption_kind) = match scenario {
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"),
InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"),
};
-10
View File
@@ -80,12 +80,6 @@ mod upgrade_compatibility_test;
#[cfg(test)]
mod replication_lww_receiver_test;
// Outbound target matrix: every object shape against every remote-target
// failure mode the fake target models (SOP:
// docs/postmortems/2026-09-03-replication-checksum-default-regression.md).
#[cfg(test)]
mod replication_target_matrix_test;
// Data usage regression tests
#[cfg(test)]
mod data_usage_test;
@@ -218,10 +212,6 @@ mod cluster_multidrive_pool_test;
#[cfg(test)]
mod inline_fast_path_cluster_test;
// backlog#2207: two-node gate for the cluster-authoritative tier stats contract.
#[cfg(test)]
mod tier_stats_cluster_test;
// PutObject / MultipartUpload with checksum (Content-MD5, x-amz-checksum-*)
#[cfg(test)]
mod checksum_upload_test;
@@ -1,254 +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.
//! On-demand migration backfill job scenarios (ODM-12, rustfs/backlog#2159):
//! a full backfill of a small-object source, cancellation, and resuming from
//! the persisted continuation token after a server restart.
use super::common::{BackfillOp, BackfillRequest, ODM_SERVER_ENV, OdmSourceSpec, OdmTestEnv, SeedObject};
use crate::fake_s3_target::Operation;
use bytes::Bytes;
use std::time::Duration;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
const SOURCE_BUCKET: &str = "odm-backfill-source";
const LOCAL_BUCKET: &str = "odm-backfill-local";
const KEY_PREFIX: &str = "cold/";
/// Keys per scenario. The fake source retains at most 4,096 object versions
/// and 4,096 journal entries, and one pull is a HEAD plus a GET, so a
/// scenario that asserts on the journal stays below ~2,000 keys. The job
/// lists 1,000 keys per page, so this still spans several pages and exercises
/// the continuation token, which is what the scenarios are about.
const SEEDED_KEYS: usize = 1500;
fn key(i: usize) -> String {
format!("{KEY_PREFIX}{i:05}")
}
/// Content that identifies the key so a mis-stored object is caught.
fn body(i: usize) -> Bytes {
Bytes::from(format!("object-{i:05}-payload"))
}
fn seed(env: &OdmTestEnv, count: usize) {
let objects: Vec<SeedObject> = (0..count).map(|i| SeedObject::new(key(i), body(i))).collect();
let etags = env.seed_source(SOURCE_BUCKET, &objects);
assert_eq!(etags.len(), count);
}
async fn configure(env: &OdmTestEnv, spec: &OdmSourceSpec) -> TestResult {
let response = env.configure_source(LOCAL_BUCKET, spec).await?;
assert_eq!(response.status, 200, "configure: {}", response.body);
// The probe issued a one-key listing; count only the job's traffic.
env.source.take_requests();
Ok(())
}
fn source_lists(env: &OdmTestEnv) -> Vec<Option<String>> {
env.source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::ListObjectsV2)
.map(|record| record.continuation_token)
.collect()
}
fn source_gets(env: &OdmTestEnv) -> usize {
env.source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::GetObject)
.count()
}
fn counter(job: &serde_json::Value, name: &str) -> u64 {
job[name].as_u64().unwrap_or_else(|| panic!("{name} missing in {job}"))
}
#[tokio::test]
async fn backfill_pulls_every_source_object_across_list_pages() -> TestResult {
const COUNT: usize = SEEDED_KEYS;
let env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
seed(&env, COUNT);
configure(&env, &env.fake_source_spec(SOURCE_BUCKET)).await?;
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start: {}", started.body);
let job = started.json()?["job"].clone();
assert_eq!(job["state"], "running");
assert_eq!(job["skip_existing"], "always");
assert_eq!(job["dry_run"], false);
let job_id = job["job_id"].as_str().expect("job id").to_string();
// A second start while the job holds its lease is a conflict.
let again = env
.backfill(LOCAL_BUCKET, BackfillOp::Start(BackfillRequest::default()))
.await?;
assert_eq!(again.status, 409, "second start: {}", again.body);
assert!(again.body.contains("OnDemandMigrationBackfillRunning"), "{}", again.body);
let done = env
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(240), |job| job["state"] == "completed")
.await?;
assert_eq!(done["job_id"], job_id.as_str());
assert_eq!(counter(&done, "listed"), COUNT as u64);
assert_eq!(counter(&done, "enqueued"), COUNT as u64);
assert_eq!(counter(&done, "pulled"), COUNT as u64);
assert_eq!(counter(&done, "failed"), 0);
assert_eq!(counter(&done, "skipped_existing"), 0);
assert!(done["continuation_token"].is_null(), "a finished job carries no cursor");
assert_eq!(done["last_key"], key(COUNT - 1));
assert!(done["failed_keys"].as_array().is_some_and(Vec::is_empty));
let expected_bytes: u64 = (0..COUNT).map(|i| body(i).len() as u64).sum();
assert_eq!(counter(&done, "bytes"), expected_bytes);
assert_eq!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await?, COUNT);
for i in [0, 999, 1000, 1200, COUNT - 1] {
env.assert_local_present(LOCAL_BUCKET, &key(i), &body(i)).await;
}
let lists = source_lists(&env);
assert_eq!(lists.len(), COUNT.div_ceil(1000), "{COUNT} keys at 1000 per page: {lists:?}");
assert!(lists[0].is_none(), "the first page starts without a cursor");
assert!(lists[1..].iter().all(Option::is_some), "every later page carries the cursor");
assert_eq!(source_gets(&env), COUNT, "every object is fetched exactly once");
// The status endpoint summarises the same job.
let status = env.status(LOCAL_BUCKET).await?;
assert_eq!(status.status, 200);
let summary = status.json()?["backfill"].clone();
assert_eq!(summary["job_id"], job_id.as_str());
assert_eq!(summary["state"], "completed");
assert_eq!(counter(&summary, "pulled"), COUNT as u64);
Ok(())
}
#[tokio::test]
async fn backfill_cancel_stops_enqueueing_and_persists_cancelled() -> TestResult {
const COUNT: usize = SEEDED_KEYS;
let env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
seed(&env, COUNT);
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
spec.policy.max_concurrent_pulls = 1;
configure(&env, &spec).await?;
// Cancelling before any job exists is a 404, not a silent success.
let nothing = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
assert_eq!(nothing.status, 404, "cancel without a job: {}", nothing.body);
assert!(nothing.body.contains("NoSuchBackfillJob"), "{}", nothing.body);
let unread = env.backfill(LOCAL_BUCKET, BackfillOp::Status).await?;
assert_eq!(unread.status, 404, "status without a job: {}", unread.body);
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start: {}", started.body);
env.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(60), |job| {
job["state"] == "running" && counter(job, "enqueued") > 0
})
.await?;
let cancelled = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
assert_eq!(cancelled.status, 200, "cancel: {}", cancelled.body);
let job = cancelled.json()?["job"].clone();
assert_eq!(job["state"], "cancelled");
let enqueued_at_cancel = counter(&job, "enqueued");
assert!(enqueued_at_cancel < COUNT as u64, "the job was cancelled mid-way: {job}");
// Nothing is queued after the cancel: the checkpoint and the source
// traffic both stop moving once the few in-flight pulls drain.
tokio::time::sleep(Duration::from_secs(2)).await;
let persisted = env.backfill_job(LOCAL_BUCKET).await?.expect("checkpoint kept for inspection");
assert_eq!(persisted["state"], "cancelled");
assert_eq!(counter(&persisted, "enqueued"), enqueued_at_cancel);
let gets_after_drain = source_gets(&env);
tokio::time::sleep(Duration::from_secs(1)).await;
assert_eq!(source_gets(&env), gets_after_drain, "no source GET after the cancel drained");
assert!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await? < COUNT);
// Cancel is idempotent and the status endpoint reports the final state.
let again = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
assert_eq!(again.status, 200, "second cancel: {}", again.body);
assert_eq!(again.json()?["job"]["state"], "cancelled");
let status = env.status(LOCAL_BUCKET).await?;
assert_eq!(status.json()?["backfill"]["state"], "cancelled");
// A cancelled job releases the bucket: a new job can start.
let restarted = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(restarted.status, 200, "restart after cancel: {}", restarted.body);
assert_ne!(restarted.json()?["job"]["job_id"], job["job_id"]);
let _ = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
Ok(())
}
#[tokio::test]
async fn backfill_resumes_from_continuation_token_after_restart() -> TestResult {
const COUNT: usize = SEEDED_KEYS;
let mut env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
seed(&env, COUNT);
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
spec.policy.max_concurrent_pulls = 2;
configure(&env, &spec).await?;
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start: {}", started.body);
let job_id = started.json()?["job"]["job_id"].as_str().expect("job id").to_string();
// Wait for the first page to be committed (cursor persisted), then kill
// the server while the job is still running.
let mid = env
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(120), |job| {
job["state"] == "running" && job["continuation_token"].is_string()
})
.await?;
assert!(counter(&mid, "listed") >= 1000 && counter(&mid, "listed") < COUNT as u64, "{mid}");
env.source.take_requests();
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
let done = env
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(300), |job| job["state"] == "completed")
.await?;
assert_eq!(done["job_id"], job_id.as_str(), "the same job continues after the restart");
assert_eq!(counter(&done, "failed"), 0);
assert!(
counter(&done, "listed") >= COUNT as u64,
"the resumed job listed the rest (the interrupted page is listed twice): {done}"
);
// Keys pulled before the crash are re-listed and skipped, never re-pulled;
// a pull whose report died with the old process is counted neither way,
// so only the lower bound and the queue accounting are exact.
assert!(counter(&done, "pulled") + counter(&done, "skipped_existing") >= COUNT as u64, "{done}");
assert!(counter(&done, "pulled") <= counter(&done, "enqueued"), "{done}");
assert_eq!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await?, COUNT);
for i in [0, 500, 999, 1000, COUNT - 1] {
env.assert_local_present(LOCAL_BUCKET, &key(i), &body(i)).await;
}
let lists = source_lists(&env);
assert!(!lists.is_empty(), "the resumed job listed the source");
assert!(
lists.iter().all(Option::is_some),
"after the restart every source listing carries a continuation-token: {lists:?}"
);
assert!(
lists.len() <= COUNT.div_ceil(1000),
"the listing did not start over from the first page: {lists:?}"
);
Ok(())
}
@@ -22,31 +22,20 @@
//! not exercised by the harness self-test.
use crate::common::{RustFSTestEnvironment, signed_request};
use crate::fake_s3_target::{
BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FakeS3TargetOptions, Operation, SeedMetadata,
};
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FakeS3TargetOptions, SeedMetadata};
use aws_config::retry::RetryConfig;
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{Credentials, Region};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use bytes::Bytes;
use futures::stream::{StreamExt, TryStreamExt};
use serde::Serialize;
use std::fmt;
use std::time::{Duration, Instant};
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// Module switch the server reads at startup (`false` before GA). The harness
/// turns it on so scenario tests exercise the feature without repeating it.
pub const ODM_MODULE_SWITCH_ENV: &str = "RUSTFS_ON_DEMAND_MIGRATION_ENABLED";
/// The source client shares the replication egress guard, which rejects the
/// fake source's loopback endpoint unless this switch is set. This is the
/// documented harness opt-in; see
/// `docs/operations/outbound-connection-policy.md`.
pub const ALLOW_LOOPBACK_SOURCE_ENV: &str = "RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET";
/// Server environment every ODM scenario starts (and restarts) with.
pub const ODM_SERVER_ENV: &[(&str, &str)] = &[(ODM_MODULE_SWITCH_ENV, "true"), (ALLOW_LOOPBACK_SOURCE_ENV, "true")];
/// Admin route prefix; the bucket name is appended as one path segment.
pub const ODM_ADMIN_ROUTE: &str = "/rustfs/admin/v3/on-demand-migration";
/// Region the fake source is addressed with (it accepts any SigV4 region).
@@ -110,7 +99,6 @@ pub struct OdmPolicy {
pub head: String,
pub range_get: String,
pub source_error: String,
pub list_through: bool,
pub respect_local_delete_marker: bool,
pub preserve_etag: bool,
pub copy_tags: bool,
@@ -138,7 +126,6 @@ impl Default for OdmPolicy {
head: "proxy".to_string(),
range_get: "serve_and_backfill".to_string(),
source_error: "propagate".to_string(),
list_through: false,
respect_local_delete_marker: true,
preserve_etag: true,
copy_tags: false,
@@ -248,21 +235,6 @@ impl AdminResponse {
}
}
/// Raw S3 response (status, headers, body) for assertions on headers the
/// SDK does not surface, such as `x-rustfs-on-demand-migration`.
#[derive(Debug, Clone)]
pub struct RawResponse {
pub status: u16,
pub headers: http::HeaderMap,
pub body: Bytes,
}
impl RawResponse {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).and_then(|value| value.to_str().ok())
}
}
/// One object to seed into the source.
#[derive(Clone)]
pub struct SeedObject {
@@ -286,21 +258,6 @@ impl SeedObject {
}
}
/// Process arguments and environment a scenario needs on top of the ODM
/// defaults, plus the fake source's own limits.
#[derive(Debug, Default)]
pub struct OdmEnvOptions<'a> {
pub source: FakeS3TargetOptions,
pub args: Vec<&'a str>,
pub env: Vec<(&'a str, &'a str)>,
/// Start the server with the file-backed KMS and a default key, so
/// bucket default encryption (SSE-S3) can be configured.
pub local_kms: bool,
}
/// Default key id of the [`OdmEnvOptions::local_kms`] backend.
pub const LOCAL_KMS_DEFAULT_KEY_ID: &str = "rustfs-odm-e2e-default-key";
/// RustFS under test plus its fake S3 source.
pub struct OdmTestEnv {
pub rustfs: RustFSTestEnvironment,
@@ -317,44 +274,11 @@ impl OdmTestEnv {
}
pub async fn start_with_options(options: FakeS3TargetOptions) -> Result<Self, BoxError> {
Self::start_with(OdmEnvOptions {
source: options,
..OdmEnvOptions::default()
})
.await
}
/// Start the pair with extra process arguments and environment for the
/// server under test (KMS, the usage scanner, replication timing). The
/// ODM module switch and the loopback-source opt-in are always set; a
/// caller-supplied entry with the same name wins.
pub async fn start_with(options: OdmEnvOptions<'_>) -> Result<Self, BoxError> {
let source = FakeS3Target::start_with_options(options.source).await?;
let source = FakeS3Target::start_with_options(options).await?;
let mut rustfs = RustFSTestEnvironment::new().await?;
let mut env: Vec<(&str, &str)> = ODM_SERVER_ENV.to_vec();
for (name, value) in &options.env {
match env.iter_mut().find(|(existing, _)| existing == name) {
Some(entry) => entry.1 = value,
None => env.push((name, value)),
}
}
let mut args: Vec<&str> = options.args;
let key_dir = format!("{}/kms-keys", rustfs.temp_dir);
if options.local_kms {
tokio::fs::create_dir_all(&key_dir).await?;
crate::kms::common::create_key_with_specific_id(&key_dir, LOCAL_KMS_DEFAULT_KEY_ID).await?;
args.extend_from_slice(&[
"--kms-enable",
"--kms-backend",
"local",
"--kms-key-dir",
&key_dir,
"--kms-default-key-id",
LOCAL_KMS_DEFAULT_KEY_ID,
]);
env.push(("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"));
}
rustfs.start_rustfs_server_with_env(args, &env).await?;
rustfs
.start_rustfs_server_with_env(vec![], &[(ODM_MODULE_SWITCH_ENV, "true")])
.await?;
let client = rustfs.create_s3_client();
Ok(Self { rustfs, source, client })
}
@@ -419,81 +343,6 @@ impl OdmTestEnv {
}
}
/// `POST .../{bucket}/backfill?op=start`, retried while the server
/// answers `OnDemandMigrationDisabled`: the bucket state is built
/// asynchronously right after the config `PUT`, so an immediate start can
/// race it. Any other answer is returned as is.
pub async fn start_backfill(&self, bucket: &str, request: BackfillRequest) -> Result<AdminResponse, BoxError> {
let deadline = Instant::now() + Duration::from_secs(15);
loop {
let response = self.backfill(bucket, BackfillOp::Start(request.clone())).await?;
let state_not_ready = response.status == 400 && response.body.contains("OnDemandMigrationDisabled");
if !state_not_ready || Instant::now() >= deadline {
return Ok(response);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// The `job` document of `GET .../{bucket}/backfill`, `None` on 404.
pub async fn backfill_job(&self, bucket: &str) -> Result<Option<serde_json::Value>, BoxError> {
let response = self.backfill(bucket, BackfillOp::Status).await?;
match response.status {
200 => Ok(Some(response.json()?["job"].clone())),
404 => Ok(None),
status => Err(format!("GET backfill answered {status}: {}", response.body).into()),
}
}
/// Polls the backfill checkpoint until `accept` returns true or `timeout`
/// elapses (an error naming the last observed document).
pub async fn wait_for_backfill(
&self,
bucket: &str,
timeout: Duration,
accept: impl Fn(&serde_json::Value) -> bool,
) -> Result<serde_json::Value, BoxError> {
let deadline = Instant::now() + timeout;
let mut last = serde_json::Value::Null;
loop {
if let Some(job) = self.backfill_job(bucket).await? {
if accept(&job) {
return Ok(job);
}
last = job;
}
if Instant::now() >= deadline {
return Err(
format!("backfill of {bucket} did not reach the expected state within {timeout:?}; last: {last}").into(),
);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// Number of keys under `prefix` listed by the RustFS under test (local
/// state only, no migration side effects).
pub async fn local_key_count(&self, bucket: &str, prefix: &str) -> Result<usize, BoxError> {
let mut count = 0;
let mut token: Option<String> = None;
loop {
let page = self
.client
.list_objects_v2()
.bucket(bucket)
.prefix(prefix)
.max_keys(1000)
.set_continuation_token(token.take())
.send()
.await?;
count += page.contents().len();
match page.next_continuation_token() {
Some(next) if page.is_truncated().unwrap_or(false) => token = Some(next.to_string()),
_ => return Ok(count),
}
}
}
async fn admin(
&self,
method: http::Method,
@@ -563,220 +412,6 @@ impl OdmTestEnv {
assert_eq!(body.as_ref(), expected, "{bucket}/{key} local content mismatch");
}
/// Polls the listing until `key` is present locally or `timeout` elapses
/// (background pulls land after the response that triggered them).
pub async fn wait_local_listed(&self, bucket: &str, key: &str, timeout: Duration) -> Result<bool, BoxError> {
let deadline = Instant::now() + timeout;
loop {
if self.local_key_listed(bucket, key).await? {
return Ok(true);
}
if Instant::now() >= deadline {
return Ok(false);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// Raw signed `GET /{bucket}/{key}` against the RustFS under test.
/// Raw signed `ListObjectsV2` (`?list-type=2&<query>`) so a scenario can
/// assert on the response headers and the raw XML, which the SDK hides.
pub async fn raw_list_objects_v2(&self, bucket: &str, query: &str) -> Result<RawResponse, BoxError> {
let url = format!(
"{}/{bucket}?list-type=2{}{query}",
self.rustfs.url,
if query.is_empty() { "" } else { "&" }
);
let response =
signed_request(http::Method::GET, &url, &self.rustfs.access_key, &self.rustfs.secret_key, None, None).await?;
Ok(RawResponse {
status: response.status().as_u16(),
headers: response.headers().clone(),
body: response.bytes().await?,
})
}
pub async fn raw_get(&self, bucket: &str, key: &str) -> Result<RawResponse, BoxError> {
let url = format!("{}/{bucket}/{key}", self.rustfs.url);
let response =
signed_request(http::Method::GET, &url, &self.rustfs.access_key, &self.rustfs.secret_key, None, None).await?;
Ok(RawResponse {
status: response.status().as_u16(),
headers: response.headers().clone(),
body: response.bytes().await?,
})
}
/// Waits until the runtime consults the source for `bucket`: a config
/// install is applied asynchronously after the admin call returns. The
/// probe is a HEAD on a key that exists nowhere, so nothing is pulled and
/// only that key enters the negative cache. The probe key is per bucket,
/// so a second bucket, or a reinstalled configuration, waits for its own
/// state instead of observing an earlier journal entry.
pub async fn wait_until_source_consulted(&self, bucket: &str) -> Result<(), BoxError> {
let probe_key = format!("_odm-readiness-probe-{bucket}-{}", uuid::Uuid::new_v4());
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let _ = self.client.head_object().bucket(bucket).key(&probe_key).send().await;
if self.source.count_requests(Operation::HeadObject, &probe_key) > 0 {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!("on-demand migration runtime for {bucket} did not consult the source in time").into());
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
/// Waits until the migration runtime for `bucket` is live, whatever the
/// source is. [`Self::wait_until_source_consulted`] proves the same thing
/// from the fake source's journal, which a real source does not have; this
/// one reads the bucket's own `requests_total.get` counters instead, which
/// only start moving once the state has been built. The probe is a GET of a
/// key that exists nowhere and is unique per call, so nothing is pulled and
/// only that key enters the negative cache.
pub async fn wait_until_odm_engaged(&self, bucket: &str) -> Result<(), BoxError> {
let probe_key = format!("_odm-engaged-probe-{}", uuid::Uuid::new_v4());
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let _ = self.raw_get(bucket, &probe_key).await?;
let counted: u64 = self
.status_json(bucket)
.await
.ok()
.as_ref()
.and_then(|status| status.pointer("/counters/requests_total/get"))
.and_then(serde_json::Value::as_object)
.map(|by_outcome| by_outcome.values().filter_map(serde_json::Value::as_u64).sum())
.unwrap_or(0);
if counted > 0 {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!("on-demand migration runtime for {bucket} did not engage in time").into());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// Creates `bucket` unless it already exists, installs `spec` on it and
/// returns once the runtime consults the source. Scenarios with a second
/// bucket, a bucket created with non-default options, or a reinstalled
/// configuration all go through this.
pub async fn configure_and_wait(&self, bucket: &str, spec: &OdmSourceSpec) -> Result<(), BoxError> {
if self.client.head_bucket().bucket(bucket).send().await.is_err() {
self.rustfs.create_test_bucket(bucket).await?;
}
let response = self.configure_source(bucket, spec).await?;
if response.status != 200 {
return Err(format!("configure on-demand migration for {bucket}: {} {}", response.status, response.body).into());
}
self.wait_until_source_consulted(bucket).await
}
/// Raw signed request against the RustFS under test with additional
/// request headers. `Range`, `If-None-Match` and the anti-loop marker are
/// not part of the SigV4 signed-header set, so they are attached after
/// signing exactly as a real client's would be.
pub async fn raw_object_request(
&self,
method: http::Method,
bucket: &str,
key: &str,
headers: &[(&str, &str)],
) -> Result<RawResponse, BoxError> {
let url = format!("{}/{bucket}/{key}", self.rustfs.url);
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
let request = http::Request::builder()
.method(method.clone())
.uri(uri)
.header(http::header::HOST, authority)
.header("x-amz-content-sha256", rustfs_signer::constants::UNSIGNED_PAYLOAD)
.body(s3s::Body::empty())?;
let signed = rustfs_signer::sign_v4(request, 0, &self.rustfs.access_key, &self.rustfs.secret_key, "", "us-east-1");
let mut builder =
crate::common::local_http_client().request(reqwest::Method::from_bytes(method.as_str().as_bytes())?, &url);
for (name, value) in signed.headers() {
builder = builder.header(name, value);
}
for (name, value) in headers {
builder = builder.header(*name, *value);
}
let response = builder.send().await?;
Ok(RawResponse {
status: response.status().as_u16(),
headers: response.headers().clone(),
body: response.bytes().await?,
})
}
/// Parsed `GET .../{bucket}/status` body. Fails when the route does not
/// answer 200 so a scenario never asserts against an error document.
pub async fn status_json(&self, bucket: &str) -> Result<serde_json::Value, BoxError> {
let response = self.status(bucket).await?;
if response.status != 200 {
return Err(format!("status for {bucket}: {} {}", response.status, response.body).into());
}
response.json()
}
/// Reads one counter out of the status document by JSON pointer, e.g.
/// `/counters/pull_failures_total/queue_full`. Missing runtime state
/// reads as 0, which is what an operator sees too.
pub async fn status_counter(&self, bucket: &str, pointer: &str) -> Result<u64, BoxError> {
Ok(self
.status_json(bucket)
.await?
.pointer(pointer)
.and_then(serde_json::Value::as_u64)
.unwrap_or(0))
}
/// Polls [`Self::status_counter`] until it reaches `at_least`. Background
/// pulls and their failure counters land after the response that started
/// them, so every counter assertion about them has to wait.
pub async fn wait_for_status_counter(
&self,
bucket: &str,
pointer: &str,
at_least: u64,
timeout: Duration,
) -> Result<u64, BoxError> {
let deadline = Instant::now() + timeout;
loop {
let value = self.status_counter(bucket, pointer).await?;
if value >= at_least {
return Ok(value);
}
if Instant::now() >= deadline {
return Err(format!("{pointer} for {bucket} stalled at {value}, expected at least {at_least}").into());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// Highest `inflight_pulls` the status route reported while `work` ran,
/// sampled every 5 ms. The concurrency ceiling is only observable from
/// outside while pulls are in flight.
pub async fn peak_inflight_pulls<F, T>(&self, bucket: &str, work: F) -> Result<(T, u64), BoxError>
where
F: std::future::Future<Output = T>,
{
let mut peak = 0;
tokio::pin!(work);
let output = loop {
tokio::select! {
output = &mut work => break output,
_ = tokio::time::sleep(Duration::from_millis(5)) => {
peak = peak.max(self.status_counter(bucket, "/inflight_pulls").await?);
}
}
};
Ok((output, peak))
}
/// Panics if `key` is listed locally.
pub async fn assert_local_absent(&self, bucket: &str, key: &str) {
assert!(
@@ -815,438 +450,3 @@ pub async fn start_source_rustfs() -> Result<RustFSTestEnvironment, BoxError> {
source.start_rustfs_server_without_cleanup(vec![]).await?;
Ok(source)
}
/// Like [`start_source_rustfs`], but with on-demand migration enabled on the
/// second server too, so it can be given a source of its own (the anti-loop
/// scenario chains two migrating servers).
pub async fn start_source_rustfs_with_odm() -> Result<RustFSTestEnvironment, BoxError> {
let mut source = RustFSTestEnvironment::new().await?;
source
.start_rustfs_server_without_cleanup_with_env(&[(ODM_MODULE_SWITCH_ENV, "true"), (ALLOW_LOOPBACK_SOURCE_ENV, "true")])
.await?;
Ok(source)
}
/// The full scenario fixture every behavior test starts from: a RustFS with
/// `bucket`, an unversioned `source_bucket` on the fake source (a plain
/// migration source), the configuration installed after `adjust` tweaked it,
/// and the runtime proven to consult the source.
pub async fn start_configured_env(
bucket: &str,
source_bucket: &str,
adjust: impl FnOnce(&mut OdmSourceSpec),
) -> Result<OdmTestEnv, BoxError> {
start_configured_env_with(OdmEnvOptions::default(), bucket, source_bucket, adjust).await
}
/// [`start_configured_env`] with extra process arguments and environment.
pub async fn start_configured_env_with(
options: OdmEnvOptions<'_>,
bucket: &str,
source_bucket: &str,
adjust: impl FnOnce(&mut OdmSourceSpec),
) -> Result<OdmTestEnv, BoxError> {
let env = OdmTestEnv::start_with(options).await?;
env.source.create_bucket_with_mode(source_bucket, BucketMode::Unversioned);
let mut spec = env.fake_source_spec(source_bucket);
adjust(&mut spec);
env.configure_and_wait(bucket, &spec).await?;
Ok(env)
}
// ---------------------------------------------------------------------------
// Provider interoperability lane (ODM-20, rustfs/backlog#2167)
// ---------------------------------------------------------------------------
//
// `interop_test.rs` has one body per case; which source that body runs against
// is decided here, from the environment. Unset means the in-process fake
// source, which is what a local run gets; the scheduled interop workflow sets
// the variables below to a MinIO container or a real cloud provider. Nothing
// else about the cases changes, so a provider difference shows up as the same
// assertion failing rather than as a separate, drifting test file.
/// Provider preset of the interop source (`minio`, `aws`, `r2`, `gcs`, `s3`).
/// Unset selects the in-process fake source.
pub const INTEROP_PROVIDER_ENV: &str = "RUSTFS_ODM_INTEROP_PROVIDER";
/// `http(s)://host[:port]`. Required for every provider including `aws`, where
/// the runtime could derive it from the region: the lane pins exactly one
/// endpoint per provider so its report names what was actually reached.
pub const INTEROP_ENDPOINT_ENV: &str = "RUSTFS_ODM_INTEROP_ENDPOINT";
pub const INTEROP_REGION_ENV: &str = "RUSTFS_ODM_INTEROP_REGION";
/// Source bucket, which must already exist: the harness never creates a bucket
/// on a real provider account.
pub const INTEROP_BUCKET_ENV: &str = "RUSTFS_ODM_INTEROP_BUCKET";
pub const INTEROP_ACCESS_KEY_ENV: &str = "RUSTFS_ODM_INTEROP_ACCESS_KEY";
pub const INTEROP_SECRET_KEY_ENV: &str = "RUSTFS_ODM_INTEROP_SECRET_KEY";
pub const INTEROP_SESSION_TOKEN_ENV: &str = "RUSTFS_ODM_INTEROP_SESSION_TOKEN";
/// `auto` (default), `path` or `virtual`.
pub const INTEROP_PATH_STYLE_ENV: &str = "RUSTFS_ODM_INTEROP_PATH_STYLE";
/// Objects the backfill case seeds.
pub const INTEROP_BACKFILL_OBJECTS_ENV: &str = "RUSTFS_ODM_INTEROP_BACKFILL_OBJECTS";
/// Directory each case writes its JSON report entry into. Unset means no
/// report, which is what a local run wants.
pub const INTEROP_REPORT_DIR_ENV: &str = "RUSTFS_ODM_INTEROP_REPORT_DIR";
/// Backfill objects when [`INTEROP_BACKFILL_OBJECTS_ENV`] is unset. The fake
/// source retains at most 4,096 object versions and 4,096 journal entries, and
/// one pull is a HEAD plus a GET, so the default has to stay far below that.
/// A real source has no such cap and the workflow raises the count there.
pub const INTEROP_DEFAULT_BACKFILL_OBJECTS: usize = 200;
/// In-flight requests while seeding or cleaning a real source. High enough to
/// hide the round-trip on a few thousand tiny objects, low enough not to look
/// like a burst to a cloud provider.
const INTEROP_SOURCE_CONCURRENCY: usize = 32;
/// A real S3-compatible endpoint acting as the migration source.
#[derive(Clone)]
pub struct InteropRemoteSource {
pub provider: String,
pub endpoint: String,
pub region: String,
pub bucket: String,
pub path_style: String,
pub access_key: String,
pub secret_key: String,
pub session_token: Option<String>,
}
impl fmt::Debug for InteropRemoteSource {
/// The interop lane uploads its logs as an artifact; keep the credentials
/// out of them.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("InteropRemoteSource")
.field("provider", &self.provider)
.field("endpoint", &self.endpoint)
.field("region", &self.region)
.field("bucket", &self.bucket)
.field("path_style", &self.path_style)
.field("access_key", &self.access_key)
.field("secret_key", &"REDACTED")
.field("session_token", &self.session_token.as_ref().map(|_| "REDACTED"))
.finish()
}
}
impl InteropRemoteSource {
/// Enabled configuration pointing at this source.
fn spec(&self) -> OdmSourceSpec {
let mut spec = OdmSourceSpec::new(
&self.provider,
&self.endpoint,
&self.region,
self.bucket.clone(),
&self.access_key,
&self.secret_key,
);
spec.source.path_style = self.path_style.clone();
spec.source.credentials = Some(OdmCredentials {
access_key: self.access_key.clone(),
secret_key: self.secret_key.clone(),
session_token: self.session_token.clone(),
});
spec
}
/// S3 client for seeding and cleaning the source bucket. Retries are off
/// so a provider-side failure surfaces as itself instead of being masked
/// by a second attempt.
fn client(&self) -> Result<Client, BoxError> {
let credentials =
Credentials::new(&self.access_key, &self.secret_key, self.session_token.clone(), None, "odm-interop-source");
let mut config = aws_sdk_s3::Config::builder()
.credentials_provider(credentials)
.region(Region::new(self.region.clone()))
.endpoint_url(&self.endpoint)
.force_path_style(self.path_style == "path")
.behavior_version_latest()
.retry_config(RetryConfig::standard().with_max_attempts(1));
// The default connector is HTTPS-only; a container source is plain HTTP.
if self.endpoint.starts_with("http://") {
config = config.http_client(SmithyHttpClientBuilder::new().build_http());
}
Ok(Client::from_conf(config.build()))
}
}
/// Where an interop case gets its source objects from.
#[derive(Debug, Clone)]
pub enum InteropSource {
/// The in-process fake source of the [`OdmTestEnv`].
Fake,
/// A real S3-compatible endpoint described by the environment.
Remote(InteropRemoteSource),
}
impl InteropSource {
/// Reads the source description from the environment. An unset
/// [`INTEROP_PROVIDER_ENV`] means the fake source; a named provider with
/// any required variable missing is an error rather than a silent
/// fallback, so a misconfigured CI secret can never pass as a green
/// real-source run.
pub fn from_env() -> Result<Self, BoxError> {
let Some(provider) = interop_env(INTEROP_PROVIDER_ENV) else {
return Ok(Self::Fake);
};
let mut missing = Vec::new();
let mut required = |name: &'static str| {
interop_env(name).unwrap_or_else(|| {
missing.push(name);
String::new()
})
};
let endpoint = required(INTEROP_ENDPOINT_ENV);
let region = required(INTEROP_REGION_ENV);
let bucket = required(INTEROP_BUCKET_ENV);
let access_key = required(INTEROP_ACCESS_KEY_ENV);
let secret_key = required(INTEROP_SECRET_KEY_ENV);
if !missing.is_empty() {
return Err(format!("{INTEROP_PROVIDER_ENV}={provider} needs {}", missing.join(", ")).into());
}
Ok(Self::Remote(InteropRemoteSource {
provider,
endpoint,
region,
bucket,
path_style: interop_env(INTEROP_PATH_STYLE_ENV).unwrap_or_else(|| "auto".to_string()),
access_key,
secret_key,
session_token: interop_env(INTEROP_SESSION_TOKEN_ENV),
}))
}
/// Name the report uses for this source.
pub fn provider(&self) -> &str {
match self {
Self::Fake => "fake",
Self::Remote(remote) => &remote.provider,
}
}
}
/// A set but empty variable is the shape a missing GitHub secret takes, so it
/// reads the same as unset here.
fn interop_env(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
/// Objects the backfill case seeds, from [`INTEROP_BACKFILL_OBJECTS_ENV`].
pub fn interop_backfill_objects() -> Result<usize, BoxError> {
match interop_env(INTEROP_BACKFILL_OBJECTS_ENV) {
Some(value) => Ok(value
.parse::<usize>()
.map_err(|error| format!("{INTEROP_BACKFILL_OBJECTS_ENV}: {error}"))?),
None => Ok(INTEROP_DEFAULT_BACKFILL_OBJECTS),
}
}
/// One interop case: a RustFS under test migrating `bucket` from whichever
/// source [`InteropSource::from_env`] resolved.
///
/// Every source key of a run lives under a unique `filter.source_prefix`, so
/// a shared real bucket can host concurrent runs, a backfill lists only this
/// run's objects, and [`Self::finish`] can delete exactly what it seeded.
pub struct OdmInteropEnv {
pub env: OdmTestEnv,
pub source: InteropSource,
pub bucket: String,
case: &'static str,
source_bucket: String,
source_prefix: String,
remote: Option<Client>,
/// Source-side keys this run created, for cleanup.
seeded: std::sync::Mutex<Vec<String>>,
/// Start of the case body, after the fixture is up. The report keeps this
/// next to the JUnit wall time so a provider's own latency is readable
/// without the constant cost of starting a RustFS server drowning it.
started: Instant,
}
impl OdmInteropEnv {
/// Starts the pair and installs the configuration `adjust` tweaked,
/// returning once the migration runtime is live.
pub async fn start(case: &'static str, bucket: &str, adjust: impl FnOnce(&mut OdmSourceSpec)) -> Result<Self, BoxError> {
let source = InteropSource::from_env()?;
let env = OdmTestEnv::start().await?;
env.rustfs.create_test_bucket(bucket).await?;
let source_prefix = format!("odm-interop/{case}/{}/", uuid::Uuid::new_v4());
let (mut spec, remote, source_bucket) = match &source {
InteropSource::Fake => {
let source_bucket = format!("{bucket}-source");
env.source.create_bucket_with_mode(&source_bucket, BucketMode::Unversioned);
(env.fake_source_spec(&source_bucket), None, source_bucket)
}
InteropSource::Remote(remote) => {
let client = remote.client()?;
client
.head_bucket()
.bucket(&remote.bucket)
.send()
.await
.map_err(|error| format!("interop source bucket {} is not reachable: {error}", remote.bucket))?;
(remote.spec(), Some(client), remote.bucket.clone())
}
};
spec.filter.source_prefix = Some(source_prefix.clone());
adjust(&mut spec);
let response = env.configure_source(bucket, &spec).await?;
if response.status != 200 {
return Err(format!("configure on-demand migration for {bucket}: {} {}", response.status, response.body).into());
}
env.wait_until_odm_engaged(bucket).await?;
Ok(Self {
env,
source,
bucket: bucket.to_string(),
case,
source_bucket,
source_prefix,
remote,
seeded: std::sync::Mutex::new(Vec::new()),
started: Instant::now(),
})
}
/// Source-side key for a local key, the same mapping the runtime applies.
fn source_key(&self, local_key: &str) -> String {
format!("{}{local_key}", self.source_prefix)
}
/// Stores `objects` in the source under this run's prefix and returns
/// their ETags, unquoted, in input order.
pub async fn seed(&self, objects: &[SeedObject]) -> Result<Vec<String>, BoxError> {
let etags = match &self.remote {
None => objects
.iter()
.map(|object| {
self.env.source.put_seed_object(
&self.source_bucket,
self.source_key(&object.key),
object.body.clone(),
&object.metadata,
)
})
.collect(),
Some(client) => {
futures::stream::iter(objects.iter().map(|object| {
let key = self.source_key(&object.key);
let body = object.body.clone();
async move {
let output = client
.put_object()
.bucket(&self.source_bucket)
.key(&key)
.body(aws_sdk_s3::primitives::ByteStream::from(body))
.send()
.await
.map_err(|error| format!("seeding {}/{key}: {error}", self.source_bucket))?;
Ok::<String, BoxError>(unquote_etag(output.e_tag().unwrap_or_default()))
}
}))
.buffered(INTEROP_SOURCE_CONCURRENCY)
.try_collect::<Vec<String>>()
.await?
}
};
self.seeded
.lock()
.expect("interop seed ledger is not poisoned")
.extend(objects.iter().map(|object| self.source_key(&object.key)));
Ok(etags)
}
/// Records the case in the lane's report and removes everything it seeded
/// from the source. Call it at the end of every case: a case that fails
/// before this point leaves no report entry, which is why the workflow
/// reconciles the entries against the JUnit case list rather than trusting
/// them to be complete.
pub async fn finish(self) -> Result<(), BoxError> {
let duration = self.started.elapsed();
let counters = self
.env
.status_json(&self.bucket)
.await
.ok()
.and_then(|status| status.get("counters").cloned());
self.write_report(duration, counters).await?;
self.clean_source().await
}
async fn write_report(&self, duration: Duration, counters: Option<serde_json::Value>) -> Result<(), BoxError> {
let Some(dir) = interop_env(INTEROP_REPORT_DIR_ENV) else {
return Ok(());
};
// The fake source journals every wire request, so its count is exact.
// A real provider has no such journal, so the report falls back to the
// bucket's own counters, which count client requests that entered
// migration rather than requests that left for the source; the
// `counted_by` field says which of the two a reader is looking at.
let (counted_by, total) = match self.remote {
None => ("fake_source_journal", self.env.source.requests().len() as u64),
Some(_) => (
"odm_status_counters",
counters
.as_ref()
.and_then(|counters| counters.pointer("/requests_total"))
.and_then(serde_json::Value::as_object)
.map(|by_op| {
by_op
.values()
.filter_map(serde_json::Value::as_object)
.flat_map(|by_outcome| by_outcome.values().filter_map(serde_json::Value::as_u64))
.sum()
})
.unwrap_or(0),
),
};
let entry = serde_json::json!({
"case": self.case,
"provider": self.source.provider(),
"source_bucket": self.source_bucket,
"source_prefix": self.source_prefix,
"duration_ms": duration.as_millis() as u64,
"source_requests": {"counted_by": counted_by, "total": total},
"odm_counters": counters,
});
tokio::fs::create_dir_all(&dir).await?;
tokio::fs::write(
format!("{dir}/{}-{}.json", self.source.provider(), self.case),
serde_json::to_vec_pretty(&entry)?,
)
.await?;
Ok(())
}
/// Deletes this run's source keys one by one: the multi-object delete is
/// not available on every provider the lane targets (the GCS XML API has
/// no equivalent), and the object counts here are small enough that the
/// portable form costs nothing worth saving.
async fn clean_source(&self) -> Result<(), BoxError> {
let Some(client) = &self.remote else {
return Ok(());
};
let keys = std::mem::take(&mut *self.seeded.lock().expect("interop seed ledger is not poisoned"));
futures::stream::iter(keys.into_iter().map(|key| async move {
client
.delete_object()
.bucket(&self.source_bucket)
.key(&key)
.send()
.await
.map_err(|error| format!("deleting {}/{key}: {error}", self.source_bucket))?;
Ok::<(), BoxError>(())
}))
.buffered(INTEROP_SOURCE_CONCURRENCY)
.try_collect::<Vec<()>>()
.await?;
Ok(())
}
}
fn unquote_etag(etag: &str) -> String {
etag.trim_matches('"').to_string()
}
@@ -1,183 +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.
//! Concurrency limits of on-demand migration (rustfs/backlog#2158):
//! single-flight on one key, the `max_concurrent_pulls` ceiling, and a full
//! background pull queue.
//!
//! The point of each case is what the source is spared, so the source
//! journal (`count_requests`) carries the assertion in every one of them.
use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env};
use crate::fake_s3_target::Operation;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use bytes::Bytes;
use std::time::Duration;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-concurrency-source";
/// Background pulls land after the response that queued them.
const SETTLE: Duration = Duration::from_secs(120);
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
fn source_get_count(env: &OdmTestEnv, key: &str) -> usize {
env.source.count_requests(Operation::GetObject, key)
}
/// Case 9: 32 concurrent misses on one key coalesce into a single-flight
/// pull. At most two source GETs are allowed: the leader plus one follower
/// that gave up waiting and streamed through.
#[tokio::test]
async fn test_odm_concurrent_misses_on_one_key_coalesce() -> TestResult {
let bucket = "odm-concurrency-singleflight";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let key = "singleflight/asset.bin";
let body = payload(512 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let responses: Vec<RawResponse> = futures::future::try_join_all((0..32).map(|_| env.raw_get(bucket, key))).await?;
for (index, response) in responses.iter().enumerate() {
assert_eq!(response.status, 200, "reader {index}: {}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body, "reader {index} received different bytes");
}
let source_gets = source_get_count(&env, key);
assert!(
(1..=2).contains(&source_gets),
"32 concurrent misses must not become {source_gets} source GETs"
);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the leader stores the object");
env.assert_local_present(bucket, key, &body).await;
let versions = env.client.list_object_versions().bucket(bucket).prefix(key).send().await?;
assert_eq!(
versions.versions().len(),
1,
"the coalesced pull commits exactly one version: {:?}",
versions.versions()
);
assert_eq!(
source_get_count(&env, key),
source_gets,
"nothing pulls the object again once it is local"
);
Ok(())
}
/// Case 10: 64 misses on distinct keys never exceed `max_concurrent_pulls`
/// in flight, and all of them eventually land.
#[tokio::test]
async fn test_odm_concurrent_pulls_respect_the_configured_ceiling() -> TestResult {
let bucket = "odm-concurrency-ceiling";
const MAX_CONCURRENT_PULLS: u32 = 4;
const KEYS: usize = 64;
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.max_concurrent_pulls = MAX_CONCURRENT_PULLS;
})
.await?;
let body = payload(256 * 1024);
let keys: Vec<String> = (0..KEYS).map(|index| format!("ceiling/object-{index:03}.bin")).collect();
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
env.seed_source(SOURCE_BUCKET, &seeds);
let reads = futures::future::try_join_all(keys.iter().map(|key| env.raw_get(bucket, key)));
let (responses, peak_inflight) = env.peak_inflight_pulls(bucket, reads).await?;
let responses = responses?;
for (key, response) in keys.iter().zip(&responses) {
assert_eq!(response.status, 200, "{key}: {}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body, "{key} received different bytes");
}
assert!(
peak_inflight <= u64::from(MAX_CONCURRENT_PULLS),
"in-flight pulls peaked at {peak_inflight}, above the configured {MAX_CONCURRENT_PULLS}"
);
assert!(
peak_inflight >= 1,
"the poll never observed a pull in flight, so the ceiling assertion proves nothing"
);
for key in &keys {
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "{key} must be stored locally");
assert_eq!(source_get_count(&env, key), 1, "{key} is pulled exactly once");
}
assert_eq!(env.status_counter(bucket, "/inflight_pulls").await?, 0, "every pull slot is released");
Ok(())
}
/// Case 11: with a small background queue, a burst of Range reads overflows
/// it. The overflow is counted and dropped, never turned into a client
/// failure: every reader still gets its 206 from the source.
#[tokio::test]
async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients() -> TestResult {
let bucket = "odm-concurrency-queue-full";
const REQUESTS: usize = 100;
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.pull_queue_capacity = 8;
spec.policy.max_concurrent_pulls = 1;
})
.await?;
let body = payload(128 * 1024);
let keys: Vec<String> = (0..REQUESTS).map(|index| format!("queue/object-{index:03}.bin")).collect();
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
env.seed_source(SOURCE_BUCKET, &seeds);
let responses: Vec<RawResponse> = futures::future::try_join_all(
keys.iter()
.map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])),
)
.await?;
for (key, response) in keys.iter().zip(&responses) {
assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body.slice(0..1024), "{key} served the wrong range");
assert_eq!(
response.header("content-range"),
Some(format!("bytes 0-1023/{}", body.len()).as_str()),
"{key}"
);
}
let queue_full = env
.wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE)
.await?;
assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue");
let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum();
assert!(
ranged_reads >= REQUESTS,
"every reader is served from the source: {ranged_reads} GETs for {REQUESTS} readers"
);
let dropped = keys.iter().filter(|key| source_get_count(&env, key) == 1).count();
assert!(
dropped > 0,
"the overflowed keys are the ones with no backfill GET, but every key got one"
);
Ok(())
}
@@ -1,551 +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.
//! Source-failure scenarios for on-demand migration (rustfs/backlog#2158):
//! access denied, the circuit breaker, first-byte and mid-body stream
//! failures (a cut body and a stalled one), ETag integrity, the negative
//! cache, and an unsupported (SSE-C) source object.
//!
//! Every case asserts what the source was asked for, not only what the
//! client received: a fault that silently turned into a second source
//! request would otherwise pass.
use super::common::{BoxError, OdmTestEnv, SeedObject, start_configured_env};
use crate::fake_s3_target::{FaultAction, Operation};
use bytes::Bytes;
use std::time::{Duration, Instant};
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-fault-source";
/// Header the GET/HEAD paths add when the answer came from the source.
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// Status of the `SourceUnavailable` error the `propagate` policy returns.
const SOURCE_UNAVAILABLE_STATUS: u16 = 424;
/// Background pulls and their counters land after the response.
const SETTLE: Duration = Duration::from_secs(60);
/// Consecutive counted source failures that open the breaker
/// (`BREAKER_FAILURE_THRESHOLD` in ecstore).
const BREAKER_FAILURE_THRESHOLD: usize = 5;
/// Position-dependent payload so a misaligned or truncated copy is caught.
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
/// A source object with a well-formed but deliberately wrong single-part
/// ETag: the fake source retains `x-rustfs-source-etag` verbatim, so HEAD
/// and GET advertise an MD5 the body does not have.
async fn seed_with_etag(env: &OdmTestEnv, key: &str, body: Bytes, etag: &str) -> TestResult {
let response = env
.source_client()
.put_object()
.bucket(SOURCE_BUCKET)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(body))
.customize()
.mutate_request({
let etag = etag.to_string();
move |request| {
request.headers_mut().insert("x-rustfs-source-etag", etag.clone());
}
})
.send()
.await?;
assert_eq!(
response.e_tag(),
Some(format!("\"{etag}\"").as_str()),
"the fake source stores the announced ETag"
);
Ok(())
}
/// A source object that reports SSE-C: the fake source echoes the customer
/// algorithm it captured from the replication passthrough transport header.
async fn seed_with_ssec(env: &OdmTestEnv, key: &str, body: Bytes) -> TestResult {
env.source_client()
.put_object()
.bucket(SOURCE_BUCKET)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(body))
.customize()
.mutate_request(|request| {
request.headers_mut().insert("x-rustfs-replication-ssec-algorithm", "AES256");
})
.send()
.await?;
Ok(())
}
/// Case 1: a 403 from the source is a configuration error, not a health
/// signal. `propagate` answers 424 and records the class; `not_found` hides
/// it as a 404. Neither counts toward the breaker.
#[tokio::test]
async fn test_odm_source_access_denied_propagates_without_opening_the_breaker() -> TestResult {
let propagating = "odm-fault-denied-propagate";
let hiding = "odm-fault-denied-notfound";
let env = start_configured_env(propagating, SOURCE_BUCKET, |_| {}).await?;
let mut hiding_spec = env.fake_source_spec(SOURCE_BUCKET);
hiding_spec.policy.source_error = "not_found".to_string();
env.configure_and_wait(hiding, &hiding_spec).await?;
let propagate_key = "denied/propagate.bin";
let hidden_key = "denied/hidden.bin";
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new(propagate_key, payload(4096)),
SeedObject::new(hidden_key, payload(4096)),
],
);
env.source
.inject_for_key(Operation::HeadObject, propagate_key, FaultAction::ResponseStatus(403), 1);
let denied = env.raw_get(propagating, propagate_key).await?;
assert_eq!(denied.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&denied.body));
assert!(
String::from_utf8_lossy(&denied.body).contains("SourceUnavailable"),
"the propagated error names the ODM source code: {}",
String::from_utf8_lossy(&denied.body)
);
assert_eq!(env.source.count_requests(Operation::HeadObject, propagate_key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, propagate_key),
0,
"a denied HEAD never reaches the body"
);
let status = env.status_json(propagating).await?;
assert_eq!(
status.pointer("/last_source_error/class").and_then(|v| v.as_str()),
Some("access_denied"),
"{status}"
);
assert_eq!(
status.pointer("/breaker/state").and_then(|v| v.as_str()),
Some("closed"),
"a configuration error must not open the breaker: {status}"
);
assert_eq!(
status
.pointer("/counters/requests_total/get/source_error")
.and_then(|v| v.as_u64()),
Some(1),
"{status}"
);
env.source
.inject_for_key(Operation::HeadObject, hidden_key, FaultAction::ResponseStatus(403), 1);
let hidden = env.raw_get(hiding, hidden_key).await?;
assert_eq!(hidden.status, 404, "{}", String::from_utf8_lossy(&hidden.body));
assert_eq!(env.source.count_requests(Operation::HeadObject, hidden_key), 1);
assert_eq!(env.source.count_requests(Operation::GetObject, hidden_key), 0);
env.assert_local_absent(propagating, propagate_key).await;
env.assert_local_absent(hiding, hidden_key).await;
Ok(())
}
/// Case 2: repeated transport failures open the breaker; while it is open
/// the source is not touched at all, and the half-open probe after the open
/// window closes it again. The open window is a compiled-in 30 s constant
/// (`BREAKER_OPEN_DURATION`), so this case waits in real time.
///
/// The source client disables SDK retries, so one logical source call is
/// exactly one wire request: the script is exactly as deep as the number of
/// breaker failures it has to produce, and the scripted fault count and the
/// observed source request count must agree.
#[tokio::test]
async fn test_odm_repeated_source_errors_open_the_breaker_and_recover() -> TestResult {
let bucket = "odm-fault-breaker";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let key = "breaker/doc.bin";
let body = payload(8192);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
env.source
.inject_for_key(Operation::HeadObject, key, FaultAction::ResponseStatus(503), BREAKER_FAILURE_THRESHOLD);
for attempt in 1..=BREAKER_FAILURE_THRESHOLD {
let response = env.raw_get(bucket, key).await?;
assert_eq!(
response.status,
SOURCE_UNAVAILABLE_STATUS,
"attempt {attempt}: {}",
String::from_utf8_lossy(&response.body)
);
}
assert_eq!(
env.status_json(bucket)
.await?
.pointer("/breaker/state")
.and_then(|v| v.as_str()),
Some("open"),
"the threshold of consecutive source failures must open the breaker"
);
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
BREAKER_FAILURE_THRESHOLD,
"every counted failure is exactly one source request"
);
// With the script cleared, the only thing that can still fail a read is
// the open breaker itself.
env.source.clear_faults();
let source_requests = env.source.count_requests(Operation::HeadObject, key);
let rejected = env.raw_get(bucket, key).await?;
assert_eq!(rejected.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&rejected.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
source_requests,
"an open breaker never touches the source"
);
assert!(
env.status_counter(bucket, "/counters/requests_total/get/breaker_open")
.await?
>= 1,
"the rejected request is counted as breaker_open"
);
// Half-open admits exactly one probe once the open window elapses.
let deadline = Instant::now() + Duration::from_secs(120);
let recovered = loop {
let response = env.raw_get(bucket, key).await?;
if response.status == 200 {
break response;
}
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS);
assert!(Instant::now() < deadline, "the breaker never left the open state");
tokio::time::sleep(Duration::from_secs(1)).await;
};
assert_eq!(recovered.body, body, "the recovered read serves the source bytes");
assert_eq!(recovered.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
source_requests + 1,
"only the half-open probe reached the source"
);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
assert_eq!(
env.status_json(bucket)
.await?
.pointer("/breaker/state")
.and_then(|v| v.as_str()),
Some("closed"),
"a successful probe closes the breaker"
);
Ok(())
}
/// Case 3: a source that holds the response past `first_byte_ms` is a
/// timeout, and the client never sees a 200 head. One logical source call is
/// one wire request, so a single scripted stall is enough to fail the read.
#[tokio::test]
async fn test_odm_source_stall_times_out_before_the_first_byte() -> TestResult {
let bucket = "odm-fault-stall";
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.source_timeout.first_byte_ms = 500;
})
.await?;
let key = "stall/doc.bin";
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, payload(4096))]);
env.source
.inject_for_key(Operation::HeadObject, key, FaultAction::Stall(Duration::from_secs(5)), 1);
let started = Instant::now();
let response = env.raw_get(bucket, key).await?;
let elapsed = started.elapsed();
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
1,
"the stalled HEAD is the only source request"
);
assert!(
elapsed < Duration::from_secs(5),
"the read timeout must cut the attempt short, took {elapsed:?}"
);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
0,
"a timed-out HEAD never starts a body read"
);
assert_eq!(
env.status_json(bucket)
.await?
.pointer("/last_source_error/class")
.and_then(|v| v.as_str()),
Some("timeout"),
);
env.assert_local_absent(bucket, key).await;
Ok(())
}
/// Case 4: the source cuts the body of an inline pull. The client sees a
/// short read, nothing is stored, and no multipart upload is left behind.
#[tokio::test]
async fn test_odm_inline_pull_aborts_when_the_source_body_is_cut() -> TestResult {
let bucket = "odm-fault-inline-cut";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let key = "cut/inline.bin";
let body = payload(256 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
env.source
.inject_for_key(Operation::GetObject, key, FaultAction::TruncateBodyAt(1024), 1);
// The client sees a transport failure while reading the body: the
// announced Content-Length is never delivered.
env.raw_get(bucket, key)
.await
.expect_err("a cut source body must not read back as a complete object");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"an aborted inline pull is not retried on the same request"
);
// Give a stray background pull time to appear before asserting absence.
tokio::time::sleep(Duration::from_secs(3)).await;
env.assert_local_absent(bucket, key).await;
let uploads = env.client.list_multipart_uploads().bucket(bucket).send().await?;
assert!(
uploads.uploads().is_empty(),
"an aborted pull leaves no multipart upload: {:?}",
uploads.uploads()
);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"nothing re-reads the source afterwards"
);
Ok(())
}
/// Case 5: the source answers, sends part of the body and then goes quiet
/// for longer than `source_timeout.idle_ms`. The inline tee must end both
/// ends: the client gets a short read rather than a silently truncated 200,
/// the pull is counted as a source timeout, and nothing (object or multipart
/// upload) is left behind locally.
#[tokio::test]
async fn test_odm_inline_pull_aborts_when_the_source_body_stalls() -> TestResult {
let bucket = "odm-fault-inline-stall";
const IDLE_MS: u64 = 1_000;
let idle = Duration::from_millis(IDLE_MS);
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.source_timeout.idle_ms = IDLE_MS;
})
.await?;
let key = "stall/inline.bin";
let body = payload(256 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
// The head and the first slice arrive at once; the source then pauses for
// four times the idle budget, which is what a stalled source looks like.
env.source.inject_for_key(
Operation::GetObject,
key,
FaultAction::SlowSendBody {
chunk_bytes: 32 * 1024,
delay: idle * 4,
},
1,
);
let started = Instant::now();
env.raw_get(bucket, key)
.await
.expect_err("a stalled source body must not read back as a complete object");
let elapsed = started.elapsed();
assert!(
elapsed < idle * 4,
"the idle budget, not the source's own pause, must end the read (took {elapsed:?})"
);
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"an aborted inline pull is not retried on the same request"
);
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/source_timeout", 1, SETTLE)
.await?;
// The leader releases its slot just after it records the failure.
let deadline = Instant::now() + SETTLE;
loop {
let inflight = env
.status_json(bucket)
.await?
.pointer("/inflight_pulls")
.and_then(|value| value.as_u64());
if inflight == Some(0) {
break;
}
assert!(Instant::now() < deadline, "the aborted pull never released its slot: {inflight:?}");
tokio::time::sleep(Duration::from_millis(200)).await;
}
env.assert_local_absent(bucket, key).await;
let uploads = env.client.list_multipart_uploads().bucket(bucket).send().await?;
assert!(
uploads.uploads().is_empty(),
"a stalled pull leaves no multipart upload: {:?}",
uploads.uploads()
);
Ok(())
}
/// Case 6: the background pull of a large object hits a cut body, counts the
/// failure, and the retry stores the object.
#[tokio::test]
async fn test_odm_background_pull_retries_a_truncated_source_body() -> TestResult {
let bucket = "odm-fault-background-cut";
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| spec.policy.inline_max_bytes = 4096).await?;
let key = "cut/background.bin";
let body = payload(512 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
// The faults are consumed in order by the two GETs the large-object path
// makes: the passthrough that answers the client (unaffected), then the
// background pull (cut).
env.source
.inject_for_key(Operation::GetObject, key, FaultAction::Delay(Duration::ZERO), 1);
env.source
.inject_for_key(Operation::GetObject, key, FaultAction::TruncateBodyAt(2048), 1);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body, "the passthrough is unaffected by the pull's fault");
// The cut body ends the pull attempt as a retryable source transport
// failure; the retry stores the object, so the pull as a whole succeeds
// and no failure is counted (only a pull that gives up is).
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the retry must store the object");
env.assert_local_present(bucket, key, &body).await;
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
3,
"one passthrough, one cut pull, one successful retry"
);
let status = env.status_json(bucket).await?;
assert_eq!(
status
.pointer("/counters/pulled_objects_total/background")
.and_then(|v| v.as_u64()),
Some(1),
"{status}"
);
assert_eq!(
status
.pointer("/counters/pull_failures_total")
.and_then(|failures| failures.as_object())
.map(|failures| failures.values().filter_map(serde_json::Value::as_u64).sum::<u64>()),
Some(0),
"a retried attempt is not a failed pull: {status}"
);
Ok(())
}
/// Case 7: the source advertises an ETag its bytes do not match. The client
/// still gets every byte; the write-back is discarded as an integrity
/// failure and nothing is stored.
#[tokio::test]
async fn test_odm_wrong_source_etag_discards_the_write_back() -> TestResult {
let bucket = "odm-fault-etag";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let key = "etag/mismatch.bin";
let body = payload(64 * 1024);
seed_with_etag(&env, key, body.clone(), "0123456789abcdef0123456789abcdef").await?;
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.body, body, "the client receives the complete source bytes");
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/etag_mismatch", 1, SETTLE)
.await?;
env.assert_local_absent(bucket, key).await;
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"a discarded write-back is not re-read"
);
Ok(())
}
/// Case 8: a source miss is remembered for `negative_cache_ttl_secs`, and
/// re-checked once the entry expires.
#[tokio::test]
async fn test_odm_source_not_found_is_negative_cached_for_the_ttl() -> TestResult {
let bucket = "odm-fault-negative-cache";
let ttl = Duration::from_secs(3);
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.negative_cache_ttl_secs = ttl.as_secs();
})
.await?;
let key = "negative/nowhere.bin";
for attempt in 1..=10 {
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 404, "attempt {attempt}: {}", String::from_utf8_lossy(&response.body));
}
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
1,
"nine of the ten misses stop at the negative cache"
);
assert!(
env.status_counter(bucket, "/counters/requests_total/get/negative_cached")
.await?
>= 9,
"the cached misses are counted"
);
tokio::time::sleep(ttl + Duration::from_secs(2)).await;
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 404);
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
2,
"an expired entry re-checks the source once"
);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
Ok(())
}
/// Case 9: an SSE-C source object cannot be migrated (the key belongs to the
/// source's client), so the read fails as unsupported without a body read.
#[tokio::test]
async fn test_odm_ssec_source_object_is_unsupported() -> TestResult {
let bucket = "odm-fault-ssec";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let key = "ssec/secret.bin";
seed_with_ssec(&env, key, payload(4096)).await?;
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
0,
"an unsupported object is rejected on the HEAD"
);
assert_eq!(
env.status_json(bucket)
.await?
.pointer("/counters/requests_total/get/unsupported")
.and_then(|v| v.as_u64()),
Some(1),
);
env.assert_local_absent(bucket, key).await;
Ok(())
}
@@ -1,298 +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.
//! Basic read-through scenarios (rustfs/backlog#2156): inline pull and
//! local persistence, large-object passthrough with background backfill,
//! Range passthrough, source 404, `versionId` reads, a disabled bucket, and
//! the HEAD passthrough that stores nothing (rustfs/backlog#2155).
//! Every source-side expectation is asserted on the fake source's journal.
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject};
use crate::fake_s3_target::{BucketMode, Operation};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use bytes::Bytes;
use std::time::Duration;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-get-source";
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// Background pulls run after the response; generous for a loaded CI host.
const BACKFILL_WAIT: Duration = Duration::from_secs(60);
/// Position-dependent payload so a misaligned or truncated copy is caught.
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
/// RustFS with `local_bucket` migrating from `SOURCE_BUCKET` on the fake
/// source (unversioned, like a plain migration source); `adjust` tweaks the
/// policy before it is installed. Returns once the runtime consults the
/// source.
async fn configured_env(local_bucket: &str, adjust: impl FnOnce(&mut OdmSourceSpec)) -> Result<OdmTestEnv, BoxError> {
let env = OdmTestEnv::start().await?;
env.source.create_bucket_with_mode(SOURCE_BUCKET, BucketMode::Unversioned);
env.rustfs.create_test_bucket(local_bucket).await?;
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
adjust(&mut spec);
let response = env.configure_source(local_bucket, &spec).await?;
assert_eq!(response.status, 200, "configure on-demand migration: {}", response.body);
env.wait_until_source_consulted(local_bucket).await?;
Ok(env)
}
fn source_get_ranges(env: &OdmTestEnv, key: &str) -> Vec<Option<String>> {
env.source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::GetObject && record.key.as_deref() == Some(key))
.map(|record| record.range)
.collect()
}
#[tokio::test]
async fn get_miss_pulls_inline_and_serves_locally_afterwards() -> TestResult {
let bucket = "odm-get-inline";
let env = configured_env(bucket, |_| {}).await?;
let key = "inline/report.bin";
let body = payload(200 * 1024);
let etag = env
.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())])
.remove(0);
let quoted_etag = format!("\"{etag}\"");
let first = env.raw_get(bucket, key).await?;
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
assert_eq!(first.header("etag"), Some(quoted_etag.as_str()), "inline answers carry the source ETag");
assert_eq!(first.header("content-length"), Some(body.len().to_string().as_str()));
assert_eq!(first.header("accept-ranges"), Some("bytes"));
assert_eq!(first.body, body, "the client receives the source bytes");
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1, "exactly one source GET");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert!(
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
"the inline pull must store the object locally"
);
let second = env.raw_get(bucket, key).await?;
assert_eq!(second.status, 200);
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "a local hit carries no source marker");
assert_eq!(second.body, body, "the local copy is the source bytes");
assert_eq!(second.header("etag"), Some(quoted_etag.as_str()), "preserve_etag keeps the source ETag");
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the second GET is served locally"
);
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
Ok(())
}
#[tokio::test]
async fn get_large_object_streams_through_and_backfills_in_background() -> TestResult {
let bucket = "odm-get-large";
let env = configured_env(bucket, |spec| spec.policy.inline_max_bytes = 4096).await?;
let key = "large/archive.bin";
let body = payload(512 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.header("content-length"), Some(body.len().to_string().as_str()));
assert_eq!(response.body, body, "the passthrough streams the whole object");
assert!(
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
"the background pull must store the object locally"
);
env.assert_local_present(bucket, key, &body).await;
assert_eq!(
source_get_ranges(&env, key),
vec![None, None],
"one passthrough GET plus one background pull, both unranged"
);
Ok(())
}
#[tokio::test]
async fn get_range_streams_206_and_backfills_the_whole_object() -> TestResult {
let bucket = "odm-get-range";
let env = configured_env(bucket, |_| {}).await?;
let key = "range/video.bin";
let body = payload(100_000);
let etag = env
.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())])
.remove(0);
let response = env
.client
.get_object()
.bucket(bucket)
.key(key)
.range("bytes=10-19")
.send()
.await?;
assert_eq!(response.content_range(), Some("bytes 10-19/100000"), "the source's 206 is passed through");
assert_eq!(response.content_length(), Some(10));
assert_eq!(response.e_tag(), Some(format!("\"{etag}\"").as_str()));
assert_eq!(response.body.collect().await?.into_bytes(), body.slice(10..20));
assert_eq!(
source_get_ranges(&env, key),
vec![Some("bytes=10-19".to_string())],
"the Range is forwarded"
);
assert!(
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
"serve_and_backfill must pull the whole object"
);
env.assert_local_present(bucket, key, &body).await;
assert_eq!(
source_get_ranges(&env, key),
vec![Some("bytes=10-19".to_string()), None],
"the background pull fetches the whole object"
);
Ok(())
}
#[tokio::test]
async fn get_source_not_found_is_404_and_negative_cached() -> TestResult {
let bucket = "odm-get-missing";
let env = configured_env(bucket, |_| {}).await?;
let key = "missing/nowhere.bin";
for attempt in 1..=2 {
let err = env
.client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect_err("a key missing on both sides is 404");
assert_eq!(err.code(), Some("NoSuchKey"), "attempt {attempt}: {err:?}");
}
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0, "a source miss never pulls");
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
1,
"the second miss stops at the negative cache"
);
env.assert_local_absent(bucket, key).await;
Ok(())
}
#[tokio::test]
async fn get_with_version_id_does_not_consult_the_source() -> TestResult {
let bucket = "odm-get-versioned";
let env = configured_env(bucket, |_| {}).await?;
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let key = "versioned/doc.bin";
let body = payload(1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let err = env
.client
.get_object()
.bucket(bucket)
.key(key)
.version_id("11111111-2222-4333-8444-555555555555")
.send()
.await
.expect_err("a version read cannot be answered by the source");
assert!(
matches!(err.code(), Some("NoSuchVersion") | Some("NoSuchKey")),
"unexpected error: {err:?}"
);
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 0);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
env.assert_local_absent(bucket, key).await;
// The same key without versionId is still migrated: the gate is per request.
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.body, body);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
Ok(())
}
#[tokio::test]
async fn get_after_disable_does_not_consult_the_source() -> TestResult {
let bucket = "odm-get-disabled";
let env = configured_env(bucket, |_| {}).await?;
let key = "disabled/doc.bin";
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, payload(1024))]);
let response = env.disable(bucket).await?;
assert_eq!(response.status, 204, "{}", response.body);
let err = env
.client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect_err("a disabled bucket answers locally");
assert_eq!(err.code(), Some("NoSuchKey"), "{err:?}");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 0);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
env.assert_local_absent(bucket, key).await;
Ok(())
}
/// A HEAD miss is answered from the source but must not store anything: the
/// key stays absent locally, so a second HEAD consults the source again. This
/// is the smoke-lane guard for the HEAD passthrough (rustfs/backlog#2155).
#[tokio::test]
async fn head_miss_answers_from_the_source_without_persisting() -> TestResult {
let bucket = "odm-head-passthrough";
let env = configured_env(bucket, |_| {}).await?;
let key = "head/report.bin";
let body = payload(32 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let head = env.raw_object_request(http::Method::HEAD, bucket, key, &[]).await?;
assert_eq!(head.status, 200, "{}", String::from_utf8_lossy(&head.body));
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
assert_eq!(head.header("content-length"), Some(body.len().to_string().as_str()));
assert!(head.body.is_empty(), "a HEAD answer carries no body");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0, "a HEAD must never pull the body");
env.assert_local_absent(bucket, key).await;
let again = env.raw_object_request(http::Method::HEAD, bucket, key, &[]).await?;
assert_eq!(again.status, 200, "{}", String::from_utf8_lossy(&again.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
2,
"nothing was written back, so the second HEAD consults the source again"
);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
env.assert_local_absent(bucket, key).await;
Ok(())
}
@@ -1,823 +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.
//! How on-demand migration composes with the rest of the bucket surface
//! (rustfs/backlog#2158): default encryption, Object Lock, quota,
//! notifications, replication, versioning and delete markers, the disable
//! switch, and the admin view.
//!
//! A pulled object goes through the internal put path, so it must be
//! indistinguishable from a client PUT. Each case pins both the resulting
//! local object and what the source was asked for.
use super::common::{
AdminResponse, BoxError, OdmEnvOptions, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env,
start_configured_env_with,
};
use crate::common::{RustFSTestEnvironment, replication_fast_env, signed_request};
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation};
use crate::object_lock::common::put_object_lock_configuration;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{
BucketVersioningStatus, Event, FilterRule, FilterRuleName, NotificationConfiguration, NotificationConfigurationFilter,
ObjectLockRetentionMode, QueueConfiguration, S3KeyFilter, ServerSideEncryption, ServerSideEncryptionByDefault,
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, VersioningConfiguration,
};
use bytes::Bytes;
use local_ip_address::local_ip;
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use serde_json::Value;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-interaction-source";
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// `userIdentity.principalId` every write-back event carries.
const ODM_PRINCIPAL_ID: &str = "rustfs-on-demand-migration";
const SETTLE: Duration = Duration::from_secs(120);
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
async fn admin(
env: &RustFSTestEnvironment,
method: http::Method,
path: &str,
body: Option<Value>,
) -> Result<AdminResponse, BoxError> {
let url = format!("{}{path}", env.url);
let body = body.map(|value| serde_json::to_vec(&value)).transpose()?;
let content_type = body.is_some().then_some("application/json");
let response = signed_request(method, &url, &env.access_key, &env.secret_key, body, content_type).await?;
Ok(AdminResponse {
status: response.status().as_u16(),
body: response.text().await?,
})
}
async fn enable_versioning(env: &OdmTestEnv, bucket: &str) -> TestResult {
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
Ok(())
}
/// Case 12: a bucket that encrypts by default stores the pulled object
/// encrypted, and it reads back as plaintext afterwards without touching the
/// source again.
#[tokio::test]
async fn test_odm_pulled_object_uses_bucket_default_encryption() -> TestResult {
let bucket = "odm-interaction-sse";
let env = start_configured_env_with(
OdmEnvOptions {
local_kms: true,
..OdmEnvOptions::default()
},
bucket,
SOURCE_BUCKET,
|_| {},
)
.await?;
env.client
.put_bucket_encryption()
.bucket(bucket)
.server_side_encryption_configuration(
ServerSideEncryptionConfiguration::builder()
.rules(
ServerSideEncryptionRule::builder()
.apply_server_side_encryption_by_default(
ServerSideEncryptionByDefault::builder()
.sse_algorithm(ServerSideEncryption::Aes256)
.build()?,
)
.build(),
)
.build()?,
)
.send()
.await?;
let key = "sse/report.bin";
let body = payload(128 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let first = env.raw_get(bucket, key).await?;
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(first.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
let second = env.raw_get(bucket, key).await?;
assert_eq!(second.status, 200, "{}", String::from_utf8_lossy(&second.body));
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "the second read is local");
assert_eq!(
second.header("x-amz-server-side-encryption"),
Some("AES256"),
"the write-back honours the bucket default encryption"
);
assert_eq!(second.body, body, "the encrypted copy reads back as the source bytes");
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the encrypted local copy serves the second read"
);
Ok(())
}
/// Case 13: a pulled object inherits the bucket's default Object Lock
/// retention, so it cannot be deleted while the retention holds.
#[tokio::test]
async fn test_odm_pulled_object_inherits_object_lock_retention() -> TestResult {
let bucket = "odm-interaction-object-lock";
let env = OdmTestEnv::start().await?;
env.source.create_bucket_with_mode(SOURCE_BUCKET, BucketMode::Unversioned);
env.client
.create_bucket()
.bucket(bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
put_object_lock_configuration(&env.client, bucket, ObjectLockRetentionMode::Compliance, Some(1), None).await?;
let spec = env.fake_source_spec(SOURCE_BUCKET);
env.configure_and_wait(bucket, &spec).await?;
let key = "locked/record.bin";
let body = payload(32 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let pulled = env.raw_get(bucket, key).await?;
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
assert_eq!(pulled.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
let head = env.client.head_object().bucket(bucket).key(key).send().await?;
assert_eq!(
head.object_lock_mode().map(|mode| mode.as_str()),
Some("COMPLIANCE"),
"the default retention mode is applied to the pulled object"
);
assert!(head.object_lock_retain_until_date().is_some(), "a retain-until date is set");
let version_id = head.version_id().ok_or("an Object Lock bucket is versioned")?.to_string();
let error = env
.client
.delete_object()
.bucket(bucket)
.key(key)
.version_id(&version_id)
.send()
.await
.expect_err("a COMPLIANCE-retained version cannot be deleted");
assert_eq!(error.code(), Some("AccessDenied"), "{error:?}");
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the rejected delete never consults the source"
);
Ok(())
}
/// Case 14: the write-back obeys the bucket quota. The client is still
/// served from the source, but nothing is stored and the failure is counted.
#[tokio::test]
async fn test_odm_write_back_respects_the_bucket_quota() -> TestResult {
let bucket = "odm-interaction-quota";
let env = start_configured_env_with(
OdmEnvOptions {
env: vec![("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_SCANNER_START_DELAY_SECS", "0")],
..OdmEnvOptions::default()
},
bucket,
SOURCE_BUCKET,
|_| {},
)
.await?;
// Fill the bucket past the quota it is about to get, so the write-back's
// admission check has to reject it.
let filler = payload(2 * 1024 * 1024);
env.client
.put_object()
.bucket(bucket)
.key("quota/filler.bin")
.body(aws_sdk_s3::primitives::ByteStream::from(filler.clone()))
.send()
.await?;
wait_for_bucket_usage(&env, bucket, filler.len() as u64).await?;
set_bucket_quota(&env, bucket, 1024 * 1024).await?;
let key = "quota/oversized.bin";
let body = payload(2 * 1024 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.body, body, "a full bucket still serves the client from the source");
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/quota", 1, SETTLE)
.await?;
env.assert_local_absent(bucket, key).await;
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the rejected write-back is not retried against the source"
);
Ok(())
}
/// The quota route answers 503 until the durable-quota capability is
/// confirmed on the fresh single-node deployment, so the write is retried.
async fn set_bucket_quota(env: &OdmTestEnv, bucket: &str, quota_bytes: u64) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let response = admin(
&env.rustfs,
http::Method::PUT,
&format!("/rustfs/admin/v3/quota/{bucket}"),
Some(serde_json::json!({ "quota": quota_bytes, "quota_type": "HARD" })),
)
.await?;
if response.status < 300 {
return Ok(());
}
if response.status != 503 || Instant::now() >= deadline {
return Err(format!("set quota for {bucket}: {} {}", response.status, response.body).into());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
async fn wait_for_bucket_usage(env: &OdmTestEnv, bucket: &str, at_least: u64) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let response = admin(&env.rustfs, http::Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None).await?;
if response.status == 200 {
let usage = serde_json::from_str::<Value>(&response.body)?
.get("current_usage")
.and_then(Value::as_u64)
.unwrap_or(0);
if usage >= at_least {
return Ok(());
}
}
if Instant::now() >= deadline {
return Err(format!("bucket usage for {bucket} did not reach {at_least} bytes: {}", response.body).into());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Case 15: a pull emits an ordinary creation event attributed to the
/// migration principal, and `emit_events=false` silences it.
#[tokio::test]
async fn test_odm_pull_emits_object_created_events_unless_disabled() -> TestResult {
let emitting = "odm-interaction-events";
let silent = "odm-interaction-events-off";
// The collector binds first: the outbound guard rejects a webhook
// endpoint on a private address unless its origin is allowed at startup.
let (endpoint, mut events) = spawn_event_collector().await?;
let allowed_origin = reqwest::Url::parse(&endpoint)?.origin().ascii_serialization();
let env = start_configured_env_with(
OdmEnvOptions {
env: vec![(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())],
..OdmEnvOptions::default()
},
emitting,
SOURCE_BUCKET,
|_| {},
)
.await?;
let mut silent_spec = env.fake_source_spec(SOURCE_BUCKET);
silent_spec.policy.emit_events = false;
env.configure_and_wait(silent, &silent_spec).await?;
let target = "odm-events";
let switches = admin(
&env.rustfs,
http::Method::PUT,
"/rustfs/admin/v3/module-switches",
Some(serde_json::json!({ "notify_enabled": true, "audit_enabled": false })),
)
.await?;
assert_eq!(switches.status, 200, "{}", switches.body);
let queue_dir = format!("{}/notify-queue-{target}", env.rustfs.temp_dir);
tokio::fs::create_dir_all(&queue_dir).await?;
let configured = admin(
&env.rustfs,
http::Method::PUT,
&format!("/rustfs/admin/v3/target/notify_webhook/{target}"),
Some(serde_json::json!({
"key_values": [
{ "key": "endpoint", "value": endpoint },
{ "key": "queue_dir", "value": queue_dir },
]
})),
)
.await?;
assert_eq!(configured.status, 200, "{}", configured.body);
wait_for_target_online(&env.rustfs, target).await?;
for bucket in [emitting, silent] {
put_notification_config(&env, bucket, target).await?;
}
// Control: an ordinary client PUT must produce an event, so a missing
// one below is about the write-back and not about the pipeline.
let control_key = "events/control.bin";
env.client
.put_object()
.bucket(emitting)
.key(control_key)
.body(aws_sdk_s3::primitives::ByteStream::from(payload(1024)))
.send()
.await?;
let control = wait_for_event(&mut events, emitting, control_key, Duration::from_secs(60))
.await
.ok_or("the notification pipeline delivered no event for a plain PUT")?;
assert_eq!(
control.pointer("/eventName").and_then(Value::as_str),
Some("s3:ObjectCreated:Put"),
"{control}"
);
let emitting_key = "events/pulled.bin";
let silent_key = "events/quiet.bin";
let body = payload(16 * 1024);
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new(emitting_key, body.clone()),
SeedObject::new(silent_key, body.clone()),
],
);
for (bucket, key) in [(emitting, emitting_key), (silent, silent_key)] {
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{bucket}: {}", String::from_utf8_lossy(&response.body));
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "{bucket}/{key} must be stored");
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1, "{bucket}/{key}");
}
let record = wait_for_event(&mut events, emitting, emitting_key, Duration::from_secs(60))
.await
.ok_or("no creation event for the pulled object")?;
assert_eq!(
record.pointer("/eventName").and_then(Value::as_str),
Some("s3:ObjectCreated:Put"),
"{record}"
);
assert_eq!(
record.pointer("/userIdentity/principalId").and_then(Value::as_str),
Some(ODM_PRINCIPAL_ID),
"{record}"
);
// The silent bucket's object landed before the event above was observed,
// so a missing event here is a decision, not a race.
assert!(
wait_for_event(&mut events, silent, silent_key, Duration::from_secs(5))
.await
.is_none(),
"emit_events=false must not publish a creation event"
);
Ok(())
}
async fn put_notification_config(env: &OdmTestEnv, bucket: &str, target: &str) -> TestResult {
let queue = QueueConfiguration::builder()
.id(format!("{bucket}-rule"))
.queue_arn(format!("arn:rustfs:sqs:us-east-1:{target}:webhook"))
.events(Event::from("s3:ObjectCreated:*"))
.filter(
NotificationConfigurationFilter::builder()
.key(
S3KeyFilter::builder()
.filter_rules(FilterRule::builder().name(FilterRuleName::Prefix).value("events/").build())
.build(),
)
.build(),
)
.build()?;
env.client
.put_bucket_notification_configuration()
.bucket(bucket)
.notification_configuration(NotificationConfiguration::builder().queue_configurations(queue).build())
.send()
.await?;
Ok(())
}
async fn wait_for_target_online(env: &RustFSTestEnvironment, target: &str) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let response = admin(env, http::Method::GET, "/rustfs/admin/v3/target/list", None).await?;
if response.status == 200 {
let body: Value = serde_json::from_str(&response.body)?;
let online = body["notification_endpoints"].as_array().is_some_and(|endpoints| {
endpoints.iter().any(|endpoint| {
endpoint["account_id"].as_str() == Some(target) && endpoint["status"].as_str() == Some("online")
})
});
if online {
return Ok(());
}
}
if Instant::now() >= deadline {
return Err(format!("webhook target {target} did not come online: {}", response.body).into());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Minimal HTTP receiver: answers everything 200 (so the target's
/// reachability probe reports online) and forwards parsed POST bodies.
async fn spawn_event_collector() -> Result<(String, mpsc::UnboundedReceiver<Value>), BoxError> {
let listener = TcpListener::bind("0.0.0.0:0").await?;
let port = listener.local_addr()?.port();
let endpoint = format!("http://{}/events", std::net::SocketAddr::new(local_ip()?, port));
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let tx = tx.clone();
tokio::spawn(async move {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 4096];
let mut content_length = 0usize;
let mut header_end = None;
while header_end.is_none() {
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
header_end = buffer.windows(4).position(|window| window == b"\r\n\r\n");
}
let header_end = header_end.expect("loop exits only with a header end");
let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
for line in headers.split("\r\n").skip(1) {
if let Some((name, value)) = line.split_once(':')
&& name.trim().eq_ignore_ascii_case("content-length")
{
content_length = value.trim().parse().unwrap_or(0);
}
}
let body_offset = header_end + 4;
while buffer.len() - body_offset < content_length {
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
}
let _ = stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
.await;
let _ = stream.shutdown().await;
if let Ok(value) = serde_json::from_slice::<Value>(&buffer[body_offset..body_offset + content_length]) {
let _ = tx.send(value);
}
});
}
});
Ok((endpoint, rx))
}
/// The first delivered record for `bucket`/`key`, or `None` on timeout.
async fn wait_for_event(
events: &mut mpsc::UnboundedReceiver<Value>,
bucket: &str,
key: &str,
timeout: Duration,
) -> Option<Value> {
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.checked_duration_since(Instant::now())?;
let envelope = tokio::time::timeout(remaining, events.recv()).await.ok()??;
for record in envelope["Records"].as_array().into_iter().flatten() {
// S3 event notifications URL-encode the object key.
let record_key = record.pointer("/s3/object/key").and_then(Value::as_str).map(|raw| {
urlencoding::decode(raw)
.map(|decoded| decoded.into_owned())
.unwrap_or_else(|_| raw.to_string())
});
if record.pointer("/s3/bucket/name").and_then(Value::as_str) == Some(bucket) && record_key.as_deref() == Some(key) {
return Some(record.clone());
}
}
}
}
/// Case 16: a pulled object enters the replication pipeline like any other
/// write, and a configuration whose source is one of the bucket's own
/// replication targets is rejected.
#[tokio::test]
async fn test_odm_pulled_object_replicates_and_target_as_source_is_rejected() -> TestResult {
let bucket = "odm-interaction-replication";
let replica_bucket = "odm-replica";
let fast_env = replication_fast_env();
let env = start_configured_env_with(
OdmEnvOptions {
env: fast_env.clone(),
..OdmEnvOptions::default()
},
bucket,
SOURCE_BUCKET,
|_| {},
)
.await?;
let replica = FakeS3Target::start().await?;
replica.create_bucket(replica_bucket);
enable_versioning(&env, bucket).await?;
let arn = set_remote_target(&env.rustfs, bucket, &replica.address(), replica_bucket).await?;
put_bucket_replication(&env.rustfs, bucket, &arn).await?;
let key = "replicated/asset.bin";
let body = payload(64 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
let deadline = Instant::now() + SETTLE;
while !replica.has_object(replica_bucket, key) {
assert!(Instant::now() < deadline, "the pulled object was never replicated to the target");
tokio::time::sleep(Duration::from_millis(200)).await;
}
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"replication reads the local copy, never the migration source"
);
let looping = OdmSourceSpec::for_fake_source(&replica, replica_bucket);
let rejected = env.configure_source(bucket, &looping).await?;
assert_eq!(
rejected.status, 400,
"a bucket may not migrate from its own replication target: {}",
rejected.body
);
Ok(())
}
async fn set_remote_target(
env: &RustFSTestEnvironment,
bucket: &str,
endpoint: &str,
target_bucket: &str,
) -> Result<String, BoxError> {
let response = admin(
env,
http::Method::PUT,
&format!("/rustfs/admin/v3/set-remote-target?bucket={}", urlencoding::encode(bucket)),
Some(serde_json::json!({
"endpoint": endpoint,
"credentials": { "accessKey": FAKE_ACCESS_KEY, "secretKey": FAKE_SECRET_KEY },
"targetbucket": target_bucket,
"secure": false,
"skipTlsVerify": false,
"type": "replication"
})),
)
.await?;
if response.status != 200 {
return Err(format!("set remote target: {} {}", response.status, response.body).into());
}
Ok(serde_json::from_str(&response.body)?)
}
async fn put_bucket_replication(env: &RustFSTestEnvironment, bucket: &str, arn: &str) -> TestResult {
let body = format!(
r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Role></Role>
<Rule>
<ID>odm-rule</ID>
<Priority>1</Priority>
<Status>Enabled</Status>
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
<Destination><Bucket>{arn}</Bucket></Destination>
</Rule>
</ReplicationConfiguration>"#
);
let url = format!("{}/{bucket}?replication", env.url);
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body.into_bytes()),
Some("application/xml"),
)
.await?;
if response.status() != 200 {
let status = response.status();
return Err(format!("put bucket replication: {status} {}", response.text().await.unwrap_or_default()).into());
}
Ok(())
}
/// Case 17: a local delete marker is the authoritative answer in a versioned
/// bucket, while an unversioned delete leaves nothing behind and the key is
/// migrated again.
#[tokio::test]
async fn test_odm_delete_marker_shadows_the_source_but_a_plain_delete_does_not() -> TestResult {
let versioned = "odm-interaction-delete-marker";
let unversioned = "odm-interaction-plain-delete";
let env = start_configured_env(versioned, SOURCE_BUCKET, |_| {}).await?;
let spec = env.fake_source_spec(SOURCE_BUCKET);
env.configure_and_wait(unversioned, &spec).await?;
enable_versioning(&env, versioned).await?;
let key = "deleted/doc.bin";
let source_body = payload(8 * 1024);
let local_body = payload(4 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, source_body.clone())]);
for bucket in [versioned, unversioned] {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(local_body.clone()))
.send()
.await?;
env.client.delete_object().bucket(bucket).key(key).send().await?;
}
let shadowed = env.raw_get(versioned, key).await?;
assert_eq!(shadowed.status, 404, "{}", String::from_utf8_lossy(&shadowed.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
0,
"a local delete marker answers without the source"
);
let migrated = env.raw_get(unversioned, key).await?;
assert_eq!(migrated.status, 200, "{}", String::from_utf8_lossy(&migrated.body));
assert_eq!(migrated.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(migrated.body, source_body, "an unversioned delete leaves the source authoritative");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
Ok(())
}
/// Case 18: deleting the configuration stops all source traffic without
/// touching what was already migrated, and reinstalling it resumes.
#[tokio::test]
async fn test_odm_disable_keeps_pulled_objects_and_stops_source_traffic() -> TestResult {
let bucket = "odm-interaction-disable";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let pulled_key = "disable/pulled.bin";
let untouched_key = "disable/untouched.bin";
let body = payload(32 * 1024);
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new(pulled_key, body.clone()),
SeedObject::new(untouched_key, body.clone()),
],
);
let pulled = env.raw_get(bucket, pulled_key).await?;
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
assert!(env.wait_local_listed(bucket, pulled_key, SETTLE).await?);
let disabled = env.disable(bucket).await?;
assert_eq!(disabled.status, 204, "{}", disabled.body);
let still_readable = env.raw_get(bucket, pulled_key).await?;
assert_eq!(still_readable.status, 200, "{}", String::from_utf8_lossy(&still_readable.body));
assert_eq!(still_readable.body, body, "a migrated object survives the disable");
assert_eq!(still_readable.header(ODM_RESPONSE_HEADER), None);
assert_eq!(env.source.count_requests(Operation::GetObject, pulled_key), 1);
let missing = env.raw_get(bucket, untouched_key).await?;
assert_eq!(missing.status, 404, "{}", String::from_utf8_lossy(&missing.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, untouched_key),
0,
"a disabled bucket never reaches the source"
);
let spec = env.fake_source_spec(SOURCE_BUCKET);
env.configure_and_wait(bucket, &spec).await?;
let resumed = env.raw_get(bucket, untouched_key).await?;
assert_eq!(resumed.status, 200, "{}", String::from_utf8_lossy(&resumed.body));
assert_eq!(resumed.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(resumed.body, body);
assert_eq!(env.source.count_requests(Operation::GetObject, untouched_key), 1);
Ok(())
}
/// Case 19: the admin surface an operator sees — the configuration read back
/// without its secret, and a status document whose counters match the source
/// journal exactly.
#[tokio::test]
async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source() -> TestResult {
let bucket = "odm-interaction-admin";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let hit_key = "admin/present.bin";
let miss_key = "admin/absent.bin";
let body = payload(16 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(hit_key, body.clone())]);
let config = env.get_config(bucket).await?;
assert_eq!(config.status, 200, "{}", config.body);
let config = config.json()?;
assert_eq!(
config
.pointer("/config/source/credentials/secret_key")
.and_then(Value::as_str),
Some("REDACTED"),
"{config}"
);
assert_eq!(
config
.pointer("/config/source/credentials/access_key")
.and_then(Value::as_str),
Some(FAKE_ACCESS_KEY),
"the access key stays readable: {config}"
);
assert!(
!config.to_string().contains(FAKE_SECRET_KEY),
"the secret must not appear anywhere in the response"
);
let hit = env.raw_get(bucket, hit_key).await?;
assert_eq!(hit.status, 200, "{}", String::from_utf8_lossy(&hit.body));
for _ in 0..2 {
let miss = env.raw_get(bucket, miss_key).await?;
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
}
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
let status = env.status_json(bucket).await?;
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
assert_eq!(status.pointer("/enabled").and_then(Value::as_bool), Some(true), "{status}");
assert_eq!(status.pointer("/module_enabled").and_then(Value::as_bool), Some(true), "{status}");
assert_eq!(status.pointer("/provider").and_then(Value::as_str), Some("s3"), "{status}");
assert_eq!(
status
.pointer("/counters/requests_total/get/source_hit")
.and_then(Value::as_u64),
Some(1),
"one source hit, matching the one source GET: {status}"
);
assert_eq!(
status
.pointer("/counters/requests_total/get/source_miss")
.and_then(Value::as_u64),
Some(1),
"only the first miss reached the source: {status}"
);
assert_eq!(
status
.pointer("/counters/requests_total/get/negative_cached")
.and_then(Value::as_u64),
Some(1),
"the second miss stopped at the negative cache: {status}"
);
assert_eq!(
status
.pointer("/counters/pulled_objects_total/inline")
.and_then(Value::as_u64),
Some(1),
"{status}"
);
assert_eq!(
status.pointer("/counters/pulled_bytes_total").and_then(Value::as_u64),
Some(body.len() as u64),
"{status}"
);
assert_eq!(env.source.count_requests(Operation::GetObject, hit_key), 1);
assert_eq!(
env.source.count_requests(Operation::HeadObject, miss_key),
1,
"the status counters and the source journal agree"
);
Ok(())
}
@@ -1,243 +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.
//! Provider interoperability cases (ODM-20, rustfs/backlog#2167).
//!
//! One body per case, run against whichever source the environment names:
//! the in-process fake source locally, a MinIO container or a real cloud
//! provider under `.github/workflows/on-demand-migration-interop.yml`. The
//! source is resolved by [`OdmInteropEnv`], so a provider difference in
//! path-style addressing, region handling, ETag shape or list pagination
//! shows up as one of these assertions failing rather than as a second,
//! drifting copy of the suite.
//!
//! Consequently these cases assert only on what every S3 implementation has
//! to agree on — what the client receives and what RustFS stored — never on
//! the fake source's request journal, which a real provider does not have.
//! The journal-backed expectations stay in `get_basic_test.rs` and
//! `interaction_test.rs`.
//!
//! The first three cases are the minimum a cloud provider is asked for (GET
//! miss, HEAD miss, merged list pagination); the backfill case runs against
//! the MinIO container, whose object count the lane raises well past the fake
//! source's caps.
use super::common::{BackfillRequest, BoxError, OdmInteropEnv, SeedObject, interop_backfill_objects};
use bytes::Bytes;
use std::time::Duration;
type TestResult = Result<(), BoxError>;
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// Background pulls land after the response that triggered them; generous for
/// a loaded runner talking to a container.
const SETTLE: Duration = Duration::from_secs(90);
/// Position-dependent payload so a misaligned or truncated copy is caught.
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
/// A GET miss is answered from the source with the source's own ETag, and the
/// object it stored serves every later read locally.
#[tokio::test]
async fn interop_get_miss_pulls_from_the_source_and_serves_locally() -> TestResult {
let case =
OdmInteropEnv::start("interop_get_miss_pulls_from_the_source_and_serves_locally", "odm-interop-get", |_| {}).await?;
let key = "interop/report.bin";
let body = payload(200 * 1024);
let etag = case.seed(&[SeedObject::new(key, body.clone())]).await?.remove(0);
let quoted_etag = format!("\"{etag}\"");
let first = case.env.raw_get(&case.bucket, key).await?;
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
assert_eq!(first.header("content-length"), Some(body.len().to_string().as_str()));
assert_eq!(
first.header("etag"),
Some(quoted_etag.as_str()),
"the source ETag is passed through unchanged"
);
assert_eq!(first.body, body, "the client receives the source bytes");
assert!(
case.env.wait_local_listed(&case.bucket, key, SETTLE).await?,
"the inline pull must store the object locally"
);
let second = case.env.raw_get(&case.bucket, key).await?;
assert_eq!(second.status, 200, "{}", String::from_utf8_lossy(&second.body));
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "a local hit carries no source marker");
assert_eq!(second.body, body, "the local copy is the source bytes");
assert_eq!(
second.header("etag"),
Some(quoted_etag.as_str()),
"preserve_etag keeps the source ETag on the stored object"
);
case.finish().await
}
/// A HEAD miss is proxied with the source's size and ETag and stores nothing.
#[tokio::test]
async fn interop_head_miss_answers_from_the_source_without_persisting() -> TestResult {
let case =
OdmInteropEnv::start("interop_head_miss_answers_from_the_source_without_persisting", "odm-interop-head", |_| {}).await?;
let key = "interop/head-only.bin";
let body = payload(9_000);
let etag = case.seed(&[SeedObject::new(key, body.clone())]).await?.remove(0);
let head = case
.env
.raw_object_request(http::Method::HEAD, &case.bucket, key, &[])
.await?;
assert_eq!(head.status, 200, "HEAD must be answered from the source");
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(head.header("content-length"), Some(body.len().to_string().as_str()));
assert_eq!(head.header("etag"), Some(format!("\"{etag}\"").as_str()));
assert!(head.body.is_empty(), "a HEAD carries no body");
case.env.assert_local_absent(&case.bucket, key).await;
// A key the source does not hold is a plain 404, not a source error.
let missing = case
.env
.raw_object_request(http::Method::HEAD, &case.bucket, "interop/absent.bin", &[])
.await?;
assert_eq!(missing.status, 404, "a source miss is a 404");
case.finish().await
}
/// The merged `ListObjectsV2` pages the source namespace in byte order, keeps
/// every page within `max_keys`, and lets a local object win a shared key.
#[tokio::test]
async fn interop_list_through_pages_the_source_namespace() -> TestResult {
const SOURCE_KEYS: usize = 120;
const PAGE_SIZE: i32 = 50;
const SOURCE_BODY_LEN: usize = 3;
const LOCAL_BODY_LEN: usize = 11;
let case = OdmInteropEnv::start("interop_list_through_pages_the_source_namespace", "odm-interop-list", |spec| {
spec.policy.list_through = true
})
.await?;
let keys: Vec<String> = (0..SOURCE_KEYS).map(|index| format!("page/obj-{index:05}")).collect();
let seeds: Vec<SeedObject> = keys
.iter()
.map(|key| SeedObject::new(key.clone(), payload(SOURCE_BODY_LEN)))
.collect();
case.seed(&seeds).await?;
// Five keys the local bucket also holds, with a body length that tells the
// two sides apart in the listing.
let shared: Vec<String> = keys.iter().step_by(25).cloned().collect();
for key in &shared {
case.env
.client
.put_object()
.bucket(&case.bucket)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(payload(LOCAL_BODY_LEN)))
.send()
.await?;
}
let mut listed: Vec<(String, i64)> = Vec::new();
let mut token: Option<String> = None;
let mut completed = false;
for _ in 0..SOURCE_KEYS {
let page = case
.env
.client
.list_objects_v2()
.bucket(&case.bucket)
.prefix("page/")
.max_keys(PAGE_SIZE)
.set_continuation_token(token.take())
.send()
.await?;
assert!(page.contents().len() <= PAGE_SIZE as usize, "a merged page must not exceed max_keys");
for object in page.contents() {
listed.push((object.key().unwrap_or_default().to_string(), object.size().unwrap_or_default()));
}
if !page.is_truncated().unwrap_or(false) {
completed = true;
break;
}
token = Some(
page.next_continuation_token()
.ok_or("truncated merged page without a continuation token")?
.to_string(),
);
}
assert!(completed, "the merged listing did not terminate");
let listed_keys: Vec<String> = listed.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(listed_keys, keys, "the merged listing is the source namespace in byte order");
for (key, size) in &listed {
let expected = if shared.contains(key) {
LOCAL_BODY_LEN
} else {
SOURCE_BODY_LEN
};
assert_eq!(*size, expected as i64, "{key} must be reported by the side that wins it");
}
case.finish().await
}
/// A backfill pulls every object under the run's source prefix. The count
/// comes from the environment: the fake source caps out around 4,096 stored
/// versions, while the MinIO lane runs the full production-shaped batch.
#[tokio::test]
async fn interop_backfill_pulls_every_source_object() -> TestResult {
const KEY_PREFIX: &str = "cold/";
let count = interop_backfill_objects()?;
assert!(count > 0, "the backfill case needs at least one source object");
let case = OdmInteropEnv::start("interop_backfill_pulls_every_source_object", "odm-interop-backfill", |_| {}).await?;
let objects: Vec<SeedObject> = (0..count)
.map(|index| SeedObject::new(format!("{KEY_PREFIX}{index:06}"), Bytes::from(format!("object-{index:06}"))))
.collect();
case.seed(&objects).await?;
let started = case.env.start_backfill(&case.bucket, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start backfill: {}", started.body);
// One pull is a HEAD plus a GET plus a local write; the ceiling scales
// with the object count so raising it in the workflow does not need a
// second knob here.
let timeout = Duration::from_secs(180 + count as u64 / 5);
let done = case
.env
.wait_for_backfill(&case.bucket, timeout, |job| job["state"] == "completed")
.await?;
for (name, expected) in [
("listed", count as u64),
("enqueued", count as u64),
("pulled", count as u64),
("failed", 0),
] {
assert_eq!(
done[name].as_u64().unwrap_or_else(|| panic!("{name} missing in {done}")),
expected,
"backfill {name}"
);
}
assert_eq!(
case.env.local_key_count(&case.bucket, KEY_PREFIX).await?,
count,
"every source object must be stored locally"
);
case.env
.assert_local_present(&case.bucket, &objects[count - 1].key, &objects[count - 1].body)
.await;
case.finish().await
}
@@ -1,365 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Optional merged `ListObjectsV2` (`policy.list_through`, ODM-17,
//! rustfs/backlog#2164): full pagination over a source and a local namespace,
//! common-prefix union under a delimiter, the continuation-token contract, and
//! the two `source_error` behaviours when the source listing fails.
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env};
use crate::fake_s3_target::{FaultAction, Operation};
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use bytes::Bytes;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-list-source";
const LIST_HEADER: &str = "x-rustfs-on-demand-migration-list";
/// Byte lengths that tell a local object from a source one in a listing.
const SOURCE_BODY_LEN: usize = 3;
const LOCAL_BODY_LEN: usize = 11;
fn body(len: usize) -> Bytes {
vec![b'x'; len].into()
}
/// RustFS migrating `bucket` from `SOURCE_BUCKET` with `list_through` on.
async fn list_through_env(bucket: &str, adjust: impl FnOnce(&mut OdmSourceSpec)) -> Result<OdmTestEnv, BoxError> {
start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.list_through = true;
adjust(spec);
})
.await
}
/// Every key the bucket lists, walked through the merged continuation token.
/// Also returns the size each page reported per key and the page sizes, so a
/// caller can assert who won a shared key and that no page exceeded `max_keys`.
async fn walk_listing(
env: &OdmTestEnv,
bucket: &str,
delimiter: Option<&str>,
max_keys: i32,
) -> Result<(Vec<(String, i64)>, Vec<String>, Vec<usize>), BoxError> {
let mut objects = Vec::new();
let mut prefixes = Vec::new();
let mut page_sizes = Vec::new();
let mut token: Option<String> = None;
for _ in 0..1000 {
let page = env
.client
.list_objects_v2()
.bucket(bucket)
.max_keys(max_keys)
.set_delimiter(delimiter.map(str::to_string))
.set_continuation_token(token.take())
.send()
.await?;
let listed = page.contents().len() + page.common_prefixes().len();
page_sizes.push(listed);
for object in page.contents() {
objects.push((object.key().unwrap_or_default().to_string(), object.size().unwrap_or_default()));
}
for prefix in page.common_prefixes() {
prefixes.push(prefix.prefix().unwrap_or_default().to_string());
}
if !page.is_truncated().unwrap_or(false) {
return Ok((objects, prefixes, page_sizes));
}
token = Some(
page.next_continuation_token()
.ok_or("truncated page without a continuation token")?
.to_string(),
);
}
Err("merged listing did not terminate".into())
}
#[tokio::test]
async fn list_through_merges_the_whole_namespace_across_full_pagination() -> TestResult {
let bucket = "odm-list-merge";
let env = list_through_env(bucket, |_| {}).await?;
// 2000 source keys, 80 of them also local, plus 10 local-only keys that
// interleave between source keys ("obj-00010x" sorts after "obj-00010").
let source_keys: Vec<String> = (0..2000).map(|index| format!("obj-{index:05}")).collect();
let seeds: Vec<SeedObject> = source_keys
.iter()
.map(|key| SeedObject::new(key.clone(), body(SOURCE_BODY_LEN)))
.collect();
env.seed_source(SOURCE_BUCKET, &seeds);
let shared: Vec<String> = source_keys.iter().step_by(25).cloned().collect();
let local_only: Vec<String> = (0..10).map(|index| format!("obj-{:05}x", index * 7)).collect();
for key in shared.iter().chain(local_only.iter()) {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
}
let max_keys = 97;
let (objects, prefixes, page_sizes) = walk_listing(&env, bucket, None, max_keys).await?;
assert!(prefixes.is_empty(), "no delimiter means no common prefixes");
let mut expected: Vec<String> = source_keys.iter().chain(local_only.iter()).cloned().collect();
expected.sort();
expected.dedup();
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(listed, expected, "the merged listing is the sorted, deduplicated union");
assert!(
page_sizes.iter().all(|size| *size <= max_keys as usize),
"no page may exceed max_keys: {page_sizes:?}"
);
let shared_sizes: Vec<i64> = objects
.iter()
.filter(|(key, _)| shared.contains(key))
.map(|(_, size)| *size)
.collect();
assert_eq!(shared_sizes.len(), shared.len(), "every shared key is listed exactly once");
assert!(
shared_sizes.iter().all(|size| *size == LOCAL_BODY_LEN as i64),
"the local object wins a key both sides hold"
);
let source_sizes: Vec<i64> = objects
.iter()
.filter(|(key, _)| !shared.contains(key) && !local_only.contains(key))
.map(|(_, size)| *size)
.collect();
assert!(
source_sizes.iter().all(|size| *size == SOURCE_BODY_LEN as i64),
"source-only keys report the source's own size"
);
Ok(())
}
#[tokio::test]
async fn list_through_unions_common_prefixes_under_a_delimiter() -> TestResult {
let bucket = "odm-list-delimiter";
let env = list_through_env(bucket, |_| {}).await?;
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("p1/a", body(SOURCE_BODY_LEN)),
SeedObject::new("p1/b", body(SOURCE_BODY_LEN)),
SeedObject::new("p2/a", body(SOURCE_BODY_LEN)),
SeedObject::new("top-s", body(SOURCE_BODY_LEN)),
],
);
for key in ["p1/c", "p3/a", "top-l"] {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
}
// A page size of two forces the prefix union to survive page boundaries.
let (objects, prefixes, page_sizes) = walk_listing(&env, bucket, Some("/"), 2).await?;
assert_eq!(prefixes, vec!["p1/", "p2/", "p3/"], "prefixes are unioned and deduplicated");
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(listed, vec!["top-l", "top-s"]);
assert!(page_sizes.iter().all(|size| *size <= 2), "{page_sizes:?}");
Ok(())
}
#[tokio::test]
async fn list_through_propagates_a_source_listing_failure() -> TestResult {
let bucket = "odm-list-propagate";
let env = list_through_env(bucket, |_| {}).await?;
env.seed_source(SOURCE_BUCKET, &[SeedObject::new("remote", body(SOURCE_BODY_LEN))]);
env.client
.put_object()
.bucket(bucket)
.key("local")
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
env.source
.inject(Operation::ListObjectsV2, FaultAction::ResponseStatus(503), 1);
let failure = env
.client
.list_objects_v2()
.bucket(bucket)
.send()
.await
.expect_err("propagate must surface the source failure");
let failure = failure.into_service_error();
assert_eq!(failure.meta().code(), Some("SourceUnavailable"), "{failure:?}");
// The next listing sees a healthy source again and merges both sides.
let (objects, _, _) = walk_listing(&env, bucket, None, 100).await?;
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(listed, vec!["local", "remote"]);
Ok(())
}
#[tokio::test]
async fn list_through_degrades_to_local_only_under_the_not_found_policy() -> TestResult {
let bucket = "odm-list-degrade";
let env = list_through_env(bucket, |spec| spec.policy.source_error = "not_found".to_string()).await?;
env.seed_source(SOURCE_BUCKET, &[SeedObject::new("remote", body(SOURCE_BODY_LEN))]);
env.client
.put_object()
.bucket(bucket)
.key("local")
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
env.source
.inject(Operation::ListObjectsV2, FaultAction::ResponseStatus(503), 1);
let degraded = env.raw_list_objects_v2(bucket, "max-keys=100").await?;
assert_eq!(degraded.status, 200, "{}", String::from_utf8_lossy(&degraded.body));
assert_eq!(
degraded.header(LIST_HEADER),
Some("local_only"),
"a degraded listing must say so in the response header"
);
let xml = String::from_utf8_lossy(&degraded.body).to_string();
assert!(xml.contains("<Key>local</Key>"), "{xml}");
assert!(!xml.contains("<Key>remote</Key>"), "a degraded listing shows local state only: {xml}");
let healthy = env.raw_list_objects_v2(bucket, "max-keys=100").await?;
assert_eq!(healthy.status, 200);
assert_eq!(healthy.header(LIST_HEADER), None, "a healthy merge carries no degradation marker");
assert!(String::from_utf8_lossy(&healthy.body).contains("<Key>remote</Key>"));
Ok(())
}
#[tokio::test]
async fn list_through_rejects_a_tampered_continuation_token() -> TestResult {
let bucket = "odm-list-token";
let env = list_through_env(bucket, |_| {}).await?;
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("a", body(SOURCE_BODY_LEN)),
SeedObject::new("b", body(SOURCE_BODY_LEN)),
SeedObject::new("c", body(SOURCE_BODY_LEN)),
],
);
let page = env.client.list_objects_v2().bucket(bucket).max_keys(1).send().await?;
let token = page.next_continuation_token().ok_or("first page must be truncated")?;
let decoded = String::from_utf8(base64_simd::STANDARD.decode_to_vec(token.as_bytes())?)?;
assert!(decoded.contains("\"t\":\"odm-list\""), "the merged token is an envelope: {decoded}");
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":2").as_bytes());
let rejected = env
.raw_list_objects_v2(bucket, &format!("continuation-token={tampered}"))
.await?;
assert_eq!(
rejected.status,
400,
"a bumped token version is a client error: {}",
String::from_utf8_lossy(&rejected.body)
);
Ok(())
}
#[tokio::test]
async fn a_merged_token_keeps_paginating_after_list_through_is_turned_off() -> TestResult {
let bucket = "odm-list-token-off";
let env = list_through_env(bucket, |_| {}).await?;
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("s1", body(SOURCE_BODY_LEN)),
SeedObject::new("s2", body(SOURCE_BODY_LEN)),
],
);
for key in ["l1", "l2"] {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
}
let page = env.client.list_objects_v2().bucket(bucket).max_keys(1).send().await?;
assert_eq!(page.contents()[0].key(), Some("l1"));
let token = page
.next_continuation_token()
.ok_or("first page must be truncated")?
.to_string();
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
spec.policy.list_through = false;
env.configure_and_wait(bucket, &spec).await?;
let resumed = env
.client
.list_objects_v2()
.bucket(bucket)
.max_keys(10)
.continuation_token(token)
.send()
.await?;
let listed: Vec<&str> = resumed.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(listed, vec!["l2"], "a merged token falls back to its local cursor");
Ok(())
}
#[tokio::test]
async fn a_local_delete_marker_hides_the_source_key_from_a_merged_listing() -> TestResult {
let bucket = "odm-list-delete-marker";
let env = list_through_env(bucket, |_| {}).await?;
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("kept", body(SOURCE_BODY_LEN)),
SeedObject::new("shadowed", body(SOURCE_BODY_LEN)),
],
);
env.client
.put_object()
.bucket(bucket)
.key("shadowed")
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
env.client.delete_object().bucket(bucket).key("shadowed").send().await?;
let (objects, _, _) = walk_listing(&env, bucket, None, 100).await?;
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(
listed,
vec!["kept"],
"a local delete marker shadows the source key the same way it does on GET"
);
Ok(())
}
+2 -18
View File
@@ -16,25 +16,9 @@
//!
//! `common` is the shared environment: one RustFS under test, one programmable
//! fake S3 source, admin-API wrappers, seeding and local-state assertions.
//! `harness_self_test` proves the harness itself; `get_basic_test` covers the
//! GET read-through (rustfs/backlog#2156) and `backfill_test` the background
//! backfill job (ODM-12, rustfs/backlog#2159); `list_through_test` covers the
//! optional merged `ListObjectsV2` (ODM-17, rustfs/backlog#2164). The fault, concurrency,
//! interaction and real-source matrix is rustfs/backlog#2158; its lane split
//! lives in `.config/nextest.toml` (fault / concurrency / real source run
//! nightly, the rest in the merge lane). `interop_test` is the provider
//! interoperability lane (ODM-20, rustfs/backlog#2167): the same case bodies
//! against the fake source locally and against a real provider named by the
//! environment in `.github/workflows/on-demand-migration-interop.yml`.
//! `harness_self_test` proves the harness itself; ODM behavior scenarios are
//! separate modules wired by later tasks.
pub mod common;
mod backfill_test;
mod concurrency_test;
mod fault_test;
mod get_basic_test;
mod harness_self_test;
mod interaction_test;
mod interop_test;
mod list_through_test;
mod real_source_test;
@@ -1,266 +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.
//! On-demand migration against a real RustFS source (rustfs/backlog#2158).
//!
//! These cases start a second (and, for the loop guard, a third) RustFS
//! process, so they carry the `_real_single_node` marker and run in the
//! nightly lane. A real source keeps no request journal, so "the source was
//! not consulted" is proven by removing the object from the source and
//! showing the read still succeeds, or by pointing the *second* server at a
//! fake source whose journal must stay empty.
use super::common::{
AdminResponse, BoxError, ODM_ADMIN_ROUTE, OdmSourceSpec, OdmTestEnv, RawResponse, SeedObject, start_source_rustfs,
start_source_rustfs_with_odm,
};
use crate::common::{RustFSTestEnvironment, signed_request};
use crate::fake_s3_target::{BucketMode, Operation};
use aws_sdk_s3::Client;
use bytes::Bytes;
use std::time::{Duration, Instant};
type TestResult = Result<(), BoxError>;
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// Reinstalled configurations are applied asynchronously; every phase polls
/// for the new behavior instead of sleeping.
const APPLY_TIMEOUT: Duration = Duration::from_secs(30);
const SETTLE: Duration = Duration::from_secs(60);
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
/// `PUT /rustfs/admin/v3/on-demand-migration/{bucket}` against any server,
/// not just the one under test.
async fn configure_odm(env: &RustFSTestEnvironment, bucket: &str, spec: &OdmSourceSpec) -> Result<AdminResponse, BoxError> {
let url = format!("{}{ODM_ADMIN_ROUTE}/{bucket}", env.url);
let body = serde_json::to_vec(&spec.to_json())?;
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body),
Some("application/json"),
)
.await?;
Ok(AdminResponse {
status: response.status().as_u16(),
body: response.text().await?,
})
}
async fn put_object(client: &Client, bucket: &str, key: &str, body: Bytes) -> TestResult {
client
.put_object()
.bucket(bucket)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(body))
.send()
.await?;
Ok(())
}
/// Polls a read against the server under test until it answers `expected`.
/// This is how a reinstalled configuration is waited for when the source
/// keeps no journal to probe.
async fn wait_for_get_status(env: &OdmTestEnv, bucket: &str, key: &str, expected: u16) -> Result<RawResponse, BoxError> {
let deadline = Instant::now() + APPLY_TIMEOUT;
loop {
let response = env.raw_get(bucket, key).await?;
if response.status == expected {
return Ok(response);
}
if Instant::now() >= deadline {
return Err(format!(
"GET {bucket}/{key} stayed at {} instead of {expected}: {}",
response.status,
String::from_utf8_lossy(&response.body)
)
.into());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Case 20: a second RustFS as the migration source — the pull, a HEAD
/// passthrough, a Range read, and both prefix knobs.
#[tokio::test]
async fn test_odm_rustfs_source_serves_pull_head_range_and_prefixes_real_single_node() -> TestResult {
let bucket = "odm-real-source";
let source_bucket = "odm-real-origin";
let source = start_source_rustfs().await?;
let source_client = source.create_s3_client();
source.create_test_bucket(source_bucket).await?;
let env = OdmTestEnv::start().await?;
env.rustfs.create_test_bucket(bucket).await?;
let spec = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
let configured = configure_odm(&env.rustfs, bucket, &spec).await?;
assert_eq!(configured.status, 200, "{}", configured.body);
// Phase 1: a miss is pulled and stored; removing it from the source
// afterwards proves the second read never goes back to the source.
let pulled_key = "real/pulled.bin";
let pulled_body = payload(256 * 1024);
put_object(&source_client, source_bucket, pulled_key, pulled_body.clone()).await?;
let pulled = wait_for_get_status(&env, bucket, pulled_key, 200).await?;
assert_eq!(pulled.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(pulled.body, pulled_body, "the client receives the source bytes");
assert!(env.wait_local_listed(bucket, pulled_key, SETTLE).await?, "the pull must store the object");
source_client
.delete_object()
.bucket(source_bucket)
.key(pulled_key)
.send()
.await?;
let local = env.raw_get(bucket, pulled_key).await?;
assert_eq!(local.status, 200, "{}", String::from_utf8_lossy(&local.body));
assert_eq!(local.header(ODM_RESPONSE_HEADER), None, "a local hit is not marked");
assert_eq!(local.body, pulled_body, "the object is served from the local copy");
// Phase 2: HEAD proxies metadata without storing anything.
let head_key = "real/head-only.bin";
let head_body = payload(9_000);
put_object(&source_client, source_bucket, head_key, head_body.clone()).await?;
let head = env.raw_object_request(http::Method::HEAD, bucket, head_key, &[]).await?;
assert_eq!(head.status, 200, "HEAD must be answered from the source");
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(head.header("content-length"), Some(head_body.len().to_string().as_str()));
env.assert_local_absent(bucket, head_key).await;
// Phase 3: a Range read is passed through as a 206.
let range_key = "real/range.bin";
let range_body = payload(100_000);
put_object(&source_client, source_bucket, range_key, range_body.clone()).await?;
let ranged = env
.raw_object_request(http::Method::GET, bucket, range_key, &[("range", "bytes=100-199")])
.await?;
assert_eq!(ranged.status, 206, "{}", String::from_utf8_lossy(&ranged.body));
assert_eq!(ranged.header("content-range"), Some("bytes 100-199/100000"));
assert_eq!(ranged.body, range_body.slice(100..200));
// Phase 4: `filter.prefix` decides which local keys may consult the
// source at all.
let allowed_key = "allowed/doc.bin";
let denied_key = "denied/doc.bin";
let filtered_body = payload(4_096);
put_object(&source_client, source_bucket, allowed_key, filtered_body.clone()).await?;
put_object(&source_client, source_bucket, denied_key, filtered_body.clone()).await?;
let mut filtered = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
filtered.filter.prefix = Some("allowed/".to_string());
let response = configure_odm(&env.rustfs, bucket, &filtered).await?;
assert_eq!(response.status, 200, "{}", response.body);
let denied = wait_for_get_status(&env, bucket, denied_key, 404).await?;
assert_eq!(denied.header(ODM_RESPONSE_HEADER), None);
env.assert_local_absent(bucket, denied_key).await;
let allowed = wait_for_get_status(&env, bucket, allowed_key, 200).await?;
assert_eq!(allowed.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(allowed.body, filtered_body);
// Phase 5: `filter.source_prefix` rewrites the key on the way out, so a
// local key resolves to a different key in the source bucket.
let rewritten_key = "rewritten/doc.bin";
let rewritten_body = payload(2_048);
put_object(&source_client, source_bucket, &format!("archive/{rewritten_key}"), rewritten_body.clone()).await?;
let mut rewriting = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
rewriting.filter.source_prefix = Some("archive/".to_string());
let response = configure_odm(&env.rustfs, bucket, &rewriting).await?;
assert_eq!(response.status, 200, "{}", response.body);
let rewritten = wait_for_get_status(&env, bucket, rewritten_key, 200).await?;
assert_eq!(rewritten.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(rewritten.body, rewritten_body, "the source prefix is prepended to the local key");
Ok(())
}
/// Case 21: two migrating servers pointed at each other must not build a
/// request loop. The middle server also has a fake source of its own, whose
/// journal is the evidence: a key it would happily fetch for a direct client
/// is never fetched for a request that arrived with the anti-loop marker.
#[tokio::test]
async fn test_odm_chained_sources_stop_at_the_loop_guard_real_single_node() -> TestResult {
let bucket = "odm-loop-guard";
let fake_bucket = "odm-loop-fake";
let env = OdmTestEnv::start().await?;
env.source.create_bucket_with_mode(fake_bucket, BucketMode::Unversioned);
env.rustfs.create_test_bucket(bucket).await?;
let middle = start_source_rustfs_with_odm().await?;
let middle_client = middle.create_s3_client();
middle.create_test_bucket(bucket).await?;
let middle_spec = OdmSourceSpec::for_fake_source(&env.source, fake_bucket);
let configured = configure_odm(&middle, bucket, &middle_spec).await?;
assert_eq!(configured.status, 200, "{}", configured.body);
let chained = OdmSourceSpec::for_rustfs_source(&middle, bucket);
let configured = configure_odm(&env.rustfs, bucket, &chained).await?;
assert_eq!(configured.status, 200, "{}", configured.body);
// The first hop works: an object that only the middle server holds is
// migrated to the server under test.
let present_key = "loop/present.bin";
let present_body = payload(16 * 1024);
put_object(&middle_client, bucket, present_key, present_body.clone()).await?;
let served = wait_for_get_status(&env, bucket, present_key, 200).await?;
assert_eq!(served.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(served.body, present_body, "the first hop serves the middle server's object");
// The second hop does not: this key exists only on the middle server's
// own source, and the anti-loop marker stops the chain there.
let guarded_key = "loop/chain-guard.bin";
let guarded_body = payload(8 * 1024);
env.seed_source(fake_bucket, &[SeedObject::new(guarded_key, guarded_body.clone())]);
let guarded = env.raw_get(bucket, guarded_key).await?;
assert_eq!(guarded.status, 404, "{}", String::from_utf8_lossy(&guarded.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, guarded_key),
0,
"a chained request must not reach a third source"
);
assert_eq!(env.source.count_requests(Operation::GetObject, guarded_key), 0);
// Proof that the guard, and not a broken configuration, is what stopped
// it: the same key served directly by the middle server does reach the
// fake source.
let direct = middle_client.get_object().bucket(bucket).key(guarded_key).send().await?;
assert_eq!(direct.body.collect().await?.into_bytes(), guarded_body);
assert_eq!(
env.source.count_requests(Operation::GetObject, guarded_key),
1,
"an unmarked request does consult the middle server's source"
);
// Now make the pair mutual and prove the read still terminates.
let mutual = OdmSourceSpec::for_rustfs_source(&env.rustfs, bucket);
let configured = configure_odm(&middle, bucket, &mutual).await?;
assert_eq!(configured.status, 200, "{}", configured.body);
let mutual_key = "loop/mutual.bin";
let started = Instant::now();
let response = wait_for_get_status(&env, bucket, mutual_key, 404).await?;
assert_eq!(response.header(ODM_RESPONSE_HEADER), None);
assert!(
started.elapsed() < Duration::from_secs(10),
"a mutual configuration must not loop, took {:?}",
started.elapsed()
);
assert_eq!(
env.source.count_requests(Operation::HeadObject, mutual_key),
0,
"the fake source is out of the chain once the pair is mutual"
);
Ok(())
}
@@ -856,13 +856,6 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn scanner_dirty_usage_snapshot(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ScannerDirtyUsageSnapshotRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::ScannerDirtyUsageSnapshotResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn background_heal_status(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::BackgroundHealStatusRequest>,
@@ -911,13 +904,6 @@ impl NodeService for MinimalLockNodeService {
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadTransitionTierConfigResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn tier_daily_stats(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::TierDailyStatsRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::TierDailyStatsResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
}
/// Spawn a gRPC lock server on a random port
@@ -35,8 +35,7 @@ mod tests {
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tokio::net::TcpStream;
use tokio::time::{Duration, Instant, interval, sleep, timeout};
use tokio::time::{Duration, Instant, interval};
use tracing::info;
const ENABLE_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_E2E";
@@ -49,8 +48,6 @@ mod tests {
const REPLACEMENT_INTENT_SUFFIX: &str = "_ahm_replacement_intent.json";
const REPLACEMENT_COMPLETION_PROOF_SUFFIX: &str = "_ahm_replacement_completion_proof.json";
const RESUME_CHECKPOINT_SUFFIX: &str = "_ahm_checkpoint.json";
const FAULT_WINDOW_OBJECT_COUNT: usize = 24;
const FAULT_WINDOW_OBJECT_BYTES: usize = 32 * 1024 * 1024;
#[derive(Debug)]
struct BaselineVersion {
@@ -68,12 +65,6 @@ mod tests {
CompletedWithIncomplete(BTreeSet<String>),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ReplacementScenario {
Baseline,
MidRebuildIoFault,
}
struct MountNamespaceGuard {
mounts: Vec<PathBuf>,
}
@@ -111,19 +102,6 @@ mod tests {
impl FaultableBlockMount {
fn mount(target: &Path, image_root: &Path, label: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
Self::mount_with_live_recovery(target, image_root, label, false)
}
fn mount_live_recovery(target: &Path, image_root: &Path, label: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
Self::mount_with_live_recovery(target, image_root, label, true)
}
fn mount_with_live_recovery(
target: &Path,
image_root: &Path,
label: &str,
live_recovery: bool,
) -> Result<Self, Box<dyn Error + Send + Sync>> {
fs::create_dir_all(image_root)?;
let image = image_root.join(format!("{label}.img"));
let file = fs::File::create(&image)?;
@@ -136,15 +114,7 @@ mod tests {
return Err("losetup --find --show returned an empty loop device".into());
}
if live_recovery {
// Keep the filesystem and RustFS' persistent root descriptor attached
// across the transient all-block EIO. A journaling ext4 abort requires
// an unmount to recover, which would test process/disk reattachment
// instead of live I/O recovery.
run_command("mkfs.ext4", &["-F", "-O", "^has_journal", &loop_device])?;
} else {
run_command("mkfs.ext4", &["-F", &loop_device])?;
}
run_command("mkfs.ext4", &["-F", &loop_device])?;
let sectors = run_command_stdout("blockdev", &["--getsz", &loop_device])?;
let dm_name = format!("rustfs_e2e_{label}_{}", std::process::id());
let table = format!("0 {sectors} linear {loop_device} 0");
@@ -152,11 +122,7 @@ mod tests {
run_command("dmsetup", &["create", &dm_name, "--table", &table])?;
let target_arg = path_to_string(target, "faultable mount target")?;
if live_recovery {
run_command("mount", &["-o", "errors=continue", &mapper, &target_arg])?;
} else {
run_command("mount", &[&mapper, &target_arg])?;
}
run_command("mount", &[&mapper, &target_arg])?;
Ok(Self {
target: target.to_path_buf(),
@@ -193,25 +159,18 @@ mod tests {
Ok(())
}
fn restore_linear_table(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
let sectors = run_command_stdout("blockdev", &["--getsz", &self.loop_device])?;
let linear_table = format!("0 {sectors} linear {} 0", self.loop_device);
// An ext4 journal abort can leave the mounted filesystem internally
// read-only. Avoid dmsetup's filesystem freeze/flush in that state;
// all I/O sent to the error target has already completed with EIO.
run_command("dmsetup", &["suspend", "--noflush", &self.dm_name])?;
run_command("dmsetup", &["suspend", &self.dm_name])?;
run_command("dmsetup", &["load", &self.dm_name, "--table", &linear_table])?;
run_command("dmsetup", &["resume", &self.dm_name])
}
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
self.restore_linear_table()
}
fn cleanup(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut first_error: Option<Box<dyn Error + Send + Sync>> = None;
if self.dm_created {
let _ = self.restore_linear_table();
let _ = self.restore_available();
}
if self.mounted {
if let Err(error) = detach_mount(&self.target) {
@@ -510,11 +469,7 @@ mod tests {
Ok((completed.version_id().map(str::to_owned), digest))
}
async fn seed_baseline(
client: &Client,
target_disk: &Path,
extra_object_count: usize,
) -> Result<Vec<BaselineVersion>, Box<dyn Error + Send + Sync>> {
async fn seed_baseline(client: &Client, target_disk: &Path) -> Result<Vec<BaselineVersion>, Box<dyn Error + Send + Sync>> {
let plain_bucket = "priv-replacement-plain";
let versioned_bucket = "priv-replacement-versions";
let null_bucket = "priv-replacement-null";
@@ -577,7 +532,7 @@ mod tests {
put_object_version(client, null_bucket, "null/current.bin", payload(512 * 1024, 8)).await?;
versions.push((null_bucket, "null/current.bin", version_id, Some(body_sha256)));
let mut versions = versions
let versions = versions
.into_iter()
.map(|(bucket, key, version_id, body_sha256)| {
let expected = census_object_version_on_disk(target_disk, bucket, key, version_id.as_deref())?;
@@ -593,23 +548,6 @@ mod tests {
})
})
.collect::<Result<Vec<_>, Box<dyn Error + Send + Sync>>>()?;
for index in 0..extra_object_count {
let key = format!("fault-window/object-{index:04}.bin");
let seed = u8::try_from(index + 32)?;
let (version_id, body_sha256) =
put_object_version(client, plain_bucket, &key, payload(FAULT_WINDOW_OBJECT_BYTES, seed)).await?;
let expected = census_object_version_on_disk(target_disk, plain_bucket, &key, version_id.as_deref())?;
if !expected.is_complete() {
return Err(format!("fault-window baseline census is incomplete for {plain_bucket}/{key}: {expected:?}").into());
}
versions.push(BaselineVersion {
bucket: plain_bucket.to_string(),
key,
version_id,
body_sha256: Some(body_sha256),
expected,
});
}
let inline = versions
.iter()
.find(|version| version.key == "history/inline.bin")
@@ -801,98 +739,6 @@ mod tests {
.collect()
}
fn target_record_details(
status: &serde_json::Value,
target_disk: &Path,
) -> Result<Vec<(String, String)>, Box<dyn Error + Send + Sync>> {
let target = target_disk.to_string_lossy();
let records = status["cluster"]["records"]
.as_array()
.ok_or_else(|| format!("replacement recovery status omitted cluster.records: {status}"))?;
records
.iter()
.filter(|record| {
record["targetSlots"]
.as_array()
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.any(|slot| slot.contains(target.as_ref()))
})
.map(|record| {
let task_id = record["taskId"]
.as_str()
.filter(|task_id| !task_id.is_empty())
.ok_or_else(|| format!("replacement recovery record omitted taskId: {record}"))?;
let state = record["state"]
.as_str()
.filter(|state| !state.is_empty())
.ok_or_else(|| format!("replacement recovery record omitted state: {record}"))?;
Ok((task_id.to_string(), state.to_string()))
})
.collect()
}
fn running_target_generation(
status: &serde_json::Value,
target_disk: &Path,
) -> Result<Option<String>, Box<dyn Error + Send + Sync>> {
if !cluster_status_is_definitive(status)? {
return Ok(None);
}
let records = target_record_details(status, target_disk)?;
if records.len() == 1 && records[0].1 == "running" {
return Ok(Some(records[0].0.clone()));
}
Ok(None)
}
fn assert_target_generation_nonterminal(
status: &serde_json::Value,
target_disk: &Path,
expected_task_id: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
if !cluster_status_is_definitive(status)? {
return Err(format!("replacement recovery became non-definitive during target EIO: {status}").into());
}
let records = target_record_details(status, target_disk)?;
let matching = records
.iter()
.filter(|(task_id, _)| task_id == expected_task_id)
.collect::<Vec<_>>();
if matching.len() != 1 {
return Err(format!(
"replacement generation {expected_task_id} must remain uniquely observable during target EIO: {records:?}"
)
.into());
}
match matching[0].1.as_str() {
"waiting_for_replacement" | "running" | "incomplete" => Ok(()),
state => Err(format!(
"replacement generation {expected_task_id} reached invalid state {state:?} during target EIO: {status}"
)
.into()),
}
}
fn assert_target_generation_completed(
status: &serde_json::Value,
target_disk: &Path,
expected_task_id: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
if !cluster_status_is_definitive(status)? {
return Err(format!("completed replacement recovery status is non-definitive: {status}").into());
}
let records = target_record_details(status, target_disk)?;
if records == [(expected_task_id.to_string(), "completed".to_string())] {
return Ok(());
}
Err(
format!("replacement generation {expected_task_id} did not retain its identity through EIO recovery: {records:?}")
.into(),
)
}
fn is_transient_recovery_version_absence(error: &(dyn Error + 'static)) -> bool {
matches!(
error.downcast_ref::<rustfs_filemeta::Error>(),
@@ -929,165 +775,6 @@ mod tests {
Ok(missing)
}
async fn wait_for_partial_replacement<'a>(
cluster: &RustFSTestClusterEnvironment,
target_disk: &Path,
versions: &'a [BaselineVersion],
timeout_secs: u64,
) -> Result<(usize, &'a BaselineVersion, String), Box<dyn Error + Send + Sync>> {
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
loop {
let missing = incomplete_versions(target_disk, versions)?;
let completed = versions.len().saturating_sub(missing.len());
if completed > 0 && completed < versions.len() {
let witness = versions.iter().find(|version| {
census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref())
.is_ok_and(|actual| actual.matches_manifest(&version.expected))
});
if let Some(witness) = witness {
let status = replacement_status(cluster).await?;
if let Some(task_id) = running_target_generation(&status, target_disk)? {
return Ok((completed, witness, task_id));
}
}
}
if completed == versions.len() {
return Err(format!(
"replacement rebuilt all {} baseline versions before target EIO could be injected",
versions.len()
)
.into());
}
if Instant::now() >= deadline {
let status = replacement_status(cluster).await?;
return Err(format!(
"replacement made no observable running partial progress within {timeout_secs}s: completed={completed}/{} status={status}",
versions.len()
)
.into());
}
sleep(Duration::from_millis(10)).await;
}
}
fn cluster_process_ids(cluster: &RustFSTestClusterEnvironment) -> Result<Vec<u32>, Box<dyn Error + Send + Sync>> {
cluster
.nodes
.iter()
.enumerate()
.map(|(index, node)| {
node.process
.as_ref()
.map(std::process::Child::id)
.ok_or_else(|| format!("cluster node {index} process is not running").into())
})
.collect()
}
async fn assert_cluster_processes_and_listeners_unchanged(
cluster: &mut RustFSTestClusterEnvironment,
expected_pids: &[u32],
) -> Result<(), Box<dyn Error + Send + Sync>> {
if cluster.nodes.len() != expected_pids.len() {
return Err("cluster node count changed during target EIO".into());
}
for (index, (node, expected_pid)) in cluster.nodes.iter_mut().zip(expected_pids).enumerate() {
let process = node
.process
.as_mut()
.ok_or_else(|| format!("cluster node {index} process disappeared during target EIO"))?;
if process.id() != *expected_pid {
return Err(format!(
"cluster node {index} PID changed during target EIO: expected {expected_pid}, got {}",
process.id()
)
.into());
}
if let Some(status) = process.try_wait()? {
return Err(format!("cluster node {index} exited during target EIO with {status}").into());
}
match timeout(Duration::from_secs(2), TcpStream::connect(&node.address)).await {
Ok(Ok(stream)) => drop(stream),
Ok(Err(error)) => {
return Err(format!("cluster node {index} TCP listener failed during target EIO: {error}").into());
}
Err(_) => return Err(format!("cluster node {index} TCP listener timed out during target EIO").into()),
}
}
Ok(())
}
async fn exercise_mid_rebuild_io_fault(
cluster: &mut RustFSTestClusterEnvironment,
replacement_mount: &FaultableBlockMount,
target_disk: &Path,
versions: &[BaselineVersion],
) -> Result<String, Box<dyn Error + Send + Sync>> {
let partial_timeout_secs = std::env::var("RUSTFS_HEAL_DISK_IO_PARTIAL_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(120);
let (partial_count, witness, task_id) =
wait_for_partial_replacement(cluster, target_disk, versions, partial_timeout_secs).await?;
let expected_pids = cluster_process_ids(cluster)?;
replacement_mount
.make_unavailable()
.map_err(|error| format!("failed to install dm-error on the active replacement: {error}"))?;
let fault_result = async {
replacement_mount
.verify_raw_io_is_unavailable()
.map_err(|error| format!("active replacement dm-error was not proven by direct I/O: {error}"))?;
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
let observation_deadline = Instant::now() + Duration::from_secs(2);
loop {
let status = timeout(Duration::from_secs(5), replacement_status(cluster))
.await
.map_err(|_| "replacement recovery status timed out during target EIO")??;
assert_target_generation_nonterminal(&status, target_disk, &task_id)?;
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
if Instant::now() >= observation_deadline {
break;
}
sleep(Duration::from_millis(100)).await;
}
Ok::<(), Box<dyn Error + Send + Sync>>(())
}
.await;
let restore_result = replacement_mount
.restore_available()
.map_err(|error| format!("failed to restore the active replacement after dm-error: {error}"));
if let Err(error) = fault_result {
if let Err(restore_error) = restore_result {
info!(%restore_error, "replacement restore also failed while preserving target EIO failure");
}
return Err(error);
}
restore_result?;
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
let actual = census_object_version_on_disk(target_disk, &witness.bucket, &witness.key, witness.version_id.as_deref())?;
if !actual.matches_manifest(&witness.expected) {
return Err(format!(
"witnessed replacement shard did not survive target EIO for {}/{}@{:?}: {actual:?}",
witness.bucket, witness.key, witness.version_id
)
.into());
}
let completed_after_restore = versions
.len()
.saturating_sub(incomplete_versions(target_disk, versions)?.len());
if completed_after_restore < partial_count {
return Err(format!(
"replacement progress regressed across target EIO: before={partial_count}, after={completed_after_restore}"
)
.into());
}
Ok(task_id)
}
fn replacement_completion_state(
status: &serde_json::Value,
target_disk: &Path,
@@ -1176,11 +863,7 @@ mod tests {
}
}
async fn run_replacement_e2e(
parity: usize,
test_name: &str,
scenario: ReplacementScenario,
) -> Result<(), Box<dyn Error + Send + Sync>> {
async fn run_replacement_e2e(parity: usize, test_name: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
if !privileged_run_enabled()? {
return Ok(());
@@ -1217,10 +900,7 @@ mod tests {
}
}
let mut target_mount = target_mount.ok_or("target drive was not mounted with the faultable block fixture")?;
let mut zram_replacement = match scenario {
ReplacementScenario::Baseline => Some(ZramBlockMount::reserve(&target_disk)?),
ReplacementScenario::MidRebuildIoFault => None,
};
let mut replacement_mount = ZramBlockMount::reserve(&target_disk)?;
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
cluster.set_env("RUSTFS_SCANNER_ENABLED", "true");
@@ -1228,21 +908,13 @@ mod tests {
cluster.set_env("RUSTFS_SCANNER_CYCLE", "1");
cluster.set_env("RUSTFS_SCANNER_START_DELAY_SECS", "0");
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
if scenario == ReplacementScenario::MidRebuildIoFault {
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
}
for node_index in 0..cluster.nodes.len() {
cluster.set_node_env(node_index, "RUST_LOG", "rustfs=info,rustfs::heal::manager=debug,rustfs_notify=debug")?;
}
cluster.start().await?;
let clients = cluster.create_all_clients()?;
let extra_object_count = match scenario {
ReplacementScenario::Baseline => 0,
ReplacementScenario::MidRebuildIoFault => FAULT_WINDOW_OBJECT_COUNT,
};
let versions = seed_baseline(&clients[0], &target_disk, extra_object_count)
let versions = seed_baseline(&clients[0], &target_disk)
.await
.map_err(|error| format!("pre-fault baseline seeding failed: {error}"))?;
verify_bodies(&clients[0], &versions)
@@ -1263,20 +935,7 @@ mod tests {
cluster.stop_node_gracefully(TARGET_NODE).await?;
target_mount.cleanup()?;
let mut faultable_replacement = match scenario {
ReplacementScenario::Baseline => {
zram_replacement
.as_mut()
.ok_or("baseline replacement zram was not reserved")?
.mount_target()?;
None
}
ReplacementScenario::MidRebuildIoFault => Some(FaultableBlockMount::mount_live_recovery(
&target_disk,
&image_root,
&format!("p{parity}_replacement_node{TARGET_NODE}_drive{TARGET_DRIVE}"),
)?),
};
replacement_mount.mount_target()?;
let missing_before_restart = incomplete_versions(&target_disk, &versions)?;
assert_eq!(
missing_before_restart.len(),
@@ -1286,28 +945,12 @@ mod tests {
cluster.start_node(TARGET_NODE).await?;
let recovery_result = async {
let faulted_task_id = match faultable_replacement.as_ref() {
Some(replacement) => {
Some(exercise_mid_rebuild_io_fault(&mut cluster, replacement, &target_disk, &versions).await?)
}
None => None,
};
wait_for_completed_replacement_with_census(&cluster, &target_disk, &versions, 420).await?;
if let Some(task_id) = faulted_task_id {
let status = replacement_status(&cluster).await?;
assert_target_generation_completed(&status, &target_disk, &task_id)?;
}
verify_bodies(&clients[0], &versions).await
}
.await;
let stop_result = cluster.stop_node_gracefully(TARGET_NODE).await;
let replacement_cleanup_result = match faultable_replacement.as_mut() {
Some(replacement) => replacement.cleanup(),
None => zram_replacement
.as_mut()
.ok_or("baseline replacement zram disappeared before cleanup")?
.cleanup(),
};
let replacement_cleanup_result = replacement_mount.cleanup();
if let Err(error) = recovery_result {
if let Err(stop_error) = stop_result {
@@ -1454,65 +1097,6 @@ mod tests {
assert_eq!(status_samples.borrow().len(), 1);
}
#[test]
fn target_eio_status_preserves_one_nonterminal_generation() {
let target = Path::new("/mnt/target");
for state in ["waiting_for_replacement", "running", "incomplete"] {
let status = serde_json::json!({
"cluster": {
"definitive": true,
"records": [{
"taskId": "generation-a",
"state": state,
"targetSlots": ["http://127.0.0.1:9000/mnt/target"]
}]
}
});
assert!(assert_target_generation_nonterminal(&status, target, "generation-a").is_ok());
}
let running = serde_json::json!({
"cluster": {
"definitive": true,
"records": [{
"taskId": "generation-a",
"state": "running",
"targetSlots": ["/mnt/target"]
}]
}
});
assert_eq!(running_target_generation(&running, target).unwrap().as_deref(), Some("generation-a"));
}
#[test]
fn target_eio_status_rejects_false_or_replaced_completion() {
let target = Path::new("/mnt/target");
let completed = serde_json::json!({
"cluster": {
"definitive": true,
"records": [{
"taskId": "generation-a",
"state": "completed",
"targetSlots": ["/mnt/target"]
}]
}
});
assert!(assert_target_generation_nonterminal(&completed, target, "generation-a").is_err());
assert!(assert_target_generation_completed(&completed, target, "generation-a").is_ok());
assert!(assert_target_generation_completed(&completed, target, "generation-b").is_err());
let duplicate = serde_json::json!({
"cluster": {
"definitive": true,
"records": [
{"taskId": "generation-a", "state": "running", "targetSlots": ["/mnt/target"]},
{"taskId": "generation-a", "state": "incomplete", "targetSlots": ["/mnt/target"]}
]
}
});
assert!(assert_target_generation_nonterminal(&duplicate, target, "generation-a").is_err());
}
#[test]
fn absent_status_requires_definitive_empty_records() {
let target = Path::new("/mnt/target");
@@ -1533,7 +1117,6 @@ mod tests {
run_replacement_e2e(
4,
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_rebuilds_ec8_plus_4_without_admin_heal",
ReplacementScenario::Baseline,
)
.await
}
@@ -1547,20 +1130,6 @@ mod tests {
run_replacement_e2e(
6,
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_rebuilds_ec6_plus_6_without_admin_heal",
ReplacementScenario::Baseline,
)
.await
}
/// Linux mount namespaces are per-thread; keep mount setup and process
/// spawning on one OS thread so child RustFS nodes inherit the test mounts.
#[tokio::test(flavor = "current_thread")]
#[ignore = "requires Linux root/CAP_SYS_ADMIN and RUSTFS_PRIVILEGED_REPLACEMENT_E2E=1"]
async fn test_privileged_3x4_auto_replacement_recovers_from_mid_rebuild_eio() -> Result<(), Box<dyn Error + Send + Sync>> {
run_replacement_e2e(
4,
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_recovers_from_mid_rebuild_eio",
ReplacementScenario::MidRebuildIoFault,
)
.await
}
+18 -442
View File
@@ -69,7 +69,7 @@ use std::net::IpAddr;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicU64, Ordering};
use time::{Duration as TimeDuration, OffsetDateTime};
use tokio::fs;
use tokio::net::TcpListener;
@@ -86,7 +86,7 @@ type BacklogMetricPoints = Arc<Mutex<BTreeMap<String, BTreeMap<String, (u64, f64
/// default. This suite opts its source servers into the loopback allowance explicitly
/// so the shared harness (`RustFSTestEnvironment` / the cluster harness) stays
/// fail-closed and every other e2e scenario keeps exercising the production SSRF policy.
pub(crate) const LOOPBACK_REPLICATION_TARGET_ENV: &[(&str, &str)] = &[("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true")];
const LOOPBACK_REPLICATION_TARGET_ENV: &[(&str, &str)] = &[("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true")];
/// Short data-scanner cycle for the failure-recovery tests (backlog#1147 repl-5).
///
@@ -402,14 +402,14 @@ fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String),
Ok((access_key, secret_key, session_token))
}
pub(crate) struct ReplicationTargetOptions<'a> {
pub(crate) endpoint: &'a str,
pub(crate) access_key: &'a str,
pub(crate) secret_key: &'a str,
pub(crate) target_bucket: &'a str,
pub(crate) secure: bool,
pub(crate) skip_tls_verify: bool,
pub(crate) ca_cert_pem: Option<&'a str>,
struct ReplicationTargetOptions<'a> {
endpoint: &'a str,
access_key: &'a str,
secret_key: &'a str,
target_bucket: &'a str,
secure: bool,
skip_tls_verify: bool,
ca_cert_pem: Option<&'a str>,
}
async fn set_replication_target(
@@ -434,7 +434,7 @@ async fn set_replication_target(
.await
}
pub(crate) async fn set_replication_target_with_options(
async fn set_replication_target_with_options(
source_env: &RustFSTestEnvironment,
source_bucket: &str,
options: ReplicationTargetOptions<'_>,
@@ -504,7 +504,7 @@ async fn send_set_replication_target_request(
.await
}
pub(crate) async fn put_bucket_replication(
async fn put_bucket_replication(
env: &RustFSTestEnvironment,
bucket: &str,
target_arn: &str,
@@ -643,10 +643,7 @@ async fn get_bucket_replication(
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
}
pub(crate) async fn enable_bucket_versioning(
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
async fn enable_bucket_versioning(env: &RustFSTestEnvironment, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
set_bucket_versioning(env, bucket, BucketVersioningStatus::Enabled).await
}
@@ -2008,87 +2005,6 @@ fn proxy_error_response(error: impl std::fmt::Display) -> Response<Full<bytes::B
.expect("static proxy response must be valid")
}
#[derive(Clone)]
struct ReplicationResponseHoldRuntime {
armed: Arc<AtomicBool>,
backend_committed: watch::Sender<bool>,
release: watch::Receiver<bool>,
}
impl ReplicationResponseHoldRuntime {
fn try_claim(&self) -> bool {
self.armed
.compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
}
struct ReplicationResponseHold {
armed: Arc<AtomicBool>,
backend_committed_signal: watch::Sender<bool>,
backend_committed: watch::Receiver<bool>,
release: watch::Sender<bool>,
}
impl ReplicationResponseHold {
fn arm(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
if self.armed.load(Ordering::Acquire) {
return Err("replication response hold was already armed".into());
}
self.backend_committed_signal
.send(false)
.map_err(|_| "replication response hold closed before rearming")?;
self.release
.send(false)
.map_err(|_| "replication response hold closed before rearming")?;
self.armed
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.map_err(|_| "replication response hold was already armed")?;
Ok(())
}
async fn wait_for_backend_commit(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let wait = async {
while !*self.backend_committed.borrow() {
self.backend_committed
.changed()
.await
.map_err(|_| "replication response hold closed before the backend committed")?;
}
Ok::<(), Box<dyn Error + Send + Sync>>(())
};
timeout(Duration::from_secs(60), wait)
.await
.map_err(|_| "timed out waiting for the replication backend to commit")?
}
fn release(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
self.release
.send(true)
.map_err(|_| "replication response hold closed before release")?;
Ok(())
}
}
fn replication_response_hold() -> (ReplicationResponseHoldRuntime, ReplicationResponseHold) {
let armed = Arc::new(AtomicBool::new(false));
let (backend_committed, backend_committed_rx) = watch::channel(false);
let (release, release_rx) = watch::channel(false);
(
ReplicationResponseHoldRuntime {
armed: armed.clone(),
backend_committed: backend_committed.clone(),
release: release_rx,
},
ReplicationResponseHold {
armed,
backend_committed_signal: backend_committed,
backend_committed: backend_committed_rx,
release,
},
)
}
async fn forward_replication_proxy_request(
request: Request<Incoming>,
backend_url: &str,
@@ -2096,7 +2012,6 @@ async fn forward_replication_proxy_request(
request_count: &AtomicU64,
mut replication_enabled: watch::Receiver<bool>,
mut held_tagging: watch::Receiver<Option<String>>,
mut response_hold: ReplicationResponseHoldRuntime,
) -> Response<Full<bytes::Bytes>> {
let (parts, body) = request.into_parts();
let is_replication = parts
@@ -2122,7 +2037,6 @@ async fn forward_replication_proxy_request(
}
}
}
let hold_response = is_replication && parts.method == http::Method::PUT && response_hold.try_claim();
let Some(path_and_query) = parts.uri.path_and_query() else {
return proxy_error_response("request URI omitted path");
@@ -2145,16 +2059,6 @@ async fn forward_replication_proxy_request(
Ok(body) => body,
Err(error) => return proxy_error_response(error),
};
if hold_response && status.is_success() {
if response_hold.backend_committed.send(true).is_err() {
return proxy_error_response("replication response hold closed after the backend committed");
}
while !*response_hold.release.borrow() {
if response_hold.release.changed().await.is_err() {
return proxy_error_response("replication response hold closed before release");
}
}
}
let mut proxied = Response::builder().status(status);
for (name, value) in &headers {
proxied = proxied.header(name, value);
@@ -2166,7 +2070,7 @@ async fn start_replication_counting_proxy(
backend_url: &str,
tasks: &mut JoinSet<()>,
) -> Result<(String, Arc<AtomicU64>, watch::Sender<bool>), Box<dyn Error + Send + Sync>> {
let (proxy_url, request_count, replication_enabled, _held_tagging, _response_hold) =
let (proxy_url, request_count, replication_enabled, _held_tagging) =
start_replication_counting_proxy_with_tag_hold(backend_url, tasks).await?;
Ok((proxy_url, request_count, replication_enabled))
}
@@ -2178,16 +2082,7 @@ async fn start_replication_counting_proxy(
async fn start_replication_counting_proxy_with_tag_hold(
backend_url: &str,
tasks: &mut JoinSet<()>,
) -> Result<
(
String,
Arc<AtomicU64>,
watch::Sender<bool>,
watch::Sender<Option<String>>,
ReplicationResponseHold,
),
Box<dyn Error + Send + Sync>,
> {
) -> Result<(String, Arc<AtomicU64>, watch::Sender<bool>, watch::Sender<Option<String>>), Box<dyn Error + Send + Sync>> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let proxy_url = format!("http://{}", listener.local_addr()?);
let backend_url = backend_url.to_string();
@@ -2195,7 +2090,6 @@ async fn start_replication_counting_proxy_with_tag_hold(
let task_request_count = request_count.clone();
let (replication_enabled, task_replication_enabled) = watch::channel(true);
let (held_tagging, task_held_tagging) = watch::channel(None);
let (response_hold_runtime, response_hold) = replication_response_hold();
tasks.spawn(async move {
let client = local_http_client();
let mut connections = JoinSet::new();
@@ -2208,7 +2102,6 @@ async fn start_replication_counting_proxy_with_tag_hold(
let request_count = task_request_count.clone();
let replication_enabled = task_replication_enabled.clone();
let held_tagging = task_held_tagging.clone();
let response_hold = response_hold_runtime.clone();
connections.spawn(async move {
let service = service_fn(move |request| {
let backend_url = backend_url.clone();
@@ -2216,7 +2109,6 @@ async fn start_replication_counting_proxy_with_tag_hold(
let request_count = request_count.clone();
let replication_enabled = replication_enabled.clone();
let held_tagging = held_tagging.clone();
let response_hold = response_hold.clone();
async move {
Ok::<_, Infallible>(
forward_replication_proxy_request(
@@ -2226,7 +2118,6 @@ async fn start_replication_counting_proxy_with_tag_hold(
&request_count,
replication_enabled,
held_tagging,
response_hold,
)
.await,
)
@@ -2239,7 +2130,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
}
}
});
Ok((proxy_url, request_count, replication_enabled, held_tagging, response_hold))
Ok((proxy_url, request_count, replication_enabled, held_tagging))
}
async fn site_replication_remove(
@@ -3930,12 +3821,6 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
let mut source_env = RustFSTestEnvironment::new().await?;
let mut source_env_vars = replication_fast_env();
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
// This matrix verifies request-time rule/admission behavior. Keep the
// background existing-object scanner outside the observation window: with
// ExistingObjectReplication enabled it may legitimately discover an
// object after its tags change, which is a separate data-replication path
// that PR #5696 intentionally did not alter.
source_env_vars.push(("RUSTFS_SCANNER_START_DELAY_SECS", "300"));
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
let mut target_env_a = RustFSTestEnvironment::new().await?;
@@ -4164,13 +4049,6 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
.await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
// A metadata edit must not retroactively admit data that failed the tag
// filter at PUT time. This is the PR #5696 safety boundary: the target ARN
// has no persisted data-admission state for this version, so adding the
// matching tag later remains metadata-only and fails closed.
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
source_client
.put_object()
.bucket(source_bucket)
@@ -7274,27 +7152,6 @@ async fn put_single_tag(
Ok(())
}
async fn put_single_tag_current(
client: &Client,
bucket: &str,
key: &str,
tag_key: &str,
tag_value: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
client
.put_object_tagging()
.bucket(bucket)
.key(key)
.tagging(
aws_sdk_s3::types::Tagging::builder()
.tag_set(aws_sdk_s3::types::Tag::builder().key(tag_key).value(tag_value).build()?)
.build()?,
)
.send()
.await?;
Ok(())
}
async fn get_single_tag(
client: &Client,
bucket: &str,
@@ -7342,28 +7199,6 @@ async fn wait_for_single_tag(
}
}
/// Poll one site until `tag_key` is absent from the selected version.
async fn wait_for_tag_absent(
client: &Client,
bucket: &str,
key: &str,
version_id: &str,
tag_key: &str,
site: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
loop {
let observed = get_single_tag(client, bucket, key, version_id, tag_key).await?;
if observed.is_none() {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(format!("{site}: {bucket}/{key}?versionId={version_id} tag {tag_key} remained {observed:?}").into());
}
sleep(Duration::from_millis(200)).await;
}
}
/// Tag key the dual-node LWW scenario edits on both sites.
const LWW_TAG_KEY: &str = "owner";
@@ -7411,265 +7246,6 @@ async fn wait_for_proxy_replication_requests(
}
}
/// rustfs/backlog#2099: metadata admission must not drop a tag edit made after
/// the target has committed the initial object but before the source persists
/// that replication as COMPLETED.
#[tokio::test]
async fn test_site_replication_tagging_during_initial_pending_window_converges() -> TestResult {
init_logging();
// `RustFSTestEnvironment::start_rustfs_server_with_env` resolves (and on a
// cold checkout builds) this binary synchronously. Keep that setup outside
// the scenario timeout so 180 seconds measures the runtime race rather
// than compilation latency.
let _rustfs_binary = rustfs_binary_path();
match timeout(Duration::from_secs(180), async {
const PAYLOAD: &str = "tagging during pending replication";
const TAG_KEY: &str = "window";
const TAG_VALUE: &str = "pending";
const DELETE_PAYLOAD: &str = "tag deletion during pending replication";
const DELETE_TAG_KEY: &str = "remove";
const DELETE_TAG_VALUE: &str = "while-pending";
let mut site_env = replication_fast_env();
site_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
let mut site_a_env = RustFSTestEnvironment::new().await?;
site_a_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let mut site_b_env = RustFSTestEnvironment::new().await?;
site_b_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let site_a_client = site_a_env.create_s3_client();
let site_b_client = site_b_env.create_s3_client();
let mut proxy_tasks = JoinSet::new();
let (
site_b_proxy,
_site_b_replication_requests,
_site_b_replication_enabled,
_site_b_held_tagging,
mut site_b_response_hold,
) = start_replication_counting_proxy_with_tag_hold(&site_b_env.url, &mut proxy_tasks).await?;
let add_status = site_replication_add(
&site_a_env,
&[
PeerSite {
name: "pending-site-a".to_string(),
endpoint: site_a_env.url.clone(),
access_key: site_a_env.access_key.clone(),
secret_key: site_a_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "pending-site-b".to_string(),
endpoint: site_b_env.url.clone(),
access_key: site_b_env.access_key.clone(),
secret_key: site_b_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {add_status:?}");
let site_info = wait_for_site_replication_enabled(&site_a_env, 2).await?;
wait_for_site_replication_enabled(&site_b_env, 2).await?;
let mut site_b_peer = site_info
.sites
.iter()
.find(|peer| peer.endpoint == site_b_env.url.as_str())
.ok_or("site B peer missing from replication info")?
.clone();
site_b_peer.endpoint = site_b_proxy.clone();
site_b_peer.sync_state = SyncStatus::Enable;
let edit = site_replication_edit(&site_a_env, "", &site_b_peer).await?;
assert!(edit.success, "unexpected site B endpoint edit: {edit:?}");
for env in [&site_a_env, &site_b_env] {
wait_for_site_replication_info(env, |info| info.sites.iter().any(|peer| peer.endpoint == site_b_proxy)).await?;
}
let bucket = "site-repl-tag-pending";
let key = "pending-window.txt";
site_a_client.create_bucket().bucket(bucket).send().await?;
wait_for_bucket_on_target(&site_b_client, bucket).await?;
site_b_response_hold.arm()?;
let put_task = {
let client = site_a_client.clone();
let bucket = bucket.to_string();
let key = key.to_string();
tokio::spawn(async move {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(PAYLOAD.as_bytes()))
.send()
.await
})
};
// The proxy reads B's complete successful response before parking it,
// so B's GET proves the object is committed while A's worker is still
// unable to persist COMPLETED.
site_b_response_hold.wait_for_backend_commit().await?;
wait_for_replicated_object(&site_b_client, bucket, key, PAYLOAD).await?;
let source_head = site_a_client.head_object().bucket(bucket).key(key).send().await?;
let version_id = source_head
.version_id()
.ok_or("source HEAD omitted the pending version ID")?
.to_string();
assert_eq!(
source_head.replication_status().map(|status| status.as_str()),
Some("PENDING"),
"source must still report PENDING while the initial replication response is held"
);
let tag_task = {
let client = site_a_client.clone();
let bucket = bucket.to_string();
let key = key.to_string();
// The original real-machine failure used the current-version S3
// API (no versionId). The delete phase below deliberately keeps an
// explicit versionId so both request shapes stay covered.
tokio::spawn(async move { put_single_tag_current(&client, &bucket, &key, TAG_KEY, TAG_VALUE).await })
};
wait_for_single_tag(&site_a_client, bucket, key, &version_id, TAG_KEY, TAG_VALUE, "site A").await?;
assert_eq!(
head_replication_status(&site_a_client, bucket, key, &version_id)
.await?
.as_deref(),
Some("PENDING"),
"tag update must be authored before the initial replication reaches COMPLETED"
);
site_b_response_hold.release()?;
let put_output = timeout(Duration::from_secs(60), put_task)
.await
.map_err(|_| "source PutObject remained blocked after releasing the replication response")???;
timeout(Duration::from_secs(60), tag_task)
.await
.map_err(|_| "PutObjectTagging remained blocked after releasing the replication response")???;
assert_eq!(put_output.version_id(), Some(version_id.as_str()));
wait_for_single_tag(&site_b_client, bucket, key, &version_id, TAG_KEY, TAG_VALUE, "site B").await?;
wait_for_version_replication_status(&site_a_client, bucket, key, &version_id, &["COMPLETED"], "site A").await?;
wait_for_version_replication_status(&site_b_client, bucket, key, &version_id, &["REPLICA"], "site B").await?;
let source_state = list_replication_state(&site_a_client, bucket).await?;
let target_state = list_replication_state(&site_b_client, bucket).await?;
assert_eq!(source_state, target_state, "tag replication must not fork the object version");
assert_eq!(source_state.len(), 1, "tag replication must leave exactly one object version");
assert_eq!(source_state[0].key, key);
assert_eq!(source_state[0].version_id, version_id);
// Re-arm the same response barrier for an initially tagged object.
// Deleting its tag while A is still PENDING proves the same admission
// rule covers DeleteObjectTagging rather than only the original PUT
// symptom.
let delete_key = "pending-delete-window.txt";
site_b_response_hold.arm()?;
let delete_put_task = {
let client = site_a_client.clone();
let bucket = bucket.to_string();
let key = delete_key.to_string();
tokio::spawn(async move {
client
.put_object()
.bucket(bucket)
.key(key)
.tagging(format!("{DELETE_TAG_KEY}={DELETE_TAG_VALUE}"))
.body(ByteStream::from_static(DELETE_PAYLOAD.as_bytes()))
.send()
.await
})
};
site_b_response_hold.wait_for_backend_commit().await?;
wait_for_replicated_object(&site_b_client, bucket, delete_key, DELETE_PAYLOAD).await?;
let delete_source_head = site_a_client.head_object().bucket(bucket).key(delete_key).send().await?;
let delete_version_id = delete_source_head
.version_id()
.ok_or("source HEAD omitted the pending tagged version ID")?
.to_string();
assert_eq!(
delete_source_head.replication_status().map(|status| status.as_str()),
Some("PENDING"),
"source tagged object must remain PENDING while its initial response is held"
);
wait_for_single_tag(
&site_b_client,
bucket,
delete_key,
&delete_version_id,
DELETE_TAG_KEY,
DELETE_TAG_VALUE,
"site B",
)
.await?;
let delete_tag_task = {
let client = site_a_client.clone();
let bucket = bucket.to_string();
let key = delete_key.to_string();
let version_id = delete_version_id.clone();
tokio::spawn(async move {
client
.delete_object_tagging()
.bucket(bucket)
.key(key)
.version_id(version_id)
.send()
.await
})
};
wait_for_tag_absent(&site_a_client, bucket, delete_key, &delete_version_id, DELETE_TAG_KEY, "site A").await?;
assert_eq!(
head_replication_status(&site_a_client, bucket, delete_key, &delete_version_id)
.await?
.as_deref(),
Some("PENDING"),
"tag deletion must be authored before the initial replication reaches COMPLETED"
);
site_b_response_hold.release()?;
let delete_put_output = timeout(Duration::from_secs(60), delete_put_task)
.await
.map_err(|_| "source tagged PutObject remained blocked after releasing the replication response")???;
timeout(Duration::from_secs(60), delete_tag_task)
.await
.map_err(|_| "DeleteObjectTagging remained blocked after releasing the replication response")???;
assert_eq!(delete_put_output.version_id(), Some(delete_version_id.as_str()));
wait_for_tag_absent(&site_b_client, bucket, delete_key, &delete_version_id, DELETE_TAG_KEY, "site B").await?;
wait_for_version_replication_status(&site_a_client, bucket, delete_key, &delete_version_id, &["COMPLETED"], "site A")
.await?;
wait_for_version_replication_status(&site_b_client, bucket, delete_key, &delete_version_id, &["REPLICA"], "site B")
.await?;
let source_state = list_replication_state(&site_a_client, bucket).await?;
let target_state = list_replication_state(&site_b_client, bucket).await?;
assert_eq!(source_state, target_state, "tag deletion must not fork the object version");
assert_eq!(source_state.len(), 2, "pending-window scenarios must leave exactly two object versions");
assert!(
source_state
.iter()
.any(|entry| entry.key == delete_key && entry.version_id == delete_version_id),
"tag deletion must preserve the original version identity"
);
proxy_tasks.abort_all();
Ok(())
})
.await
{
Ok(result) => result,
Err(_) => Err("pending-window site-replication tagging test timed out".into()),
}
}
/// rustfs/backlog#1953 (audit A4/P1-6): receiver-side LWW for replicated
/// metadata categories, exercised end to end over the real dual-node
/// active-active site-replication control plane — sender, worker, status
@@ -7706,9 +7282,9 @@ async fn test_site_replication_tagging_lww_converges_active_active_real_dual_nod
site_b_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let mut proxy_tasks = JoinSet::new();
let (site_a_proxy, site_a_replication_requests, _site_a_replication_enabled, site_a_held_tagging, _site_a_response_hold) =
let (site_a_proxy, site_a_replication_requests, _site_a_replication_enabled, site_a_held_tagging) =
start_replication_counting_proxy_with_tag_hold(&site_a_env.url, &mut proxy_tasks).await?;
let (site_b_proxy, site_b_replication_requests, _site_b_replication_enabled, site_b_held_tagging, _site_b_response_hold) =
let (site_b_proxy, site_b_replication_requests, _site_b_replication_enabled, site_b_held_tagging) =
start_replication_counting_proxy_with_tag_hold(&site_b_env.url, &mut proxy_tasks).await?;
let site_a_client = site_a_env.create_s3_client();
@@ -1,519 +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.
//! Outbound target matrix: every object shape RustFS replicates, against
//! every remote-target failure mode the fake target models.
//!
//! The matrix exists because a fix for one target class shipped a regression
//! for another (rustfs#6895 fixed rustfs#6853 and caused rustfs#7082; see
//! `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`).
//! Each row is one target mode with its own RustFS source and fake target;
//! each cell is one object shape. [`expectation`] is the single place that
//! says what a cell must do today:
//!
//! - `Completed` cells must replicate and the target must hold the source
//! bytes; the journal must also show the wire shape the cell relies on.
//! - `KnownFailing` cells pin an open issue. They must fail for the recorded
//! reason, and the moment they start passing the test fails with an XPASS
//! message so the expectation is flipped in the same PR as the fix.
//!
//! Adding a target behavior the fleet has shown: add the mode to the fake
//! target, add a row here, and record any cell that is red before the fix.
use crate::common::{RustFSTestEnvironment, init_logging, replication_fast_env};
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY};
use crate::fake_s3_target::{FakeS3Target, Operation as FakeTargetOperation, RequestRecord};
use crate::on_demand_migration::common::fake_source_client;
use crate::replication_extension_test::{
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, enable_bucket_versioning, put_bucket_replication,
set_replication_target_with_options,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::{ByteStream, DateTime};
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockMode};
use bytes::Bytes;
use std::error::Error;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::time::{Duration, sleep, timeout};
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
/// A remote-target behavior the fleet has shown, as the fake target models it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TargetMode {
/// RustFS / MinIO-like target: adopts source version ids, decodes any
/// framing, enforces no checksum rule.
Baseline,
/// SeaweedFS 3.97 (rustfs#6853): refuses `aws-chunked` bodies. A sender
/// that frames its uploads gets a hard failure here instead of a
/// silently corrupted replica.
RejectAwsChunked,
/// AWS S3 / MinIO / Impossible Cloud (rustfs#7082): a PutObject with
/// Object Lock parameters must carry `Content-MD5` or `x-amz-checksum-*`.
RequireChecksumWithObjectLock,
/// AWS S3 / Wasabi / Impossible Cloud: mints its own version ids
/// (rustfs/backlog#2085). Data must still land.
MintOwnVersionIds,
}
impl TargetMode {
const ALL: [TargetMode; 4] = [
TargetMode::Baseline,
TargetMode::RejectAwsChunked,
TargetMode::RequireChecksumWithObjectLock,
TargetMode::MintOwnVersionIds,
];
fn apply(self, target: &FakeS3Target) {
match self {
TargetMode::Baseline => {}
TargetMode::RejectAwsChunked => target.reject_aws_chunked_uploads(true),
TargetMode::RequireChecksumWithObjectLock => target.require_checksum_for_object_lock(true),
TargetMode::MintOwnVersionIds => target.assign_own_version_ids(true),
}
}
fn slug(self) -> &'static str {
match self {
TargetMode::Baseline => "baseline",
TargetMode::RejectAwsChunked => "reject-aws-chunked",
TargetMode::RequireChecksumWithObjectLock => "require-checksum-object-lock",
TargetMode::MintOwnVersionIds => "mint-own-version-ids",
}
}
}
/// An object shape the replication transport treats differently.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ObjectShape {
/// The exact rustfs#7082 reproduction: a zero-byte object.
Empty,
/// Small single-part object with no Object Lock parameters.
Plain,
/// Single-part object with a GOVERNANCE retention period.
Retention,
/// Single-part object with legal hold ON.
LegalHold,
/// Two-part multipart upload, no Object Lock parameters.
Multipart,
/// Two-part multipart upload with a GOVERNANCE retention period; the
/// lock headers travel on CreateMultipartUpload, which has no body.
LockedMultipart,
}
impl ObjectShape {
const ALL: [ObjectShape; 6] = [
ObjectShape::Empty,
ObjectShape::Plain,
ObjectShape::Retention,
ObjectShape::LegalHold,
ObjectShape::Multipart,
ObjectShape::LockedMultipart,
];
fn key(self) -> &'static str {
match self {
ObjectShape::Empty => "matrix/empty.bin",
ObjectShape::Plain => "matrix/plain.bin",
ObjectShape::Retention => "matrix/retention.bin",
ObjectShape::LegalHold => "matrix/legal-hold.bin",
ObjectShape::Multipart => "matrix/multipart.bin",
ObjectShape::LockedMultipart => "matrix/locked-multipart.bin",
}
}
fn carries_object_lock_params(self) -> bool {
matches!(self, ObjectShape::Retention | ObjectShape::LegalHold | ObjectShape::LockedMultipart)
}
/// Upload the shape to the source and return the bytes the target must
/// end up holding.
async fn put(self, client: &Client, bucket: &str) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
let key = self.key();
match self {
ObjectShape::Empty => {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b""))
.send()
.await?;
Ok(Bytes::new())
}
ObjectShape::Plain => {
let body = payload(64 * 1024, 0x11);
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.send()
.await?;
Ok(body)
}
ObjectShape::Retention => {
let body = payload(48 * 1024, 0x22);
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.object_lock_mode(ObjectLockMode::Governance)
.object_lock_retain_until_date(retain_until())
.send()
.await?;
Ok(body)
}
ObjectShape::LegalHold => {
let body = payload(32 * 1024, 0x33);
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
.send()
.await?;
Ok(body)
}
ObjectShape::Multipart => multipart_put(client, bucket, key, 0x44, false).await,
ObjectShape::LockedMultipart => multipart_put(client, bucket, key, 0x55, true).await,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Expectation {
/// Replicates COMPLETED and the target holds the source bytes.
Completed,
/// Replicates FAILED today for a recorded reason; pinned to an open issue.
KnownFailing(&'static str),
}
/// The cells that are red today, each pinned to the open issue that owns it.
/// This is the single source of truth: a fix that turns a cell green must
/// remove its entry in the same PR, and [`check_known_failing_cell`] refuses
/// an unexpected pass so the table cannot go stale silently. rustfs#7082
/// (Retention and LegalHold against the checksum-requiring target) lived
/// here until the replication PUT started carrying a Content-MD5 derived
/// from the source ETag.
const KNOWN_FAILING_CELLS: &[(TargetMode, ObjectShape, &str)] = &[];
fn expectation(mode: TargetMode, shape: ObjectShape) -> Expectation {
KNOWN_FAILING_CELLS
.iter()
.find(|(known_mode, known_shape, _)| *known_mode == mode && *known_shape == shape)
.map(|(_, _, issue)| Expectation::KnownFailing(issue))
.unwrap_or(Expectation::Completed)
}
#[tokio::test]
async fn matrix_baseline_target() -> TestResult {
run_row(TargetMode::Baseline).await
}
#[tokio::test]
async fn matrix_reject_aws_chunked_target() -> TestResult {
run_row(TargetMode::RejectAwsChunked).await
}
#[tokio::test]
async fn matrix_require_checksum_with_object_lock_target() -> TestResult {
run_row(TargetMode::RequireChecksumWithObjectLock).await
}
#[tokio::test]
async fn matrix_mint_own_version_ids_target() -> TestResult {
run_row(TargetMode::MintOwnVersionIds).await
}
/// Every known-red entry must name a real cell and an issue, and the lookup
/// must round-trip, so a stale or mistyped entry cannot silently pin nothing.
#[test]
fn known_failing_table_names_real_cells() {
for (mode, shape, issue) in KNOWN_FAILING_CELLS {
assert!(
TargetMode::ALL.contains(mode) && ObjectShape::ALL.contains(shape),
"{mode:?}/{shape:?} is not a matrix cell"
);
assert!(
issue.starts_with("rustfs#") || issue.starts_with("rustfs/backlog#"),
"{issue} must name an open issue"
);
assert_eq!(expectation(*mode, *shape), Expectation::KnownFailing(issue));
}
let red_cells = TargetMode::ALL
.iter()
.flat_map(|mode| ObjectShape::ALL.iter().map(move |shape| (*mode, *shape)))
.filter(|(mode, shape)| matches!(expectation(*mode, *shape), Expectation::KnownFailing(_)))
.count();
assert_eq!(red_cells, KNOWN_FAILING_CELLS.len());
}
async fn run_row(mode: TargetMode) -> TestResult {
init_logging();
let target = FakeS3Target::start().await?;
let target_bucket = format!("matrix-{}-dst", mode.slug());
target.create_bucket_with_object_lock(target_bucket.clone());
mode.apply(&target);
let mut source_env = RustFSTestEnvironment::new().await?;
let mut env_vars = replication_fast_env();
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
env_vars.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
source_env.start_rustfs_server_with_env(vec![], &env_vars).await?;
let source_bucket = format!("matrix-{}-src", mode.slug());
let source_client = source_env.create_s3_client();
source_client
.create_bucket()
.bucket(&source_bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
enable_bucket_versioning(&source_env, &source_bucket).await?;
let target_arn = set_replication_target_with_options(
&source_env,
&source_bucket,
ReplicationTargetOptions {
endpoint: &target.address(),
access_key: FAKE_ACCESS_KEY,
secret_key: FAKE_SECRET_KEY,
target_bucket: &target_bucket,
secure: false,
skip_tls_verify: false,
ca_cert_pem: None,
},
)
.await?;
put_bucket_replication(&source_env, &source_bucket, &target_arn).await?;
let target_client = fake_source_client(&target);
let mut failures = Vec::new();
for shape in ObjectShape::ALL {
let cell = format!("{}/{:?}", mode.slug(), shape);
let expected_body = shape.put(&source_client, &source_bucket).await?;
let status = wait_for_terminal_replication_status(&source_client, &source_bucket, shape.key()).await?;
let journal = target.requests();
let outcome = match expectation(mode, shape) {
Expectation::Completed => {
check_completed_cell(&cell, &status, &target_client, &target_bucket, shape, &expected_body, &journal).await
}
Expectation::KnownFailing(issue) => check_known_failing_cell(&cell, issue, &status, shape, &journal),
};
if let Err(err) = outcome {
failures.push(format!("{cell}: {err}"));
}
}
target.shutdown().await;
if failures.is_empty() {
Ok(())
} else {
Err(format!(
"{} matrix cell(s) violated their expectation:\n {}",
failures.len(),
failures.join("\n ")
)
.into())
}
}
async fn check_completed_cell(
cell: &str,
status: &str,
target_client: &Client,
target_bucket: &str,
shape: ObjectShape,
expected_body: &Bytes,
journal: &[RequestRecord],
) -> TestResult {
if status != "COMPLETED" {
return Err(format!("expected COMPLETED, source reports {status}").into());
}
let stored = target_client
.get_object()
.bucket(target_bucket)
.key(shape.key())
.send()
.await
.map_err(|err| format!("target GET failed after COMPLETED: {err}"))?
.body
.collect()
.await?
.into_bytes();
if stored != *expected_body {
return Err(format!(
"target holds {} bytes that differ from the {} source bytes (COMPLETED over a corrupted replica)",
stored.len(),
expected_body.len()
)
.into());
}
// The wire shape the cell relies on: plain signed payloads (rustfs#6853)
// for every upload of this key, and the lock headers present exactly when
// the shape carries them.
let uploads: Vec<&RequestRecord> = journal
.iter()
.filter(|record| {
record.key.as_deref() == Some(shape.key())
&& matches!(
record.operation,
FakeTargetOperation::PutObject | FakeTargetOperation::UploadPart | FakeTargetOperation::CreateMultipartUpload
)
})
.collect();
if uploads.is_empty() {
return Err("no upload reached the target although the source reports COMPLETED".into());
}
if let Some(framed) = uploads.iter().find(|record| record.transport.aws_chunked) {
return Err(format!("{cell}: an upload went out aws-chunked (rustfs#6853 framing): {framed:?}").into());
}
let lock_headers_seen = uploads.iter().any(|record| record.transport.object_lock_params);
if lock_headers_seen != shape.carries_object_lock_params() {
return Err(format!(
"object lock headers on the wire: {lock_headers_seen}, shape carries them: {}",
shape.carries_object_lock_params()
)
.into());
}
// rustfs#7082 contract: every PutObject that carries Object Lock
// parameters also carries Content-MD5 or an x-amz-checksum-* header,
// whatever the target's own policy is.
if let Some(bare) = uploads.iter().find(|record| {
record.operation == FakeTargetOperation::PutObject
&& record.transport.object_lock_params
&& record.transport.content_md5.is_none()
&& record.transport.checksum_headers.is_empty()
}) {
return Err(format!("a locked PutObject went out without any integrity header (rustfs#7082): {bare:?}").into());
}
Ok(())
}
fn check_known_failing_cell(cell: &str, issue: &str, status: &str, shape: ObjectShape, journal: &[RequestRecord]) -> TestResult {
if status == "COMPLETED" {
return Err(format!(
"XPASS: {cell} reached COMPLETED but the expectation table pins it to {issue}; \
the fix landed, so flip this cell to Expectation::Completed in the same PR"
)
.into());
}
if status != "FAILED" {
return Err(format!("expected FAILED ({issue}), source reports {status}").into());
}
// Fail for the recorded reason, not by accident: the PUT carried the lock
// headers and no integrity header at all.
let rejected = journal.iter().any(|record| {
record.operation == FakeTargetOperation::PutObject
&& record.key.as_deref() == Some(shape.key())
&& record.transport.object_lock_params
&& record.transport.content_md5.is_none()
&& record.transport.checksum_headers.is_empty()
});
if !rejected {
return Err(format!(
"FAILED, but not for the {issue} reason (a locked PUT without Content-MD5 / x-amz-checksum-*); journal: {journal:?}"
)
.into());
}
Ok(())
}
/// First terminal replication status (`COMPLETED` or `FAILED`) the source
/// reports for the key.
async fn wait_for_terminal_replication_status(
client: &Client,
bucket: &str,
key: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let wait = async {
loop {
let head = client.head_object().bucket(bucket).key(key).send().await?;
match head.replication_status().map(|status| status.as_str().to_string()) {
Some(status) if status == "COMPLETED" || status == "FAILED" => return Ok(status),
_ => sleep(Duration::from_millis(200)).await,
}
}
};
match timeout(Duration::from_secs(90), wait).await {
Ok(result) => result,
Err(_) => Err(format!("{key} reached no terminal replication status within 90 seconds").into()),
}
}
async fn multipart_put(
client: &Client,
bucket: &str,
key: &str,
fill: u8,
locked: bool,
) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
let part_one = payload(5 * 1024 * 1024, fill);
let part_two = payload(256 * 1024, fill.wrapping_add(1));
let mut create = client.create_multipart_upload().bucket(bucket).key(key);
if locked {
create = create
.object_lock_mode(ObjectLockMode::Governance)
.object_lock_retain_until_date(retain_until());
}
let upload_id = create
.send()
.await?
.upload_id()
.ok_or("CreateMultipartUpload returned no upload id")?
.to_string();
let mut completed = Vec::new();
for (number, part) in [(1, &part_one), (2, &part_two)] {
let etag = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(number)
.body(ByteStream::from(part.clone()))
.send()
.await?
.e_tag()
.ok_or("UploadPart returned no ETag")?
.to_string();
completed.push(CompletedPart::builder().part_number(number).e_tag(etag).build());
}
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
.send()
.await?;
let mut body = Vec::with_capacity(part_one.len() + part_two.len());
body.extend_from_slice(&part_one);
body.extend_from_slice(&part_two);
Ok(Bytes::from(body))
}
fn payload(len: usize, fill: u8) -> Bytes {
Bytes::from((0..len).map(|i| fill.wrapping_add((i % 251) as u8)).collect::<Vec<u8>>())
}
fn retain_until() -> DateTime {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after epoch")
.as_secs();
DateTime::from_secs(now as i64 + 86_400)
}
@@ -1,144 +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.
//! Two-node gate for the tier stats wire contract (rustfs/backlog#2207).
//!
//! Before this contract, `GET /v3/tier-stats` answered from the process that
//! happened to receive the request, so the same query returned different
//! numbers depending on which node a client reached, with nothing in the body
//! saying so. These tests pin the two properties that fix costs: the answer is
//! node-independent, and it states how much of the cluster it covers.
use crate::common::{RustFSTestClusterEnvironment, admin_request, init_logging};
use http::Method;
use http::StatusCode;
use serde_json::Value;
use std::time::Duration;
use tokio::time::{Instant, sleep};
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
const TIER_STATS_PATH: &str = "/rustfs/admin/v3/tier-stats";
const PEER_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
async fn tier_stats(cluster: &RustFSTestClusterEnvironment, node: usize, query: &str) -> TestResult<(StatusCode, String)> {
admin_request(
&cluster.nodes[node].url,
Method::GET,
&format!("{TIER_STATS_PATH}{query}"),
None,
&cluster.access_key,
&cluster.secret_key,
)
.await
}
async fn tier_stats_json(cluster: &RustFSTestClusterEnvironment, node: usize) -> TestResult<Value> {
let (status, body) = tier_stats(cluster, node, "").await?;
assert_eq!(status, StatusCode::OK, "node {node} must answer tier-stats: {body}");
Ok(serde_json::from_str(&body)?)
}
/// Wait until every node reports the whole cluster.
///
/// Peer clients are established during startup, so a query issued in the first
/// moments can legitimately see a peer as unavailable. Polling separates that
/// startup window from the failure this test is about: an answer that stays
/// partial because the peer never reports at all.
async fn wait_for_complete_activity(cluster: &RustFSTestClusterEnvironment) -> TestResult<Vec<Value>> {
let deadline = Instant::now() + PEER_CONVERGENCE_TIMEOUT;
loop {
let mut bodies = Vec::with_capacity(cluster.nodes.len());
for node in 0..cluster.nodes.len() {
bodies.push(tier_stats_json(cluster, node).await?);
}
if bodies.iter().all(|body| body["activity"]["status"] == "complete") {
return Ok(bodies);
}
if Instant::now() >= deadline {
return Err(format!("tier-stats activity never became complete on every node: {bodies:?}").into());
}
sleep(Duration::from_millis(500)).await;
}
}
#[tokio::test]
async fn tier_stats_answers_the_same_cluster_result_from_either_node() -> TestResult {
init_logging();
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
cluster.start().await?;
let bodies = wait_for_complete_activity(&cluster).await?;
let (first, second) = (&bodies[0], &bodies[1]);
for (node, body) in bodies.iter().enumerate() {
assert_eq!(body["contractVersion"], 2, "node {node} must name the current contract version");
assert_eq!(
body["activity"]["nodesExpected"], 2,
"node {node} must expect both cluster members, not only itself"
);
assert_eq!(
body["activity"]["nodesReporting"], 2,
"node {node} must include its peer's rolling window in the sum"
);
assert_eq!(
body["activity"]["unavailableNodes"],
serde_json::json!([]),
"node {node} reported a complete result while naming an unavailable member"
);
}
// A fixture cluster has no remote tier, so this pair is equal over an
// empty list; `nodesReporting` above is what proves the peer answered.
// The per-tier summing itself is pinned by the aggregator unit tests.
assert_eq!(
first["tiers"], second["tiers"],
"the same query must not depend on which node received it"
);
assert_eq!(
first["inventory"]["status"], second["inventory"]["status"],
"both nodes read the same persisted usage snapshot"
);
cluster.stop();
Ok(())
}
#[tokio::test]
async fn tier_stats_keeps_the_legacy_body_reachable_and_rejects_unknown_formats() -> TestResult {
init_logging();
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
cluster.start().await?;
let (status, body) = tier_stats(&cluster, 0, "?format=legacy").await?;
assert_eq!(status, StatusCode::OK, "the pinned legacy body must stay reachable: {body}");
let legacy: Value = serde_json::from_str(&body)?;
assert!(
legacy.is_object() && legacy.get("contractVersion").is_none(),
"the legacy body is the bare tier map, not the current envelope: {legacy}"
);
let (status, body) = tier_stats(&cluster, 0, "?format=v3").await?;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"an unknown format must be rejected rather than answered in another shape: {body}"
);
cluster.stop();
Ok(())
}
+4 -4
View File
@@ -130,7 +130,7 @@ rustfs-concurrency.workspace = true
rustfs-credentials = { workspace = true }
rustfs-common.workspace = true
rustfs-heal-contracts.workspace = true
rustfs-scanner-metrics.workspace = true
rustfs-scanner-contracts.workspace = true
rustfs-policy.workspace = true
rustfs-protos.workspace = true
rustfs-replication.workspace = true
@@ -163,10 +163,10 @@ http-body = { workspace = true }
http-body-util.workspace = true
url.workspace = true
uuid = { workspace = true, features = ["v4", "fast-rng", "serde", "macro-diagnostics"] }
rustfs-erasure-codec = { workspace = true, features = ["simd-accel"] }
reed-solomon-erasure = { workspace = true, features = ["simd-accel"] }
reed-solomon-simd = { workspace = true }
lazy_static.workspace = true
moka = { workspace = true, features = ["future", "sync"] }
moka = { workspace = true, features = ["future"] }
rustfs-lock.workspace = true
rustfs-io-metrics.workspace = true
regex = { workspace = true }
@@ -185,7 +185,7 @@ hyper-rustls = { workspace = true, default-features = false, features = ["native
hostname.workspace = true
rustls = { workspace = true, default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
rustls-pki-types.workspace = true
tokio = { workspace = true, features = ["io-util", "sync", "signal", "fs", "rt-multi-thread", "time"] }
tokio = { workspace = true, features = ["io-util", "sync", "signal", "fs", "rt-multi-thread"] }
tonic = { workspace = true, features = ["gzip", "deflate"] }
xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] }
tower = { workspace = true, features = ["timeout"] }
+4 -4
View File
@@ -122,10 +122,10 @@ fn bench_encode_performance(c: &mut Criterion) {
});
group.finish();
// Test direct rustfs-erasure-codec implementation for large shards (>= 512 bytes)
// Test direct reed-solomon-erasure implementation for large shards (>= 512 bytes)
let shard_size = calc_shard_size(config.data_size, config.data_shards);
if shard_size >= 512 && config.parity_shards > 0 {
use rustfs_erasure_codec::galois_8::ReedSolomon;
use reed_solomon_erasure::galois_8::ReedSolomon;
let mut rse_group = c.benchmark_group("encode_rse_direct");
rse_group.throughput(Throughput::Bytes(config.data_size as u64));
@@ -204,10 +204,10 @@ fn bench_decode_performance(c: &mut Criterion) {
);
group.finish();
// Test direct rustfs-erasure-codec decoding for large shards
// Test direct reed-solomon-erasure decoding for large shards
let shard_size = calc_shard_size(config.data_size, config.data_shards);
if shard_size >= 512 && config.parity_shards > 0 {
use rustfs_erasure_codec::galois_8::ReedSolomon;
use reed_solomon_erasure::galois_8::ReedSolomon;
if let Ok(rs) = ReedSolomon::new(config.data_shards, config.parity_shards) {
let mut rse_group = c.benchmark_group("decode_rse_direct");
+13 -74
View File
@@ -49,7 +49,7 @@ pub mod bucket {
apply_transition_rule, enqueue_expiry_for_existing_objects, enqueue_transition_for_existing_objects,
enqueue_transition_for_existing_objects_scoped, enqueue_transition_for_existing_objects_scoped_with_cancel,
enqueue_transition_immediate, expire_transitioned_object, get_global_expiry_state, get_global_transition_state,
init_background_expiry, lifecycle_version_delete_target, manual_transition_queue_snapshot, post_restore_opts,
init_background_expiry, manual_transition_queue_snapshot, post_restore_opts,
run_stale_multipart_upload_cleanup_once, validate_transition_tier,
};
}
@@ -78,7 +78,6 @@ pub mod bucket {
#[cfg(feature = "test-util")]
pub use crate::bucket::lifecycle::transition_transaction::{
TransitionTransactionRecoveryStats, recover_transition_transaction_records,
recover_transition_transaction_records_at,
};
}
@@ -89,9 +88,8 @@ pub mod bucket {
#[allow(clippy::module_inception)]
pub mod lifecycle {
pub use crate::bucket::lifecycle::lifecycle::{
Event, ExpirationOptions, IlmAction, LIFECYCLE_MALFORMED_XML_ERROR_KIND, Lifecycle, LifecycleCalculate,
ObjectOpts, RuleValidate, TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, expected_expiry_time,
object_opts_from_object_info,
Event, ExpirationOptions, IlmAction, Lifecycle, LifecycleCalculate, ObjectOpts, RuleValidate,
TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, expected_expiry_time, object_opts_from_object_info,
};
}
@@ -119,7 +117,7 @@ pub mod bucket {
}
pub mod tier_last_day_stats {
pub use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats, TierDailyStatsWire};
pub use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats};
}
pub mod tier_sweeper {
@@ -130,6 +128,7 @@ pub mod bucket {
}
pub mod metadata {
pub use crate::bucket::metadata::BUCKET_DURABILITY_CONFIG;
pub use crate::bucket::metadata::{
BUCKET_ACCELERATE_CONFIG, BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_LOGGING_CONFIG,
BUCKET_NOTIFICATION_CONFIG, BUCKET_POLICY_CONFIG, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, BUCKET_QUOTA_CONFIG_FILE,
@@ -138,7 +137,6 @@ pub mod bucket {
BUCKET_TARGETS_FILE, BUCKET_VERSIONING_CONFIG, BUCKET_WEBSITE_CONFIG, BucketMetadata, OBJECT_LOCK_CONFIG,
load_bucket_metadata, table_catalog_path_hash,
};
pub use crate::bucket::metadata::{BUCKET_DURABILITY_CONFIG, BUCKET_ON_DEMAND_MIGRATION_CONFIG};
}
pub mod durability {
@@ -147,51 +145,6 @@ pub mod bucket {
};
}
pub mod on_demand_migration {
pub use crate::bucket::on_demand_migration::{
ApplyOutcome, BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION,
Breaker, BreakerState, BreakerTransition, BreakerVerdict, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, GaugeGuard,
LastSourceError, LatencyBucketSnapshot, NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache, OdmBucketSnapshot, OdmLookup,
OdmOp, OdmOutcome, OdmStateError, OdmStats, OdmStatsSnapshot, OnDemandMigrationSys, PullError, PullFailureReason,
PullFollower, PullLeader, PullOutcome, PullPath, PullResult, PullSlot, SOURCE_LATENCY_BUCKET_BOUNDS_MS,
SourceLatencySnapshot, source_client_spec,
};
pub use crate::bucket::on_demand_migration::{
ConfigPublishHook, FilterConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_HOOK, ON_DEMAND_MIGRATION_CONFIG_VERSION,
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy,
SourceConfig, SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
};
pub use crate::bucket::on_demand_migration::{
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS,
PullCompletion, PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody,
WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
idle_guarded_body,
};
pub use crate::bucket::on_demand_migration::{
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
};
pub mod backfill {
pub use crate::bucket::on_demand_migration::backfill::{
BACKFILL_CHECKPOINT_FILE, BACKFILL_CHECKPOINT_FORMAT_VERSION, BACKFILL_FAILED_KEYS_CAPACITY, BACKFILL_LEASE,
BACKFILL_LEASE_LOCK_PREFIX, BACKFILL_LIST_PAGE_SIZE, BACKFILL_RECOVERY_INTERVAL, BACKFILL_SAVE_EVERY_KEYS,
BACKFILL_SAVE_INTERVAL, BackfillCheckpoint, BackfillContext, BackfillContextFactory, BackfillError,
BackfillLastError, BackfillOwner, BackfillRecoveryStats, BackfillRequest, BackfillRunner, BackfillState,
BucketBackfillContext, LocalBackfillObject, PriorityPullPermits, PullPermit, PullPriority, SkipExisting,
StoredCheckpoint, SysBackfillContexts, global_backfill_runner, install_global_backfill_runner, key_hash,
read_checkpoint, run_backfill_recovery_loop, spawn_backfill_recovery_loop,
};
}
pub mod source_client {
pub use crate::bucket::on_demand_migration::source_client::{
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceListRequest, SourceObject, SourcePage,
SourceProbe, SourceProvider, SourceSse, SourceTimeouts, USER_AGENT_SUFFIX, is_multipart_etag, range_header_value,
resolve_path_style,
};
}
}
pub mod metadata_sys {
#[cfg(feature = "test-util")]
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
@@ -201,11 +154,11 @@ pub mod bucket {
delete_if_incarnation, delete_under_transaction_lock, get, get_accelerate_config, get_bucket_policy,
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config, get_public_access_block_config,
get_quota_config, get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config,
get_versioning_config, get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata,
remove_bucket_metadata, set_bucket_metadata, update, update_bucket_targets_under_transaction_lock,
update_config_with, update_if_incarnation, update_quota_if_incarnation, update_under_transaction_lock,
get_object_lock_config, get_object_lock_config_state, get_public_access_block_config, get_quota_config,
get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config,
get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata,
set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
update_quota_if_incarnation, update_under_transaction_lock,
};
}
@@ -246,20 +199,6 @@ pub mod bucket {
}
}
pub mod remote_s3_client {
pub use crate::bucket::remote_s3_client::{
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_client,
validate_remote_endpoint,
};
}
pub mod sealed_credentials {
pub use crate::bucket::sealed_credentials::{
CredentialSealer, SEALED_CREDENTIAL_VERSION, SealScope, SealedCredential, SealedCredentialError,
SealedCredentialStore, credential_sealer, install_credential_sealer, seal_secret, unseal_secret,
};
}
pub mod replication {
pub use crate::bucket::replication::replication_pool::{
DurableMrfBacklogSummary, DurableMrfBucketBacklog, DurableMrfTargetBacklog, MrfBacklogObservabilitySummary,
@@ -480,7 +419,7 @@ pub mod notification {
#[cfg(any(test, feature = "test-util"))]
pub use crate::services::notification_sys::rotate_cross_pool_fence_fleet_proof_for_test;
pub use crate::services::notification_sys::{
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
acquire_cross_pool_fence_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
};
@@ -533,8 +472,8 @@ pub mod rpc {
pub use crate::cluster::rpc::{
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
PeerRestClient, PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
ScannerBucketListing, ScannerPeerActivity, ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease, TONIC_RPC_PREFIX,
TonicInterceptor, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options,
ScannerBucketListing, ScannerPeerActivity, ScannerPublicationLease, TONIC_RPC_PREFIX, TonicInterceptor,
build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options,
encode_heal_bucket_rpc_options, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
gen_tonic_signature_interceptor, node_service_time_out_client, node_service_time_out_client_no_auth,
normalize_tonic_rpc_audience, set_tonic_canonical_body_digest, sign_ns_scanner_capability,
+471 -327
View File
@@ -15,16 +15,17 @@
use crate::bucket::metadata::BucketMetadata;
use crate::bucket::metadata_sys::get_bucket_targets_config;
use crate::bucket::metadata_sys::get_replication_config;
use crate::bucket::remote_s3_client::{
PathStyle, REPLICATION_TARGET_RETRY_POLICY, RemoteCredentials, RemoteS3EndpointSpec, build_remote_s3_client,
};
use crate::bucket::replication::{ObjectLockIntegrity, object_lock_put_integrity};
use crate::bucket::replication::{ReplicationStatusType, ReplicationTargetConfigBridge};
use crate::bucket::target::ARN;
use crate::bucket::target::BucketTargetType;
use crate::bucket::target::{self, BucketTarget, BucketTargets, Credentials};
use crate::bucket::versioning_sys::BucketVersioningSys;
use crate::runtime::sources as runtime_sources;
use aws_credential_types::Credentials as SdkCredentials;
use aws_credential_types::provider::{ProvideCredentials, error::CredentialsError, future};
use aws_sdk_s3::config::Region as SdkRegion;
use aws_sdk_s3::config::RequestChecksumCalculation;
use aws_sdk_s3::config::SharedHttpClient;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::operation::complete_multipart_upload::CompleteMultipartUploadOutput;
@@ -36,17 +37,28 @@ use aws_sdk_s3::operation::head_object::HeadObjectError;
use aws_sdk_s3::operation::put_object_tagging::{PutObjectTaggingError, PutObjectTaggingOutput};
use aws_sdk_s3::operation::upload_part::UploadPartOutput;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::BucketVersioningStatus;
use aws_sdk_s3::types::Tagging as SdkTagging;
use aws_sdk_s3::types::{
ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
ChecksumMode, CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
ServerSideEncryption,
};
use aws_sdk_s3::{Client as S3Client, operation::head_object::HeadObjectOutput};
use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
use aws_sdk_s3::{Client as S3Client, Config as S3Config, operation::head_object::HeadObjectOutput};
use aws_sdk_s3::{config::SharedCredentialsProvider, types::BucketVersioningStatus};
use aws_smithy_http_client::{Builder as SmithyHttpClientBuilder, tls as smithy_tls};
use aws_smithy_runtime_api::box_error::BoxError;
use aws_smithy_runtime_api::client::http::{
HttpConnector as SmithyHttpConnector, HttpConnectorFuture, SharedHttpConnector, http_client_fn,
};
use aws_smithy_runtime_api::client::orchestrator::{HttpRequest, HttpResponse};
use aws_smithy_runtime_api::client::result::ConnectorError;
use aws_smithy_types::body::SdkBody;
use futures::{StreamExt, stream};
use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
use hyper_util::client::legacy::Client as HyperClient;
use hyper_util::rt::{TokioExecutor, TokioTimer};
use reqwest::Client as HttpClient;
use rustfs_config::{DEFAULT_TRUST_LEAF_CERT_AS_CA, ENV_TRUST_LEAF_CERT_AS_CA, RUSTFS_CA_CERT, RUSTFS_TLS_CERT};
use rustfs_utils::egress::{OutboundUrlError, validate_outbound_url};
use rustfs_utils::http::{
AMZ_BUCKET_REPLICATION_STATUS, AMZ_OBJECT_LOCK_BYPASS_GOVERNANCE, AMZ_OBJECT_LOCK_LEGAL_HOLD, AMZ_OBJECT_LOCK_MODE,
AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, AMZ_OBJECT_TAGGING_LOWER, AMZ_STORAGE_CLASS, AMZ_WEBSITE_REDIRECT_LOCATION, is_amz_header,
@@ -58,10 +70,12 @@ use rustfs_utils::http::{
SUFFIX_SOURCE_REPLICATION_RETENTION_TIMESTAMP, SUFFIX_SOURCE_REPLICATION_TAGGING_TIMESTAMP, SUFFIX_SOURCE_VERSION_ID,
insert_header,
};
use rustls_pki_types::pem::PemObject;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::error::Error;
use std::fmt;
use std::path::Path;
use std::str::FromStr as _;
use std::sync::Arc;
use std::sync::OnceLock;
@@ -70,6 +84,7 @@ use std::time::{Duration, Instant, SystemTime};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::sync::Mutex;
use tokio::sync::RwLock;
use tower::Service;
use tracing::error;
use tracing::warn;
use url::Url;
@@ -77,54 +92,72 @@ use uuid::Uuid;
const MAX_CONCURRENT_TARGET_HEALTH_CHECKS: usize = 16;
const REDACTED_CREDENTIAL: &str = "<redacted>";
const EXPIRED_REMOTE_TARGET_CREDENTIALS: &str = "remote target credentials have expired";
fn remote_credentials(credentials: &Credentials, account_id: &str) -> RemoteCredentials {
RemoteCredentials {
access_key: credentials.access_key.clone(),
secret_key: credentials.secret_key.clone(),
session_token: credentials.effective_session_token().map(str::to_string),
expiration: credentials.effective_expiration().map(SystemTime::from),
account_id: account_id.to_string(),
}
#[derive(Clone)]
struct RemoteTargetCredentialsProvider {
credentials: SdkCredentials,
}
fn target_path_style(path: &str) -> PathStyle {
match path.trim().to_ascii_lowercase().as_str() {
// Explicit DNS/virtual-hosted-style requested by user.
"dns" | "off" | "false" => PathStyle::VirtualHost,
// Explicit path-style or legacy boolean-like values.
"path" | "on" | "true" => PathStyle::Path,
// `auto` and empty are defaulted to path-style for custom S3-compatible endpoints.
"auto" | "" => PathStyle::Auto,
// Unknown values: prefer compatibility with S3-compatible services.
_ => PathStyle::Path,
}
}
impl From<&BucketTarget> for RemoteS3EndpointSpec {
fn from(target: &BucketTarget) -> Self {
RemoteS3EndpointSpec {
endpoint: target.endpoint.clone(),
secure: target.secure,
region: target.region.clone(),
path_style: target_path_style(&target.path),
credentials: target
.credentials
.as_ref()
.map(|credentials| remote_credentials(credentials, &target.reset_id)),
skip_tls_verify: target.skip_tls_verify,
ca_cert_pem: (!target.ca_cert_pem.trim().is_empty()).then(|| target.ca_cert_pem.clone()),
connect_timeout: None,
read_timeout: None,
// Replication has no retry budget of its own on the request path,
// so it keeps the SDK's standard three attempts; stating it here
// pins the behaviour to this line instead of an SDK default.
retry: REPLICATION_TARGET_RETRY_POLICY,
user_agent_suffix: "",
impl RemoteTargetCredentialsProvider {
fn resolve_at(&self, now: SystemTime) -> aws_credential_types::provider::Result {
if self.credentials.expiry().is_some_and(|expiration| expiration <= now) {
return Err(CredentialsError::provider_error(std::io::Error::other(EXPIRED_REMOTE_TARGET_CREDENTIALS)));
}
Ok(self.credentials.clone())
}
}
impl fmt::Debug for RemoteTargetCredentialsProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RemoteTargetCredentialsProvider")
.field("temporary", &self.credentials.session_token().is_some())
.field("expiration", &self.credentials.expiry())
.finish()
}
}
impl ProvideCredentials for RemoteTargetCredentialsProvider {
fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a>
where
Self: 'a,
{
future::ProvideCredentials::ready(self.resolve_at(SystemTime::now()))
}
fn fallback_on_interrupt(&self) -> Option<SdkCredentials> {
self.resolve_at(SystemTime::now()).ok()
}
}
fn remote_target_sdk_credentials(
credentials: &Credentials,
account_id: &str,
now: SystemTime,
) -> Result<SdkCredentials, &'static str> {
let session_token = credentials.effective_session_token();
let expiration = credentials.effective_expiration().map(SystemTime::from);
if expiration.is_some() && session_token.is_none() {
return Err("remote target credential expiration requires a session token");
}
if expiration.is_some_and(|expiration| expiration <= now) {
return Err(EXPIRED_REMOTE_TARGET_CREDENTIALS);
}
let mut builder = SdkCredentials::builder()
.access_key_id(credentials.access_key.clone())
.secret_access_key(credentials.secret_key.clone())
.account_id(account_id.to_string())
.provider_name("bucket_target_sys");
if let Some(session_token) = session_token {
builder = builder.session_token(session_token.to_string());
}
if let Some(expiration) = expiration {
builder = builder.expiry(expiration);
}
Ok(builder.build())
}
pub type HeadObjectSdkError = Box<SdkError<HeadObjectError>>;
pub type GetObjectSdkError = Box<SdkError<GetObjectError>>;
pub type GetObjectTaggingSdkError = Box<SdkError<GetObjectTaggingError>>;
@@ -1025,17 +1058,57 @@ impl BucketTargetSys {
});
};
let spec = RemoteS3EndpointSpec::from(target);
let client = build_remote_s3_client(&spec)
.await
.map_err(|err| BucketTargetError::RemoteTargetConnectionErr {
let creds = remote_target_sdk_credentials(credentials, &target.reset_id, SystemTime::now()).map_err(|error| {
BucketTargetError::RemoteTargetConnectionErr {
bucket: target.target_bucket.clone(),
access_key: credentials.access_key.clone(),
error: err.to_string(),
})?;
error: error.to_string(),
}
})?;
let endpoint = if target.secure {
format!("https://{}", target.endpoint)
} else {
format!("http://{}", target.endpoint)
};
let parsed_endpoint = Url::parse(&endpoint).map_err(|err| BucketTargetError::RemoteTargetConnectionErr {
bucket: target.target_bucket.clone(),
access_key: credentials.access_key.clone(),
error: format!("invalid target endpoint: {err}"),
})?;
validate_replication_target_endpoint(&parsed_endpoint).map_err(|err| BucketTargetError::RemoteTargetConnectionErr {
bucket: target.target_bucket.clone(),
access_key: credentials.access_key.clone(),
error: format!("target endpoint is not allowed: {err}"),
})?;
let mut config_builder = S3Config::builder()
.endpoint_url(endpoint.clone())
.credentials_provider(SharedCredentialsProvider::new(RemoteTargetCredentialsProvider { credentials: creds }))
.region(SdkRegion::new(target.region.clone()))
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
.request_checksum_calculation(replication_request_checksum_calculation());
if should_force_path_style(target) {
config_builder = config_builder.force_path_style(true);
}
if let Some(http_client) =
build_aws_s3_http_client_for_target(target)
.await
.map_err(|err| BucketTargetError::RemoteTargetConnectionErr {
bucket: target.target_bucket.clone(),
access_key: credentials.access_key.clone(),
error: err.to_string(),
})?
{
config_builder = config_builder.http_client(http_client);
}
let config = config_builder.build();
Ok(TargetClient {
endpoint: spec.endpoint_url(),
endpoint,
credentials: target.credentials.clone(),
bucket: target.target_bucket.clone(),
storage_class: target.storage_class.clone(),
@@ -1045,7 +1118,7 @@ impl BucketTargetSys {
secure: target.secure,
health_check_duration: target.health_check_duration,
replicate_sync: target.replication_sync,
client: Arc::new(client),
client: Arc::new(S3Client::from_conf(config)),
})
}
@@ -1208,6 +1281,327 @@ impl BucketTargetSys {
}
}
#[derive(Debug)]
struct AcceptAnyServerCertVerifier;
impl rustls::client::danger::ServerCertVerifier for AcceptAnyServerCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &rustls_pki_types::CertificateDer<'_>,
_intermediates: &[rustls_pki_types::CertificateDer<'_>],
_server_name: &rustls_pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls_pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::aws_lc_rs::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[derive(Clone)]
struct TargetHyperHttpConnector<C> {
client: HyperClient<C, SdkBody>,
}
impl<C> fmt::Debug for TargetHyperHttpConnector<C> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TargetHyperHttpConnector")
.field("client", &"** hyper client **")
.finish()
}
}
impl<C> SmithyHttpConnector for TargetHyperHttpConnector<C>
where
C: Clone + Send + Sync + 'static,
C: Service<Uri>,
C::Response:
hyper::rt::Read + hyper::rt::Write + hyper_util::client::legacy::connect::Connection + Send + Sync + Unpin + 'static,
C::Future: Unpin + Send + 'static,
C::Error: Into<BoxError>,
{
fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
let request = match request.try_into_http1x() {
Ok(request) => request,
Err(err) => return HttpConnectorFuture::ready(Err(ConnectorError::user(err.into()))),
};
let mut client = self.client.clone();
let fut = client.call(request);
HttpConnectorFuture::new(async move {
let response = fut
.await
.map_err(|err| ConnectorError::io(err.into()))?
.map(SdkBody::from_body_1_x);
HttpResponse::try_from(response).map_err(|err| ConnectorError::other(err.into(), None))
})
}
}
fn ensure_rustls_crypto_provider() {
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
}
fn has_custom_ca_pem(target: &BucketTarget) -> bool {
!target.ca_cert_pem.trim().is_empty()
}
/// Env opt-in that re-enables loopback replication targets. Loopback (`127.0.0.1`,
/// `::1`, `localhost`) is a classic SSRF vector and stays rejected by default, but
/// single-host multi-instance dev setups and the e2e harness legitimately replicate
/// over loopback. Never set this in production.
const ALLOW_LOOPBACK_REPLICATION_TARGET_ENV: &str = "RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET";
fn loopback_replication_targets_allowed() -> bool {
std::env::var(ALLOW_LOOPBACK_REPLICATION_TARGET_ENV)
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false)
}
const REPLICATION_STREAMING_CHECKSUMS_ENV: &str = "RUSTFS_REPLICATION_STREAMING_CHECKSUMS";
/// Streaming trailer checksums make the SDK frame request bodies as
/// `aws-chunked`; a target that does not decode that framing stores the frames
/// verbatim, silently corrupting every replica while the transfer itself
/// succeeds (#6853). Plain signed payloads are the compatible default; the env
/// knob restores trailer checksums for fleets whose targets are all known to
/// decode them.
fn replication_request_checksum_calculation() -> RequestChecksumCalculation {
if std::env::var(REPLICATION_STREAMING_CHECKSUMS_ENV)
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false)
{
RequestChecksumCalculation::WhenSupported
} else {
RequestChecksumCalculation::WhenRequired
}
}
fn validate_replication_target_endpoint(url: &Url) -> Result<(), OutboundUrlError> {
validate_replication_target_endpoint_inner(url, loopback_replication_targets_allowed())
}
fn validate_replication_target_endpoint_inner(url: &Url, allow_loopback: bool) -> Result<(), OutboundUrlError> {
match validate_outbound_url(url) {
Ok(()) => Ok(()),
// Replication targets are trusted infrastructure the operator configures, and
// legitimately live on private networks, so private addresses are always allowed.
Err(OutboundUrlError::ForbiddenHost {
reason: "private address",
..
}) => Ok(()),
// Loopback is far higher SSRF risk, so it is allowed only under the explicit,
// off-by-default opt-in above (single-host multi-instance / the e2e harness).
Err(OutboundUrlError::ForbiddenHost {
reason: "loopback address" | "loopback host",
..
}) if allow_loopback => Ok(()),
Err(err) => Err(err),
}
}
fn build_insecure_aws_s3_http_client() -> SharedHttpClient {
ensure_rustls_crypto_provider();
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier))
.with_no_client_auth();
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls_config)
.https_or_http()
.enable_http1()
.enable_http2()
.build();
let mut client_builder = HyperClient::builder(TokioExecutor::new());
client_builder.pool_timer(TokioTimer::new());
let client = client_builder.build(https);
let connector = SharedHttpConnector::new(TargetHyperHttpConnector { client });
http_client_fn(move |_settings, _components| connector.clone())
}
fn validate_ca_pem_bundle(ca_cert_pem: &[u8]) -> Result<(), String> {
let certs = rustls_pki_types::CertificateDer::pem_slice_iter(ca_cert_pem)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| format!("invalid PEM encoding: {err}"))?;
if certs.is_empty() {
return Err("no certificates found".to_string());
}
// Smithy's rustls adapter defers parsing custom certificates and assumes
// they are valid when the HTTPS connector is built. Validate every DER
// certificate first so malformed configuration is reported rather than
// reaching an `expect` in the dependency.
let mut validation_store = rustls::RootCertStore::empty();
for cert in certs {
validation_store
.add(cert)
.map_err(|err| format!("invalid X.509 certificate: {err}"))?;
}
Ok(())
}
fn validate_target_ca_pem(ca_cert_pem: &str) -> Result<(), BucketTargetError> {
validate_ca_pem_bundle(ca_cert_pem.as_bytes())
.map_err(|err| BucketTargetError::Io(std::io::Error::other(format!("invalid target CA PEM: {err}"))))
}
fn compose_replication_trust_store(certificate_bundles: impl IntoIterator<Item = Vec<u8>>) -> (smithy_tls::TrustStore, usize) {
// `TrustStore::default()` keeps the platform-native roots enabled. Target
// and RUSTFS_TLS_PATH certificates extend that baseline instead of
// replacing it with a target-specific trust island.
let mut trust_store = smithy_tls::TrustStore::default();
let mut custom_bundle_count = 0;
for pem in certificate_bundles {
trust_store.add_pem_certificate(pem);
custom_bundle_count += 1;
}
(trust_store, custom_bundle_count)
}
fn build_aws_s3_http_client_with_trust_store(trust_store: smithy_tls::TrustStore) -> Result<SharedHttpClient, BucketTargetError> {
let tls_context = smithy_tls::TlsContext::builder()
.with_trust_store(trust_store)
.build()
.map_err(|err| BucketTargetError::Io(std::io::Error::other(format!("invalid target CA PEM: {err}"))))?;
Ok(SmithyHttpClientBuilder::new()
.tls_provider(smithy_tls::Provider::rustls(smithy_tls::rustls_provider::CryptoMode::AwsLc))
.tls_context(tls_context)
.build_https())
}
async fn load_tls_path_ca_bundles(tls_dir: &Path, trust_leaf_cert_as_ca: bool) -> Vec<Vec<u8>> {
let mut certificate_bundles = Vec::new();
let ca_path = tls_dir.join(RUSTFS_CA_CERT);
match tokio::fs::read(&ca_path).await {
Ok(pem) => match validate_ca_pem_bundle(&pem) {
Ok(()) => certificate_bundles.push(pem),
Err(err) => warn!("ignoring invalid custom CA bundle {:?} for replication client: {}", ca_path, err),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!("failed to read custom CA bundle {:?} for replication client: {}", ca_path, e),
}
if trust_leaf_cert_as_ca {
let leaf_cert_path = tls_dir.join(RUSTFS_TLS_CERT);
match tokio::fs::read(&leaf_cert_path).await {
Ok(pem) => match validate_ca_pem_bundle(&pem) {
Ok(()) => certificate_bundles.push(pem),
Err(err) => warn!(
"ignoring invalid leaf certificate {:?} for replication client trust store: {}",
leaf_cert_path, err
),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!("failed to read leaf cert {:?} for replication client trust store: {}", leaf_cert_path, e),
}
}
certificate_bundles
}
async fn load_configured_tls_ca_bundles() -> Vec<Vec<u8>> {
let tls_path = rustfs_utils::get_env_str(rustfs_config::ENV_RUSTFS_TLS_PATH, rustfs_config::DEFAULT_RUSTFS_TLS_PATH);
if tls_path.is_empty() {
return Vec::new();
}
load_tls_path_ca_bundles(
Path::new(&tls_path),
rustfs_utils::get_env_bool(ENV_TRUST_LEAF_CERT_AS_CA, DEFAULT_TRUST_LEAF_CERT_AS_CA),
)
.await
}
async fn build_aws_s3_http_client_from_target_ca_pem(ca_cert_pem: &str) -> Result<SharedHttpClient, BucketTargetError> {
validate_target_ca_pem(ca_cert_pem)?;
let mut certificate_bundles = load_configured_tls_ca_bundles().await;
certificate_bundles.push(ca_cert_pem.as_bytes().to_vec());
let (trust_store, _) = compose_replication_trust_store(certificate_bundles);
build_aws_s3_http_client_with_trust_store(trust_store)
}
async fn build_aws_s3_http_client_for_target(target: &BucketTarget) -> Result<Option<SharedHttpClient>, BucketTargetError> {
if !target.secure {
return Ok(None);
}
if target.skip_tls_verify {
return Ok(Some(build_insecure_aws_s3_http_client()));
}
if has_custom_ca_pem(target) {
return build_aws_s3_http_client_from_target_ca_pem(&target.ca_cert_pem)
.await
.map(Some);
}
Ok(build_aws_s3_http_client_from_tls_path().await)
}
async fn build_aws_s3_http_client_from_tls_path() -> Option<aws_sdk_s3::config::SharedHttpClient> {
let certificate_bundles = load_configured_tls_ca_bundles().await;
if certificate_bundles.is_empty() {
return None;
}
let (trust_store, _) = compose_replication_trust_store(certificate_bundles);
match build_aws_s3_http_client_with_trust_store(trust_store) {
Ok(client) => Some(client),
Err(e) => {
warn!("failed to build AWS SDK TLS context for replication client: {}", e);
None
}
}
}
fn should_force_path_style(target: &BucketTarget) -> bool {
match target.path.trim().to_ascii_lowercase().as_str() {
// Explicit DNS/virtual-hosted-style requested by user.
"dns" | "off" | "false" => false,
// Explicit path-style or legacy boolean-like values.
"path" | "on" | "true" => true,
// `auto` and empty are defaulted to path-style for custom S3-compatible endpoints.
"auto" | "" => true,
// Unknown values: prefer compatibility with S3-compatible services.
_ => true,
}
}
// generate ARN that is unique to this target type
fn generate_arn(t: &BucketTarget, depl_id: &str) -> String {
let uuid = if depl_id.is_empty() {
@@ -1387,25 +1781,6 @@ impl Default for AdvancedPutOptions {
}
}
/// Decide how a replication PUT satisfies the Object Lock integrity rule from
/// the headers it is about to send (the pure decision lives in the replication crate, re-exported through the replication boundary).
fn object_lock_put_integrity_for(headers: &HeaderMap, opts: &PutObjectOptions) -> ObjectLockIntegrity {
let lock_params = opts.mode.is_some() || opts.retain_until_date.unix_timestamp() != 0 || opts.legalhold.is_some();
let has_integrity_header = headers.keys().any(|name| {
let name = name.as_str();
name.starts_with("x-amz-checksum-") || name == "x-amz-sdk-checksum-algorithm" || name == "content-md5"
});
let plaintext_end_to_end = !headers.contains_key("x-amz-server-side-encryption")
&& !headers.contains_key("x-amz-server-side-encryption-customer-algorithm")
&& !rustfs_utils::http::has_ssec_transport_headers(headers);
object_lock_put_integrity(
lock_params,
has_integrity_header,
plaintext_end_to_end,
Some(opts.internal.source_etag.as_str()),
)
}
/// The subset of the target's PutObject response replication audits.
#[derive(Debug, Clone)]
pub struct RemotePutObjectResponse {
@@ -1975,7 +2350,7 @@ impl TargetClient {
) -> Result<RemotePutObjectResponse, S3ClientError> {
let mut headers = opts.header();
let mut builder = self.client.put_object();
let builder = self.client.put_object();
let version_id = opts.internal.source_version_id.clone();
if !version_id.is_empty() {
@@ -1983,27 +2358,6 @@ impl TargetClient {
}
let api_version_id = resolve_put_api_version_id(&version_id).map(ToOwned::to_owned);
// A PUT carrying Object Lock parameters must also carry Content-MD5 or
// an x-amz-checksum-* header on AWS-compatible targets (rustfs#7082).
// The plain-payload default (rustfs#6853) sends neither, so supply one
// here: the source ETag when it is the MD5 of the wire bytes, else an
// SDK-computed checksum.
match object_lock_put_integrity_for(&headers, opts) {
ObjectLockIntegrity::NotRequired => {}
ObjectLockIntegrity::ContentMd5Hex(md5_hex) => {
let digest = hex_simd::decode_to_vec(md5_hex.as_bytes())
.map_err(|err| S3ClientError::new(format!("source etag is not hex: {err}")))?;
let encoded = base64_simd::STANDARD.encode_to_string(digest);
headers.insert(
http::header::HeaderName::from_static("content-md5"),
HeaderValue::from_str(&encoded).map_err(|err| S3ClientError::new(format!("invalid Content-MD5: {err}")))?,
);
}
ObjectLockIntegrity::SdkChecksum => {
builder = builder.checksum_algorithm(ChecksumAlgorithm::Crc32);
}
}
match builder
.bucket(bucket)
.key(object)
@@ -2353,25 +2707,7 @@ impl Error for BucketTargetError {}
#[cfg(test)]
mod tests {
use super::*;
use crate::bucket::remote_s3_client::{
EXPIRED_REMOTE_TARGET_CREDENTIALS, RemoteS3RetryPolicy, RemoteTargetCredentialsProvider,
build_aws_s3_http_client_for_spec, build_aws_s3_http_client_from_target_ca_pem,
build_aws_s3_http_client_with_trust_store, build_insecure_aws_s3_http_client, compose_replication_trust_store,
ensure_rustls_crypto_provider, load_tls_path_ca_bundles, remote_sdk_credentials,
replication_request_checksum_calculation, validate_remote_endpoint_inner, validate_target_ca_pem,
};
use aws_credential_types::Credentials as SdkCredentials;
use aws_sdk_s3::Config as S3Config;
use aws_sdk_s3::config::{Region as SdkRegion, RequestChecksumCalculation, SharedCredentialsProvider, SharedHttpClient};
use aws_smithy_runtime_api::client::http::{
HttpConnector as SmithyHttpConnector, HttpConnectorFuture, SharedHttpConnector, http_client_fn,
};
use aws_smithy_runtime_api::client::orchestrator::HttpResponse;
use aws_smithy_types::body::SdkBody;
use rcgen::generate_simple_self_signed;
use rustfs_config::{RUSTFS_CA_CERT, RUSTFS_TLS_CERT};
use rustfs_utils::egress::OutboundUrlError;
use rustfs_utils::http::AMZ_SERVER_SIDE_ENCRYPTION;
// The startup panic fix for hosts without a CA bundle (issue #6734) rests
// on two properties: the health-check client constructor never panics, and
@@ -2503,153 +2839,6 @@ mod tests {
assert_eq!(header("content-length"), Some("4"));
}
fn recorded_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
fn locked_put_options(source_etag: &str) -> PutObjectOptions {
PutObjectOptions {
mode: Some(ObjectLockRetentionMode::Governance),
retain_until_date: OffsetDateTime::from_unix_timestamp(4_102_444_800).expect("valid timestamp"),
internal: AdvancedPutOptions {
source_etag: source_etag.to_string(),
..Default::default()
},
..Default::default()
}
}
/// rustfs#7082: a locked PUT of a plaintext single-part object carries a
/// Content-MD5 derived from the source ETag, still as a plain payload.
#[tokio::test]
async fn locked_put_object_carries_content_md5_from_the_source_etag() {
let (client, recorded) = header_recording_target_client(Vec::new());
client
.put_object(
"target-bucket",
"object",
4,
streaming_test_body(b"data"),
&locked_put_options("\"8d777f385d3dfec8815d20f7496026dc\""),
)
.await
.expect("recorded put_object should succeed");
let recorded = recorded.lock().expect("recorded header lock should not be poisoned");
let headers = &recorded[0];
// base64 of the MD5 bytes of "data".
assert_eq!(recorded_header(headers, "content-md5"), Some("jXd/OF09/siBXSD3SWAm3A=="));
assert_eq!(recorded_header(headers, "x-amz-object-lock-mode"), Some("GOVERNANCE"));
assert_eq!(
recorded_header(headers, "x-amz-trailer"),
None,
"Content-MD5 must not change the payload framing"
);
assert!(
recorded_header(headers, "content-encoding").is_none_or(|v| !v.contains("aws-chunked")),
"locked uploads stay plain signed payloads"
);
assert_eq!(recorded_header(headers, "content-length"), Some("4"));
}
/// A legal hold is an Object Lock parameter too.
#[tokio::test]
async fn legal_hold_put_object_carries_content_md5() {
let (client, recorded) = header_recording_target_client(Vec::new());
let opts = PutObjectOptions {
legalhold: Some(ObjectLockLegalHoldStatus::On),
internal: AdvancedPutOptions {
source_etag: "8d777f385d3dfec8815d20f7496026dc".to_string(),
..Default::default()
},
..Default::default()
};
client
.put_object("target-bucket", "object", 4, streaming_test_body(b"data"), &opts)
.await
.expect("recorded put_object should succeed");
let recorded = recorded.lock().expect("recorded header lock should not be poisoned");
assert_eq!(recorded_header(&recorded[0], "content-md5"), Some("jXd/OF09/siBXSD3SWAm3A=="));
assert_eq!(recorded_header(&recorded[0], "x-amz-object-lock-legal-hold"), Some("ON"));
}
/// When the source ETag is not the MD5 of the wire bytes (multipart
/// layout, or an encrypted object) the SDK computes the checksum instead;
/// with a streaming body that is a CRC32 trailer.
#[tokio::test]
async fn locked_put_object_without_a_usable_etag_uses_an_sdk_checksum() {
for (label, opts) in [
("multipart etag", locked_put_options("8d777f385d3dfec8815d20f7496026dc-3")),
("managed sse", {
let mut opts = locked_put_options("8d777f385d3dfec8815d20f7496026dc");
opts.user_metadata
.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "AES256".to_string());
opts
}),
] {
let (client, recorded) = header_recording_target_client(Vec::new());
client
.put_object("target-bucket", "object", 4, streaming_test_body(b"data"), &opts)
.await
.expect("recorded put_object should succeed");
let recorded = recorded.lock().expect("recorded header lock should not be poisoned");
let headers = &recorded[0];
assert_eq!(
recorded_header(headers, "content-md5"),
None,
"{label}: the source etag is not the wire MD5"
);
assert!(
recorded_header(headers, "x-amz-sdk-checksum-algorithm").is_some_and(|v| v.eq_ignore_ascii_case("CRC32"))
|| recorded_header(headers, "x-amz-checksum-crc32").is_some(),
"{label}: the SDK must announce a CRC32 checksum; headers: {headers:?}"
);
}
}
/// A forwarded source checksum already satisfies the rule; nothing is added.
#[tokio::test]
async fn locked_put_object_keeps_a_forwarded_source_checksum() {
let (client, recorded) = header_recording_target_client(Vec::new());
let mut opts = locked_put_options("8d777f385d3dfec8815d20f7496026dc");
opts.user_metadata
.insert("x-amz-checksum-crc32".to_string(), "rfPzYw==".to_string());
client
.put_object("target-bucket", "object", 4, streaming_test_body(b"data"), &opts)
.await
.expect("recorded put_object should succeed");
let recorded = recorded.lock().expect("recorded header lock should not be poisoned");
let headers = &recorded[0];
assert_eq!(recorded_header(headers, "x-amz-checksum-crc32"), Some("rfPzYw=="));
assert_eq!(recorded_header(headers, "content-md5"), None);
assert_eq!(recorded_header(headers, "x-amz-trailer"), None);
}
/// Without Object Lock parameters the plain-payload default is untouched.
#[tokio::test]
async fn unlocked_put_object_adds_no_integrity_header() {
let (client, recorded) = header_recording_target_client(Vec::new());
let opts = PutObjectOptions {
internal: AdvancedPutOptions {
source_etag: "8d777f385d3dfec8815d20f7496026dc".to_string(),
..Default::default()
},
..Default::default()
};
client
.put_object("target-bucket", "object", 4, streaming_test_body(b"data"), &opts)
.await
.expect("recorded put_object should succeed");
let recorded = recorded.lock().expect("recorded header lock should not be poisoned");
let headers = &recorded[0];
assert_eq!(recorded_header(headers, "content-md5"), None);
assert_eq!(recorded_header(headers, "x-amz-trailer"), None);
assert_eq!(recorded_header(headers, "x-amz-sdk-checksum-algorithm"), None);
}
#[tokio::test]
async fn put_object_returns_the_etag_the_target_stored() {
let (client, _) =
@@ -2745,8 +2934,8 @@ mod tests {
expiration: Some(jiff::Timestamp::try_from(expiration).expect("test expiration should convert")),
};
let sdk_credentials = remote_sdk_credentials(&remote_credentials(&credentials, "account"), now)
.expect("unexpired temporary credentials should build");
let sdk_credentials =
remote_target_sdk_credentials(&credentials, "account", now).expect("unexpired temporary credentials should build");
assert_eq!(sdk_credentials.session_token(), Some("temporary-session-token"));
assert_eq!(sdk_credentials.expiry(), Some(expiration));
@@ -2762,7 +2951,7 @@ mod tests {
expiration: Some("0001-01-01T00:00:00Z".parse().expect("Go zero time should parse")),
};
let sdk_credentials = remote_sdk_credentials(&remote_credentials(&credentials, ""), SystemTime::now())
let sdk_credentials = remote_target_sdk_credentials(&credentials, "", SystemTime::now())
.expect("Go zero expiration should remain compatible with static credentials");
assert!(sdk_credentials.session_token().is_none());
@@ -2780,14 +2969,14 @@ mod tests {
};
assert_eq!(
remote_sdk_credentials(&remote_credentials(&credentials, ""), SystemTime::UNIX_EPOCH + Duration::from_secs(1_000))
remote_target_sdk_credentials(&credentials, "", SystemTime::UNIX_EPOCH + Duration::from_secs(1_000))
.expect_err("expiration without a session token must fail"),
"remote target credential expiration requires a session token"
);
credentials.session_token = Some("temporary-session-token".to_string());
assert_eq!(
remote_sdk_credentials(&remote_credentials(&credentials, ""), expiration)
remote_target_sdk_credentials(&credentials, "", expiration)
.expect_err("credentials expire at the exact expiration boundary"),
EXPIRED_REMOTE_TARGET_CREDENTIALS
);
@@ -2847,7 +3036,7 @@ mod tests {
session_token: Some("temporary-session-token".to_string()),
expiration: Some("2099-01-01T00:00:00Z".parse().expect("future expiration should parse")),
};
let sdk_credentials = remote_sdk_credentials(&remote_credentials(&credentials, ""), SystemTime::now())
let sdk_credentials = remote_target_sdk_credentials(&credentials, "", SystemTime::now())
.expect("unexpired temporary credentials should build");
let client = S3Client::from_conf(
S3Config::builder()
@@ -3022,51 +3211,6 @@ mod tests {
assert!(!replication_target_versioning_enabled(None));
}
#[test]
fn remote_endpoint_spec_from_target_keeps_legacy_path_style_and_trust_semantics() {
for (path, expected) in [
("dns", PathStyle::VirtualHost),
("OFF", PathStyle::VirtualHost),
("false", PathStyle::VirtualHost),
("path", PathStyle::Path),
("on", PathStyle::Path),
("true", PathStyle::Path),
(" auto ", PathStyle::Auto),
("", PathStyle::Auto),
("something-else", PathStyle::Path),
] {
assert_eq!(target_path_style(path), expected, "path={path:?}");
}
let spec = RemoteS3EndpointSpec::from(&BucketTarget {
endpoint: "192.168.1.10:9000".to_string(),
secure: true,
region: "us-east-1".to_string(),
ca_cert_pem: " ".to_string(),
reset_id: "reset-1".to_string(),
credentials: Some(Credentials {
access_key: "access".to_string(),
secret_key: "secret".to_string(),
session_token: Some(" ".to_string()),
expiration: Some("0001-01-01T00:00:00Z".parse().expect("Go zero time should parse")),
}),
..Default::default()
});
assert_eq!(spec.endpoint_url(), "https://192.168.1.10:9000");
assert!(spec.ca_cert_pem.is_none(), "whitespace-only CA PEM means unset");
assert!(spec.connect_timeout.is_none() && spec.read_timeout.is_none());
assert_eq!(spec.user_agent_suffix, "");
assert_eq!(
spec.retry,
RemoteS3RetryPolicy::Standard { max_attempts: 3 },
"replication targets keep the SDK's historical three attempts"
);
let credentials = spec.credentials.expect("credentials carry over");
assert_eq!(credentials.account_id, "reset-1");
assert!(credentials.session_token.is_none(), "blank session token is absent");
assert!(credentials.expiration.is_none(), "Go zero expiration is absent");
}
fn parse_url(raw: &str) -> Url {
Url::parse(raw).expect("test URL should parse")
}
@@ -3076,16 +3220,16 @@ mod tests {
// Public hosts and private-network targets are allowed regardless of the
// loopback opt-in — replication commonly runs across trusted private infra.
for allow_loopback in [false, true] {
assert!(validate_remote_endpoint_inner(&parse_url("https://s3.example.com"), allow_loopback).is_ok());
assert!(validate_remote_endpoint_inner(&parse_url("http://10.0.0.5:9000"), allow_loopback).is_ok());
assert!(validate_remote_endpoint_inner(&parse_url("http://192.168.1.20"), allow_loopback).is_ok());
assert!(validate_replication_target_endpoint_inner(&parse_url("https://s3.example.com"), allow_loopback).is_ok());
assert!(validate_replication_target_endpoint_inner(&parse_url("http://10.0.0.5:9000"), allow_loopback).is_ok());
assert!(validate_replication_target_endpoint_inner(&parse_url("http://192.168.1.20"), allow_loopback).is_ok());
}
}
#[test]
fn replication_endpoint_rejects_loopback_without_opt_in() {
// Default (production) behaviour: loopback IP and localhost host both rejected.
let err = validate_remote_endpoint_inner(&parse_url("http://127.0.0.1:9000"), false)
let err = validate_replication_target_endpoint_inner(&parse_url("http://127.0.0.1:9000"), false)
.expect_err("loopback IP must be rejected by default");
assert!(matches!(
err,
@@ -3094,7 +3238,7 @@ mod tests {
..
}
));
let err = validate_remote_endpoint_inner(&parse_url("http://localhost:9000"), false)
let err = validate_replication_target_endpoint_inner(&parse_url("http://localhost:9000"), false)
.expect_err("localhost must be rejected by default");
assert!(matches!(
err,
@@ -3109,15 +3253,15 @@ mod tests {
fn replication_endpoint_allows_loopback_with_opt_in() {
// e2e harness / single-host multi-instance: opt-in re-enables loopback in
// both IP (127.0.0.1, ::1) and hostname (localhost) forms.
assert!(validate_remote_endpoint_inner(&parse_url("http://127.0.0.1:9000"), true).is_ok());
assert!(validate_remote_endpoint_inner(&parse_url("http://[::1]:9000"), true).is_ok());
assert!(validate_remote_endpoint_inner(&parse_url("http://localhost:9000"), true).is_ok());
assert!(validate_replication_target_endpoint_inner(&parse_url("http://127.0.0.1:9000"), true).is_ok());
assert!(validate_replication_target_endpoint_inner(&parse_url("http://[::1]:9000"), true).is_ok());
assert!(validate_replication_target_endpoint_inner(&parse_url("http://localhost:9000"), true).is_ok());
}
#[test]
fn replication_endpoint_opt_in_does_not_open_other_ssrf_targets() {
// The loopback opt-in must not widen into link-local / metadata endpoints.
let err = validate_remote_endpoint_inner(&parse_url("http://169.254.169.254/latest/meta-data"), true)
let err = validate_replication_target_endpoint_inner(&parse_url("http://169.254.169.254/latest/meta-data"), true)
.expect_err("metadata endpoint must stay rejected even with loopback opt-in");
assert!(matches!(
err,
@@ -3126,7 +3270,7 @@ mod tests {
..
}
));
let err = validate_remote_endpoint_inner(&parse_url("http://[fe80::1]:9000"), true)
let err = validate_replication_target_endpoint_inner(&parse_url("http://[fe80::1]:9000"), true)
.expect_err("link-local must stay rejected even with loopback opt-in");
assert!(matches!(
err,
@@ -4135,12 +4279,12 @@ mod tests {
#[tokio::test]
async fn skip_tls_verify_takes_priority_over_invalid_custom_ca_pem() {
let client = build_aws_s3_http_client_for_spec(&RemoteS3EndpointSpec::from(&BucketTarget {
let client = build_aws_s3_http_client_for_target(&BucketTarget {
secure: true,
skip_tls_verify: true,
ca_cert_pem: "not a pem".to_string(),
..Default::default()
}))
})
.await
.expect("skip verification should bypass custom CA parsing");
@@ -16,9 +16,8 @@ use super::runtime_boundary as runtime_sources;
use crate::bucket::lifecycle::lifecycle;
use crate::object_api::ObjectInfo;
use crate::services::event_notification::{EventArgs, send_event};
use crate::storage_api_contracts::object::{DeletedObject, ObjectToDelete};
use rustfs_s3_types::EventName;
use rustfs_scanner_metrics::metrics::IlmAction;
use rustfs_scanner_contracts::metrics::IlmAction;
const LIFECYCLE_EXPIRY_USER_AGENT: &str = "Internal: [ILM-Expiry]";
const LIFECYCLE_TRANSITION_USER_AGENT: &str = "Internal: [ILM-Transition]";
@@ -77,60 +76,6 @@ pub(crate) fn emit_non_transitioned_expiration_event(action: IlmAction, source:
emit_lifecycle_event(event_name, deleted, LIFECYCLE_EXPIRY_USER_AGENT);
}
/// Emit the lifecycle expiration event for one version removed by the batch
/// `NewerNoncurrentVersions` expiry path.
///
/// That path never sent events, so a successful noncurrent-version expiry was
/// invisible to notification subscribers even though the equivalent
/// current-version path emits one (backlog#2202).
pub(crate) fn emit_noncurrent_expiration_event(bucket: &str, target: &ObjectToDelete, deleted: &DeletedObject, failed: bool) {
if let Some((event_name, object)) = noncurrent_expiration_event(bucket, target, deleted, failed) {
emit_lifecycle_event(event_name, object, LIFECYCLE_EXPIRY_USER_AGENT);
}
}
/// Decide which event a single batch entry earned, if any.
///
/// Only an entry that mutated something may be announced. "No error" is not
/// enough, and neither is `found`: the disk layer skips an absent version and
/// reports success (`delete_versions_internal` in `disk/local.rs` continues
/// past `FileVersionNotFound`), so a batch entry for a version that was
/// already gone comes back indistinguishable from a committed delete. The
/// delete plan's own source lookup is the signal that survives that, and the
/// lifecycle batch path always performs it because every target carries an
/// exact version identity.
fn noncurrent_expiration_event(
bucket: &str,
target: &ObjectToDelete,
deleted: &DeletedObject,
failed: bool,
) -> Option<(EventName, ObjectInfo)> {
if failed || !deleted.found || deleted.source_missing {
return None;
}
// A version removed by explicit version id is a plain versioned delete
// even when that version is itself a delete marker; only a request that
// carried no version id can publish a new delete marker. This is the rule
// the S3 DeleteObjects path applies (issue #6745). `delete_object_versions`
// now refuses targets without an exact version identity, so the
// marker-creation shape is unreachable from that caller; the mapping stays
// here so a future caller cannot silently announce the wrong mutation.
let created_delete_marker = deleted.delete_marker && target.version_id.is_none();
let (event_name, version_id) = if created_delete_marker {
(EventName::LifecycleExpirationDeleteMarkerCreated, deleted.delete_marker_version_id)
} else {
(EventName::LifecycleExpirationDelete, deleted.version_id.or(target.version_id))
};
let object = ObjectInfo {
bucket: bucket.to_string(),
name: target.object_name.clone(),
version_id,
delete_marker: deleted.delete_marker,
..Default::default()
};
Some((event_name, object))
}
fn emit_lifecycle_event(event_name: EventName, object: ObjectInfo, user_agent: &str) {
send_event(EventArgs {
event_name: event_name.to_string(),
@@ -168,7 +113,6 @@ fn non_transitioned_expiration_event_name(
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn transitioned_expiration_event_marks_delete_marker_creation() {
@@ -185,87 +129,4 @@ mod tests {
EventName::LifecycleExpirationDelete
);
}
fn deleted_version(version_id: Uuid) -> DeletedObject {
DeletedObject {
object_name: "object".to_string(),
version_id: Some(version_id),
found: true,
..Default::default()
}
}
fn target_version(version_id: Option<Uuid>) -> ObjectToDelete {
ObjectToDelete {
object_name: "object".to_string(),
version_id,
..Default::default()
}
}
#[test]
fn noncurrent_expiration_emits_versioned_delete_with_exact_identity() {
let version_id = Uuid::new_v4();
let (event_name, object) =
noncurrent_expiration_event("bucket", &target_version(Some(version_id)), &deleted_version(version_id), false)
.expect("a committed delete must emit");
assert_eq!(event_name, EventName::LifecycleExpirationDelete);
assert_eq!(object.bucket, "bucket");
assert_eq!(object.name, "object");
assert_eq!(object.version_id, Some(version_id));
}
/// Removing a noncurrent version that happens to be a delete marker is a
/// plain versioned delete, not a delete-marker creation.
#[test]
fn noncurrent_expiration_of_a_delete_marker_version_is_a_plain_delete() {
let version_id = Uuid::new_v4();
let deleted = DeletedObject {
delete_marker: true,
..deleted_version(version_id)
};
let (event_name, object) = noncurrent_expiration_event("bucket", &target_version(Some(version_id)), &deleted, false)
.expect("a committed delete must emit");
assert_eq!(event_name, EventName::LifecycleExpirationDelete);
assert_eq!(object.version_id, Some(version_id));
}
#[test]
fn noncurrent_expiration_reports_a_created_delete_marker() {
let marker_version_id = Uuid::new_v4();
let deleted = DeletedObject {
object_name: "object".to_string(),
delete_marker: true,
delete_marker_version_id: Some(marker_version_id),
found: true,
..Default::default()
};
let (event_name, object) =
noncurrent_expiration_event("bucket", &target_version(None), &deleted, false).expect("a committed delete must emit");
assert_eq!(event_name, EventName::LifecycleExpirationDeleteMarkerCreated);
assert_eq!(object.version_id, Some(marker_version_id));
}
/// A batch mixes successes with failures and versions that were already
/// gone; only a real mutation may produce an event. A version that was
/// already gone comes back with no error and `found` set, so
/// `source_missing` is the signal that keeps it silent.
#[test]
fn noncurrent_expiration_skips_failed_and_missing_versions() {
let version_id = Uuid::new_v4();
let target = target_version(Some(version_id));
assert!(noncurrent_expiration_event("bucket", &target, &deleted_version(version_id), true).is_none());
let absent = DeletedObject {
source_missing: true,
..deleted_version(version_id)
};
assert!(noncurrent_expiration_event("bucket", &target, &absent, false).is_none());
let not_found = DeletedObject {
found: false,
..deleted_version(version_id)
};
assert!(noncurrent_expiration_event("bucket", &target, &not_found, false).is_none());
}
}
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -15,9 +15,8 @@
use crate::object_api::ObjectInfo;
pub use rustfs_lifecycle::{
Event, ExpirationOptions, IlmAction, LIFECYCLE_MALFORMED_XML_ERROR_KIND, Lifecycle, LifecycleCalculate, ObjectOpts,
RuleValidate, TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due,
expected_expiry_time, expiration_action_has_valid_target,
Event, ExpirationOptions, IlmAction, Lifecycle, LifecycleCalculate, ObjectOpts, RuleValidate, TRANSITION_COMPLETE,
TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due, expected_expiry_time,
};
pub fn object_opts_from_object_info(oi: &ObjectInfo) -> ObjectOpts {
@@ -182,16 +182,6 @@ pub(crate) enum DurableIlmRecordCheckpoint {
identity_sha256: String,
state: tier_delete_journal::TierDeleteDispatchManifestState,
},
TierDeleteDispatchParent {
content_sha256: String,
identity_sha256: String,
revision: u64,
next_chunk_sequence: u64,
completed_journal_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
active_chunk_identity_sha256: Option<String>,
completed: bool,
},
TransitionTransaction {
content_sha256: String,
identity_sha256: String,
@@ -230,7 +220,6 @@ impl DurableIlmRecordCheckpoint {
match self {
Self::TierDeleteJournal { content_sha256, .. }
| Self::TierDeleteDispatchManifest { content_sha256, .. }
| Self::TierDeleteDispatchParent { content_sha256, .. }
| Self::TransitionTransaction { content_sha256, .. }
| Self::ManualTransitionJob { content_sha256, .. }
| Self::ManualTransitionScope { content_sha256, .. }
@@ -352,52 +341,6 @@ impl DurableIlmRecordCheckpoint {
(Preparing, DispatchAuthorized | Aborting) | (Aborting, Aborted) | (DispatchAuthorized, Completed)
)
}
(
Self::TierDeleteDispatchParent {
identity_sha256: previous_identity,
revision: previous_revision,
next_chunk_sequence: previous_sequence,
completed_journal_count: previous_completed_journals,
active_chunk_identity_sha256: previous_active,
completed: previous_completed,
..
},
Self::TierDeleteDispatchParent {
identity_sha256: next_identity,
revision: next_revision,
next_chunk_sequence: next_sequence,
completed_journal_count: next_completed_journals,
active_chunk_identity_sha256: next_active,
completed: next_completed,
..
},
) => {
let Some((sequence_delta, completed_journal_delta)) = tier_delete_dispatch_parent_progress_delta(
*previous_sequence,
*previous_completed_journals,
*next_sequence,
*next_completed_journals,
) else {
return Err(Error::other("durable ILM record generation is not a monotonic successor"));
};
let same_position_transition = sequence_delta == 0
&& completed_journal_delta == 0
&& matches!(
(previous_active.as_ref(), next_active.as_ref(), previous_completed, next_completed),
(None, Some(_), false, false) | (Some(_), None, false, false) | (None, None, false, true)
);
let progress_transition = sequence_delta > 0
&& completed_journal_delta > 0
&& !matches!(
(previous_active.as_ref(), next_active.as_ref()),
(Some(previous), Some(next)) if previous == next
);
previous_identity == next_identity
&& !previous_completed
&& next_revision > previous_revision
&& (!next_completed || next_active.is_none())
&& (same_position_transition || progress_transition)
}
(
Self::TransitionTransaction {
identity_sha256: previous_identity,
@@ -532,58 +475,11 @@ impl DurableIlmRecordCheckpoint {
..
},
) => previous_identity == terminal_identity,
(
Self::TierDeleteDispatchParent {
identity_sha256: previous_identity,
revision: previous_revision,
next_chunk_sequence: previous_sequence,
completed_journal_count: previous_completed_journals,
active_chunk_identity_sha256: previous_active,
completed: false,
..
},
Self::TierDeleteDispatchParent {
identity_sha256: terminal_identity,
revision: terminal_revision,
next_chunk_sequence: terminal_sequence,
completed_journal_count: terminal_completed_journals,
active_chunk_identity_sha256: None,
completed: true,
..
},
) => {
previous_identity == terminal_identity
&& terminal_revision > previous_revision
&& tier_delete_dispatch_parent_progress_delta(
*previous_sequence,
*previous_completed_journals,
*terminal_sequence,
*terminal_completed_journals,
)
.is_some_and(|(sequence_delta, completed_journal_delta)| {
if sequence_delta == 0 && completed_journal_delta == 0 {
previous_active.is_none()
} else {
sequence_delta > 0 && completed_journal_delta > 0
}
})
}
_ => false,
}
}
}
fn tier_delete_dispatch_parent_progress_delta(
previous_sequence: u64,
previous_completed_journals: u64,
next_sequence: u64,
next_completed_journals: u64,
) -> Option<(u64, u64)> {
let sequence_delta = next_sequence.checked_sub(previous_sequence)?;
let completed_journal_delta = next_completed_journals.checked_sub(previous_completed_journals)?;
(sequence_delta <= completed_journal_delta).then_some((sequence_delta, completed_journal_delta))
}
fn transition_state_distance(
from: transition_transaction::TransitionTransactionState,
to: transition_transaction::TransitionTransactionState,
@@ -1017,42 +913,17 @@ pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<Val
)
}
DurableIlmRecordKind::TierDeleteDispatchManifest => {
match tier_delete_journal::validate_tier_delete_dispatch_manifest_record(path, data)? {
tier_delete_journal::TierDeleteDispatchDurableRecord::Manifest {
operation_id,
let (operation_id, identity_sha256, state) =
tier_delete_journal::validate_tier_delete_dispatch_manifest_record(path, data)?;
(
"operation_id",
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
DurableIlmRecordCheckpoint::TierDeleteDispatchManifest {
content_sha256,
identity_sha256,
state,
} => (
"operation_id",
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
DurableIlmRecordCheckpoint::TierDeleteDispatchManifest {
content_sha256,
identity_sha256,
state,
},
),
tier_delete_journal::TierDeleteDispatchDurableRecord::Parent {
operation_id,
identity_sha256,
revision,
next_chunk_sequence,
completed_journal_count,
active_chunk_identity_sha256,
completed,
} => (
"operation_id",
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
DurableIlmRecordCheckpoint::TierDeleteDispatchParent {
content_sha256,
identity_sha256,
revision,
next_chunk_sequence,
completed_journal_count,
active_chunk_identity_sha256,
completed,
},
),
}
},
)
}
DurableIlmRecordKind::TransitionTransaction => {
let transaction = transition_transaction::decode_transition_transaction_record(path, data)
@@ -1272,85 +1143,6 @@ mod tests {
assert!(aborted.validate_successor(&preparing).is_err());
}
#[test]
fn tier_delete_dispatch_parent_checkpoint_is_monotonic_across_chunks() {
let identity = "a".repeat(64);
let checkpoint = |revision, sequence, completed_journals, active: Option<&str>, completed| {
DurableIlmRecordCheckpoint::TierDeleteDispatchParent {
content_sha256: format!("{revision:064x}"),
identity_sha256: identity.clone(),
revision,
next_chunk_sequence: sequence,
completed_journal_count: completed_journals,
active_chunk_identity_sha256: active.map(ToOwned::to_owned),
completed,
}
};
let idle = checkpoint(0, 0, 0, None, false);
let first_child = "b".repeat(64);
let second_child = "c".repeat(64);
let bound = checkpoint(1, 0, 0, Some(&first_child), false);
let advanced = checkpoint(2, 1, 2, None, false);
let next_bound = checkpoint(3, 1, 2, Some(&second_child), false);
let completed = checkpoint(4, 2, 3, None, true);
let terminal_after_more_chunks = checkpoint(6, 4, 7, None, true);
idle.validate_successor(&bound).expect("an idle parent may bind one child");
bound
.validate_successor(&advanced)
.expect("a completed child may advance the parent sequence");
advanced
.validate_successor(&next_bound)
.expect("the next sequence may bind a new immutable child");
next_bound
.validate_successor(&completed)
.expect("receipt progress may skip directly to a later terminal checkpoint");
assert!(
bound.is_predecessor_of_terminal(&terminal_after_more_chunks),
"terminal cleanup may still recognize a valid multi-chunk predecessor"
);
assert!(
advanced.is_predecessor_of_terminal(&terminal_after_more_chunks),
"terminal cleanup may still skip over later valid parent generations"
);
assert!(
idle.validate_successor(&checkpoint(1, 0, 1, Some(&first_child), false))
.is_err()
);
assert!(bound.validate_successor(&checkpoint(2, 1, 0, None, false)).is_err());
assert!(
bound.validate_successor(&checkpoint(2, 2, 1, None, false)).is_err(),
"sequence cannot advance beyond completed journal evidence"
);
assert!(
advanced.validate_successor(&checkpoint(3, 1, 3, None, false)).is_err(),
"completed journal count cannot grow without a completed child sequence"
);
assert!(
bound.validate_successor(&checkpoint(2, 0, 0, None, true)).is_err(),
"an active child cannot be marked completed without completion evidence"
);
assert!(
bound
.validate_successor(&checkpoint(2, 0, 0, Some(&second_child), false))
.is_err(),
"an active child cannot be replaced at the same parent sequence"
);
assert!(
bound
.validate_successor(&checkpoint(2, 1, 1, Some(&first_child), false))
.is_err(),
"sequence growth cannot retain the same active child identity"
);
assert!(
!bound.is_predecessor_of_terminal(&checkpoint(2, 0, 0, None, true)),
"terminal cleanup must not treat an active child as completed without count evidence"
);
assert!(completed.validate_successor(&checkpoint(5, 3, 4, None, true)).is_err());
assert!(completed.validate_successor(&advanced).is_err());
assert!(advanced.validate_successor(&idle).is_err());
}
#[test]
fn tier_delete_journal_checkpoint_binds_dispatch_and_full_state_monotonically() {
use crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::{Committed, Dispatched, Prepared};
@@ -20,7 +20,6 @@ const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle";
const EVENT_LIFECYCLE_CLEANUP_SKIPPED: &str = "lifecycle_cleanup_skipped";
const EVENT_LIFECYCLE_CLEANUP_FAILED: &str = "lifecycle_cleanup_failed";
use crate::bucket::lifecycle::bucket_lifecycle_audit::emit_noncurrent_expiration_event;
use crate::bucket::lifecycle::lifecycle;
use crate::bucket::lifecycle::replication_sink::{self, ReplicationObjectBridge};
use crate::object_api::ObjectOptions;
@@ -35,23 +34,7 @@ pub async fn delete_object_versions(
to_del: &[ObjectToDelete],
_lc_event: lifecycle::Event,
bucket_incarnation_id: Uuid,
) -> usize {
if to_del.iter().any(|target| {
target.version_id.is_none()
|| (target.version_id.is_some_and(|version_id| version_id.is_nil()) && target.expected_identity.is_none())
}) {
debug!(
event = EVENT_LIFECYCLE_CLEANUP_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
bucket,
target_count = to_del.len(),
reason = "incomplete_version_identity",
"Skipped lifecycle noncurrent version cleanup"
);
return to_del.len();
}
) {
let delete_config_snapshot = match ReplicationObjectBridge::delete_request_config(api, bucket).await {
Ok(snapshot) => Arc::new(snapshot),
Err(err) => {
@@ -64,11 +47,10 @@ pub async fn delete_object_versions(
reason = "delete_config_snapshot_unavailable",
"Skipped lifecycle noncurrent version cleanup"
);
return to_del.len();
return;
}
};
let mut remaining = to_del;
let mut failed = 0;
loop {
let mut to_del = remaining;
if to_del.len() > MAX_DELETE_LIST {
@@ -89,7 +71,6 @@ pub async fn delete_object_versions(
},
)
.await;
failed += errors.iter().filter(|err| err.is_some()).count();
for (i, deleted_obj) in deleted_objs.iter_mut().enumerate() {
if errors.get(i).and_then(|err| err.as_ref()).is_some() {
@@ -99,12 +80,6 @@ pub async fn delete_object_versions(
// version so it does not sit resident until TTL (ODC-26).
if let Some(target) = to_del.get(i) {
crate::object_api::notify_object_mutation(bucket, &target.object_name).await;
// Announce the version this batch actually removed. Cache
// eviction and replication scheduling keep their existing
// order and admission; the event is derived from the committed
// result, and a send failure never rolls back a delete that
// already happened (backlog#2202).
emit_noncurrent_expiration_event(bucket, target, deleted_obj, false);
}
if deleted_obj.replication_state.is_none() {
continue;
@@ -136,5 +111,4 @@ pub async fn delete_object_versions(
break;
}
}
failed
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_scanner_metrics::metrics::IlmAction;
use rustfs_scanner_contracts::metrics::IlmAction;
use crate::bucket::lifecycle::lifecycle::ObjectOpts;
use crate::bucket::replication::ReplicationLifecycleBridge;
@@ -77,7 +77,7 @@ mod tests {
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
use crate::object_api::{ObjectInfo, ObjectOptions};
use crate::storage_api_contracts::object::ObjectToDelete;
use rustfs_scanner_metrics::metrics::IlmAction;
use rustfs_scanner_contracts::metrics::IlmAction;
use s3s::dto::{
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication,
DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
File diff suppressed because it is too large Load Diff
@@ -32,10 +32,7 @@ use crate::store::ECStore;
use rustfs_filemeta::FileInfo;
pub const DEFAULT_FREE_VERSION_RECOVERY_LIMIT: usize = 1_000;
// These are page-wide repair budgets. Applying them per bucket would still let
// one recovery pass walk an unbounded namespace before the scheduler can cool down.
const DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT: usize = 10_000;
const DEFAULT_FREE_VERSION_RECOVERY_BUCKET_LIMIT: usize = 100;
#[cfg(not(test))]
const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(test)]
@@ -44,12 +41,6 @@ const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, crate::error::Error>;
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct RecoveryWorkBudget {
pub(super) max_objects: usize,
pub(super) max_buckets: usize,
}
fn recovery_walk_options(limit: usize, marker: Option<String>) -> WalkOptions {
WalkOptions {
include_free_versions: true,
@@ -67,7 +58,6 @@ fn recovery_walk_options(limit: usize, marker: Option<String>) -> WalkOptions {
#[cfg(test)]
pub(super) enum RecoveryWalkTestAction {
SendItems(Vec<ObjectInfo>),
SendItemsThenError(Vec<ObjectInfo>, crate::error::Error),
SendItemsThenHang(Vec<ObjectInfo>, Arc<tokio::sync::Notify>),
SendItemsUntilReceiverCloses(Arc<tokio::sync::Notify>),
@@ -287,17 +277,6 @@ pub(super) async fn list_tier_free_versions(
bucket_marker: Option<String>,
object_marker: Option<String>,
cancel_token: CancellationToken,
) -> Result<FreeVersionRecoveryPage> {
list_tier_free_versions_with_budget(api, limit, bucket_marker, object_marker, cancel_token, recovery_work_budget(limit)).await
}
pub(super) async fn list_tier_free_versions_with_budget(
api: Arc<ECStore>,
limit: usize,
bucket_marker: Option<String>,
object_marker: Option<String>,
cancel_token: CancellationToken,
work_budget: RecoveryWorkBudget,
) -> Result<FreeVersionRecoveryPage> {
let mut page = FreeVersionRecoveryPage {
items: Vec::new(),
@@ -311,9 +290,6 @@ pub(super) async fn list_tier_free_versions_with_budget(
if limit == 0 {
return Ok(page);
}
if work_budget.max_objects == 0 || work_budget.max_buckets == 0 {
return Err(std::io::Error::other("free-version recovery work budget must be greater than zero").into());
}
let bucket_options = BucketOptions::default();
let list_buckets = async {
@@ -337,7 +313,7 @@ pub(super) async fn list_tier_free_versions_with_budget(
};
let mut bucket_seen = bucket_marker.is_none();
let mut truncated_after: Option<RecoveryCursor> = None;
let mut remaining_scan_objects = work_budget.max_objects;
let walk_scan_limit = recovery_walk_scan_limit(limit);
for bucket in buckets {
if cancel_token.is_cancelled() {
@@ -353,20 +329,12 @@ pub(super) async fn list_tier_free_versions_with_budget(
bucket_seen = true;
}
if page.buckets_scanned >= work_budget.max_buckets {
page.truncated = true;
page.next_bucket_marker = Some(bucket.name);
page.next_object_marker = None;
break;
}
page.buckets_scanned += 1;
let bucket_object_marker = if bucket_marker.as_deref() == Some(bucket.name.as_str()) {
object_marker.clone()
} else {
None
};
let bucket_walk_limit = remaining_scan_objects;
let (tx, mut rx) = mpsc::channel::<ObjectInfoOrErr>(100);
let cancel = cancel_token.child_token();
@@ -390,21 +358,6 @@ pub(super) async fn list_tier_free_versions_with_budget(
#[cfg(test)]
if let Some(action) = test_action {
match action {
RecoveryWalkTestAction::SendItems(items) => {
for item in items {
if tx
.send(ObjectInfoOrErr {
item: Some(item),
err: None,
})
.await
.is_err()
{
return Ok(());
}
}
return Ok(());
}
RecoveryWalkTestAction::SendItemsThenError(items, err) => {
for item in items {
if tx
@@ -472,7 +425,7 @@ pub(super) async fn list_tier_free_versions_with_budget(
}
}
api.walk(cancel, &bucket_name, "", tx, recovery_walk_options(bucket_walk_limit, object_marker))
api.walk(cancel, &bucket_name, "", tx, recovery_walk_options(walk_scan_limit, object_marker))
.await
}
});
@@ -513,18 +466,6 @@ pub(super) async fn list_tier_free_versions_with_budget(
let Some(oi) = item.item else {
continue;
};
if last_seen_object.as_deref() != Some(oi.name.as_str()) && scanned_objects >= bucket_walk_limit {
// The disk listing limit follows S3-visible counting rules and
// does not charge metadata containing only hidden/free versions.
// Enforce the repair budget again at the decoded-object boundary.
page.truncated = true;
page.next_bucket_marker = Some(bucket.name.clone());
page.next_object_marker = last_seen_object.clone();
cancel.cancel();
draining_after_truncation = true;
drain_deadline = Some(tokio::time::Instant::now() + BACKGROUND_WALK_SHUTDOWN_TIMEOUT);
continue;
}
record_scanned_object(&mut last_seen_object, &mut scanned_objects, &oi.name);
if let Some(cursor) = &truncated_after
&& (cursor.bucket.as_str() != bucket.name.as_str() || cursor.object.as_str() != oi.name.as_str())
@@ -568,8 +509,7 @@ pub(super) async fn list_tier_free_versions_with_budget(
return Err(err);
}
walk_result?;
remaining_scan_objects = remaining_scan_objects.saturating_sub(scanned_objects);
mark_scan_truncated_if_needed(&mut page, scanned_objects, bucket_walk_limit, &bucket.name, last_seen_object.as_deref());
mark_scan_truncated_if_needed(&mut page, scanned_objects, walk_scan_limit, &bucket.name, last_seen_object.as_deref());
if page.truncated {
break;
@@ -588,13 +528,6 @@ fn recovery_walk_scan_limit(limit: usize) -> usize {
DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT.max(limit.saturating_add(1))
}
fn recovery_work_budget(limit: usize) -> RecoveryWorkBudget {
RecoveryWorkBudget {
max_objects: recovery_walk_scan_limit(limit),
max_buckets: DEFAULT_FREE_VERSION_RECOVERY_BUCKET_LIMIT,
}
}
fn record_scanned_object(last_seen_object: &mut Option<String>, scanned_objects: &mut usize, object: &str) {
if last_seen_object.as_deref() == Some(object) {
return;
@@ -767,13 +700,6 @@ mod tests {
recovery_walk_scan_limit(DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT),
DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT + 1
);
assert_eq!(
recovery_work_budget(DEFAULT_FREE_VERSION_RECOVERY_LIMIT),
RecoveryWorkBudget {
max_objects: DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT,
max_buckets: DEFAULT_FREE_VERSION_RECOVERY_BUCKET_LIMIT,
}
);
}
#[test]
@@ -19,7 +19,6 @@
#![allow(clippy::all)]
use rustfs_data_usage::TierStats;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashMap;
use std::ops::Sub;
@@ -28,24 +27,7 @@ use tracing::{error, warn};
pub type DailyAllTierStats = HashMap<String, LastDayTierStats>;
/// One bin per hour of the rolling day. The bin index is the UTC hour, so the
/// array is a ring the writer ages forward rather than a queue.
pub const TIER_DAILY_STATS_BINS: usize = 24;
/// Interchange form of [`LastDayTierStats`] for the internode tier-stats RPC.
///
/// The in-memory type keeps its bins private because the ring is only
/// meaningful together with `updated_at`; this type carries both across the
/// wire and is validated back into the ring by [`LastDayTierStats::from_wire`].
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TierDailyStatsWire {
pub bins: Vec<TierStats>,
/// Seconds since the Unix epoch. Bins are hour-resolution, so a coarser
/// timestamp than the in-memory `OffsetDateTime` loses nothing.
pub updated_at_unix_secs: i64,
}
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct LastDayTierStats {
bins: [TierStats; 24],
updated_at: OffsetDateTime,
@@ -98,57 +80,11 @@ impl LastDayTierStats {
}
}
/// The rolling ring as observed, without aging it forward.
///
/// Only meaningful together with [`LastDayTierStats::updated_at`]: a bin
/// belongs to the hour of its index within the day that ends at
/// `updated_at`.
pub fn bins(&self) -> &[TierStats; TIER_DAILY_STATS_BINS] {
&self.bins
}
pub fn updated_at(&self) -> OffsetDateTime {
self.updated_at
}
pub fn to_wire(&self) -> TierDailyStatsWire {
TierDailyStatsWire {
bins: self.bins.to_vec(),
updated_at_unix_secs: self.updated_at.unix_timestamp(),
}
}
/// Rebuild the ring from a peer's response.
///
/// A ring of the wrong width or an unrepresentable timestamp is corrupt
/// peer input, not a zero sample: it returns an error so the caller can
/// report the node as non-reporting instead of merging a plausible but
/// wrong day into a cluster total.
pub fn from_wire(wire: TierDailyStatsWire) -> Result<Self, std::io::Error> {
let bins: [TierStats; TIER_DAILY_STATS_BINS] = wire.bins.try_into().map_err(|bins: Vec<TierStats>| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("tier daily stats must carry {TIER_DAILY_STATS_BINS} bins, got {}", bins.len()),
)
})?;
let updated_at = OffsetDateTime::from_unix_timestamp(wire.updated_at_unix_secs).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("tier daily stats carry an unrepresentable timestamp: {err}"),
)
})?;
Ok(Self { bins, updated_at })
}
/// Combine two independently observed rings.
///
/// Each node counts only the transitions it completed itself, so summing
/// bins across nodes is a cluster total rather than a double count. The
/// older ring is aged forward to the newer one's clock first, so a node
/// that stopped transitioning hours ago contributes its still-current
/// bins and not its expired ones.
pub fn merge(&self, m: LastDayTierStats) -> LastDayTierStats {
#[allow(
dead_code,
reason = "asserted by this file's tests; the lib target cannot see test-only consumers (backlog#1823)"
)]
fn merge(&self, m: LastDayTierStats) -> LastDayTierStats {
let mut cl = self.clone();
let mut cm = m;
let mut merged = LastDayTierStats::default();
@@ -172,7 +108,6 @@ impl LastDayTierStats {
#[cfg(test)]
mod test {
use super::*;
use time::Duration;
#[test]
fn total_sums_all_recorded_stats() {
@@ -197,83 +132,4 @@ mod test {
}
);
}
fn sample(total_size: u64) -> TierStats {
TierStats {
total_size,
num_versions: 1,
num_objects: 1,
}
}
#[test]
fn wire_round_trip_preserves_the_ring_and_its_clock() {
let mut stats = LastDayTierStats::default();
stats.add_stats(sample(10));
let restored = LastDayTierStats::from_wire(stats.to_wire()).expect("a ring this node produced must decode");
assert_eq!(restored.bins(), stats.bins(), "every bin must survive the wire");
assert_eq!(
restored.updated_at().unix_timestamp(),
stats.updated_at().unix_timestamp(),
"the ring's clock must survive the wire"
);
}
#[test]
fn a_ring_of_the_wrong_width_is_rejected() {
let mut wire = LastDayTierStats::default().to_wire();
wire.bins.pop();
let err = LastDayTierStats::from_wire(wire).expect_err("a short ring must not decode as a zero day");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn an_unrepresentable_clock_is_rejected() {
let mut wire = LastDayTierStats::default().to_wire();
wire.updated_at_unix_secs = i64::MIN;
let err = LastDayTierStats::from_wire(wire).expect_err("an unrepresentable clock must not decode");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn merge_sums_two_nodes_that_transitioned_in_the_same_hour() {
let mut left = LastDayTierStats::default();
left.add_stats(sample(10));
let mut right = LastDayTierStats::default();
right.add_stats(sample(20));
assert_eq!(
left.merge(right).total(),
TierStats {
total_size: 30,
num_versions: 2,
num_objects: 2,
},
"each node counts only its own completions, so a merge is a cluster total"
);
}
#[test]
fn merge_ages_out_a_peer_ring_older_than_a_day() {
let mut stale = LastDayTierStats::default();
stale.add_stats(sample(10));
stale.updated_at -= Duration::days(2);
let mut fresh = LastDayTierStats::default();
fresh.add_stats(sample(20));
assert_eq!(
fresh.merge(stale).total(),
TierStats {
total_size: 20,
num_versions: 1,
num_objects: 1,
},
"a node that stopped transitioning more than a day ago must not keep contributing"
);
}
}
@@ -575,7 +575,6 @@ pub(crate) async fn delete_confirmed_transition_candidate_exact_with_lease_idemp
#[cfg(test)]
static CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)]
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_manager_and_identity(
obj_name: &str,
rv_id: &str,
@@ -25,17 +25,14 @@ use crate::bucket::lifecycle::durable_namespace::TRANSITION_TRANSACTION_NAMESPAC
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
use crate::bucket::lifecycle::tier_sweeper::{
delete_confirmed_transition_candidate_exact_with_lease_idempotent,
delete_confirmed_transition_candidate_exact_with_manager_and_identity,
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
};
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result as EcstoreResult};
use crate::object_api::ObjectOptions;
use crate::services::tier::{tier::TierConfigMgr, warm_backend::TransitionCandidateProbe};
use crate::storage_api_contracts::{
list::ListOperations as _,
namespace::NamespaceLocking as _,
object::{HTTPPreconditions, ObjectOperations as _},
};
use crate::storage_api_contracts::{list::ListOperations as _, object::ObjectOperations as _};
use crate::store::ECStore;
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
@@ -113,7 +110,7 @@ pub struct TransitionRemoteVersion {
impl TransitionRemoteVersion {
pub fn known_from_put_response(version_id: impl Into<String>) -> Self {
let version_id = version_id.into();
if version_id.is_empty() {
if version_id.is_empty() || Uuid::parse_str(&version_id).is_ok_and(|parsed| parsed.is_nil()) {
Self::unversioned()
} else {
Self::versioned(version_id)
@@ -310,16 +307,10 @@ impl TransitionTransaction {
if self.write_id.is_nil() {
return Err(TransitionTransactionError::Corrupt("write_id is nil"));
}
if self.not_after_unix_nanos <= 0 {
return Err(TransitionTransactionError::Corrupt("ownership deadline is not positive"));
}
self.source.validate()?;
if self.tier_name.is_empty() {
return Err(TransitionTransactionError::Corrupt("tier name is empty"));
}
if self.backend_fingerprint == [0; 32] {
return Err(TransitionTransactionError::Corrupt("backend fingerprint is empty"));
}
if self.remote_object
!= canonical_transition_remote_object(self.deployment_id, &self.source.bucket, self.transaction_id, self.write_id)?
{
@@ -485,18 +476,6 @@ impl TransitionTransaction {
Ok(())
}
fn has_same_immutable_identity(&self, other: &Self) -> bool {
self.deployment_id == other.deployment_id
&& self.transaction_id == other.transaction_id
&& self.owner_epoch == other.owner_epoch
&& self.write_id == other.write_id
&& self.source == other.source
&& self.tier_name == other.tier_name
&& self.backend_fingerprint == other.backend_fingerprint
&& self.remote_object == other.remote_object
&& self.not_after_unix_nanos == other.not_after_unix_nanos
}
fn validate_cleanup_proof(&self, proof: &TransitionCleanupProof) -> Result<()> {
if proof.transaction_id != self.transaction_id
|| proof.write_id != self.write_id
@@ -606,91 +585,20 @@ pub(crate) async fn save_transition_transaction_record(
let object =
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
let data = transaction.encode().map_err(transition_transaction_store_error)?;
config_boundary::save_config_with_opts(
api.clone(),
&object,
data.clone(),
&ObjectOptions {
max_parity: true,
http_preconditions: Some(HTTPPreconditions {
if_none_match: Some("*".to_string()),
..Default::default()
}),
..Default::default()
},
)
.await?;
config_boundary::save_config(api.clone(), &object, data.clone()).await?;
// Box::pin: the durable-receipt state machine is large and sits on the
// already-deep transition worker poll chain; keeping it inline overflows
// the default 2 MiB tokio worker stack in debug builds.
Box::pin(api.record_durable_ilm_decommission_progress(&object, &data)).await
}
pub(crate) async fn save_transition_transaction_record_if_current(
api: Arc<ECStore>,
expected: &TransitionTransaction,
next: &TransitionTransaction,
) -> EcstoreResult<()> {
let object = transition_transaction_record_object_name(next.transaction_id).map_err(transition_transaction_store_error)?;
let revision_is_next = expected.revision.checked_add(1) == Some(next.revision);
let state_is_next = state_change_allowed(expected.state, next.state)
|| matches!(
(expected.state, next.state),
(
TransitionTransactionState::Uploaded
| TransitionTransactionState::UploadOutcomeUnknown
| TransitionTransactionState::LocalCommitStarted,
TransitionTransactionState::CleanupPending
)
);
let remote_version_is_monotonic = expected.remote_version.is_unknown() || expected.remote_version == next.remote_version;
if !expected.has_same_immutable_identity(next) || !revision_is_next || !state_is_next || !remote_version_is_monotonic {
return Err(Error::PreconditionFailed);
}
let (current, etag) = load_transition_transaction_record_with_etag(api.clone(), expected.transaction_id).await?;
if &current != expected {
return Err(Error::PreconditionFailed);
}
let data = next.encode().map_err(transition_transaction_store_error)?;
config_boundary::save_config_with_opts(
api.clone(),
&object,
data.clone(),
&ObjectOptions {
max_parity: true,
http_preconditions: Some(HTTPPreconditions {
if_match: Some(etag),
..Default::default()
}),
..Default::default()
},
)
.await?;
// Box::pin: see save_transition_transaction_record.
Box::pin(api.record_durable_ilm_decommission_progress(&object, &data)).await
}
pub(crate) async fn load_transition_transaction_record(
api: Arc<ECStore>,
transaction_id: Uuid,
) -> EcstoreResult<TransitionTransaction> {
load_transition_transaction_record_with_etag(api, transaction_id)
.await
.map(|(transaction, _)| transaction)
}
async fn load_transition_transaction_record_with_etag(
api: Arc<ECStore>,
transaction_id: Uuid,
) -> EcstoreResult<(TransitionTransaction, String)> {
let object = transition_transaction_record_object_name(transaction_id).map_err(transition_transaction_store_error)?;
let (data, object_info) = config_boundary::read_config_with_metadata(api, &object, &ObjectOptions::default()).await?;
let etag = object_info
.etag
.filter(|etag| !etag.trim().is_empty())
.ok_or_else(|| Error::other("transition transaction record is missing an ETag"))?;
let transaction = TransitionTransaction::decode(transaction_id, &data).map_err(transition_transaction_store_error)?;
Ok((transaction, etag))
let data = config_boundary::read_config(api, &object).await?;
TransitionTransaction::decode(transaction_id, &data).map_err(transition_transaction_store_error)
}
pub(crate) async fn delete_transition_transaction_record(
@@ -699,18 +607,10 @@ pub(crate) async fn delete_transition_transaction_record(
) -> EcstoreResult<()> {
let object =
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
let (current, etag) = match load_transition_transaction_record_with_etag(api.clone(), transaction.transaction_id).await {
Ok(record) => record,
Err(Error::ConfigNotFound) => return Ok(()),
Err(err) => return Err(err),
};
if &current != transaction {
return Err(Error::PreconditionFailed);
}
let data = current.encode().map_err(transition_transaction_store_error)?;
let data = transaction.encode().map_err(transition_transaction_store_error)?;
// Box::pin: see save_transition_transaction_record.
Box::pin(api.record_durable_ilm_decommission_terminal(&object, &data)).await?;
match config_boundary::delete_config_if_match(api, &object, &etag).await {
match config_boundary::delete_config(api, &object).await {
Ok(()) | Err(Error::ConfigNotFound) => Ok(()),
Err(err) => Err(err),
}
@@ -737,84 +637,6 @@ pub enum TransitionTransactionRecoveryOutcome {
Retained,
}
#[cfg(test)]
#[derive(Default)]
struct TransitionRecoveryClaimBarrierState {
transaction_id: Uuid,
arrived: tokio::sync::Notify,
release: tokio::sync::Notify,
}
#[cfg(test)]
pub(crate) struct TransitionRecoveryClaimBarrier {
state: Arc<TransitionRecoveryClaimBarrierState>,
}
#[cfg(test)]
static TRANSITION_RECOVERY_CLAIM_BARRIER: std::sync::OnceLock<
std::sync::Mutex<Option<Arc<TransitionRecoveryClaimBarrierState>>>,
> = std::sync::OnceLock::new();
#[cfg(test)]
impl TransitionRecoveryClaimBarrier {
pub(crate) fn install(transaction_id: Uuid) -> Self {
let state = Arc::new(TransitionRecoveryClaimBarrierState {
transaction_id,
..Default::default()
});
let mut slot = TRANSITION_RECOVERY_CLAIM_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("transition recovery claim barrier mutex should not poison");
assert!(
slot.is_none(),
"transition recovery claim barrier must be installed by one test at a time"
);
*slot = Some(Arc::clone(&state));
drop(slot);
Self { state }
}
pub(crate) async fn wait_until_paused(&self) {
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
.await
.expect("transition recovery should reach the cleanup claim CAS");
}
pub(crate) fn release(&self) {
self.state.release.notify_one();
}
}
#[cfg(test)]
impl Drop for TransitionRecoveryClaimBarrier {
fn drop(&mut self) {
self.state.release.notify_one();
let mut slot = TRANSITION_RECOVERY_CLAIM_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("transition recovery claim barrier mutex should not poison");
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
*slot = None;
}
}
}
#[cfg(test)]
async fn pause_before_transition_recovery_claim(transaction_id: Uuid) {
let barrier = TRANSITION_RECOVERY_CLAIM_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("transition recovery claim barrier mutex should not poison")
.as_ref()
.filter(|barrier| barrier.transaction_id == transaction_id)
.cloned();
if let Some(barrier) = barrier {
barrier.arrived.notify_one();
barrier.release.notified().await;
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TransitionOperatorProbe {
@@ -1036,129 +858,49 @@ pub async fn process_transition_transaction_record(
transaction: &TransitionTransaction,
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
transaction.validate().map_err(transition_transaction_store_error)?;
// Box the expanded recovery state machine so callers on Tokio's default
// worker stack do not inline its full future into an already-deep scan.
Box::pin(process_transition_transaction_record_at(
api,
transaction,
time::OffsetDateTime::now_utc().unix_timestamp_nanos(),
))
.await
}
async fn process_transition_transaction_record_at(
api: Arc<ECStore>,
observed: &TransitionTransaction,
now_unix_nanos: i128,
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
let record_name =
transition_transaction_record_object_name(observed.transaction_id).map_err(transition_transaction_store_error)?;
// The synthetic key avoids nesting the recovery lock with the config
// object's own I/O lock. Holding it across the bounded source proof and
// remote DELETE elects one destructive recovery worker across nodes.
let recovery_lock = api
.new_ns_lock(RUSTFS_META_BUCKET, &format!("{record_name}.recovery-lock"))
.await?;
let _recovery_guard = recovery_lock
.get_write_lock(crate::set_disk::get_lock_acquire_timeout())
.await?;
let current = match load_transition_transaction_record(api.clone(), observed.transaction_id).await {
Ok(current) => current,
Err(Error::ConfigNotFound) => return Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
Err(err) => return Err(err),
};
if &current != observed {
return Ok(TransitionTransactionRecoveryOutcome::Retained);
}
match current.state {
match transaction.state {
TransitionTransactionState::Uploaded => {
if transition_transaction_ownership_is_active(&current, now_unix_nanos) {
return Ok(TransitionTransactionRecoveryOutcome::Retained);
delete_transition_remote_candidate(api.clone(), transaction).await?;
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
TransitionTransactionState::CleanupPending => match local_commit_matches_transaction(api.clone(), transaction).await {
Ok(true) => {
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
let mut cleanup = current.clone();
cleanup
.mark_cleanup_pending(
current.fence(),
TransitionCleanupProof {
transaction_id: current.transaction_id,
write_id: current.write_id,
remote_object: current.remote_object.clone(),
remote_version: current.remote_version.clone(),
backend_fingerprint: current.backend_fingerprint,
decision: TransitionCleanupDecision::UploadAbortedBeforeLocalCommit,
},
)
.map_err(transition_transaction_store_error)?;
#[cfg(test)]
pause_before_transition_recovery_claim(current.transaction_id).await;
match save_transition_transaction_record_if_current(api.clone(), &current, &cleanup).await {
Ok(()) => recover_cleanup_pending(api, &cleanup).await,
Err(Error::PreconditionFailed) | Err(Error::ConfigNotFound) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Ok(false) => {
delete_transition_remote_candidate(api.clone(), transaction).await?;
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
Err(err) if transition_source_is_missing(&err) => {
delete_transition_remote_candidate(api.clone(), transaction).await?;
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
Err(err) => Err(err),
},
TransitionTransactionState::LocalCommitStarted => {
match local_commit_matches_transaction(api.clone(), transaction).await {
Ok(true) => {
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Err(err) => Err(err),
}
}
TransitionTransactionState::CleanupPending => recover_cleanup_pending(api, &current).await,
TransitionTransactionState::LocalCommitStarted => match local_commit_matches_transaction(api.clone(), &current).await {
Ok(true) => {
delete_transition_transaction_record(api, &current).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Err(err) => Err(err),
},
TransitionTransactionState::AbortedNoRemote | TransitionTransactionState::Committed => {
delete_transition_transaction_record(api, &current).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
TransitionTransactionState::UploadOutcomeUnknown => {
if transition_transaction_ownership_is_active(&current, now_unix_nanos) {
Ok(TransitionTransactionRecoveryOutcome::Retained)
} else {
recover_unknown_upload_outcome(api, &current).await
}
}
TransitionTransactionState::UploadStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
}
}
fn transition_transaction_ownership_is_active(transaction: &TransitionTransaction, now_unix_nanos: i128) -> bool {
now_unix_nanos < i128::from(transaction.not_after_unix_nanos)
}
async fn recover_cleanup_pending(
api: Arc<ECStore>,
transaction: &TransitionTransaction,
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
match local_commit_matches_transaction(api.clone(), transaction).await {
Ok(true) => {
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
Ok(false) => delete_unreferenced_transition_candidate(api, transaction).await,
Err(err) if transition_source_is_missing(&err) => delete_unreferenced_transition_candidate(api, transaction).await,
Err(err) => Err(err),
TransitionTransactionState::UploadOutcomeUnknown => recover_unknown_upload_outcome(api, transaction).await,
TransitionTransactionState::UploadStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
}
}
async fn delete_unreferenced_transition_candidate(
api: Arc<ECStore>,
transaction: &TransitionTransaction,
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
let current = match load_transition_transaction_record(api.clone(), transaction.transaction_id).await {
Ok(current) => current,
Err(Error::ConfigNotFound) => return Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
Err(err) => return Err(err),
};
if &current != transaction || current.state != TransitionTransactionState::CleanupPending {
return Ok(TransitionTransactionRecoveryOutcome::Retained);
}
delete_transition_remote_candidate(api.clone(), &current).await?;
delete_transition_transaction_record(api, &current).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
async fn recover_unknown_upload_outcome(
api: Arc<ECStore>,
transaction: &TransitionTransaction,
@@ -1186,7 +928,17 @@ async fn recover_unknown_upload_outcome(
TransitionCandidateProbe::VersionedPresent(version_id)
if Uuid::parse_str(&version_id).is_ok_and(|version_id| version_id.is_nil()) =>
{
Ok(TransitionTransactionRecoveryOutcome::Retained)
delete_confirmed_transition_candidate_exact_with_manager_and_identity(
&transaction.remote_object,
&version_id,
&transaction.tier_name,
transaction.backend_fingerprint,
&api.tier_config_mgr(),
)
.await
.map_err(Error::other)?;
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
TransitionCandidateProbe::VersionedPresent(version_id) => {
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned(version_id)).await
@@ -1216,11 +968,10 @@ async fn cleanup_recovered_unknown_upload_candidate(
},
)
.map_err(transition_transaction_store_error)?;
match save_transition_transaction_record_if_current(api.clone(), transaction, &cleanup).await {
Ok(()) => recover_cleanup_pending(api, &cleanup).await,
Err(Error::PreconditionFailed) | Err(Error::ConfigNotFound) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Err(err) => Err(err),
}
save_transition_transaction_record(api.clone(), &cleanup).await?;
delete_transition_remote_candidate(api.clone(), &cleanup).await?;
delete_transition_transaction_record(api, &cleanup).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
fn transition_source_is_missing(err: &Error) -> bool {
@@ -1235,7 +986,13 @@ fn transition_source_is_missing(err: &Error) -> bool {
}
async fn local_commit_matches_transaction(api: Arc<ECStore>, transaction: &TransitionTransaction) -> EcstoreResult<bool> {
let opts = transition_source_lookup_options(transaction);
let opts = ObjectOptions {
version_id: transaction.source.version_id.map(|version_id| version_id.to_string()),
versioned: transaction.source.version_mode == TransitionSourceVersionMode::Versioned,
version_suspended: transaction.source.version_mode == TransitionSourceVersionMode::VersionSuspended,
metadata_cache_safe: false,
..Default::default()
};
let object = api
.get_object_info(&transaction.source.bucket, &transaction.source.object, &opts)
.await?;
@@ -1246,23 +1003,6 @@ async fn local_commit_matches_transaction(api: Arc<ECStore>, transaction: &Trans
&& transitioned.version_id == transaction.remote_version.tier_delete_version_id().unwrap_or_default())
}
fn transition_source_lookup_options(transaction: &TransitionTransaction) -> ObjectOptions {
ObjectOptions {
version_id: match transaction.source.version_mode {
TransitionSourceVersionMode::Versioned => transaction.source.version_id.map(|version_id| version_id.to_string()),
// Both modes identify the stored null version. Query it explicitly
// so a later versioning change cannot redirect the proof to a new latest version.
TransitionSourceVersionMode::Unversioned | TransitionSourceVersionMode::VersionSuspended => {
Some(Uuid::nil().to_string())
}
},
versioned: transaction.source.version_mode == TransitionSourceVersionMode::Versioned,
version_suspended: transaction.source.version_mode == TransitionSourceVersionMode::VersionSuspended,
metadata_cache_safe: false,
..Default::default()
}
}
async fn delete_transition_remote_candidate(api: Arc<ECStore>, transaction: &TransitionTransaction) -> EcstoreResult<()> {
let version_id = transaction.remote_version.tier_delete_version_id().unwrap_or_default();
let version_id_exact = transaction.remote_version.kind == TransitionRemoteVersionKind::Versioned;
@@ -1283,25 +1023,6 @@ pub async fn recover_transition_transaction_records(
api: Arc<ECStore>,
limit: usize,
marker: Option<String>,
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
recover_transition_transaction_records_with_now(api, limit, marker, None).await
}
#[cfg(any(test, feature = "test-util"))]
pub async fn recover_transition_transaction_records_at(
api: Arc<ECStore>,
limit: usize,
marker: Option<String>,
now_unix_nanos: i128,
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
recover_transition_transaction_records_with_now(api, limit, marker, Some(now_unix_nanos)).await
}
async fn recover_transition_transaction_records_with_now(
api: Arc<ECStore>,
limit: usize,
marker: Option<String>,
now_unix_nanos: Option<i128>,
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
if limit == 0 {
return Err(Error::other("transition transaction recovery limit must be greater than zero"));
@@ -1366,13 +1087,7 @@ async fn recover_transition_transaction_records_with_now(
}
};
let recovery = match now_unix_nanos {
Some(now_unix_nanos) => {
Box::pin(process_transition_transaction_record_at(api.clone(), &transaction, now_unix_nanos)).await
}
None => process_transition_transaction_record(api.clone(), &transaction).await,
};
match recovery {
match process_transition_transaction_record(api.clone(), &transaction).await {
Ok(
TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted
| TransitionTransactionRecoveryOutcome::RecordDeleted,
@@ -1617,34 +1332,6 @@ mod tests {
.expect("expired unknown upload outcome should be eligible");
}
#[test]
fn transition_ownership_window_expires_at_not_after() {
let transaction = new_transaction();
let deadline = i128::from(transaction.not_after_unix_nanos);
assert!(transition_transaction_ownership_is_active(&transaction, deadline - 1));
assert!(!transition_transaction_ownership_is_active(&transaction, deadline));
}
#[test]
fn null_transition_source_lookup_targets_the_exact_version_shape() {
for mode in [
TransitionSourceVersionMode::Unversioned,
TransitionSourceVersionMode::VersionSuspended,
] {
let mut transaction = new_transaction();
transaction.source.version_id = None;
transaction.source.version_mode = mode;
let opts = transition_source_lookup_options(&transaction);
assert_eq!(opts.version_id, Some(Uuid::nil().to_string()));
assert!(!opts.versioned);
assert_eq!(opts.version_suspended, mode == TransitionSourceVersionMode::VersionSuspended);
assert!(!opts.metadata_cache_safe);
}
}
fn cleanup_proof(transaction: &TransitionTransaction, decision: TransitionCleanupDecision) -> TransitionCleanupProof {
TransitionCleanupProof {
transaction_id: transaction.transaction_id,
@@ -1659,17 +1346,10 @@ mod tests {
#[test]
fn remote_version_distinguishes_unknown_unversioned_and_versioned() {
assert_eq!(TransitionRemoteVersion::known_from_put_response("").tier_delete_version_id(), None);
let nil_version = Uuid::nil().to_string();
let invalid_nil = TransitionRemoteVersion::known_from_put_response(nil_version.clone());
assert_eq!(
invalid_nil.tier_delete_version_id(),
Some(nil_version.as_str()),
"a non-empty version must never be downgraded to an unversioned DELETE"
TransitionRemoteVersion::known_from_put_response(Uuid::nil().to_string()).tier_delete_version_id(),
None
);
assert!(matches!(
invalid_nil.validate(),
Err(TransitionTransactionError::Corrupt("versioned remote version is nil uuid"))
));
let version_id = Uuid::new_v4().to_string();
assert_eq!(
@@ -1788,34 +1468,6 @@ mod tests {
})
.is_ok()
);
assert!(matches!(
TransitionTransaction::new(TransitionTransactionInit {
deployment_id: Uuid::new_v4(),
transaction_id: Uuid::new_v4(),
owner_epoch: Uuid::new_v4(),
write_id: Uuid::new_v4(),
source: source_identity(TransitionSourceVersionMode::Unversioned),
tier_name: "warm-tier".to_string(),
backend_fingerprint: [0; 32],
not_after_unix_nanos: 1,
}),
Err(TransitionTransactionError::Corrupt("backend fingerprint is empty"))
));
assert!(matches!(
TransitionTransaction::new(TransitionTransactionInit {
deployment_id: Uuid::new_v4(),
transaction_id: Uuid::new_v4(),
owner_epoch: Uuid::new_v4(),
write_id: Uuid::new_v4(),
source: source_identity(TransitionSourceVersionMode::Unversioned),
tier_name: "warm-tier".to_string(),
backend_fingerprint: BACKEND_FINGERPRINT,
not_after_unix_nanos: 0,
}),
Err(TransitionTransactionError::Corrupt("ownership deadline is not positive"))
));
}
#[test]
+2 -156
View File
@@ -270,7 +270,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_ON_DEMAND_MIGRATION_CONFIG: &str = "on-demand-migration.json";
pub const BUCKET_TABLE_RESERVED_PREFIX: &str = ".rustfs-table";
pub const BUCKET_TABLE_CATALOG_META_PREFIX: &str = "s3tables/catalog";
pub const BUCKET_TABLE_CATALOG_TABLE_BUCKETS_PREFIX: &str = "table-buckets";
@@ -322,7 +321,6 @@ pub struct BucketMetadata {
pub bucket_acl_config_json: Vec<u8>,
pub table_bucket_config_json: Vec<u8>,
pub durability_config_json: Vec<u8>,
pub on_demand_migration_config_json: Vec<u8>,
pub policy_config_updated_at: OffsetDateTime,
pub object_lock_config_updated_at: OffsetDateTime,
@@ -344,7 +342,6 @@ pub struct BucketMetadata {
pub bucket_acl_config_updated_at: OffsetDateTime,
pub table_bucket_config_updated_at: OffsetDateTime,
pub durability_config_updated_at: OffsetDateTime,
pub on_demand_migration_config_updated_at: OffsetDateTime,
pub new_field_updated_at: OffsetDateTime,
@@ -396,7 +393,6 @@ impl Default for BucketMetadata {
bucket_acl_config_json: Default::default(),
table_bucket_config_json: Default::default(),
durability_config_json: Default::default(),
on_demand_migration_config_json: Default::default(),
policy_config_updated_at: OffsetDateTime::UNIX_EPOCH,
object_lock_config_updated_at: OffsetDateTime::UNIX_EPOCH,
encryption_config_updated_at: OffsetDateTime::UNIX_EPOCH,
@@ -417,7 +413,6 @@ impl Default for BucketMetadata {
bucket_acl_config_updated_at: OffsetDateTime::UNIX_EPOCH,
table_bucket_config_updated_at: OffsetDateTime::UNIX_EPOCH,
durability_config_updated_at: OffsetDateTime::UNIX_EPOCH,
on_demand_migration_config_updated_at: OffsetDateTime::UNIX_EPOCH,
new_field_updated_at: OffsetDateTime::UNIX_EPOCH,
policy_config: Default::default(),
notification_config: Default::default(),
@@ -482,23 +477,6 @@ impl BucketMetadata {
/// Absent/empty/unparsable payloads all mean "no override" (the bucket
/// follows the global durability mode); a parse failure is logged so a
/// corrupted entry cannot silently change fsync behavior.
/// Parsed on-demand migration config, if one is stored.
///
/// `Ok(None)` means no config (absent or cleared). A stored payload that
/// does not parse is an error, never a default: the runtime must not
/// pull from a source it cannot describe.
pub fn on_demand_migration_config(
&self,
) -> std::result::Result<
Option<super::on_demand_migration::OnDemandMigrationConfig>,
super::on_demand_migration::OnDemandMigrationConfigError,
> {
if self.on_demand_migration_config_json.is_empty() {
return Ok(None);
}
super::on_demand_migration::OnDemandMigrationConfig::from_json(&self.on_demand_migration_config_json).map(Some)
}
pub fn durability_config(&self) -> Option<super::durability::BucketDurabilityConfig> {
if self.durability_config_json.is_empty() {
return None;
@@ -577,9 +555,6 @@ impl BucketMetadata {
"BucketAclConfigJSON" | "BucketAclConfigJson" => self.bucket_acl_config_json = read_msgp_bin(rd)?,
"TableBucketConfigJSON" | "TableBucketConfigJson" => self.table_bucket_config_json = read_msgp_bin(rd)?,
"DurabilityConfigJSON" | "DurabilityConfigJson" => self.durability_config_json = read_msgp_bin(rd)?,
"OnDemandMigrationConfigJSON" | "OnDemandMigrationConfigJson" => {
self.on_demand_migration_config_json = read_msgp_bin(rd)?
}
"CorsConfigUpdatedAt" => self.cors_config_updated_at = read_msgp_time_value(rd)?,
"LoggingConfigUpdatedAt" => self.logging_config_updated_at = read_msgp_time_value(rd)?,
"WebsiteConfigUpdatedAt" => self.website_config_updated_at = read_msgp_time_value(rd)?,
@@ -589,7 +564,6 @@ impl BucketMetadata {
"BucketAclConfigUpdatedAt" => self.bucket_acl_config_updated_at = read_msgp_time_value(rd)?,
"TableBucketConfigUpdatedAt" => self.table_bucket_config_updated_at = read_msgp_time_value(rd)?,
"DurabilityConfigUpdatedAt" => self.durability_config_updated_at = read_msgp_time_value(rd)?,
"OnDemandMigrationConfigUpdatedAt" => self.on_demand_migration_config_updated_at = read_msgp_time_value(rd)?,
other => {
tracing::debug!(field = %other, "BucketMetadata decode_from: skipping unknown field");
skip_msgp_value(rd)?;
@@ -602,8 +576,8 @@ impl BucketMetadata {
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
// Map size: MinIO fields (25) + RustFS extensions (21)
let map_len: u32 = 46;
// Map size: MinIO fields (25) + RustFS extensions (19)
let map_len: u32 = 44;
rmp::encode::write_map_len(wr, map_len)?;
// MinIO field order (same as Go struct)
@@ -663,7 +637,6 @@ impl BucketMetadata {
write_bin_field(wr, "BucketAclConfigJSON", &self.bucket_acl_config_json)?;
write_bin_field(wr, "TableBucketConfigJSON", &self.table_bucket_config_json)?;
write_bin_field(wr, "DurabilityConfigJSON", &self.durability_config_json)?;
write_bin_field(wr, "OnDemandMigrationConfigJSON", &self.on_demand_migration_config_json)?;
rmp::encode::write_str(wr, "CorsConfigUpdatedAt")?;
write_msgp_time(wr, self.cors_config_updated_at)?;
rmp::encode::write_str(wr, "LoggingConfigUpdatedAt")?;
@@ -682,8 +655,6 @@ impl BucketMetadata {
write_msgp_time(wr, self.table_bucket_config_updated_at)?;
rmp::encode::write_str(wr, "DurabilityConfigUpdatedAt")?;
write_msgp_time(wr, self.durability_config_updated_at)?;
rmp::encode::write_str(wr, "OnDemandMigrationConfigUpdatedAt")?;
write_msgp_time(wr, self.on_demand_migration_config_updated_at)?;
Ok(())
}
@@ -785,9 +756,6 @@ impl BucketMetadata {
if self.durability_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.durability_config_updated_at = self.created
}
if self.on_demand_migration_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.on_demand_migration_config_updated_at = self.created
}
}
pub fn update_config(&mut self, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
@@ -903,17 +871,6 @@ impl BucketMetadata {
self.durability_config_json = data;
self.durability_config_updated_at = updated;
}
BUCKET_ON_DEMAND_MIGRATION_CONFIG => {
// Structural check only (shape, unknown fields); the
// deployment-relative rules run in the admin handler with a
// `ValidationContext`. A blob this build cannot read must not
// be persisted for every later reader to trip over.
if !data.is_empty() {
super::on_demand_migration::OnDemandMigrationConfig::from_json(&data).map_err(Error::other)?;
}
self.on_demand_migration_config_json = data;
self.on_demand_migration_config_updated_at = updated;
}
_ => return Err(Error::other(format!("config file not found : {config_file}"))),
}
@@ -1822,117 +1779,6 @@ mod test {
assert!(!bm.table_bucket_enabled());
}
const ODM_JSON: &[u8] = br#"{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
/// rustfs/backlog#2148: the on-demand migration config is a RustFS
/// extension entry that round-trips through `update_config` and the
/// msgpack codec, clears on delete, and never parses corruption into a
/// default.
#[test]
fn on_demand_migration_config_round_trips_and_tracks_updates() {
use crate::bucket::on_demand_migration::{OnDemandMigrationConfig, OnDemandMigrationConfigError};
let mut bm = BucketMetadata::new("odm-bucket");
assert_eq!(bm.on_demand_migration_config(), Ok(None), "fresh metadata carries no config");
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
.expect("valid config is accepted");
assert_ne!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
assert_eq!(bm.on_demand_migration_config(), Ok(Some(expected.clone())));
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
assert_eq!(back.on_demand_migration_config_json, bm.on_demand_migration_config_json);
assert_eq!(
back.on_demand_migration_config_updated_at.unix_timestamp(),
bm.on_demand_migration_config_updated_at.unix_timestamp()
);
assert_eq!(back.on_demand_migration_config(), Ok(Some(expected)));
// A blob this build cannot read is rejected at the write boundary
// rather than persisted for every reader to trip over.
let before = bm.on_demand_migration_config_json.clone();
assert!(
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec())
.is_err()
);
assert_eq!(bm.on_demand_migration_config_json, before, "a rejected update leaves the blob untouched");
// Delete clears the entry.
let stamped = bm.on_demand_migration_config_updated_at;
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, Vec::new()).unwrap();
assert!(bm.on_demand_migration_config_json.is_empty());
assert_eq!(bm.on_demand_migration_config(), Ok(None));
assert!(bm.on_demand_migration_config_updated_at >= stamped);
// Corruption that bypassed `update_config` (disk, another writer)
// is a typed error, never a default.
bm.on_demand_migration_config_json = b"not-json".to_vec();
assert!(matches!(bm.on_demand_migration_config(), Err(OnDemandMigrationConfigError::Malformed(_))));
}
/// rustfs/backlog#2148: a `.metadata.bin` written before the on-demand
/// migration keys existed decodes with an empty blob and an epoch
/// timestamp that `default_timestamps` back-fills from `created`.
#[test]
fn on_demand_migration_config_absent_in_legacy_blob_defaults_to_created() {
let blob = decode_hex(include_str!("../../tests/fixtures/minio/bucket_metadata.blob.hex"));
let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata");
assert!(bm.on_demand_migration_config_json.is_empty());
assert_eq!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
assert_eq!(bm.on_demand_migration_config(), Ok(None));
bm.default_timestamps();
assert_ne!(bm.created, OffsetDateTime::UNIX_EPOCH, "fixture must carry a real creation time");
assert_eq!(bm.on_demand_migration_config_updated_at, bm.created);
// A metadata blob from this build with no config set stays
// indistinguishable from the legacy one for these fields.
let fresh = BucketMetadata::unmarshal(&BucketMetadata::new("fresh").marshal_msg().unwrap()).unwrap();
assert!(fresh.on_demand_migration_config_json.is_empty());
assert_eq!(fresh.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
}
/// rustfs/backlog#2148: a reader that predates the two on-demand
/// migration keys takes `decode_from`'s unknown-field branch, which is
/// `skip_msgp_value`. Walk the new-format blob with exactly that
/// primitive and prove both keys are skipped without desynchronising the
/// stream, so the fields that follow them still decode.
#[test]
fn old_decoder_skips_on_demand_migration_fields_without_desync() {
let mut bm = BucketMetadata::new("odm-skip");
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
.unwrap();
bm.update_config(BUCKET_DURABILITY_CONFIG, br#"{"mode":"relaxed"}"#.to_vec())
.unwrap();
let buf = bm.marshal_msg().unwrap();
let mut rd = std::io::Cursor::new(buf.as_slice());
let fields = rmp::decode::read_map_len(&mut rd).unwrap();
let mut skipped = Vec::new();
let mut durability_json = Vec::new();
for _ in 0..fields {
let key_len = rmp::decode::read_str_len(&mut rd).unwrap();
let mut key = vec![0u8; key_len as usize];
rd.read_exact(&mut key).unwrap();
let key = String::from_utf8(key).unwrap();
match key.as_str() {
// The field an old reader knows that is encoded *after* the
// unknown JSON key and *before* the unknown timestamp key.
"DurabilityConfigJSON" => durability_json = read_msgp_bin(&mut rd).unwrap(),
other => {
if other.starts_with("OnDemandMigration") {
skipped.push(other.to_string());
}
skip_msgp_value(&mut rd).unwrap();
}
}
}
assert_eq!(skipped, ["OnDemandMigrationConfigJSON", "OnDemandMigrationConfigUpdatedAt"]);
assert_eq!(durability_json, br#"{"mode":"relaxed"}"#);
assert_eq!(rd.position() as usize, buf.len(), "old-style walk must consume the blob exactly");
}
/// HP-5b (rustfs/backlog#938): the durability override is a RustFS
/// extension entry and must survive an encode/decode round trip.
#[test]
-221
View File
@@ -19,7 +19,6 @@ use super::quota::BucketQuota;
use super::target::BucketTargets;
use crate::bucket::bucket_target_sys::BucketTargetSys;
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
use crate::bucket::on_demand_migration::{ON_DEMAND_MIGRATION_CONFIG_HOOK, OnDemandMigrationConfig};
use crate::bucket::utils::is_meta_bucketname;
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result, is_err_bucket_not_found, is_err_strict_volume_not_found};
@@ -385,42 +384,6 @@ fn clear_bucket_durability(bucket: &str) {
crate::disk::local::bucket_durability::set(bucket, None);
}
/// Publish the bucket's on-demand migration config (or its absence) to the
/// runtime registered in `ON_DEMAND_MIGRATION_CONFIG_HOOK`.
///
/// Called from the same five cache-install paths as
/// [`sync_bucket_durability`]. A stored payload this build cannot parse is
/// published as `None`: the runtime must stop pulling for that bucket rather
/// than keep an older config or guess.
fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) {
let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() else {
return;
};
match bm.on_demand_migration_config() {
Ok(config) => hook(bucket, config.as_ref()),
Err(err) => {
warn!(
event = "bucket_metadata_parse_failed",
component = "ecstore",
subsystem = "bucket_metadata",
bucket = %bucket,
config = "on_demand_migration",
error = %err,
"Failed to parse bucket metadata config"
);
hook(bucket, None);
}
}
}
/// Withdraw a bucket's on-demand migration config when its metadata leaves
/// the cache.
fn clear_on_demand_migration(bucket: &str) {
if let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() {
hook(bucket, None);
}
}
pub async fn get(bucket: &str) -> Result<Arc<BucketMetadata>> {
let sys = get_bucket_metadata_sys()?;
let lock = sys.read().await;
@@ -1007,16 +970,6 @@ pub async fn get_durability_config(
Ok((bm.durability_config(), bm.durability_config_updated_at))
}
/// The bucket's on-demand migration config with its update time, or
/// `Ok(None)` when the bucket has none. A stored payload that does not parse
/// is a typed error (`OnDemandMigrationConfigError` inside `Error::Io`).
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
bucket_meta_sys.get_on_demand_migration_config(bucket).await
}
pub async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
@@ -1161,16 +1114,6 @@ pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfigur
bucket_meta_sys.get_replication_config(bucket).await
}
pub(crate) async fn get_replication_config_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
bucket_meta_sys.get_replication_config(bucket).await
}
pub async fn get_notification_config(bucket: &str) -> Result<Option<NotificationConfiguration>> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
@@ -1549,7 +1492,6 @@ impl BucketMetadataSys {
if removed {
BucketTargetSys::get().delete(bucket).await;
clear_bucket_durability(bucket);
clear_on_demand_migration(bucket);
}
}
return Ok(());
@@ -1587,7 +1529,6 @@ impl BucketMetadataSys {
self.missing_buckets.invalidate(bucket).await;
sync_bucket_target_sys(bucket, &bm).await;
sync_bucket_durability(bucket, &bm);
sync_on_demand_migration(bucket, &bm);
}
MetadataLoadMode::Initial => {
let _publish_guard = self
@@ -1634,7 +1575,6 @@ impl BucketMetadataSys {
if removed {
BucketTargetSys::get().delete(bucket).await;
clear_bucket_durability(bucket);
clear_on_demand_migration(bucket);
}
return Ok(());
}
@@ -1657,7 +1597,6 @@ impl BucketMetadataSys {
self.missing_buckets.invalidate(bucket).await;
sync_bucket_target_sys(bucket, &metadata).await;
sync_bucket_durability(bucket, &metadata);
sync_on_demand_migration(bucket, &metadata);
Ok(())
}
@@ -1685,7 +1624,6 @@ impl BucketMetadataSys {
self.missing_buckets.invalidate(&bucket).await;
sync_bucket_target_sys(&bucket, &bm).await;
sync_bucket_durability(&bucket, &bm);
sync_on_demand_migration(&bucket, &bm);
}
}
@@ -1706,7 +1644,6 @@ impl BucketMetadataSys {
if removed {
BucketTargetSys::get().delete(bucket).await;
clear_bucket_durability(bucket);
clear_on_demand_migration(bucket);
}
removed || removed_fabricated
}
@@ -1996,7 +1933,6 @@ impl BucketMetadataSys {
self.missing_buckets.invalidate(bucket).await;
sync_bucket_target_sys(bucket, &bm).await;
sync_bucket_durability(bucket, &bm);
sync_on_demand_migration(bucket, &bm);
} else {
let exists = self
.bucket_exists(bucket, &guard, "lazy bucket metadata existence check")
@@ -2335,7 +2271,6 @@ impl BucketMetadataSys {
self.missing_buckets.invalidate(bucket).await;
sync_bucket_target_sys(bucket, &metadata).await;
sync_bucket_durability(bucket, &metadata);
sync_on_demand_migration(bucket, &metadata);
Ok(BucketMetadataAuthority::Authoritative(metadata))
}
@@ -2528,17 +2463,6 @@ impl BucketMetadataSys {
Err(Error::ConfigNotFound)
}
}
/// See [`get_on_demand_migration_config`].
pub async fn get_on_demand_migration_config(
&self,
bucket: &str,
) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
let (bm, _) = self.get_config(bucket).await?;
let config = bm.on_demand_migration_config().map_err(Error::other)?;
Ok(config.map(|config| (config, bm.on_demand_migration_config_updated_at)))
}
}
/// Test-only fixture shared with sibling modules (e.g. the quota checker
@@ -4119,151 +4043,6 @@ mod tests {
assert_eq!(bucket_durability::lookup(bucket), None);
}
const ODM_JSON: &[u8] = br#"{"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
/// Every `(bucket, config)` the recording hook has seen. Tests filter by
/// their own bucket name; the hook is process-wide and set once.
static ODM_HOOK_CALLS: std::sync::Mutex<Vec<(String, Option<OnDemandMigrationConfig>)>> = std::sync::Mutex::new(Vec::new());
fn install_recording_odm_hook() {
ON_DEMAND_MIGRATION_CONFIG_HOOK.get_or_init(|| {
Box::new(|bucket, config| {
ODM_HOOK_CALLS.lock().unwrap().push((bucket.to_string(), config.cloned()));
})
});
}
fn odm_hook_calls(bucket: &str) -> Vec<Option<OnDemandMigrationConfig>> {
ODM_HOOK_CALLS
.lock()
.unwrap()
.iter()
.filter(|(name, _)| name == bucket)
.map(|(_, config)| config.clone())
.collect()
}
/// rustfs/backlog#2148: the accessor reports absence as `Ok(None)` and a
/// stored payload it cannot parse as a typed error, never as a default
/// and never as `ConfigNotFound`.
#[tokio::test]
async fn get_on_demand_migration_config_distinguishes_absent_from_corrupt() {
use crate::bucket::on_demand_migration::OnDemandMigrationConfigError;
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore);
let bucket = "odm-accessor";
sys.set(bucket.to_string(), Arc::new(BucketMetadata::new(bucket))).await;
assert_eq!(sys.get_on_demand_migration_config(bucket).await.unwrap(), None);
let mut corrupt = BucketMetadata::new(bucket);
corrupt.on_demand_migration_config_json = br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec();
sys.set(bucket.to_string(), Arc::new(corrupt)).await;
let err = sys
.get_on_demand_migration_config(bucket)
.await
.expect_err("corrupt config must not read as a default");
assert_ne!(err, Error::ConfigNotFound, "corruption must not be reported as absence");
let typed = match &err {
Error::Io(io) => io
.get_ref()
.and_then(|source| source.downcast_ref::<OnDemandMigrationConfigError>()),
_ => None,
};
assert!(
matches!(typed, Some(OnDemandMigrationConfigError::Malformed(_))),
"typed parse error must survive the Result boundary, got: {err:?}"
);
let mut valid = BucketMetadata::new(bucket);
valid
.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
.unwrap();
let stamped = valid.on_demand_migration_config_updated_at;
sys.set(bucket.to_string(), Arc::new(valid)).await;
let (config, updated_at) = sys
.get_on_demand_migration_config(bucket)
.await
.unwrap()
.expect("stored config is returned");
assert_eq!(config, OnDemandMigrationConfig::from_json(ODM_JSON).unwrap());
assert_eq!(updated_at, stamped);
}
/// rustfs/backlog#2148: the publish hook fires on every path that
/// installs bucket metadata into the cache (set, initial load, peer
/// reload, refresh loop, lazy load) and withdraws on removal, mirroring
/// `sync_bucket_durability`.
#[tokio::test]
async fn on_demand_migration_hook_fires_on_every_cache_install_path() {
install_recording_odm_hook();
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
let bucket = "odm-hook-paths";
for dir in &dirs {
std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist");
}
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
let expect_publish = |before: usize, label: &str| {
let calls = odm_hook_calls(bucket);
assert_eq!(calls.len(), before + 1, "{label} must publish exactly once");
assert_eq!(calls.last().unwrap().as_ref(), Some(&expected), "{label} must publish the stored config");
};
// set (via persist_new_and_set, which installs through `set`).
let mut bm = BucketMetadata::new(bucket);
bm.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
.unwrap();
let writer = BucketMetadataSys::new(ecstore.clone());
let before = odm_hook_calls(bucket).len();
writer.persist_new_and_set(bm).await.expect("metadata should persist");
expect_publish(before, "set");
// init (initial load on a cold system).
let mut cold = BucketMetadataSys::new(ecstore.clone());
let before = odm_hook_calls(bucket).len();
cold.init(vec![bucket.to_string()]).await;
assert!(cold.get(bucket).await.is_ok(), "initial load must cache the bucket");
expect_publish(before, "init");
// peer reload.
let before = odm_hook_calls(bucket).len();
cold.reload_from_store(bucket).await.expect("peer reload should publish");
expect_publish(before, "peer reload");
// refresh loop.
let before = odm_hook_calls(bucket).len();
let mut failed = HashSet::new();
cold.concurrent_load(&[bucket.to_string()], &mut failed, MetadataLoadMode::Refresh)
.await;
assert!(failed.is_empty(), "refresh must succeed");
expect_publish(before, "refresh loop");
// lazy load on another cold system.
let lazy = BucketMetadataSys::new(ecstore);
let before = odm_hook_calls(bucket).len();
let (_, loaded) = lazy.get_config(bucket).await.expect("lazy load should publish");
assert!(loaded, "the lazy path must have gone to disk");
expect_publish(before, "lazy load");
// Removal withdraws the config.
let before = odm_hook_calls(bucket).len();
assert!(lazy.remove(bucket).await);
let calls = odm_hook_calls(bucket);
assert_eq!(calls.len(), before + 1, "remove must withdraw exactly once");
assert_eq!(calls.last().unwrap(), &None);
// A corrupt payload is withdrawn, never published as a config.
let mut corrupt = BucketMetadata::new(bucket);
corrupt.on_demand_migration_config_json = b"not-json".to_vec();
let before = odm_hook_calls(bucket).len();
lazy.set(bucket.to_string(), Arc::new(corrupt)).await;
let calls = odm_hook_calls(bucket);
assert_eq!(calls.len(), before + 1);
assert_eq!(calls.last().unwrap(), &None, "unreadable config must publish absence");
}
#[tokio::test]
async fn refresh_wait_exits_when_cancelled() {
let cancel_token = CancellationToken::new();
-3
View File
@@ -26,12 +26,9 @@ mod metadata_test;
pub mod migration;
mod msgp_decode;
pub mod object_lock;
pub mod on_demand_migration;
pub mod policy_sys;
pub mod quota;
pub mod remote_s3_client;
pub mod replication;
pub mod sealed_credentials;
pub mod tagging;
pub mod target;
pub mod utils;
File diff suppressed because it is too large Load Diff
@@ -1,362 +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 three-state circuit breaker protecting an on-demand migration
//! source (rustfs/backlog#2152).
//!
//! `Closed` lets every request through and counts consecutive failures
//! inside a sliding window; reaching the threshold opens the breaker. `Open`
//! rejects everything until the open duration elapses, then moves to
//! `HalfOpen`, which admits a single probe: success closes the breaker,
//! failure re-opens it. Timing uses `tokio::time::Instant` so tests can drive
//! it with `tokio::time::pause`.
//!
//! Only transport-level failures count (`Throttled`, `Timeout`, `Connect`,
//! `ServerError`). `NotFound` is a healthy answer and resets the failure
//! streak; `AccessDenied`, `Unsupported` and `Other` are configuration or
//! object problems that neither open nor close the breaker.
use super::source_client::SourceError;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::time::Instant;
/// Consecutive counted failures that open the breaker.
pub const BREAKER_FAILURE_THRESHOLD: u32 = 5;
/// Failures further apart than this do not accumulate.
pub const BREAKER_FAILURE_WINDOW: Duration = Duration::from_secs(30);
/// How long an open breaker rejects before admitting a probe.
pub const BREAKER_OPEN_DURATION: Duration = Duration::from_secs(30);
/// Probes admitted while half-open.
pub const BREAKER_HALF_OPEN_MAX_PROBES: u32 = 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BreakerState {
Closed,
Open,
HalfOpen,
}
impl BreakerState {
pub fn as_str(self) -> &'static str {
match self {
BreakerState::Closed => "closed",
BreakerState::Open => "open",
BreakerState::HalfOpen => "half_open",
}
}
}
/// A state change the caller may want to log.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BreakerTransition {
pub from: BreakerState,
pub to: BreakerState,
}
/// How a source result is scored by the breaker.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BreakerVerdict {
/// Resets the failure streak; closes a half-open breaker.
Success,
/// Counts toward the threshold; re-opens a half-open breaker.
Failure,
/// Leaves the breaker untouched.
Neutral,
}
impl BreakerVerdict {
/// `None` is a successful source call.
pub fn for_result(error: Option<&SourceError>) -> Self {
match error {
None | Some(SourceError::NotFound) => BreakerVerdict::Success,
Some(SourceError::Throttled | SourceError::Timeout | SourceError::Connect(_) | SourceError::ServerError(_)) => {
BreakerVerdict::Failure
}
Some(SourceError::AccessDenied | SourceError::Unsupported(_) | SourceError::Other(_)) => BreakerVerdict::Neutral,
}
}
}
#[derive(Debug)]
struct Inner {
state: BreakerState,
consecutive_failures: u32,
last_failure_at: Option<Instant>,
opened_at: Option<Instant>,
half_open_probes: u32,
}
#[derive(Debug)]
pub struct Breaker {
inner: Mutex<Inner>,
}
impl Default for Breaker {
fn default() -> Self {
Self::new()
}
}
impl Breaker {
pub fn new() -> Self {
Self {
inner: Mutex::new(Inner {
state: BreakerState::Closed,
consecutive_failures: 0,
last_failure_at: None,
opened_at: None,
half_open_probes: 0,
}),
}
}
/// Current state after applying the open-duration timeout.
pub fn state(&self) -> BreakerState {
let mut inner = self.inner.lock();
Self::advance(&mut inner, Instant::now());
inner.state
}
/// Whether a request may reach the source right now. Consumes the
/// half-open probe budget when it grants one.
pub fn allow_request(&self) -> bool {
let mut inner = self.inner.lock();
Self::advance(&mut inner, Instant::now());
match inner.state {
BreakerState::Closed => true,
BreakerState::Open => false,
BreakerState::HalfOpen => {
if inner.half_open_probes < BREAKER_HALF_OPEN_MAX_PROBES {
inner.half_open_probes += 1;
true
} else {
false
}
}
}
}
/// Scores a source result; returns the transition it caused, if any.
pub fn record(&self, verdict: BreakerVerdict) -> Option<BreakerTransition> {
match verdict {
BreakerVerdict::Success => self.record_success(),
BreakerVerdict::Failure => self.record_failure(),
BreakerVerdict::Neutral => None,
}
}
pub fn record_success(&self) -> Option<BreakerTransition> {
let mut inner = self.inner.lock();
let now = Instant::now();
Self::advance(&mut inner, now);
inner.consecutive_failures = 0;
inner.last_failure_at = None;
match inner.state {
BreakerState::Closed => None,
// A success while open can only come from a request admitted
// before the breaker opened; it says nothing about recovery.
BreakerState::Open => None,
BreakerState::HalfOpen => Some(Self::transition(&mut inner, BreakerState::Closed, now)),
}
}
pub fn record_failure(&self) -> Option<BreakerTransition> {
let mut inner = self.inner.lock();
let now = Instant::now();
Self::advance(&mut inner, now);
match inner.state {
BreakerState::Closed => {
let within_window = inner
.last_failure_at
.is_some_and(|last| now.saturating_duration_since(last) <= BREAKER_FAILURE_WINDOW);
inner.consecutive_failures = if within_window { inner.consecutive_failures + 1 } else { 1 };
inner.last_failure_at = Some(now);
if inner.consecutive_failures >= BREAKER_FAILURE_THRESHOLD {
Some(Self::transition(&mut inner, BreakerState::Open, now))
} else {
None
}
}
BreakerState::Open => None,
BreakerState::HalfOpen => Some(Self::transition(&mut inner, BreakerState::Open, now)),
}
}
fn advance(inner: &mut Inner, now: Instant) {
if inner.state == BreakerState::Open
&& inner
.opened_at
.is_some_and(|opened| now.saturating_duration_since(opened) >= BREAKER_OPEN_DURATION)
{
Self::transition(inner, BreakerState::HalfOpen, now);
}
}
fn transition(inner: &mut Inner, to: BreakerState, now: Instant) -> BreakerTransition {
let from = inner.state;
inner.state = to;
match to {
BreakerState::Open => {
inner.opened_at = Some(now);
inner.half_open_probes = 0;
}
BreakerState::HalfOpen => {
inner.half_open_probes = 0;
}
BreakerState::Closed => {
inner.opened_at = None;
inner.half_open_probes = 0;
inner.consecutive_failures = 0;
inner.last_failure_at = None;
}
}
BreakerTransition { from, to }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn server_error() -> SourceError {
SourceError::ServerError(503)
}
#[tokio::test(start_paused = true)]
async fn five_failures_open_then_half_open_after_timeout() {
let breaker = Breaker::new();
for i in 0..BREAKER_FAILURE_THRESHOLD - 1 {
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&server_error()))), None, "failure {i}");
assert_eq!(breaker.state(), BreakerState::Closed);
}
assert_eq!(
breaker.record(BreakerVerdict::for_result(Some(&server_error()))),
Some(BreakerTransition {
from: BreakerState::Closed,
to: BreakerState::Open
})
);
assert_eq!(breaker.state(), BreakerState::Open);
assert!(!breaker.allow_request());
tokio::time::advance(BREAKER_OPEN_DURATION - Duration::from_secs(1)).await;
assert!(!breaker.allow_request());
assert_eq!(breaker.state(), BreakerState::Open);
tokio::time::advance(Duration::from_secs(1)).await;
assert_eq!(breaker.state(), BreakerState::HalfOpen);
assert!(breaker.allow_request(), "one probe is admitted");
assert!(!breaker.allow_request(), "second probe is rejected");
}
#[tokio::test(start_paused = true)]
async fn half_open_probe_success_closes_and_failure_reopens() {
let breaker = Breaker::new();
for _ in 0..BREAKER_FAILURE_THRESHOLD {
breaker.record_failure();
}
tokio::time::advance(BREAKER_OPEN_DURATION).await;
assert!(breaker.allow_request());
assert_eq!(
breaker.record_failure(),
Some(BreakerTransition {
from: BreakerState::HalfOpen,
to: BreakerState::Open
})
);
assert!(!breaker.allow_request());
tokio::time::advance(BREAKER_OPEN_DURATION).await;
assert!(breaker.allow_request());
assert_eq!(
breaker.record_success(),
Some(BreakerTransition {
from: BreakerState::HalfOpen,
to: BreakerState::Closed
})
);
assert_eq!(breaker.state(), BreakerState::Closed);
assert!(breaker.allow_request());
// The streak restarts from zero after closing.
for _ in 0..BREAKER_FAILURE_THRESHOLD - 1 {
assert_eq!(breaker.record_failure(), None);
}
assert_eq!(breaker.state(), BreakerState::Closed);
}
#[tokio::test(start_paused = true)]
async fn failures_outside_window_do_not_accumulate() {
let breaker = Breaker::new();
for _ in 0..BREAKER_FAILURE_THRESHOLD - 1 {
breaker.record_failure();
}
tokio::time::advance(BREAKER_FAILURE_WINDOW + Duration::from_secs(1)).await;
assert_eq!(breaker.record_failure(), None, "stale streak restarts at one");
assert_eq!(breaker.state(), BreakerState::Closed);
}
#[test]
fn not_found_and_access_denied_do_not_count() {
let breaker = Breaker::new();
for _ in 0..BREAKER_FAILURE_THRESHOLD - 1 {
breaker.record(BreakerVerdict::for_result(Some(&server_error())));
}
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&SourceError::AccessDenied))), None);
assert_eq!(breaker.state(), BreakerState::Closed);
// AccessDenied is neutral: the streak is still one short of opening.
assert_eq!(
breaker.record(BreakerVerdict::for_result(Some(&SourceError::Unsupported("sse-c".into())))),
None
);
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&SourceError::Other("x".into())))), None);
// NotFound is a healthy answer and resets the streak entirely.
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&SourceError::NotFound))), None);
for _ in 0..BREAKER_FAILURE_THRESHOLD - 1 {
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&SourceError::Timeout))), None);
}
assert_eq!(breaker.state(), BreakerState::Closed);
}
#[test]
fn verdicts_cover_every_source_error_class() {
assert_eq!(BreakerVerdict::for_result(None), BreakerVerdict::Success);
assert_eq!(BreakerVerdict::for_result(Some(&SourceError::NotFound)), BreakerVerdict::Success);
for failure in [
SourceError::Throttled,
SourceError::Timeout,
SourceError::Connect("refused".into()),
SourceError::ServerError(500),
] {
assert_eq!(BreakerVerdict::for_result(Some(&failure)), BreakerVerdict::Failure, "{failure:?}");
}
for neutral in [
SourceError::AccessDenied,
SourceError::Unsupported("sse-c".into()),
SourceError::Other("x".into()),
] {
assert_eq!(BreakerVerdict::for_result(Some(&neutral)), BreakerVerdict::Neutral, "{neutral:?}");
}
}
#[test]
fn state_labels_are_stable() {
assert_eq!(BreakerState::Closed.as_str(), "closed");
assert_eq!(BreakerState::Open.as_str(), "open");
assert_eq!(BreakerState::HalfOpen.as_str(), "half_open");
assert_eq!(serde_json::to_string(&BreakerState::HalfOpen).unwrap(), "\"half_open\"");
}
}
File diff suppressed because it is too large Load Diff
@@ -1,824 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Optional `ListObjectsV2` list-through (`policy.list_through`,
//! rustfs/backlog#2164): the local listing and the source listing are merged
//! into one ordered page so clients see the whole namespace while a bucket is
//! migrating.
//!
//! Everything here is pure. The handler owns the I/O and the payloads; this
//! module owns the ordering, the page boundary, and the opaque continuation
//! token that carries both cursors.
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
/// The only continuation-token envelope version this build reads and writes.
pub const LIST_THROUGH_TOKEN_VERSION: u32 = 1;
/// Envelope marker. A bucket that is *not* merging hands out the local
/// listing's own marker, so the decoder needs a positive signal before it
/// treats an opaque token as a merged one.
const LIST_THROUGH_TOKEN_TAG: &str = "odm-list";
/// Pages fetched per side per request: the first page, plus at most one refill
/// when the first one was mostly consumed by the previous page. Two pages of
/// `max_keys` always cover a full merged page, so this is a bound, not a
/// heuristic.
pub const MAX_LIST_FETCHES_PER_SIDE: usize = 2;
/// Per-bucket ceiling on source `ListObjectsV2` calls, in calls per second.
pub const SOURCE_LIST_RATE_PER_SEC: u32 = 10;
/// How long a listing may wait for a source rate-limit slot before it gives up
/// and answers from local state alone.
pub const SOURCE_LIST_MAX_RATE_WAIT: Duration = Duration::from_secs(1);
/// One listing entry as the merge orders it: an object key, or — under a
/// delimiter — a rolled-up common prefix. Both sort by `name` alone, which is
/// how S3 interleaves `Contents` and `CommonPrefixes` on the wire.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListEntryKey {
pub name: String,
pub is_prefix: bool,
}
impl ListEntryKey {
pub fn object(name: impl Into<String>) -> Self {
Self {
name: name.into(),
is_prefix: false,
}
}
pub fn prefix(name: impl Into<String>) -> Self {
Self {
name: name.into(),
is_prefix: true,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MergeSide {
Local,
Source,
}
/// One entry of the merged page: the side it came from and its index in that
/// side's buffer, in push order. The caller keeps the payloads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MergePick {
pub side: MergeSide,
pub index: usize,
}
/// The continuation-token envelope. Opaque to clients: it is serialized as
/// JSON and then base64-encoded by the same helper that encodes a plain local
/// marker, so the wire shape is `base64(json)`.
///
/// A `null` cursor with `done = false` means "list that side from the start";
/// `done = true` means the side is finished and must not be listed again.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListThroughToken {
/// Envelope marker, always [`LIST_THROUGH_TOKEN_TAG`].
pub t: String,
pub v: u32,
#[serde(default)]
pub local: Option<String>,
#[serde(default)]
pub local_done: bool,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub source_done: bool,
/// Last entry the previous page consumed. A side whose page was only
/// partially consumed is re-listed from the same cursor and everything at
/// or below this key is dropped, which is delimiter-safe: a rolled-up
/// common prefix compares as itself, never as its members.
#[serde(default)]
pub last_key: Option<String>,
}
impl ListThroughToken {
fn new(local: SideCursor, source: SideCursor, last_key: Option<String>) -> Self {
Self {
t: LIST_THROUGH_TOKEN_TAG.to_string(),
v: LIST_THROUGH_TOKEN_VERSION,
local: local.token,
local_done: local.done,
source: source.token,
source_done: source.done,
last_key,
}
}
pub fn encode(&self) -> String {
// The envelope is built here from owned strings, so serialization
// cannot fail; the fallback keeps the signature infallible.
serde_json::to_string(self).unwrap_or_default()
}
}
/// What a decoded (base64-stripped) continuation token turned out to be.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ListThroughCursor {
/// A plain local listing marker: the bucket was not merging when the token
/// was issued, or the client is paginating a non-merged listing.
Local(String),
Merged(Box<ListThroughToken>),
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum ListThroughTokenError {
#[error("continuation token version {0} is not supported")]
UnsupportedVersion(u32),
/// The message never echoes the token: it is client-controlled input.
#[error("continuation token is malformed")]
Malformed,
}
/// Classifies an already base64-decoded continuation token.
///
/// Only a JSON object carrying the envelope marker is read as a merged token;
/// anything else is a local marker, so a bucket that turns `list_through` off
/// keeps paginating with the tokens it handed out. A token that *is* an
/// envelope but was tampered with (unknown version, unknown field, truncated
/// JSON) is an error, never a silent fallback.
pub fn decode_continuation_token(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
if !decoded.starts_with('{') {
return Ok(ListThroughCursor::Local(decoded.to_string()));
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(decoded) else {
// Not JSON at all: an object key may legitimately start with '{'.
return Ok(ListThroughCursor::Local(decoded.to_string()));
};
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
return Ok(ListThroughCursor::Local(decoded.to_string()));
}
match value.get("v").and_then(serde_json::Value::as_u64) {
Some(version) if version == u64::from(LIST_THROUGH_TOKEN_VERSION) => {}
Some(version) => return Err(ListThroughTokenError::UnsupportedVersion(version.min(u64::from(u32::MAX)) as u32)),
None => return Err(ListThroughTokenError::Malformed),
}
serde_json::from_value::<ListThroughToken>(value)
.map(|token| ListThroughCursor::Merged(Box::new(token)))
.map_err(|_| ListThroughTokenError::Malformed)
}
/// How the source must be listed for a request, given `filter.prefix`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SourceListPlan {
/// The request prefix and `filter.prefix` are disjoint: the source holds
/// nothing this listing could show.
Skip,
/// Ordinary paged listing under `prefix`, rolled up with the request's
/// delimiter — the source's own roll-up boundary matches the request's.
Page { prefix: String },
/// `filter.prefix` reaches past a delimiter, so every key the source could
/// contribute rolls into this one common prefix. One bounded probe listing
/// decides whether it exists; there is nothing to paginate.
Folded { probe_prefix: String, common_prefix: String },
}
/// Intersects the request prefix with `filter.prefix` and decides how (or
/// whether) the source is listed.
pub fn source_list_plan(request_prefix: &str, filter_prefix: Option<&str>, delimiter: Option<&str>) -> SourceListPlan {
let filter = filter_prefix.unwrap_or_default();
let source_prefix = if filter.starts_with(request_prefix) {
filter
} else if request_prefix.starts_with(filter) {
request_prefix
} else {
return SourceListPlan::Skip;
};
let Some(delimiter) = delimiter.filter(|delimiter| !delimiter.is_empty()) else {
return SourceListPlan::Page {
prefix: source_prefix.to_string(),
};
};
// `source_prefix` always starts with `request_prefix`, so this slice is on
// a character boundary.
let extra = &source_prefix[request_prefix.len()..];
match extra.find(delimiter) {
Some(at) => SourceListPlan::Folded {
probe_prefix: source_prefix.to_string(),
common_prefix: format!("{request_prefix}{}", &extra[..at + delimiter.len()]),
},
None => SourceListPlan::Page {
prefix: source_prefix.to_string(),
},
}
}
/// Where one side resumes.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SideCursor {
pub token: Option<String>,
pub done: bool,
}
/// One page a side actually fetched this round.
#[derive(Clone, Debug, PartialEq, Eq)]
struct FetchedPage {
/// Token it was fetched with; `None` means from the start of the listing.
token: Option<String>,
/// Entries it contributed to the buffer, after the `last_key` filter.
count: usize,
/// Cursor for the page after it, `None` when it was the last one.
next_token: Option<String>,
}
/// Where a side resumes after `consumed` of its buffered entries were taken.
///
/// A fully consumed page advances to its successor; a partially consumed one
/// is re-listed from the same cursor next time and re-filtered by `last_key`.
fn advance_cursor(pages: &[FetchedPage], consumed: usize) -> SideCursor {
let mut remaining = consumed;
let mut cursor = SideCursor { token: None, done: true };
for page in pages {
if remaining >= page.count {
remaining -= page.count;
cursor = match &page.next_token {
Some(next) => SideCursor {
token: Some(next.clone()),
done: false,
},
None => SideCursor { token: None, done: true },
};
} else {
cursor = SideCursor {
token: page.token.clone(),
done: false,
};
break;
}
}
cursor
}
/// A page the merge driver still needs.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FetchRequest {
pub side: MergeSide,
pub token: Option<String>,
}
#[derive(Debug, Default)]
struct SideState {
start: SideCursor,
pages: Vec<FetchedPage>,
entries: Vec<ListEntryKey>,
more: bool,
disabled: bool,
}
impl SideState {
fn from_cursor(token: Option<String>, done: bool) -> Self {
Self {
start: SideCursor { token, done },
..Default::default()
}
}
fn needs_page(&self, max_keys: usize) -> Option<Option<String>> {
if self.disabled || self.start.done {
return None;
}
match self.pages.last() {
None => Some(self.start.token.clone()),
Some(last) => {
let room = self.entries.len() < max_keys;
let capped = self.pages.len() >= MAX_LIST_FETCHES_PER_SIDE;
(self.more && room && !capped).then(|| last.next_token.clone())
}
}
}
}
/// The merged page, once both sides have handed over everything they will.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MergeOutcome {
/// Entries of the merged page, in wire order; indices point into each
/// side's buffer in push order.
pub picks: Vec<MergePick>,
pub is_truncated: bool,
/// `Some` exactly when `is_truncated`.
pub next_token: Option<ListThroughToken>,
}
/// Drives one merged page: the caller asks [`Self::next_fetch`] what to list,
/// hands the page back with [`Self::push_page`], and finishes with
/// [`Self::finish`]. Nothing here does I/O, so the same driver is exercised by
/// the property test and by the handler.
#[derive(Debug)]
pub struct ListThroughMerger {
max_keys: usize,
last_key: Option<String>,
local: SideState,
source: SideState,
}
impl ListThroughMerger {
/// `token` is the envelope from the client's continuation token, absent on
/// the first page of a listing.
pub fn new(max_keys: usize, token: Option<&ListThroughToken>) -> Self {
let (local, source, last_key) = match token {
Some(token) => (
SideState::from_cursor(token.local.clone(), token.local_done),
SideState::from_cursor(token.source.clone(), token.source_done),
token.last_key.clone(),
),
None => (SideState::default(), SideState::default(), None),
};
Self {
max_keys,
last_key,
local,
source,
}
}
/// Whether an entry the listing returned still belongs to this page: a
/// re-listed page repeats what the previous page already consumed.
pub fn accepts(&self, name: &str) -> bool {
self.last_key.as_deref().is_none_or(|bound| name > bound)
}
/// The source contributes nothing to this page: it failed, is rate-limited,
/// or `filter.prefix` excludes it.
pub fn disable_source(&mut self) {
self.source.disabled = true;
}
pub fn next_fetch(&self) -> Option<FetchRequest> {
for (side, state) in [(MergeSide::Local, &self.local), (MergeSide::Source, &self.source)] {
if let Some(token) = state.needs_page(self.max_keys) {
return Some(FetchRequest { side, token });
}
}
None
}
/// Records one fetched page. `entries` must be sorted by `name` and already
/// filtered with [`Self::accepts`]; the caller keeps the matching payloads
/// in the same order.
pub fn push_page(&mut self, side: MergeSide, entries: Vec<ListEntryKey>, is_truncated: bool, next_token: Option<String>) {
let state = match side {
MergeSide::Local => &mut self.local,
MergeSide::Source => &mut self.source,
};
let token = match state.pages.last() {
Some(last) => last.next_token.clone(),
None => state.start.token.clone(),
};
// A truncated page without a cursor cannot be continued; treating the
// side as finished is the only alternative to looping on it forever.
state.more = is_truncated && next_token.is_some();
state.pages.push(FetchedPage {
token,
count: entries.len(),
next_token: is_truncated.then_some(next_token).flatten(),
});
state.entries.extend(entries);
}
pub fn finish(self) -> MergeOutcome {
let Self {
max_keys,
last_key,
local,
source,
} = self;
// A side with more pages behind it can only be trusted up to the last
// key it handed over: past that horizon the other side's entries could
// still be deduplicated by one we have not seen, which is what keeps
// "local wins on equal keys" true across page boundaries.
let horizon = [
local
.more
.then(|| local.entries.last().map_or("", |entry| entry.name.as_str())),
source
.more
.then(|| source.entries.last().map_or("", |entry| entry.name.as_str())),
]
.into_iter()
.flatten()
.min();
let mut picks = Vec::with_capacity(max_keys.min(local.entries.len() + source.entries.len()));
let mut consumed_local = 0usize;
let mut consumed_source = 0usize;
let mut consumed_key: Option<String> = None;
while picks.len() < max_keys {
let next_local = local.entries.get(consumed_local).map(|entry| entry.name.as_str());
let next_source = source.entries.get(consumed_source).map(|entry| entry.name.as_str());
let name = match (next_local, next_source) {
(None, None) => break,
(Some(name), None) | (None, Some(name)) => name,
(Some(left), Some(right)) => left.min(right),
};
if horizon.is_some_and(|horizon| name > horizon) {
break;
}
let take_local = next_local == Some(name);
let take_source = next_source == Some(name);
consumed_key = Some(name.to_string());
if take_local {
picks.push(MergePick {
side: MergeSide::Local,
index: consumed_local,
});
consumed_local += 1;
} else {
picks.push(MergePick {
side: MergeSide::Source,
index: consumed_source,
});
}
if take_source {
consumed_source += 1;
}
}
let local_cursor = advance_cursor(&local.pages, consumed_local);
let source_cursor = if source.disabled {
// Keep the source where it was so a recovered source resumes there;
// this page is answered from local state alone.
source.start.clone()
} else {
advance_cursor(&source.pages, consumed_source)
};
let local_left = !local_cursor.done || consumed_local < local.entries.len();
let source_left = !source.disabled && (!source_cursor.done || consumed_source < source.entries.len());
let is_truncated = local_left || source_left;
let last_key = consumed_key.or(last_key);
MergeOutcome {
picks,
is_truncated,
next_token: is_truncated.then(|| ListThroughToken::new(local_cursor, source_cursor, last_key)),
}
}
}
/// Token bucket capping source `ListObjectsV2` calls for one bucket.
///
/// A caller that cannot be served inside its budget is refused rather than
/// queued: a listing degrades to local state instead of holding the request
/// open behind other tenants' listings.
#[derive(Debug)]
pub struct SourceListRateLimiter {
rate_per_sec: f64,
burst: f64,
state: Mutex<RateLimiterState>,
}
#[derive(Debug)]
struct RateLimiterState {
tokens: f64,
updated_at: Instant,
}
impl SourceListRateLimiter {
pub fn new(rate_per_sec: u32) -> Self {
let rate_per_sec = f64::from(rate_per_sec.max(1));
Self {
rate_per_sec,
burst: rate_per_sec,
state: Mutex::new(RateLimiterState {
tokens: rate_per_sec,
updated_at: Instant::now(),
}),
}
}
/// Reserves one call, returning how long the caller must wait before making
/// it, or `None` when that wait would exceed `max_wait` (nothing is
/// reserved then).
pub fn reserve(&self, max_wait: Duration) -> Option<Duration> {
self.reserve_at(Instant::now(), max_wait)
}
pub fn reserve_at(&self, now: Instant, max_wait: Duration) -> Option<Duration> {
let mut state = self.state.lock();
let elapsed = now.saturating_duration_since(state.updated_at).as_secs_f64();
state.tokens = (state.tokens + elapsed * self.rate_per_sec).min(self.burst);
state.updated_at = now;
if state.tokens >= 1.0 {
state.tokens -= 1.0;
return Some(Duration::ZERO);
}
let wait = Duration::from_secs_f64((1.0 - state.tokens) / self.rate_per_sec);
if wait > max_wait {
return None;
}
state.tokens -= 1.0;
Some(wait)
}
}
impl Default for SourceListRateLimiter {
fn default() -> Self {
Self::new(SOURCE_LIST_RATE_PER_SEC)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::collections::BTreeSet;
/// One `ListObjectsV2` page over a sorted key set, with the S3 rules the
/// merge relies on: delimiter roll-up, `max_keys`, and a continuation
/// token that resumes after the last entry the page returned.
fn reference_page(
keys: &[String],
prefix: &str,
delimiter: Option<&str>,
after: Option<&str>,
max_keys: usize,
) -> (Vec<ListEntryKey>, bool, Option<String>) {
let mut entries: Vec<ListEntryKey> = Vec::new();
for key in keys.iter().filter(|key| key.starts_with(prefix)) {
let entry = match delimiter.and_then(|delimiter| key[prefix.len()..].find(delimiter).map(|at| (delimiter, at))) {
Some((delimiter, at)) => ListEntryKey::prefix(&key[..prefix.len() + at + delimiter.len()]),
None => ListEntryKey::object(key.clone()),
};
if entries.last().is_none_or(|last| last.name != entry.name) {
entries.push(entry);
}
}
if let Some(after) = after {
entries.retain(|entry| entry.name.as_str() > after);
}
let truncated = entries.len() > max_keys;
entries.truncate(max_keys);
let next = truncated.then(|| entries.last().map(|entry| entry.name.clone())).flatten();
(entries, truncated && next.is_some(), next)
}
/// Full pagination through the merger, returning every entry it emitted and
/// the page sizes it produced.
fn walk(
local: &[String],
source: &[String],
prefix: &str,
delimiter: Option<&str>,
max_keys: usize,
) -> (Vec<(ListEntryKey, MergeSide)>, Vec<usize>) {
let mut emitted = Vec::new();
let mut page_sizes = Vec::new();
let mut token: Option<ListThroughToken> = None;
for _ in 0..10_000 {
let mut merger = ListThroughMerger::new(max_keys, token.as_ref());
let mut buffers = [Vec::<ListEntryKey>::new(), Vec::<ListEntryKey>::new()];
while let Some(fetch) = merger.next_fetch() {
let keys = match fetch.side {
MergeSide::Local => local,
MergeSide::Source => source,
};
let (entries, truncated, next) = reference_page(keys, prefix, delimiter, fetch.token.as_deref(), max_keys);
let kept: Vec<ListEntryKey> = entries.into_iter().filter(|entry| merger.accepts(&entry.name)).collect();
buffers[usize::from(fetch.side == MergeSide::Source)].extend(kept.iter().cloned());
merger.push_page(fetch.side, kept, truncated, next);
}
let outcome = merger.finish();
page_sizes.push(outcome.picks.len());
for pick in &outcome.picks {
let entry = buffers[usize::from(pick.side == MergeSide::Source)][pick.index].clone();
emitted.push((entry, pick.side));
}
if !outcome.is_truncated {
return (emitted, page_sizes);
}
token = outcome.next_token;
}
panic!("merged pagination did not terminate");
}
fn expected(local: &[String], source: &[String], prefix: &str, delimiter: Option<&str>) -> Vec<ListEntryKey> {
let mut all: Vec<String> = local.iter().chain(source.iter()).cloned().collect();
all.sort();
all.dedup();
let (entries, _, _) = reference_page(&all, prefix, delimiter, None, usize::MAX);
entries
}
#[test]
fn reference_page_rolls_up_and_paginates() {
let keys = vec!["a/1".to_string(), "a/2".to_string(), "b".to_string(), "c/1".to_string()];
let (entries, truncated, next) = reference_page(&keys, "", Some("/"), None, 2);
assert_eq!(entries, vec![ListEntryKey::prefix("a/"), ListEntryKey::object("b")]);
assert!(truncated);
assert_eq!(next.as_deref(), Some("b"));
}
#[test]
fn merged_pages_are_ordered_and_local_wins_on_equal_keys() {
let local = vec!["a".to_string(), "c".to_string()];
let source = vec!["b".to_string(), "c".to_string(), "d".to_string()];
let (emitted, sizes) = walk(&local, &source, "", None, 2);
let names: Vec<&str> = emitted.iter().map(|(entry, _)| entry.name.as_str()).collect();
assert_eq!(names, vec!["a", "b", "c", "d"]);
assert_eq!(emitted[2].1, MergeSide::Local, "the shared key must come from local");
assert!(sizes.iter().all(|size| *size <= 2), "{sizes:?}");
}
#[test]
fn source_only_listing_paginates_without_a_local_side() {
let source: Vec<String> = (0..7).map(|index| format!("k{index}")).collect();
let (emitted, _) = walk(&[], &source, "", None, 3);
assert_eq!(emitted.len(), 7);
assert!(emitted.iter().all(|(_, side)| *side == MergeSide::Source));
}
#[test]
fn a_disabled_source_answers_from_local_alone() {
let mut merger = ListThroughMerger::new(10, None);
merger.disable_source();
assert_eq!(
merger.next_fetch(),
Some(FetchRequest {
side: MergeSide::Local,
token: None
})
);
merger.push_page(MergeSide::Local, vec![ListEntryKey::object("a")], false, None);
assert_eq!(merger.next_fetch(), None);
let outcome = merger.finish();
assert_eq!(outcome.picks.len(), 1);
assert!(!outcome.is_truncated);
assert!(outcome.next_token.is_none());
}
#[test]
fn a_degraded_page_keeps_the_source_cursor_for_the_next_one() {
let resume = ListThroughToken {
t: LIST_THROUGH_TOKEN_TAG.to_string(),
v: LIST_THROUGH_TOKEN_VERSION,
local: Some("local-1".to_string()),
local_done: false,
source: Some("source-1".to_string()),
source_done: false,
last_key: Some("a".to_string()),
};
let mut merger = ListThroughMerger::new(1, Some(&resume));
merger.disable_source();
merger.push_page(
MergeSide::Local,
vec![ListEntryKey::object("b"), ListEntryKey::object("c")],
true,
Some("local-2".to_string()),
);
let outcome = merger.finish();
assert!(outcome.is_truncated);
let token = outcome.next_token.expect("truncated page carries a token");
assert_eq!(token.source.as_deref(), Some("source-1"), "the source cursor must not move");
assert!(!token.source_done);
assert_eq!(token.last_key.as_deref(), Some("b"));
assert_eq!(token.local.as_deref(), Some("local-1"), "a partly read page is re-listed");
}
#[test]
fn token_round_trips_and_rejects_tampering() {
let token = ListThroughToken::new(
SideCursor {
token: Some("l".to_string()),
done: false,
},
SideCursor { token: None, done: true },
Some("k".to_string()),
);
let encoded = token.encode();
assert_eq!(decode_continuation_token(&encoded), Ok(ListThroughCursor::Merged(Box::new(token))));
let bumped = encoded.replace("\"v\":1", "\"v\":2");
assert_eq!(decode_continuation_token(&bumped), Err(ListThroughTokenError::UnsupportedVersion(2)));
let extra = encoded.replace("{", "{\"x\":1,");
assert_eq!(decode_continuation_token(&extra), Err(ListThroughTokenError::Malformed));
let truncated = &encoded[..encoded.len() - 3];
assert_eq!(decode_continuation_token(truncated), Ok(ListThroughCursor::Local(truncated.to_string())));
let no_version = "{\"t\":\"odm-list\"}";
assert_eq!(decode_continuation_token(no_version), Err(ListThroughTokenError::Malformed));
}
#[test]
fn a_plain_local_marker_stays_local() {
assert_eq!(
decode_continuation_token("photos/2024/01.jpg"),
Ok(ListThroughCursor::Local("photos/2024/01.jpg".to_string()))
);
assert_eq!(
decode_continuation_token("{not json"),
Ok(ListThroughCursor::Local("{not json".to_string()))
);
assert_eq!(
decode_continuation_token("{\"t\":\"other\"}"),
Ok(ListThroughCursor::Local("{\"t\":\"other\"}".to_string()))
);
}
#[test]
fn source_list_plan_intersects_the_filter_prefix() {
assert_eq!(source_list_plan("", None, None), SourceListPlan::Page { prefix: String::new() });
assert_eq!(
source_list_plan("photos/2024/", Some("photos/"), None),
SourceListPlan::Page {
prefix: "photos/2024/".to_string()
}
);
assert_eq!(
source_list_plan("photos/", Some("photos/2024/"), None),
SourceListPlan::Page {
prefix: "photos/2024/".to_string()
}
);
assert_eq!(source_list_plan("videos/", Some("photos/"), None), SourceListPlan::Skip);
assert_eq!(
source_list_plan("", Some("photos/2024/"), Some("/")),
SourceListPlan::Folded {
probe_prefix: "photos/2024/".to_string(),
common_prefix: "photos/".to_string(),
}
);
assert_eq!(
source_list_plan("pho", Some("photos"), Some("/")),
SourceListPlan::Page {
prefix: "photos".to_string()
},
"a filter prefix that adds no delimiter keeps the source's own roll-up"
);
}
#[test]
fn rate_limiter_spends_its_burst_then_paces_and_refuses() {
let limiter = SourceListRateLimiter::new(10);
let start = Instant::now();
for _ in 0..10 {
assert_eq!(limiter.reserve_at(start, Duration::from_secs(1)), Some(Duration::ZERO));
}
let paced = limiter.reserve_at(start, Duration::from_secs(1)).expect("within the budget");
assert!(paced > Duration::ZERO && paced <= Duration::from_millis(101), "{paced:?}");
assert_eq!(limiter.reserve_at(start, Duration::ZERO), None, "a zero budget refuses");
// A full second of refill restores the whole burst.
assert_eq!(limiter.reserve_at(start + Duration::from_secs(5), Duration::ZERO), Some(Duration::ZERO));
}
fn key_set() -> impl Strategy<Value = Vec<String>> {
proptest::collection::btree_set(
proptest::sample::select(vec!["a", "a/", "a/1", "a/2", "a/b/1", "b", "b/1", "c", "c/1", "c/2", "d", "d/e/f"])
.prop_map(str::to_string),
0..=12,
)
.prop_map(|set: BTreeSet<String>| set.into_iter().collect())
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Full pagination of a merged listing equals the sorted, deduplicated
/// union of both sides, with every shared key served by local, and no
/// page longer than `max_keys`.
#[test]
fn merged_pagination_equals_the_deduplicated_union(
local in key_set(),
source in key_set(),
max_keys in 1usize..=5,
with_delimiter in any::<bool>(),
prefix in proptest::sample::select(vec!["", "a", "a/", "c/"]),
) {
let delimiter = with_delimiter.then_some("/");
let (emitted, sizes) = walk(&local, &source, prefix, delimiter, max_keys);
let got: Vec<ListEntryKey> = emitted.iter().map(|(entry, _)| entry.clone()).collect();
prop_assert_eq!(got, expected(&local, &source, prefix, delimiter));
prop_assert!(sizes.iter().all(|size| *size <= max_keys), "{:?}", sizes);
for (entry, side) in &emitted {
if !entry.is_prefix && local.iter().any(|key| key == &entry.name) {
prop_assert_eq!(*side, MergeSide::Local, "local must win for {}", entry.name);
}
}
}
}
}
@@ -1,60 +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.
//! On-Demand Migration (ODM): a bucket can name an external S3-compatible
//! source bucket; GET misses are served from that source and backfilled
//! locally. This module owns the bucket-level configuration model
//! (`on-demand-migration.json` in the bucket metadata file), the source
//! client, and the per-node runtime (`sys`) that turns configs into live
//! clients guarded by a breaker, a negative cache, singleflight and a pull
//! concurrency limit (rustfs/backlog#2147).
pub mod backfill;
pub mod breaker;
pub mod config;
pub mod list_through;
pub mod negative_cache;
pub mod pull;
pub mod source_client;
pub mod stats;
pub mod sys;
pub use breaker::{
BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION, Breaker,
BreakerState, BreakerTransition, BreakerVerdict,
};
pub use config::{
ConfigPublishHook, FilterConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_HOOK, ON_DEMAND_MIGRATION_CONFIG_VERSION,
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy, SourceConfig,
SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
};
pub use list_through::{
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
};
pub use negative_cache::{NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache};
pub use pull::{
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS, PullCompletion,
PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody, WriteBackError,
WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with, idle_guarded_body,
};
pub use stats::{
GaugeGuard, LastSourceError, LatencyBucketSnapshot, OdmOp, OdmOutcome, OdmStats, OdmStatsSnapshot, PullFailureReason,
PullPath, SOURCE_LATENCY_BUCKET_BOUNDS_MS, SourceLatencySnapshot,
};
pub use sys::{
ApplyOutcome, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, OdmBucketSnapshot, OdmLookup, OdmStateError,
OnDemandMigrationSys, PullError, PullFollower, PullLeader, PullOutcome, PullResult, PullSlot, source_client_spec,
};
@@ -1,130 +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 cache of keys the source answered 404 for
//! (rustfs/backlog#2152). A hit short-circuits the source lookup for
//! `policy.negative_cache_ttl_secs`; a TTL of zero disables the cache.
//!
//! Entries are never invalidated on a local PUT: once the object exists
//! locally the handler never consults ODM for it, so a stale negative entry
//! is harmless.
use std::time::Duration;
/// Upper bound on remembered keys per bucket; LRU eviction beyond it.
pub const NEGATIVE_CACHE_MAX_ENTRIES: u64 = 100_000;
#[derive(Debug)]
pub struct NegativeCache {
cache: Option<moka::sync::Cache<String, ()>>,
ttl: Duration,
}
impl NegativeCache {
/// `ttl == 0` builds a disabled cache that never records anything.
pub fn new(ttl: Duration) -> Self {
Self::with_capacity(ttl, NEGATIVE_CACHE_MAX_ENTRIES)
}
pub fn with_capacity(ttl: Duration, max_entries: u64) -> Self {
let cache = (!ttl.is_zero()).then(|| {
moka::sync::Cache::builder()
.max_capacity(max_entries)
.time_to_live(ttl)
.build()
});
Self { cache, ttl }
}
pub fn is_enabled(&self) -> bool {
self.cache.is_some()
}
pub fn ttl(&self) -> Duration {
self.ttl
}
/// Whether `key` is currently remembered as absent on the source.
pub fn contains(&self, key: &str) -> bool {
self.cache.as_ref().is_some_and(|cache| cache.get(key).is_some())
}
/// Remembers `key` as absent; no-op when disabled.
pub fn insert(&self, key: &str) {
if let Some(cache) = &self.cache {
cache.insert(key.to_string(), ());
}
}
/// Forgets `key` (e.g. after an admin-triggered backfill found it).
pub fn remove(&self, key: &str) {
if let Some(cache) = &self.cache {
cache.invalidate(key);
}
}
/// Approximate live entry count, for status snapshots only.
pub fn len(&self) -> u64 {
self.cache.as_ref().map_or(0, |cache| {
cache.run_pending_tasks();
cache.entry_count()
})
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_expires_after_ttl() {
let cache = NegativeCache::new(Duration::from_millis(80));
assert!(cache.is_enabled());
cache.insert("a/x");
assert!(cache.contains("a/x"));
assert!(!cache.contains("a/y"));
std::thread::sleep(Duration::from_millis(160));
assert!(!cache.contains("a/x"), "entry must expire after the TTL");
}
#[test]
fn zero_ttl_disables_the_cache() {
let cache = NegativeCache::new(Duration::ZERO);
assert!(!cache.is_enabled());
cache.insert("a/x");
assert!(!cache.contains("a/x"));
assert!(cache.is_empty());
}
#[test]
fn remove_forgets_a_key() {
let cache = NegativeCache::new(Duration::from_secs(30));
cache.insert("a/x");
cache.remove("a/x");
assert!(!cache.contains("a/x"));
}
#[test]
fn capacity_bounds_entries() {
let cache = NegativeCache::with_capacity(Duration::from_secs(30), 4);
for i in 0..64 {
cache.insert(&format!("k{i}"));
}
assert!(cache.len() <= 4, "len {} exceeds capacity", cache.len());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,531 +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 on-demand migration counters (rustfs/backlog#2152).
//!
//! `OdmStats` is lock-free and survives config rebuilds; `snapshot()` turns
//! it into the serializable `OdmStatsSnapshot` that the metrics collector
//! and the admin status route (ODM-10/14/15) consume. Field names and label
//! values are a wire contract: the golden JSON test below pins them.
use super::breaker::BreakerState;
use super::source_client::SourceError;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use time::OffsetDateTime;
/// Request operations that can enter ODM.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OdmOp {
Get,
Head,
}
impl OdmOp {
pub const ALL: [OdmOp; 2] = [OdmOp::Get, OdmOp::Head];
pub fn as_str(self) -> &'static str {
match self {
OdmOp::Get => "get",
OdmOp::Head => "head",
}
}
}
/// How a request that entered ODM ended. `local_hit` is deliberately absent:
/// requests served locally never reach the runtime.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OdmOutcome {
SourceHit,
SourceMiss,
SourceError,
BreakerOpen,
NegativeCached,
Filtered,
Unsupported,
}
impl OdmOutcome {
pub const ALL: [OdmOutcome; 7] = [
OdmOutcome::SourceHit,
OdmOutcome::SourceMiss,
OdmOutcome::SourceError,
OdmOutcome::BreakerOpen,
OdmOutcome::NegativeCached,
OdmOutcome::Filtered,
OdmOutcome::Unsupported,
];
pub fn as_str(self) -> &'static str {
match self {
OdmOutcome::SourceHit => "source_hit",
OdmOutcome::SourceMiss => "source_miss",
OdmOutcome::SourceError => "source_error",
OdmOutcome::BreakerOpen => "breaker_open",
OdmOutcome::NegativeCached => "negative_cached",
OdmOutcome::Filtered => "filtered",
OdmOutcome::Unsupported => "unsupported",
}
}
}
/// Which pipeline stored a pulled object locally.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PullPath {
/// Streamed to the client and written locally in one pass.
Inline,
/// Pulled by a background task after a partial/large read.
Background,
/// Pulled by the backfill job.
Backfill,
}
impl PullPath {
pub const ALL: [PullPath; 3] = [PullPath::Inline, PullPath::Background, PullPath::Backfill];
pub fn as_str(self) -> &'static str {
match self {
PullPath::Inline => "inline",
PullPath::Background => "background",
PullPath::Backfill => "backfill",
}
}
}
/// Why a pull did not produce a local object.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PullFailureReason {
SourceNotFound,
SourceAccessDenied,
SourceThrottled,
SourceTimeout,
SourceConnect,
SourceServerError,
SourceUnsupported,
SourceOther,
/// Source bytes did not match the ETag advertised by HEAD/GET.
EtagMismatch,
/// The local write (internal PUT) failed.
LocalWrite,
/// The bucket quota rejected the write-back.
Quota,
/// The bucket state was removed or the process is shutting down.
Canceled,
/// The background pull queue was full.
QueueFull,
}
impl PullFailureReason {
pub const ALL: [PullFailureReason; 13] = [
PullFailureReason::SourceNotFound,
PullFailureReason::SourceAccessDenied,
PullFailureReason::SourceThrottled,
PullFailureReason::SourceTimeout,
PullFailureReason::SourceConnect,
PullFailureReason::SourceServerError,
PullFailureReason::SourceUnsupported,
PullFailureReason::SourceOther,
PullFailureReason::EtagMismatch,
PullFailureReason::LocalWrite,
PullFailureReason::Quota,
PullFailureReason::Canceled,
PullFailureReason::QueueFull,
];
pub fn as_str(self) -> &'static str {
match self {
PullFailureReason::SourceNotFound => "source_not_found",
PullFailureReason::SourceAccessDenied => "source_access_denied",
PullFailureReason::SourceThrottled => "source_throttled",
PullFailureReason::SourceTimeout => "source_timeout",
PullFailureReason::SourceConnect => "source_connect",
PullFailureReason::SourceServerError => "source_server_error",
PullFailureReason::SourceUnsupported => "source_unsupported",
PullFailureReason::SourceOther => "source_other",
PullFailureReason::EtagMismatch => "etag_mismatch",
PullFailureReason::LocalWrite => "local_write",
PullFailureReason::Quota => "quota",
PullFailureReason::Canceled => "canceled",
PullFailureReason::QueueFull => "queue_full",
}
}
}
impl From<&SourceError> for PullFailureReason {
fn from(err: &SourceError) -> Self {
match err {
SourceError::NotFound => PullFailureReason::SourceNotFound,
SourceError::AccessDenied => PullFailureReason::SourceAccessDenied,
SourceError::Throttled => PullFailureReason::SourceThrottled,
SourceError::Timeout => PullFailureReason::SourceTimeout,
SourceError::Connect(_) => PullFailureReason::SourceConnect,
SourceError::ServerError(_) => PullFailureReason::SourceServerError,
SourceError::Unsupported(_) => PullFailureReason::SourceUnsupported,
SourceError::Other(_) => PullFailureReason::SourceOther,
}
}
}
/// Upper bounds (milliseconds) of the source latency histogram buckets; the
/// implicit last bucket is unbounded. Roughly logarithmic from 5 ms to 60 s.
pub const SOURCE_LATENCY_BUCKET_BOUNDS_MS: [u64; 14] = [
5, 10, 20, 50, 100, 200, 500, 1_000, 2_000, 5_000, 10_000, 20_000, 30_000, 60_000,
];
#[derive(Debug, Default)]
struct LatencyHistogram {
/// One counter per bound plus one for the overflow bucket.
buckets: [AtomicU64; SOURCE_LATENCY_BUCKET_BOUNDS_MS.len() + 1],
count: AtomicU64,
sum_ms: AtomicU64,
}
impl LatencyHistogram {
fn observe(&self, latency: Duration) {
let ms = u64::try_from(latency.as_millis()).unwrap_or(u64::MAX);
let index = SOURCE_LATENCY_BUCKET_BOUNDS_MS
.iter()
.position(|bound| ms <= *bound)
.unwrap_or(SOURCE_LATENCY_BUCKET_BOUNDS_MS.len());
self.buckets[index].fetch_add(1, Ordering::Relaxed);
self.count.fetch_add(1, Ordering::Relaxed);
self.sum_ms.fetch_add(ms, Ordering::Relaxed);
}
fn snapshot(&self) -> SourceLatencySnapshot {
let mut cumulative = 0;
let buckets = SOURCE_LATENCY_BUCKET_BOUNDS_MS
.iter()
.zip(self.buckets.iter())
.map(|(bound, counter)| {
cumulative += counter.load(Ordering::Relaxed);
LatencyBucketSnapshot {
le_ms: *bound,
count: cumulative,
}
})
.collect();
SourceLatencySnapshot {
buckets,
count: self.count.load(Ordering::Relaxed),
sum_ms: self.sum_ms.load(Ordering::Relaxed),
}
}
}
/// The most recent source failure, kept for operators: class only, never the
/// key or the message (which may echo attacker-controlled input).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LastSourceError {
pub class: String,
#[serde(with = "time::serde::rfc3339")]
pub at: OffsetDateTime,
}
#[derive(Debug, Default)]
pub struct OdmStats {
requests_total: [[AtomicU64; OdmOutcome::ALL.len()]; OdmOp::ALL.len()],
pulled_bytes_total: AtomicU64,
pulled_objects_total: [AtomicU64; PullPath::ALL.len()],
pull_failures_total: [AtomicU64; PullFailureReason::ALL.len()],
inflight_pulls: AtomicU64,
queue_depth: AtomicU64,
source_latency: LatencyHistogram,
last_source_error: Mutex<Option<LastSourceError>>,
}
impl OdmStats {
pub fn new() -> Self {
Self::default()
}
pub fn record_request(&self, op: OdmOp, outcome: OdmOutcome) {
self.requests_total[op as usize][outcome as usize].fetch_add(1, Ordering::Relaxed);
}
pub fn record_pulled_bytes(&self, bytes: u64) {
self.pulled_bytes_total.fetch_add(bytes, Ordering::Relaxed);
}
pub fn record_pulled_object(&self, path: PullPath) {
self.pulled_objects_total[path as usize].fetch_add(1, Ordering::Relaxed);
}
pub fn record_pull_failure(&self, reason: PullFailureReason) {
self.pull_failures_total[reason as usize].fetch_add(1, Ordering::Relaxed);
}
pub fn record_source_latency(&self, latency: Duration) {
self.source_latency.observe(latency);
}
pub fn record_source_error(&self, err: &SourceError) {
self.record_source_error_at(err, OffsetDateTime::now_utc());
}
pub fn record_source_error_at(&self, err: &SourceError, at: OffsetDateTime) {
*self.last_source_error.lock() = Some(LastSourceError {
class: err.class_label().to_string(),
at,
});
}
pub fn last_source_error(&self) -> Option<LastSourceError> {
self.last_source_error.lock().clone()
}
pub fn inflight_pulls(&self) -> u64 {
self.inflight_pulls.load(Ordering::Relaxed)
}
pub fn queue_depth(&self) -> u64 {
self.queue_depth.load(Ordering::Relaxed)
}
/// RAII increment of `inflight_pulls`.
pub fn inflight_guard(self: &Arc<Self>) -> GaugeGuard {
GaugeGuard::new(Arc::clone(self), OdmGauge::InflightPulls)
}
/// RAII increment of `queue_depth`.
pub fn queue_guard(self: &Arc<Self>) -> GaugeGuard {
GaugeGuard::new(Arc::clone(self), OdmGauge::QueueDepth)
}
fn gauge(&self, gauge: OdmGauge) -> &AtomicU64 {
match gauge {
OdmGauge::InflightPulls => &self.inflight_pulls,
OdmGauge::QueueDepth => &self.queue_depth,
}
}
/// Read-only, side-effect-free copy of every counter. The breaker lives
/// next to the stats in the bucket state; its state is passed in so the
/// snapshot stays a single document.
pub fn snapshot(&self, breaker_state: BreakerState) -> OdmStatsSnapshot {
let mut requests_total = BTreeMap::new();
for op in OdmOp::ALL {
let mut by_outcome = BTreeMap::new();
for outcome in OdmOutcome::ALL {
by_outcome.insert(
outcome.as_str().to_string(),
self.requests_total[op as usize][outcome as usize].load(Ordering::Relaxed),
);
}
requests_total.insert(op.as_str().to_string(), by_outcome);
}
let pulled_objects_total = PullPath::ALL
.iter()
.map(|path| {
(
path.as_str().to_string(),
self.pulled_objects_total[*path as usize].load(Ordering::Relaxed),
)
})
.collect();
let pull_failures_total = PullFailureReason::ALL
.iter()
.map(|reason| {
(
reason.as_str().to_string(),
self.pull_failures_total[*reason as usize].load(Ordering::Relaxed),
)
})
.collect();
OdmStatsSnapshot {
requests_total,
pulled_bytes_total: self.pulled_bytes_total.load(Ordering::Relaxed),
pulled_objects_total,
pull_failures_total,
inflight_pulls: self.inflight_pulls(),
queue_depth: self.queue_depth(),
source_latency: self.source_latency.snapshot(),
last_source_error: self.last_source_error(),
breaker_state,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OdmGauge {
InflightPulls,
QueueDepth,
}
/// Increments a gauge on creation and decrements it on drop. Owns its
/// `OdmStats` so it can live inside the pull slot handed to callers.
#[derive(Debug)]
pub struct GaugeGuard {
stats: Arc<OdmStats>,
gauge: OdmGauge,
}
impl GaugeGuard {
fn new(stats: Arc<OdmStats>, gauge: OdmGauge) -> Self {
stats.gauge(gauge).fetch_add(1, Ordering::Relaxed);
Self { stats, gauge }
}
}
impl Drop for GaugeGuard {
fn drop(&mut self) {
self.stats.gauge(self.gauge).fetch_sub(1, Ordering::Relaxed);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LatencyBucketSnapshot {
/// Upper bound of the bucket in milliseconds.
pub le_ms: u64,
/// Cumulative observations at or below `le_ms`.
pub count: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceLatencySnapshot {
pub buckets: Vec<LatencyBucketSnapshot>,
/// Total observations, including those above the last bound.
pub count: u64,
pub sum_ms: u64,
}
/// Serializable copy of [`OdmStats`]. Every key is snake_case and every
/// label set is fixed, so consumers can rely on the document shape.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OdmStatsSnapshot {
/// `op -> outcome -> count`.
pub requests_total: BTreeMap<String, BTreeMap<String, u64>>,
pub pulled_bytes_total: u64,
/// `path -> count`.
pub pulled_objects_total: BTreeMap<String, u64>,
/// `reason -> count`.
pub pull_failures_total: BTreeMap<String, u64>,
pub inflight_pulls: u64,
pub queue_depth: u64,
pub source_latency: SourceLatencySnapshot,
pub last_source_error: Option<LastSourceError>,
pub breaker_state: BreakerState,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use time::macros::datetime;
#[test]
fn snapshot_matches_golden_json() {
let stats = Arc::new(OdmStats::new());
stats.record_request(OdmOp::Get, OdmOutcome::SourceHit);
stats.record_request(OdmOp::Get, OdmOutcome::SourceHit);
stats.record_request(OdmOp::Head, OdmOutcome::NegativeCached);
stats.record_pulled_bytes(4096);
stats.record_pulled_object(PullPath::Inline);
stats.record_pull_failure(PullFailureReason::from(&SourceError::Timeout));
stats.record_source_latency(Duration::from_millis(3));
stats.record_source_latency(Duration::from_millis(750));
stats.record_source_latency(Duration::from_secs(90));
stats.record_source_error_at(&SourceError::ServerError(502), datetime!(2026-09-02 10:00:00 UTC));
let _inflight = stats.inflight_guard();
let _queued = stats.queue_guard();
let snapshot = stats.snapshot(BreakerState::HalfOpen);
let actual = serde_json::to_value(&snapshot).unwrap();
let expected = json!({
"requests_total": {
"get": {
"breaker_open": 0, "filtered": 0, "negative_cached": 0, "source_error": 0,
"source_hit": 2, "source_miss": 0, "unsupported": 0
},
"head": {
"breaker_open": 0, "filtered": 0, "negative_cached": 1, "source_error": 0,
"source_hit": 0, "source_miss": 0, "unsupported": 0
}
},
"pulled_bytes_total": 4096,
"pulled_objects_total": { "backfill": 0, "background": 0, "inline": 1 },
"pull_failures_total": {
"canceled": 0, "etag_mismatch": 0, "local_write": 0, "queue_full": 0, "quota": 0,
"source_access_denied": 0, "source_connect": 0, "source_not_found": 0, "source_other": 0,
"source_server_error": 0, "source_throttled": 0, "source_timeout": 1, "source_unsupported": 0
},
"inflight_pulls": 1,
"queue_depth": 1,
"source_latency": {
"buckets": [
{ "le_ms": 5, "count": 1 }, { "le_ms": 10, "count": 1 }, { "le_ms": 20, "count": 1 },
{ "le_ms": 50, "count": 1 }, { "le_ms": 100, "count": 1 }, { "le_ms": 200, "count": 1 },
{ "le_ms": 500, "count": 1 }, { "le_ms": 1000, "count": 2 }, { "le_ms": 2000, "count": 2 },
{ "le_ms": 5000, "count": 2 }, { "le_ms": 10000, "count": 2 }, { "le_ms": 20000, "count": 2 },
{ "le_ms": 30000, "count": 2 }, { "le_ms": 60000, "count": 2 }
],
"count": 3,
"sum_ms": 90753
},
"last_source_error": { "class": "server_error", "at": "2026-09-02T10:00:00Z" },
"breaker_state": "half_open"
});
assert_eq!(actual, expected);
let round_trip: OdmStatsSnapshot = serde_json::from_value(actual).unwrap();
assert_eq!(round_trip, snapshot);
}
#[test]
fn gauges_return_to_zero_when_guards_drop() {
let stats = Arc::new(OdmStats::new());
{
let _a = stats.inflight_guard();
let _b = stats.inflight_guard();
let _c = stats.queue_guard();
assert_eq!(stats.inflight_pulls(), 2);
assert_eq!(stats.queue_depth(), 1);
}
assert_eq!(stats.inflight_pulls(), 0);
assert_eq!(stats.queue_depth(), 0);
}
#[test]
fn pull_failure_reason_covers_every_source_error_class() {
let cases = [
(SourceError::NotFound, PullFailureReason::SourceNotFound),
(SourceError::AccessDenied, PullFailureReason::SourceAccessDenied),
(SourceError::Throttled, PullFailureReason::SourceThrottled),
(SourceError::Timeout, PullFailureReason::SourceTimeout),
(SourceError::Connect("x".into()), PullFailureReason::SourceConnect),
(SourceError::ServerError(500), PullFailureReason::SourceServerError),
(SourceError::Unsupported("x".into()), PullFailureReason::SourceUnsupported),
(SourceError::Other("x".into()), PullFailureReason::SourceOther),
];
for (err, reason) in cases {
assert_eq!(PullFailureReason::from(&err), reason, "{err:?}");
assert_eq!(serde_json::to_string(&reason).unwrap(), format!("\"{}\"", reason.as_str()));
}
}
#[test]
fn label_lists_are_exhaustive_and_unique() {
let outcomes: std::collections::BTreeSet<_> = OdmOutcome::ALL.iter().map(|o| o.as_str()).collect();
assert_eq!(outcomes.len(), OdmOutcome::ALL.len());
let reasons: std::collections::BTreeSet<_> = PullFailureReason::ALL.iter().map(|r| r.as_str()).collect();
assert_eq!(reasons.len(), PullFailureReason::ALL.len());
let paths: std::collections::BTreeSet<_> = PullPath::ALL.iter().map(|p| p.as_str()).collect();
assert_eq!(paths.len(), PullPath::ALL.len());
}
}
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -16,7 +16,7 @@ use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation};
use crate::data_usage::get_bucket_usage_memory;
use rustfs_config::QUOTA_CONFIG_FILE;
use rustfs_scanner_metrics::metrics::Metric;
use rustfs_scanner_contracts::metrics::Metric;
use std::sync::Arc;
use std::time::Instant;
use time::OffsetDateTime;
@@ -120,9 +120,9 @@ impl QuotaChecker {
let duration = start_time.elapsed();
// inc_time is now a plain fn (not async) — no .await needed.
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaCheck, duration);
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaCheck, duration);
if !allowed {
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaViolation, duration);
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaViolation, duration);
}
Ok(result)
@@ -185,7 +185,7 @@ impl QuotaChecker {
.await
.map_err(QuotaError::StorageError)?;
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
Ok(updated_at)
}
@@ -206,7 +206,7 @@ impl QuotaChecker {
}
.map_err(QuotaError::StorageError)?;
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
Ok(updated_at)
}
@@ -1,887 +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.
//! Shared builder for outbound `aws_sdk_s3::Client`s.
//!
//! Replication targets (`bucket_target_sys`) and the on-demand migration
//! source client build their remote clients from one neutral
//! [`RemoteS3EndpointSpec`]: endpoint assembly, credential handling, path-style
//! selection, custom CA / skip-TLS transports, the SDK retry policy and the
//! outbound SSRF gate all live here so both callers share exactly one policy.
//! The retry policy is the one knob the two consumers deliberately disagree
//! on, so [`RemoteS3EndpointSpec::retry`] is a required field rather than an
//! inherited SDK default. The gate keeps the
//! relaxed replication semantics documented in
//! `docs/operations/outbound-connection-policy.md`: private addresses are
//! always allowed, loopback only behind `RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET`.
use aws_credential_types::Credentials as SdkCredentials;
use aws_credential_types::provider::{ProvideCredentials, error::CredentialsError, future};
use aws_sdk_s3::config::Region as SdkRegion;
use aws_sdk_s3::config::RequestChecksumCalculation;
use aws_sdk_s3::config::SharedCredentialsProvider;
use aws_sdk_s3::config::SharedHttpClient;
use aws_sdk_s3::config::retry::RetryConfig;
use aws_sdk_s3::{Client as S3Client, Config as S3Config};
use aws_smithy_http_client::{Builder as SmithyHttpClientBuilder, tls as smithy_tls};
use aws_smithy_runtime_api::box_error::BoxError;
use aws_smithy_runtime_api::client::http::{
HttpConnector as SmithyHttpConnector, HttpConnectorFuture, SharedHttpConnector, http_client_fn,
};
use aws_smithy_runtime_api::client::interceptors::Intercept;
use aws_smithy_runtime_api::client::interceptors::context::BeforeTransmitInterceptorContextMut;
use aws_smithy_runtime_api::client::orchestrator::{HttpRequest, HttpResponse};
use aws_smithy_runtime_api::client::result::ConnectorError;
use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
use aws_smithy_types::body::SdkBody;
use aws_smithy_types::config_bag::ConfigBag;
use aws_smithy_types::timeout::TimeoutConfig;
use http::Uri;
use hyper_util::client::legacy::Client as HyperClient;
use hyper_util::rt::{TokioExecutor, TokioTimer};
use rustfs_config::{DEFAULT_TRUST_LEAF_CERT_AS_CA, ENV_TRUST_LEAF_CERT_AS_CA, RUSTFS_CA_CERT, RUSTFS_TLS_CERT};
use rustfs_utils::egress::{OutboundUrlError, validate_outbound_url};
use rustls_pki_types::pem::PemObject;
use std::fmt;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use tower::Service;
use tracing::warn;
use url::Url;
const REDACTED_CREDENTIAL: &str = "<redacted>";
pub(crate) const EXPIRED_REMOTE_TARGET_CREDENTIALS: &str = "remote target credentials have expired";
/// Request addressing style for a remote S3-compatible endpoint.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PathStyle {
/// Caller did not choose; the builder defaults to path-style because that
/// is what custom S3-compatible endpoints accept most reliably.
Auto,
/// `https://endpoint/bucket/key`.
Path,
/// `https://bucket.endpoint/key`.
VirtualHost,
}
impl PathStyle {
/// Resolves the style to the SDK `force_path_style` flag. `Auto` keeps
/// the historical replication default (path-style).
pub fn force_path_style(self) -> bool {
!matches!(self, PathStyle::VirtualHost)
}
}
/// SDK-level retry policy for a remote client. Retries are invisible to the
/// caller — one logical call becomes several wire requests — so every consumer
/// states its own instead of inheriting the SDK default: a caller that already
/// owns a retry budget would otherwise multiply it against an endpoint that is
/// by definition already failing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RemoteS3RetryPolicy {
/// One logical call is exactly one wire request; the caller owns the
/// retry budget.
Disabled,
/// Smithy's standard strategy, capped at `max_attempts` attempts in total
/// (the initial request included). Values below 1 are clamped to 1.
Standard { max_attempts: u32 },
}
/// The SDK default replication targets have always run with, written out so a
/// change to it is a change to this line rather than to a dependency default.
pub const REPLICATION_TARGET_RETRY_POLICY: RemoteS3RetryPolicy = RemoteS3RetryPolicy::Standard { max_attempts: 3 };
impl RemoteS3RetryPolicy {
fn retry_config(self) -> RetryConfig {
match self {
RemoteS3RetryPolicy::Disabled => RetryConfig::disabled(),
RemoteS3RetryPolicy::Standard { max_attempts } => RetryConfig::standard().with_max_attempts(max_attempts.max(1)),
}
}
}
/// Static or temporary credentials for a remote endpoint. `expiration` without
/// a `session_token` is rejected at build time: only STS-style temporary
/// credentials expire, so that combination is a corrupted configuration
/// rather than a static key.
#[derive(Clone)]
pub struct RemoteCredentials {
pub access_key: String,
pub secret_key: String,
pub session_token: Option<String>,
pub expiration: Option<SystemTime>,
/// SDK credential `account_id`; replication targets pass their reset id.
pub account_id: String,
}
impl fmt::Debug for RemoteCredentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RemoteCredentials")
.field("access_key", &self.access_key)
.field("secret_key", &REDACTED_CREDENTIAL)
.field("session_token", &self.session_token.as_ref().map(|_| REDACTED_CREDENTIAL))
.field("expiration", &self.expiration)
.field("account_id", &self.account_id)
.finish()
}
}
/// Neutral description of a remote S3 endpoint from which an
/// `aws_sdk_s3::Client` is built.
#[derive(Clone, Debug)]
pub struct RemoteS3EndpointSpec {
/// `host[:port]` without a scheme; `secure` selects `https` or `http`.
pub endpoint: String,
pub secure: bool,
pub region: String,
pub path_style: PathStyle,
pub credentials: Option<RemoteCredentials>,
/// Accept any server certificate. Takes priority over `ca_cert_pem`.
pub skip_tls_verify: bool,
/// Extra PEM bundle trusted alongside the platform roots and the
/// `RUSTFS_TLS_PATH` bundle. `None` and whitespace-only mean "not set".
pub ca_cert_pem: Option<String>,
pub connect_timeout: Option<Duration>,
pub read_timeout: Option<Duration>,
/// How many wire requests one logical call may cost. Every consumer
/// declares it; see [`RemoteS3RetryPolicy`].
pub retry: RemoteS3RetryPolicy,
/// Appended to the SDK `User-Agent` (space separated) so the remote side
/// can identify the caller; empty means no suffix.
pub user_agent_suffix: &'static str,
}
impl RemoteS3EndpointSpec {
/// Full endpoint URL (`scheme://host[:port]`) as handed to the SDK.
pub fn endpoint_url(&self) -> String {
if self.secure {
format!("https://{}", self.endpoint)
} else {
format!("http://{}", self.endpoint)
}
}
fn custom_ca_pem(&self) -> Option<&str> {
self.ca_cert_pem.as_deref().filter(|pem| !pem.trim().is_empty())
}
}
#[derive(Debug, thiserror::Error)]
pub enum RemoteS3ClientError {
#[error("remote endpoint requires credentials")]
MissingCredentials,
#[error("{0}")]
Credentials(&'static str),
#[error("invalid target endpoint: {0}")]
InvalidEndpoint(String),
#[error("target endpoint is not allowed: {0}")]
EndpointNotAllowed(#[source] OutboundUrlError),
#[error("invalid target CA PEM: {0}")]
InvalidCaPem(String),
}
#[derive(Clone)]
pub(crate) struct RemoteTargetCredentialsProvider {
pub(crate) credentials: SdkCredentials,
}
impl RemoteTargetCredentialsProvider {
pub(crate) fn resolve_at(&self, now: SystemTime) -> aws_credential_types::provider::Result {
if self.credentials.expiry().is_some_and(|expiration| expiration <= now) {
return Err(CredentialsError::provider_error(std::io::Error::other(EXPIRED_REMOTE_TARGET_CREDENTIALS)));
}
Ok(self.credentials.clone())
}
}
impl fmt::Debug for RemoteTargetCredentialsProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RemoteTargetCredentialsProvider")
.field("temporary", &self.credentials.session_token().is_some())
.field("expiration", &self.credentials.expiry())
.finish()
}
}
impl ProvideCredentials for RemoteTargetCredentialsProvider {
fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a>
where
Self: 'a,
{
future::ProvideCredentials::ready(self.resolve_at(SystemTime::now()))
}
fn fallback_on_interrupt(&self) -> Option<SdkCredentials> {
self.resolve_at(SystemTime::now()).ok()
}
}
pub(crate) fn remote_sdk_credentials(credentials: &RemoteCredentials, now: SystemTime) -> Result<SdkCredentials, &'static str> {
if credentials.expiration.is_some() && credentials.session_token.is_none() {
return Err("remote target credential expiration requires a session token");
}
if credentials.expiration.is_some_and(|expiration| expiration <= now) {
return Err(EXPIRED_REMOTE_TARGET_CREDENTIALS);
}
let mut builder = SdkCredentials::builder()
.access_key_id(credentials.access_key.clone())
.secret_access_key(credentials.secret_key.clone())
.account_id(credentials.account_id.clone())
.provider_name("bucket_target_sys");
if let Some(session_token) = &credentials.session_token {
builder = builder.session_token(session_token.clone());
}
if let Some(expiration) = credentials.expiration {
builder = builder.expiry(expiration);
}
Ok(builder.build())
}
/// Appends a caller-identifying token to the SDK `User-Agent`. Runs after
/// signing: SigV4 excludes `user-agent` from the canonical request, so the
/// signature stays valid.
#[derive(Debug)]
struct UserAgentSuffixInterceptor {
suffix: &'static str,
}
impl Intercept for UserAgentSuffixInterceptor {
fn name(&self) -> &'static str {
"RustfsUserAgentSuffix"
}
fn modify_before_transmit(
&self,
context: &mut BeforeTransmitInterceptorContextMut<'_>,
_runtime_components: &RuntimeComponents,
_cfg: &mut ConfigBag,
) -> Result<(), BoxError> {
let headers = context.request_mut().headers_mut();
let user_agent = match headers.get(http::header::USER_AGENT.as_str()) {
Some(existing) => format!("{existing} {}", self.suffix),
None => self.suffix.to_string(),
};
headers.try_insert(http::header::USER_AGENT.as_str(), user_agent)?;
Ok(())
}
}
/// Builds the SDK config for `spec` without finalizing it, so callers can add
/// interceptors or (in tests) swap the HTTP client before `build()`.
pub(crate) async fn build_remote_s3_config(
spec: &RemoteS3EndpointSpec,
) -> Result<aws_sdk_s3::config::Builder, RemoteS3ClientError> {
let Some(credentials) = &spec.credentials else {
return Err(RemoteS3ClientError::MissingCredentials);
};
let creds = remote_sdk_credentials(credentials, SystemTime::now()).map_err(RemoteS3ClientError::Credentials)?;
let endpoint = spec.endpoint_url();
let parsed_endpoint = Url::parse(&endpoint).map_err(|err| RemoteS3ClientError::InvalidEndpoint(err.to_string()))?;
validate_remote_endpoint(&parsed_endpoint).map_err(RemoteS3ClientError::EndpointNotAllowed)?;
let mut config_builder = S3Config::builder()
.endpoint_url(endpoint)
.credentials_provider(SharedCredentialsProvider::new(RemoteTargetCredentialsProvider { credentials: creds }))
.region(SdkRegion::new(spec.region.clone()))
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
.request_checksum_calculation(replication_request_checksum_calculation())
.retry_config(spec.retry.retry_config());
if spec.path_style.force_path_style() {
config_builder = config_builder.force_path_style(true);
}
if let Some(http_client) = build_aws_s3_http_client_for_spec(spec).await? {
config_builder = config_builder.http_client(http_client);
}
if spec.connect_timeout.is_some() || spec.read_timeout.is_some() {
let mut timeouts = TimeoutConfig::builder();
if let Some(connect_timeout) = spec.connect_timeout {
timeouts = timeouts.connect_timeout(connect_timeout);
}
if let Some(read_timeout) = spec.read_timeout {
timeouts = timeouts.read_timeout(read_timeout);
}
config_builder = config_builder.timeout_config(timeouts.build());
}
if !spec.user_agent_suffix.is_empty() {
config_builder = config_builder.interceptor(UserAgentSuffixInterceptor {
suffix: spec.user_agent_suffix,
});
}
Ok(config_builder)
}
/// Builds an `aws_sdk_s3::Client` for `spec`, applying the outbound endpoint
/// gate, credential validation and the TLS transport selection.
pub async fn build_remote_s3_client(spec: &RemoteS3EndpointSpec) -> Result<S3Client, RemoteS3ClientError> {
Ok(S3Client::from_conf(build_remote_s3_config(spec).await?.build()))
}
#[derive(Debug)]
struct AcceptAnyServerCertVerifier;
impl rustls::client::danger::ServerCertVerifier for AcceptAnyServerCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &rustls_pki_types::CertificateDer<'_>,
_intermediates: &[rustls_pki_types::CertificateDer<'_>],
_server_name: &rustls_pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls_pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::aws_lc_rs::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[derive(Clone)]
struct TargetHyperHttpConnector<C> {
client: HyperClient<C, SdkBody>,
}
impl<C> fmt::Debug for TargetHyperHttpConnector<C> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TargetHyperHttpConnector")
.field("client", &"** hyper client **")
.finish()
}
}
impl<C> SmithyHttpConnector for TargetHyperHttpConnector<C>
where
C: Clone + Send + Sync + 'static,
C: Service<Uri>,
C::Response:
hyper::rt::Read + hyper::rt::Write + hyper_util::client::legacy::connect::Connection + Send + Sync + Unpin + 'static,
C::Future: Unpin + Send + 'static,
C::Error: Into<BoxError>,
{
fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
let request = match request.try_into_http1x() {
Ok(request) => request,
Err(err) => return HttpConnectorFuture::ready(Err(ConnectorError::user(err.into()))),
};
let mut client = self.client.clone();
let fut = client.call(request);
HttpConnectorFuture::new(async move {
let response = fut
.await
.map_err(|err| ConnectorError::io(err.into()))?
.map(SdkBody::from_body_1_x);
HttpResponse::try_from(response).map_err(|err| ConnectorError::other(err.into(), None))
})
}
}
pub(crate) fn ensure_rustls_crypto_provider() {
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
}
/// Env opt-in that re-enables loopback replication targets. Loopback (`127.0.0.1`,
/// `::1`, `localhost`) is a classic SSRF vector and stays rejected by default, but
/// single-host multi-instance dev setups and the e2e harness legitimately replicate
/// over loopback. Never set this in production.
const ALLOW_LOOPBACK_REPLICATION_TARGET_ENV: &str = "RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET";
fn loopback_replication_targets_allowed() -> bool {
std::env::var(ALLOW_LOOPBACK_REPLICATION_TARGET_ENV)
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false)
}
const REPLICATION_STREAMING_CHECKSUMS_ENV: &str = "RUSTFS_REPLICATION_STREAMING_CHECKSUMS";
/// Streaming trailer checksums make the SDK frame request bodies as
/// `aws-chunked`; a target that does not decode that framing stores the frames
/// verbatim, silently corrupting every replica while the transfer itself
/// succeeds (#6853). Plain signed payloads are the compatible default; the env
/// knob restores trailer checksums for fleets whose targets are all known to
/// decode them.
pub(crate) fn replication_request_checksum_calculation() -> RequestChecksumCalculation {
if std::env::var(REPLICATION_STREAMING_CHECKSUMS_ENV)
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false)
{
RequestChecksumCalculation::WhenSupported
} else {
RequestChecksumCalculation::WhenRequired
}
}
/// Outbound gate for operator-configured remote endpoints (replication
/// targets, on-demand migration sources). See
/// `docs/operations/outbound-connection-policy.md`.
pub fn validate_remote_endpoint(url: &Url) -> Result<(), OutboundUrlError> {
validate_remote_endpoint_inner(url, loopback_replication_targets_allowed())
}
pub(crate) fn validate_remote_endpoint_inner(url: &Url, allow_loopback: bool) -> Result<(), OutboundUrlError> {
match validate_outbound_url(url) {
Ok(()) => Ok(()),
// Replication targets are trusted infrastructure the operator configures, and
// legitimately live on private networks, so private addresses are always allowed.
Err(OutboundUrlError::ForbiddenHost {
reason: "private address",
..
}) => Ok(()),
// Loopback is far higher SSRF risk, so it is allowed only under the explicit,
// off-by-default opt-in above (single-host multi-instance / the e2e harness).
Err(OutboundUrlError::ForbiddenHost {
reason: "loopback address" | "loopback host",
..
}) if allow_loopback => Ok(()),
Err(err) => Err(err),
}
}
pub(crate) fn build_insecure_aws_s3_http_client() -> SharedHttpClient {
ensure_rustls_crypto_provider();
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier))
.with_no_client_auth();
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls_config)
.https_or_http()
.enable_http1()
.enable_http2()
.build();
let mut client_builder = HyperClient::builder(TokioExecutor::new());
client_builder.pool_timer(TokioTimer::new());
let client = client_builder.build(https);
let connector = SharedHttpConnector::new(TargetHyperHttpConnector { client });
http_client_fn(move |_settings, _components| connector.clone())
}
fn validate_ca_pem_bundle(ca_cert_pem: &[u8]) -> Result<(), String> {
let certs = rustls_pki_types::CertificateDer::pem_slice_iter(ca_cert_pem)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| format!("invalid PEM encoding: {err}"))?;
if certs.is_empty() {
return Err("no certificates found".to_string());
}
// Smithy's rustls adapter defers parsing custom certificates and assumes
// they are valid when the HTTPS connector is built. Validate every DER
// certificate first so malformed configuration is reported rather than
// reaching an `expect` in the dependency.
let mut validation_store = rustls::RootCertStore::empty();
for cert in certs {
validation_store
.add(cert)
.map_err(|err| format!("invalid X.509 certificate: {err}"))?;
}
Ok(())
}
pub(crate) fn validate_target_ca_pem(ca_cert_pem: &str) -> Result<(), RemoteS3ClientError> {
validate_ca_pem_bundle(ca_cert_pem.as_bytes()).map_err(RemoteS3ClientError::InvalidCaPem)
}
pub(crate) fn compose_replication_trust_store(
certificate_bundles: impl IntoIterator<Item = Vec<u8>>,
) -> (smithy_tls::TrustStore, usize) {
// `TrustStore::default()` keeps the platform-native roots enabled. Target
// and RUSTFS_TLS_PATH certificates extend that baseline instead of
// replacing it with a target-specific trust island.
let mut trust_store = smithy_tls::TrustStore::default();
let mut custom_bundle_count = 0;
for pem in certificate_bundles {
trust_store.add_pem_certificate(pem);
custom_bundle_count += 1;
}
(trust_store, custom_bundle_count)
}
pub(crate) fn build_aws_s3_http_client_with_trust_store(
trust_store: smithy_tls::TrustStore,
) -> Result<SharedHttpClient, RemoteS3ClientError> {
let tls_context = smithy_tls::TlsContext::builder()
.with_trust_store(trust_store)
.build()
.map_err(|err| RemoteS3ClientError::InvalidCaPem(err.to_string()))?;
Ok(SmithyHttpClientBuilder::new()
.tls_provider(smithy_tls::Provider::rustls(smithy_tls::rustls_provider::CryptoMode::AwsLc))
.tls_context(tls_context)
.build_https())
}
pub(crate) async fn load_tls_path_ca_bundles(tls_dir: &Path, trust_leaf_cert_as_ca: bool) -> Vec<Vec<u8>> {
let mut certificate_bundles = Vec::new();
let ca_path = tls_dir.join(RUSTFS_CA_CERT);
match tokio::fs::read(&ca_path).await {
Ok(pem) => match validate_ca_pem_bundle(&pem) {
Ok(()) => certificate_bundles.push(pem),
Err(err) => warn!("ignoring invalid custom CA bundle {:?} for replication client: {}", ca_path, err),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!("failed to read custom CA bundle {:?} for replication client: {}", ca_path, e),
}
if trust_leaf_cert_as_ca {
let leaf_cert_path = tls_dir.join(RUSTFS_TLS_CERT);
match tokio::fs::read(&leaf_cert_path).await {
Ok(pem) => match validate_ca_pem_bundle(&pem) {
Ok(()) => certificate_bundles.push(pem),
Err(err) => warn!(
"ignoring invalid leaf certificate {:?} for replication client trust store: {}",
leaf_cert_path, err
),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!("failed to read leaf cert {:?} for replication client trust store: {}", leaf_cert_path, e),
}
}
certificate_bundles
}
async fn load_configured_tls_ca_bundles() -> Vec<Vec<u8>> {
let tls_path = rustfs_utils::get_env_str(rustfs_config::ENV_RUSTFS_TLS_PATH, rustfs_config::DEFAULT_RUSTFS_TLS_PATH);
if tls_path.is_empty() {
return Vec::new();
}
load_tls_path_ca_bundles(
Path::new(&tls_path),
rustfs_utils::get_env_bool(ENV_TRUST_LEAF_CERT_AS_CA, DEFAULT_TRUST_LEAF_CERT_AS_CA),
)
.await
}
pub(crate) async fn build_aws_s3_http_client_from_target_ca_pem(
ca_cert_pem: &str,
) -> Result<SharedHttpClient, RemoteS3ClientError> {
validate_target_ca_pem(ca_cert_pem)?;
let mut certificate_bundles = load_configured_tls_ca_bundles().await;
certificate_bundles.push(ca_cert_pem.as_bytes().to_vec());
let (trust_store, _) = compose_replication_trust_store(certificate_bundles);
build_aws_s3_http_client_with_trust_store(trust_store)
}
/// Selects the HTTP client for `spec`: `None` keeps the SDK default (plain
/// HTTP, or HTTPS with platform roots when no custom trust is configured).
pub(crate) async fn build_aws_s3_http_client_for_spec(
spec: &RemoteS3EndpointSpec,
) -> Result<Option<SharedHttpClient>, RemoteS3ClientError> {
if !spec.secure {
return Ok(None);
}
if spec.skip_tls_verify {
return Ok(Some(build_insecure_aws_s3_http_client()));
}
if let Some(ca_cert_pem) = spec.custom_ca_pem() {
return build_aws_s3_http_client_from_target_ca_pem(ca_cert_pem).await.map(Some);
}
Ok(build_aws_s3_http_client_from_tls_path().await)
}
async fn build_aws_s3_http_client_from_tls_path() -> Option<SharedHttpClient> {
let certificate_bundles = load_configured_tls_ca_bundles().await;
if certificate_bundles.is_empty() {
return None;
}
let (trust_store, _) = compose_replication_trust_store(certificate_bundles);
match build_aws_s3_http_client_with_trust_store(trust_store) {
Ok(client) => Some(client),
Err(e) => {
warn!("failed to build AWS SDK TLS context for replication client: {}", e);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use aws_smithy_runtime_api::http::StatusCode as SmithyStatusCode;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
fn spec(endpoint: &str, secure: bool) -> RemoteS3EndpointSpec {
RemoteS3EndpointSpec {
endpoint: endpoint.to_string(),
secure,
region: "us-east-1".to_string(),
path_style: PathStyle::Auto,
credentials: Some(RemoteCredentials {
access_key: "access".to_string(),
secret_key: "secret".to_string(),
session_token: None,
expiration: None,
account_id: String::new(),
}),
skip_tls_verify: false,
ca_cert_pem: None,
connect_timeout: None,
read_timeout: None,
retry: RemoteS3RetryPolicy::Disabled,
user_agent_suffix: "",
}
}
type RecordedHeaders = Arc<Mutex<Vec<Vec<(String, String)>>>>;
#[derive(Clone, Debug)]
struct RecordingHeaderConnector {
request_headers: RecordedHeaders,
}
impl SmithyHttpConnector for RecordingHeaderConnector {
fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
self.request_headers
.lock()
.expect("recorded header lock should not be poisoned")
.push(
request
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
);
HttpConnectorFuture::ready(Ok(HttpResponse::new(
SmithyStatusCode::try_from(200_u16).expect("200 should be a valid response status"),
SdkBody::empty(),
)))
}
}
#[tokio::test]
async fn build_remote_s3_client_rejects_loopback_and_metadata_endpoints() {
// Default (no loopback opt-in): loopback in IPv4, IPv6 and hostname
// forms plus the metadata endpoint all return the typed gate error.
for endpoint in ["127.0.0.1:9000", "[::1]:9000", "localhost:9000", "169.254.169.254"] {
let err = build_remote_s3_client(&spec(endpoint, false))
.await
.err()
.unwrap_or_else(|| panic!("{endpoint} must be rejected by the outbound gate"));
assert!(
matches!(err, RemoteS3ClientError::EndpointNotAllowed(OutboundUrlError::ForbiddenHost { .. })),
"{endpoint}: unexpected error {err:?}"
);
assert!(err.to_string().contains("not allowed"), "{endpoint}: {err}");
}
}
#[tokio::test]
async fn build_remote_s3_client_allows_private_and_public_endpoints() {
for endpoint in ["10.0.0.1:9000", "192.168.1.20", "s3.example.com"] {
build_remote_s3_client(&spec(endpoint, false))
.await
.unwrap_or_else(|err| panic!("{endpoint} should be allowed: {err}"));
}
}
#[tokio::test]
async fn build_remote_s3_client_requires_credentials() {
let mut spec = spec("s3.example.com", true);
spec.credentials = None;
let err = build_remote_s3_client(&spec)
.await
.expect_err("missing credentials must be a typed error");
assert!(matches!(err, RemoteS3ClientError::MissingCredentials));
}
#[tokio::test]
async fn build_remote_s3_client_rejects_expiration_without_session_token() {
let mut spec = spec("s3.example.com", true);
spec.credentials
.as_mut()
.expect("spec fixture carries credentials")
.expiration = Some(SystemTime::now() + Duration::from_secs(3_600));
let err = build_remote_s3_client(&spec)
.await
.expect_err("expiration without session token must be rejected");
assert_eq!(err.to_string(), "remote target credential expiration requires a session token");
}
#[tokio::test]
async fn build_remote_s3_client_rejects_invalid_custom_ca_pem() {
let mut spec = spec("192.168.1.10:9000", true);
spec.ca_cert_pem = Some("not a pem".to_string());
let err = build_remote_s3_client(&spec)
.await
.expect_err("invalid custom CA PEM must be rejected");
assert!(matches!(err, RemoteS3ClientError::InvalidCaPem(_)));
assert!(err.to_string().contains("invalid target CA PEM"));
}
/// Answers every request with a retryable 503 and counts the wire
/// requests one logical call produced.
#[derive(Clone, Debug)]
struct CountingUnavailableConnector {
wire_requests: Arc<AtomicUsize>,
}
impl SmithyHttpConnector for CountingUnavailableConnector {
fn call(&self, _request: HttpRequest) -> HttpConnectorFuture {
self.wire_requests.fetch_add(1, Ordering::SeqCst);
HttpConnectorFuture::ready(Ok(HttpResponse::new(
SmithyStatusCode::try_from(503_u16).expect("503 should be a valid response status"),
SdkBody::empty(),
)))
}
}
async fn wire_requests_for_one_failed_call(retry: RemoteS3RetryPolicy) -> usize {
let wire_requests = Arc::new(AtomicUsize::new(0));
let connector = SharedHttpConnector::new(CountingUnavailableConnector {
wire_requests: Arc::clone(&wire_requests),
});
let http_client = http_client_fn(move |_settings, _components| connector.clone());
let mut spec = spec("s3.example.com", true);
spec.retry = retry;
let config = build_remote_s3_config(&spec)
.await
.expect("spec should build")
.http_client(http_client)
.build();
S3Client::from_conf(config)
.head_bucket()
.bucket("bucket")
.send()
.await
.expect_err("a 503 must fail the call");
wire_requests.load(Ordering::SeqCst)
}
#[tokio::test(start_paused = true)]
async fn retry_policy_decides_how_many_wire_requests_one_call_costs() {
assert_eq!(
wire_requests_for_one_failed_call(RemoteS3RetryPolicy::Disabled).await,
1,
"a disabled policy must not amplify one logical call"
);
assert_eq!(
wire_requests_for_one_failed_call(REPLICATION_TARGET_RETRY_POLICY).await,
3,
"replication targets keep the three-attempt SDK default"
);
assert_eq!(
wire_requests_for_one_failed_call(RemoteS3RetryPolicy::Standard { max_attempts: 0 }).await,
1,
"a zero attempt budget is clamped to the initial request"
);
}
#[test]
fn path_style_auto_and_path_force_path_style() {
assert!(PathStyle::Auto.force_path_style());
assert!(PathStyle::Path.force_path_style());
assert!(!PathStyle::VirtualHost.force_path_style());
}
#[test]
fn remote_credentials_debug_redacts_secrets() {
let credentials = RemoteCredentials {
access_key: "access".to_string(),
secret_key: "very-secret".to_string(),
session_token: Some("session-token".to_string()),
expiration: None,
account_id: String::new(),
};
let rendered = format!("{credentials:?}");
assert!(rendered.contains("access"));
assert!(!rendered.contains("very-secret"));
assert!(!rendered.contains("session-token"));
}
#[tokio::test]
async fn user_agent_suffix_is_appended_after_signing() {
let request_headers: RecordedHeaders = Arc::new(Mutex::new(Vec::new()));
let connector = SharedHttpConnector::new(RecordingHeaderConnector {
request_headers: Arc::clone(&request_headers),
});
let http_client = http_client_fn(move |_settings, _components| connector.clone());
let mut spec = spec("s3.example.com", true);
spec.user_agent_suffix = "RustFS-Test/0.0";
spec.connect_timeout = Some(Duration::from_secs(5));
spec.read_timeout = Some(Duration::from_secs(5));
let config = build_remote_s3_config(&spec)
.await
.expect("spec should build")
.http_client(http_client)
.build();
S3Client::from_conf(config)
.head_bucket()
.bucket("bucket")
.send()
.await
.expect("recording connector should accept the request");
let recorded = request_headers.lock().expect("recorded header lock should not be poisoned");
let headers = &recorded[0];
let user_agent = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("user-agent"))
.map(|(_, v)| v.as_str())
.expect("SDK request must carry a user-agent");
assert!(user_agent.ends_with(" RustFS-Test/0.0"), "user-agent was {user_agent}");
assert!(user_agent.starts_with("aws-sdk-rust/"), "SDK identity must be preserved: {user_agent}");
assert!(
headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"request must still be signed"
);
}
}
+1 -2
View File
@@ -53,12 +53,12 @@ pub use replication_config_boundary::{
replication_target_arns, should_remove_replication_target, site_replication_rule_deployment_id,
unsupported_replication_config_field, validate_replication_config_structure, validate_replication_config_target_arns,
};
pub(crate) use replication_filemeta_boundary::version_purge_statuses_map;
pub use replication_filemeta_boundary::{
MrfOpKind, MrfReplicateEntry, REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationState,
ReplicationStatusType, ReplicationType, VersionPurgeStatusType, replication_state_to_filemeta,
replication_status_to_filemeta, replication_statuses_map, version_purge_status_to_filemeta,
};
pub(crate) use replication_filemeta_boundary::{ReplicationGenerationSnapshot, version_purge_statuses_map};
pub(crate) use replication_filemeta_boundary::{
replication_state_from_filemeta, replication_status_from_filemeta, version_purge_status_from_filemeta,
};
@@ -88,5 +88,4 @@ pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog};
pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
pub use replication_storage_boundary::{ReplicationObjectIO, ReplicationStorage};
pub use replication_target_boundary::SsecPassthroughCapability;
pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity};
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
@@ -15,7 +15,7 @@
pub use rustfs_replication::{MrfOpKind, MrfReplicateEntry};
pub(crate) use rustfs_replication::{
REPLICATE_EXISTING, REPLICATE_HEAL_DELETE, ReplicateTargetDecision, ReplicatedInfos, ReplicatedTargetInfo, ReplicationAction,
ReplicationGenerationSnapshot, ReplicationWorkerOperation, ResyncDecision, get_replication_state, parse_replicate_decision,
ReplicationWorkerOperation, ResyncDecision, get_replication_state, parse_replicate_decision,
replicate_decision_for_admitted_targets, target_reset_header, version_purge_statuses_map,
};
pub use rustfs_replication::{
@@ -575,7 +575,7 @@ pub(crate) async fn must_replicate(bucket: &str, object: &str, mopts: MustReplic
let mut sopts = opts.clone();
sopts.target_arn = arn.clone();
let replicate = metadata_target_should_replicate(&cfg, &sopts, &mopts, &arn);
let replicate = cfg.replicate(&sopts) && mopts.metadata_target_is_eligible(&arn);
let synchronous = if let Some(cli) = cli { cli.replicate_sync } else { false };
dsc.set(ReplicateTargetDecision::new(arn, replicate, synchronous));
@@ -584,15 +584,6 @@ pub(crate) async fn must_replicate(bucket: &str, object: &str, mopts: MustReplic
dsc
}
fn metadata_target_should_replicate(
cfg: &ReplicationConfiguration,
opts: &ObjectOpts,
mopts: &MustReplicateOptions,
arn: &str,
) -> bool {
cfg.replicate(opts) && mopts.metadata_target_is_eligible(arn)
}
#[cfg(test)]
mod tests {
use s3s::dto::{
@@ -622,46 +613,6 @@ mod tests {
}
}
#[test]
fn metadata_replication_requires_both_current_rule_match_and_historical_admission() {
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
let mut rule = replication_rule();
rule.destination.bucket = arn.to_string();
rule.filter = Some(ReplicationRuleFilter {
prefix: Some("admitted/".to_string()),
..Default::default()
});
let cfg = ReplicationConfiguration {
role: String::new(),
rules: vec![rule],
};
let mut metadata = HashMap::new();
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_REPLICATION_STATUS, format!("{arn}=PENDING;"));
let admitted = MustReplicateOptions::new(&metadata, String::new(), ReplicationType::Metadata, false);
let matching = ObjectOpts {
name: "admitted/object".to_string(),
target_arn: arn.to_string(),
..Default::default()
};
assert!(metadata_target_should_replicate(&cfg, &matching, &admitted, arn));
let rule_mismatch = ObjectOpts {
name: "outside/object".to_string(),
target_arn: arn.to_string(),
..Default::default()
};
assert!(
!metadata_target_should_replicate(&cfg, &rule_mismatch, &admitted, arn),
"historical admission must not bypass the current replication rule"
);
let never_admitted = MustReplicateOptions::new(&HashMap::new(), String::new(), ReplicationType::Metadata, false);
assert!(
!metadata_target_should_replicate(&cfg, &matching, &never_admitted, arn),
"a current rule match must not create historical admission"
);
}
#[test]
fn replication_config_empty_and_replicate_follow_config() {
let empty = ReplicationConfig::default();
@@ -52,6 +52,7 @@ use super::runtime_boundary as runtime_sources;
use futures_util::stream::{self, StreamExt};
use metrics::{counter, histogram};
use rustfs_utils::hash::HashAlgorithm;
use rustfs_utils::http::{SUFFIX_REPLICATION_TIMESTAMP, get_str};
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::sync::Arc;
@@ -938,11 +939,7 @@ async fn replay_mrf_object_entry<S: ReplicationStorage>(
Some(queue_replication_heal(&entry.bucket, oi, entry.retry_count.max(0) as u32).await)
} else {
let roi = admitted_mrf_replicate_object(oi, entry, entry.op.replication_type());
if replicate_object_with_outcome(roi, storage.clone())
.await
.1
.consumes_mrf_entry()
{
if replicate_object_with_outcome(roi, storage.clone()).await.1 {
Some(ReplicationQueueAdmission::Queued)
} else {
Some(ReplicationQueueAdmission::Missed)
@@ -981,11 +978,7 @@ async fn replay_mrf_metadata_entry<S: ReplicationStorage>(
Some(queue_replication_metadata(&entry.bucket, oi, entry.retry_count.max(0) as u32).await)
} else {
let roi = admitted_mrf_replicate_object(oi, entry, ReplicationType::Metadata);
if replicate_object_with_outcome(roi, storage.clone())
.await
.1
.consumes_mrf_entry()
{
if replicate_object_with_outcome(roi, storage.clone()).await.1 {
Some(ReplicationQueueAdmission::Queued)
} else {
Some(ReplicationQueueAdmission::Missed)
@@ -2985,11 +2978,8 @@ fn replicate_object_info_from_object_info(
) -> ReplicateObjectInfo {
let tgt_statuses = replication_statuses_map(&oi.replication_status_internal.clone().unwrap_or_default());
let purge_statuses = version_purge_statuses_map(&oi.version_purge_status_internal.clone().unwrap_or_default());
let replication_generation = oi.replication_generation_snapshot();
let tm = replication_generation
.timestamp
.as_deref()
.map(|value| OffsetDateTime::parse(value, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH));
let tm = get_str(&oi.user_defined, SUFFIX_REPLICATION_TIMESTAMP)
.map(|v| OffsetDateTime::parse(&v, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH));
let mut rstate = oi.replication_state();
rstate.replicate_decision_str = dsc.to_string();
let asz = oi.get_actual_size_or_physical();
@@ -3016,7 +3006,6 @@ fn replicate_object_info_from_object_info(
target_statuses: tgt_statuses,
target_purge_statuses: purge_statuses,
replication_timestamp: tm,
replication_generation,
user_tags: (*oi.user_tags).clone(),
checksum,
retry_count: 0,
@@ -17,8 +17,6 @@ use super::replication_config_boundary::{ObjectOpts, ReplicationConfigurationExt
use super::replication_config_store::ReplicationConfigStore;
use super::replication_error_boundary::{Error, Result, is_err_object_not_found, is_err_version_not_found};
use super::replication_event_sink::{EventArgs, send_event, send_local_event};
#[cfg(test)]
use super::replication_filemeta_boundary::ReplicationGenerationSnapshot;
use super::replication_filemeta_boundary::{
REPLICATE_EXISTING, ReplicateDecision, ReplicateObjectInfo, ReplicatedInfos, ReplicatedTargetInfo, ReplicationAction,
ReplicationState, ReplicationStatusType, ReplicationType, VersionPurgeStatusType, get_replication_state,
@@ -49,13 +47,12 @@ use super::replication_resync_boundary::{
};
#[cfg(test)]
use super::replication_resync_boundary::{RESYNC_META_FORMAT, RESYNC_META_VERSION, WIRE_ZERO_TIME_UNIX};
#[cfg(test)]
use super::replication_storage_boundary::ReplicationDeletedObject;
use super::replication_storage_boundary::{
AdvancedGetOptions, EcstoreObjectOperations, GetObjectReader, HTTPPreconditions, HTTPRangeSpec, ObjectInfo, ObjectOptions,
ObjectToDelete, ReplicationObjectIO, ReplicationStatusWritebackCondition, ReplicationStatusWritebackMode, ReplicationStorage,
StatObjectOptions, StorageObjectInfoOrErr, WalkOptions,
ObjectToDelete, ReplicationObjectIO, ReplicationStorage, StatObjectOptions, StorageObjectInfoOrErr, WalkOptions,
};
#[cfg(test)]
use super::replication_storage_boundary::{NamespaceLockFence, NamespaceLockSignalTestFence, ReplicationDeletedObject};
use super::replication_target_boundary::{
ERR_REPLICATION_SSEC_PASSTHROUGH_UNSUPPORTED, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions,
RemotePutObjectResponse, ReplicationTargetStore, S3ClientError, SsecPassthroughCapability, SsecPassthroughGate, TargetClient,
@@ -1628,7 +1625,6 @@ pub async fn get_heal_replicate_object_info(oi: &ObjectInfo, rcfg: &ReplicationC
let mut replication_state = oi.replication_state();
replication_state.replicate_decision_str = dsc.to_string();
let actual_size = oi.get_actual_size_or_physical();
let replication_generation = oi.replication_generation_snapshot();
Ok(ReplicateObjectInfo {
name: oi.name.clone(),
@@ -1651,7 +1647,6 @@ pub async fn get_heal_replicate_object_info(oi: &ObjectInfo, rcfg: &ReplicationC
target_statuses,
target_purge_statuses,
replication_timestamp: None,
replication_generation,
ssec: replication_object_is_ssec_encrypted(&user_defined),
user_tags: (*oi.user_tags).clone(),
checksum: oi.checksum.clone(),
@@ -2936,125 +2931,10 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
replicate_object_with_outcome(roi, storage).await.0
}
enum ReplicationStatePersistOutcome {
Updated,
Superseded,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ReplicationAttemptDisposition {
Persisted,
Superseded,
Retry,
}
impl ReplicationAttemptDisposition {
pub(crate) fn consumes_mrf_entry(self) -> bool {
matches!(self, Self::Persisted | Self::Superseded)
}
}
fn replication_attempt_preflight(roi: &ReplicateObjectInfo) -> Option<(ReplicationState, ReplicationAttemptDisposition)> {
roi.replication_generation
.invalid
.then(|| (roi.replication_state.clone().unwrap_or_default(), ReplicationAttemptDisposition::Retry))
}
#[derive(Debug, Default, PartialEq, Eq)]
struct ReplicationTerminalPublication {
emit_terminal_failure: bool,
emit_event: bool,
update_transition_stats: bool,
update_same_state_failure_stats: bool,
}
fn replication_terminal_publication(
suppress_terminal_publication: bool,
state_update_needed: bool,
attempt_status: ReplicationStatusType,
previous_internal_matches_attempt: bool,
) -> ReplicationTerminalPublication {
if suppress_terminal_publication {
return ReplicationTerminalPublication::default();
}
ReplicationTerminalPublication {
emit_terminal_failure: true,
emit_event: true,
update_transition_stats: state_update_needed,
update_same_state_failure_stats: attempt_status != ReplicationStatusType::Completed && previous_internal_matches_attempt,
}
}
fn replication_status_writeback_mode(state_update_needed: bool) -> ReplicationStatusWritebackMode {
if state_update_needed {
ReplicationStatusWritebackMode::Update
} else {
ReplicationStatusWritebackMode::ValidateOnly
}
}
/// Publish a worker's status with a storage-enforced compare-and-set token.
/// `put_object_metadata` checks the token while it owns the object write lock,
/// avoiding both a read/write race and any need to hold a hot object lock
/// across network I/O.
fn replication_status_writeback_options(
roi: &ReplicateObjectInfo,
replication_lock_guard: &rustfs_lock::NamespaceLockGuard,
new_replication_internal: Option<&String>,
mode: ReplicationStatusWritebackMode,
) -> ObjectOptions {
let mut eval_metadata = HashMap::new();
if let Some(status) = new_replication_internal {
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_STATUS, status.clone());
}
let mut write_opts = ObjectOptions {
version_id: roi.version_id.map(|version_id| version_id.to_string()),
eval_metadata: Some(eval_metadata),
replication_status_writeback: Some(Box::new(ReplicationStatusWritebackCondition {
expected_generation: roi.replication_generation.clone(),
mode,
})),
..Default::default()
};
// The remote transfer runs under a renewable replication namespace lock.
// Carry that guard's loss signal into the storage commit so a worker whose
// lease expired while it was doing remote I/O cannot publish over a newer
// worker for the same generation.
write_opts.add_namespace_lock_guard(replication_lock_guard);
write_opts
}
async fn persist_replication_state_if_current<S: ReplicationStorage>(
roi: &ReplicateObjectInfo,
storage: &Arc<S>,
replication_lock_guard: &rustfs_lock::NamespaceLockGuard,
new_replication_internal: Option<&String>,
mode: ReplicationStatusWritebackMode,
object_info: &mut ObjectInfo,
) -> Result<ReplicationStatePersistOutcome> {
let write_opts = replication_status_writeback_options(roi, replication_lock_guard, new_replication_internal, mode);
match storage.put_object_metadata(&roi.bucket, &roi.name, &write_opts).await {
Ok(updated) => {
*object_info = updated;
Ok(ReplicationStatePersistOutcome::Updated)
}
Err(Error::PreconditionFailed) => Ok(ReplicationStatePersistOutcome::Superseded),
Err(error) => Err(error),
}
}
pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
roi: ReplicateObjectInfo,
storage: Arc<S>,
) -> (ReplicationState, ReplicationAttemptDisposition) {
// Conflicting compatibility aliases, empty opaque timestamps, and invalid
// mutation UUIDs are corruption, not evidence that another generation
// superseded this task. Fail before any target I/O and keep the MRF entry
// retryable instead of repeatedly transmitting and then acknowledging it.
if let Some(outcome) = replication_attempt_preflight(&roi) {
return outcome;
}
) -> (ReplicationState, bool) {
let bucket = roi.bucket.clone();
let object = roi.name.clone();
@@ -3083,10 +2963,10 @@ pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return (roi.replication_state.unwrap_or_default(), ReplicationAttemptDisposition::Retry);
return (roi.replication_state.unwrap_or_default(), false);
}
};
let obj_lock_guard = match obj_ns_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
let _obj_lock_guard = match obj_ns_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
Ok(g) => g,
Err(e) => {
debug!(
@@ -3106,7 +2986,7 @@ pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return (roi.replication_state.unwrap_or_default(), ReplicationAttemptDisposition::Retry);
return (roi.replication_state.unwrap_or_default(), false);
}
};
@@ -3177,111 +3057,54 @@ pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
}
let version_id = roi.version_id.map(|v| v.to_string());
note_replication_terminal_failure(&bucket, &object, version_id.as_deref(), &rinfos);
let previous_state = roi.replication_state.clone().unwrap_or_default();
let mut merged_state = get_replication_state(&rinfos, &previous_state, version_id.clone());
let mut replication_status = merged_state.composite_replication_status();
let merged_state = get_replication_state(&rinfos, &previous_state, version_id);
let replication_status = merged_state.composite_replication_status();
let new_replication_internal = merged_state.replication_status_internal.clone();
let mut object_info = roi.to_object_info();
let mut disposition = ReplicationAttemptDisposition::Persisted;
let mut suppress_terminal_publication = false;
let state_update_needed = roi.replication_status_internal != new_replication_internal || rinfos.replication_resynced();
let writeback_mode = replication_status_writeback_mode(state_update_needed);
let mut state_persisted = true;
match persist_replication_state_if_current(
&roi,
&storage,
&obj_lock_guard,
new_replication_internal.as_ref(),
writeback_mode,
&mut object_info,
)
.await
{
Ok(ReplicationStatePersistOutcome::Updated) => {}
Ok(ReplicationStatePersistOutcome::Superseded) => {
// A tag/retention/legal-hold mutation committed while this
// worker was in flight. Its PENDING state is authoritative and
// must remain discoverable by the queue/MRF/scanner after a
// crash or missed admission. Return that newer state to sync
// callers and leave its worker to publish the terminal status.
suppress_terminal_publication = true;
disposition = ReplicationAttemptDisposition::Superseded;
let read_opts = ObjectOptions {
version_id: roi.version_id.map(|version_id| version_id.to_string()),
..Default::default()
};
match storage.get_object_info(&bucket, &object, &read_opts).await {
Ok(current) => {
object_info = current;
merged_state = object_info.replication_state();
replication_status = merged_state.composite_replication_status();
}
Err(error) => {
// The CAS result is authoritative: a best-effort refetch
// failure must not turn a superseded task back into a
// retry that can publish the stale generation later.
debug!(
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
object = %object,
error = %error,
reason = "source_snapshot_refetch_failed_after_superseded",
"Could not refresh source state after skipping stale replication status update"
);
}
}
debug!(
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
object = %object,
reason = "source_replication_snapshot_superseded",
"Skipped stale replication status update"
);
if roi.replication_status_internal != new_replication_internal || rinfos.replication_resynced() {
let mut eval_metadata = HashMap::new();
if let Some(ref s) = new_replication_internal {
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_STATUS, s.clone());
}
Err(e) => {
disposition = ReplicationAttemptDisposition::Retry;
suppress_terminal_publication = true;
// Persisting the resynced replication status failed. Don't swallow
// it silently — the object's on-disk status now disagrees with the
// resync result and needs operator visibility (backlog#799 B23).
warn!(
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
object = %object,
error = %e,
"Failed to persist resynced replication status metadata"
);
}
}
let popts = ObjectOptions {
version_id: roi.version_id.map(|v| v.to_string()),
eval_metadata: Some(eval_metadata),
..Default::default()
};
let publication = replication_terminal_publication(
suppress_terminal_publication,
state_update_needed,
rinfos.replication_status(),
roi.replication_status_internal == rinfos.replication_status_internal(),
);
if publication.update_transition_stats
&& let Some(stats) = runtime_sources::replication_stats()
{
for tgt in &rinfos.targets {
if tgt.replication_status != tgt.prev_replication_status {
stats
.update(&bucket, tgt, tgt.replication_status.clone(), tgt.prev_replication_status.clone())
.await;
match storage.put_object_metadata(&bucket, &object, &popts).await {
Ok(u) => object_info = u,
Err(e) => {
state_persisted = false;
// Persisting the resynced replication status failed. Don't swallow
// it silently — the object's on-disk status now disagrees with the
// resync result and needs operator visibility (backlog#799 B23).
warn!(
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
object = %object,
error = %e,
"Failed to persist resynced replication status metadata"
);
}
}
}
if publication.emit_terminal_failure {
note_replication_terminal_failure(&bucket, &object, version_id.as_deref(), &rinfos);
if let Some(stats) = runtime_sources::replication_stats() {
for tgt in &rinfos.targets {
if tgt.replication_status != tgt.prev_replication_status {
stats
.update(&bucket, tgt, tgt.replication_status.clone(), tgt.prev_replication_status.clone())
.await;
}
}
}
}
let event_name = if replication_status == ReplicationStatusType::Completed {
@@ -3290,17 +3113,16 @@ pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
EventName::ObjectReplicationFailed.to_string()
};
if publication.emit_event {
send_local_event(EventArgs {
event_name,
bucket_name: bucket.clone(),
object: object_info,
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
}
send_local_event(EventArgs {
event_name,
bucket_name: bucket.clone(),
object: object_info,
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
if publication.update_same_state_failure_stats
if rinfos.replication_status() != ReplicationStatusType::Completed
&& roi.replication_status_internal == rinfos.replication_status_internal()
&& let Some(stats) = runtime_sources::replication_stats()
{
for tgt in &rinfos.targets {
@@ -3312,7 +3134,7 @@ pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
}
}
(merged_state, disposition)
(merged_state, state_persisted)
}
/// Emit the operator-visible record of a replication attempt that ended FAILED.
@@ -3669,7 +3491,7 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
}
};
if let Some(reason) = replication_single_put_size_error(is_multipart, transfer_size, object_info.etag.as_deref()) {
if let Some(reason) = replication_single_put_size_error(is_multipart, transfer_size) {
drop(gr);
rinfo.replication_status = ReplicationStatusType::Failed;
rinfo.error = Some(reason.clone());
@@ -4367,8 +4189,7 @@ async fn replicate_all_payload_to_target<S: ReplicationObjectIO>(
// Fail before streaming a body the target is required to reject: an S3
// PutObject caps at 5 GiB, and this route is chosen by the source object's
// storage shape rather than its size (rustfs#6825).
if let Some(reason) = replication_single_put_size_error(ctx.is_multipart, ctx.transfer_size, ctx.object_info.etag.as_deref())
{
if let Some(reason) = replication_single_put_size_error(ctx.is_multipart, ctx.transfer_size) {
drop(gr);
return Some(std::io::Error::other(reason));
}
@@ -4745,105 +4566,6 @@ async fn replicate_multipart_parts_and_complete<S: ReplicationObjectIO>(
mod tests {
use super::super::replication_filemeta_boundary::ReplicateTargetDecision;
#[test]
fn same_state_terminal_retry_uses_validate_only() {
assert_eq!(replication_status_writeback_mode(false), ReplicationStatusWritebackMode::ValidateOnly);
assert_eq!(replication_status_writeback_mode(true), ReplicationStatusWritebackMode::Update);
}
#[test]
fn superseded_attempt_has_no_terminal_publication_side_effects() {
assert!(ReplicationAttemptDisposition::Persisted.consumes_mrf_entry());
assert!(ReplicationAttemptDisposition::Superseded.consumes_mrf_entry());
assert!(!ReplicationAttemptDisposition::Retry.consumes_mrf_entry());
assert_eq!(
replication_terminal_publication(true, false, ReplicationStatusType::Failed, true),
ReplicationTerminalPublication::default()
);
assert_eq!(
replication_terminal_publication(true, true, ReplicationStatusType::Completed, false),
ReplicationTerminalPublication::default()
);
let current = replication_terminal_publication(false, false, ReplicationStatusType::Failed, true);
assert!(current.emit_terminal_failure);
assert!(current.emit_event);
assert!(!current.update_transition_stats);
assert!(current.update_same_state_failure_stats);
assert_eq!(
replication_terminal_publication(true, true, ReplicationStatusType::Failed, false),
ReplicationTerminalPublication::default(),
"a retryable status-persistence failure must not publish a terminal result"
);
}
#[test]
fn invalid_generation_retries_before_remote_replication() {
let mut preserved_state = ReplicationState::default();
preserved_state
.targets
.insert("arn:target".to_string(), ReplicationStatusType::Pending);
let invalid = ReplicateObjectInfo {
replication_generation: ReplicationGenerationSnapshot {
invalid: true,
..Default::default()
},
replication_state: Some(preserved_state.clone()),
..Default::default()
};
assert_eq!(
replication_attempt_preflight(&invalid),
Some((preserved_state, ReplicationAttemptDisposition::Retry))
);
assert!(replication_attempt_preflight(&ReplicateObjectInfo::default()).is_none());
}
#[tokio::test]
async fn terminal_writeback_carries_replication_lock_loss_fence() {
let lock = rustfs_lock::NamespaceLock::new(
"replication-status-writeback-fence".to_string(),
Arc::new(rustfs_lock::LocalClient::new()),
);
let guard = lock
.get_write_lock(
rustfs_lock::ObjectKey::new("bucket", "/[replicate]/object"),
"worker-a",
std::time::Duration::from_secs(2),
)
.await
.expect("replication lock should be acquired");
let signal = guard
.lock_lost_signal()
.expect("distributed guard must expose its loss signal");
let forced_lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
let _test_fence = NamespaceLockSignalTestFence::install_with_loss_handle(&signal, Arc::clone(&forced_lost));
// Build the writeback after remote work has acquired the guard, then
// lose the lease before storage reaches its commit fence.
let opts = replication_status_writeback_options(
&ReplicateObjectInfo::default(),
&guard,
None,
ReplicationStatusWritebackMode::ValidateOnly,
);
forced_lost.store(true, std::sync::atomic::Ordering::Release);
assert!(
opts.namespace_lock_fence
.as_ref()
.is_some_and(NamespaceLockFence::is_lock_lost),
"terminal CAS must observe a replication lease lost after remote I/O"
);
assert!(!ReplicationAttemptDisposition::Retry.consumes_mrf_entry());
assert_eq!(
replication_terminal_publication(true, true, ReplicationStatusType::Failed, false),
ReplicationTerminalPublication::default(),
"a fenced writeback retry must not publish terminal events or statistics"
);
}
#[test]
fn unavailable_object_target_is_persisted_as_failed() {
let arn = "arn:object-target";
@@ -18,11 +18,7 @@ use tokio_util::sync::CancellationToken;
use super::replication_error_boundary::Error;
use super::replication_filemeta_boundary::{replication_state_from_filemeta, version_purge_status_from_filemeta};
pub(crate) type ReplicationObjectStore = crate::store::ECStore;
pub(crate) use crate::object_api::{
GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader, ReplicationStatusWritebackCondition, ReplicationStatusWritebackMode,
};
#[cfg(test)]
pub(crate) use crate::object_api::{NamespaceLockFence, NamespaceLockSignalTestFence};
pub(crate) use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
pub(crate) use crate::storage_api_contracts::list::{
ListOperations, StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions,
};
@@ -43,7 +43,6 @@ pub(crate) use crate::bucket::bucket_target_sys::{
pub(crate) use crate::bucket::target::BucketTarget;
pub(crate) use crate::bucket::target::BucketTargets;
pub use rustfs_replication::SsecPassthroughCapability;
pub use rustfs_replication::{ObjectLockIntegrity, object_lock_put_integrity};
pub(crate) use rustfs_replication::{
SsecPassthroughGate, is_replication_target_offline_error, ssec_passthrough_gate, version_identity_drifted,
};
@@ -850,11 +849,6 @@ mod tests {
metadata.insert(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), "DAREv2-HMAC-SHA256".to_string());
metadata.insert(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER.to_string(), "sealed".to_string());
metadata.insert(MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER.to_string(), "true".to_string());
rustfs_utils::http::insert_str(
&mut metadata,
rustfs_utils::http::SUFFIX_REPLICATION_GENERATION,
Uuid::from_u128(1).to_string(),
);
let object_info = ObjectInfo {
user_defined: Arc::new(metadata),
@@ -900,13 +894,6 @@ mod tests {
assert!(!options.user_metadata.contains_key(AMZ_SERVER_SIDE_ENCRYPTION));
assert!(!options.user_metadata.contains_key(SSEC_ALGORITHM_HEADER));
assert!(!options.user_metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER));
assert!(
options
.user_metadata
.keys()
.all(|key| !key.contains(rustfs_utils::http::SUFFIX_REPLICATION_GENERATION)),
"source-local mutation generation must never cross the replication wire"
);
assert!(
!options
.user_metadata
@@ -1,351 +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.
//! Sealed remote credentials shared by the three stores that persist one
//! (rustfs/backlog#2168): replication targets (`bucket-targets.json`), remote
//! tiers (`tier-config.bin`) and on-demand migration sources
//! (`on-demand-migration.json`).
//!
//! The design record is `docs/architecture/remote-credential-sealing-adr.md`.
//! What this module owns: the versioned envelope, the encryption context that
//! binds a ciphertext to the record owning it, the sealer registration point,
//! and the fail-closed error type. What it deliberately does not own: any KMS
//! call (ECStore does not depend on `rustfs-kms`; the binary installs a
//! sealer, exactly like `ON_DEMAND_MIGRATION_CONFIG_HOOK` and the event
//! dispatch hook in `crates/ecstore/src/services/event_notification.rs`), and
//! any decision about which stored field a consumer writes.
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, OnceLock};
/// Envelope format this build writes. A reader accepts only versions it
/// knows; an unknown version is a typed error, never a fallback.
pub const SEALED_CREDENTIAL_VERSION: u8 = 1;
/// Which store a sealed value belongs to. Part of the encryption context, so
/// a ciphertext cannot be replayed into a different store.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SealedCredentialStore {
/// `bucket-targets.json` (replication and other bucket targets).
BucketTargets,
/// `tier-config.bin` (remote tiers).
TierConfig,
/// `on-demand-migration.json` (migration sources).
OnDemandMigration,
}
impl SealedCredentialStore {
pub fn as_str(self) -> &'static str {
match self {
SealedCredentialStore::BucketTargets => "bucket-targets",
SealedCredentialStore::TierConfig => "tier-config",
SealedCredentialStore::OnDemandMigration => "on-demand-migration",
}
}
}
/// Identity of the record a secret belongs to: the store, its owner (bucket
/// name, tier name, or target ARN) and the field name. Rendered into the KMS
/// encryption context so a ciphertext moved between buckets, tiers or fields
/// fails to decrypt instead of silently authorizing a different remote.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SealScope {
pub store: SealedCredentialStore,
pub owner: String,
pub field: &'static str,
}
impl SealScope {
pub fn new(store: SealedCredentialStore, owner: impl Into<String>, field: &'static str) -> Self {
Self {
store,
owner: owner.into(),
field,
}
}
/// The encryption context handed to the sealer. Keys are stable: they are
/// part of the on-disk contract, because a ciphertext only decrypts under
/// the same context.
pub fn encryption_context(&self) -> HashMap<String, String> {
HashMap::from([
("rustfs:store".to_string(), self.store.as_str().to_string()),
("rustfs:owner".to_string(), self.owner.clone()),
("rustfs:field".to_string(), self.field.to_string()),
])
}
}
/// A sealed secret as persisted. `Debug` prints no ciphertext: a sealed value
/// is not a secret, but it is noise in a log line and an operator reading one
/// should see the key it is wrapped under, not the bytes.
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SealedCredential {
/// Envelope version; see [`SEALED_CREDENTIAL_VERSION`].
pub v: u8,
/// KMS master key id the data key is wrapped under.
pub key_id: String,
/// Master key version, when the backend reports one. Carried so the KMS
/// re-wrap job (`docs/architecture/kms-bulk-rekey-contract.md`) can tell
/// stale envelopes apart; nothing here rotates on its own.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_version: Option<String>,
/// Algorithm label reported by the sealer, for forensics and migration.
pub alg: String,
/// Ciphertext blob as produced by the sealer, base64 (standard, padded)
/// in the JSON stores and raw inside the tier msgpack payload.
pub ct: String,
}
impl fmt::Debug for SealedCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SealedCredential")
.field("v", &self.v)
.field("key_id", &self.key_id)
.field("key_version", &self.key_version)
.field("alg", &self.alg)
.field("ct", &format_args!("<{} bytes sealed>", self.ct.len()))
.finish()
}
}
impl SealedCredential {
/// Rejects an envelope this build cannot read. Called before every
/// unseal so an unknown version fails here rather than inside a backend.
pub fn check_version(&self) -> Result<(), SealedCredentialError> {
if self.v == SEALED_CREDENTIAL_VERSION {
Ok(())
} else {
Err(SealedCredentialError::UnsupportedVersion(self.v))
}
}
}
/// Why a seal or unseal did not produce a usable value. Every variant is
/// terminal for the record that carried it: a caller reports the remote as
/// unusable, and never substitutes a default or empty credential.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum SealedCredentialError {
/// No sealer is installed: KMS is not configured, or the process has not
/// finished startup. Reading a sealed record is impossible here.
#[error("no credential sealer is installed")]
NoSealer,
/// The stored envelope is from a newer (or otherwise unknown) format.
#[error("unsupported sealed credential envelope version {0}")]
UnsupportedVersion(u8),
/// The stored bytes are not a well-formed envelope.
#[error("malformed sealed credential: {0}")]
Malformed(String),
/// The sealer refused: wrong encryption context, missing key, revoked
/// access, or a failed authentication tag.
#[error("sealed credential could not be unsealed: {0}")]
Kms(String),
}
/// The KMS-backed half, installed by the binary at startup.
#[async_trait]
pub trait CredentialSealer: Send + Sync + 'static {
/// Wraps `plaintext` under the scope's encryption context.
async fn seal(&self, plaintext: &str, scope: &SealScope) -> Result<SealedCredential, SealedCredentialError>;
/// Unwraps a stored envelope. Must fail when the envelope was sealed
/// under a different scope.
async fn unseal(&self, sealed: &SealedCredential, scope: &SealScope) -> Result<String, SealedCredentialError>;
}
static CREDENTIAL_SEALER: OnceLock<Arc<dyn CredentialSealer>> = OnceLock::new();
/// Installs the process-wide sealer. Returns `false` when one is already
/// installed, matching the other ECStore hooks.
pub fn install_credential_sealer(sealer: Arc<dyn CredentialSealer>) -> bool {
CREDENTIAL_SEALER.set(sealer).is_ok()
}
/// The installed sealer, or `None` when KMS is not wired. Callers that only
/// need to know whether sealing is possible use this; callers that must have
/// it use [`seal_secret`] / [`unseal_secret`] and get the typed error.
pub fn credential_sealer() -> Option<Arc<dyn CredentialSealer>> {
CREDENTIAL_SEALER.get().cloned()
}
/// Seals one secret field. Fails closed: without a sealer the caller must
/// reject the write rather than persist the secret in clear text after the
/// operator asked for sealing.
pub async fn seal_secret(plaintext: &str, scope: &SealScope) -> Result<SealedCredential, SealedCredentialError> {
let sealer = credential_sealer().ok_or(SealedCredentialError::NoSealer)?;
sealer.seal(plaintext, scope).await
}
/// Unseals one secret field, rejecting an unknown envelope version first.
pub async fn unseal_secret(sealed: &SealedCredential, scope: &SealScope) -> Result<String, SealedCredentialError> {
sealed.check_version()?;
let sealer = credential_sealer().ok_or(SealedCredentialError::NoSealer)?;
sealer.unseal(sealed, scope).await
}
#[cfg(test)]
mod tests {
use super::*;
use parking_lot::Mutex;
/// Stands in for the KMS-backed sealer: records the context it was called
/// with, and refuses a ciphertext presented under a different one.
#[derive(Default)]
struct FakeSealer {
sealed_contexts: Mutex<Vec<HashMap<String, String>>>,
}
#[async_trait]
impl CredentialSealer for FakeSealer {
async fn seal(&self, plaintext: &str, scope: &SealScope) -> Result<SealedCredential, SealedCredentialError> {
let context = scope.encryption_context();
self.sealed_contexts.lock().push(context.clone());
let mut bound = serde_json::to_string(&context).expect("context serializes");
bound.push('|');
bound.push_str(plaintext);
Ok(SealedCredential {
v: SEALED_CREDENTIAL_VERSION,
key_id: "key-1".to_string(),
key_version: Some("3".to_string()),
alg: "AES-256-GCM".to_string(),
ct: base64_simd::STANDARD.encode_to_string(bound.as_bytes()),
})
}
async fn unseal(&self, sealed: &SealedCredential, scope: &SealScope) -> Result<String, SealedCredentialError> {
let raw = base64_simd::STANDARD
.decode_to_vec(sealed.ct.as_bytes())
.map_err(|err| SealedCredentialError::Malformed(err.to_string()))?;
let bound = String::from_utf8(raw).map_err(|err| SealedCredentialError::Malformed(err.to_string()))?;
let expected = serde_json::to_string(&scope.encryption_context()).expect("context serializes");
bound
.strip_prefix(&expected)
.and_then(|rest| rest.strip_prefix('|'))
.map(str::to_string)
.ok_or_else(|| SealedCredentialError::Kms("encryption context mismatch".to_string()))
}
}
fn scope(owner: &str) -> SealScope {
SealScope::new(SealedCredentialStore::OnDemandMigration, owner, "secret_key")
}
#[tokio::test]
async fn seal_round_trips_and_binds_the_scope() {
let sealer = Arc::new(FakeSealer::default());
let sealed = sealer.seal("super-secret", &scope("photos")).await.expect("seal");
assert_eq!(sealed.v, SEALED_CREDENTIAL_VERSION);
assert_eq!(sealed.key_version.as_deref(), Some("3"));
assert_eq!(sealer.unseal(&sealed, &scope("photos")).await.expect("unseal"), "super-secret");
// The same ciphertext under another bucket must not unseal.
let err = sealer
.unseal(&sealed, &scope("other-bucket"))
.await
.expect_err("a ciphertext must not move between owners");
assert!(matches!(err, SealedCredentialError::Kms(_)), "{err}");
// Nor under another field of the same record.
let other_field = SealScope::new(SealedCredentialStore::OnDemandMigration, "photos", "session_token");
let err = sealer
.unseal(&sealed, &other_field)
.await
.expect_err("a ciphertext must not move between fields");
assert!(matches!(err, SealedCredentialError::Kms(_)), "{err}");
let contexts = sealer.sealed_contexts.lock();
assert_eq!(contexts.len(), 1);
assert_eq!(contexts[0]["rustfs:store"], "on-demand-migration");
assert_eq!(contexts[0]["rustfs:owner"], "photos");
assert_eq!(contexts[0]["rustfs:field"], "secret_key");
}
#[tokio::test]
async fn an_unknown_envelope_version_is_rejected_before_the_sealer_is_asked() {
let sealed = SealedCredential {
v: SEALED_CREDENTIAL_VERSION + 1,
key_id: "key-1".to_string(),
key_version: None,
alg: "AES-256-GCM".to_string(),
ct: "Zm9v".to_string(),
};
assert_eq!(
sealed.check_version().expect_err("a newer envelope must not be read"),
SealedCredentialError::UnsupportedVersion(SEALED_CREDENTIAL_VERSION + 1)
);
// The global helper reports the version, not "no sealer", even in a
// process where none is installed.
assert_eq!(
unseal_secret(&sealed, &scope("photos")).await.expect_err("version first"),
SealedCredentialError::UnsupportedVersion(SEALED_CREDENTIAL_VERSION + 1)
);
}
#[tokio::test]
async fn without_a_sealer_both_directions_fail_closed() {
// This test binary installs no sealer, so the global helpers must
// report NoSealer rather than fall back to clear text.
assert!(credential_sealer().is_none(), "no sealer is installed in unit tests");
assert_eq!(
seal_secret("super-secret", &scope("photos")).await.expect_err("seal"),
SealedCredentialError::NoSealer
);
let sealed = SealedCredential {
v: SEALED_CREDENTIAL_VERSION,
key_id: "key-1".to_string(),
key_version: None,
alg: "AES-256-GCM".to_string(),
ct: "Zm9v".to_string(),
};
assert_eq!(
unseal_secret(&sealed, &scope("photos")).await.expect_err("unseal"),
SealedCredentialError::NoSealer
);
}
#[test]
fn debug_and_serde_keep_the_on_disk_shape_stable() {
let sealed = SealedCredential {
v: 1,
key_id: "key-1".to_string(),
key_version: None,
alg: "AES-256-GCM".to_string(),
ct: "Zm9v".to_string(),
};
// key_version is omitted when absent, so an envelope from a backend
// without version history stays compact.
assert_eq!(
serde_json::to_string(&sealed).expect("serialize"),
r#"{"v":1,"key_id":"key-1","alg":"AES-256-GCM","ct":"Zm9v"}"#
);
let parsed: SealedCredential = serde_json::from_str(r#"{"v":1,"key_id":"key-1","alg":"AES-256-GCM","ct":"Zm9v"}"#)
.expect("an envelope without key_version parses");
assert_eq!(parsed, sealed);
let rendered = format!("{sealed:?}");
assert!(rendered.contains("key-1"), "{rendered}");
assert!(!rendered.contains("Zm9v"), "Debug must not print the ciphertext: {rendered}");
}
#[test]
fn a_malformed_envelope_is_a_typed_error() {
let err = serde_json::from_str::<SealedCredential>(r#"{"v":1,"key_id":"key-1"}"#)
.map_err(|err| SealedCredentialError::Malformed(err.to_string()))
.expect_err("a truncated envelope must not parse");
assert!(matches!(err, SealedCredentialError::Malformed(_)), "{err}");
}
}
@@ -90,6 +90,10 @@ impl BucketVersioningSys {
/// caller's own instance context so a second in-process store never
/// answers with the first instance's versioning state; falls back to the
/// ambient system when the instance cell is not initialized.
#[allow(
dead_code,
reason = "instance-scoped seam (backlog#1052) with no caller in this port (backlog#1823)"
)]
pub(crate) async fn get_in(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> Result<VersioningConfiguration> {
if bucket == RUSTFS_META_BUCKET || bucket.starts_with(RUSTFS_META_BUCKET) {
return Ok(VersioningConfiguration::default());
+1 -1
View File
@@ -48,7 +48,7 @@ pub use internode_data_transport::build_internode_data_transport_from_env;
pub(crate) use peer_rest_client::TierConfigReloadOutcome;
pub use peer_rest_client::{
KMS_SIGNAL_SUBSYSTEM, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, SERVICE_SIGNAL_REFRESH_CONFIG,
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity, ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease,
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity, ScannerPublicationLease,
};
pub(crate) use peer_s3_client::heal_bucket_local_on_disks;
pub use peer_s3_client::{
@@ -20,11 +20,9 @@ use crate::cluster::rpc::{set_tonic_canonical_body_digest, set_tonic_mutation_bo
use crate::error::{Error, Result};
use crate::storage_api_contracts::internode::{
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION, SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_ACTIVITY_PROTOCOL_VERSION,
SCANNER_ACTIVITY_V6_PROTOCOL_VERSION, SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES,
SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION, SCANNER_DIRTY_USAGE_SNAPSHOT_RPC_MAX_MESSAGE_SIZE,
SCANNER_ACTIVITY_V6_PROTOCOL_VERSION,
};
use crate::{
bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats, TierDailyStatsWire},
bucket::replication::BucketStats,
disk::disk_store::{get_drive_active_check_interval, get_drive_active_check_timeout},
layout::endpoints::EndpointServerPools,
@@ -48,19 +46,18 @@ use rustfs_protos::proto_gen::node_service::{
HealControlRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest,
LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ReplacementRecoveryStatusRequest,
ScannerActivityRequest, ScannerActivityResponse, ScannerDirtyUsageSnapshotRequest, ScannerDirtyUsageSnapshotResponse,
ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest, ScannerPublicationLeaseResponse, ServerInfoRequest,
SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest,
TierDailyStatsRequest, TierMutationAbortRequest, TierMutationCommitRequest, TierMutationControlResponse,
TierMutationFailureClass, TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
tier_mutation_control_service_client::TierMutationControlServiceClient,
ScannerActivityRequest, ScannerActivityResponse, ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest,
ScannerPublicationLeaseResponse, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest,
StartProfilingRequest, StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest,
TierMutationControlResponse, TierMutationFailureClass, TierMutationPeerState, TierMutationPrepareRequest,
node_service_client::NodeServiceClient, tier_mutation_control_service_client::TierMutationControlServiceClient,
};
pub use rustfs_protos::{PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS};
use rustfs_protos::{TierMutationRpcPhase, evict_failed_connection};
use rustfs_utils::XHost;
use serde::{Deserialize, Serialize as _};
use std::{
collections::{BTreeMap, HashMap},
collections::HashMap,
io::Cursor,
sync::{
Arc, Weak,
@@ -187,31 +184,18 @@ pub struct ScannerPeerActivity {
pub publication_blocked: Option<bool>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScannerPeerDirtyUsageSnapshot {
pub instance_id: String,
pub generation: u64,
pub pending_bucket_count: u64,
pub protocol_version: u32,
pub complete: bool,
pub buckets: BTreeMap<String, u64>,
}
fn scanner_instance_id_is_valid(instance_id: &str) -> bool {
instance_id.len() == 32
&& instance_id
.as_bytes()
.iter()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
}
fn decode_scanner_activity_with_verifier(
response: ScannerActivityResponse,
challenge: &[u8; 16],
verify_proof: impl FnOnce(&[u8], &[u8]) -> Result<()>,
) -> Result<ScannerPeerActivity> {
let instance_id = &response.instance_id;
if !scanner_instance_id_is_valid(instance_id) {
if instance_id.len() != 32
|| !instance_id
.as_bytes()
.iter()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
{
return Err(Error::other("peer returned an invalid scanner activity instance ID"));
}
let (
@@ -333,82 +317,6 @@ fn decode_scanner_activity(response: ScannerActivityResponse, challenge: &[u8; 1
})
}
fn decode_scanner_dirty_usage_snapshot_with_verifier(
response: ScannerDirtyUsageSnapshotResponse,
challenge: &[u8; 16],
verify_proof: impl FnOnce(&[u8], &[u8]) -> Result<()>,
) -> Result<ScannerPeerDirtyUsageSnapshot> {
let canonical = rustfs_protos::canonical_scanner_dirty_usage_snapshot_response_body(challenge, &response)
.map_err(|_| Error::other("peer scanner dirty usage snapshot is too large to authenticate"))?;
verify_proof(&canonical, &response.response_proof)?;
if response.protocol_version != SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION {
return Err(Error::other("peer returned unsupported scanner dirty usage snapshot protocol"));
}
if !scanner_instance_id_is_valid(&response.instance_id) {
return Err(Error::other("peer returned an invalid scanner dirty usage snapshot instance ID"));
}
if response.generation == u64::MAX {
return Err(Error::other("peer scanner dirty usage snapshot exhausted its generation"));
}
if response.pending_bucket_count > 0 && response.generation == 0 {
return Err(Error::other("peer scanner dirty usage snapshot has pending buckets without a generation"));
}
if response.buckets.len() > SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES {
return Err(Error::other("peer scanner dirty usage snapshot exceeds the entry limit"));
}
let bucket_count = u64::try_from(response.buckets.len())
.map_err(|_| Error::other("peer scanner dirty usage snapshot entry count cannot be represented"))?;
let max_entries = u64::try_from(SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES).unwrap_or(u64::MAX);
if response.complete {
if response.pending_bucket_count != bucket_count {
return Err(Error::other(
"complete peer scanner dirty usage snapshot has an inconsistent bucket count",
));
}
} else if !response.buckets.is_empty() || response.pending_bucket_count <= max_entries {
return Err(Error::other(
"incomplete peer scanner dirty usage snapshot must represent an entry-limit overflow",
));
}
for pair in response.buckets.windows(2) {
if pair[0].bucket >= pair[1].bucket {
return Err(Error::other("peer scanner dirty usage snapshot buckets are not strictly ordered"));
}
}
for bucket in &response.buckets {
if bucket.bucket.is_empty() {
return Err(Error::other("peer scanner dirty usage snapshot contains an empty bucket name"));
}
if bucket.generation == 0 || bucket.generation > response.generation {
return Err(Error::other("peer scanner dirty usage snapshot contains an invalid bucket generation"));
}
}
Ok(ScannerPeerDirtyUsageSnapshot {
instance_id: response.instance_id,
generation: response.generation,
pending_bucket_count: response.pending_bucket_count,
protocol_version: response.protocol_version,
complete: response.complete,
buckets: response
.buckets
.into_iter()
.map(|bucket| (bucket.bucket, bucket.generation))
.collect(),
})
}
fn decode_scanner_dirty_usage_snapshot(
response: ScannerDirtyUsageSnapshotResponse,
challenge: &[u8; 16],
) -> Result<ScannerPeerDirtyUsageSnapshot> {
decode_scanner_dirty_usage_snapshot_with_verifier(response, challenge, |canonical, proof| {
verify_tonic_rpc_response_proof(canonical, proof)
.map_err(|_| Error::other("peer returned an invalid scanner dirty usage snapshot response proof"))
})
}
fn scanner_activity_protocol_unsupported(err: &Error) -> bool {
matches!(
err,
@@ -1078,40 +986,6 @@ impl PeerRestClient {
Ok(cpus)
}
/// This peer's own rolling-day transition counters, per remote tier.
///
/// The response is untrusted peer input: a ring of the wrong width or an
/// unrepresentable clock is rejected here rather than merged, so a corrupt
/// answer makes the node non-reporting instead of silently shifting a
/// cluster total.
pub async fn tier_daily_stats(&self) -> Result<DailyAllTierStats> {
self.finalize_result(self.tier_daily_stats_inner().await).await
}
async fn tier_daily_stats_inner(&self) -> Result<DailyAllTierStats> {
let mut client = self.get_client().await?;
let request = Request::new(TierDailyStatsRequest {});
let response = client.tier_daily_stats(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(peer_failure_without_details("tier_daily_stats", None));
}
let mut buf = Deserializer::new(Cursor::new(response.tier_daily_stats));
let wire: HashMap<String, TierDailyStatsWire> = Deserialize::deserialize(&mut buf)?;
wire.into_iter()
.map(|(tier, stats)| {
LastDayTierStats::from_wire(stats)
.map(|stats| (tier, stats))
.map_err(Error::from)
})
.collect()
}
pub async fn get_net_info(&self) -> Result<NetInfo> {
self.finalize_result(self.get_net_info_inner().await).await
}
@@ -2026,30 +1900,6 @@ impl PeerRestClient {
}
}
pub async fn scanner_dirty_usage_snapshot(&self) -> Result<ScannerPeerDirtyUsageSnapshot> {
self.finalize_result(
async {
let challenge = Uuid::new_v4();
let mut client = self
.get_client()
.await?
.max_decoding_message_size(SCANNER_DIRTY_USAGE_SNAPSHOT_RPC_MAX_MESSAGE_SIZE)
.max_encoding_message_size(SCANNER_DIRTY_USAGE_SNAPSHOT_RPC_MAX_MESSAGE_SIZE);
let mut request = Request::new(ScannerDirtyUsageSnapshotRequest {
challenge: challenge.as_bytes().to_vec().into(),
protocol_version: SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION,
});
let canonical = rustfs_protos::canonical_scanner_dirty_usage_snapshot_request_body(request.get_ref())
.map_err(|_| Error::other("scanner dirty usage snapshot request is too large to authenticate"))?;
set_tonic_canonical_body_digest(&mut request, &canonical)?;
let response = client.scanner_dirty_usage_snapshot(request).await?.into_inner();
decode_scanner_dirty_usage_snapshot(response, challenge.as_bytes())
}
.await,
)
.await
}
pub async fn acknowledge_scanner_dirty_usage(&self, instance_id: String, generation: u64) -> Result<ScannerPeerActivity> {
let result = self
.scanner_activity_request_with_protocol(instance_id.clone(), generation, SCANNER_ACTIVITY_PROTOCOL_VERSION)
@@ -2755,141 +2605,6 @@ mod tests {
})
}
fn decode_test_scanner_dirty_usage_snapshot(
response: ScannerDirtyUsageSnapshotResponse,
) -> Result<ScannerPeerDirtyUsageSnapshot> {
decode_scanner_dirty_usage_snapshot_with_verifier(response, &[9; 16], |_canonical, proof| {
(proof == b"proof")
.then_some(())
.ok_or_else(|| Error::other("peer returned an invalid scanner dirty usage snapshot response proof"))
})
}
fn test_scanner_dirty_usage_snapshot_response() -> ScannerDirtyUsageSnapshotResponse {
ScannerDirtyUsageSnapshotResponse {
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
generation: 7,
pending_bucket_count: 2,
protocol_version: SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION,
complete: true,
buckets: vec![
rustfs_protos::proto_gen::node_service::ScannerDirtyUsageBucket {
bucket: "archive".to_string(),
generation: 3,
},
rustfs_protos::proto_gen::node_service::ScannerDirtyUsageBucket {
bucket: "photos".to_string(),
generation: 7,
},
],
response_proof: b"proof".to_vec().into(),
}
}
#[test]
fn scanner_dirty_usage_snapshot_requires_a_complete_authenticated_ordered_view() {
let decoded = decode_test_scanner_dirty_usage_snapshot(test_scanner_dirty_usage_snapshot_response())
.expect("a complete authenticated dirty usage snapshot should decode");
assert_eq!(decoded.instance_id, "0123456789abcdef0123456789abcdef");
assert_eq!(decoded.generation, 7);
assert_eq!(decoded.pending_bucket_count, 2);
assert_eq!(decoded.protocol_version, SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION);
assert!(decoded.complete);
assert_eq!(decoded.buckets.get("archive"), Some(&3));
assert_eq!(decoded.buckets.get("photos"), Some(&7));
let overflow_count =
u64::try_from(SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES + 1).expect("the test snapshot entry limit should fit in u64");
let overflow = decode_test_scanner_dirty_usage_snapshot(ScannerDirtyUsageSnapshotResponse {
pending_bucket_count: overflow_count,
complete: false,
buckets: Vec::new(),
..test_scanner_dirty_usage_snapshot_response()
})
.expect("an explicit all-or-nothing overflow snapshot should decode");
assert!(!overflow.complete);
assert!(overflow.buckets.is_empty());
}
#[test]
fn scanner_dirty_usage_snapshot_rejects_inconsistent_or_partial_peer_data() {
let mut cases = Vec::new();
let mut invalid_instance = test_scanner_dirty_usage_snapshot_response();
invalid_instance.instance_id = "ABCDEF0123456789ABCDEF0123456789".to_string();
cases.push((invalid_instance, "instance ID"));
let mut unsupported = test_scanner_dirty_usage_snapshot_response();
unsupported.protocol_version = SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION + 1;
cases.push((unsupported, "unsupported"));
let mut exhausted = test_scanner_dirty_usage_snapshot_response();
exhausted.generation = u64::MAX;
cases.push((exhausted, "exhausted"));
let mut inconsistent_count = test_scanner_dirty_usage_snapshot_response();
inconsistent_count.pending_bucket_count = 3;
cases.push((inconsistent_count, "bucket count"));
let mut unordered = test_scanner_dirty_usage_snapshot_response();
unordered.buckets.reverse();
cases.push((unordered, "strictly ordered"));
let mut future_bucket = test_scanner_dirty_usage_snapshot_response();
future_bucket.buckets[0].generation = 8;
cases.push((future_bucket, "bucket generation"));
let mut zero_generation = test_scanner_dirty_usage_snapshot_response();
zero_generation.buckets[0].generation = 0;
cases.push((zero_generation, "bucket generation"));
let mut empty_bucket = test_scanner_dirty_usage_snapshot_response();
empty_bucket.buckets[0].bucket.clear();
cases.push((empty_bucket, "empty bucket name"));
let mut partial = test_scanner_dirty_usage_snapshot_response();
partial.complete = false;
cases.push((partial, "entry-limit overflow"));
let too_many_buckets = ScannerDirtyUsageSnapshotResponse {
generation: 1,
pending_bucket_count: u64::try_from(SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES + 1)
.expect("the test snapshot entry limit should fit in u64"),
buckets: (0..=SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES)
.map(|index| rustfs_protos::proto_gen::node_service::ScannerDirtyUsageBucket {
bucket: format!("bucket-{index:04}"),
generation: 1,
})
.collect(),
..test_scanner_dirty_usage_snapshot_response()
};
cases.push((too_many_buckets, "exceeds the entry limit"));
let overflow_count =
u64::try_from(SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES + 1).expect("the test snapshot entry limit should fit in u64");
let invalid_overflow = ScannerDirtyUsageSnapshotResponse {
generation: 0,
pending_bucket_count: overflow_count,
complete: false,
buckets: Vec::new(),
..test_scanner_dirty_usage_snapshot_response()
};
cases.push((invalid_overflow, "without a generation"));
for (response, expected) in cases {
let err =
decode_test_scanner_dirty_usage_snapshot(response).expect_err("malformed dirty usage snapshots must fail closed");
assert!(err.to_string().contains(expected), "expected {expected:?} in {err}");
}
let mut invalid_proof = test_scanner_dirty_usage_snapshot_response();
invalid_proof.protocol_version = SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION + 1;
invalid_proof.response_proof = b"invalid".to_vec().into();
let err = decode_test_scanner_dirty_usage_snapshot(invalid_proof)
.expect_err("an invalid response proof must fail before peer fields are trusted");
assert!(err.to_string().contains("response proof"));
}
#[test]
fn build_clients_from_slots_preserves_missing_remote_topology_slots() {
let slots = vec![
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -56
View File
@@ -234,7 +234,6 @@ async fn pause_data_movement_multipart_before_abort(bucket: &str, object: &str)
}
fn data_movement_abort_opts(
object_info: &ObjectInfo,
src_pool_idx: usize,
expected_bucket_incarnation_id: Option<uuid::Uuid>,
lock_lost_signal: Option<&Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
@@ -243,9 +242,6 @@ fn data_movement_abort_opts(
let mut opts = ObjectOptions {
data_movement: true,
src_pool_idx,
versioned: object_info.version_id.is_some(),
version_id: object_info.version_id.map(|version_id| version_id.to_string()),
mod_time: object_info.mod_time,
expected_bucket_incarnation_id,
..Default::default()
};
@@ -269,19 +265,14 @@ fn insert_data_movement_checksum(user_defined: &mut HashMap<String, String>, obj
}
}
fn data_movement_upload_identity_parts(version_id: Option<&str>, mod_time: Option<time::OffsetDateTime>) -> String {
let version_id = version_id.unwrap_or("none");
let mod_time = mod_time.map_or_else(|| "none".to_string(), |mod_time| mod_time.unix_timestamp_nanos().to_string());
format!("v1:{version_id}:{mod_time}")
}
fn data_movement_upload_identity(object_info: &ObjectInfo) -> String {
let version_id = object_info.version_id.map(|version_id| version_id.to_string());
data_movement_upload_identity_parts(version_id.as_deref(), object_info.mod_time)
}
pub(crate) fn data_movement_upload_identity_from_options(opts: &ObjectOptions) -> String {
data_movement_upload_identity_parts(opts.version_id.as_deref(), opts.mod_time)
let version_id = object_info
.version_id
.map_or_else(|| "none".to_string(), |version_id| version_id.to_string());
let mod_time = object_info
.mod_time
.map_or_else(|| "none".to_string(), |mod_time| mod_time.unix_timestamp_nanos().to_string());
format!("v1:{version_id}:{mod_time}")
}
fn data_movement_new_multipart_opts(object_info: &ObjectInfo, src_pool_idx: usize) -> ObjectOptions {
@@ -495,7 +486,7 @@ pub(crate) fn data_movement_target_precondition() -> HTTPPreconditions {
}
}
pub(crate) fn is_owned_data_movement_target(target: &ObjectInfo) -> bool {
fn is_owned_data_movement_target(target: &ObjectInfo) -> bool {
let rustfs_marker = rustfs_utils::http::internal_key_rustfs(SUFFIX_DATA_MOVED);
let minio_marker = format!("{}{SUFFIX_DATA_MOVED}", rustfs_utils::http::MINIO_INTERNAL_PREFIX);
if rustfs_utils::http::get_consistent_str(&target.user_defined, SUFFIX_DATA_MOVED) != Some("true")
@@ -569,12 +560,9 @@ fn resolve_data_movement_abort_result(
primary_err: Error,
abort_err: Error,
) -> Error {
data_movement_context_error(
format!(
"{op_label}: abort_multipart_upload failed for {bucket}/{object} upload {upload_id} after error {primary_err}: {abort_err}"
),
abort_err,
)
Error::other(format!(
"{op_label}: abort_multipart_upload failed for {bucket}/{object} upload {upload_id} after error {primary_err}: {abort_err}"
))
}
/// A data-movement stage failure that keeps the error it wrapped.
@@ -602,21 +590,15 @@ impl std::error::Error for DataMovementStageError {
}
}
pub(crate) fn data_movement_context_error<E>(rendered: String, err: E) -> Error
where
E: std::error::Error + Send + Sync + 'static,
{
Error::other(DataMovementStageError {
rendered,
source: Box::new(err),
})
}
fn data_movement_stage_error<E>(op_label: &str, stage: &str, bucket: &str, object: &str, err: E) -> Error
where
E: std::error::Error + Send + Sync + 'static,
{
data_movement_context_error(format!("{op_label}: {stage} failed for {bucket}/{object}: {err}"), err)
let rendered = format!("{op_label}: {stage} failed for {bucket}/{object}: {err}");
Error::other(DataMovementStageError {
rendered,
source: Box::new(err),
})
}
#[cfg(test)]
@@ -1680,13 +1662,8 @@ async fn migrate_object_inner(
);
return Ok(());
}
let mut cleanup_opts = data_movement_abort_opts(
&object_info,
pool_idx,
source_bucket_incarnation_id,
lock_lost_signal.as_ref(),
capacity_owner,
);
let mut cleanup_opts =
data_movement_abort_opts(pool_idx, source_bucket_incarnation_id, lock_lost_signal.as_ref(), capacity_owner);
if let Some(anchor) = mutation_fence.as_ref() {
anchor.guard().add_namespace_lock_fence(&mut cleanup_opts);
}
@@ -1888,13 +1865,8 @@ async fn migrate_object_inner(
.await;
if multipart_result.is_ok() && should_abort_multipart_upload(&abort_multipart_flag) {
let mut abort_opts = data_movement_abort_opts(
&object_info,
pool_idx,
expected_bucket_incarnation_id,
lock_lost_signal.as_ref(),
capacity_owner,
);
let mut abort_opts =
data_movement_abort_opts(pool_idx, expected_bucket_incarnation_id, lock_lost_signal.as_ref(), capacity_owner);
if let Some(anchor) = mutation_fence.as_ref() {
anchor.guard().add_namespace_lock_fence(&mut abort_opts);
}
@@ -1971,13 +1943,8 @@ async fn migrate_object_inner(
if should_abort_multipart_upload(&abort_multipart_flag) {
#[cfg(all(test, feature = "test-util"))]
pause_data_movement_multipart_before_abort(&bucket, &object_info.name).await;
let mut abort_opts = data_movement_abort_opts(
&object_info,
pool_idx,
expected_bucket_incarnation_id,
lock_lost_signal.as_ref(),
capacity_owner,
);
let mut abort_opts =
data_movement_abort_opts(pool_idx, expected_bucket_incarnation_id, lock_lost_signal.as_ref(), capacity_owner);
if let Some(anchor) = mutation_fence.as_ref() {
anchor.guard().add_namespace_lock_fence(&mut abort_opts);
}
@@ -2358,7 +2325,6 @@ mod tests {
assert!(message.contains("bucket-a/object-a"));
assert!(message.contains("upload upload-1"));
assert!(message.contains(Error::SlowDown.to_string().as_str()));
assert!(matches!(data_movement_stage_source(&err), Some(Error::OperationCanceled)));
}
#[test]
+1
View File
@@ -2964,6 +2964,7 @@ mod tests {
decommission_cancelers: RwLock::new(Vec::new()),
start_gate: TokioMutex::new(()),
pool_meta_save_gate: TokioMutex::default(),
decommission_capacity_entry_gate: TokioMutex::default(),
ctx,
bucket_fence_registry: Arc::default(),
})
+2 -2
View File
@@ -845,7 +845,7 @@ mod tests {
use std::error::Error as _;
let error = DiskError::from(ErasureConstructionError::ModernEncoder {
source: rustfs_erasure_codec::Error::TooManyShards,
source: reed_solomon_erasure::Error::TooManyShards,
});
let io_source = error.source().expect("DiskError::Io must expose its io::Error source");
assert!(io_source.is::<io::Error>());
@@ -856,7 +856,7 @@ mod tests {
let encoder_source = construction_source
.source()
.expect("construction error must expose the encoder error");
assert!(encoder_source.is::<rustfs_erasure_codec::Error>());
assert!(encoder_source.is::<reed_solomon_erasure::Error>());
}
#[test]
-107
View File
@@ -6964,17 +6964,6 @@ impl LocalDisk {
while let Some((last_name, _, _, _)) = dir_stack.last()
&& *last_name < name
{
// A prior iteration of this same loop may have just recursed
// into a pending subdirectory and hit the page limit there.
// Popping and recursing into another one anyway would still
// scan (and emit entries for) a directory beyond where the
// page was supposed to stop - stop draining the stack the
// moment the limit is reached, same as the check below this
// loop guards against for the current entry itself.
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
}
let (pop, skip_object, dir_to_skip, scan_required) = dir_stack.pop().expect("operation should succeed");
write_metacache_obj(
out,
@@ -7006,16 +6995,6 @@ impl LocalDisk {
}
}
// The while-loop above may have just recursed into a pending
// subdirectory and hit the page limit there. `name` sorts after
// that subdirectory's entries, so emitting it now would hand the
// caller a continuation marker past the subdirectory's unscanned
// tail, permanently skipping those keys on the next page instead
// of just deferring them to it.
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
}
let mut meta = MetaCacheEntry {
name,
..Default::default()
@@ -17051,92 +17030,6 @@ mod test {
assert!(names.contains(&"quux/thud".to_string()));
}
#[test]
#[serial_test::serial]
fn scan_dir_does_not_emit_entries_past_a_limit_hit_inside_a_subdirectory() {
// Reproduces the rustfs 1.0.0-rc.5 recursive-listing data loss: a
// subdirectory ("subdir/") holds more objects than the page limit, and
// a sibling object ("zzz_after") sorts after that whole subdirectory.
// scan_dir must stop at the limit and never emit "zzz_after" once the
// recursive scan of "subdir/" has already exhausted it - emitting it
// anyway hands gather_results a continuation marker that skips past
// the still-unscanned tail of "subdir/" on the next page, losing those
// keys permanently.
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should be created");
runtime.block_on(async {
use rustfs_filemeta::MetacacheReader;
use tempfile::tempdir;
let dir = tempdir().expect("tempdir should be created");
let bucket = "test-bucket";
let bucket_dir = dir.path().join(bucket);
const SUBDIR_OBJECTS: usize = 20;
const LIMIT: i32 = 15;
let subdir = bucket_dir.join("subdir");
for index in 0..SUBDIR_OBJECTS {
let object_dir = subdir.join(format!("object-{index:04}"));
fs::create_dir_all(&object_dir)
.await
.expect("object directory should be created");
fs::write(object_dir.join(STORAGE_FORMAT_FILE), b"meta")
.await
.expect("object metadata should be written");
}
let after_dir = bucket_dir.join("zzz_after");
fs::create_dir_all(&after_dir)
.await
.expect("object directory should be created");
fs::write(after_dir.join(STORAGE_FORMAT_FILE), b"meta")
.await
.expect("object metadata should be written");
let endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
let (reader, mut writer) = tokio::io::duplex(1 << 20);
let mut out = MetacacheWriter::new(&mut writer);
let opts = WalkDirOptions {
bucket: bucket.to_string(),
base_dir: String::new(),
recursive: true,
limit: LIMIT,
..Default::default()
};
let mut objs_returned = 0;
disk.scan_dir(String::new(), String::new(), &opts, &mut out, &mut objs_returned, false, None)
.await
.expect("scan_dir should succeed");
out.close().await.expect("metacache writer should close");
drop(out);
drop(writer);
let mut reader = MetacacheReader::new(reader);
let names: Vec<String> = reader
.read_all()
.await
.expect("scan output should decode")
.into_iter()
.filter(|entry| !entry.metadata.is_empty())
.map(|entry| entry.name)
.collect();
assert!(
!names.contains(&"zzz_after".to_string()),
"zzz_after sorts after the truncated subdir/ and must not be emitted \
once the page limit was hit inside subdir/, or the continuation \
marker built from it will skip subdir/'s unscanned tail forever: {names:?}"
);
});
}
#[test]
#[serial_test::serial]
fn scan_dir_records_whole_parent_read_dir_before_page_limit() {
+1 -1
View File
@@ -14,7 +14,7 @@
use crate::erasure::codec::workspace::RustfsCodecDecodeWorkspace;
use crate::erasure::coding::Erasure;
use rustfs_erasure_codec::galois_8::ReedSolomon;
use reed_solomon_erasure::galois_8::ReedSolomon;
use std::io;
use std::sync::{Arc, OnceLock};
+9 -9
View File
@@ -12,13 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Erasure coding implementation using rustfs-erasure-codec (GF(2^8)).
//! Erasure coding implementation using reed-solomon-erasure (GF(2^8)).
//! Supports legacy (reed-solomon-simd) for reading/healing old-version files.
//!
use bytes::{Bytes, BytesMut};
use reed_solomon_erasure::galois_8::ReedSolomon;
use reed_solomon_simd;
use rustfs_erasure_codec::galois_8::ReedSolomon;
use smallvec::SmallVec;
use std::{
collections::HashMap,
@@ -69,7 +69,7 @@ impl EncodedBlock {
}
}
const MODERN_MAX_TOTAL_SHARDS: usize = <rustfs_erasure_codec::galois_8::Field as rustfs_erasure_codec::Field>::ORDER;
const MODERN_MAX_TOTAL_SHARDS: usize = <reed_solomon_erasure::galois_8::Field as reed_solomon_erasure::Field>::ORDER;
const MODERN_REED_SOLOMON_CACHE_MAX_ENTRIES: usize = 64;
const LEGACY_REED_SOLOMON_CACHE_MAX_ENTRIES: usize = 16;
// Vec growth may retain twice the requested logical length. Keeping the logical
@@ -109,7 +109,7 @@ pub enum ErasureConstructionError {
#[error("failed to construct modern Reed-Solomon encoder")]
ModernEncoder {
#[source]
source: rustfs_erasure_codec::Error,
source: reed_solomon_erasure::Error,
},
/// The legacy encoder wrapper failed to initialize.
@@ -354,7 +354,7 @@ impl LegacyReedSolomonEncoder {
}
}
/// Reed-Solomon encoder using rustfs-erasure-codec
/// Reed-Solomon encoder using reed-solomon-erasure
pub struct ReedSolomonEncoder {
data_shards: usize,
parity_shards: usize,
@@ -372,7 +372,7 @@ impl Clone for ReedSolomonEncoder {
}
impl ReedSolomonEncoder {
fn try_new_typed(data_shards: usize, parity_shards: usize) -> Result<Self, rustfs_erasure_codec::Error> {
fn try_new_typed(data_shards: usize, parity_shards: usize) -> Result<Self, reed_solomon_erasure::Error> {
let encoder = if parity_shards > 0 {
Some(cached_modern_reed_solomon(data_shards, parity_shards)?)
} else {
@@ -445,7 +445,7 @@ impl ReedSolomonEncoder {
}
}
fn cached_modern_reed_solomon(data_shards: usize, parity_shards: usize) -> Result<Arc<ReedSolomon>, rustfs_erasure_codec::Error> {
fn cached_modern_reed_solomon(data_shards: usize, parity_shards: usize) -> Result<Arc<ReedSolomon>, reed_solomon_erasure::Error> {
let key = (data_shards, parity_shards);
let cache = MODERN_REED_SOLOMON_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
@@ -1407,11 +1407,11 @@ mod tests {
#[test]
fn construction_errors_preserve_encoder_sources() {
let modern = ErasureConstructionError::ModernEncoder {
source: rustfs_erasure_codec::Error::TooManyShards,
source: reed_solomon_erasure::Error::TooManyShards,
};
assert!(
std::error::Error::source(&modern)
.and_then(|source| source.downcast_ref::<rustfs_erasure_codec::Error>())
.and_then(|source| source.downcast_ref::<reed_solomon_erasure::Error>())
.is_some()
);
+2 -2
View File
@@ -1221,7 +1221,7 @@ mod tests {
use std::error::Error as _;
let error = StorageError::from(ErasureConstructionError::ModernEncoder {
source: rustfs_erasure_codec::Error::TooManyShards,
source: reed_solomon_erasure::Error::TooManyShards,
});
let io_source = error.source().expect("StorageError::Io must expose its io::Error source");
assert!(io_source.is::<std::io::Error>());
@@ -1232,7 +1232,7 @@ mod tests {
let encoder_source = construction_source
.source()
.expect("construction error must expose the encoder error");
assert!(encoder_source.is::<rustfs_erasure_codec::Error>());
assert!(encoder_source.is::<reed_solomon_erasure::Error>());
}
// The lifecycle transition worker relies on this arm alone to suppress the
+2 -3
View File
@@ -18,9 +18,8 @@ pub mod object_api_utils;
use crate::bucket::metadata_sys::get_versioning_config;
use crate::bucket::replication::{
DeleteReplicationConfigSnapshot, ReplicateDecision, ReplicationGenerationSnapshot, ReplicationState, ReplicationStatusType,
VersionPurgeStatusType, replication_status_from_filemeta, replication_statuses_map, version_purge_status_from_filemeta,
version_purge_statuses_map,
DeleteReplicationConfigSnapshot, ReplicateDecision, ReplicationState, ReplicationStatusType, VersionPurgeStatusType,
replication_status_from_filemeta, replication_statuses_map, version_purge_status_from_filemeta, version_purge_statuses_map,
};
use crate::bucket::versioning::VersioningApi as _;
use crate::config::storageclass;
+1 -196
View File
@@ -19,7 +19,6 @@ use crate::storage_api_contracts::{
HTTPPreconditions, ObjectLockRetentionOptions, ObjectPreconditionError, ObjectPreconditionPart, ObjectPreconditionState,
},
};
use sha2::{Digest, Sha256};
use std::io;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use tokio::sync::{Mutex, Notify, OwnedRwLockReadGuard};
@@ -286,7 +285,7 @@ pub struct QuotaAdmission {
pub struct LifecycleDeleteAllRequest {
pub(crate) version_id: Option<Uuid>,
pub(crate) delete_marker: bool,
pub(crate) action: rustfs_scanner_metrics::metrics::IlmAction,
pub(crate) action: rustfs_scanner_contracts::metrics::IlmAction,
pub(crate) rule_id: String,
pub(crate) phase: LifecycleDeleteAllPhase,
}
@@ -981,13 +980,6 @@ pub struct ObjectOptions {
pub lifecycle_audit_event: LcAuditEvent,
pub eval_metadata: Option<HashMap<String, String>>,
/// Internal compare-and-set condition for replication workers publishing
/// terminal status after remote I/O. Storage validates it while holding
/// the object write lock so an older worker cannot overwrite a newer
/// mutation's PENDING state. Keep the condition boxed because
/// `ObjectOptions` is passed by value through deep storage futures.
#[doc(hidden)]
pub replication_status_writeback: Option<Box<ReplicationStatusWritebackCondition>>,
pub object_lock_retention: Option<ObjectLockRetentionOptions>,
pub object_lock_delete: Option<crate::storage_api_contracts::object::ObjectLockDeleteOptions>,
/// Authoritative bucket Object Lock snapshot installed inside `ECStore`
@@ -1008,21 +1000,6 @@ pub struct ObjectOptions {
pub decommission_capacity_admission: Option<Arc<crate::store::ECStore>>,
}
#[derive(Clone, Debug, Default)]
#[doc(hidden)]
pub struct ReplicationStatusWritebackCondition {
pub(crate) expected_generation: ReplicationGenerationSnapshot,
pub(crate) mode: ReplicationStatusWritebackMode,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[doc(hidden)]
pub enum ReplicationStatusWritebackMode {
#[default]
Update,
ValidateOnly,
}
impl ObjectOptions {
pub(crate) fn with_capacity_expected_data_bytes(expected_data_bytes: Option<usize>) -> Self {
Self {
@@ -1109,7 +1086,6 @@ impl std::fmt::Debug for ObjectOptions {
|| !self.lifecycle_audit_event.event.storage_class.is_empty()),
)
.field("eval_metadata_count", &self.eval_metadata.as_ref().map(HashMap::len))
.field("replication_status_writeback", &self.replication_status_writeback.is_some())
.field("object_lock_retention", &self.object_lock_retention.is_some())
.field("object_lock_delete", &self.object_lock_delete)
.field("object_lock_config_snapshot", &self.object_lock_config_snapshot.is_some())
@@ -1305,32 +1281,6 @@ impl ObjectOptions {
}
}
fn replication_snapshot_internal_value(
metadata: &HashMap<String, String>,
suffix: &str,
) -> std::result::Result<Option<String>, ()> {
match rustfs_utils::http::get_consistent_str(metadata, suffix) {
Some(value) => Ok(Some(value.to_string())),
None if rustfs_utils::http::contains_key_str(metadata, suffix) => Err(()),
None => Ok(None),
}
}
fn update_replication_fingerprint_bytes(hasher: &mut Sha256, value: &[u8]) {
let len = u64::try_from(value.len()).unwrap_or(u64::MAX);
hasher.update(len.to_le_bytes());
hasher.update(value);
}
fn update_replication_fingerprint_optional_str(hasher: &mut Sha256, value: Option<&str>) {
if let Some(value) = value {
hasher.update([1]);
update_replication_fingerprint_bytes(hasher, value.as_bytes());
} else {
hasher.update([0]);
}
}
#[derive(Debug, Default)]
pub struct ObjectInfo {
pub bucket: String,
@@ -1419,144 +1369,6 @@ impl Clone for ObjectInfo {
}
impl ObjectInfo {
/// Capture the source mutation snapshot used by replication workers when
/// publishing terminal status. The semantic fingerprint is recomputed at
/// the storage CAS boundary, so an older writer that preserves an unknown
/// UUID and collides on the timestamp still cannot hide a payload change.
pub(crate) fn replication_generation_snapshot(&self) -> ReplicationGenerationSnapshot {
let timestamp = replication_snapshot_internal_value(&self.user_defined, rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP);
let mutation_id =
replication_snapshot_internal_value(&self.user_defined, rustfs_utils::http::SUFFIX_REPLICATION_GENERATION);
let tagging_timestamp =
replication_snapshot_internal_value(&self.user_defined, rustfs_utils::http::SUFFIX_TAGGING_TIMESTAMP);
let retention_timestamp =
replication_snapshot_internal_value(&self.user_defined, rustfs_utils::http::SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP);
let legalhold_timestamp =
replication_snapshot_internal_value(&self.user_defined, rustfs_utils::http::SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP);
let invalid = timestamp.is_err()
|| mutation_id.is_err()
|| tagging_timestamp.is_err()
|| retention_timestamp.is_err()
|| legalhold_timestamp.is_err();
let timestamp = timestamp.unwrap_or_default();
let mutation_id = mutation_id.unwrap_or_default();
let tagging_timestamp = tagging_timestamp.unwrap_or_default();
let retention_timestamp = retention_timestamp.unwrap_or_default();
let legalhold_timestamp = legalhold_timestamp.unwrap_or_default();
let opaque_timestamp_is_invalid = [
timestamp.as_deref(),
tagging_timestamp.as_deref(),
retention_timestamp.as_deref(),
legalhold_timestamp.as_deref(),
]
.into_iter()
.flatten()
.any(str::is_empty);
let mutation_id_is_invalid = mutation_id.as_deref().is_some_and(|value| {
Uuid::parse_str(value)
.ok()
.filter(|generation| !generation.is_nil())
.is_none()
});
let invalid =
invalid || opaque_timestamp_is_invalid || mutation_id_is_invalid || (mutation_id.is_some() && timestamp.is_none());
let payload_fingerprint = (!invalid).then(|| {
self.replication_payload_fingerprint(
tagging_timestamp.as_deref(),
retention_timestamp.as_deref(),
legalhold_timestamp.as_deref(),
)
});
ReplicationGenerationSnapshot {
timestamp,
mutation_id,
payload_fingerprint,
invalid,
}
}
fn replication_payload_fingerprint(
&self,
tagging_timestamp: Option<&str>,
retention_timestamp: Option<&str>,
legalhold_timestamp: Option<&str>,
) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(b"rustfs-replication-payload-v1");
// A suspended bucket's current null version is represented as
// `Some(Uuid::nil())` at the request/queue boundary and as `None`
// when the same xl.meta is reread without versioning flags. They are
// one persisted identity, so do not let that representation detail
// make a worker permanently supersede its own terminal write-back.
if let Some(version_id) = self.version_id.filter(|version_id| !version_id.is_nil()) {
hasher.update([1]);
hasher.update(version_id.as_bytes());
} else {
hasher.update([0]);
}
if let Some(data_dir) = self.data_dir {
hasher.update([1]);
hasher.update(data_dir.as_bytes());
} else {
hasher.update([0]);
}
if let Some(mod_time) = self.mod_time {
hasher.update([1]);
hasher.update(mod_time.unix_timestamp_nanos().to_le_bytes());
} else {
hasher.update([0]);
}
update_replication_fingerprint_optional_str(&mut hasher, self.content_type.as_deref());
update_replication_fingerprint_optional_str(&mut hasher, self.content_encoding.as_deref());
update_replication_fingerprint_optional_str(&mut hasher, self.storage_class.as_deref());
if let Some(expires) = self.expires {
hasher.update([1]);
hasher.update(expires.unix_timestamp_nanos().to_le_bytes());
} else {
hasher.update([0]);
}
update_replication_fingerprint_bytes(&mut hasher, self.user_tags.as_bytes());
update_replication_fingerprint_optional_str(&mut hasher, tagging_timestamp);
update_replication_fingerprint_optional_str(&mut hasher, retention_timestamp);
update_replication_fingerprint_optional_str(&mut hasher, legalhold_timestamp);
let mut target_arns = self
.replication_status_internal
.as_deref()
.map(replication_statuses_map)
.unwrap_or_default()
.into_keys()
.collect::<Vec<_>>();
target_arns.sort_unstable();
hasher.update(u64::try_from(target_arns.len()).unwrap_or(u64::MAX).to_le_bytes());
for arn in target_arns {
update_replication_fingerprint_bytes(&mut hasher, arn.as_bytes());
}
update_replication_fingerprint_bytes(&mut hasher, self.replication_decision.as_bytes());
let mut user_metadata = self
.user_defined
.iter()
.filter(|(key, _)| {
!rustfs_utils::http::is_internal_key(key)
&& !key.eq_ignore_ascii_case(rustfs_utils::http::AMZ_BUCKET_REPLICATION_STATUS)
})
.collect::<Vec<_>>();
user_metadata.sort_unstable_by(|left, right| left.0.cmp(right.0).then_with(|| left.1.cmp(right.1)));
hasher.update(u64::try_from(user_metadata.len()).unwrap_or(u64::MAX).to_le_bytes());
for (key, value) in user_metadata {
update_replication_fingerprint_bytes(&mut hasher, key.as_bytes());
update_replication_fingerprint_bytes(&mut hasher, value.as_bytes());
}
hasher.finalize().into()
}
pub fn is_compressed(&self) -> bool {
rustfs_utils::http::contains_key_str(&self.user_defined, rustfs_utils::http::SUFFIX_COMPRESSION)
}
@@ -2189,13 +2001,6 @@ fn versions_after_marker(file_infos: &rustfs_filemeta::FileInfoVersions, marker:
mod tests {
use super::*;
#[test]
fn replication_status_writeback_condition_remains_indirected() {
fn assert_indirected(_: &Option<Box<ReplicationStatusWritebackCondition>>) {}
assert_indirected(&ObjectOptions::default().replication_status_writeback);
}
#[test]
fn object_lock_config_snapshot_is_bound_to_store_bucket_and_incarnation() {
let store_id = Uuid::new_v4();
+8
View File
@@ -213,6 +213,7 @@ pub struct InstanceContext {
object_encryption_resolver: OnceLock<Arc<dyn ObjectEncryptionResolver>>,
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
#[cfg(test)]
tier_delete_journal_recovery_wakeup: tokio::sync::Notify,
}
@@ -259,6 +260,7 @@ impl InstanceContext {
object_encryption_resolver: OnceLock::new(),
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
#[cfg(test)]
tier_delete_journal_recovery_wakeup: tokio::sync::Notify::new(),
}
}
@@ -653,10 +655,16 @@ impl InstanceContext {
.insert(store_id)
}
#[cfg(test)]
#[allow(
dead_code,
reason = "driven by the tier-delete-journal recovery test behind `--features test-util` (backlog#1823)"
)]
pub(crate) fn wake_tier_delete_journal_recovery(&self) {
self.tier_delete_journal_recovery_wakeup.notify_one();
}
#[cfg(test)]
pub(crate) async fn wait_for_tier_delete_journal_recovery(&self) {
self.tier_delete_journal_recovery_wakeup.notified().await;
}
@@ -117,85 +117,26 @@ pub fn send_event(args: EventArgs) {
);
}
/// Shared event recorder for this crate's tests.
///
/// [`register_event_dispatch_hook`] backs a `OnceLock`, so only the first
/// caller in a test binary can install a hook. Every test that needs to
/// observe dispatched events must therefore go through this single recorder
/// instead of registering its own.
#[cfg(test)]
pub(crate) mod test_recorder {
use super::register_event_dispatch_hook;
use std::sync::{Mutex, OnceLock};
use uuid::Uuid;
/// The fields a test needs from a dispatched event. `EventArgs` itself is
/// not `Clone`, and recording a reduced shape keeps this test seam from
/// constraining the production type.
#[derive(Clone, Debug)]
pub(crate) struct RecordedEvent {
pub(crate) event_name: String,
pub(crate) bucket: String,
pub(crate) object: String,
pub(crate) version_id: Option<Uuid>,
pub(crate) delete_marker: bool,
}
static RECORDED: OnceLock<Mutex<Vec<RecordedEvent>>> = OnceLock::new();
fn recorded() -> &'static Mutex<Vec<RecordedEvent>> {
RECORDED.get_or_init(|| Mutex::new(Vec::new()))
}
pub(crate) fn install() {
static INSTALLED: OnceLock<()> = OnceLock::new();
INSTALLED.get_or_init(|| {
assert!(
register_event_dispatch_hook(|args| {
recorded().lock().unwrap_or_else(|err| err.into_inner()).push(RecordedEvent {
event_name: args.event_name,
bucket: args.bucket_name,
object: args.object.name,
version_id: args.object.version_id,
delete_marker: args.object.delete_marker,
});
}),
"the test event recorder must own this binary's dispatch hook"
);
});
}
/// Everything recorded for one bucket. Tests select by their own unique
/// bucket name rather than draining, because tests that are not
/// `#[serial]` may dispatch events concurrently.
pub(crate) fn recorded_for_bucket(bucket: &str) -> Vec<RecordedEvent> {
recorded()
.lock()
.unwrap_or_else(|err| err.into_inner())
.iter()
.filter(|event| event.bucket == bucket)
.cloned()
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
static DISPATCH_COUNT: AtomicUsize = AtomicUsize::new(0);
#[test]
fn send_event_dispatches_to_registered_hook() {
test_recorder::install();
let bucket = format!("event-dispatch-{}", uuid::Uuid::new_v4().simple());
let _ = register_event_dispatch_hook(|_args| {
DISPATCH_COUNT.fetch_add(1, Ordering::Relaxed);
});
let before = DISPATCH_COUNT.load(Ordering::Relaxed);
send_event(EventArgs {
event_name: "s3:ObjectCreated:Put".to_string(),
bucket_name: bucket.clone(),
bucket_name: "demo".to_string(),
..Default::default()
});
let dispatched = test_recorder::recorded_for_bucket(&bucket);
assert_eq!(dispatched.len(), 1);
assert_eq!(dispatched[0].event_name, "s3:ObjectCreated:Put");
assert_eq!(DISPATCH_COUNT.load(Ordering::Relaxed), before + 1);
}
}
+26 -26
View File
@@ -32,7 +32,7 @@ use rustfs_madmin::metrics::{
ScannerSourceCycleSnapshot as MadminScannerSourceCycleSnapshot, ScannerSourceWorkSnapshot as MadminScannerSourceWorkSnapshot,
ScannerUsageFreshnessSnapshot as MadminScannerUsageFreshnessSnapshot, TimedAction as MadminTimedAction,
};
use rustfs_scanner_metrics::metrics::global_metrics;
use rustfs_scanner_contracts::metrics::global_metrics;
use rustfs_utils::os::get_drive_stats;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
@@ -82,7 +82,7 @@ fn unix_millis_to_jiff_timestamp(millis: u64, fallback: Timestamp) -> Timestamp
}
}
fn to_madmin_scanner_metrics(metrics: rustfs_scanner_metrics::metrics::ScannerMetricsReport) -> MadminScannerMetrics {
fn to_madmin_scanner_metrics(metrics: rustfs_scanner_contracts::metrics::ScannerMetricsReport) -> MadminScannerMetrics {
MadminScannerMetrics {
collected_at: metrics.collected_at,
current_cycle: metrics.current_cycle,
@@ -565,7 +565,7 @@ async fn collect_local_disks_metrics(disks: &HashSet<String>) -> HashMap<String,
mod test {
use super::*;
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
use rustfs_scanner_metrics::metrics::CurrentCycle;
use rustfs_scanner_contracts::metrics::CurrentCycle;
use serial_test::serial;
use std::time::Duration;
@@ -619,7 +619,7 @@ mod test {
#[test]
fn scanner_metrics_mapping_preserves_partial_source_status() {
let current_started = Utc::now() - chrono::Duration::seconds(5);
let scanner = to_madmin_scanner_metrics(rustfs_scanner_metrics::metrics::ScannerMetricsReport {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
current_cycle_active: true,
current_started: chrono_to_jiff_timestamp(current_started),
last_cycle_partial_source: "usage".to_string(),
@@ -628,7 +628,7 @@ mod test {
cycle_recovery_required_total: 2,
cycle_last_progress_age: 17,
leader_lease_without_progress: true,
partial_cycles_by_source: vec![rustfs_scanner_metrics::metrics::ScannerSourceCycleSnapshot {
partial_cycles_by_source: vec![rustfs_scanner_contracts::metrics::ScannerSourceCycleSnapshot {
source: "usage".to_string(),
cycles: 2,
}],
@@ -654,11 +654,11 @@ mod test {
#[tokio::test]
#[serial]
async fn collect_local_metrics_preserves_scanner_cycle_started_time() {
let previous_init_time = *rustfs_scanner_metrics::GLOBAL_INIT_TIME.read().await;
let previous_init_time = *rustfs_scanner_contracts::GLOBAL_INIT_TIME.read().await;
let previous_cycle = global_metrics().get_cycle().await;
let init_time = Utc::now() - chrono::Duration::hours(1);
let cycle_started = Utc::now() - chrono::Duration::seconds(5);
*rustfs_scanner_metrics::GLOBAL_INIT_TIME.write().await = Some(init_time);
*rustfs_scanner_contracts::GLOBAL_INIT_TIME.write().await = Some(init_time);
let cycle = CurrentCycle {
current: 0,
next: 1,
@@ -673,7 +673,7 @@ mod test {
.finish_scan_cycle_work_with_cycle(cycle_start, previous_cycle.clone().unwrap_or_default())
.await;
global_metrics().set_cycle(previous_cycle).await;
*rustfs_scanner_metrics::GLOBAL_INIT_TIME.write().await = previous_init_time;
*rustfs_scanner_contracts::GLOBAL_INIT_TIME.write().await = previous_init_time;
let encoded = rmp_serde::to_vec_named(&realtime).expect("realtime metrics should encode");
let decoded: RealtimeMetrics = rmp_serde::from_slice(&encoded).expect("realtime metrics should decode");
@@ -686,8 +686,8 @@ mod test {
#[test]
fn scanner_metrics_mapping_preserves_pacing_pressure() {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_metrics::metrics::ScannerMetricsReport {
pacing_pressure: rustfs_scanner_metrics::metrics::ScannerPacingPressureSnapshot {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
pacing_pressure: rustfs_scanner_contracts::metrics::ScannerPacingPressureSnapshot {
primary_pressure: "cycle_budget".to_string(),
current_queued_scans: 4,
current_active_scans: 2,
@@ -712,12 +712,12 @@ mod test {
#[test]
fn scanner_metrics_mapping_preserves_lifecycle_transition_status() {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_metrics::metrics::ScannerMetricsReport {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
current_cycle_lifecycle_expiry_actions: 2,
current_cycle_lifecycle_transition_actions: 3,
last_cycle_lifecycle_expiry_actions: 5,
last_cycle_lifecycle_transition_actions: 7,
lifecycle_expiry: rustfs_scanner_metrics::metrics::ScannerLifecycleExpirySnapshot {
lifecycle_expiry: rustfs_scanner_contracts::metrics::ScannerLifecycleExpirySnapshot {
current_queue_capacity: 16,
current_queued: 5,
current_active: 2,
@@ -729,7 +729,7 @@ mod test {
scanner_not_enqueued: 2,
delete_failed: 1,
},
lifecycle_transition: rustfs_scanner_metrics::metrics::ScannerLifecycleTransitionSnapshot {
lifecycle_transition: rustfs_scanner_contracts::metrics::ScannerLifecycleTransitionSnapshot {
current_queue_capacity: 16,
current_queued: 5,
current_active: 2,
@@ -778,10 +778,10 @@ mod test {
#[test]
fn scanner_metrics_mapping_preserves_maintenance_control_status() {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_metrics::metrics::ScannerMetricsReport {
maintenance_control: rustfs_scanner_metrics::metrics::ScannerMaintenanceControlSnapshot {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
maintenance_control: rustfs_scanner_contracts::metrics::ScannerMaintenanceControlSnapshot {
primary_control: "blocked_source".to_string(),
sources: vec![rustfs_scanner_metrics::metrics::ScannerMaintenanceSourceSnapshot {
sources: vec![rustfs_scanner_contracts::metrics::ScannerMaintenanceSourceSnapshot {
source: "lifecycle".to_string(),
state: "blocked".to_string(),
reason: "missed_work".to_string(),
@@ -815,8 +815,8 @@ mod test {
#[test]
fn scanner_metrics_mapping_preserves_usage_freshness_status() {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_metrics::metrics::ScannerMetricsReport {
usage_freshness: rustfs_scanner_metrics::metrics::ScannerUsageFreshnessSnapshot {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
usage_freshness: rustfs_scanner_contracts::metrics::ScannerUsageFreshnessSnapshot {
dirty_pending_buckets: 3,
last_dirty_mark_unix_secs: 10,
last_dirty_clear_unix_secs: 11,
@@ -857,7 +857,7 @@ mod test {
#[test]
fn scanner_metrics_mapping_preserves_distributed_status_fields() {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_metrics::metrics::ScannerMetricsReport {
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
active_scan_paths: 2,
oldest_active_path_age_seconds: 45,
active_paths: vec!["disk-a/bucket-a".to_string(), "disk-b/bucket-b".to_string()],
@@ -913,7 +913,7 @@ mod test {
cycle_max_directories: 38,
bitrot_cycle_enabled: true,
bitrot_cycle_seconds: 39.0,
scan_checkpoint: Some(rustfs_scanner_metrics::metrics::ScannerCheckpointReport {
scan_checkpoint: Some(rustfs_scanner_contracts::metrics::ScannerCheckpointReport {
version: 1,
resume_after: "bucket-a/prefix-a".to_string(),
reason: "directories".to_string(),
@@ -923,7 +923,7 @@ mod test {
scan_checkpoint_cleared: 41,
scan_checkpoint_ignored: 42,
scan_checkpoint_stale: 43,
source_work: vec![rustfs_scanner_metrics::metrics::ScannerSourceWorkSnapshot {
source_work: vec![rustfs_scanner_contracts::metrics::ScannerSourceWorkSnapshot {
source: "usage".to_string(),
checked: 44,
queued: 45,
@@ -932,7 +932,7 @@ mod test {
skipped: 48,
missed: 49,
}],
current_cycle_source_work: vec![rustfs_scanner_metrics::metrics::ScannerSourceWorkSnapshot {
current_cycle_source_work: vec![rustfs_scanner_contracts::metrics::ScannerSourceWorkSnapshot {
source: "lifecycle".to_string(),
checked: 50,
queued: 51,
@@ -941,7 +941,7 @@ mod test {
skipped: 54,
missed: 55,
}],
last_cycle_source_work: vec![rustfs_scanner_metrics::metrics::ScannerSourceWorkSnapshot {
last_cycle_source_work: vec![rustfs_scanner_contracts::metrics::ScannerSourceWorkSnapshot {
source: "heal".to_string(),
checked: 56,
queued: 57,
@@ -950,7 +950,7 @@ mod test {
skipped: 60,
missed: 61,
}],
replication_repair: vec![rustfs_scanner_metrics::metrics::ScannerReplicationRepairSnapshot {
replication_repair: vec![rustfs_scanner_contracts::metrics::ScannerReplicationRepairSnapshot {
source: "bucket_replication".to_string(),
kind: "object".to_string(),
scanner_role: "repair_admission".to_string(),
@@ -962,7 +962,7 @@ mod test {
skipped: 66,
missed: 67,
}],
current_cycle_replication_repair: vec![rustfs_scanner_metrics::metrics::ScannerReplicationRepairSnapshot {
current_cycle_replication_repair: vec![rustfs_scanner_contracts::metrics::ScannerReplicationRepairSnapshot {
source: "bucket_replication".to_string(),
kind: "delete_marker".to_string(),
scanner_role: "repair_admission".to_string(),
@@ -974,7 +974,7 @@ mod test {
skipped: 72,
missed: 73,
}],
last_cycle_replication_repair: vec![rustfs_scanner_metrics::metrics::ScannerReplicationRepairSnapshot {
last_cycle_replication_repair: vec![rustfs_scanner_contracts::metrics::ScannerReplicationRepairSnapshot {
source: "site_replication".to_string(),
kind: "active_resync".to_string(),
scanner_role: "boundary_signal".to_string(),
+9 -446
View File
@@ -12,10 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::bucket::lifecycle::tier_last_day_stats::DailyAllTierStats;
use crate::cluster::rpc::{
PeerRestClient, ScannerPeerActivity, ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease, TierConfigReloadOutcome,
};
use crate::cluster::rpc::{PeerRestClient, ScannerPeerActivity, ScannerPublicationLease, TierConfigReloadOutcome};
use crate::diagnostics::admin_server_info::get_commit_id;
use crate::disk::DiskAPI;
use crate::error::{Error, Result};
@@ -53,7 +50,6 @@ const LOG_SUBSYSTEM_NOTIFICATION: &str = "notification";
const EVENT_NOTIFICATION_PEER_PROPAGATION: &str = "notification_peer_propagation";
const EVENT_NOTIFICATION_CAPABILITY_PROBE: &str = "notification_capability_probe";
const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const TIER_DAILY_STATS_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const TIER_CONFIG_RELOAD_RETRY_BASE: Duration = Duration::from_millis(100);
const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5);
const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10);
@@ -61,24 +57,18 @@ const REMOTE_VERSION_STATE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30);
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 2;
const TIER_DELETE_JOURNAL_POLICY_SUPPORTED_VERSION: u32 = 3;
const DECOMMISSION_TARGET_FENCE_POLICY_SUPPORTED_VERSION: u32 = 4;
type CrossPoolFencePolicyResult = Result<BTreeMap<String, Uuid>>;
fn cross_pool_fence_policy_results(
peer_epochs: BTreeMap<String, Uuid>,
minimum_version: u32,
) -> (CrossPoolFencePolicyResult, CrossPoolFencePolicyResult, CrossPoolFencePolicyResult) {
) -> (CrossPoolFencePolicyResult, CrossPoolFencePolicyResult) {
let journal_result = if minimum_version >= TIER_DELETE_JOURNAL_POLICY_SUPPORTED_VERSION {
Ok(peer_epochs.clone())
} else {
Err(Error::other("tier delete journal v6 policy capability version is unsupported"))
};
let decommission_target_fence_result = if minimum_version >= DECOMMISSION_TARGET_FENCE_POLICY_SUPPORTED_VERSION {
Ok(peer_epochs.clone())
} else {
Err(Error::other("decommission target fence policy capability version is unsupported"))
};
(Ok(peer_epochs), journal_result, decommission_target_fence_result)
(Ok(peer_epochs), journal_result)
}
#[derive(Clone, Debug)]
@@ -241,9 +231,6 @@ pub(crate) struct RemoteVersionStateFleetProofToken(FleetCapabilityProofToken);
#[derive(Clone, PartialEq, Eq)]
pub struct CrossPoolFenceFleetProofToken(FleetCapabilityProofToken);
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct DecommissionTargetFenceFleetProofToken(FleetCapabilityProofToken);
/// A point-in-time proof that every current storage member implements the v6
/// dispatch-manifest policy. It intentionally has no `Clone` implementation:
/// one acquisition authorizes one manifest construction attempt.
@@ -255,7 +242,6 @@ pub(crate) struct TierDeleteJournalFleetProofToken {
static REMOTE_VERSION_STATE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
static CROSS_POOL_FENCE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
static TIER_DELETE_JOURNAL_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
static DECOMMISSION_TARGET_FENCE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
static REMOTE_VERSION_STATE_PROBE_TOPOLOGY: OnceLock<String> = OnceLock::new();
fn cross_pool_fence_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
@@ -270,10 +256,6 @@ fn tier_delete_journal_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCap
TIER_DELETE_JOURNAL_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
}
fn decommission_target_fence_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
DECOMMISSION_TARGET_FENCE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
}
fn revoke_fleet_capability_proof_state(state: &mut FleetCapabilityProofState) {
if let Some(proof) = state.proof.take() {
proof.generation.revoke();
@@ -386,18 +368,6 @@ pub fn cross_pool_fence_fleet_proof_matches(proof: &CrossPoolFenceFleetProofToke
fleet_capability_proof_matches(cross_pool_fence_fleet_proof_slot(), &proof.0)
}
pub(crate) fn acquire_decommission_target_fence_fleet_proof() -> Option<DecommissionTargetFenceFleetProofToken> {
let expected_topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get()?;
let state = decommission_target_fence_fleet_proof_slot()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
acquire_fleet_capability_proof_from(&state, expected_topology, Instant::now()).map(DecommissionTargetFenceFleetProofToken)
}
pub(crate) fn decommission_target_fence_fleet_proof_matches(proof: &DecommissionTargetFenceFleetProofToken) -> bool {
fleet_capability_proof_matches(decommission_target_fence_fleet_proof_slot(), &proof.0)
}
pub(crate) fn acquire_tier_delete_journal_fleet_proof() -> Option<TierDeleteJournalFleetProofToken> {
let expected_topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get()?;
let state = tier_delete_journal_fleet_proof_slot()
@@ -498,19 +468,6 @@ pub(crate) fn install_cross_pool_fence_fleet_proof_for_test() {
journal_state.topology_conflict = false;
journal_state.draining_generation = None;
journal_state.proof = proof.as_ref().map(FleetCapabilityProof::with_fresh_generation);
drop(journal_state);
let mut decommission_state = decommission_target_fence_fleet_proof_slot()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
debug_assert!(
decommission_state
.proof
.as_ref()
.is_none_or(|current| current.generation.is_drained())
);
decommission_state.topology_conflict = false;
decommission_state.draining_generation = None;
decommission_state.proof = proof.as_ref().map(FleetCapabilityProof::with_fresh_generation);
}
#[cfg(test)]
@@ -519,8 +476,6 @@ pub(crate) struct CrossPoolFenceFleetProofGuard {
previous_topology_conflict: bool,
previous_journal_proof: Option<FleetCapabilityProof>,
previous_journal_topology_conflict: bool,
previous_decommission_proof: Option<FleetCapabilityProof>,
previous_decommission_topology_conflict: bool,
}
#[cfg(test)]
@@ -547,17 +502,6 @@ impl Drop for CrossPoolFenceFleetProofGuard {
.map(FleetCapabilityProof::with_fresh_generation);
journal_state.draining_generation = None;
journal_state.topology_conflict = self.previous_journal_topology_conflict;
drop(journal_state);
let mut decommission_state = decommission_target_fence_fleet_proof_slot()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
decommission_state.proof = self
.previous_decommission_proof
.take()
.as_ref()
.map(FleetCapabilityProof::with_fresh_generation);
decommission_state.draining_generation = None;
decommission_state.topology_conflict = self.previous_decommission_topology_conflict;
}
}
@@ -571,16 +515,11 @@ pub(crate) fn without_cross_pool_fence_fleet_proof_for_test() -> CrossPoolFenceF
let mut journal_state = tier_delete_journal_fleet_proof_slot()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut decommission_state = decommission_target_fence_fleet_proof_slot()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let guard = CrossPoolFenceFleetProofGuard {
previous_proof: state.proof.clone(),
previous_topology_conflict: state.topology_conflict,
previous_journal_proof: journal_state.proof.clone(),
previous_journal_topology_conflict: journal_state.topology_conflict,
previous_decommission_proof: decommission_state.proof.clone(),
previous_decommission_topology_conflict: decommission_state.topology_conflict,
};
if let Some(proof) = state.proof.take() {
proof.generation.revoke();
@@ -596,49 +535,6 @@ pub(crate) fn without_cross_pool_fence_fleet_proof_for_test() -> CrossPoolFenceF
}
}
journal_state.topology_conflict = true;
if let Some(proof) = decommission_state.proof.take() {
proof.generation.revoke();
if !proof.generation.is_drained() {
decommission_state.draining_generation = Some(proof.generation);
}
}
decommission_state.topology_conflict = true;
guard
}
#[cfg(test)]
pub(crate) struct DecommissionTargetFenceFleetProofGuard {
previous_proof: Option<FleetCapabilityProof>,
previous_topology_conflict: bool,
}
#[cfg(test)]
impl Drop for DecommissionTargetFenceFleetProofGuard {
fn drop(&mut self) {
let mut state = decommission_target_fence_fleet_proof_slot()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.proof = self
.previous_proof
.take()
.as_ref()
.map(FleetCapabilityProof::with_fresh_generation);
state.draining_generation = None;
state.topology_conflict = self.previous_topology_conflict;
}
}
#[cfg(test)]
pub(crate) fn without_decommission_target_fence_fleet_proof_for_test() -> DecommissionTargetFenceFleetProofGuard {
let mut state = decommission_target_fence_fleet_proof_slot()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let guard = DecommissionTargetFenceFleetProofGuard {
previous_proof: state.proof.clone(),
previous_topology_conflict: state.topology_conflict,
};
revoke_fleet_capability_proof_state(&mut state);
state.topology_conflict = true;
guard
}
@@ -677,15 +573,6 @@ pub fn rotate_cross_pool_fence_fleet_proof_for_test() -> bool {
if journal_state.draining_generation.is_none() {
journal_state.proof = Some(proof.with_fresh_generation());
}
drop(journal_state);
let mut decommission_state = decommission_target_fence_fleet_proof_slot()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
decommission_state.topology_conflict = false;
revoke_fleet_capability_proof_state(&mut decommission_state);
if decommission_state.draining_generation.is_none() {
decommission_state.proof = Some(proof.with_fresh_generation());
}
true
}
@@ -765,7 +652,6 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
remote_version_state_fleet_proof_slot(),
cross_pool_fence_fleet_proof_slot(),
tier_delete_journal_fleet_proof_slot(),
decommission_target_fence_fleet_proof_slot(),
] {
mark_fleet_capability_topology_conflict(slot);
}
@@ -798,15 +684,11 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
.unwrap_or_else(|_| Err(Error::other("cross-pool fence fleet capability probe timed out"))),
None => Err(Error::other("cross-pool fence fleet capability notification system is unavailable")),
};
let (fence_result, journal_result, decommission_target_fence_result) = match fence_probe {
let (fence_result, journal_result) = match fence_probe {
Ok((peer_epochs, minimum_version)) => cross_pool_fence_policy_results(peer_epochs, minimum_version),
Err(err) => {
let message = err.to_string();
(
Err(Error::other(message.clone())),
Err(Error::other(message.clone())),
Err(Error::other(message)),
)
(Err(Error::other(message.clone())), Err(Error::other(message)))
}
};
let topology_conflict = remote_version_state_fleet_proof_slot()
@@ -817,7 +699,6 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
revoke_fleet_capability_proof(remote_version_state_fleet_proof_slot());
revoke_fleet_capability_proof(cross_pool_fence_fleet_proof_slot());
revoke_fleet_capability_proof(tier_delete_journal_fleet_proof_slot());
revoke_fleet_capability_proof(decommission_target_fence_fleet_proof_slot());
} else if let Some(err) = publish_fleet_capability_probe_result(
remote_version_state_fleet_proof_slot(),
&topology_fingerprint,
@@ -862,24 +743,6 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
"notification capability probe"
);
}
if !topology_conflict
&& let Some(err) = publish_fleet_capability_probe_result(
decommission_target_fence_fleet_proof_slot(),
&topology_fingerprint,
decommission_target_fence_result,
Instant::now(),
)
{
debug!(
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
capability = "decommission_target_fence_v2",
state = "failed_closed",
error = %err,
"notification capability probe"
);
}
sleep(REMOTE_VERSION_STATE_PROBE_INTERVAL).await;
}
});
@@ -971,126 +834,12 @@ impl NotificationSys {
// A single-node deployment has no remote member to lower the local
// policy version advertised by this binary.
if minimum_version == u32::MAX {
minimum_version = DECOMMISSION_TARGET_FENCE_POLICY_SUPPORTED_VERSION;
minimum_version = TIER_DELETE_JOURNAL_POLICY_SUPPORTED_VERSION;
}
Ok((peer_epochs, minimum_version))
}
}
/// Rolling tier activity summed over every cluster member that answered, with
/// the reporting coverage behind the sum.
///
/// The coverage is part of the result rather than a log line: a sum over a
/// subset of the cluster is not a cluster total, and a caller that renders it
/// as one is the defect this type exists to prevent.
pub struct ClusterTierDailyStats {
pub stats: DailyAllTierStats,
/// Members whose rolling day is included, always at least this node.
pub nodes_reporting: usize,
/// Members this deployment expects to hear from, including this node.
pub nodes_expected: usize,
/// Members that could not be asked, could not answer, or answered with a
/// ring this build refuses to merge. Sorted, and named by grid host.
pub unavailable_nodes: Vec<String>,
}
impl ClusterTierDailyStats {
pub fn is_complete(&self) -> bool {
self.unavailable_nodes.is_empty() && self.nodes_reporting == self.nodes_expected
}
}
/// Fold one member's rolling day into the running cluster total.
///
/// Merging (rather than adding totals) ages each member's ring to the newer
/// clock first, so a member that stopped transitioning yesterday contributes
/// only the hours still inside the rolling day.
fn merge_tier_daily_stats(into: &mut DailyAllTierStats, from: DailyAllTierStats) {
for (tier, stats) in from {
match into.remove(&tier) {
Some(existing) => {
into.insert(tier, existing.merge(stats));
}
None => {
into.insert(tier, stats);
}
}
}
}
impl NotificationSys {
/// Sum this node's rolling tier activity with every reachable peer's.
///
/// Each node records only the transitions it completed itself, so the sum
/// is a cluster total and a retried transition is counted once, by the
/// node that finally committed it. Peers are probed concurrently under a
/// per-peer deadline so one black-holed member cannot hold the admin
/// request open.
pub async fn tier_daily_stats(&self, local: DailyAllTierStats) -> ClusterTierDailyStats {
let mut stats = local;
let nodes_expected = self.peer_clients.len() + 1;
let mut nodes_reporting = 1;
let mut unavailable_nodes = Vec::new();
let mut probes = Vec::with_capacity(self.peer_clients.len());
for (idx, client) in self.peer_clients.iter().enumerate() {
let host = self.tier_daily_stats_peer_host(idx, client.as_ref());
probes.push(async move {
let Some(client) = client.as_ref() else {
return (host, Err(Error::other("peer is not reachable")));
};
// The peer is already named by the caller's `peer` log field
// and by `unavailable_nodes`, so the deadline is reported as
// the typed variant rather than a formatted fragment.
let result = timeout(TIER_DAILY_STATS_PROBE_TIMEOUT, client.tier_daily_stats())
.await
.unwrap_or(Err(Error::Timeout));
(host, result)
});
}
for (host, result) in join_all(probes).await {
match result {
Ok(peer_stats) => {
nodes_reporting += 1;
merge_tier_daily_stats(&mut stats, peer_stats);
}
Err(err) => {
warn!(
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
peer = host,
error = %err,
"tier daily stats peer did not report"
);
unavailable_nodes.push(host);
}
}
}
unavailable_nodes.sort();
ClusterTierDailyStats {
stats,
nodes_reporting,
nodes_expected,
unavailable_nodes,
}
}
/// Name a peer slot even when no client was ever built for it, so an
/// unreachable member is reported by host instead of disappearing.
fn tier_daily_stats_peer_host(&self, idx: usize, client: Option<&PeerRestClient>) -> String {
if let Some(client) = client {
return client.grid_host.clone();
}
self.peer_topology_hosts
.get(idx)
.cloned()
.unwrap_or_else(|| format!("peer[{idx}]"))
}
}
pub struct NotificationPeerErr {
pub host: String,
pub err: Option<Error>,
@@ -2082,32 +1831,6 @@ impl NotificationSys {
Ok(generations)
}
pub async fn scanner_dirty_usage_snapshots(&self) -> Result<Vec<(String, ScannerPeerDirtyUsageSnapshot)>> {
if self.peer_clients.is_empty() {
return Err(Error::other("scanner dirty usage snapshot probe has no remote peers"));
}
if self.all_peer_clients.len() != self.peer_clients.len() + 1 {
return Err(Error::other("scanner dirty usage snapshot peer topology is incomplete"));
}
let mut futures = Vec::with_capacity(self.peer_clients.len());
for client in self.peer_clients.iter().cloned() {
futures.push(async move {
let client = client.ok_or_else(|| Error::other("scanner dirty usage snapshot peer is unreachable"))?;
let host = client.grid_host.clone();
scanner_dirty_usage_snapshot_with_retry(&client, &host)
.await
.map(|snapshot| (host, snapshot))
});
}
let mut snapshots = Vec::with_capacity(futures.len());
for result in join_all(futures).await {
snapshots.push(result?);
}
Ok(snapshots)
}
pub async fn acknowledge_scanner_dirty_usage(&self, acknowledgements: Vec<(String, String, u64)>) -> Result<bool> {
let mut by_host = HashMap::with_capacity(acknowledgements.len());
for (host, instance_id, generation) in acknowledgements {
@@ -2619,54 +2342,6 @@ async fn scanner_activity_with_retry(client: &PeerRestClient, host: &str) -> Res
}
}
async fn scanner_dirty_usage_snapshot_with_retry(client: &PeerRestClient, host: &str) -> Result<ScannerPeerDirtyUsageSnapshot> {
let first = timeout(SCANNER_ACTIVITY_PROBE_TIMEOUT, client.scanner_dirty_usage_snapshot()).await;
let should_retry = match &first {
Ok(Ok(_)) => false,
Ok(Err(err)) => scanner_activity_should_retry(Some(err), false),
Err(_) => scanner_activity_should_retry(None, true),
};
match first {
Ok(Ok(snapshot)) => return Ok(snapshot),
Ok(Err(err)) if !should_retry => return Err(err),
Ok(Err(err)) => {
debug!(
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
result = "retrying",
capability = "scanner_dirty_usage_snapshot",
peer = host,
error = %err,
"notification capability probe retrying"
);
client.prepare_retry().await;
}
Err(_) => {
debug!(
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
result = "retrying",
capability = "scanner_dirty_usage_snapshot",
peer = host,
timeout = ?SCANNER_ACTIVITY_PROBE_TIMEOUT,
"notification capability probe retrying"
);
client.prepare_retry().await;
}
}
match timeout(SCANNER_ACTIVITY_PROBE_TIMEOUT, client.scanner_dirty_usage_snapshot()).await {
Ok(result) => result,
Err(_) => {
client.evict_connection().await;
Err(Error::Timeout)
}
}
}
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
async fn call_peer_with_timeout<F, Fut>(
timeout_dur: Duration,
@@ -3127,83 +2802,17 @@ fn aggregate_scanner_dirty_usage_acknowledgement_results(
#[cfg(test)]
mod tests {
use super::*;
use crate::bucket::lifecycle::tier_last_day_stats::LastDayTierStats;
use rustfs_data_usage::TierStats;
fn ring(total_size: u64) -> LastDayTierStats {
let mut stats = LastDayTierStats::default();
stats.add_stats(TierStats {
total_size,
num_versions: 1,
num_objects: 1,
});
stats
}
#[test]
fn merging_peer_rings_sums_each_tier_without_double_counting() {
let mut cluster = DailyAllTierStats::from([("WARM".to_string(), ring(10))]);
merge_tier_daily_stats(
&mut cluster,
DailyAllTierStats::from([("WARM".to_string(), ring(20)), ("COLD".to_string(), ring(5))]),
);
assert_eq!(
cluster.get("WARM").expect("the shared tier must survive the merge").total(),
TierStats {
total_size: 30,
num_versions: 2,
num_objects: 2,
},
"both nodes' completions belong in the cluster total"
);
assert_eq!(
cluster.get("COLD").expect("a tier only one node saw must be kept").total(),
TierStats {
total_size: 5,
num_versions: 1,
num_objects: 1,
}
);
}
#[test]
fn a_single_member_result_is_complete_and_a_missing_peer_is_not() {
let complete = ClusterTierDailyStats {
stats: DailyAllTierStats::new(),
nodes_reporting: 1,
nodes_expected: 1,
unavailable_nodes: Vec::new(),
};
assert!(complete.is_complete(), "a single-member deployment reports its whole cluster");
let partial = ClusterTierDailyStats {
stats: DailyAllTierStats::new(),
nodes_reporting: 1,
nodes_expected: 2,
unavailable_nodes: vec!["node-b:9000".to_string()],
};
assert!(!partial.is_complete(), "a silent member must make the sum partial");
}
#[test]
fn cross_pool_policy_versions_authorize_only_their_supported_protocols() {
fn cross_pool_v2_remains_generic_but_cannot_authorize_v6_journal() {
let peers = BTreeMap::from([("node-b:9000".to_string(), Uuid::new_v4())]);
let (generic_v2, journal_v2, decommission_v2) = cross_pool_fence_policy_results(peers.clone(), 2);
let (generic_v2, journal_v2) = cross_pool_fence_policy_results(peers.clone(), 2);
assert!(generic_v2.is_ok(), "v2 remains valid for existing cross-pool fencing");
assert!(journal_v2.is_err(), "a mixed v2/v3 fleet must fail closed for journal-v6 deletion");
assert!(decommission_v2.is_err(), "v2 cannot authorize the sticky per-target decommission fence");
let (generic_v3, journal_v3, decommission_v3) = cross_pool_fence_policy_results(peers.clone(), 3);
let (generic_v3, journal_v3) = cross_pool_fence_policy_results(peers, 3);
assert!(generic_v3.is_ok());
assert!(journal_v3.is_ok(), "an all-v3 fleet may authorize journal-v6 deletion");
assert!(decommission_v3.is_err(), "v3 members do not understand the per-target decommission fence");
let (generic_v4, journal_v4, decommission_v4) = cross_pool_fence_policy_results(peers, 4);
assert!(generic_v4.is_ok());
assert!(journal_v4.is_ok());
assert!(decommission_v4.is_ok(), "an all-v4 fleet may create sticky per-target reservations");
}
#[test]
@@ -3764,52 +3373,6 @@ mod tests {
assert!(err.to_string().contains("peer topology is incomplete"));
}
#[tokio::test]
async fn scanner_dirty_usage_snapshot_probe_rejects_unusable_peer_topologies() {
let unreachable = NotificationSys {
peer_clients: vec![None],
all_peer_clients: vec![None, None],
peer_topology_hosts: vec!["node-a:9000".to_string()],
peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())],
tier_config_reload_workers: Default::default(),
};
let err = unreachable
.scanner_dirty_usage_snapshots()
.await
.expect_err("an unreachable peer must invalidate the distributed dirty usage snapshot");
assert!(err.to_string().contains("peer is unreachable"));
let empty = NotificationSys {
peer_clients: Vec::new(),
all_peer_clients: Vec::new(),
peer_topology_hosts: Vec::new(),
peer_admin_caches: Vec::new(),
tier_config_reload_workers: Default::default(),
};
let err = empty
.scanner_dirty_usage_snapshots()
.await
.expect_err("an empty peer set must not produce a distributed dirty usage snapshot");
assert!(err.to_string().contains("no remote peers"));
let client = PeerRestClient::new(
"127.0.0.1:9000".to_string().try_into().expect("peer host should parse"),
"http://127.0.0.1:9000".to_string(),
);
let incomplete = NotificationSys {
peer_clients: vec![Some(client)],
all_peer_clients: vec![None],
peer_topology_hosts: vec!["127.0.0.1:9000".to_string()],
peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())],
tier_config_reload_workers: Default::default(),
};
let err = incomplete
.scanner_dirty_usage_snapshots()
.await
.expect_err("an incomplete topology must not produce a distributed dirty usage snapshot");
assert!(err.to_string().contains("peer topology is incomplete"));
}
#[tokio::test]
async fn server_info_no_client_slot_uses_topology_host_without_counting_rpc_failure() {
let sys = NotificationSys {
@@ -83,6 +83,7 @@ pub async fn test_store_with_persisted_rebalance_meta(
decommission_cancelers: tokio::sync::RwLock::new(vec![None]),
start_gate: tokio::sync::Mutex::new(()),
pool_meta_save_gate: tokio::sync::Mutex::default(),
decommission_capacity_entry_gate: tokio::sync::Mutex::default(),
ctx,
bucket_fence_registry: std::sync::Arc::default(),
});
@@ -232,6 +233,7 @@ async fn test_pool_stores_with_contexts(
decommission_cancelers: tokio::sync::RwLock::new(vec![None; pool_count]),
start_gate: tokio::sync::Mutex::new(()),
pool_meta_save_gate: tokio::sync::Mutex::new(pool_meta_write_state.independent_clone_for_test()),
decommission_capacity_entry_gate: tokio::sync::Mutex::default(),
ctx: store_ctx,
bucket_fence_registry: std::sync::Arc::default(),
})
@@ -3009,6 +3009,7 @@ fn test_store_with_rebalance_meta(meta: RebalanceMeta) -> Arc<crate::store::ECSt
decommission_cancelers: tokio::sync::RwLock::new(Vec::new()),
start_gate: tokio::sync::Mutex::new(()),
pool_meta_save_gate: tokio::sync::Mutex::default(),
decommission_capacity_entry_gate: tokio::sync::Mutex::default(),
ctx: crate::runtime::instance::bootstrap_ctx(),
bucket_fence_registry: std::sync::Arc::default(),
})
File diff suppressed because it is too large Load Diff
+9 -187
View File
@@ -18,203 +18,25 @@
#![allow(unused_must_use)]
#![allow(clippy::all)]
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default, Clone)]
#[serde(default)]
pub struct TierServicePrincipalAuth {
#[serde(rename = "TenantID", alias = "tenantID", alias = "tenant_id")]
pub tenant_id: String,
#[serde(rename = "ClientID", alias = "clientID", alias = "client_id")]
pub client_id: String,
#[serde(rename = "ClientSecret", alias = "clientSecret", alias = "client_secret")]
pub client_secret: String,
}
impl TierServicePrincipalAuth {
pub(crate) fn is_empty(&self) -> bool {
self.tenant_id.is_empty() && self.client_id.is_empty() && self.client_secret.is_empty()
}
}
impl std::fmt::Debug for TierServicePrincipalAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TierServicePrincipalAuth")
.field("tenant_id", &self.tenant_id)
.field("client_id", &self.client_id)
.field("client_secret", &"REDACTED")
.finish()
}
}
#[derive(Serialize, Deserialize, Default, Clone)]
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
#[serde(default)]
pub struct TierCreds {
#[serde(rename = "access", alias = "accessKey")]
#[serde(rename = "accessKey")]
pub access_key: String,
#[serde(rename = "secret", alias = "secretKey")]
#[serde(rename = "secretKey")]
pub secret_key: String,
#[serde(rename = "awsrole", alias = "awsRole")]
#[serde(rename = "awsRole")]
pub aws_role: bool,
#[serde(rename = "awsroleWebIdentity", alias = "awsRoleWebIdentityTokenFile")]
#[serde(rename = "awsRoleWebIdentityTokenFile")]
pub aws_role_web_identity_token_file: String,
#[serde(rename = "awsroleARN", alias = "awsRoleArn", alias = "awsRoleARN")]
#[serde(rename = "awsRoleArn")]
pub aws_role_arn: String,
#[serde(rename = "azSP", alias = "azsp", skip_serializing_if = "TierServicePrincipalAuth::is_empty")]
pub azure_service_principal: TierServicePrincipalAuth,
//azsp: ServicePrincipalAuth,
#[serde(
rename = "creds",
alias = "credsJson",
alias = "credsJSON",
alias = "creds_json",
default,
skip_serializing_if = "Vec::is_empty",
with = "base64_bytes"
)]
//#[serde(rename = "credsJson")]
pub creds_json: Vec<u8>,
}
impl std::fmt::Debug for TierCreds {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TierCreds")
.field("access_key", &self.access_key)
.field("secret_key", &"REDACTED")
.field("aws_role", &self.aws_role)
.field(
"aws_role_web_identity_token_file",
&(!self.aws_role_web_identity_token_file.is_empty()).then_some("REDACTED"),
)
.field("aws_role_arn", &self.aws_role_arn)
.field("azure_service_principal", &self.azure_service_principal)
.field("creds_json", &(!self.creds_json.is_empty()).then_some("REDACTED"))
.finish()
}
}
mod base64_bytes {
use super::*;
pub(super) fn serialize<S>(value: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&base64_simd::STANDARD.encode_to_string(value))
}
pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum EncodedBytes {
Base64(String),
Legacy(Vec<u8>),
}
match EncodedBytes::deserialize(deserializer)? {
EncodedBytes::Base64(value) => base64_simd::STANDARD
.decode_to_vec(value.as_bytes())
.or_else(|_| base64_simd::STANDARD_NO_PAD.decode_to_vec(value.as_bytes()))
.or_else(|_| base64_simd::URL_SAFE.decode_to_vec(value.as_bytes()))
.or_else(|_| base64_simd::URL_SAFE_NO_PAD.decode_to_vec(value.as_bytes()))
.map_err(de::Error::custom),
EncodedBytes::Legacy(value) => Ok(value),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tier_creds_accepts_madmin_wire_names_and_base64_gcs_json() {
let service_account = r#"{"type":"service_account","project_id":"tier-🚀x"}"#.as_bytes();
let encoded = "eyJ0eXBlIjoic2VydmljZV9hY2NvdW50IiwicHJvamVjdF9pZCI6InRpZXIt8J+agHgifQ==";
let creds: TierCreds = serde_json::from_value(serde_json::json!({
"access": "access",
"secret": "secret",
"awsrole": false,
"creds": encoded,
}))
.expect("madmin tier credentials should decode");
assert_eq!(creds.access_key, "access");
assert_eq!(creds.secret_key, "secret");
assert_eq!(creds.creds_json.as_slice(), &service_account[..]);
let wire = serde_json::to_value(&creds).expect("madmin tier credentials should encode");
assert_eq!(wire["access"], "access");
assert_eq!(wire["secret"], "secret");
assert_eq!(wire["creds"], encoded);
assert!(wire.get("accessKey").is_none());
assert!(wire.get("secretKey").is_none());
let legacy: TierCreds = serde_json::from_value(serde_json::json!({
"accessKey": "legacy-access",
"secretKey": "legacy-secret",
"credsJson": service_account,
}))
.expect("the former RustFS field names and byte-array encoding should remain readable");
assert_eq!(legacy.access_key, "legacy-access");
assert_eq!(legacy.secret_key, "legacy-secret");
assert_eq!(legacy.creds_json.as_slice(), &service_account[..]);
}
#[test]
fn tier_creds_accepts_all_supported_base64_alphabets_and_padding_modes() {
let service_account = r#"{"type":"service_account","project_id":"tier-🚀"}"#.as_bytes();
for encoder in [
base64_simd::STANDARD,
base64_simd::STANDARD_NO_PAD,
base64_simd::URL_SAFE,
base64_simd::URL_SAFE_NO_PAD,
] {
let encoded = encoder.encode_to_string(service_account);
let creds: TierCreds = serde_json::from_value(serde_json::json!({ "creds": encoded }))
.expect("all supported madmin base64 forms should decode");
assert_eq!(creds.creds_json, service_account);
}
}
#[test]
fn tier_creds_debug_redacts_secret_payloads() {
let creds = TierCreds {
access_key: "access".to_string(),
secret_key: "tier-secret-value".to_string(),
aws_role_web_identity_token_file: "/var/run/private-token".to_string(),
creds_json: br#"{"private_key":"gcs-private-key-value"}"#.to_vec(),
..Default::default()
};
let rendered = format!("{creds:?}");
assert!(!rendered.contains("tier-secret-value"));
assert!(!rendered.contains("/var/run/private-token"));
assert!(!rendered.contains("gcs-private-key-value"));
}
#[test]
fn tier_creds_accepts_canonical_madmin_azure_service_principal_wire_shape() {
let creds: TierCreds = serde_json::from_value(serde_json::json!({
"azSP": {
"TenantID": "tenant",
"ClientID": "client",
"ClientSecret": "service-principal-secret"
}
}))
.expect("canonical madmin azure service principal credentials should decode");
assert_eq!(creds.azure_service_principal.tenant_id, "tenant");
assert_eq!(creds.azure_service_principal.client_id, "client");
assert_eq!(creds.azure_service_principal.client_secret, "service-principal-secret");
let wire = serde_json::to_value(&creds).expect("canonical madmin credentials should encode");
assert_eq!(wire["azSP"]["TenantID"], "tenant");
assert_eq!(wire["azSP"]["ClientID"], "client");
assert_eq!(wire["azSP"]["ClientSecret"], "service-principal-secret");
assert!(!format!("{creds:?}").contains("service-principal-secret"));
}
}
+124 -296
View File
@@ -38,11 +38,11 @@ const WASABI_ALTERNATIVE_ENDPOINTS: &[(&str, &str)] = &[
("eu-south-1", "https://s3.it-1.wasabisys.com"),
];
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub enum TierType {
#[default]
Unsupported,
#[serde(rename = "s3", alias = "S3")]
#[serde(rename = "s3")]
S3,
#[serde(rename = "wasabi")]
Wasabi,
@@ -58,7 +58,7 @@ pub enum TierType {
Huaweicloud,
#[serde(rename = "azure")]
Azure,
#[serde(rename = "gcs", alias = "GCS")]
#[serde(rename = "gcs")]
GCS,
#[serde(rename = "r2")]
R2,
@@ -138,18 +138,16 @@ impl TierType {
}
}
pub(crate) const TIER_CREDENTIAL_REDACTED: &str = "REDACTED";
#[derive(Default, Serialize, Deserialize)]
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct TierConfig {
#[serde(skip)]
pub version: String,
#[serde(rename = "type", alias = "Type")]
#[serde(rename = "type")]
pub tier_type: TierType,
#[serde(rename = "Name", alias = "name", skip_serializing)]
#[serde(skip)]
pub name: String,
#[serde(rename = "s3", alias = "S3", skip_serializing_if = "Option::is_none")]
#[serde(rename = "s3", skip_serializing_if = "Option::is_none")]
pub s3: Option<TierS3>,
#[serde(rename = "wasabi", skip_serializing_if = "Option::is_none")]
pub wasabi: Option<TierWasabi>,
@@ -161,7 +159,7 @@ pub struct TierConfig {
pub huaweicloud: Option<TierHuaweicloud>,
#[serde(rename = "azure", skip_serializing_if = "Option::is_none")]
pub azure: Option<TierAzure>,
#[serde(rename = "gcs", alias = "GCS", skip_serializing_if = "Option::is_none")]
#[serde(rename = "gcs", skip_serializing_if = "Option::is_none")]
pub gcs: Option<TierGCS>,
#[serde(rename = "r2", skip_serializing_if = "Option::is_none")]
pub r2: Option<TierR2>,
@@ -172,91 +170,109 @@ pub struct TierConfig {
}
impl Clone for TierConfig {
fn clone(&self) -> Self {
self.redacted()
fn clone(&self) -> TierConfig {
let mut s3 = None;
let mut wasabi = None;
let mut r = None;
let mut compatible_backend = None;
let mut aliyun = None;
let mut tencent = None;
let mut huaweicloud = None;
let mut azure = None;
let mut gcs = None;
let mut r2 = None;
match self.tier_type {
TierType::S3 => {
if let Some(s3_) = self.s3.as_ref() {
let mut s3_clone = s3_.clone();
s3_clone.secret_key = "REDACTED".to_string();
s3 = Some(s3_clone);
}
}
TierType::Wasabi => {
if let Some(wasabi_) = self.wasabi.as_ref() {
let mut wasabi_clone = wasabi_.clone();
wasabi_clone.secret_key = "REDACTED".to_string();
wasabi = Some(wasabi_clone);
}
}
TierType::RustFS => {
if let Some(r_) = self.rustfs.as_ref() {
let mut r_clone = r_.clone();
r_clone.secret_key = "REDACTED".to_string();
r = Some(r_clone);
}
}
TierType::MinIO => {
if let Some(compatible_backend_) = self.minio.as_ref() {
let mut compatible_backend_clone = compatible_backend_.clone();
compatible_backend_clone.secret_key = "REDACTED".to_string();
compatible_backend = Some(compatible_backend_clone);
}
}
TierType::Aliyun => {
if let Some(aliyun_) = self.aliyun.as_ref() {
let mut aliyun_clone = aliyun_.clone();
aliyun_clone.secret_key = "REDACTED".to_string();
aliyun = Some(aliyun_clone);
}
}
TierType::Tencent => {
if let Some(tencent_) = self.tencent.as_ref() {
let mut tencent_clone = tencent_.clone();
tencent_clone.secret_key = "REDACTED".to_string();
tencent = Some(tencent_clone);
}
}
TierType::Huaweicloud => {
if let Some(huaweicloud_) = self.huaweicloud.as_ref() {
let mut huaweicloud_clone = huaweicloud_.clone();
huaweicloud_clone.secret_key = "REDACTED".to_string();
huaweicloud = Some(huaweicloud_clone);
}
}
TierType::Azure => {
if let Some(azure_) = self.azure.as_ref() {
let mut azure_clone = azure_.clone();
azure_clone.secret_key = "REDACTED".to_string();
azure = Some(azure_clone);
}
}
TierType::GCS => {
if let Some(gcs_) = self.gcs.as_ref() {
let mut gcs_clone = gcs_.clone();
gcs_clone.creds = "REDACTED".to_string();
gcs = Some(gcs_clone);
}
}
TierType::R2 => {
if let Some(r2_) = self.r2.as_ref() {
let mut r2_clone = r2_.clone();
r2_clone.secret_key = "REDACTED".to_string();
r2 = Some(r2_clone);
}
}
_ => (),
}
TierConfig {
version: self.version.clone(),
tier_type: self.tier_type.clone(),
name: self.name.clone(),
s3,
wasabi,
rustfs: r,
minio: compatible_backend,
aliyun,
tencent,
huaweicloud,
azure,
gcs,
r2,
}
}
}
impl TierConfig {
pub(crate) fn redacted(&self) -> Self {
let mut redacted = Self {
version: self.version.clone(),
tier_type: self.tier_type.clone(),
name: self.name.clone(),
..Default::default()
};
match self.tier_type {
TierType::S3 => {
redacted.s3 = self.s3.clone().map(|mut backend| {
backend.secret_key = TIER_CREDENTIAL_REDACTED.to_string();
if !backend.aws_role_web_identity_token_file.is_empty() {
backend.aws_role_web_identity_token_file = TIER_CREDENTIAL_REDACTED.to_string();
}
backend
});
}
TierType::Wasabi => {
redacted.wasabi = self.wasabi.clone().map(|mut backend| {
backend.secret_key = TIER_CREDENTIAL_REDACTED.to_string();
backend
});
}
TierType::RustFS => {
redacted.rustfs = self.rustfs.clone().map(|mut backend| {
backend.secret_key = TIER_CREDENTIAL_REDACTED.to_string();
backend
});
}
TierType::MinIO => {
redacted.minio = self.minio.clone().map(|mut backend| {
backend.secret_key = TIER_CREDENTIAL_REDACTED.to_string();
backend
});
}
TierType::Aliyun => {
redacted.aliyun = self.aliyun.clone().map(|mut backend| {
backend.secret_key = TIER_CREDENTIAL_REDACTED.to_string();
backend
});
}
TierType::Tencent => {
redacted.tencent = self.tencent.clone().map(|mut backend| {
backend.secret_key = TIER_CREDENTIAL_REDACTED.to_string();
backend
});
}
TierType::Huaweicloud => {
redacted.huaweicloud = self.huaweicloud.clone().map(|mut backend| {
backend.secret_key = TIER_CREDENTIAL_REDACTED.to_string();
backend
});
}
TierType::Azure => {
redacted.azure = self.azure.clone().map(|mut backend| {
backend.secret_key = TIER_CREDENTIAL_REDACTED.to_string();
if !backend.sp_auth.client_secret.is_empty() {
backend.sp_auth.client_secret = TIER_CREDENTIAL_REDACTED.to_string();
}
backend
});
}
TierType::GCS => {
redacted.gcs = self.gcs.clone().map(|mut backend| {
backend.creds = TIER_CREDENTIAL_REDACTED.to_string();
backend
});
}
TierType::R2 => {
redacted.r2 = self.r2.clone().map(|mut backend| {
backend.secret_key = TIER_CREDENTIAL_REDACTED.to_string();
backend
});
}
TierType::Unsupported => {}
}
redacted
}
pub(crate) fn clone_with_credentials(&self) -> Self {
Self {
version: self.version.clone(),
@@ -356,61 +372,31 @@ impl TierConfig {
}
}
impl std::fmt::Debug for TierConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let redacted = self.redacted();
f.debug_struct("TierConfig")
.field("version", &redacted.version)
.field("tier_type", &redacted.tier_type)
.field("name", &redacted.name)
.field("s3", &redacted.s3)
.field("wasabi", &redacted.wasabi)
.field("aliyun", &redacted.aliyun)
.field("tencent", &redacted.tencent)
.field("huaweicloud", &redacted.huaweicloud)
.field("azure", &redacted.azure)
.field("gcs", &redacted.gcs)
.field("r2", &redacted.r2)
.field("rustfs", &redacted.rustfs)
.field("minio", &redacted.minio)
.finish()
}
}
//type S3Options = impl Fn(TierS3) -> Pin<Box<Result<()>>> + Send + Sync + 'static;
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
#[serde(default)]
pub struct TierS3 {
#[serde(alias = "Name")]
pub name: String,
#[serde(alias = "Endpoint")]
pub endpoint: String,
#[serde(rename = "accessKey", alias = "AccessKey")]
#[serde(rename = "accessKey")]
pub access_key: String,
#[serde(rename = "secretKey", alias = "SecretKey")]
#[serde(rename = "secretKey")]
pub secret_key: String,
#[serde(alias = "Bucket")]
pub bucket: String,
#[serde(alias = "Prefix")]
pub prefix: String,
#[serde(alias = "Region")]
pub region: String,
#[serde(rename = "storageClass", alias = "StorageClass")]
#[serde(rename = "storageClass")]
pub storage_class: String,
#[serde(rename = "AWSRole", alias = "awsRole", skip_serializing)]
#[serde(skip)]
pub aws_role: bool,
#[serde(
rename = "AWSRoleWebIdentityTokenFile",
alias = "awsRoleWebIdentityTokenFile",
skip_serializing
)]
#[serde(skip)]
pub aws_role_web_identity_token_file: String,
#[serde(rename = "AWSRoleARN", alias = "awsRoleARN", alias = "awsRoleArn", skip_serializing)]
#[serde(skip)]
pub aws_role_arn: String,
#[serde(rename = "AWSRoleSessionName", alias = "awsRoleSessionName", skip_serializing)]
#[serde(skip)]
pub aws_role_session_name: String,
#[serde(rename = "AWSRoleDurationSeconds", alias = "awsRoleDurationSeconds", skip_serializing)]
#[serde(skip)]
pub aws_role_duration_seconds: i32,
}
@@ -637,11 +623,8 @@ pub struct TierHuaweicloud {
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
#[serde(default)]
pub struct ServicePrincipalAuth {
#[serde(alias = "TenantID")]
pub tenant_id: String,
#[serde(alias = "ClientID")]
pub client_id: String,
#[serde(alias = "ClientSecret")]
pub client_secret: String,
}
@@ -657,9 +640,9 @@ pub struct TierAzure {
pub bucket: String,
pub prefix: String,
pub region: String,
#[serde(rename = "storageClass", alias = "StorageClass")]
#[serde(rename = "storageClass")]
pub storage_class: String,
#[serde(rename = "spAuth", alias = "SPAuth")]
#[serde(rename = "spAuth")]
pub sp_auth: ServicePrincipalAuth,
}
@@ -713,19 +696,14 @@ fn AzureStorageClass(sc string) func(az *TierAzure) error {
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
#[serde(default)]
pub struct TierGCS {
#[serde(alias = "Name")]
pub name: String,
#[serde(alias = "Endpoint")]
pub endpoint: String,
#[serde(rename = "creds", alias = "Creds")]
#[serde(rename = "creds")]
pub creds: String,
#[serde(alias = "Bucket")]
pub bucket: String,
#[serde(alias = "Prefix")]
pub prefix: String,
#[serde(alias = "Region")]
pub region: String,
#[serde(rename = "storageClass", alias = "StorageClass")]
#[serde(rename = "storageClass")]
pub storage_class: String,
}
@@ -747,43 +725,6 @@ pub struct TierR2 {
mod tests {
use super::*;
#[test]
fn s3_gcs_type_uppercase_aliases_preserve_lowercase_output() {
let s3: TierType = serde_json::from_str(r#""S3""#).expect("uppercase S3 wire value should decode");
let gcs: TierType = serde_json::from_str(r#""GCS""#).expect("uppercase GCS wire value should decode");
assert!(matches!(s3, TierType::S3));
assert!(matches!(gcs, TierType::GCS));
assert_eq!(serde_json::to_string(&s3).expect("S3 type should encode"), r#""s3""#);
assert_eq!(serde_json::to_string(&gcs).expect("GCS type should encode"), r#""gcs""#);
}
#[test]
fn azure_service_principal_accepts_canonical_madmin_field_names() {
for field in ["TenantID", "ClientID", "ClientSecret"] {
let mut sp_auth = serde_json::Map::new();
sp_auth.insert(field.to_string(), serde_json::Value::String("present".to_string()));
let config: TierConfig = serde_json::from_value(serde_json::json!({
"type": "azure",
"Name": "COLD-AZURE",
"azure": {
"name": "COLD-AZURE",
"endpoint": "https://azure.example.invalid",
"accessKey": "account",
"secretKey": "key",
"bucket": "archive",
"SPAuth": sp_auth
}
}))
.expect("mixed RustFS/madmin Azure payload should decode");
let sp_auth = &config.azure.expect("Azure payload should exist").sp_auth;
assert!(
!sp_auth.tenant_id.is_empty() || !sp_auth.client_id.is_empty() || !sp_auth.client_secret.is_empty(),
"canonical {field} must not be silently discarded"
);
}
}
fn wasabi_config() -> TierWasabi {
TierWasabi {
name: "COLD-WASABI".to_string(),
@@ -897,14 +838,9 @@ mod tests {
let config = TierConfig {
tier_type: TierType::Wasabi,
wasabi: Some(wasabi_config()),
rustfs: Some(TierRustFS {
access_key: "inactive-access".to_string(),
secret_key: "inactive-secret".to_string(),
..Default::default()
}),
..Default::default()
};
let redacted = config.redacted();
let redacted = config.clone();
assert_eq!(
redacted
.wasabi
@@ -913,46 +849,21 @@ mod tests {
.secret_key,
"REDACTED"
);
assert!(redacted.rustfs.is_none(), "the external view should retain only the active provider");
let cloned = config.clone();
assert_eq!(cloned.wasabi.expect("redacted Wasabi clone should remain").secret_key, "REDACTED");
assert!(cloned.rustfs.is_none(), "ordinary Clone must retain its redacted API semantics");
let preserved = config.clone_with_credentials();
assert_eq!(
preserved
config
.clone_with_credentials()
.wasabi
.as_ref()
.expect("credential-bearing Wasabi snapshot should remain")
.expect("credential-bearing Wasabi payload should remain")
.secret_key,
"secret"
);
assert_eq!(
preserved
.rustfs
.expect("credential-bearing snapshots should preserve inactive provider data")
.secret_key,
"inactive-secret"
);
let mut debug_config = wasabi_config();
debug_config.secret_key = "wasabi-debug-secret-value".to_string();
let debug = format!("{debug_config:?}");
assert!(debug.contains("REDACTED"));
assert!(!debug.contains("wasabi-debug-secret-value"));
let debug = format!(
"{:?}",
TierConfig {
tier_type: TierType::RustFS,
rustfs: Some(TierRustFS {
secret_key: "rustfs-debug-secret-value".to_string(),
..Default::default()
}),
..Default::default()
}
);
assert!(debug.contains("REDACTED"));
assert!(!debug.contains("rustfs-debug-secret-value"));
}
#[test]
@@ -983,7 +894,7 @@ mod tests {
assert_eq!(encoded, expected);
let decoded: TierConfig = serde_json::from_value(encoded).expect("Wasabi Admin JSON should decode");
assert!(matches!(decoded.tier_type, TierType::Wasabi));
let redacted = config.redacted();
let redacted = config.clone();
assert_eq!(
config
.wasabi
@@ -1005,87 +916,4 @@ mod tests {
"REDACTED"
);
}
#[test]
fn api_serialization_and_debug_redact_s3_gcs_and_azure_credentials() {
let cases = [
(
"s3",
TierConfig {
tier_type: TierType::S3,
s3: Some(TierS3 {
secret_key: "s3-secret-bytes".to_string(),
aws_role_web_identity_token_file: "/var/run/s3-private-token".to_string(),
..Default::default()
}),
..Default::default()
},
vec!["s3-secret-bytes", "/var/run/s3-private-token"],
),
(
"gcs",
TierConfig {
tier_type: TierType::GCS,
gcs: Some(TierGCS {
creds: r#"{"type":"service_account","private_key":"gcs-private-key-bytes"}"#.to_string(),
..Default::default()
}),
..Default::default()
},
vec!["gcs-private-key-bytes"],
),
(
"azure",
TierConfig {
tier_type: TierType::Azure,
azure: Some(TierAzure {
secret_key: "azure-account-secret-bytes".to_string(),
sp_auth: ServicePrincipalAuth {
client_secret: "azure-client-secret-bytes".to_string(),
..Default::default()
},
..Default::default()
}),
..Default::default()
},
vec!["azure-account-secret-bytes", "azure-client-secret-bytes"],
),
];
for (provider, config, secrets) in cases {
let api = serde_json::to_string(&config.redacted()).expect("redacted API config should serialize");
let debug = format!("{config:?}");
assert!(api.contains(TIER_CREDENTIAL_REDACTED), "{provider} API output should be visibly redacted");
assert!(
debug.contains(TIER_CREDENTIAL_REDACTED),
"{provider} Debug output should be visibly redacted"
);
for secret in secrets {
assert!(!api.contains(secret), "{provider} API output exposed credential bytes");
assert!(!debug.contains(secret), "{provider} Debug output exposed credential bytes");
}
}
}
#[test]
fn azure_static_account_redaction_preserves_an_empty_service_principal_secret() {
let config = TierConfig {
tier_type: TierType::Azure,
azure: Some(TierAzure {
secret_key: "azure-account-secret-bytes".to_string(),
sp_auth: ServicePrincipalAuth::default(),
..Default::default()
}),
..Default::default()
};
let api = serde_json::to_value(config.redacted()).expect("redacted Azure API config should serialize");
let debug = format!("{config:?}");
assert_eq!(api["azure"]["secretKey"], TIER_CREDENTIAL_REDACTED);
assert_eq!(api["azure"]["spAuth"]["client_secret"], "");
assert!(debug.contains("client_secret: \"\""));
assert!(!debug.contains("client_secret: \"REDACTED\""));
assert!(!debug.contains("azure-account-secret-bytes"));
}
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More