mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-05 19:55:37 +00:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 043842d571 | |||
| e1608fbd9c | |||
| eb1b17802c | |||
| 112f70914d | |||
| ea9aa53fd8 | |||
| dd368f0f5b | |||
| 6d8606412e | |||
| 037354cec0 | |||
| 30ab919bb3 | |||
| 8fc1c9281e | |||
| 14cef91423 | |||
| c9acc33720 | |||
| 955d491174 | |||
| 1c4e9f1b65 | |||
| a9f01dbbdb | |||
| 35aefbb2a5 | |||
| 8fb335cf19 | |||
| 8f763fb1a2 | |||
| a6b5da64f2 | |||
| 1210428b6d | |||
| d5426f59ec | |||
| 4da48f888e | |||
| f54323b062 | |||
| cc5060ac20 | |||
| d8580ec970 | |||
| 034efa8dac | |||
| 188f380b3b | |||
| e2a921bc16 | |||
| 447f3c704b | |||
| d915f9565e | |||
| 55ad7508b9 | |||
| 33fd056000 | |||
| af2e9df821 | |||
| 0a92a7d98c | |||
| c589fd2439 | |||
| 3e5d4ebb09 | |||
| 3677871468 | |||
| cc1ec6b992 | |||
| 9e2545244c | |||
| f053862aad | |||
| e8a7f4bc4a | |||
| 7ba5cd6888 | |||
| 1277c11f04 | |||
| bdb069c0fd | |||
| b65ab747d3 | |||
| 8c48f184b5 |
@@ -0,0 +1,2 @@
|
|||||||
|
sha256-linux=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
||||||
|
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
.NOTPARALLEL: pre-commit pre-pr dev-check
|
.NOTPARALLEL: pre-commit pre-pr dev-check
|
||||||
|
|
||||||
.PHONY: setup-hooks
|
.PHONY: setup-hooks
|
||||||
setup-hooks: ## Set up git hooks
|
setup-hooks: ## Install the configured pre-commit hooks
|
||||||
@echo "🔧 Setting up git hooks..."
|
@echo "🔧 Setting up git hooks..."
|
||||||
chmod +x .git/hooks/pre-commit
|
pre-commit validate-config
|
||||||
|
pre-commit install
|
||||||
@echo "✅ Git hooks setup complete!"
|
@echo "✅ Git hooks setup complete!"
|
||||||
|
|
||||||
.PHONY: doc-paths-check
|
.PHONY: doc-paths-check
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ script-tests: ## Run shell script tests
|
|||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_nightly_candidate.py
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
||||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||||
|
|||||||
@@ -183,6 +183,13 @@ test-group = 'e2e-reliability'
|
|||||||
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||||
test-group = 'e2e-inline-boundaries'
|
test-group = 'e2e-inline-boundaries'
|
||||||
|
|
||||||
|
# 4-node 4-drive distributed Actions suite: each case starts four rustfs
|
||||||
|
# processes and up to sixteen data directories. Serialize across nextest's
|
||||||
|
# process boundary so several 4x4 clusters never overlap.
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = 'package(e2e_test) & test(/^distributed::/)'
|
||||||
|
test-group = 'e2e-cluster-nightly'
|
||||||
|
|
||||||
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
|
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
|
||||||
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
||||||
# one group.
|
# one group.
|
||||||
@@ -526,6 +533,27 @@ path = "junit.xml"
|
|||||||
filter = 'package(e2e_test)'
|
filter = 'package(e2e_test)'
|
||||||
test-group = 'e2e-cluster-nightly'
|
test-group = 'e2e-cluster-nightly'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# e2e-distributed profile — 4-node 4-disk Actions suite
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Storage-sensitive PR / nightly / dispatch lane owned by
|
||||||
|
# .github/workflows/e2e-distributed.yml.
|
||||||
|
# Each case starts four rustfs processes (and for site replication, two
|
||||||
|
# clusters). Upgrade cases also require RUSTFS_UPGRADE_SOURCE_BINARY.
|
||||||
|
# Serialized via e2e-cluster-nightly with no retries.
|
||||||
|
[profile.e2e-distributed]
|
||||||
|
default-filter = 'package(e2e_test) & test(/^distributed::/)'
|
||||||
|
fail-fast = false
|
||||||
|
# Decommission / rebalance cases poll for up to 180s with little stdout.
|
||||||
|
slow-timeout = { period = "120s", terminate-after = 6 }
|
||||||
|
|
||||||
|
[profile.e2e-distributed.junit]
|
||||||
|
path = "junit.xml"
|
||||||
|
|
||||||
|
[[profile.e2e-distributed.overrides]]
|
||||||
|
filter = 'package(e2e_test)'
|
||||||
|
test-group = 'e2e-cluster-nightly'
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
|
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -586,6 +614,10 @@ path = "junit.xml"
|
|||||||
# cluster-fault lane. heal_erasure_disk_rebuild is intentionally not
|
# cluster-fault lane. heal_erasure_disk_rebuild is intentionally not
|
||||||
# excluded here because backlog#2213 promotes core heal rebuild coverage to
|
# excluded here because backlog#2213 promotes core heal rebuild coverage to
|
||||||
# this merge/main lane while retaining nightly coverage.
|
# this merge/main lane while retaining nightly coverage.
|
||||||
|
# * distributed:: — 4-node 4-disk Actions suite (S3, lock, versioning,
|
||||||
|
# replication, quota, observability, expand/decommission/rebalance, site
|
||||||
|
# replication, chaos, upgrade history/IAM). Owns [profile.e2e-distributed] and
|
||||||
|
# .github/workflows/e2e-distributed.yml.
|
||||||
# * on_demand_migration::interop_test — the ODM-20 provider interoperability
|
# * on_demand_migration::interop_test — the ODM-20 provider interoperability
|
||||||
# cases, which are meaningless without a source: they run in the dedicated
|
# cases, which are meaningless without a source: they run in the dedicated
|
||||||
# [profile.e2e-odm-interop] lane below, where the workflow points them at a
|
# [profile.e2e-odm-interop] lane below, where the workflow points them at a
|
||||||
@@ -607,6 +639,7 @@ default-filter = """
|
|||||||
package(e2e_test)
|
package(e2e_test)
|
||||||
& !test(/^protocols::/)
|
& !test(/^protocols::/)
|
||||||
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||||
|
& !test(/^distributed::/)
|
||||||
& !test(/^replication_extension_test::/)
|
& !test(/^replication_extension_test::/)
|
||||||
& !test(/^replication_target_matrix_test::/)
|
& !test(/^replication_target_matrix_test::/)
|
||||||
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
|
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Copyright 2024 RustFS Team
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
name: Quick Checks
|
||||||
|
description: Run the shared compile-free RustFS quality checks.
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Install quality tools
|
||||||
|
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
||||||
|
with:
|
||||||
|
tool: |
|
||||||
|
ripgrep@15.2.0
|
||||||
|
shellcheck@0.11.0
|
||||||
|
|
||||||
|
- name: Install actionlint
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
actionlint_dir="$(mktemp -d "${RUNNER_TEMP}/actionlint.XXXXXX")"
|
||||||
|
curl --fail --location --silent --show-error \
|
||||||
|
--output "$actionlint_dir/actionlint.tar.gz" \
|
||||||
|
https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz
|
||||||
|
echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $actionlint_dir/actionlint.tar.gz" | sha256sum --check --status
|
||||||
|
tar -xzf "$actionlint_dir/actionlint.tar.gz" -C "$actionlint_dir" actionlint
|
||||||
|
rm "$actionlint_dir/actionlint.tar.gz"
|
||||||
|
echo "$actionlint_dir" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
|
||||||
|
- name: Check workflow syntax and shell scripts
|
||||||
|
shell: bash
|
||||||
|
run: shellcheck --version && actionlint
|
||||||
|
|
||||||
|
- name: Check code formatting
|
||||||
|
shell: bash
|
||||||
|
run: cargo fmt --all --check
|
||||||
|
|
||||||
|
- name: Check unsafe code allowances
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_unsafe_code_allowances.sh
|
||||||
|
|
||||||
|
- name: Check layered dependencies
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_layer_dependencies.sh
|
||||||
|
|
||||||
|
- name: Check architecture migration rules
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_architecture_migration_rules.sh
|
||||||
|
|
||||||
|
- name: Check logging guardrails
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_logging_guardrails.sh
|
||||||
|
|
||||||
|
- name: Check error other(format!) ratchet
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_error_other_format_ratchet.sh
|
||||||
|
|
||||||
|
- name: Check tokio io-uring feature guard
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_no_tokio_io_uring.sh
|
||||||
|
|
||||||
|
- name: Check extension schema boundaries
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_extension_schema_boundaries.sh
|
||||||
|
|
||||||
|
- name: Check body-cache whitelist guard
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_body_cache_whitelist.sh
|
||||||
|
|
||||||
|
- name: Check s3s footprint ratchet
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_s3s_footprint.sh
|
||||||
|
|
||||||
|
- name: Check cryptographic capability wording
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_fips_wording.sh
|
||||||
|
|
||||||
|
- name: Check no embedded secret material
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_embedded_secrets.sh
|
||||||
|
|
||||||
|
- name: Run script contract tests
|
||||||
|
shell: bash
|
||||||
|
run: make script-tests
|
||||||
|
|
||||||
|
- name: Check test wiring
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python3 ./scripts/check_test_wiring.py --self-test
|
||||||
|
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||||
|
python3 ./scripts/test_security_workflow.py
|
||||||
|
python3 ./scripts/test_nightly_candidate.py
|
||||||
|
python3 ./scripts/check_test_wiring.py
|
||||||
|
|
||||||
|
- name: Check no planning docs committed
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_no_planning_docs.sh
|
||||||
|
|
||||||
|
- name: Check CI paths stay in sync
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_ci_paths_sync.sh
|
||||||
|
|
||||||
|
- name: Check io_uring lane --lib precondition
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/check_uring_lane_lib_only.sh
|
||||||
@@ -10,16 +10,16 @@ Use N/A when there is no related issue.
|
|||||||
|
|
||||||
## Summary of Changes
|
## Summary of Changes
|
||||||
<!--
|
<!--
|
||||||
Briefly explain what changed and why reviewers should accept it.
|
Describe the concrete problem and resulting behavior. For a behavior change, name the input or state that triggers it and the expected outcome. Explain any new dependency or abstraction that the change needs.
|
||||||
Focus on behavior, compatibility, and review-relevant context.
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
<!--
|
<!--
|
||||||
List the commands or checks you ran, for example:
|
Give 1–3 concrete pieces of evidence for the changed behavior: the test or command, its observed result, and the regression it catches. For a bug fix, record a failing-before/passing-after check or explain why it was unavailable.
|
||||||
- `make pre-commit`
|
|
||||||
|
|
||||||
Use N/A only when verification is not applicable.
|
Identify the tested commit and any local changes. When testing a prebuilt binary or external service, include its source/version and artifact identity; a successful run against a different build is not evidence for this change.
|
||||||
|
|
||||||
|
List relevant checks not run and the remaining risk. Use the validation tier in AGENTS.md; do not run broader checks solely to fill this section. For documentation-only changes, list the applicable documentation checks. Use N/A only when verification is not applicable.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Impact
|
## Impact
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
|
||||||
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
|
||||||
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
|
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
|
||||||
|
{
|
||||||
|
"workflow": ".github/workflows/e2e-distributed.yml",
|
||||||
|
"max_age_hours": 36,
|
||||||
|
"never_ran_grace_until": "2026-09-18T00:00:00Z"
|
||||||
|
},
|
||||||
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
|
||||||
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
|
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
|
||||||
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
|
||||||
|
|||||||
@@ -12,24 +12,10 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
# Companion to ci.yml for required status checks.
|
# Reports the existing required checks for paths excluded by ci.yml.
|
||||||
#
|
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
|
||||||
# ci.yml skips docs-only pull requests via paths-ignore, but the branch ruleset
|
# action to keep validation coverage aligned. Keep this paths list in sync with
|
||||||
# requires a check named "Test and Lint" — without this workflow a docs-only PR
|
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
|
||||||
# would wait on it forever. This workflow triggers on exactly the paths ci.yml
|
|
||||||
# ignores and reports success under the same job name. Mixed PRs trigger both
|
|
||||||
# workflows and the real check still gates: a required check with any failing
|
|
||||||
# run blocks the merge.
|
|
||||||
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks
|
|
||||||
#
|
|
||||||
# "Quick Checks" is mirrored here ahead of the ruleset change that will make it
|
|
||||||
# required too (rustfs/backlog#1599). Until that change lands this job is
|
|
||||||
# inert; mirroring it first is what lets the ruleset change happen without
|
|
||||||
# stranding docs-only PRs on a check nobody reports.
|
|
||||||
#
|
|
||||||
# Keep the paths list below in sync with the pull_request paths-ignore list
|
|
||||||
# in ci.yml, and keep the quick-checks steps below byte-identical to the
|
|
||||||
# quick-checks job in ci.yml.
|
|
||||||
|
|
||||||
name: Continuous Integration (docs only)
|
name: Continuous Integration (docs only)
|
||||||
|
|
||||||
@@ -59,19 +45,6 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Deliberately NOT a bare `echo`. Once "Quick Checks" becomes a required
|
|
||||||
# check, ci.yml gates every expensive job behind it, so a mixed PR reports
|
|
||||||
# two check runs with this name: the real one (45-51s) and this companion.
|
|
||||||
# GitHub has no written contract for how it picks between same-named
|
|
||||||
# required check runs ("latest wins" vs "any failure blocks"), so instead of
|
|
||||||
# relying on ordering we make both runs execute the same commands against
|
|
||||||
# the same merge ref — their conclusions are then necessarily identical and
|
|
||||||
# the choice does not matter. Keep these steps byte-identical to the
|
|
||||||
# quick-checks job in ci.yml (a guard script that asserts this, and the paths
|
|
||||||
# sync below, is tracked in rustfs/backlog#1603).
|
|
||||||
#
|
|
||||||
# For a genuinely docs-only PR this adds no strictness (no code changed, so
|
|
||||||
# fmt and the guards always pass) and costs ~50s of ubuntu-latest.
|
|
||||||
quick-checks:
|
quick-checks:
|
||||||
name: Quick Checks
|
name: Quick Checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -82,64 +55,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Run shared quick checks
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
uses: ./.github/actions/quick-checks
|
||||||
with:
|
|
||||||
tool: ripgrep@15.2.0
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
|
||||||
with:
|
|
||||||
components: rustfmt
|
|
||||||
|
|
||||||
- name: Check code formatting
|
|
||||||
run: cargo fmt --all --check
|
|
||||||
|
|
||||||
- name: Check unsafe code allowances
|
|
||||||
run: ./scripts/check_unsafe_code_allowances.sh
|
|
||||||
|
|
||||||
- name: Check layered dependencies
|
|
||||||
run: ./scripts/check_layer_dependencies.sh
|
|
||||||
|
|
||||||
- name: Check architecture migration rules
|
|
||||||
run: ./scripts/check_architecture_migration_rules.sh
|
|
||||||
|
|
||||||
- name: Check logging guardrails
|
|
||||||
run: ./scripts/check_logging_guardrails.sh
|
|
||||||
|
|
||||||
- name: Check tokio io-uring feature guard
|
|
||||||
run: ./scripts/check_no_tokio_io_uring.sh
|
|
||||||
|
|
||||||
- name: Check extension schema boundaries
|
|
||||||
run: ./scripts/check_extension_schema_boundaries.sh
|
|
||||||
|
|
||||||
- name: Check body-cache whitelist guard
|
|
||||||
run: ./scripts/check_body_cache_whitelist.sh
|
|
||||||
|
|
||||||
- name: Check s3s footprint ratchet
|
|
||||||
run: ./scripts/check_s3s_footprint.sh
|
|
||||||
|
|
||||||
- name: Check cryptographic capability wording
|
|
||||||
run: ./scripts/check_fips_wording.sh
|
|
||||||
|
|
||||||
- name: Check no embedded secret material
|
|
||||||
run: ./scripts/check_embedded_secrets.sh
|
|
||||||
|
|
||||||
- name: Check test wiring
|
|
||||||
run: |
|
|
||||||
python3 ./scripts/check_test_wiring.py --self-test
|
|
||||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
|
||||||
python3 ./scripts/test_security_workflow.py
|
|
||||||
python3 ./scripts/check_test_wiring.py
|
|
||||||
|
|
||||||
- name: Check no planning docs committed
|
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
|
||||||
|
|
||||||
- name: Check CI paths stay in sync
|
|
||||||
run: ./scripts/check_ci_paths_sync.sh
|
|
||||||
|
|
||||||
- name: Check io_uring lane --lib precondition
|
|
||||||
run: ./scripts/check_uring_lane_lib_only.sh
|
|
||||||
|
|
||||||
test-and-lint:
|
test-and-lint:
|
||||||
name: Test and Lint
|
name: Test and Lint
|
||||||
|
|||||||
@@ -100,12 +100,7 @@ jobs:
|
|||||||
- name: Typos check with custom config file
|
- name: Typos check with custom config file
|
||||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
||||||
|
|
||||||
# Fast, compile-free checks that fail early so contributors get feedback in
|
# Fail early with compile-free checks shared with docs-only CI.
|
||||||
# ~1 minute instead of waiting for the full test job.
|
|
||||||
#
|
|
||||||
# These steps are mirrored byte-for-byte in ci-docs-only.yml so that a mixed
|
|
||||||
# PR, which reports two check runs named "Quick Checks", cannot get one red
|
|
||||||
# and one green. Edit both jobs together.
|
|
||||||
quick-checks:
|
quick-checks:
|
||||||
name: Quick Checks
|
name: Quick Checks
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -117,67 +112,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Run shared quick checks
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
uses: ./.github/actions/quick-checks
|
||||||
with:
|
|
||||||
tool: ripgrep@15.2.0
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
|
||||||
with:
|
|
||||||
components: rustfmt
|
|
||||||
|
|
||||||
- name: Check code formatting
|
|
||||||
run: cargo fmt --all --check
|
|
||||||
|
|
||||||
- name: Check unsafe code allowances
|
|
||||||
run: ./scripts/check_unsafe_code_allowances.sh
|
|
||||||
|
|
||||||
- name: Check layered dependencies
|
|
||||||
run: ./scripts/check_layer_dependencies.sh
|
|
||||||
|
|
||||||
- name: Check architecture migration rules
|
|
||||||
run: ./scripts/check_architecture_migration_rules.sh
|
|
||||||
|
|
||||||
- name: Check logging guardrails
|
|
||||||
run: ./scripts/check_logging_guardrails.sh
|
|
||||||
|
|
||||||
- name: Check error other(format!) ratchet
|
|
||||||
run: ./scripts/check_error_other_format_ratchet.sh
|
|
||||||
|
|
||||||
- name: Check tokio io-uring feature guard
|
|
||||||
run: ./scripts/check_no_tokio_io_uring.sh
|
|
||||||
|
|
||||||
- name: Check extension schema boundaries
|
|
||||||
run: ./scripts/check_extension_schema_boundaries.sh
|
|
||||||
|
|
||||||
- name: Check body-cache whitelist guard
|
|
||||||
run: ./scripts/check_body_cache_whitelist.sh
|
|
||||||
|
|
||||||
- name: Check s3s footprint ratchet
|
|
||||||
run: ./scripts/check_s3s_footprint.sh
|
|
||||||
|
|
||||||
- name: Check cryptographic capability wording
|
|
||||||
run: ./scripts/check_fips_wording.sh
|
|
||||||
|
|
||||||
- name: Check no embedded secret material
|
|
||||||
run: ./scripts/check_embedded_secrets.sh
|
|
||||||
|
|
||||||
- name: Check test wiring
|
|
||||||
run: |
|
|
||||||
python3 ./scripts/check_test_wiring.py --self-test
|
|
||||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
|
||||||
python3 ./scripts/test_security_workflow.py
|
|
||||||
python3 ./scripts/check_test_wiring.py
|
|
||||||
|
|
||||||
- name: Check no planning docs committed
|
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
|
||||||
|
|
||||||
- name: Check CI paths stay in sync
|
|
||||||
run: ./scripts/check_ci_paths_sync.sh
|
|
||||||
|
|
||||||
- name: Check io_uring lane --lib precondition
|
|
||||||
run: ./scripts/check_uring_lane_lib_only.sh
|
|
||||||
|
|
||||||
test-and-lint:
|
test-and-lint:
|
||||||
name: Test and Lint
|
name: Test and Lint
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# Copyright 2024 RustFS Team
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# 4-node 4-disk distributed e2e lane.
|
||||||
|
#
|
||||||
|
# Each selected test starts a real localhost cluster via
|
||||||
|
# `RustFSTestClusterEnvironment` (4 processes; 4 drives per node unless the
|
||||||
|
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
|
||||||
|
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
|
||||||
|
# nightly runs, and manual dispatches all execute the same fail-closed suite.
|
||||||
|
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
|
||||||
|
|
||||||
|
name: e2e-distributed
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "Cargo.lock"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- ".config/nextest.toml"
|
||||||
|
- ".github/workflows/e2e-distributed.yml"
|
||||||
|
- "crates/audit/**"
|
||||||
|
- "crates/common/**"
|
||||||
|
- "crates/config/**"
|
||||||
|
- "crates/e2e_test/**"
|
||||||
|
- "crates/ecstore/**"
|
||||||
|
- "crates/filemeta/**"
|
||||||
|
- "crates/heal/**"
|
||||||
|
- "crates/iam/**"
|
||||||
|
- "crates/lock/**"
|
||||||
|
- "crates/madmin/**"
|
||||||
|
- "crates/notify/**"
|
||||||
|
- "crates/replication/**"
|
||||||
|
- "crates/s3-client/**"
|
||||||
|
- "crates/s3-ops/**"
|
||||||
|
- "crates/s3-types/**"
|
||||||
|
- "crates/scanner/**"
|
||||||
|
- "crates/storage-api/**"
|
||||||
|
- "crates/utils/**"
|
||||||
|
- "rustfs/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
filter:
|
||||||
|
description: "Optional nextest -E filter (default: the whole e2e-distributed profile)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
schedule:
|
||||||
|
# 05:53 UTC nightly — clear of e2e-nightly (04:29) and ODM interop (05:23).
|
||||||
|
- cron: "53 5 * * *"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
distributed:
|
||||||
|
name: Distributed 4-node 4-disk e2e
|
||||||
|
runs-on: sm-standard-4
|
||||||
|
timeout-minutes: 180
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
NO_PROXY: 127.0.0.1,localhost
|
||||||
|
HTTP_PROXY: ""
|
||||||
|
HTTPS_PROXY: ""
|
||||||
|
# Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
|
||||||
|
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||||
|
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||||
|
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
rust-version: stable
|
||||||
|
cache-shared-key: ci-e2e-distributed
|
||||||
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Prepare isolated filesystems for pool movement
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
|
||||||
|
mkdir -p "${mount_base}"
|
||||||
|
roots=()
|
||||||
|
for pool in 0 1 2 3; do
|
||||||
|
image="${mount_base}/pool-${pool}.img"
|
||||||
|
mountpoint="${mount_base}/pool-${pool}"
|
||||||
|
truncate -s 1G "${image}"
|
||||||
|
mkfs.ext4 -q -F "${image}"
|
||||||
|
mkdir -p "${mountpoint}"
|
||||||
|
sudo mount -o loop,nosuid,nodev "${image}" "${mountpoint}"
|
||||||
|
sudo chmod 1777 "${mountpoint}"
|
||||||
|
roots+=("${mountpoint}")
|
||||||
|
done
|
||||||
|
printf -v joined_roots '%s:' "${roots[@]}"
|
||||||
|
echo "RUSTFS_E2E_POOL_ROOTS=${joined_roots%:}" >> "${GITHUB_ENV}"
|
||||||
|
findmnt --noheadings --output TARGET,SOURCE,FSTYPE --target "${roots[0]}"
|
||||||
|
findmnt --noheadings --output TARGET,SOURCE,FSTYPE --target "${roots[1]}"
|
||||||
|
findmnt --noheadings --output TARGET,SOURCE,FSTYPE --target "${roots[2]}"
|
||||||
|
findmnt --noheadings --output TARGET,SOURCE,FSTYPE --target "${roots[3]}"
|
||||||
|
|
||||||
|
- name: Download pinned previous release
|
||||||
|
env:
|
||||||
|
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$SOURCE_DIR"
|
||||||
|
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
|
||||||
|
curl --fail --location --retry 3 --output "$archive" \
|
||||||
|
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
|
||||||
|
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
|
||||||
|
unzip -q "$archive" -d "$SOURCE_DIR"
|
||||||
|
chmod +x "$SOURCE_DIR/rustfs"
|
||||||
|
test -x "$SOURCE_DIR/rustfs"
|
||||||
|
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build rustfs binary
|
||||||
|
run: |
|
||||||
|
cargo build -p rustfs --bins
|
||||||
|
: > target/debug/rustfs.features
|
||||||
|
|
||||||
|
- name: Verify distributed e2e membership
|
||||||
|
env:
|
||||||
|
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
||||||
|
run: |
|
||||||
|
cargo nextest list --profile e2e-distributed -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
||||||
|
python3 ./scripts/check_test_wiring.py --check-profile e2e-distributed "${NEXTEST_LISTING}"
|
||||||
|
|
||||||
|
- name: Run distributed 4-node e2e suite
|
||||||
|
env:
|
||||||
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-distributed-logs
|
||||||
|
FILTER: ${{ inputs.filter }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -n "${FILTER}" ]; then
|
||||||
|
cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||||
|
else
|
||||||
|
cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload distributed e2e diagnostics
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: e2e-distributed-${{ github.run_number }}
|
||||||
|
path: |
|
||||||
|
target/nextest/e2e-distributed/junit.xml
|
||||||
|
${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
||||||
|
${{ runner.temp }}/rustfs-e2e-distributed-logs/
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
- name: Unmount isolated pool filesystems
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
|
||||||
|
for pool in 0 1 2 3; do
|
||||||
|
mountpoint="${mount_base}/pool-${pool}"
|
||||||
|
if mountpoint --quiet "${mountpoint}"; then
|
||||||
|
sudo umount "${mountpoint}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
alert-on-failure:
|
||||||
|
name: Alert on scheduled failure
|
||||||
|
needs: [distributed]
|
||||||
|
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Open or update failure-tracking issue
|
||||||
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -19,7 +19,9 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- ".github/workflows/e2e-upgrade.yml"
|
- ".github/workflows/e2e-upgrade.yml"
|
||||||
- "crates/e2e_test/src/common.rs"
|
- "crates/e2e_test/src/common.rs"
|
||||||
|
- "crates/e2e_test/src/fake_s3_target/**"
|
||||||
- "crates/e2e_test/src/lib.rs"
|
- "crates/e2e_test/src/lib.rs"
|
||||||
|
- "crates/e2e_test/src/replication_extension_test.rs"
|
||||||
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||||
- "crates/ecstore/**"
|
- "crates/ecstore/**"
|
||||||
- "crates/filemeta/**"
|
- "crates/filemeta/**"
|
||||||
@@ -44,9 +46,9 @@ concurrency:
|
|||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
RUST_BACKTRACE: 1
|
RUST_BACKTRACE: 1
|
||||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
UPGRADE_SOURCE_VERSION: 1.0.0-rc.5
|
||||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
|
||||||
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
upgrade:
|
upgrade:
|
||||||
@@ -55,14 +57,31 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: Direct upgrade from rc.2
|
# The two `_from_rc2_` tests keep their names: they assert
|
||||||
|
# release-independent object contracts and pass unchanged against the
|
||||||
|
# newer pinned source, so renaming them would only churn history and
|
||||||
|
# the CI required-check names. UPGRADE_SOURCE_VERSION above is the
|
||||||
|
# single source of truth for which release they actually run against.
|
||||||
|
- name: Direct upgrade from the previous release
|
||||||
cache_key: e2e-direct-upgrade
|
cache_key: e2e-direct-upgrade
|
||||||
test: direct_upgrade_from_rc2_preserves_object_contracts
|
test: direct_upgrade_from_rc2_preserves_object_contracts
|
||||||
artifact: direct-upgrade
|
artifact: direct-upgrade
|
||||||
- name: Mixed-version rolling upgrade from rc.2
|
- name: Mixed-version rolling upgrade from the previous release
|
||||||
cache_key: e2e-mixed-version-upgrade
|
cache_key: e2e-mixed-version-upgrade
|
||||||
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
|
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
|
||||||
artifact: mixed-version-upgrade
|
artifact: mixed-version-upgrade
|
||||||
|
- name: Bucket configuration survives the upgrade
|
||||||
|
cache_key: e2e-bucket-config-upgrade
|
||||||
|
test: direct_upgrade_from_previous_release_preserves_bucket_configuration
|
||||||
|
artifact: bucket-config-upgrade
|
||||||
|
- name: Rollback reads current bucket metadata
|
||||||
|
cache_key: e2e-bucket-config-rollback
|
||||||
|
test: rollback_to_previous_release_reads_current_bucket_metadata
|
||||||
|
artifact: bucket-config-rollback
|
||||||
|
- name: ODM configuration recovery after rc.5 rollback
|
||||||
|
cache_key: e2e-odm-config-rollback
|
||||||
|
test: rc5_rollback_requires_restoring_odm_configuration
|
||||||
|
artifact: odm-config-rollback
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -166,8 +166,9 @@ jobs:
|
|||||||
# e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... .
|
# e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... .
|
||||||
# Skipped when the R2 secrets are not configured (artifact-only mode).
|
# Skipped when the R2 secrets are not configured (artifact-only mode).
|
||||||
- name: Upload DEB to Cloudflare R2
|
- name: Upload DEB to Cloudflare R2
|
||||||
if: env.R2_ACCESS_KEY_ID != ''
|
id: publish
|
||||||
env:
|
env:
|
||||||
|
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
@@ -182,28 +183,70 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v aws >/dev/null 2>&1; then
|
|
||||||
sudo apt-get update && sudo apt-get install -y -qq awscli
|
|
||||||
fi
|
|
||||||
|
|
||||||
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
||||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||||
export AWS_DEFAULT_REGION="auto"
|
export AWS_DEFAULT_REGION="auto"
|
||||||
|
|
||||||
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
|
SOURCE_SHA="$(git rev-parse HEAD)"
|
||||||
|
if [[ "${SOURCE_SHA}" != "${GITHUB_SHA}" ]]; then
|
||||||
|
echo "Checkout SHA does not match the nightly build run" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DEB_SHA256="$(sha256sum "${DEB_FILE}" | cut -d ' ' -f 1)"
|
||||||
|
CANDIDATE_KEY="artifacts/rustfs/packages/nightly/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${DEB_SHA256}/rustfs.deb"
|
||||||
|
CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}"
|
||||||
|
|
||||||
|
# Old AWS CLI models lack conditional PutObject support. Never fall
|
||||||
|
# back to an overwriting upload for a candidate.
|
||||||
|
AWS_CLI=aws
|
||||||
|
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y -qq python3-venv
|
||||||
|
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/nightly-awscli.XXXXXX")"
|
||||||
|
trap 'rm -rf "${AWS_CLI_DIR}"' EXIT
|
||||||
|
python3 -m venv "${AWS_CLI_DIR}"
|
||||||
|
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
|
||||||
|
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
|
||||||
|
fi
|
||||||
|
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
|
||||||
|
"${AWS_CLI}" --version
|
||||||
|
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${CANDIDATE_KEY}" \
|
||||||
|
--body "${DEB_FILE}" --if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
|
||||||
|
PUBLISHED_SHA256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${CANDIDATE_URL}" | sha256sum | cut -d ' ' -f 1)"
|
||||||
|
if [[ "${PUBLISHED_SHA256}" != "${DEB_SHA256}" ]]; then
|
||||||
|
echo "Published candidate checksum does not match the built package" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/"
|
R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/"
|
||||||
|
|
||||||
echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}"
|
echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}"
|
||||||
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
"${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||||
|
|
||||||
# Stable "latest" alias so tests can fetch the newest nightly
|
# Stable "latest" alias so tests can fetch the newest nightly
|
||||||
# without knowing today's date.
|
# without knowing today's date.
|
||||||
echo "📤 Uploading latest alias"
|
echo "📤 Uploading latest alias"
|
||||||
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
"${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
||||||
--endpoint-url "$R2_ENDPOINT" --only-show-errors
|
--endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||||
|
|
||||||
echo "✅ R2 upload complete"
|
echo "✅ R2 upload complete"
|
||||||
|
|
||||||
|
CANDIDATE_FILE="${RUNNER_TEMP}/nightly-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
|
||||||
|
jq -n --arg source_sha "${SOURCE_SHA}" \
|
||||||
|
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||||
|
--arg package_url "${CANDIDATE_URL}" --arg package_sha256 "${DEB_SHA256}" \
|
||||||
|
'{schema: 1, source_sha: $source_sha, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' \
|
||||||
|
> "${CANDIDATE_FILE}"
|
||||||
|
echo "candidate_file=${CANDIDATE_FILE}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Upload nightly candidate manifest
|
||||||
|
if: ${{ steps.publish.outputs.candidate_file != '' }}
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: nightly-candidate-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: ${{ steps.publish.outputs.candidate_file }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
||||||
#
|
#
|
||||||
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
|
|
||||||
# Functional chain driver: runs the ten functional suites in a fixed order
|
# Functional chain driver: runs the ten functional suites in a fixed order
|
||||||
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
|
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
|
||||||
# replication, with performance on its own runner in parallel) and guarantees
|
# replication -> performance). Each suite attempts the next handoff even
|
||||||
# the chain keeps moving even when individual suites fail.
|
# when its tests fail.
|
||||||
#
|
#
|
||||||
# Each suite workflow can still be dispatched standalone (workflow_dispatch);
|
# Each suite workflow can still be dispatched standalone (workflow_dispatch);
|
||||||
# only chain-triggered runs forward to the next suite via repository_dispatch,
|
# only chain-triggered runs forward to the next suite via repository_dispatch,
|
||||||
@@ -59,16 +59,3 @@ jobs:
|
|||||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||||
-f event_type='rustfs-chain-upgrade' \
|
-f event_type='rustfs-chain-upgrade' \
|
||||||
-F 'client_payload[from_suite]=nightly-build'
|
-F 'client_payload[from_suite]=nightly-build'
|
||||||
|
|
||||||
- name: Dispatch performance suite (parallel, own runner)
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
|
||||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch performance" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
|
||||||
-f event_type='rustfs-chain-performance' \
|
|
||||||
-F 'client_payload[from_suite]=nightly-build'
|
|
||||||
|
|||||||
@@ -54,14 +54,26 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
heal-test:
|
heal-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
# Requirement: a failing suite must not fail the workflow; failures
|
|
||||||
# are filed to rustfs/backlog and the chain continues.
|
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 480
|
timeout-minutes: 480
|
||||||
# Standalone manual run, or one link of the nightly functional chain
|
# Standalone manual run, or one link of the nightly functional chain
|
||||||
# (storage -> heal -> pool). Pool expansion no longer re-runs heal.
|
# (storage -> heal -> pool). Pool expansion no longer re-runs heal.
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-heal-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'RUSTFS_WARP_LOG_FILE=%s/warp.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -117,7 +129,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
fi
|
fi
|
||||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Preflight checks
|
- name: Preflight checks
|
||||||
run: |
|
run: |
|
||||||
@@ -127,7 +139,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
fi
|
fi
|
||||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Run heal test (write -> outage -> heal -> verify)
|
- name: Run heal test (write -> outage -> heal -> verify)
|
||||||
id: test
|
id: test
|
||||||
@@ -137,13 +149,10 @@ jobs:
|
|||||||
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||||
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
|
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
|
||||||
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
|
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
|
||||||
--log-file /tmp/rustfs-heal-test.log
|
--log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-heal-test.log
|
|
||||||
REPORT_FILE: /tmp/rustfs-heal-report.md
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PACKAGE_URL='${{ inputs.package_url }}'
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
@@ -152,8 +161,9 @@ jobs:
|
|||||||
else
|
else
|
||||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
fi
|
fi
|
||||||
STEPS_TABLE="/tmp/rustfs-heal-steps.md"
|
STEPS_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/steps.md"
|
||||||
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY'
|
CASE_RESULT=success
|
||||||
|
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY' || CASE_RESULT=failure
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -165,6 +175,7 @@ jobs:
|
|||||||
|
|
||||||
steps = {}
|
steps = {}
|
||||||
order = []
|
order = []
|
||||||
|
status_rank = {'SKIP': 0, 'PASS': 1, 'FAIL': 2}
|
||||||
version = None
|
version = None
|
||||||
version_node = None
|
version_node = None
|
||||||
verdict = None
|
verdict = None
|
||||||
@@ -178,14 +189,15 @@ jobs:
|
|||||||
n, desc, status = m.group(1), m.group(2), m.group(3)
|
n, desc, status = m.group(1), m.group(2), m.group(3)
|
||||||
if n not in steps:
|
if n not in steps:
|
||||||
order.append(n)
|
order.append(n)
|
||||||
steps[n] = (desc, status) # later lines win (fail after pass)
|
if n not in steps or status_rank[status] > status_rank[steps[n][1]]:
|
||||||
|
steps[n] = (desc, status)
|
||||||
continue
|
continue
|
||||||
m = ver_re.match(line)
|
m = ver_re.match(line)
|
||||||
if m:
|
if m:
|
||||||
version, version_node = m.group(1), m.group(2)
|
version, version_node = m.group(1), m.group(2)
|
||||||
continue
|
continue
|
||||||
m = result_re.match(line)
|
m = result_re.match(line)
|
||||||
if m:
|
if m and verdict != 'FAIL':
|
||||||
verdict, verdict_detail = m.group(1), m.group(2)
|
verdict, verdict_detail = m.group(1), m.group(2)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
@@ -205,30 +217,43 @@ jobs:
|
|||||||
out.write(f'| {n} | {desc} | {status} |\n')
|
out.write(f'| {n} | {desc} | {status} |\n')
|
||||||
if not order:
|
if not order:
|
||||||
out.write('| - | - | NOT RUN (no step result lines found) |\n')
|
out.write('| - | - | NOT RUN (no step result lines found) |\n')
|
||||||
|
complete = set(steps) == {str(n) for n in range(1, 8)}
|
||||||
|
sys.exit(0 if complete and verdict != 'FAIL' and all(status == 'PASS' for _, status in steps.values()) else 1)
|
||||||
PY
|
PY
|
||||||
|
RESULT=failure
|
||||||
|
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||||
|
RESULT=success
|
||||||
|
fi
|
||||||
{
|
{
|
||||||
echo "# RustFS heal test report"
|
echo "# RustFS heal test report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${{ github.event_name }}"
|
echo "- Trigger: ${{ github.event_name }}"
|
||||||
echo "- Package: ${PACKAGE_SOURCE}"
|
echo "- Package: ${PACKAGE_SOURCE}"
|
||||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
echo "- Test Step Outcome: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${STEPS_TABLE}" || true
|
if [ "${RESULT}" = "success" ]; then
|
||||||
echo ""
|
cat "${STEPS_TABLE}"
|
||||||
echo "## Log tail"
|
echo ""
|
||||||
echo '```text'
|
echo "## Log tail"
|
||||||
tail -n 200 "${LOG_FILE}" || true
|
echo '```text'
|
||||||
echo '```'
|
tail -n 200 "${LOG_FILE}"
|
||||||
|
echo '```'
|
||||||
|
else
|
||||||
|
echo "The suite or evidence validation failed. See this run's artifact for partial step results and suite.log."
|
||||||
|
fi
|
||||||
} | tee "${REPORT_FILE}"
|
} | tee "${REPORT_FILE}"
|
||||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-heal-report.md
|
|
||||||
SUITE: heal
|
SUITE: heal
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -238,28 +263,32 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 'heal'
|
SUITE: 'heal'
|
||||||
SUITE_LABEL: 'Heal'
|
SUITE_LABEL: 'Heal'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-heal-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-heal-test.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -287,14 +316,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -310,14 +341,16 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload test logs
|
- name: Upload test logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-heal-test-${{ github.run_id }}
|
name: rustfs-heal-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-heal-test*.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-warp.*.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/warp.log
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/steps.md
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||||
|
|||||||
@@ -49,10 +49,28 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
kms-test:
|
kms-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 420
|
timeout-minutes: 420
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository (for report parser)
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-kms-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -109,9 +127,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Run KMS suite
|
- name: Run KMS suite
|
||||||
id: test
|
id: test
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-kms.log
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x auto-testing/rustfs-kms-test.sh
|
chmod +x auto-testing/rustfs-kms-test.sh
|
||||||
@@ -141,10 +156,7 @@ jobs:
|
|||||||
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-kms.log
|
|
||||||
REPORT_FILE: /tmp/rustfs-kms-report.md
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PACKAGE_URL='${{ inputs.package_url }}'
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
@@ -156,79 +168,43 @@ jobs:
|
|||||||
else
|
else
|
||||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="/tmp/rustfs-kms-cases.md"
|
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
CASE_RESULT=success
|
||||||
import re
|
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||||
import sys
|
RESULT=failure
|
||||||
|
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
RESULT=success
|
||||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
fi
|
||||||
start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
|
|
||||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
index = {}
|
|
||||||
try:
|
|
||||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
|
||||||
for raw in fh:
|
|
||||||
line = ansi.sub('', raw).strip()
|
|
||||||
m = start_re.match(line)
|
|
||||||
if m:
|
|
||||||
case_id, name = m.group(1), m.group(2)
|
|
||||||
if case_id not in index:
|
|
||||||
index[case_id] = len(rows)
|
|
||||||
rows.append([case_id, name, 'RUNNING'])
|
|
||||||
continue
|
|
||||||
m = done_re.match(line)
|
|
||||||
if m:
|
|
||||||
status, case_id = m.group(1), m.group(2)
|
|
||||||
if case_id in index:
|
|
||||||
rows[index[case_id]][2] = status
|
|
||||||
else:
|
|
||||||
rows.append([case_id, case_id, status])
|
|
||||||
index[case_id] = len(rows) - 1
|
|
||||||
except FileNotFoundError:
|
|
||||||
rows = []
|
|
||||||
|
|
||||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
|
||||||
for _, _, status in rows:
|
|
||||||
counts[status] = counts.get(status, 0) + 1
|
|
||||||
|
|
||||||
with open(out_file, 'w', encoding='utf-8') as out:
|
|
||||||
out.write('## Case Summary\n\n')
|
|
||||||
out.write(f"- Total: {len(rows)}\\n")
|
|
||||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
|
||||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
|
||||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
|
||||||
out.write('\\n')
|
|
||||||
out.write('| Case | Name | Status |\\n')
|
|
||||||
out.write('| --- | --- | --- |\\n')
|
|
||||||
for case_id, name, status in rows:
|
|
||||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
|
||||||
PY
|
|
||||||
{
|
{
|
||||||
echo "# RustFS KMS test report"
|
echo "# RustFS KMS test report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${{ github.event_name }}"
|
echo "- Trigger: ${{ github.event_name }}"
|
||||||
echo "- Package: ${PACKAGE_SOURCE}"
|
echo "- Package: ${PACKAGE_SOURCE}"
|
||||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
echo "- Test Step Outcome: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${CASE_TABLE}" || true
|
if [ "${RESULT}" = "success" ]; then
|
||||||
echo ""
|
cat "${CASE_TABLE}"
|
||||||
echo "## Log tail"
|
echo ""
|
||||||
echo '```text'
|
echo "## Log tail"
|
||||||
tail -n 200 "${LOG_FILE}" || true
|
echo '```text'
|
||||||
echo '```'
|
tail -n 200 "${LOG_FILE}"
|
||||||
|
echo '```'
|
||||||
|
else
|
||||||
|
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||||
|
fi
|
||||||
} | tee "${REPORT_FILE}"
|
} | tee "${REPORT_FILE}"
|
||||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-kms-report.md
|
|
||||||
SUITE: kms
|
SUITE: kms
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -238,28 +214,32 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 'kms'
|
SUITE: 'kms'
|
||||||
SUITE_LABEL: 'KMS'
|
SUITE_LABEL: 'KMS'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-kms-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-kms.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -287,14 +267,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -310,14 +292,15 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload report and logs
|
- name: Upload report and logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-kms-test-${{ github.run_id }}
|
name: rustfs-kms-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-kms.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-kms-report.md
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -49,17 +49,16 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
repository_dispatch:
|
repository_dispatch:
|
||||||
# Chain entry: dispatched by rustfs-functional-chain.yml (runs on its own
|
# Chain handoff: dispatched when the replication suite finishes.
|
||||||
# pf-testing runner, in parallel with the shared-VM chain).
|
|
||||||
types: [rustfs-chain-performance]
|
types: [rustfs-chain-performance]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
# Dedicated pf-testing runner/environment: own concurrency group so perf runs
|
# The default performance nodes overlap the other suites' remote VMs, even
|
||||||
# never block (or are blocked by) the pool-expansion / heal tests.
|
# though the runner differs. Hold the shared lock through cleanup as well.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: rustfs-performance-test
|
group: rustfs-shared-functional-tests
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
@@ -76,22 +75,33 @@ env:
|
|||||||
# Package used by the nightly run (workflow_dispatch inputs are empty for
|
# Package used by the nightly run (workflow_dispatch inputs are empty for
|
||||||
# workflow_run events), i.e. the latest nightly deb published by nightly-gnu.yml.
|
# workflow_run events), i.e. the latest nightly deb published by nightly-gnu.yml.
|
||||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||||
# Fixed benchmark result directory so later steps can read summary.md
|
|
||||||
RUSTFS_RESULT_DIR: /tmp/rustfs-perf-results
|
|
||||||
# Cross-repo token for uploading reports to rustfs/dashboard (set in repo settings)
|
# Cross-repo token for uploading reports to rustfs/dashboard (set in repo settings)
|
||||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
performance-test:
|
performance-test:
|
||||||
runs-on: pf-testing
|
runs-on: pf-testing
|
||||||
# Requirement: a failing benchmark must not fail the workflow;
|
|
||||||
# failures are filed to rustfs/backlog.
|
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 900
|
timeout-minutes: 900
|
||||||
# Run on manual dispatch, or when the nightly build completed successfully.
|
# Run on manual dispatch, or when the nightly build completed successfully.
|
||||||
# Skipped when nightly failed.
|
# Skipped when nightly failed.
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-performance-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'RUSTFS_RESULT_DIR=%s/results\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'VERSION_FILE=%s/version.txt\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -123,7 +133,7 @@ jobs:
|
|||||||
if: ${{ inputs.cleanup_before != 'false' }}
|
if: ${{ inputs.cleanup_before != 'false' }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x auto-testing/rustfs_performance_test.sh
|
chmod +x auto-testing/rustfs_performance_test.sh
|
||||||
./auto-testing/rustfs_performance_test.sh --step 1 -y
|
./auto-testing/rustfs_performance_test.sh --step 1 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||||
|
|
||||||
- name: Install RustFS package & start cluster (4x4)
|
- name: Install RustFS package & start cluster (4x4)
|
||||||
run: |
|
run: |
|
||||||
@@ -133,7 +143,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
fi
|
fi
|
||||||
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}"
|
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Preflight checks
|
- name: Preflight checks
|
||||||
run: |
|
run: |
|
||||||
@@ -143,7 +153,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
fi
|
fi
|
||||||
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}"
|
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Run benchmark (GET/PUT/MIXED)
|
- name: Run benchmark (GET/PUT/MIXED)
|
||||||
id: benchmark
|
id: benchmark
|
||||||
@@ -156,17 +166,15 @@ jobs:
|
|||||||
--step 5 -y \
|
--step 5 -y \
|
||||||
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
|
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
|
||||||
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
|
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
|
||||||
--log-file /tmp/rustfs-perf-test.log
|
--log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Analyze results
|
- name: Analyze results
|
||||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||||
run: |
|
run: |
|
||||||
./auto-testing/rustfs_performance_test.sh --step 6 -y
|
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||||
|
|
||||||
- name: Collect RustFS version info
|
- name: Collect RustFS version info
|
||||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||||
env:
|
|
||||||
VERSION_FILE: /tmp/rustfs-version.txt
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
read -r -a NODES <<< "${RUSTFS_NODES}"
|
read -r -a NODES <<< "${RUSTFS_NODES}"
|
||||||
@@ -186,7 +194,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
RESULT_DIR: ${{ env.RUSTFS_RESULT_DIR }}
|
RESULT_DIR: ${{ env.RUSTFS_RESULT_DIR }}
|
||||||
VERSION_FILE: /tmp/rustfs-version.txt
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -194,7 +201,7 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
SUMMARY="${RESULT_DIR}/summary.md"
|
SUMMARY="${RESULT_DIR}/summary.md"
|
||||||
[ -f "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
|
[ -s "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="reports/${DATE}.md"
|
REPORT_PATH="reports/${DATE}.md"
|
||||||
{
|
{
|
||||||
@@ -202,6 +209,8 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- **Date**: ${DATE}"
|
echo "- **Date**: ${DATE}"
|
||||||
echo "- **Run**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
echo "- **Run**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
echo "- **Attempt**: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- **Workflow Commit**: ${GITHUB_SHA}"
|
||||||
echo "- **Trigger**: ${{ github.event_name }}"
|
echo "- **Trigger**: ${{ github.event_name }}"
|
||||||
echo "- **Package**: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
echo "- **Package**: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -211,8 +220,8 @@ jobs:
|
|||||||
echo '```text'
|
echo '```text'
|
||||||
cat "${VERSION_FILE}"
|
cat "${VERSION_FILE}"
|
||||||
echo '```'
|
echo '```'
|
||||||
} > /tmp/rustfs-perf-report.md
|
} > "${REPORT_FILE}"
|
||||||
CONTENT="$(python3 -c 'import base64; print(base64.b64encode(open("/tmp/rustfs-perf-report.md","rb").read()).decode())')"
|
CONTENT="$(python3 -c 'import base64,sys; print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report: ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report: ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||||
@@ -231,11 +240,10 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 'performance'
|
SUITE: 'performance'
|
||||||
SUITE_LABEL: 'Performance'
|
SUITE_LABEL: 'Performance'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-perf-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-perf-test.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -263,14 +271,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -286,20 +296,26 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload test logs & results
|
- name: Upload test logs & results
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-perf-test-${{ github.run_id }}
|
name: rustfs-perf-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-perf-test*.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-perf-results/**
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
/tmp/rustfs-version.txt
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/version.txt
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/master.log
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/summary.md
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/summary.tsv
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/get_*.txt
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/put_*.txt
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/mixed_*.txt
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Reset test environment (after)
|
- name: Reset test environment (after)
|
||||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||||
run: |
|
run: |
|
||||||
./auto-testing/rustfs_performance_test.sh --step 7 -y
|
./auto-testing/rustfs_performance_test.sh --step 7 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||||
|
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|||||||
@@ -76,9 +76,6 @@ jobs:
|
|||||||
pool-expansion-test:
|
pool-expansion-test:
|
||||||
name: Pool expansion / decommission test
|
name: Pool expansion / decommission test
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
# Requirement: a failing suite must not fail the workflow; failures
|
|
||||||
# are filed to rustfs/backlog and the chain continues.
|
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 360
|
timeout-minutes: 360
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
env:
|
env:
|
||||||
@@ -542,17 +539,22 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.pool_test.outcome == 'failure' || steps.pool_test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.pool_test.outcome == 'failure' || steps.pool_test.outcome == 'cancelled') }}
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ on:
|
|||||||
- site
|
- site
|
||||||
default: all
|
default: all
|
||||||
repository_dispatch:
|
repository_dispatch:
|
||||||
# Chain handoff: dispatched when the security suite finishes. This is the
|
# Chain handoff: dispatched when the security suite finishes.
|
||||||
# last link of the functional chain.
|
|
||||||
types: [rustfs-chain-replication]
|
types: [rustfs-chain-replication]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -62,12 +61,28 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
replication-test:
|
replication-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
# A failed replication run must not break the chain or the workflow: the
|
|
||||||
# failure is reported to rustfs/backlog instead (see the issue step).
|
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 360
|
timeout-minutes: 360
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository (for report parser)
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-replication-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -116,9 +131,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Run replication suite
|
- name: Run replication suite
|
||||||
id: test
|
id: test
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-replication.log
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x auto-testing/rustfs-replication-test.sh
|
chmod +x auto-testing/rustfs-replication-test.sh
|
||||||
@@ -141,10 +153,7 @@ jobs:
|
|||||||
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-replication.log
|
|
||||||
REPORT_FILE: /tmp/rustfs-replication-report.md
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PACKAGE_URL='${{ inputs.package_url }}'
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
@@ -166,80 +175,44 @@ jobs:
|
|||||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="/tmp/rustfs-replication-cases.md"
|
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
CASE_RESULT=success
|
||||||
import re
|
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||||
import sys
|
RESULT=failure
|
||||||
|
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
RESULT=success
|
||||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
fi
|
||||||
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
|
||||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
index = {}
|
|
||||||
try:
|
|
||||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
|
||||||
for raw in fh:
|
|
||||||
line = ansi.sub('', raw).strip()
|
|
||||||
m = start_re.match(line)
|
|
||||||
if m:
|
|
||||||
case_id, name = m.group(1), m.group(2)
|
|
||||||
if case_id not in index:
|
|
||||||
index[case_id] = len(rows)
|
|
||||||
rows.append([case_id, name, 'RUNNING'])
|
|
||||||
continue
|
|
||||||
m = done_re.match(line)
|
|
||||||
if m:
|
|
||||||
status, case_id = m.group(1), m.group(2)
|
|
||||||
if case_id in index:
|
|
||||||
rows[index[case_id]][2] = status
|
|
||||||
else:
|
|
||||||
rows.append([case_id, case_id, status])
|
|
||||||
index[case_id] = len(rows) - 1
|
|
||||||
except FileNotFoundError:
|
|
||||||
rows = []
|
|
||||||
|
|
||||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
|
||||||
for _, _, status in rows:
|
|
||||||
counts[status] = counts.get(status, 0) + 1
|
|
||||||
|
|
||||||
with open(out_file, 'w', encoding='utf-8') as out:
|
|
||||||
out.write('## Case Summary\n\n')
|
|
||||||
out.write(f"- Total: {len(rows)}\\n")
|
|
||||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
|
||||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
|
||||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
|
||||||
out.write('\\n')
|
|
||||||
out.write('| Case | Name | Status |\\n')
|
|
||||||
out.write('| --- | --- | --- |\\n')
|
|
||||||
for case_id, name, status in rows:
|
|
||||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
|
||||||
PY
|
|
||||||
{
|
{
|
||||||
echo "# RustFS replication test report"
|
echo "# RustFS replication test report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${{ github.event_name }}"
|
echo "- Trigger: ${{ github.event_name }}"
|
||||||
echo "- Package: ${PACKAGE_SOURCE}"
|
echo "- Package: ${PACKAGE_SOURCE}"
|
||||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
echo "- Test Step Outcome: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${CASE_TABLE}" || true
|
if [ "${RESULT}" = "success" ]; then
|
||||||
echo ""
|
cat "${CASE_TABLE}"
|
||||||
echo "## Log tail"
|
echo ""
|
||||||
echo '```text'
|
echo "## Log tail"
|
||||||
tail -n 200 "${LOG_FILE}" || true
|
echo '```text'
|
||||||
echo '```'
|
tail -n 200 "${LOG_FILE}"
|
||||||
|
echo '```'
|
||||||
|
else
|
||||||
|
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||||
|
fi
|
||||||
} | tee "${REPORT_FILE}"
|
} | tee "${REPORT_FILE}"
|
||||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-replication-report.md
|
|
||||||
SUITE: replication
|
SUITE: replication
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -249,28 +222,32 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 'replication'
|
SUITE: 'replication'
|
||||||
SUITE_LABEL: 'Replication (bucket + site)'
|
SUITE_LABEL: 'Replication (bucket + site)'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-replication-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-replication.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -298,14 +275,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -321,14 +300,15 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload report and logs
|
- name: Upload report and logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-replication-${{ github.run_id }}
|
name: rustfs-replication-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-replication.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-replication-report.md
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: always()
|
if: always()
|
||||||
@@ -349,13 +329,50 @@ jobs:
|
|||||||
'
|
'
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Chain complete
|
- name: "Continue functional chain (next: Performance)"
|
||||||
# Replication is the last link of the functional chain: nothing to
|
|
||||||
# dispatch after it. This step just records that the chain finished.
|
|
||||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "Functional chain complete: replication (final suite) finished."
|
set -uo pipefail
|
||||||
echo "from_suite=security trigger=${{ github.event_name }} outcome=${{ steps.test.outcome }}"
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DISPATCHED=0
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||||
|
-f event_type='rustfs-chain-performance' \
|
||||||
|
-F 'client_payload[from_suite]=replication'; then
|
||||||
|
echo "dispatched next suite Performance (attempt ${attempt})"
|
||||||
|
DISPATCHED=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||||
|
sleep "${attempt}0"
|
||||||
|
done
|
||||||
|
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||||
|
echo "ERROR: functional chain stalled: could not dispatch Performance after 3 attempts" >&2
|
||||||
|
TITLE="[functional][chain] stalled after replication (run ${GITHUB_RUN_ID})"
|
||||||
|
BODY_FILE="$(mktemp)"
|
||||||
|
trap 'rm -f "${BODY_FILE}"' EXIT
|
||||||
|
{
|
||||||
|
echo "The functional chain could not hand off from **replication** to **Performance** after 3 attempts."
|
||||||
|
echo ""
|
||||||
|
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||||
|
echo "- Expected next event: 'rustfs-chain-performance'"
|
||||||
|
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||||
|
echo "- Recovery: re-dispatch manually with"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-performance'"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
} > "${BODY_FILE}"
|
||||||
|
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||||
|
--body-file "${BODY_FILE}" --label functional-test \
|
||||||
|
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||||
|
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|||||||
@@ -37,10 +37,28 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
s3-compat-test:
|
s3-compat-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 360
|
timeout-minutes: 360
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository (for report parser)
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-s3-compat-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -88,9 +106,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Run S3 compatibility suite
|
- name: Run S3 compatibility suite
|
||||||
id: test
|
id: test
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-s3-compat.log
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x auto-testing/rustfs-s3-compat-test.sh
|
chmod +x auto-testing/rustfs-s3-compat-test.sh
|
||||||
@@ -107,10 +122,7 @@ jobs:
|
|||||||
./auto-testing/rustfs-s3-compat-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-s3-compat-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-s3-compat.log
|
|
||||||
REPORT_FILE: /tmp/rustfs-s3-compat-report.md
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PACKAGE_URL='${{ inputs.package_url }}'
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
@@ -132,83 +144,44 @@ jobs:
|
|||||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="/tmp/rustfs-s3-compat-cases.md"
|
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
CASE_RESULT=success
|
||||||
import re
|
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||||
import sys
|
RESULT=failure
|
||||||
|
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
RESULT=success
|
||||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
fi
|
||||||
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
|
||||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
index = {}
|
|
||||||
current = None
|
|
||||||
try:
|
|
||||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
|
||||||
for raw in fh:
|
|
||||||
line = ansi.sub('', raw).strip()
|
|
||||||
m = start_re.match(line)
|
|
||||||
if m:
|
|
||||||
case_id, name = m.group(1), m.group(2)
|
|
||||||
current = case_id
|
|
||||||
if case_id not in index:
|
|
||||||
index[case_id] = len(rows)
|
|
||||||
rows.append([case_id, name, 'RUNNING'])
|
|
||||||
continue
|
|
||||||
m = done_re.match(line)
|
|
||||||
if m:
|
|
||||||
status, case_id = m.group(1), m.group(2)
|
|
||||||
if case_id in index:
|
|
||||||
rows[index[case_id]][2] = status
|
|
||||||
else:
|
|
||||||
rows.append([case_id, case_id, status])
|
|
||||||
index[case_id] = len(rows) - 1
|
|
||||||
current = None
|
|
||||||
except FileNotFoundError:
|
|
||||||
rows = []
|
|
||||||
|
|
||||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
|
||||||
for _, _, status in rows:
|
|
||||||
counts[status] = counts.get(status, 0) + 1
|
|
||||||
|
|
||||||
with open(out_file, 'w', encoding='utf-8') as out:
|
|
||||||
out.write('## Case Summary\n\n')
|
|
||||||
out.write(f"- Total: {len(rows)}\\n")
|
|
||||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
|
||||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
|
||||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
|
||||||
out.write('\\n')
|
|
||||||
out.write('| Case | Name | Status |\\n')
|
|
||||||
out.write('| --- | --- | --- |\\n')
|
|
||||||
for case_id, name, status in rows:
|
|
||||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
|
||||||
PY
|
|
||||||
{
|
{
|
||||||
echo "# RustFS S3 compatibility test report"
|
echo "# RustFS S3 compatibility test report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${{ github.event_name }}"
|
echo "- Trigger: ${{ github.event_name }}"
|
||||||
echo "- Package: ${PACKAGE_SOURCE}"
|
echo "- Package: ${PACKAGE_SOURCE}"
|
||||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
echo "- Test Step Outcome: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${CASE_TABLE}" || true
|
if [ "${RESULT}" = "success" ]; then
|
||||||
echo ""
|
cat "${CASE_TABLE}"
|
||||||
echo "## Log tail"
|
echo ""
|
||||||
echo '```text'
|
echo "## Log tail"
|
||||||
tail -n 200 "${LOG_FILE}" || true
|
echo '```text'
|
||||||
echo '```'
|
tail -n 200 "${LOG_FILE}"
|
||||||
|
echo '```'
|
||||||
|
else
|
||||||
|
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||||
|
fi
|
||||||
} | tee "${REPORT_FILE}"
|
} | tee "${REPORT_FILE}"
|
||||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-s3-compat-report.md
|
|
||||||
SUITE: s3
|
SUITE: s3
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -218,28 +191,32 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 's3'
|
SUITE: 's3'
|
||||||
SUITE_LABEL: 'S3 compatibility'
|
SUITE_LABEL: 'S3 compatibility'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-s3-compat-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-s3-compat.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -267,14 +244,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -290,14 +269,15 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload report and logs
|
- name: Upload report and logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-s3-compat-${{ github.run_id }}
|
name: rustfs-s3-compat-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-s3-compat.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-s3-compat-report.md
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -77,10 +77,14 @@ jobs:
|
|||||||
timeout-minutes: 360
|
timeout-minutes: 360
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
# Checkout the repository into its own subdirectory. Checking out at
|
||||||
|
# the workspace root would wipe the auto-testing clone above (that is
|
||||||
|
# exactly how run 33934141181 lost rustfs-security-test.sh).
|
||||||
- name: Checkout repository (for the OIDC live gate script)
|
- name: Checkout repository (for the OIDC live gate script)
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
path: rustfs-repo
|
||||||
|
|
||||||
- name: Initialize security evidence
|
- name: Initialize security evidence
|
||||||
id: evidence
|
id: evidence
|
||||||
@@ -88,7 +92,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
umask 077
|
umask 077
|
||||||
SECURITY_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-security-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
SECURITY_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-security-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
mkdir -- "${SECURITY_ARTIFACTS_DIR}"
|
mkdir -- "${SECURITY_ARTIFACTS_DIR}" "${SECURITY_ARTIFACTS_DIR}-scratch"
|
||||||
printf 'SECURITY_ARTIFACTS_DIR=%s\n' "${SECURITY_ARTIFACTS_DIR}" >> "${GITHUB_ENV}"
|
printf 'SECURITY_ARTIFACTS_DIR=%s\n' "${SECURITY_ARTIFACTS_DIR}" >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
@@ -144,8 +148,8 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
||||||
TMPDIR: ${{ env.SECURITY_ARTIFACTS_DIR }}
|
TMPDIR: ${{ env.SECURITY_ARTIFACTS_DIR }}-scratch
|
||||||
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/scripts/test/oidc_keycloak_live.sh
|
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/rustfs-repo/scripts/test/oidc_keycloak_live.sh
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x auto-testing/rustfs-security-test.sh
|
chmod +x auto-testing/rustfs-security-test.sh
|
||||||
@@ -168,7 +172,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||||
fi
|
fi
|
||||||
GITHUB_STEP_SUMMARY=/dev/null ./auto-testing/rustfs-security-test.sh "${ARGS[@]}"
|
GITHUB_STEP_SUMMARY=/dev/null ./auto-testing/rustfs-security-test.sh "${ARGS[@]}" 2>&1 | tee "${SECURITY_ARTIFACTS_DIR}/suite.log"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
id: report
|
id: report
|
||||||
@@ -219,17 +223,22 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
@@ -296,7 +305,10 @@ jobs:
|
|||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-security-test-${{ github.run_id }}-${{ github.run_attempt }}
|
name: rustfs-security-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: ${{ env.SECURITY_ARTIFACTS_DIR }}/
|
path: |
|
||||||
|
${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
|
||||||
|
${{ env.SECURITY_ARTIFACTS_DIR }}/suite.log
|
||||||
|
${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,28 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
storage-test:
|
storage-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 360
|
timeout-minutes: 360
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository (for report parser)
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-storage-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -97,9 +115,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Run storage engine suite
|
- name: Run storage engine suite
|
||||||
id: test
|
id: test
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-storage.log
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x auto-testing/rustfs-storage-test.sh
|
chmod +x auto-testing/rustfs-storage-test.sh
|
||||||
@@ -122,10 +137,7 @@ jobs:
|
|||||||
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-storage.log
|
|
||||||
REPORT_FILE: /tmp/rustfs-storage-report.md
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PACKAGE_URL='${{ inputs.package_url }}'
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
@@ -147,83 +159,44 @@ jobs:
|
|||||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="/tmp/rustfs-storage-cases.md"
|
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
CASE_RESULT=success
|
||||||
import re
|
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||||
import sys
|
RESULT=failure
|
||||||
|
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
RESULT=success
|
||||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
fi
|
||||||
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
|
||||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
index = {}
|
|
||||||
current = None
|
|
||||||
try:
|
|
||||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
|
||||||
for raw in fh:
|
|
||||||
line = ansi.sub('', raw).strip()
|
|
||||||
m = start_re.match(line)
|
|
||||||
if m:
|
|
||||||
case_id, name = m.group(1), m.group(2)
|
|
||||||
current = case_id
|
|
||||||
if case_id not in index:
|
|
||||||
index[case_id] = len(rows)
|
|
||||||
rows.append([case_id, name, 'RUNNING'])
|
|
||||||
continue
|
|
||||||
m = done_re.match(line)
|
|
||||||
if m:
|
|
||||||
status, case_id = m.group(1), m.group(2)
|
|
||||||
if case_id in index:
|
|
||||||
rows[index[case_id]][2] = status
|
|
||||||
else:
|
|
||||||
rows.append([case_id, case_id, status])
|
|
||||||
index[case_id] = len(rows) - 1
|
|
||||||
current = None
|
|
||||||
except FileNotFoundError:
|
|
||||||
rows = []
|
|
||||||
|
|
||||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
|
||||||
for _, _, status in rows:
|
|
||||||
counts[status] = counts.get(status, 0) + 1
|
|
||||||
|
|
||||||
with open(out_file, 'w', encoding='utf-8') as out:
|
|
||||||
out.write('## Case Summary\n\n')
|
|
||||||
out.write(f"- Total: {len(rows)}\\n")
|
|
||||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
|
||||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
|
||||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
|
||||||
out.write('\\n')
|
|
||||||
out.write('| Case | Name | Status |\\n')
|
|
||||||
out.write('| --- | --- | --- |\\n')
|
|
||||||
for case_id, name, status in rows:
|
|
||||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
|
||||||
PY
|
|
||||||
{
|
{
|
||||||
echo "# RustFS storage engine test report"
|
echo "# RustFS storage engine test report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${{ github.event_name }}"
|
echo "- Trigger: ${{ github.event_name }}"
|
||||||
echo "- Package: ${PACKAGE_SOURCE}"
|
echo "- Package: ${PACKAGE_SOURCE}"
|
||||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
echo "- Test Step Outcome: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${CASE_TABLE}" || true
|
if [ "${RESULT}" = "success" ]; then
|
||||||
echo ""
|
cat "${CASE_TABLE}"
|
||||||
echo "## Log tail"
|
echo ""
|
||||||
echo '```text'
|
echo "## Log tail"
|
||||||
tail -n 200 "${LOG_FILE}" || true
|
echo '```text'
|
||||||
echo '```'
|
tail -n 200 "${LOG_FILE}"
|
||||||
|
echo '```'
|
||||||
|
else
|
||||||
|
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||||
|
fi
|
||||||
} | tee "${REPORT_FILE}"
|
} | tee "${REPORT_FILE}"
|
||||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-storage-report.md
|
|
||||||
SUITE: storage
|
SUITE: storage
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -233,28 +206,32 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 'storage'
|
SUITE: 'storage'
|
||||||
SUITE_LABEL: 'Storage engine'
|
SUITE_LABEL: 'Storage engine'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-storage-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-storage.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -282,14 +259,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -305,14 +284,15 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload report and logs
|
- name: Upload report and logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-storage-${{ github.run_id }}
|
name: rustfs-storage-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-storage.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-storage-report.md
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -61,9 +61,6 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
tier-test:
|
tier-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
# Requirement: a failing suite must not fail the workflow; failures
|
|
||||||
# are filed to rustfs/backlog and the chain continues.
|
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 420
|
timeout-minutes: 420
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
@@ -380,17 +377,22 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: Verify required tier evidence
|
- name: Verify required tier evidence
|
||||||
id: evidence_verify
|
id: evidence_verify
|
||||||
|
|||||||
@@ -79,10 +79,28 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
upgrade-test:
|
upgrade-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 420
|
timeout-minutes: 420
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository (for report parser)
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-upgrade-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -142,9 +160,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run upgrade compatibility suite
|
- name: Run upgrade compatibility suite
|
||||||
id: test
|
id: test
|
||||||
continue-on-error: true
|
|
||||||
env:
|
env:
|
||||||
LOG_FILE: /tmp/rustfs-upgrade.log
|
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -202,10 +218,7 @@ jobs:
|
|||||||
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-upgrade.log
|
|
||||||
REPORT_FILE: /tmp/rustfs-upgrade-report.md
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
FROM_URL='${{ inputs.from_url }}'
|
FROM_URL='${{ inputs.from_url }}'
|
||||||
@@ -226,103 +239,47 @@ jobs:
|
|||||||
else
|
else
|
||||||
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="/tmp/rustfs-upgrade-cases.md"
|
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||||
MATRIX_TABLE="/tmp/rustfs-upgrade-matrix.md"
|
MATRIX_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/matrix.md"
|
||||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" <<'PY'
|
CASE_RESULT=success
|
||||||
import re
|
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" || CASE_RESULT=failure
|
||||||
import sys
|
RESULT=failure
|
||||||
|
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||||
log_file, out_file, matrix_file = sys.argv[1], sys.argv[2], sys.argv[3]
|
RESULT=success
|
||||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
fi
|
||||||
start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
|
|
||||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
|
|
||||||
topo_re = re.compile(
|
|
||||||
r'^\[UPG-TOPO\]\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+PASS=(\d+)\s+FAIL=(\d+)\s*$')
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
index = {}
|
|
||||||
topo_rows = []
|
|
||||||
try:
|
|
||||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
|
||||||
for raw in fh:
|
|
||||||
line = ansi.sub('', raw).strip()
|
|
||||||
m = topo_re.match(line)
|
|
||||||
if m:
|
|
||||||
topo_rows.append(m.groups())
|
|
||||||
continue
|
|
||||||
m = start_re.match(line)
|
|
||||||
if m:
|
|
||||||
case_id, name = m.group(1), m.group(2)
|
|
||||||
if case_id not in index:
|
|
||||||
index[case_id] = len(rows)
|
|
||||||
rows.append([case_id, name, 'RUNNING'])
|
|
||||||
continue
|
|
||||||
m = done_re.match(line)
|
|
||||||
if m:
|
|
||||||
status, case_id = m.group(1), m.group(2)
|
|
||||||
if case_id in index:
|
|
||||||
rows[index[case_id]][2] = status
|
|
||||||
else:
|
|
||||||
rows.append([case_id, case_id, status])
|
|
||||||
index[case_id] = len(rows) - 1
|
|
||||||
except FileNotFoundError:
|
|
||||||
rows = []
|
|
||||||
|
|
||||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
|
||||||
for _, _, status in rows:
|
|
||||||
counts[status] = counts.get(status, 0) + 1
|
|
||||||
|
|
||||||
with open(out_file, 'w', encoding='utf-8') as out:
|
|
||||||
out.write('## Case Summary\n\n')
|
|
||||||
out.write(f"- Total: {len(rows)}\\n")
|
|
||||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
|
||||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
|
||||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
|
||||||
out.write('\\n')
|
|
||||||
out.write('| Case | Name | Status |\\n')
|
|
||||||
out.write('| --- | --- | --- |\\n')
|
|
||||||
for case_id, name, status in rows:
|
|
||||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
|
||||||
|
|
||||||
# Upgrade matrix: one row per topology/backend with the versions
|
|
||||||
# captured on the nodes (rustfs --version) and the aggregated
|
|
||||||
# result. The dashboard renders this table directly.
|
|
||||||
with open(matrix_file, 'w', encoding='utf-8') as out:
|
|
||||||
out.write('## Upgrade Matrix\n\n')
|
|
||||||
out.write('| Topology | KMS Backend | From Version | To Version | Result |\n')
|
|
||||||
out.write('| --- | --- | --- | --- | --- |\n')
|
|
||||||
for topo, backend, old_v, new_v, npass, nfail in topo_rows:
|
|
||||||
result = 'PASS' if nfail == '0' else 'FAIL'
|
|
||||||
out.write(f'| {topo} | {backend} | {old_v} | {new_v} | {result} (PASS={npass} FAIL={nfail}) |\n')
|
|
||||||
if not topo_rows:
|
|
||||||
out.write('| - | - | - | - | NOT RUN (suite failed before upgrade) |\n')
|
|
||||||
PY
|
|
||||||
{
|
{
|
||||||
echo "# RustFS upgrade compatibility report"
|
echo "# RustFS upgrade compatibility report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${{ github.event_name }}"
|
echo "- Trigger: ${{ github.event_name }}"
|
||||||
echo "- From: ${FROM_SOURCE}"
|
echo "- From: ${FROM_SOURCE}"
|
||||||
echo "- To: ${TO_SOURCE}"
|
echo "- To: ${TO_SOURCE}"
|
||||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
echo "- Test Step Outcome: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${MATRIX_TABLE}" || true
|
if [ "${RESULT}" = "success" ]; then
|
||||||
echo ""
|
cat "${MATRIX_TABLE}"
|
||||||
cat "${CASE_TABLE}" || true
|
echo ""
|
||||||
echo ""
|
cat "${CASE_TABLE}"
|
||||||
echo "## Log tail"
|
echo ""
|
||||||
echo '```text'
|
echo "## Log tail"
|
||||||
tail -n 200 "${LOG_FILE}" || true
|
echo '```text'
|
||||||
echo '```'
|
tail -n 200 "${LOG_FILE}"
|
||||||
|
echo '```'
|
||||||
|
else
|
||||||
|
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||||
|
fi
|
||||||
} | tee "${REPORT_FILE}"
|
} | tee "${REPORT_FILE}"
|
||||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-upgrade-report.md
|
|
||||||
SUITE: upgrade
|
SUITE: upgrade
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -332,28 +289,32 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 'upgrade'
|
SUITE: 'upgrade'
|
||||||
SUITE_LABEL: 'Upgrade compatibility'
|
SUITE_LABEL: 'Upgrade compatibility'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-upgrade-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-upgrade.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -381,14 +342,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -404,14 +367,16 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload report and logs
|
- name: Upload report and logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-upgrade-test-${{ github.run_id }}
|
name: rustfs-upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-upgrade-report.md
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-upgrade.*/*
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: ignore
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/matrix.md
|
||||||
|
if-no-files-found: error
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ on:
|
|||||||
- "Continuous Integration"
|
- "Continuous Integration"
|
||||||
- "coverage"
|
- "coverage"
|
||||||
- "e2e-nightly"
|
- "e2e-nightly"
|
||||||
|
- "e2e-distributed"
|
||||||
- "e2e-s3tests"
|
- "e2e-s3tests"
|
||||||
- "Fuzz"
|
- "Fuzz"
|
||||||
- "mint"
|
- "mint"
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: rustfs-dev-check
|
- id: rustfs-fmt-check
|
||||||
name: rustfs dev-check
|
name: Rust formatting
|
||||||
entry: make dev-check
|
entry: cargo fmt --all --check
|
||||||
language: system
|
language: system
|
||||||
types: [rust]
|
types: [rust]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|||||||
+4
-1
@@ -18,7 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Read paths: an object at or below `policy.inline_max_bytes` (16 MiB by default) is teed to the client and to the local store in a single source read; a larger object or a Range read streams through and a background pull stores the whole object. A HEAD miss is proxied to the source and stores nothing (`policy.head = local_only` disables it). Every source-backed response carries `x-rustfs-on-demand-migration: source`
|
- Read paths: an object at or below `policy.inline_max_bytes` (16 MiB by default) is teed to the client and to the local store in a single source read; a larger object or a Range read streams through and a background pull stores the whole object. A HEAD miss is proxied to the source and stores nothing (`policy.head = local_only` disables it). Every source-backed response carries `x-rustfs-on-demand-migration: source`
|
||||||
- Protections: a per-source circuit breaker, a per-key negative cache, singleflight per key, a concurrency limit and a bounded pull queue shared by the inline and background paths, an optional bandwidth limit, an anti-loop request marker, and the shared outbound-endpoint (SSRF) policy
|
- Protections: a per-source circuit breaker, a per-key negative cache, singleflight per key, a concurrency limit and a bounded pull queue shared by the inline and background paths, an optional bandwidth limit, an anti-loop request marker, and the shared outbound-endpoint (SSRF) policy
|
||||||
- Metrics under `rustfs_on_demand_migration_*` (`requests_total`, `pulled_bytes_total`, `pulled_objects_total`, `pull_failures_total`, `inflight_pulls`, `queue_depth`, `source_latency_seconds_*`, `breaker_state`), mirrored per node by the admin status route
|
- Metrics under `rustfs_on_demand_migration_*` (`requests_total`, `pulled_bytes_total`, `pulled_objects_total`, `pull_failures_total`, `inflight_pulls`, `queue_depth`, `source_latency_seconds_*`, `breaker_state`), mirrored per node by the admin status route
|
||||||
- Limitations: listings show only local objects (the source is not merged into `ListObjectsV2`); PUT and DELETE never reach the source; a source object updated after it was pulled is not re-fetched; SSE-C source objects are unsupported and answer 424; `Last-Modified` on a pulled object is the local write time, with the source timestamp kept in metadata
|
- Listings: `ListObjects` v1 remains local with ordinary key markers. `ListObjectsV2` can merge source objects when `policy.list_through = true`; this is off by default
|
||||||
|
- Upgrade and rollback: finish upgrading every node before enabling ODM. An rc.5 node that writes bucket configuration drops the ODM fields from metadata; neither a later restart nor moving the service out of ECStore recovers them. Before rollback, disable ODM and securely retain the original full configuration and credentials. After every node returns to a compatible version, restore and validate that configuration. Redacted exports cannot replace the credential backup; source-only objects are unavailable through RustFS while ODM is disabled. See the upgrade and rollback section of `docs/operations/on-demand-migration.md`
|
||||||
|
- Optional Google dependencies: default and `full` server builds retain native GCS support. `cargo build -p rustfs --no-default-features --features ftps,webdav` excludes Google SDKs while preserving configuration decoding and redaction; native GCS ODM and tier operations require the `gcs` feature. Do not use that build with existing GCS-tiered data
|
||||||
|
- Limitations: PUT and DELETE never reach the source; a source object updated after it was pulled is not re-fetched; SSE-C source objects are unsupported and answer 424; `Last-Modified` on a pulled object is the local write time, with the source timestamp kept in metadata
|
||||||
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
|
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
|
||||||
- Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes
|
- Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes
|
||||||
- Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window
|
- Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window
|
||||||
|
|||||||
+11
-37
@@ -109,24 +109,17 @@ affected boundaries and risks. CI still runs its configured repository gates.
|
|||||||
|
|
||||||
### 🔒 Git Pre-commit Hooks (optional)
|
### 🔒 Git Pre-commit Hooks (optional)
|
||||||
|
|
||||||
Git hooks are **not** versioned in this repository, so a fresh clone has no
|
The optional hook uses the checked-in `.pre-commit-config.yaml`. Install [pre-commit](https://pre-commit.com/#installation), then run this from the checkout or a linked worktree:
|
||||||
active pre-commit hook. If you add your own `.git/hooks/pre-commit` (a good
|
|
||||||
choice is a one-liner that runs `make pre-commit`), you can mark it executable
|
|
||||||
with:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make setup-hooks
|
make setup-hooks
|
||||||
```
|
```
|
||||||
|
|
||||||
Or manually:
|
The hook runs `cargo fmt --all --check` when staged files include Rust source. It does not compile the workspace or run tests. Fix formatting with `cargo fmt --all`, inspect and stage the result, then commit again.
|
||||||
|
|
||||||
```bash
|
`pre-commit install` resolves Git's hook directory for linked worktrees and preserves an existing hook in migration mode. If you use `core.hooksPath`, keep that hook manager and integrate `pre-commit run` there; the installer refuses to silently replace that configuration.
|
||||||
chmod +x .git/hooks/pre-commit
|
|
||||||
```
|
|
||||||
|
|
||||||
With or without a hook, follow the verification tiers in `AGENTS.md`. Run the
|
A local hook provides early formatting feedback. With or without it, follow the verification tiers in `AGENTS.md`, run relevant behavioral tests, and satisfy the CI merge gates. `make pre-commit` and `make dev-check` remain explicit broader commands.
|
||||||
applicable scoped checks, and reserve `make pre-pr` for broad cross-module
|
|
||||||
changes whose impact cannot be bounded by those checks.
|
|
||||||
|
|
||||||
### 📝 Formatting Configuration
|
### 📝 Formatting Configuration
|
||||||
|
|
||||||
@@ -138,31 +131,11 @@ fn_call_width = 90
|
|||||||
single_line_let_else_max_width = 100
|
single_line_let_else_max_width = 100
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚫 Commit Prevention
|
|
||||||
|
|
||||||
If you set up a pre-commit hook and your code doesn't meet the formatting requirements, the hook will:
|
|
||||||
|
|
||||||
1. **Block the commit** and show clear error messages
|
|
||||||
2. **Provide exact commands** to fix the issues
|
|
||||||
3. **Guide you through** the resolution process
|
|
||||||
|
|
||||||
Example output when formatting fails:
|
|
||||||
|
|
||||||
```
|
|
||||||
❌ Code formatting check failed!
|
|
||||||
💡 Please run 'cargo fmt --all' to format your code before committing.
|
|
||||||
|
|
||||||
🔧 Quick fix:
|
|
||||||
cargo fmt --all
|
|
||||||
git add .
|
|
||||||
git commit
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔄 Development Workflow
|
### 🔄 Development Workflow
|
||||||
|
|
||||||
1. **Make your changes**
|
1. **Make your changes**
|
||||||
2. **Format your code**: `make fmt` or `cargo fmt --all`
|
2. **Format your code**: `make fmt` or `cargo fmt --all`
|
||||||
3. **Run the fast gate**: `make pre-commit` (no clippy, no tests)
|
3. **Select relevant checks** using the validation tier in `AGENTS.md`; use `make pre-commit` when its broader fast gate adds useful coverage
|
||||||
4. **Commit your changes**: `git commit -m "your message"`
|
4. **Commit your changes**: `git commit -m "your message"`
|
||||||
5. **Complete the applicable multi-role adversarial review** for non-exempt changes (see `AGENTS.md`)
|
5. **Complete the applicable multi-role adversarial review** for non-exempt changes (see `AGENTS.md`)
|
||||||
6. **Run applicable scoped checks before opening/updating a PR**; consider
|
6. **Run applicable scoped checks before opening/updating a PR**; consider
|
||||||
@@ -206,11 +179,12 @@ Configure your IDE to:
|
|||||||
#### Pre-commit hook not running?
|
#### Pre-commit hook not running?
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check if hook is executable
|
pre-commit validate-config
|
||||||
ls -la .git/hooks/pre-commit
|
pre-commit run --all-files
|
||||||
|
# Inspect any configured hook manager; do not overwrite it.
|
||||||
# Make it executable if needed
|
git config --get core.hooksPath
|
||||||
chmod +x .git/hooks/pre-commit
|
# Install if no separate hook manager is configured.
|
||||||
|
make setup-hooks
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Formatting issues?
|
#### Formatting issues?
|
||||||
|
|||||||
Generated
+67
-34
@@ -315,7 +315,7 @@ dependencies = [
|
|||||||
"strum",
|
"strum",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zstd",
|
"zstd 0.13.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -508,7 +508,7 @@ dependencies = [
|
|||||||
"arrow-select",
|
"arrow-select",
|
||||||
"flatbuffers",
|
"flatbuffers",
|
||||||
"lz4_flex",
|
"lz4_flex",
|
||||||
"zstd",
|
"zstd 0.13.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1679,7 +1679,7 @@ version = "0.10.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1698,7 +1698,7 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2110,7 +2110,7 @@ version = "0.4.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common 0.1.7",
|
"crypto-common 0.1.6",
|
||||||
"inout 0.1.4",
|
"inout 0.1.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2249,8 +2249,8 @@ dependencies = [
|
|||||||
"liblzma",
|
"liblzma",
|
||||||
"lz4",
|
"lz4",
|
||||||
"memchr",
|
"memchr",
|
||||||
"zstd",
|
"zstd 0.13.3",
|
||||||
"zstd-safe",
|
"zstd-safe 7.3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2580,7 +2580,7 @@ version = "0.5.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -2605,11 +2605,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3901,7 +3901,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.10.4",
|
"block-buffer 0.10.4",
|
||||||
"const-oid 0.9.6",
|
"const-oid 0.9.6",
|
||||||
"crypto-common 0.1.7",
|
"crypto-common 0.1.6",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4067,7 +4067,7 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"zip",
|
"zip",
|
||||||
"zstd",
|
"zstd 0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4166,7 +4166,7 @@ dependencies = [
|
|||||||
"crypto-bigint 0.5.5",
|
"crypto-bigint 0.5.5",
|
||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
"ff 0.13.1",
|
"ff 0.13.1",
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
"group 0.13.0",
|
"group 0.13.0",
|
||||||
"hkdf 0.12.4",
|
"hkdf 0.12.4",
|
||||||
"pem-rfc7468 0.7.0",
|
"pem-rfc7468 0.7.0",
|
||||||
@@ -4499,7 +4499,7 @@ checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"io-lifetimes 2.0.4",
|
"io-lifetimes 2.0.4",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4622,9 +4622,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
@@ -4637,7 +4637,7 @@ version = "1.4.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
|
checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
@@ -5319,9 +5319,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hotpath-macros"
|
name = "hotpath-macros"
|
||||||
version = "0.25.0"
|
version = "0.25.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "929b2285d2cd21b2733a7fb6ebc843bb4f83dbd1db0122f5f9ebb9567b1e2613"
|
checksum = "846bde0d9600d98434e1aac376977d7718bfe3d2f5312a041b7c59a6a466c51a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -5660,7 +5660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-padding 0.3.3",
|
"block-padding 0.3.3",
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5693,7 +5693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
|
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"io-lifetimes 3.0.1",
|
"io-lifetimes 3.0.1",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5971,7 +5971,7 @@ dependencies = [
|
|||||||
"lz4",
|
"lz4",
|
||||||
"snap",
|
"snap",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zstd",
|
"zstd 0.13.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7115,7 +7115,7 @@ version = "5.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.21.7",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
@@ -7658,7 +7658,7 @@ dependencies = [
|
|||||||
"snap",
|
"snap",
|
||||||
"tokio",
|
"tokio",
|
||||||
"twox-hash",
|
"twox-hash",
|
||||||
"zstd",
|
"zstd 0.13.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9493,6 +9493,8 @@ dependencies = [
|
|||||||
"atomic_enum",
|
"atomic_enum",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
|
"aws-smithy-runtime-api",
|
||||||
|
"aws-smithy-types",
|
||||||
"axum",
|
"axum",
|
||||||
"base64-simd",
|
"base64-simd",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -9501,11 +9503,13 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"const-str",
|
"const-str",
|
||||||
"datafusion",
|
"datafusion",
|
||||||
|
"faster-hex",
|
||||||
"flatbuffers",
|
"flatbuffers",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"google-cloud-auth",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
"hex-simd",
|
"hex-simd",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
@@ -9524,6 +9528,7 @@ dependencies = [
|
|||||||
"metrics",
|
"metrics",
|
||||||
"metrics-util",
|
"metrics-util",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
"moka",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
"p256 0.14.0",
|
"p256 0.14.0",
|
||||||
@@ -9616,9 +9621,10 @@ dependencies = [
|
|||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
"x509-parser",
|
"x509-parser",
|
||||||
|
"xxhash-rust",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
"zip",
|
"zip",
|
||||||
"zstd",
|
"zstd 0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10228,7 +10234,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"zip",
|
"zip",
|
||||||
"zstd",
|
"zstd 0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10395,7 +10401,7 @@ dependencies = [
|
|||||||
"tracing-opentelemetry",
|
"tracing-opentelemetry",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
"zstd",
|
"zstd 0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10985,7 +10991,7 @@ dependencies = [
|
|||||||
"transform-stream",
|
"transform-stream",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows",
|
||||||
"zstd",
|
"zstd 0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -11399,7 +11405,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base16ct 0.2.0",
|
"base16ct 0.2.0",
|
||||||
"der 0.7.10",
|
"der 0.7.10",
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.9",
|
||||||
"pkcs8 0.10.2",
|
"pkcs8 0.10.2",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -12399,7 +12405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -13651,6 +13657,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -13823,7 +13838,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
|
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -14095,7 +14110,7 @@ dependencies = [
|
|||||||
"typed-path",
|
"typed-path",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
"zopfli",
|
"zopfli",
|
||||||
"zstd",
|
"zstd 0.13.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -14128,7 +14143,16 @@ version = "0.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zstd-safe",
|
"zstd-safe 7.3.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-safe 8.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -14140,6 +14164,15 @@ dependencies = [
|
|||||||
"zstd-sys",
|
"zstd-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-safe"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zstd-sys"
|
name = "zstd-sys"
|
||||||
version = "2.1.0+zstd.1.5.7"
|
version = "2.1.0+zstd.1.5.7"
|
||||||
|
|||||||
+7
-6
@@ -199,10 +199,10 @@ serde_urlencoded = "0.7.1"
|
|||||||
# matching stable releases are not available yet, while previous stable lines
|
# matching stable releases are not available yet, while previous stable lines
|
||||||
# have incompatible APIs. Keep them exact-pinned and monitor upstream for stable
|
# have incompatible APIs. Keep them exact-pinned and monitor upstream for stable
|
||||||
# releases.
|
# releases.
|
||||||
aes-gcm = { version = "=0.11.1" }
|
aes-gcm = { version = "0.11.1" }
|
||||||
argon2 = { version = "=0.6.0" }
|
argon2 = { version = "0.6.0" }
|
||||||
blake2 = "=0.11.0"
|
blake2 = "0.11.0"
|
||||||
chacha20poly1305 = { version = "=0.11.0" }
|
chacha20poly1305 = { version = "0.11.0" }
|
||||||
crc-fast = "1.10.0"
|
crc-fast = "1.10.0"
|
||||||
hmac = { version = "0.13.0" }
|
hmac = { version = "0.13.0" }
|
||||||
jsonwebtoken = { version = "11.0.0" }
|
jsonwebtoken = { version = "11.0.0" }
|
||||||
@@ -343,7 +343,7 @@ windows = { version = "0.62.2" }
|
|||||||
windows-sys = "0.61.2"
|
windows-sys = "0.61.2"
|
||||||
xxhash-rust = { version = "0.8.18" }
|
xxhash-rust = { version = "0.8.18" }
|
||||||
zip = "8.6.0"
|
zip = "8.6.0"
|
||||||
zstd = "0.13.3"
|
zstd = "0.14.0"
|
||||||
|
|
||||||
# Observability and Metrics
|
# Observability and Metrics
|
||||||
metrics = "0.24.6"
|
metrics = "0.24.6"
|
||||||
@@ -371,7 +371,8 @@ dav-server = "0.11.0"
|
|||||||
|
|
||||||
# Performance Analysis and Memory Profiling
|
# Performance Analysis and Memory Profiling
|
||||||
rustfs-mimalloc = { version = "0.5.3" }
|
rustfs-mimalloc = { version = "0.5.3" }
|
||||||
hotpath = { version = "0.25.0", default-features = false }
|
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||||
|
hotpath = { version = "=0.25.0", default-features = false }
|
||||||
# Snapshot testing for output format regression detection
|
# Snapshot testing for output format regression detection
|
||||||
insta = { version = "1.48" }
|
insta = { version = "1.48" }
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,21 @@ Scanner cycle budget controls:
|
|||||||
- timeout returns S3 `SlowDown`, so clients should use normal SDK retry handling.
|
- timeout returns S3 `SlowDown`, so clients should use normal SDK retry handling.
|
||||||
- this is not a fdatasync or group-commit switch. Track fdatasync batching separately with `rustfs_s3_put_object_rename_fdatasync_batch_files`.
|
- this is not a fdatasync or group-commit switch. Track fdatasync batching separately with `rustfs_s3_put_object_rename_fdatasync_batch_files`.
|
||||||
|
|
||||||
|
## Remote tier timeout environment variables
|
||||||
|
|
||||||
|
- `RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS`
|
||||||
|
- remote tier TCP connect timeout.
|
||||||
|
- default is `10`.
|
||||||
|
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default.
|
||||||
|
- `RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS`
|
||||||
|
- remote tier request timeout through response headers.
|
||||||
|
- default is `86400` so large transition uploads keep a production-safe budget.
|
||||||
|
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default. Very large values are accepted and act as a correspondingly long budget.
|
||||||
|
- `RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS`
|
||||||
|
- maximum idle time between remote tier response-body chunks.
|
||||||
|
- default is `60`; the timer resets only when non-empty body data keeps progressing.
|
||||||
|
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default.
|
||||||
|
|
||||||
## Drive timeout environment variables
|
## Drive timeout environment variables
|
||||||
|
|
||||||
- `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS`
|
- `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS`
|
||||||
|
|||||||
@@ -137,6 +137,28 @@ pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
|
|||||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
||||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
||||||
|
|
||||||
|
/// Environment variable for remote tier TCP connect timeout in seconds.
|
||||||
|
pub const ENV_TIER_REMOTE_CONNECT_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS";
|
||||||
|
/// Default remote tier TCP connect timeout in seconds.
|
||||||
|
pub const DEFAULT_TIER_REMOTE_CONNECT_TIMEOUT_SECS: u64 = 10;
|
||||||
|
|
||||||
|
/// Environment variable for the remote tier request timeout in seconds.
|
||||||
|
///
|
||||||
|
/// This bounds upload/download request progress through response headers. The
|
||||||
|
/// default is intentionally large so multi-TiB transition uploads keep their
|
||||||
|
/// previous production budget while black-hole remotes no longer wait forever.
|
||||||
|
pub const ENV_TIER_REMOTE_REQUEST_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS";
|
||||||
|
/// Default remote tier request timeout in seconds.
|
||||||
|
pub const DEFAULT_TIER_REMOTE_REQUEST_TIMEOUT_SECS: u64 = 24 * 60 * 60;
|
||||||
|
|
||||||
|
/// Environment variable for remote tier response-body idle timeout in seconds.
|
||||||
|
///
|
||||||
|
/// The timer is re-armed on every non-empty response-body chunk, so slow but
|
||||||
|
/// progressing remotes can continue while silent response bodies are cancelled.
|
||||||
|
pub const ENV_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS";
|
||||||
|
/// Default remote tier response-body idle timeout in seconds.
|
||||||
|
pub const DEFAULT_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS: u64 = 60;
|
||||||
|
|
||||||
/// Request the object-transaction fencing contract used by storage-owned
|
/// Request the object-transaction fencing contract used by storage-owned
|
||||||
/// cleanup receipts and lock-window optimizations.
|
/// cleanup receipts and lock-window optimizations.
|
||||||
///
|
///
|
||||||
@@ -812,6 +834,16 @@ mod remote_version_state_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_tier_timeout_env_names_are_stable() {
|
||||||
|
assert_eq!(super::ENV_TIER_REMOTE_CONNECT_TIMEOUT_SECS, "RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS");
|
||||||
|
assert_eq!(super::ENV_TIER_REMOTE_REQUEST_TIMEOUT_SECS, "RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS");
|
||||||
|
assert_eq!(
|
||||||
|
super::ENV_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS,
|
||||||
|
"RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn data_movement_part_checksum_gate_uses_stable_environment_names() {
|
fn data_movement_part_checksum_gate_uses_stable_environment_names() {
|
||||||
assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE");
|
assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE");
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
|||||||
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
|
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
|
||||||
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
||||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||||
|
| **distributed 4×4** | [`src/distributed/`](src/distributed) | Storage-sensitive PR and nightly `e2e-distributed` lane: S3, object lock/WORM, versioning, bucket/site replication, quota, expand/decommission/rebalance, concurrency, chaos, 4-node upgrade of historical data and IAM AK/SK. Map: [`docs/testing/distributed-e2e.md`](../../docs/testing/distributed-e2e.md) |
|
||||||
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
||||||
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||||
|
|
||||||
@@ -171,6 +172,7 @@ the same profile for membership and execution with one nightly worker.
|
|||||||
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
||||||
| Direct and mixed-version rolling upgrades from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** |
|
| Direct and mixed-version rolling upgrades from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** |
|
||||||
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
||||||
|
| Distributed 4-node 4-disk (`e2e-distributed` profile) | `.github/workflows/e2e-distributed.yml` | **Active** (storage-sensitive PR / nightly / dispatch) |
|
||||||
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
||||||
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
||||||
| Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) |
|
| Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) |
|
||||||
@@ -191,6 +193,9 @@ cargo nextest run --profile e2e-smoke -p e2e_test
|
|||||||
cargo nextest run --profile e2e-full -p e2e_test
|
cargo nextest run --profile e2e-full -p e2e_test
|
||||||
# Cluster fault nightly lane
|
# Cluster fault nightly lane
|
||||||
cargo nextest run --profile e2e-nightly -p e2e_test
|
cargo nextest run --profile e2e-nightly -p e2e_test
|
||||||
|
# 4-node 4-disk distributed lane (S3 / lock / versioning / replication / decommission / chaos / upgrade)
|
||||||
|
# Upgrade cases need RUSTFS_UPGRADE_SOURCE_BINARY; without it they fail closed.
|
||||||
|
cargo nextest run --profile e2e-distributed -p e2e_test
|
||||||
# Replication nightly lane; awscurl is required for STS paths
|
# Replication nightly lane; awscurl is required for STS paths
|
||||||
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||||
# Fixed-port protocol nightly lane
|
# Fixed-port protocol nightly lane
|
||||||
|
|||||||
@@ -1700,6 +1700,69 @@ impl RustFSTestClusterEnvironment {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append a new single-node erasure pool to a stopped multi-pool cluster.
|
||||||
|
///
|
||||||
|
/// Used to simulate pool expansion on localhost: every pool already owns
|
||||||
|
/// exactly one node with `drives_per_node >= 2` (the only multi-pool layout
|
||||||
|
/// the single-host `RUSTFS_VOLUMES` syntax can express). The new node is
|
||||||
|
/// allocated a fresh port and empty drive directories; callers must
|
||||||
|
/// [`Self::start`] afterwards so every process picks up the extended
|
||||||
|
/// volumes argument. Existing data directories are left untouched.
|
||||||
|
pub async fn append_single_node_pool(&mut self) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
if self.nodes.iter().any(|node| node.process.is_some()) {
|
||||||
|
return Err("stop the cluster before appending a pool".into());
|
||||||
|
}
|
||||||
|
if self.topology.drives_per_node < 2 {
|
||||||
|
return Err(
|
||||||
|
"append_single_node_pool requires drives_per_node >= 2 (the server parser rejects a single-drive ellipses pool)"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pools = self.topology.normalized_pools();
|
||||||
|
for (pool_idx, nodes) in pools.iter().enumerate() {
|
||||||
|
if nodes.len() != 1 {
|
||||||
|
return Err(format!(
|
||||||
|
"pool {pool_idx} spans {} nodes; append_single_node_pool requires one node per pool",
|
||||||
|
nodes.len()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_idx = self.nodes.len();
|
||||||
|
let port = RustFSTestEnvironment::find_available_port().await?;
|
||||||
|
let address = format!("127.0.0.1:{port}");
|
||||||
|
let data_dirs: Vec<String> = (0..self.topology.drives_per_node)
|
||||||
|
.map(|drive| format!("{}/node{}/drive{}", self.temp_dir, new_idx, drive))
|
||||||
|
.collect();
|
||||||
|
for dir in &data_dirs {
|
||||||
|
fs::create_dir_all(dir).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.nodes.push(ClusterNode {
|
||||||
|
url: format!("http://{address}"),
|
||||||
|
address,
|
||||||
|
data_dir: data_dirs[0].clone(),
|
||||||
|
data_dirs,
|
||||||
|
pool_idx: pools.len(),
|
||||||
|
process: None,
|
||||||
|
});
|
||||||
|
pools.push(vec![new_idx]);
|
||||||
|
self.topology.node_count = self.nodes.len();
|
||||||
|
self.topology.pools = pools;
|
||||||
|
self.node_extra_env.push(Vec::new());
|
||||||
|
self.node_capture_log_paths.push(None);
|
||||||
|
self.volume_proxy_addresses.push(None);
|
||||||
|
|
||||||
|
if !self.extra_env.iter().any(|(key, _)| key == "RUSTFS_UNSAFE_BYPASS_DISK_CHECK") {
|
||||||
|
self.extra_env
|
||||||
|
.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(new_idx)
|
||||||
|
}
|
||||||
|
|
||||||
/// Gracefully stop one cluster node and wait for its process to exit.
|
/// Gracefully stop one cluster node and wait for its process to exit.
|
||||||
///
|
///
|
||||||
/// This is intentionally separate from [`Self::stop_node`]: the latter is
|
/// This is intentionally separate from [`Self::stop_node`]: the latter is
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
|
||||||
|
wait_for_ready, wait_until,
|
||||||
|
};
|
||||||
|
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
|
||||||
|
use crate::common::{build_test_s3_config, init_logging};
|
||||||
|
use crate::fault_proxy::FaultMode;
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{Barrier, mpsc};
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn kill_and_restart_node_preserves_objects() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("killnode");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let body = vec![0x11u8; 128 * 1024];
|
||||||
|
put_object(&dist.client(0)?, &bucket, "keep.bin", body.clone()).await?;
|
||||||
|
|
||||||
|
dist.cluster.stop_node(3)?;
|
||||||
|
retrying_get_equals(&dist.client(0)?, &bucket, "keep.bin", &body, Duration::from_secs(20)).await?;
|
||||||
|
|
||||||
|
dist.cluster.start_node(3).await?;
|
||||||
|
wait_for_ready(&dist.cluster).await?;
|
||||||
|
assert_object_bytes(&dist.client(3)?, &bucket, "keep.bin", &body).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_cluster_restart_preserves_objects() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("pwr");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let body = vec![0x44u8; 64 * 1024];
|
||||||
|
put_object(&dist.client(1)?, &bucket, "survive.bin", body.clone()).await?;
|
||||||
|
|
||||||
|
dist.cluster.stop();
|
||||||
|
dist.cluster.start().await?;
|
||||||
|
wait_for_ready(&dist.cluster).await?;
|
||||||
|
for node_idx in 0..dist.cluster.nodes.len() {
|
||||||
|
assert_object_bytes(&dist.client(node_idx)?, &bucket, "survive.bin", &body).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fresh_drive_replacement_is_physically_healed_without_data_change() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start_with_env(DistLayout::FourByFour, &[("RUSTFS_HEAL_ENABLED", "true")]).await?;
|
||||||
|
let bucket = unique_bucket("baddrive");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let body = payload_for("fresh-drive/durable.bin", 8 * 1024 * 1024);
|
||||||
|
put_object(&dist.client(1)?, &bucket, "durable.bin", body.clone()).await?;
|
||||||
|
|
||||||
|
let replaced_drive = PathBuf::from(&dist.cluster.nodes[0].data_dirs[0]);
|
||||||
|
let baseline = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
|
||||||
|
assert!(
|
||||||
|
baseline.is_complete(),
|
||||||
|
"replacement target did not hold a complete baseline shard: {baseline:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!baseline.expected_part_numbers.is_empty(),
|
||||||
|
"replacement witness must use physical part shards: {baseline:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
dist.cluster.stop_node(0)?;
|
||||||
|
let format_path = replaced_drive.join(".rustfs.sys/format.json");
|
||||||
|
let format = std::fs::read(&format_path)?;
|
||||||
|
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
|
||||||
|
std::fs::rename(&replaced_drive, &retired_drive)?;
|
||||||
|
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path omitted parent")?)?;
|
||||||
|
std::fs::write(&format_path, format)?;
|
||||||
|
let empty = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
|
||||||
|
assert!(!empty.has_xl_meta, "fresh replacement unexpectedly retained object metadata: {empty:?}");
|
||||||
|
|
||||||
|
dist.cluster.start_node(0).await?;
|
||||||
|
wait_for_ready(&dist.cluster).await?;
|
||||||
|
let heal_body =
|
||||||
|
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||||
|
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[1].url);
|
||||||
|
signed_admin_post(&heal_url, Some(heal_body), &dist.cluster.access_key, &dist.cluster.secret_key).await?;
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(90),
|
||||||
|
|| async {
|
||||||
|
let healed = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
|
||||||
|
Ok(healed.matches_manifest(&baseline))
|
||||||
|
},
|
||||||
|
"fresh replacement contains the original complete shard manifest",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for node_idx in 0..dist.cluster.nodes.len() {
|
||||||
|
assert_object_bytes(&dist.client(node_idx)?, &bucket, "durable.bin", &body).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_gets_survive_peer_node_kill() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("getkill");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let body = payload_for("inflight/steady.bin", 8 * 1024 * 1024);
|
||||||
|
put_object(&dist.client(0)?, &bucket, "steady.bin", body.clone()).await?;
|
||||||
|
|
||||||
|
let live: Vec<_> = (0..3).map(|idx| dist.client(idx)).collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let worker_count = 12;
|
||||||
|
let release = Arc::new(Barrier::new(worker_count + 1));
|
||||||
|
let (started_tx, mut started_rx) = mpsc::unbounded_channel();
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for idx in 0..worker_count {
|
||||||
|
let client = live[idx % live.len()].clone();
|
||||||
|
let bucket = bucket.clone();
|
||||||
|
let body = body.clone();
|
||||||
|
let release = release.clone();
|
||||||
|
let started_tx = started_tx.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
let response = client.get_object().bucket(&bucket).key("steady.bin").send().await?;
|
||||||
|
if response.content_length() != Some(body.len() as i64) {
|
||||||
|
return Err::<(), Box<dyn std::error::Error + Send + Sync>>(
|
||||||
|
format!("worker {idx} received a wrong content length").into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
started_tx.send(idx)?;
|
||||||
|
release.wait().await;
|
||||||
|
let actual = response.body.collect().await?.into_bytes();
|
||||||
|
if actual.as_ref() != body.as_slice() {
|
||||||
|
return Err(format!("worker {idx} received corrupted bytes after peer kill").into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
drop(started_tx);
|
||||||
|
for _ in 0..worker_count {
|
||||||
|
timeout(Duration::from_secs(30), started_rx.recv())
|
||||||
|
.await?
|
||||||
|
.ok_or("a streaming GET exited before reaching the kill barrier")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
dist.cluster.stop_node(3)?;
|
||||||
|
release.wait().await;
|
||||||
|
for handle in handles {
|
||||||
|
handle.await??;
|
||||||
|
}
|
||||||
|
|
||||||
|
dist.cluster.start_node(3).await?;
|
||||||
|
wait_for_ready(&dist.cluster).await?;
|
||||||
|
assert_object_bytes(&dist.client(3)?, &bucket, "steady.bin", &body).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn blackholed_node_client_network_preserves_cluster_availability_and_recovers() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let proxy = crate::fault_proxy::FaultProxy::start(dist.cluster.nodes[3].address.parse()?).await?;
|
||||||
|
let proxied_url = format!("http://{}", proxy.local_addr());
|
||||||
|
let proxied_client = Client::from_conf(build_test_s3_config(
|
||||||
|
&proxied_url,
|
||||||
|
&dist.cluster.access_key,
|
||||||
|
&dist.cluster.secret_key,
|
||||||
|
None,
|
||||||
|
"distributed-network-chaos",
|
||||||
|
));
|
||||||
|
|
||||||
|
let result: TestResult = async {
|
||||||
|
let bucket = unique_bucket("netfault");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let baseline = payload_for("network/baseline.bin", 1024 * 1024);
|
||||||
|
put_object(&dist.client(0)?, &bucket, "baseline.bin", baseline.clone()).await?;
|
||||||
|
assert_object_bytes(&proxied_client, &bucket, "baseline.bin", &baseline).await?;
|
||||||
|
|
||||||
|
proxy.set_mode(FaultMode::Blackhole);
|
||||||
|
assert_eq!(proxy.mode(), FaultMode::Blackhole);
|
||||||
|
if let Ok(Ok(_)) = timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
proxied_client.get_object().bucket(&bucket).key("baseline.bin").send(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err("blackholed node endpoint unexpectedly completed a GET".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let during = payload_for("network/during.bin", 1024 * 1024);
|
||||||
|
timeout(Duration::from_secs(30), async {
|
||||||
|
put_object(&dist.client(1)?, &bucket, "during-blackhole.bin", during.clone()).await?;
|
||||||
|
assert_object_bytes(&dist.client(2)?, &bucket, "baseline.bin", &baseline).await?;
|
||||||
|
assert_object_bytes(&dist.client(0)?, &bucket, "during-blackhole.bin", &during).await?;
|
||||||
|
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
proxy.set_mode(FaultMode::Pass);
|
||||||
|
retrying_get_equals(&proxied_client, &bucket, "during-blackhole.bin", &during, Duration::from_secs(30)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
proxy.set_mode(FaultMode::Pass);
|
||||||
|
proxy.shutdown().await;
|
||||||
|
result
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, unique_bucket};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Barrier;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_high_concurrency_mixed_workload_is_consistent_on_every_node() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("conc");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let clients = Arc::new(dist.clients()?);
|
||||||
|
let worker_count = 24;
|
||||||
|
let rounds = 4;
|
||||||
|
let barrier = Arc::new(Barrier::new(worker_count));
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for idx in 0..worker_count {
|
||||||
|
let clients = clients.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
let bucket = bucket.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
barrier.wait().await;
|
||||||
|
let writer = &clients[idx % clients.len()];
|
||||||
|
let reader = &clients[(idx + 1) % clients.len()];
|
||||||
|
let copier = &clients[(idx + 2) % clients.len()];
|
||||||
|
let mut retained = Vec::with_capacity(rounds);
|
||||||
|
for round in 0..rounds {
|
||||||
|
let key = format!("source/worker-{idx:02}-round-{round}.bin");
|
||||||
|
let copy_key = format!("retained/worker-{idx:02}-round-{round}.bin");
|
||||||
|
let body = payload_for(&key, 64 * 1024);
|
||||||
|
put_object(writer, &bucket, &key, body.clone()).await?;
|
||||||
|
|
||||||
|
let head = reader.head_object().bucket(&bucket).key(&key).send().await?;
|
||||||
|
if head.content_length() != Some(body.len() as i64) {
|
||||||
|
return Err(format!("HEAD returned the wrong size for {key}: {head:?}").into());
|
||||||
|
}
|
||||||
|
assert_object_bytes(reader, &bucket, &key, &body).await?;
|
||||||
|
|
||||||
|
copier
|
||||||
|
.copy_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(©_key)
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_object_bytes(writer, &bucket, ©_key, &body).await?;
|
||||||
|
|
||||||
|
writer.delete_object().bucket(&bucket).key(&key).send().await?;
|
||||||
|
let missing = reader
|
||||||
|
.head_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(&key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("deleted source key must not remain visible");
|
||||||
|
if missing.raw_response().map(|response| response.status().as_u16()) != Some(404) {
|
||||||
|
return Err(format!("deleted source {key} returned an unexpected result: {missing:?}").into());
|
||||||
|
}
|
||||||
|
retained.push((copy_key, body));
|
||||||
|
}
|
||||||
|
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(retained)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut inventory = Vec::new();
|
||||||
|
for handle in handles {
|
||||||
|
inventory.extend(handle.await??);
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_keys: BTreeSet<_> = inventory.iter().map(|(key, _)| key.as_str()).collect();
|
||||||
|
for (node_idx, client) in clients.iter().enumerate() {
|
||||||
|
let listed = client.list_objects_v2().bucket(&bucket).prefix("retained/").send().await?;
|
||||||
|
let listed_keys: BTreeSet<_> = listed.contents().iter().filter_map(|object| object.key()).collect();
|
||||||
|
assert_eq!(listed_keys, expected_keys, "node {node_idx} returned a divergent retained-key listing");
|
||||||
|
for (key, body) in &inventory {
|
||||||
|
assert_object_bytes(client, &bucket, key, body)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("node {node_idx} failed to read {key}: {error}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
|
||||||
|
decommission_status_json, payload_for, put_inventory_retrying, retrying_get_equals, retrying_put, start_decommission,
|
||||||
|
unique_bucket, wait_for_decommission_complete, wait_for_decommission_running_with_progress,
|
||||||
|
};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::Barrier;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_puts_during_decommission_do_not_lose_baseline_or_new_objects() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||||
|
let bucket = unique_bucket("concdecom");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let baseline_client = dist.client(0)?;
|
||||||
|
let inventory = put_inventory_retrying(&baseline_client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
|
||||||
|
dist.expand_to_four_pools().await?;
|
||||||
|
|
||||||
|
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||||
|
|
||||||
|
let clients = Arc::new(dist.clients()?);
|
||||||
|
let barrier = Arc::new(Barrier::new(17));
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for idx in 0..16 {
|
||||||
|
let clients = clients.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
let bucket = bucket.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
barrier.wait().await;
|
||||||
|
let client = &clients[idx % clients.len()];
|
||||||
|
let key = format!("live/{idx:02}.bin");
|
||||||
|
let body = payload_for(&key, 8 * 1024);
|
||||||
|
retrying_put(client, &bucket, &key, body.clone(), Duration::from_secs(45)).await?;
|
||||||
|
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((key, body))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_decommission_running_with_progress(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||||
|
barrier.wait().await;
|
||||||
|
|
||||||
|
let mut live_objects = Vec::new();
|
||||||
|
for handle in handles {
|
||||||
|
live_objects.push(handle.await??);
|
||||||
|
}
|
||||||
|
let status = decommission_status_json(&dist.cluster).await?;
|
||||||
|
if !decommission_running_with_progress(&status, DECOMMISSION_POOL_ID)? {
|
||||||
|
return Err(format!("decommission did not remain active across concurrent PUTs: {status}").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||||
|
|
||||||
|
let checker = dist.client(2)?;
|
||||||
|
assert_inventory(&checker, &bucket, &inventory).await?;
|
||||||
|
for (key, body) in live_objects {
|
||||||
|
retrying_get_equals(&checker, &bucket, &key, &body, Duration::from_secs(30)).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, enable_versioning, put_inventory_retrying,
|
||||||
|
sha256_hex, start_decommission, unique_bucket, wait_for_decommission_active, wait_for_decommission_complete,
|
||||||
|
};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn decommission_does_not_alter_object_sha256_across_pools() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||||
|
let bucket = unique_bucket("integrity");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let client = dist.client(0)?;
|
||||||
|
enable_versioning(&client, &bucket).await?;
|
||||||
|
let inventory = put_inventory_retrying(&client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
|
||||||
|
let before: Vec<(String, String)> = inventory.iter().map(|(key, body)| (key.clone(), sha256_hex(body))).collect();
|
||||||
|
|
||||||
|
let versioned_key = "history/versioned.bin";
|
||||||
|
let version_one = b"historical bytes before data movement".to_vec();
|
||||||
|
let version_two = b"current bytes before data movement".to_vec();
|
||||||
|
let version_one_id = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(ByteStream::from(version_one.clone()))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("historical PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
let version_two_id = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(ByteStream::from(version_two.clone()))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("current PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let multipart_key = "multipart/moved.bin";
|
||||||
|
let first_part = vec![0x31; 5 * 1024 * 1024];
|
||||||
|
let second_part = vec![0x72; 1024 * 1024];
|
||||||
|
let upload = client
|
||||||
|
.create_multipart_upload()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let upload_id = upload.upload_id().ok_or("movement multipart upload omitted upload ID")?;
|
||||||
|
let uploaded_one = client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.part_number(1)
|
||||||
|
.body(ByteStream::from(first_part.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let uploaded_two = client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.part_number(2)
|
||||||
|
.body(ByteStream::from(second_part.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
client
|
||||||
|
.complete_multipart_upload()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.multipart_upload(
|
||||||
|
CompletedMultipartUpload::builder()
|
||||||
|
.parts(
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(1)
|
||||||
|
.e_tag(uploaded_one.e_tag().ok_or("movement part 1 omitted ETag")?)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.parts(
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(2)
|
||||||
|
.e_tag(uploaded_two.e_tag().ok_or("movement part 2 omitted ETag")?)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
dist.expand_to_four_pools().await?;
|
||||||
|
|
||||||
|
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||||
|
wait_for_decommission_active(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||||
|
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||||
|
|
||||||
|
let after_client = dist.client(2)?;
|
||||||
|
assert_inventory(&after_client, &bucket, &inventory).await?;
|
||||||
|
for (key, expected_hash) in before {
|
||||||
|
let got = after_client.get_object().bucket(&bucket).key(&key).send().await?;
|
||||||
|
let body = got.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(sha256_hex(body.as_ref()), expected_hash, "checksum changed for {key} after decommission");
|
||||||
|
}
|
||||||
|
for (version_id, expected) in [(&version_one_id, &version_one), (&version_two_id, &version_two)] {
|
||||||
|
let got = after_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(versioned_key)
|
||||||
|
.version_id(version_id)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await?
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(got.as_ref(), expected.as_slice(), "version {version_id} changed after decommission");
|
||||||
|
}
|
||||||
|
let mut expected_multipart = first_part;
|
||||||
|
expected_multipart.extend_from_slice(&second_part);
|
||||||
|
let got_multipart = after_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await?
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(
|
||||||
|
sha256_hex(got_multipart.as_ref()),
|
||||||
|
sha256_hex(&expected_multipart),
|
||||||
|
"multipart checksum changed after decommission"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, list_pools_json, put_inventory,
|
||||||
|
put_inventory_retrying, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_active,
|
||||||
|
wait_for_decommission_complete, wait_for_rebalance_active, wait_for_rebalance_complete,
|
||||||
|
};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_pool_expand_preserves_objects_then_rebalance() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||||
|
let bucket = unique_bucket("expand");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let client = dist.client(0)?;
|
||||||
|
let inventory = put_inventory(&client, &bucket, 64, 256 * 1024).await?;
|
||||||
|
assert_inventory(&client, &bucket, &inventory).await?;
|
||||||
|
|
||||||
|
for expected_nodes in 2..=4 {
|
||||||
|
let new_node = dist.append_pool_and_restart().await?;
|
||||||
|
assert_eq!(new_node + 1, expected_nodes);
|
||||||
|
assert_inventory(&dist.client(new_node)?, &bucket, &inventory).await?;
|
||||||
|
}
|
||||||
|
assert_eq!(dist.cluster.nodes.len(), 4);
|
||||||
|
|
||||||
|
// Prove that the expanded pool map is durable, and clear any recovery
|
||||||
|
// latch raised while the newly-added pool replicas converged.
|
||||||
|
dist.restart_current_binary_gracefully().await?;
|
||||||
|
|
||||||
|
let after_expand = dist.client(0)?;
|
||||||
|
assert_inventory(&after_expand, &bucket, &inventory).await?;
|
||||||
|
let peer = dist.client(3)?;
|
||||||
|
assert_inventory(&peer, &bucket, &inventory).await?;
|
||||||
|
|
||||||
|
let rebalance_id = start_rebalance(&dist.cluster).await?;
|
||||||
|
wait_for_rebalance_active(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
||||||
|
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
|
||||||
|
assert_inventory(&peer, &bucket, &inventory).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_pool_decommission_moves_objects_without_loss() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||||
|
let bucket = unique_bucket("decom");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let client = dist.client(0)?;
|
||||||
|
let inventory = put_inventory_retrying(&client, &bucket, 96, 128 * 1024, Duration::from_secs(30)).await?;
|
||||||
|
dist.expand_to_four_pools().await?;
|
||||||
|
|
||||||
|
let pools_before = list_pools_json(&dist.cluster).await?;
|
||||||
|
let pool_count = pools_before
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len)
|
||||||
|
.or_else(|| pools_before.get("pools").and_then(serde_json::Value::as_array).map(Vec::len))
|
||||||
|
.ok_or_else(|| format!("pool list omitted an array: {pools_before}"))?;
|
||||||
|
assert_eq!(pool_count, 4, "expected exactly four pools before decommission: {pools_before}");
|
||||||
|
|
||||||
|
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||||
|
wait_for_decommission_active(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||||
|
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||||
|
|
||||||
|
let after = dist.client(2)?;
|
||||||
|
assert_inventory(&after, &bucket, &inventory).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket, wait_until,
|
||||||
|
};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_four_drive_multipart_and_cross_node_listing_agree() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("extra");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let client = dist.client(0)?;
|
||||||
|
|
||||||
|
let key = "multipart.bin";
|
||||||
|
let part1 = vec![0x41u8; 5 * 1024 * 1024];
|
||||||
|
let part2 = vec![0x42u8; 5 * 1024 * 1024];
|
||||||
|
let upload = client.create_multipart_upload().bucket(&bucket).key(key).send().await?;
|
||||||
|
let upload_id = upload.upload_id().ok_or("missing upload id")?.to_string();
|
||||||
|
|
||||||
|
let uploaded1 = client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.upload_id(&upload_id)
|
||||||
|
.part_number(1)
|
||||||
|
.body(ByteStream::from(part1.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let uploaded2 = client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.upload_id(&upload_id)
|
||||||
|
.part_number(2)
|
||||||
|
.body(ByteStream::from(part2.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.complete_multipart_upload()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.upload_id(&upload_id)
|
||||||
|
.multipart_upload(
|
||||||
|
CompletedMultipartUpload::builder()
|
||||||
|
.parts(
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(1)
|
||||||
|
.e_tag(uploaded1.e_tag().unwrap_or_default())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.parts(
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(2)
|
||||||
|
.e_tag(uploaded2.e_tag().unwrap_or_default())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut expected = part1;
|
||||||
|
expected.extend_from_slice(&part2);
|
||||||
|
for node_idx in 0..dist.cluster.nodes.len() {
|
||||||
|
assert_object_bytes(&dist.client(node_idx)?, &bucket, key, &expected).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
put_object(&client, &bucket, "list/a", b"a".to_vec()).await?;
|
||||||
|
put_object(&dist.client(2)?, &bucket, "list/b", b"b".to_vec()).await?;
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
for node_idx in 0..dist.cluster.nodes.len() {
|
||||||
|
let listed = dist
|
||||||
|
.client(node_idx)?
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.prefix("list/")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let keys: Vec<String> = listed
|
||||||
|
.contents()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|object| object.key().map(str::to_string))
|
||||||
|
.collect();
|
||||||
|
seen.push(keys);
|
||||||
|
}
|
||||||
|
for keys in &seen[1..] {
|
||||||
|
assert_eq!(&seen[0], keys, "list results diverged across nodes: {seen:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let got = get_object_bytes(&dist.client(3)?, &bucket, "list/a").await?;
|
||||||
|
assert_eq!(got, b"a");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_list_buckets_agree_across_all_nodes() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("listed");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
put_object(&dist.client(0)?, &bucket, "seed.bin", b"seed".to_vec()).await?;
|
||||||
|
|
||||||
|
for node_idx in 0..dist.cluster.nodes.len() {
|
||||||
|
let client = dist.client(node_idx)?;
|
||||||
|
let name = bucket.clone();
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(20),
|
||||||
|
|| {
|
||||||
|
let client = client.clone();
|
||||||
|
let name = name.clone();
|
||||||
|
async move {
|
||||||
|
let listed = client.list_buckets().send().await?;
|
||||||
|
Ok(listed.buckets().iter().any(|entry| entry.name() == Some(name.as_str())))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
&format!("node {node_idx} lists {bucket}"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(20),
|
||||||
|
|| {
|
||||||
|
let client = dist.client(node_idx).expect("client");
|
||||||
|
let name = bucket.clone();
|
||||||
|
async move { Ok(get_object_bytes(&client, &name, "seed.bin").await.ok() == Some(b"seed".to_vec())) }
|
||||||
|
},
|
||||||
|
&format!("node {node_idx} reads seed.bin"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! 4-node 4-drive distributed e2e coverage.
|
||||||
|
//!
|
||||||
|
//! Selected by `[profile.e2e-distributed]` and run from
|
||||||
|
//! `.github/workflows/e2e-distributed.yml`. Excluded from `e2e-full` because
|
||||||
|
//! each case starts four real `rustfs` processes.
|
||||||
|
|
||||||
|
mod chaos_test;
|
||||||
|
mod concurrency_stability_test;
|
||||||
|
mod concurrent_data_movement_test;
|
||||||
|
mod data_integrity_movement_test;
|
||||||
|
mod expand_decommission_rebalance_test;
|
||||||
|
mod extra_test;
|
||||||
|
mod harness;
|
||||||
|
mod object_lock_test;
|
||||||
|
mod observability_test;
|
||||||
|
mod replication_quota_test;
|
||||||
|
mod s3_basic_test;
|
||||||
|
mod s3_during_data_movement_test;
|
||||||
|
mod site_replication_test;
|
||||||
|
mod upgrade_test;
|
||||||
|
mod versioning_test;
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{DistCluster, DistLayout, TestResult, unique_bucket};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use crate::object_lock::common::{
|
||||||
|
delete_object_with_bypass, put_object_lock_configuration, put_object_with_legal_hold, put_object_with_retention,
|
||||||
|
};
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::error::SdkError;
|
||||||
|
use aws_sdk_s3::operation::delete_object::DeleteObjectError;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{
|
||||||
|
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
|
||||||
|
ObjectLockRule,
|
||||||
|
};
|
||||||
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
|
|
||||||
|
fn delete_denied(error: &SdkError<DeleteObjectError>, context: &str) -> TestResult {
|
||||||
|
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||||
|
if code == Some("AccessDenied") {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("{context}: expected AccessDenied, got {error:?}").into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn expect_versioned_delete_denied(
|
||||||
|
client: &Client,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
version_id: &str,
|
||||||
|
bypass: bool,
|
||||||
|
context: &str,
|
||||||
|
) -> TestResult {
|
||||||
|
match delete_object_with_bypass(client, bucket, key, Some(version_id), bypass).await {
|
||||||
|
Ok(_) => Err(format!("{context}: DeleteObject of retained version must be denied").into()),
|
||||||
|
Err(error) => delete_denied(error.as_ref(), context),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_four_drive_object_lock_worm_blocks_delete() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let client = dist.client(0)?;
|
||||||
|
let peer = dist.client(2)?;
|
||||||
|
let bucket = unique_bucket("objlock");
|
||||||
|
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.object_lock_enabled_for_bucket(true)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let retain_until = Utc::now() + ChronoDuration::days(1);
|
||||||
|
|
||||||
|
let compliance_key = "compliance.bin";
|
||||||
|
let compliance_version = put_object_with_retention(
|
||||||
|
&client,
|
||||||
|
&bucket,
|
||||||
|
compliance_key,
|
||||||
|
b"locked-compliance",
|
||||||
|
ObjectLockRetentionMode::Compliance,
|
||||||
|
retain_until,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Unversioned DELETE is allowed: it only creates a delete marker. WORM
|
||||||
|
// applies to a specific version id.
|
||||||
|
let marker = peer.delete_object().bucket(&bucket).key(compliance_key).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
marker.delete_marker(),
|
||||||
|
Some(true),
|
||||||
|
"unversioned DELETE on a locked object must create a delete marker"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect_versioned_delete_denied(&peer, &bucket, compliance_key, &compliance_version, false, "COMPLIANCE without bypass")
|
||||||
|
.await?;
|
||||||
|
expect_versioned_delete_denied(&peer, &bucket, compliance_key, &compliance_version, true, "COMPLIANCE with bypass").await?;
|
||||||
|
|
||||||
|
let governance_key = "governance.bin";
|
||||||
|
let governance_version = put_object_with_retention(
|
||||||
|
&client,
|
||||||
|
&bucket,
|
||||||
|
governance_key,
|
||||||
|
b"locked-governance",
|
||||||
|
ObjectLockRetentionMode::Governance,
|
||||||
|
retain_until,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
expect_versioned_delete_denied(&peer, &bucket, governance_key, &governance_version, false, "GOVERNANCE without bypass")
|
||||||
|
.await?;
|
||||||
|
delete_object_with_bypass(&peer, &bucket, governance_key, Some(&governance_version), true).await?;
|
||||||
|
let deleted_governance = peer
|
||||||
|
.head_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(governance_key)
|
||||||
|
.version_id(&governance_version)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("GOVERNANCE bypass must remove the retained version");
|
||||||
|
assert_eq!(
|
||||||
|
deleted_governance.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"deleted GOVERNANCE version returned an unexpected HEAD result: {deleted_governance:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let hold_key = "legal-hold.bin";
|
||||||
|
let hold_version =
|
||||||
|
put_object_with_legal_hold(&client, &bucket, hold_key, b"legal-hold", ObjectLockLegalHoldStatus::On).await?;
|
||||||
|
expect_versioned_delete_denied(&peer, &bucket, hold_key, &hold_version, false, "legal hold without bypass").await?;
|
||||||
|
expect_versioned_delete_denied(&peer, &bucket, hold_key, &hold_version, true, "legal hold with bypass").await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_default_retention_is_visible_and_non_lock_bucket_rejects_configuration() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let writer = dist.client(0)?;
|
||||||
|
let reader = dist.client(3)?;
|
||||||
|
let bucket = unique_bucket("default-lock");
|
||||||
|
|
||||||
|
writer
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.object_lock_enabled_for_bucket(true)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
put_object_lock_configuration(&writer, &bucket, ObjectLockRetentionMode::Governance, Some(1), None).await?;
|
||||||
|
|
||||||
|
let key = "default-governance.bin";
|
||||||
|
let put = writer
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from_static(b"default retention payload"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let version_id = put.version_id().ok_or("default-retained PUT omitted version ID")?;
|
||||||
|
|
||||||
|
let config = reader.get_object_lock_configuration().bucket(&bucket).send().await?;
|
||||||
|
let default_retention = config
|
||||||
|
.object_lock_configuration()
|
||||||
|
.and_then(|configuration| configuration.rule())
|
||||||
|
.and_then(|rule| rule.default_retention())
|
||||||
|
.ok_or("GetObjectLockConfiguration omitted default retention")?;
|
||||||
|
assert_eq!(default_retention.mode().map(|mode| mode.as_str()), Some("GOVERNANCE"));
|
||||||
|
assert_eq!(default_retention.days(), Some(1));
|
||||||
|
|
||||||
|
let retention = reader
|
||||||
|
.get_object_retention()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(version_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let retention = retention.retention().ok_or("GetObjectRetention omitted applied retention")?;
|
||||||
|
assert_eq!(retention.mode().map(|mode| mode.as_str()), Some("GOVERNANCE"));
|
||||||
|
let retain_until = retention
|
||||||
|
.retain_until_date()
|
||||||
|
.ok_or("default retention omitted retain-until date")?;
|
||||||
|
assert!(retain_until.secs() > Utc::now().timestamp(), "default retention is not in the future");
|
||||||
|
|
||||||
|
let versioning = reader.get_bucket_versioning().bucket(&bucket).send().await?;
|
||||||
|
assert_eq!(versioning.status().map(|status| status.as_str()), Some("Enabled"));
|
||||||
|
expect_versioned_delete_denied(&reader, &bucket, key, version_id, false, "default GOVERNANCE retention without bypass")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let plain_bucket = unique_bucket("no-lock");
|
||||||
|
dist.create_bucket(&plain_bucket).await?;
|
||||||
|
let configuration = ObjectLockConfiguration::builder()
|
||||||
|
.object_lock_enabled(ObjectLockEnabled::Enabled)
|
||||||
|
.rule(
|
||||||
|
ObjectLockRule::builder()
|
||||||
|
.default_retention(
|
||||||
|
DefaultRetention::builder()
|
||||||
|
.mode(ObjectLockRetentionMode::Governance)
|
||||||
|
.days(1)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
let error = writer
|
||||||
|
.put_object_lock_configuration()
|
||||||
|
.bucket(&plain_bucket)
|
||||||
|
.object_lock_configuration(configuration)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("an unversioned bucket must reject Object Lock enablement");
|
||||||
|
let service_error = error
|
||||||
|
.as_service_error()
|
||||||
|
.ok_or("non-lock bucket rejection was not an S3 service error")?;
|
||||||
|
assert_eq!(service_error.code(), Some("InvalidBucketState"), "unexpected error: {error:?}");
|
||||||
|
assert_eq!(
|
||||||
|
service_error.message(),
|
||||||
|
Some("Object Lock configuration cannot be enabled on existing buckets"),
|
||||||
|
"unexpected error: {error:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{DistCluster, DistLayout, TestResult, cluster_admin_ok, unique_bucket, wait_for_ready};
|
||||||
|
use crate::common::{admin_request, init_logging, local_http_client};
|
||||||
|
use aws_sdk_s3::operation::RequestId;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use http::Method;
|
||||||
|
use http_body_util::{BodyExt, Empty};
|
||||||
|
use hyper::body::Incoming;
|
||||||
|
use hyper::service::service_fn;
|
||||||
|
use hyper::{Request, Response};
|
||||||
|
use hyper_util::rt::TokioIo;
|
||||||
|
use local_ip_address::local_ip;
|
||||||
|
use rustfs_madmin::metrics::RealtimeMetrics;
|
||||||
|
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio::time::{Instant, timeout};
|
||||||
|
|
||||||
|
async fn spawn_audit_collector() -> TestResult<(String, mpsc::UnboundedReceiver<Value>, JoinHandle<()>)> {
|
||||||
|
let listener = TcpListener::bind("0.0.0.0:0").await?;
|
||||||
|
let endpoint = format!("http://{}/audit", std::net::SocketAddr::new(local_ip()?, listener.local_addr()?.port()));
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((stream, _)) = listener.accept().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let tx = tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let service = service_fn(move |request: Request<Incoming>| {
|
||||||
|
let tx = tx.clone();
|
||||||
|
async move {
|
||||||
|
let method = request.method().clone();
|
||||||
|
if let Ok(body) = request.into_body().collect().await
|
||||||
|
&& method == Method::POST
|
||||||
|
&& let Ok(payload) = serde_json::from_slice::<Value>(&body.to_bytes())
|
||||||
|
{
|
||||||
|
if let Some(records) = payload["Records"].as_array() {
|
||||||
|
for entry in records {
|
||||||
|
let _ = tx.send(entry.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = tx.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok::<_, Infallible>(Response::new(Empty::<Bytes>::new()))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = hyper::server::conn::http1::Builder::new()
|
||||||
|
.serve_connection(TokioIo::new(stream), service)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok((endpoint, rx, handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_audit_entry(
|
||||||
|
rx: &mut mpsc::UnboundedReceiver<Value>,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
request_id: &str,
|
||||||
|
) -> TestResult<Value> {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
loop {
|
||||||
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||||
|
if remaining.is_zero() {
|
||||||
|
return Err(format!(
|
||||||
|
"audit webhook did not receive PutObject for {bucket}/{key}; received {} other records: {seen:?}",
|
||||||
|
seen.len()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let entry = match timeout(remaining, rx.recv()).await {
|
||||||
|
Ok(Some(entry)) => entry,
|
||||||
|
Ok(None) => return Err("audit collector stopped before the expected entry arrived".into()),
|
||||||
|
Err(_) => {
|
||||||
|
return Err(format!(
|
||||||
|
"audit webhook did not receive PutObject for {bucket}/{key}; received {} other records: {seen:?}",
|
||||||
|
seen.len()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if entry["api"]["name"].as_str() == Some("s3:PutObject")
|
||||||
|
&& entry["api"]["bucket"].as_str() == Some(bucket)
|
||||||
|
&& entry["api"]["object"].as_str() == Some(key)
|
||||||
|
&& entry["requestID"].as_str() == Some(request_id)
|
||||||
|
{
|
||||||
|
return Ok(entry);
|
||||||
|
}
|
||||||
|
if seen.len() < 8 {
|
||||||
|
seen.push(format!(
|
||||||
|
"api={:?} bucket={:?} object={:?} requestID={:?}",
|
||||||
|
entry["api"]["name"].as_str(),
|
||||||
|
entry["api"]["bucket"].as_str(),
|
||||||
|
entry["api"]["object"].as_str(),
|
||||||
|
entry["requestID"].as_str()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_health_inventory_metrics_and_audit_delivery_are_consistent() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let (audit_endpoint, mut audit_entries, collector) = spawn_audit_collector().await?;
|
||||||
|
let audit_origin = reqwest::Url::parse(&audit_endpoint)?.origin().ascii_serialization();
|
||||||
|
let audit_env = [
|
||||||
|
("RUSTFS_AUDIT_ENABLE", "true"),
|
||||||
|
("RUSTFS_AUDIT_WEBHOOK_ENABLE_DISTRIBUTED", "on"),
|
||||||
|
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_DISTRIBUTED", audit_endpoint.as_str()),
|
||||||
|
(ENV_OUTBOUND_ALLOW_ORIGINS, audit_origin.as_str()),
|
||||||
|
];
|
||||||
|
let mut dist = DistCluster::new_stopped_with_env(DistLayout::FourByFour, &audit_env).await?;
|
||||||
|
for node_idx in 0..dist.cluster.nodes.len() {
|
||||||
|
let queue_dir = format!("{}/audit-queue-node-{node_idx}", dist.cluster.temp_dir);
|
||||||
|
tokio::fs::create_dir_all(&queue_dir).await?;
|
||||||
|
dist.cluster
|
||||||
|
.set_node_env(node_idx, "RUSTFS_AUDIT_WEBHOOK_QUEUE_DIR_DISTRIBUTED", queue_dir)?;
|
||||||
|
}
|
||||||
|
dist.cluster.start().await?;
|
||||||
|
wait_for_ready(&dist.cluster).await?;
|
||||||
|
|
||||||
|
let http = local_http_client();
|
||||||
|
for node in &dist.cluster.nodes {
|
||||||
|
for probe in ["ready", "live"] {
|
||||||
|
let response = http.get(format!("{}/health/{probe}", node.url)).send().await?;
|
||||||
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"node {} {probe} probe failed: {}",
|
||||||
|
node.address,
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let info_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/info", None).await?;
|
||||||
|
let info: Value = serde_json::from_str(&info_body)?;
|
||||||
|
let servers = info["info"]["servers"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| format!("admin info omitted servers: {info}"))?;
|
||||||
|
assert_eq!(servers.len(), 4, "admin info did not report all four nodes: {info}");
|
||||||
|
|
||||||
|
let storage_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
|
||||||
|
let storage: Value = serde_json::from_str(&storage_body)?;
|
||||||
|
let disks = storage["info"]["disks"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| format!("storageinfo omitted disks: {storage}"))?;
|
||||||
|
assert_eq!(disks.len(), 16, "storageinfo did not report all sixteen drives: {storage}");
|
||||||
|
assert!(
|
||||||
|
disks.iter().all(|disk| {
|
||||||
|
disk["state"].as_str().is_some_and(|state| state.eq_ignore_ascii_case("ok"))
|
||||||
|
&& disk["runtimeState"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|state| state.eq_ignore_ascii_case("online"))
|
||||||
|
}),
|
||||||
|
"storageinfo reported a drive that was not healthy and online: {storage}"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (node_idx, node) in dist.cluster.nodes.iter().enumerate() {
|
||||||
|
let (status, metrics_body) = admin_request(
|
||||||
|
&node.url,
|
||||||
|
Method::GET,
|
||||||
|
"/rustfs/admin/v3/metrics?n=1&by-host=true&by-disk=true",
|
||||||
|
None,
|
||||||
|
&dist.cluster.access_key,
|
||||||
|
&dist.cluster.secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(status.is_success(), "node {node_idx} metrics failed: {status} {metrics_body}");
|
||||||
|
let sample: RealtimeMetrics = serde_json::from_str(
|
||||||
|
metrics_body
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| format!("node {node_idx} returned empty metrics"))?,
|
||||||
|
)?;
|
||||||
|
assert!(sample.finally, "node {node_idx} metrics sample was not terminal");
|
||||||
|
assert!(sample.errors.is_empty(), "node {node_idx} metrics reported errors: {:?}", sample.errors);
|
||||||
|
assert!(!sample.hosts.is_empty(), "node {node_idx} metrics omitted hosts");
|
||||||
|
}
|
||||||
|
|
||||||
|
let targets_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/audit/target/list", None).await?;
|
||||||
|
let targets: Value = serde_json::from_str(&targets_body)?;
|
||||||
|
let configured = targets["audit_endpoints"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| format!("audit target list omitted audit_endpoints: {targets}"))?
|
||||||
|
.iter()
|
||||||
|
.any(|target| target["account_id"].as_str() == Some("distributed") && target["service"].as_str() == Some("webhook"));
|
||||||
|
assert!(configured, "configured audit webhook was missing: {targets}");
|
||||||
|
|
||||||
|
let bucket = unique_bucket("audit");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let key = "correlated/audit-object.bin";
|
||||||
|
let put = dist
|
||||||
|
.client(2)?
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from_static(b"distributed audit payload"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let request_id = put.request_id().ok_or("PutObject response omitted request ID")?;
|
||||||
|
let audit = wait_for_audit_entry(&mut audit_entries, &bucket, key, request_id).await?;
|
||||||
|
assert_eq!(
|
||||||
|
audit["api"]["status_code"].as_i64(),
|
||||||
|
Some(200),
|
||||||
|
"audit entry did not report success: {audit}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!audit.to_string().contains(&dist.cluster.secret_key),
|
||||||
|
"audit entry leaked the root secret key"
|
||||||
|
);
|
||||||
|
|
||||||
|
collector.abort();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DistCluster, DistLayout, TestResult, enable_versioning, put_bucket_replication, put_object, retrying_put, set_bucket_quota,
|
||||||
|
set_remote_target, unique_bucket, wait_for_ready, wait_for_replicated_bytes, wait_until,
|
||||||
|
};
|
||||||
|
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, init_logging};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use http::Method;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
async fn wait_for_replication_status(
|
||||||
|
client: &aws_sdk_s3::Client,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
expected: &[&str],
|
||||||
|
timeout: Duration,
|
||||||
|
) -> TestResult {
|
||||||
|
wait_until(
|
||||||
|
timeout,
|
||||||
|
|| async {
|
||||||
|
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||||
|
Ok(head
|
||||||
|
.replication_status()
|
||||||
|
.is_some_and(|status| expected.contains(&status.as_str())))
|
||||||
|
},
|
||||||
|
&format!("replication status for {bucket}/{key} in {expected:?}"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_bucket_replication_converges_to_peer_cluster() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let (source, mut target) = DistCluster::start_replication_pair().await?;
|
||||||
|
let source_bucket = unique_bucket("replsrc");
|
||||||
|
let target_bucket = unique_bucket("repldst");
|
||||||
|
source.create_bucket(&source_bucket).await?;
|
||||||
|
target.create_bucket(&target_bucket).await?;
|
||||||
|
|
||||||
|
let source_client = source.client(0)?;
|
||||||
|
let target_client = target.client(0)?;
|
||||||
|
enable_versioning(&source_client, &source_bucket).await?;
|
||||||
|
enable_versioning(&target_client, &target_bucket).await?;
|
||||||
|
|
||||||
|
let arn = set_remote_target(&source.cluster, &source_bucket, &target.cluster, &target_bucket).await?;
|
||||||
|
put_bucket_replication(&source.cluster, &source_bucket, &arn).await?;
|
||||||
|
|
||||||
|
let key = "replicated/metadata-and-tags.bin";
|
||||||
|
let body = b"distributed-bucket-replication".to_vec();
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&source_bucket)
|
||||||
|
.key(key)
|
||||||
|
.metadata("origin", "four-node-source")
|
||||||
|
.tagging("suite=distributed&shape=metadata")
|
||||||
|
.body(ByteStream::from(body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_replicated_bytes(&target_client, &target_bucket, key, &body, Duration::from_secs(45)).await?;
|
||||||
|
wait_for_replication_status(&source_client, &source_bucket, key, &["COMPLETED"], Duration::from_secs(30)).await?;
|
||||||
|
|
||||||
|
let peer_read = target.client(3)?;
|
||||||
|
wait_for_replicated_bytes(&peer_read, &target_bucket, key, &body, Duration::from_secs(15)).await?;
|
||||||
|
let replica_head = peer_read.head_object().bucket(&target_bucket).key(key).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
replica_head
|
||||||
|
.metadata()
|
||||||
|
.and_then(|metadata| metadata.get("origin"))
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("four-node-source")
|
||||||
|
);
|
||||||
|
assert_eq!(replica_head.replication_status().map(|status| status.as_str()), Some("REPLICA"));
|
||||||
|
let replica_tags = peer_read.get_object_tagging().bucket(&target_bucket).key(key).send().await?;
|
||||||
|
let tags: std::collections::BTreeMap<_, _> = replica_tags.tag_set().iter().map(|tag| (tag.key(), tag.value())).collect();
|
||||||
|
assert_eq!(tags.get("suite"), Some(&"distributed"));
|
||||||
|
assert_eq!(tags.get("shape"), Some(&"metadata"));
|
||||||
|
|
||||||
|
target.cluster.stop();
|
||||||
|
let outage_key = "replicated/queued-during-target-outage.bin";
|
||||||
|
let outage_body = b"retry-after-target-restart".to_vec();
|
||||||
|
put_object(&source_client, &source_bucket, outage_key, outage_body.clone()).await?;
|
||||||
|
wait_for_replication_status(
|
||||||
|
&source_client,
|
||||||
|
&source_bucket,
|
||||||
|
outage_key,
|
||||||
|
&["PENDING", "FAILED"],
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
target.cluster.start().await?;
|
||||||
|
wait_for_ready(&target.cluster).await?;
|
||||||
|
wait_for_replicated_bytes(&target.client(2)?, &target_bucket, outage_key, &outage_body, Duration::from_secs(90)).await?;
|
||||||
|
wait_for_replication_status(&source_client, &source_bucket, outage_key, &["COMPLETED"], Duration::from_secs(45)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_four_drive_hard_quota_rejects_over_limit_put() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start_with_env(DistLayout::FourByFour, FAST_DATA_USAGE_SCANNER_ENV).await?;
|
||||||
|
let bucket = unique_bucket("quota");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
set_bucket_quota(&dist.cluster, &bucket, 8 * 1024).await?;
|
||||||
|
|
||||||
|
let client = dist.client(1)?;
|
||||||
|
retrying_put(&client, &bucket, "small.bin", vec![0u8; 1024], Duration::from_secs(30)).await?;
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
|| async {
|
||||||
|
let (status, body) = super::harness::cluster_admin(
|
||||||
|
&dist.cluster,
|
||||||
|
Method::GET,
|
||||||
|
&format!("/rustfs/admin/v3/quota-stats/{bucket}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let stats: serde_json::Value =
|
||||||
|
serde_json::from_str(&body).map_err(|error| format!("quota stats returned invalid JSON: {error}: {body}"))?;
|
||||||
|
let usage = stats
|
||||||
|
.get("current_usage")
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.ok_or_else(|| format!("quota stats omitted current_usage: {stats}"))?;
|
||||||
|
Ok(usage >= 1024)
|
||||||
|
},
|
||||||
|
"quota stats observe small object",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let oversized_key = "too-big.bin";
|
||||||
|
let error = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(oversized_key)
|
||||||
|
.body(vec![0u8; 16 * 1024].into())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("hard quota must reject the oversized PUT");
|
||||||
|
let service_error = error
|
||||||
|
.as_service_error()
|
||||||
|
.ok_or("quota rejection was not an S3 service error")?;
|
||||||
|
assert_eq!(
|
||||||
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(400),
|
||||||
|
"quota rejection must be HTTP 400: {error:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(service_error.code(), Some("InvalidRequest"), "unexpected quota error: {error:?}");
|
||||||
|
assert!(
|
||||||
|
service_error
|
||||||
|
.message()
|
||||||
|
.is_some_and(|message| message.starts_with("Bucket quota exceeded")),
|
||||||
|
"PUT must fail specifically at quota admission: {error:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let missing = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(oversized_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("an object rejected by quota must not become visible");
|
||||||
|
assert_eq!(
|
||||||
|
missing.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"quota-rejected object returned an unexpected HEAD result: {missing:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let listed = client.list_objects_v2().bucket(&bucket).send().await?;
|
||||||
|
assert!(
|
||||||
|
listed.contents().iter().all(|object| object.key() != Some(oversized_key)),
|
||||||
|
"quota-rejected key leaked into ListObjectsV2"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket};
|
||||||
|
use crate::common::{init_logging, local_http_client};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::presigning::PresigningConfig;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{Delete, MetadataDirective, ObjectIdentifier};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_four_drive_s3_put_get_head_list_copy_rename_delete_and_presign() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("s3basic");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
|
||||||
|
let writer = dist.client(0)?;
|
||||||
|
let reader = dist.client(3)?;
|
||||||
|
let key = "dir/object.bin";
|
||||||
|
let body = vec![0xA5u8; 256 * 1024];
|
||||||
|
put_object(&writer, &bucket, key, body.clone()).await?;
|
||||||
|
|
||||||
|
let head = reader.head_object().bucket(&bucket).key(key).send().await?;
|
||||||
|
assert_eq!(head.content_length(), Some(body.len() as i64));
|
||||||
|
assert_object_bytes(&reader, &bucket, key, &body).await?;
|
||||||
|
|
||||||
|
let ranged = reader
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.range("bytes=0-15")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let ranged_body = ranged.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(ranged_body.as_ref(), &body[..16]);
|
||||||
|
|
||||||
|
let listed = reader.list_objects_v2().bucket(&bucket).prefix("dir/").send().await?;
|
||||||
|
let keys: Vec<_> = listed.contents().iter().filter_map(|object| object.key()).collect();
|
||||||
|
assert_eq!(keys, vec![key]);
|
||||||
|
|
||||||
|
let copy_key = "dir/object-copy.bin";
|
||||||
|
reader
|
||||||
|
.copy_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(copy_key)
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.metadata_directive(MetadataDirective::Copy)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_object_bytes(&writer, &bucket, copy_key, &body).await?;
|
||||||
|
|
||||||
|
let moved_key = "dir/object-moved.bin";
|
||||||
|
writer
|
||||||
|
.copy_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(moved_key)
|
||||||
|
.copy_source(format!("{bucket}/{copy_key}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
writer.delete_object().bucket(&bucket).key(copy_key).send().await?;
|
||||||
|
match writer.head_object().bucket(&bucket).key(copy_key).send().await {
|
||||||
|
Ok(_) => return Err("copied source still present after rename delete".into()),
|
||||||
|
Err(error) if error.as_service_error().is_some_and(|err| err.is_not_found()) => {}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
assert_object_bytes(&reader, &bucket, moved_key, &body).await?;
|
||||||
|
|
||||||
|
let presigned = writer
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.presigned(PresigningConfig::expires_in(Duration::from_secs(120))?)
|
||||||
|
.await?;
|
||||||
|
let response = local_http_client().get(presigned.uri().to_string()).send().await?;
|
||||||
|
assert!(response.status().is_success(), "presigned GET failed: {}", response.status());
|
||||||
|
let presigned_body = response.bytes().await?;
|
||||||
|
assert_eq!(presigned_body.as_ref(), body.as_slice());
|
||||||
|
|
||||||
|
let empty_key = "empty";
|
||||||
|
put_object(&writer, &bucket, empty_key, Vec::new()).await?;
|
||||||
|
let empty = get_object_bytes(&reader, &bucket, empty_key).await?;
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
|
||||||
|
let deleted = writer
|
||||||
|
.delete_objects()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.delete(
|
||||||
|
Delete::builder()
|
||||||
|
.objects(ObjectIdentifier::builder().key(key).build()?)
|
||||||
|
.objects(ObjectIdentifier::builder().key(moved_key).build()?)
|
||||||
|
.objects(ObjectIdentifier::builder().key(empty_key).build()?)
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(deleted.errors().is_empty(), "DeleteObjects reported failures: {deleted:?}");
|
||||||
|
assert_eq!(deleted.deleted().len(), 3, "DeleteObjects did not acknowledge every key");
|
||||||
|
|
||||||
|
let remaining = reader.list_objects_v2().bucket(&bucket).send().await?;
|
||||||
|
assert!(remaining.contents().is_empty(), "bucket still has objects after delete");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_s3_metadata_tags_special_keys_pagination_and_multipart_abort() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("s3matrix");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let writer = dist.client(0)?;
|
||||||
|
let reader = dist.client(3)?;
|
||||||
|
|
||||||
|
let special_key = "unicode/测试 space+percent%25.txt";
|
||||||
|
let special_body = b"metadata and tagging survive distributed routing".to_vec();
|
||||||
|
let put = writer
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(special_key)
|
||||||
|
.metadata("test-meta", "distributed")
|
||||||
|
.tagging("purpose=compatibility&scope=four-by-four")
|
||||||
|
.body(ByteStream::from(special_body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let etag = put.e_tag().ok_or("PutObject omitted ETag")?.to_string();
|
||||||
|
|
||||||
|
let head = reader.head_object().bucket(&bucket).key(special_key).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
head.metadata()
|
||||||
|
.and_then(|metadata| metadata.get("test-meta"))
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("distributed")
|
||||||
|
);
|
||||||
|
assert_eq!(head.e_tag(), Some(etag.as_str()));
|
||||||
|
let tags = reader.get_object_tagging().bucket(&bucket).key(special_key).send().await?;
|
||||||
|
let actual_tags: std::collections::BTreeMap<_, _> = tags
|
||||||
|
.tag_set()
|
||||||
|
.iter()
|
||||||
|
.map(|tag| (tag.key().to_string(), tag.value().to_string()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(actual_tags.get("purpose").map(String::as_str), Some("compatibility"));
|
||||||
|
assert_eq!(actual_tags.get("scope").map(String::as_str), Some("four-by-four"));
|
||||||
|
|
||||||
|
let conditional = reader
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(special_key)
|
||||||
|
.if_match(&etag)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(conditional.body.collect().await?.into_bytes().as_ref(), special_body.as_slice());
|
||||||
|
let invalid_range = reader
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(special_key)
|
||||||
|
.range("bytes=999999-1000000")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("an unsatisfiable range must fail");
|
||||||
|
assert_eq!(
|
||||||
|
invalid_range.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("InvalidRange"),
|
||||||
|
"unexpected invalid-range error: {invalid_range:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let upload_key = "multipart/aborted.bin";
|
||||||
|
let upload = writer
|
||||||
|
.create_multipart_upload()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(upload_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let upload_id = upload.upload_id().ok_or("CreateMultipartUpload omitted upload ID")?;
|
||||||
|
writer
|
||||||
|
.upload_part()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(upload_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.part_number(1)
|
||||||
|
.body(ByteStream::from(vec![0x5Au8; 5 * 1024 * 1024]))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let pending = reader
|
||||||
|
.list_multipart_uploads()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.prefix("multipart/")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(pending.uploads().iter().any(|entry| entry.upload_id() == Some(upload_id)));
|
||||||
|
writer
|
||||||
|
.abort_multipart_upload()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(upload_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let after_abort = reader
|
||||||
|
.list_multipart_uploads()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.prefix("multipart/")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(after_abort.uploads().iter().all(|entry| entry.upload_id() != Some(upload_id)));
|
||||||
|
let aborted_head = reader
|
||||||
|
.head_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(upload_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("aborted multipart upload must not create an object");
|
||||||
|
assert_eq!(
|
||||||
|
aborted_head.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"aborted multipart object returned an unexpected HEAD result: {aborted_head:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
for index in 0..113 {
|
||||||
|
let key = format!("page/{index:04}.txt");
|
||||||
|
put_object(&writer, &bucket, &key, format!("page-{index}").into_bytes()).await?;
|
||||||
|
}
|
||||||
|
let mut token = None;
|
||||||
|
let mut paged_keys = Vec::new();
|
||||||
|
loop {
|
||||||
|
let page = reader
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.prefix("page/")
|
||||||
|
.max_keys(37)
|
||||||
|
.set_continuation_token(token.take())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
paged_keys.extend(page.contents().iter().filter_map(|object| object.key().map(str::to_string)));
|
||||||
|
if page.is_truncated() != Some(true) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
token = Some(
|
||||||
|
page.next_continuation_token()
|
||||||
|
.ok_or("truncated ListObjectsV2 page omitted next continuation token")?
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(paged_keys.len(), 113);
|
||||||
|
let expected: Vec<_> = (0..113).map(|index| format!("page/{index:04}.txt")).collect();
|
||||||
|
assert_eq!(paged_keys, expected, "pagination lost, duplicated, or reordered keys");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
|
||||||
|
decommission_status_json, put_inventory_retrying, rebalance_running_with_progress, rebalance_status_json,
|
||||||
|
retrying_get_equals, retrying_put, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
|
||||||
|
wait_for_decommission_running_with_progress, wait_for_rebalance_complete, wait_for_rebalance_running_with_progress,
|
||||||
|
};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||||
|
let bucket = unique_bucket("s3move");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let client = dist.client(0)?;
|
||||||
|
let inventory = put_inventory_retrying(&client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
|
||||||
|
dist.expand_to_four_pools().await?;
|
||||||
|
|
||||||
|
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||||
|
wait_for_decommission_running_with_progress(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||||
|
let live = dist.client(2)?;
|
||||||
|
retrying_put(
|
||||||
|
&live,
|
||||||
|
&bucket,
|
||||||
|
"during-decommission.bin",
|
||||||
|
b"written-while-decommissioning".to_vec(),
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
retrying_get_equals(
|
||||||
|
&live,
|
||||||
|
&bucket,
|
||||||
|
"during-decommission.bin",
|
||||||
|
b"written-while-decommissioning",
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let listed = live.list_objects_v2().bucket(&bucket).send().await?;
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.contents()
|
||||||
|
.iter()
|
||||||
|
.any(|object| object.key() == Some("during-decommission.bin")),
|
||||||
|
"list during decommission missed the newly written key"
|
||||||
|
);
|
||||||
|
let status = decommission_status_json(&dist.cluster).await?;
|
||||||
|
if !decommission_running_with_progress(&status, DECOMMISSION_POOL_ID)? {
|
||||||
|
return Err(format!("decommission did not remain active across the S3 operations: {status}").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||||
|
assert_inventory(&live, &bucket, &inventory).await?;
|
||||||
|
|
||||||
|
let rebalance_id = start_rebalance(&dist.cluster).await?;
|
||||||
|
wait_for_rebalance_running_with_progress(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
||||||
|
retrying_put(
|
||||||
|
&live,
|
||||||
|
&bucket,
|
||||||
|
"during-rebalance.bin",
|
||||||
|
b"written-while-rebalancing".to_vec(),
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
retrying_get_equals(
|
||||||
|
&live,
|
||||||
|
&bucket,
|
||||||
|
"during-rebalance.bin",
|
||||||
|
b"written-while-rebalancing",
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let status = rebalance_status_json(&dist.cluster).await?;
|
||||||
|
if !rebalance_running_with_progress(&status, &rebalance_id)? {
|
||||||
|
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
|
||||||
|
}
|
||||||
|
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
|
||||||
|
assert_inventory(&dist.client(1)?, &bucket, &inventory).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DistCluster, TestResult, cluster_admin_ok, enable_versioning, put_object, unique_bucket, wait_for_replicated_bytes,
|
||||||
|
wait_until,
|
||||||
|
};
|
||||||
|
use crate::common::{init_logging, signed_request};
|
||||||
|
use http::{Method, StatusCode};
|
||||||
|
use rustfs_madmin::{PeerSite, ReplicateAddStatus, SiteReplicationInfo, SyncStatus};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
async fn site_replication_add(
|
||||||
|
cluster: &crate::common::RustFSTestClusterEnvironment,
|
||||||
|
sites: &[PeerSite],
|
||||||
|
) -> TestResult<ReplicateAddStatus> {
|
||||||
|
let url = format!("{}/rustfs/admin/v3/site-replication/add?replicateILMExpiry=false", cluster.nodes[0].url);
|
||||||
|
let response = signed_request(
|
||||||
|
Method::PUT,
|
||||||
|
&url,
|
||||||
|
&cluster.access_key,
|
||||||
|
&cluster.secret_key,
|
||||||
|
Some(serde_json::to_vec(sites)?),
|
||||||
|
Some("application/json"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if response.status() != StatusCode::OK {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("site replication add failed: {status} {body}").into());
|
||||||
|
}
|
||||||
|
Ok(serde_json::from_slice(&response.bytes().await?)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn site_replication_info(cluster: &crate::common::RustFSTestClusterEnvironment) -> TestResult<SiteReplicationInfo> {
|
||||||
|
let body = cluster_admin_ok(cluster, Method::GET, "/rustfs/admin/v3/site-replication/info", None).await?;
|
||||||
|
Ok(serde_json::from_str(&body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_site_replication_enabled(cluster: &crate::common::RustFSTestClusterEnvironment) -> TestResult {
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
|| async {
|
||||||
|
let info = site_replication_info(cluster).await?;
|
||||||
|
Ok(info.enabled && info.sites.len() == 2 && info.sites.iter().all(|site| site.sync_state == SyncStatus::Enable))
|
||||||
|
},
|
||||||
|
"site replication enabled with two synchronized sites",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_site_replication_replicates_object_to_peer_site() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let (site_a, site_b) = DistCluster::start_replication_pair().await?;
|
||||||
|
let bucket = unique_bucket("siterepl");
|
||||||
|
site_a.create_bucket(&bucket).await?;
|
||||||
|
site_b.create_bucket(&bucket).await?;
|
||||||
|
|
||||||
|
let client_a = site_a.client(0)?;
|
||||||
|
let client_b = site_b.client(0)?;
|
||||||
|
enable_versioning(&client_a, &bucket).await?;
|
||||||
|
enable_versioning(&client_b, &bucket).await?;
|
||||||
|
|
||||||
|
let sites = vec![
|
||||||
|
PeerSite {
|
||||||
|
name: "site-a".to_string(),
|
||||||
|
endpoint: site_a.cluster.nodes[0].url.clone(),
|
||||||
|
access_key: site_a.cluster.access_key.clone(),
|
||||||
|
secret_key: site_a.cluster.secret_key.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
PeerSite {
|
||||||
|
name: "site-b".to_string(),
|
||||||
|
endpoint: site_b.cluster.nodes[0].url.clone(),
|
||||||
|
access_key: site_b.cluster.access_key.clone(),
|
||||||
|
secret_key: site_b.cluster.secret_key.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let add_status = site_replication_add(&site_a.cluster, &sites).await?;
|
||||||
|
assert!(
|
||||||
|
add_status.success && add_status.err_detail.is_empty() && add_status.initial_sync_error_message.is_empty(),
|
||||||
|
"site replication add reported failure: {add_status:?}"
|
||||||
|
);
|
||||||
|
wait_for_site_replication_enabled(&site_a.cluster).await?;
|
||||||
|
wait_for_site_replication_enabled(&site_b.cluster).await?;
|
||||||
|
|
||||||
|
let info_a = site_replication_info(&site_a.cluster).await?;
|
||||||
|
let remote = info_a
|
||||||
|
.sites
|
||||||
|
.iter()
|
||||||
|
.find(|site| site.name == "site-b")
|
||||||
|
.ok_or_else(|| format!("site A info omitted the configured site-b peer: {info_a:?}"))?;
|
||||||
|
assert_eq!(remote.endpoint, site_b.cluster.nodes[0].url);
|
||||||
|
let deployment_ids: std::collections::BTreeSet<_> = info_a.sites.iter().map(|site| site.deployment_id.as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
deployment_ids.iter().all(|deployment_id| !deployment_id.is_empty()) && deployment_ids.len() == 2,
|
||||||
|
"site peers must have two distinct non-empty deployment IDs: {info_a:?}"
|
||||||
|
);
|
||||||
|
assert!(info_a.retry_stats.is_none(), "site A has pending replication retries: {info_a:?}");
|
||||||
|
assert!(info_a.pending_operation.is_none(), "site A has a pending operation: {info_a:?}");
|
||||||
|
|
||||||
|
let key = "site-object.bin";
|
||||||
|
let body = b"four-node-site-replication".to_vec();
|
||||||
|
put_object(&client_a, &bucket, key, body.clone()).await?;
|
||||||
|
wait_for_replicated_bytes(&client_b, &bucket, key, &body, Duration::from_secs(60)).await?;
|
||||||
|
|
||||||
|
let peer_b = site_b.client(3)?;
|
||||||
|
wait_for_replicated_bytes(&peer_b, &bucket, key, &body, Duration::from_secs(20)).await?;
|
||||||
|
|
||||||
|
let reverse_key = "reverse/site-object.bin";
|
||||||
|
let reverse_body = b"site-b-to-site-a".to_vec();
|
||||||
|
put_object(&site_b.client(2)?, &bucket, reverse_key, reverse_body.clone()).await?;
|
||||||
|
wait_for_replicated_bytes(&site_a.client(3)?, &bucket, reverse_key, &reverse_body, Duration::from_secs(60)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! 4-node upgrade coverage for historical objects and IAM AK/SK.
|
||||||
|
//!
|
||||||
|
//! Complements `upgrade_compatibility_test` (single-node SSE/multipart and
|
||||||
|
//! mixed-version listing). This module pins the distributed contract the
|
||||||
|
//! hardware upgrade chain is meant to catch: after a 4-node upgrade, objects
|
||||||
|
//! written on the previous release still read back, and IAM user credentials
|
||||||
|
//! created before the upgrade still authenticate.
|
||||||
|
//!
|
||||||
|
//! Requires `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous
|
||||||
|
//! release. The `e2e-distributed` workflow downloads that binary; a local run
|
||||||
|
//! without it fails closed rather than skipping.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, enable_versioning, get_object_bytes, put_object,
|
||||||
|
unique_bucket, wait_until,
|
||||||
|
};
|
||||||
|
use crate::common::{
|
||||||
|
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
|
||||||
|
};
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
||||||
|
const IAM_SECRET: &str = "UpgradeTestSecretKey1";
|
||||||
|
const WRONG_SECRET: &str = "WrongSecretKey000000";
|
||||||
|
const CREDENTIAL_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
struct UpgradeSeed {
|
||||||
|
history_bucket: String,
|
||||||
|
history_key: &'static str,
|
||||||
|
history_body: Vec<u8>,
|
||||||
|
versioned_bucket: String,
|
||||||
|
versioned_key: &'static str,
|
||||||
|
version1: String,
|
||||||
|
version1_body: Vec<u8>,
|
||||||
|
version2: String,
|
||||||
|
version2_body: Vec<u8>,
|
||||||
|
iam_bucket: String,
|
||||||
|
iam_key: &'static str,
|
||||||
|
iam_body: Vec<u8>,
|
||||||
|
iam_user: String,
|
||||||
|
iam_secret: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_binary() -> TestResult<PathBuf> {
|
||||||
|
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(format!("upgrade source binary does not exist: {}", path.display()).into());
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_upgrade_logs(cluster: &mut DistCluster, label: &str) -> TestResult {
|
||||||
|
let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
std::fs::create_dir_all(&log_dir)?;
|
||||||
|
for node_idx in 0..cluster.cluster.nodes.len() {
|
||||||
|
let path = Path::new(&log_dir).join(format!("{label}-node-{node_idx}.log"));
|
||||||
|
cluster
|
||||||
|
.cluster
|
||||||
|
.set_node_capture_log_path(node_idx, path.to_string_lossy().into_owned())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iam_rw_policy(bucket: &str) -> String {
|
||||||
|
serde_json::json!({
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["s3:*"],
|
||||||
|
"Resource": [
|
||||||
|
format!("arn:aws:s3:::{bucket}"),
|
||||||
|
format!("arn:aws:s3:::{bucket}/*")
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_iam_user(dist: &DistCluster, user: &str, secret: &str, policy_name: &str, bucket: &str) -> TestResult {
|
||||||
|
let url = &dist.cluster.nodes[0].url;
|
||||||
|
let access = &dist.cluster.access_key;
|
||||||
|
let admin_secret = &dist.cluster.secret_key;
|
||||||
|
admin_create_user_via(AdminTransport::Signed, url, access, admin_secret, user, secret).await?;
|
||||||
|
admin_add_canned_policy_via(AdminTransport::Signed, url, access, admin_secret, policy_name, &iam_rw_policy(bucket)).await?;
|
||||||
|
admin_attach_user_policy_via(AdminTransport::Signed, url, access, admin_secret, policy_name, user).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, label: &str) -> TestResult {
|
||||||
|
wait_until(
|
||||||
|
CREDENTIAL_TIMEOUT,
|
||||||
|
|| {
|
||||||
|
let client = client.clone();
|
||||||
|
let bucket = bucket.to_string();
|
||||||
|
let key = key.to_string();
|
||||||
|
let body = body.clone();
|
||||||
|
async move {
|
||||||
|
put_object(&client, &bucket, &key, body).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8], label: &str) -> TestResult {
|
||||||
|
wait_until(
|
||||||
|
CREDENTIAL_TIMEOUT,
|
||||||
|
|| {
|
||||||
|
let client = client.clone();
|
||||||
|
let bucket = bucket.to_string();
|
||||||
|
let key = key.to_string();
|
||||||
|
let expected = expected.to_vec();
|
||||||
|
async move {
|
||||||
|
let got = get_object_bytes(&client, &bucket, &key).await?;
|
||||||
|
Ok(got == expected)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_history_and_iam(dist: &DistCluster) -> TestResult<UpgradeSeed> {
|
||||||
|
let history_bucket = unique_bucket("upg-hist");
|
||||||
|
let versioned_bucket = unique_bucket("upg-ver");
|
||||||
|
let iam_bucket = unique_bucket("upg-iam");
|
||||||
|
dist.create_bucket(&history_bucket).await?;
|
||||||
|
dist.create_bucket(&versioned_bucket).await?;
|
||||||
|
dist.create_bucket(&iam_bucket).await?;
|
||||||
|
|
||||||
|
let root = dist.client(0)?;
|
||||||
|
enable_versioning(&root, &versioned_bucket).await?;
|
||||||
|
|
||||||
|
let history_key = "plain-history.bin";
|
||||||
|
let history_body = b"written by the previous 4-node release".to_vec();
|
||||||
|
put_object(&root, &history_bucket, history_key, history_body.clone()).await?;
|
||||||
|
|
||||||
|
let versioned_key = "versioned-history.txt";
|
||||||
|
let version1_body = b"version-one-before-upgrade".to_vec();
|
||||||
|
let version1 = root
|
||||||
|
.put_object()
|
||||||
|
.bucket(&versioned_bucket)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(aws_sdk_s3::primitives::ByteStream::from(version1_body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("first versioned PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
let version2_body = b"version-two-before-upgrade".to_vec();
|
||||||
|
let version2 = root
|
||||||
|
.put_object()
|
||||||
|
.bucket(&versioned_bucket)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(aws_sdk_s3::primitives::ByteStream::from(version2_body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("second versioned PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let iam_user = format!("upg{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||||
|
let policy_name = format!("upgpol{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||||
|
create_iam_user(dist, &iam_user, IAM_SECRET, &policy_name, &iam_bucket).await?;
|
||||||
|
|
||||||
|
let iam_key = "iam-history.bin";
|
||||||
|
let iam_body = b"written with pre-upgrade IAM AK/SK".to_vec();
|
||||||
|
let iam_client = dist.client_with_credentials(1, &iam_user, IAM_SECRET)?;
|
||||||
|
wait_for_put(&iam_client, &iam_bucket, iam_key, iam_body.clone(), "IAM user PUT before upgrade").await?;
|
||||||
|
|
||||||
|
Ok(UpgradeSeed {
|
||||||
|
history_bucket,
|
||||||
|
history_key,
|
||||||
|
history_body,
|
||||||
|
versioned_bucket,
|
||||||
|
versioned_key,
|
||||||
|
version1,
|
||||||
|
version1_body,
|
||||||
|
version2,
|
||||||
|
version2_body,
|
||||||
|
iam_bucket,
|
||||||
|
iam_key,
|
||||||
|
iam_body,
|
||||||
|
iam_user,
|
||||||
|
iam_secret: IAM_SECRET,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_history_and_iam(dist: &DistCluster, seed: &UpgradeSeed, context: &str) -> TestResult {
|
||||||
|
let root_a = dist.client(0)?;
|
||||||
|
let root_b = dist.client(3)?;
|
||||||
|
wait_for_bytes(
|
||||||
|
&root_b,
|
||||||
|
&seed.history_bucket,
|
||||||
|
seed.history_key,
|
||||||
|
&seed.history_body,
|
||||||
|
&format!("{context}: root GET historical object"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_object_bytes(&root_a, &seed.history_bucket, seed.history_key, &seed.history_body).await?;
|
||||||
|
|
||||||
|
let v1 = root_b
|
||||||
|
.get_object()
|
||||||
|
.bucket(&seed.versioned_bucket)
|
||||||
|
.key(seed.versioned_key)
|
||||||
|
.version_id(&seed.version1)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let v1_body = v1.body.collect().await?.into_bytes();
|
||||||
|
if v1_body.as_ref() != seed.version1_body.as_slice() {
|
||||||
|
return Err(format!("{context}: version 1 bytes changed after upgrade").into());
|
||||||
|
}
|
||||||
|
let v2 = root_a
|
||||||
|
.get_object()
|
||||||
|
.bucket(&seed.versioned_bucket)
|
||||||
|
.key(seed.versioned_key)
|
||||||
|
.version_id(&seed.version2)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let v2_body = v2.body.collect().await?.into_bytes();
|
||||||
|
if v2_body.as_ref() != seed.version2_body.as_slice() {
|
||||||
|
return Err(format!("{context}: version 2 bytes changed after upgrade").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let users = cluster_admin_ok(&dist.cluster, http::Method::GET, "/rustfs/admin/v3/list-users", None).await?;
|
||||||
|
if !users.contains(&seed.iam_user) {
|
||||||
|
return Err(format!("{context}: list-users lost IAM user {}: {users}", seed.iam_user).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let iam_on_upgraded = dist.client_with_credentials(0, &seed.iam_user, seed.iam_secret)?;
|
||||||
|
let iam_on_peer = dist.client_with_credentials(3, &seed.iam_user, seed.iam_secret)?;
|
||||||
|
wait_for_bytes(
|
||||||
|
&iam_on_upgraded,
|
||||||
|
&seed.iam_bucket,
|
||||||
|
seed.iam_key,
|
||||||
|
&seed.iam_body,
|
||||||
|
&format!("{context}: IAM GET historical object on node 0"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
wait_for_bytes(
|
||||||
|
&iam_on_peer,
|
||||||
|
&seed.iam_bucket,
|
||||||
|
seed.iam_key,
|
||||||
|
&seed.iam_body,
|
||||||
|
&format!("{context}: IAM GET historical object on node 3"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let post_key = format!("after-upgrade-{context}.txt");
|
||||||
|
let post_body = format!("{context}: written with the same IAM AK/SK after upgrade").into_bytes();
|
||||||
|
wait_for_put(
|
||||||
|
&iam_on_peer,
|
||||||
|
&seed.iam_bucket,
|
||||||
|
&post_key,
|
||||||
|
post_body.clone(),
|
||||||
|
&format!("{context}: IAM PUT after upgrade"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_object_bytes(&iam_on_upgraded, &seed.iam_bucket, &post_key, &post_body).await?;
|
||||||
|
|
||||||
|
let bad = dist.client_with_credentials(1, &seed.iam_user, WRONG_SECRET)?;
|
||||||
|
match bad.get_object().bucket(&seed.iam_bucket).key(seed.iam_key).send().await {
|
||||||
|
Ok(_) => return Err(format!("{context}: wrong secret must not read the IAM object").into()),
|
||||||
|
Err(error) => {
|
||||||
|
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||||
|
let rejected = code == Some("SignatureDoesNotMatch")
|
||||||
|
|| code == Some("InvalidAccessKeyId")
|
||||||
|
|| code == Some("AccessDenied")
|
||||||
|
|| code == Some("InvalidArgument")
|
||||||
|
|| error.raw_response().is_some_and(|response| response.status().as_u16() == 403);
|
||||||
|
if !rejected {
|
||||||
|
return Err(format!("{context}: wrong secret failed with unexpected error {error:?}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let post_root_key = format!("root-after-{context}.bin");
|
||||||
|
let post_root_body = format!("{context}: root write after upgrade").into_bytes();
|
||||||
|
put_object(&root_a, &seed.history_bucket, &post_root_key, post_root_body.clone()).await?;
|
||||||
|
assert_object_bytes(&root_b, &seed.history_bucket, &post_root_key, &post_root_body).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_direct_upgrade_preserves_history_and_iam_credentials() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let previous = source_binary()?;
|
||||||
|
let mut dist = DistCluster::new_stopped(DistLayout::FourNodeFourDisk).await?;
|
||||||
|
capture_upgrade_logs(&mut dist, "direct-upgrade")?;
|
||||||
|
dist.start_from_binary(&previous).await?;
|
||||||
|
|
||||||
|
let seed = seed_history_and_iam(&dist).await?;
|
||||||
|
dist.restart_with_current_binary().await?;
|
||||||
|
assert_history_and_iam(&dist, &seed, "direct").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let previous = source_binary()?;
|
||||||
|
let mut dist = DistCluster::new_stopped(DistLayout::FourNodeFourDisk).await?;
|
||||||
|
capture_upgrade_logs(&mut dist, "rolling-upgrade")?;
|
||||||
|
dist.start_from_binary(&previous).await?;
|
||||||
|
|
||||||
|
let seed = seed_history_and_iam(&dist).await?;
|
||||||
|
|
||||||
|
dist.replace_node_with_current_binary(0).await?;
|
||||||
|
assert_history_and_iam(&dist, &seed, "one-current-node").await?;
|
||||||
|
|
||||||
|
for node_idx in [1, 2] {
|
||||||
|
dist.replace_node_with_current_binary(node_idx).await?;
|
||||||
|
}
|
||||||
|
assert_history_and_iam(&dist, &seed, "one-previous-node").await?;
|
||||||
|
|
||||||
|
dist.replace_node_with_current_binary(3).await?;
|
||||||
|
assert_history_and_iam(&dist, &seed, "homogeneous-current").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{DistCluster, DistLayout, TestResult, enable_versioning, get_object_bytes, put_object, unique_bucket};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_four_drive_versioning_put_list_get_delete_marker() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("version");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let writer = dist.client(0)?;
|
||||||
|
let reader = dist.client(3)?;
|
||||||
|
enable_versioning(&writer, &bucket).await?;
|
||||||
|
|
||||||
|
let key = "versioned.txt";
|
||||||
|
let v1_id = writer
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(b"v1".to_vec().into())
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("v1 PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
let v2_id = writer
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(b"v2".to_vec().into())
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("v2 PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let versions = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
|
||||||
|
let matching_versions: Vec<_> = versions
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.filter(|version| version.key() == Some(key))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(matching_versions.len(), 2, "fresh key must have exactly two versions: {versions:?}");
|
||||||
|
assert!(versions.delete_markers().is_empty(), "fresh key unexpectedly has a delete marker");
|
||||||
|
assert!(
|
||||||
|
matching_versions
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.version_id() == Some(v1_id.as_str()) && version.is_latest() != Some(true)),
|
||||||
|
"v1 was not the historical version: {versions:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matching_versions
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.version_id() == Some(v2_id.as_str()) && version.is_latest() == Some(true)),
|
||||||
|
"v2 was not the latest version: {versions:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let latest = get_object_bytes(&reader, &bucket, key).await?;
|
||||||
|
assert_eq!(latest, b"v2");
|
||||||
|
|
||||||
|
let older = reader.get_object().bucket(&bucket).key(key).version_id(&v1_id).send().await?;
|
||||||
|
let older_body = older.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(older_body.as_ref(), b"v1");
|
||||||
|
|
||||||
|
let deleted = writer.delete_object().bucket(&bucket).key(key).send().await?;
|
||||||
|
assert_eq!(deleted.delete_marker(), Some(true));
|
||||||
|
let marker_id = deleted.version_id().ok_or("DeleteObject omitted delete-marker version ID")?;
|
||||||
|
let after_delete = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
|
||||||
|
let matching_markers: Vec<_> = after_delete
|
||||||
|
.delete_markers()
|
||||||
|
.iter()
|
||||||
|
.filter(|marker| marker.key() == Some(key))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
matching_markers.len(),
|
||||||
|
1,
|
||||||
|
"delete marker missing or duplicated after current-version delete: {after_delete:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matching_markers[0].version_id() == Some(marker_id) && matching_markers[0].is_latest() == Some(true),
|
||||||
|
"DeleteObject response and ListObjectVersions disagree about the marker: {after_delete:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let latest_after_delete = reader.get_object().bucket(&bucket).key(key).send().await;
|
||||||
|
match latest_after_delete {
|
||||||
|
Ok(_) => return Err("current version should be a delete marker".into()),
|
||||||
|
Err(error)
|
||||||
|
if error
|
||||||
|
.as_service_error()
|
||||||
|
.and_then(ProvideErrorMetadata::code)
|
||||||
|
.is_some_and(|code| code == "NoSuchKey" || code == "NotFound") => {}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
|
||||||
|
let restored = reader.get_object().bucket(&bucket).key(key).version_id(&v1_id).send().await?;
|
||||||
|
let restored_body = restored.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(restored_body.as_ref(), b"v1");
|
||||||
|
|
||||||
|
writer
|
||||||
|
.delete_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(marker_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(get_object_bytes(&reader, &bucket, key).await?, b"v2");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_versioning_suspension_keeps_one_null_version_and_history() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("suspend");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let writer = dist.client(0)?;
|
||||||
|
let reader = dist.client(3)?;
|
||||||
|
enable_versioning(&writer, &bucket).await?;
|
||||||
|
|
||||||
|
let key = "suspended.txt";
|
||||||
|
let original = writer
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(b"enabled-history".to_vec().into())
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("enabled PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
writer
|
||||||
|
.put_bucket_versioning()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.versioning_configuration(
|
||||||
|
VersioningConfiguration::builder()
|
||||||
|
.status(BucketVersioningStatus::Suspended)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
put_object(&writer, &bucket, key, b"null-one".to_vec()).await?;
|
||||||
|
put_object(&writer, &bucket, key, b"null-two".to_vec()).await?;
|
||||||
|
assert_eq!(get_object_bytes(&reader, &bucket, key).await?, b"null-two");
|
||||||
|
|
||||||
|
let versions = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
|
||||||
|
let matching: Vec<_> = versions
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.filter(|version| version.key() == Some(key))
|
||||||
|
.collect();
|
||||||
|
assert!(matching.iter().any(|version| version.version_id() == Some(original.as_str())));
|
||||||
|
let null_version_count = matching
|
||||||
|
.iter()
|
||||||
|
.filter(|version| {
|
||||||
|
matches!(
|
||||||
|
version.version_id(),
|
||||||
|
None | Some("") | Some("null") | Some("00000000-0000-0000-0000-000000000000")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(null_version_count, 1, "suspended overwrites must keep one null version: {versions:?}");
|
||||||
|
|
||||||
|
let historical = reader
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(&original)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(historical.body.collect().await?.into_bytes().as_ref(), b"enabled-history");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -378,6 +378,11 @@ mod bucket_stats_regression_test;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod distributed_startup_regression_test;
|
mod distributed_startup_regression_test;
|
||||||
|
|
||||||
|
// 4-node / 4-disk distributed Actions suite (S3, lock, versioning, replication,
|
||||||
|
// quota, observability, expand/decommission/rebalance, site replication, chaos).
|
||||||
|
#[cfg(test)]
|
||||||
|
mod distributed;
|
||||||
|
|
||||||
// P1 regression: tier/ILM transition (rustfs#5218, #5130, #5011, #4826, #5024)
|
// P1 regression: tier/ILM transition (rustfs#5218, #5130, #5011, #4826, #5024)
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tier_transition_regression_test;
|
mod tier_transition_regression_test;
|
||||||
|
|||||||
@@ -20,9 +20,10 @@
|
|||||||
//! journal (`count_requests`) carries the assertion in every one of them.
|
//! journal (`count_requests`) carries the assertion in every one of them.
|
||||||
|
|
||||||
use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env};
|
use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env};
|
||||||
use crate::fake_s3_target::Operation;
|
use crate::fake_s3_target::{FaultAction, Operation};
|
||||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use futures::{StreamExt, TryStreamExt};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
type TestResult = Result<(), BoxError>;
|
type TestResult = Result<(), BoxError>;
|
||||||
@@ -145,14 +146,38 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let body = payload(128 * 1024);
|
let body = payload(128 * 1024);
|
||||||
|
let blocker = "queue/blocker.bin";
|
||||||
|
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(blocker, body.clone())]);
|
||||||
|
// The one-chunk range completes immediately; its full background pull
|
||||||
|
// occupies the only slot while the remaining requests fill the queue.
|
||||||
|
env.source.inject_for_key(
|
||||||
|
Operation::GetObject,
|
||||||
|
blocker,
|
||||||
|
FaultAction::SlowSendBody {
|
||||||
|
chunk_bytes: 1024,
|
||||||
|
delay: Duration::from_millis(100),
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
let response = env
|
||||||
|
.raw_object_request(http::Method::GET, bucket, blocker, &[("range", "bytes=0-1023")])
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status, 206);
|
||||||
|
assert_eq!(response.body, body.slice(0..1024));
|
||||||
|
env.wait_for_status_counter(bucket, "/inflight_pulls", 1, SETTLE).await?;
|
||||||
|
|
||||||
let keys: Vec<String> = (0..REQUESTS).map(|index| format!("queue/object-{index:03}.bin")).collect();
|
let keys: Vec<String> = (0..REQUESTS).map(|index| format!("queue/object-{index:03}.bin")).collect();
|
||||||
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
|
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
|
||||||
env.seed_source(SOURCE_BUCKET, &seeds);
|
env.seed_source(SOURCE_BUCKET, &seeds);
|
||||||
|
|
||||||
let responses: Vec<RawResponse> = futures::future::try_join_all(
|
// Bound source connections below the fixture's limit while still
|
||||||
|
// submitting all 100 requests to the eight-slot background queue.
|
||||||
|
let responses: Vec<RawResponse> = futures::stream::iter(
|
||||||
keys.iter()
|
keys.iter()
|
||||||
.map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])),
|
.map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])),
|
||||||
)
|
)
|
||||||
|
.buffered(16)
|
||||||
|
.try_collect()
|
||||||
.await?;
|
.await?;
|
||||||
for (key, response) in keys.iter().zip(&responses) {
|
for (key, response) in keys.iter().zip(&responses) {
|
||||||
assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body));
|
assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body));
|
||||||
@@ -168,6 +193,15 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
|
|||||||
.wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE)
|
.wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE)
|
||||||
.await?;
|
.await?;
|
||||||
assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue");
|
assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue");
|
||||||
|
let queue_full = usize::try_from(queue_full)?;
|
||||||
|
assert!(queue_full <= REQUESTS);
|
||||||
|
env.wait_for_status_counter(
|
||||||
|
bucket,
|
||||||
|
"/counters/pulled_objects_total/background",
|
||||||
|
u64::try_from(REQUESTS + 1 - queue_full)?,
|
||||||
|
SETTLE,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum();
|
let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -175,9 +209,6 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
|
|||||||
"every reader is served from the source: {ranged_reads} GETs for {REQUESTS} readers"
|
"every reader is served from the source: {ranged_reads} GETs for {REQUESTS} readers"
|
||||||
);
|
);
|
||||||
let dropped = keys.iter().filter(|key| source_get_count(&env, key) == 1).count();
|
let dropped = keys.iter().filter(|key| source_get_count(&env, key) == 1).count();
|
||||||
assert!(
|
assert_eq!(dropped, queue_full, "only overflowed keys remain without a background GET");
|
||||||
dropped > 0,
|
|
||||||
"the overflowed keys are the ones with no backfill GET, but every key got one"
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,16 +265,13 @@ async fn list_through_rejects_a_tampered_continuation_token() -> TestResult {
|
|||||||
let decoded = String::from_utf8(base64_simd::STANDARD.decode_to_vec(token.as_bytes())?)?;
|
let decoded = String::from_utf8(base64_simd::STANDARD.decode_to_vec(token.as_bytes())?)?;
|
||||||
assert!(decoded.contains("\"t\":\"odm-list\""), "the merged token is an envelope: {decoded}");
|
assert!(decoded.contains("\"t\":\"odm-list\""), "the merged token is an envelope: {decoded}");
|
||||||
|
|
||||||
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":2").as_bytes());
|
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":3").as_bytes());
|
||||||
let rejected = env
|
assert_ne!(tampered, token, "the test must change the token version");
|
||||||
.raw_list_objects_v2(bucket, &format!("continuation-token={tampered}"))
|
let query = serde_urlencoded::to_string([("continuation-token", tampered.as_str())])?;
|
||||||
.await?;
|
let rejected = env.raw_list_objects_v2(bucket, &query).await?;
|
||||||
assert_eq!(
|
let error_body = String::from_utf8_lossy(&rejected.body);
|
||||||
rejected.status,
|
assert_eq!(rejected.status, 400, "a bumped token version is a client error: {}", error_body);
|
||||||
400,
|
assert!(error_body.contains("<Code>InvalidArgument</Code>"), "{error_body}");
|
||||||
"a bumped token version is a client error: {}",
|
|
||||||
String::from_utf8_lossy(&rejected.body)
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,21 +12,34 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
use crate::common::{
|
||||||
|
RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging, replication_fast_env, rustfs_binary_path,
|
||||||
|
};
|
||||||
|
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target};
|
||||||
|
use crate::on_demand_migration::common::{ODM_SERVER_ENV, OdmTestEnv, SeedObject};
|
||||||
|
use crate::replication_extension_test::{
|
||||||
|
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, put_bucket_replication, set_replication_target_with_options,
|
||||||
|
};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ServerSideEncryption, VersioningConfiguration,
|
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, DefaultRetention,
|
||||||
|
ExpirationStatus, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter, ObjectLockConfiguration, ObjectLockEnabled,
|
||||||
|
ObjectLockRetentionMode, ObjectLockRule, PublicAccessBlockConfiguration, ServerSideEncryption, ServerSideEncryptionByDefault,
|
||||||
|
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, Tag, Tagging, VersioningConfiguration,
|
||||||
};
|
};
|
||||||
|
use http::{Method, StatusCode};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
use tokio::time::{Instant, sleep};
|
use tokio::time::{Instant, sleep};
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||||
|
|
||||||
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
||||||
|
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
|
||||||
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||||
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||||
const PLAIN_BUCKET: &str = "upgrade-plain-data";
|
const PLAIN_BUCKET: &str = "upgrade-plain-data";
|
||||||
@@ -40,6 +53,32 @@ const MULTIPART_UPLOADS_PER_WORKER: usize = 16;
|
|||||||
// comfortably covers that window plus CI scheduling jitter.
|
// comfortably covers that window plus CI scheduling jitter.
|
||||||
const LISTING_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
const LISTING_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
// Bucket-configuration upgrade/rollback scenarios (rustfs#7172, #7183, #7089).
|
||||||
|
const CONFIG_PLAIN_BUCKET: &str = "upgrade-config-plain";
|
||||||
|
const CONFIG_ENCRYPTED_BUCKET: &str = "upgrade-config-encrypted";
|
||||||
|
const CONFIG_REPLICATED_BUCKET: &str = "upgrade-config-replicated";
|
||||||
|
const CONFIG_LOCKED_BUCKET: &str = "upgrade-config-locked";
|
||||||
|
const CONFIG_REPLICA_BUCKET: &str = "upgrade-config-replica";
|
||||||
|
const ROLLBACK_BUCKET: &str = "rollback-config-data";
|
||||||
|
const ROLLBACK_REPLICA_BUCKET: &str = "rollback-config-replica";
|
||||||
|
const BUCKET_QUOTA_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
|
const LIFECYCLE_RULE_ID: &str = "upgrade-expire-logs";
|
||||||
|
const LIFECYCLE_PREFIX: &str = "logs/";
|
||||||
|
const LIFECYCLE_DAYS: i32 = 30;
|
||||||
|
const BUCKET_TAG_KEY: &str = "owner";
|
||||||
|
const BUCKET_TAG_VALUE: &str = "upgrade-compatibility";
|
||||||
|
const OBJECT_LOCK_DAYS: i32 = 1;
|
||||||
|
// `set-bucket-quota` answers 503 until the scanner has made the bucket's usage
|
||||||
|
// authoritative; the quota test uses the same 30s budget.
|
||||||
|
const QUOTA_READINESS_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
// Quota admission fails closed while a freshly started server has neither
|
||||||
|
// authoritative usage nor a persisted degraded baseline for the bucket
|
||||||
|
// (rustfs#5716), so a write to a quota-enabled bucket is retryable-503 for that
|
||||||
|
// window. It is a restart property, not an upgrade property — the same window
|
||||||
|
// opens on the very first start — so the write assertions ride it out instead
|
||||||
|
// of treating it as an upgrade failure.
|
||||||
|
const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90);
|
||||||
|
|
||||||
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
@@ -240,6 +279,93 @@ async fn exercise_mixed_cluster(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pins the published old writer's limitation and the supported recovery
|
||||||
|
/// procedure. This is not a promise that mixed-version ODM is supported.
|
||||||
|
/// Replace the loss assertion when ODM gains independent persistence;
|
||||||
|
/// preserving configuration across rc.5 writes is then an improvement.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires the pinned 1.0.0-rc.5 release binary"]
|
||||||
|
async fn rc5_rollback_requires_restoring_odm_configuration() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let previous_binary = source_binary()?;
|
||||||
|
let version = tokio::process::Command::new(&previous_binary)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
assert!(version.status.success(), "previous binary must report its version");
|
||||||
|
assert!(
|
||||||
|
String::from_utf8(version.stdout)?.contains(RC5_COMMIT),
|
||||||
|
"this compatibility scenario requires the published rc.5 writer"
|
||||||
|
);
|
||||||
|
let mut env = OdmTestEnv::start().await?;
|
||||||
|
let bucket = "odm-rc5-rollback";
|
||||||
|
let source_bucket = "odm-rc5-source";
|
||||||
|
env.source.create_bucket_with_mode(source_bucket, BucketMode::Unversioned);
|
||||||
|
env.seed_source(
|
||||||
|
source_bucket,
|
||||||
|
&[SeedObject::new(
|
||||||
|
"source-only",
|
||||||
|
bytes::Bytes::from_static(b"source read after recovery"),
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
env.rustfs.create_test_bucket(bucket).await?;
|
||||||
|
let saved_config = env.fake_source_spec(source_bucket);
|
||||||
|
assert_eq!(env.configure_source(bucket, &saved_config).await?.status, 200);
|
||||||
|
let before = env.get_config(bucket).await?;
|
||||||
|
assert_eq!(before.status, 200);
|
||||||
|
let expected_config = before
|
||||||
|
.json()?
|
||||||
|
.get("config")
|
||||||
|
.cloned()
|
||||||
|
.ok_or("configuration response omitted config")?;
|
||||||
|
env.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("local")
|
||||||
|
.body(ByteStream::from_static(b"local data survives rollback"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
||||||
|
let restarted = env.get_config(bucket).await?;
|
||||||
|
assert_eq!(restarted.status, 200, "a current writer preserves ODM across restart");
|
||||||
|
assert_eq!(restarted.json()?.get("config"), Some(&expected_config));
|
||||||
|
|
||||||
|
restart_from_binary(&mut env.rustfs, &previous_binary, &[]).await?;
|
||||||
|
env.client
|
||||||
|
.put_bucket_tagging()
|
||||||
|
.bucket(bucket)
|
||||||
|
.tagging(
|
||||||
|
Tagging::builder()
|
||||||
|
.tag_set(Tag::builder().key("writer").value("rc5").build()?)
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
||||||
|
let missing = env.get_config(bucket).await?;
|
||||||
|
assert_eq!(missing.status, 404, "rc.5 rewrites metadata without ODM keys");
|
||||||
|
assert!(missing.body.contains("NoSuchConfiguration"));
|
||||||
|
assert_eq!(read_object(&env.client, bucket, "local", None).await?.1, b"local data survives rollback");
|
||||||
|
let tags = env.client.get_bucket_tagging().bucket(bucket).send().await?;
|
||||||
|
assert!(tags.tag_set().iter().any(|tag| tag.key() == "writer" && tag.value() == "rc5"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
env.configure_source(bucket, &saved_config).await?.status,
|
||||||
|
200,
|
||||||
|
"restore from saved full configuration"
|
||||||
|
);
|
||||||
|
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
||||||
|
let restored = env.get_config(bucket).await?;
|
||||||
|
assert_eq!(restored.status, 200, "restored ODM configuration persists");
|
||||||
|
assert_eq!(restored.json()?.get("config"), Some(&expected_config));
|
||||||
|
env.wait_until_source_consulted(bucket).await?;
|
||||||
|
assert_eq!(
|
||||||
|
read_object(&env.client, bucket, "source-only", None).await?.1,
|
||||||
|
b"source read after recovery"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires a pinned previous RustFS release binary"]
|
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||||
async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
|
async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
|
||||||
@@ -429,3 +555,653 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Child-process environment shared by both bucket-configuration scenarios.
|
||||||
|
///
|
||||||
|
/// The replication target is an in-process fake bound to `127.0.0.1`, which
|
||||||
|
/// `set-remote-target` rejects as an SSRF risk without the loopback opt-in, and
|
||||||
|
/// the proxy bypass keeps a developer's `HTTP_PROXY` from intercepting the
|
||||||
|
/// server's outbound health check.
|
||||||
|
fn bucket_config_server_env() -> Vec<(&'static str, &'static str)> {
|
||||||
|
let mut env = vec![
|
||||||
|
(SSE_MASTER_KEY_ENV, SSE_MASTER_KEY),
|
||||||
|
("NO_PROXY", "127.0.0.1,localhost"),
|
||||||
|
("HTTP_PROXY", ""),
|
||||||
|
("HTTPS_PROXY", ""),
|
||||||
|
// Shorten the scanner cycle so the bucket's usage becomes authoritative
|
||||||
|
// in seconds; both `set-bucket-quota` and quota admission block on it.
|
||||||
|
("RUSTFS_SCANNER_CYCLE", "1"),
|
||||||
|
("RUSTFS_SCANNER_START_DELAY_SECS", "0"),
|
||||||
|
];
|
||||||
|
env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||||
|
env.extend(replication_fast_env());
|
||||||
|
env
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart `env` in place on the same data directory using an explicit binary.
|
||||||
|
///
|
||||||
|
/// [`RustFSTestEnvironment::restart_server_preserving_data`] always relaunches
|
||||||
|
/// the workspace build, which is the upgrade direction only. The rollback
|
||||||
|
/// scenario needs the reverse: stop the current build and bring the pinned
|
||||||
|
/// previous release up on the metadata that build just wrote.
|
||||||
|
async fn restart_from_binary(env: &mut RustFSTestEnvironment, binary: &Path, server_env: &[(&str, &str)]) -> TestResult {
|
||||||
|
env.stop_server();
|
||||||
|
env.start_rustfs_server_from_binary(binary, vec![], server_env).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_bucket_quota(env: &RustFSTestEnvironment, bucket: &str, quota_bytes: u64) -> TestResult {
|
||||||
|
let path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||||
|
let body = serde_json::json!({ "quota": quota_bytes, "quota_type": "HARD" }).to_string();
|
||||||
|
let deadline = Instant::now() + QUOTA_READINESS_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let (status, response) =
|
||||||
|
admin_request(&env.url, Method::PUT, &path, Some(body.clone()), &env.access_key, &env.secret_key).await?;
|
||||||
|
if status.is_success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if status != StatusCode::SERVICE_UNAVAILABLE || Instant::now() >= deadline {
|
||||||
|
return Err(format!("setting the quota of {bucket} failed: {status} {response}").into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT into a quota-enabled bucket, riding out the post-start quota-admission
|
||||||
|
/// warm-up described on [`QUOTA_ADMISSION_WARMUP_TIMEOUT`].
|
||||||
|
///
|
||||||
|
/// Only `ServiceUnavailable` is retried: any other failure, and a warm-up that
|
||||||
|
/// never ends, is a genuine regression and surfaces as an error.
|
||||||
|
async fn put_object_through_quota_warmup(client: &Client, bucket: &str, key: &str, body: &'static [u8]) -> TestResult {
|
||||||
|
let deadline = Instant::now() + QUOTA_ADMISSION_WARMUP_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let result = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from_static(body))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
let error = match result {
|
||||||
|
Ok(_) => return Ok(()),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
let retryable = error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("ServiceUnavailable");
|
||||||
|
if !retryable || Instant::now() >= deadline {
|
||||||
|
return Err(format!("PUT {bucket}/{key} failed after the quota warm-up window: {error}").into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_bucket_quota(env: &RustFSTestEnvironment, bucket: &str) -> Result<Option<u64>, BoxError> {
|
||||||
|
let path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||||
|
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
|
||||||
|
if status != StatusCode::OK {
|
||||||
|
return Err(format!("reading the quota of {bucket} failed: {status} {response}").into());
|
||||||
|
}
|
||||||
|
let quota: serde_json::Value = serde_json::from_str(&response)?;
|
||||||
|
Ok(quota.get("quota").and_then(serde_json::Value::as_u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /rustfs/admin/v3/list-remote-targets?bucket=...`.
|
||||||
|
///
|
||||||
|
/// Returns an error for any non-200, because rustfs#7172 made this endpoint
|
||||||
|
/// fail closed on a `bucket-targets.json` blob the running build cannot parse.
|
||||||
|
/// An upgrade that misreads a blob written by the previous release therefore
|
||||||
|
/// shows up here as an error, and a silently dropped target shows up as an
|
||||||
|
/// empty list — the caller must distinguish the two.
|
||||||
|
async fn list_remote_targets(env: &RustFSTestEnvironment, bucket: &str) -> Result<Vec<serde_json::Value>, BoxError> {
|
||||||
|
let path = format!("/rustfs/admin/v3/list-remote-targets?bucket={}", urlencoding::encode(bucket));
|
||||||
|
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
|
||||||
|
if status != StatusCode::OK {
|
||||||
|
return Err(format!("list-remote-targets for {bucket} failed: {status} {response}").into());
|
||||||
|
}
|
||||||
|
Ok(serde_json::from_str(&response)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that `bucket` still carries exactly the replication target `arn`.
|
||||||
|
async fn assert_remote_target_preserved(env: &RustFSTestEnvironment, bucket: &str, arn: &str, context: &str) -> TestResult {
|
||||||
|
let targets = list_remote_targets(env, bucket).await?;
|
||||||
|
assert_eq!(
|
||||||
|
targets.len(),
|
||||||
|
1,
|
||||||
|
"{context}: list-remote-targets must still report the single configured target, got {targets:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
targets[0].get("arn").and_then(serde_json::Value::as_str),
|
||||||
|
Some(arn),
|
||||||
|
"{context}: the target ARN changed across the restart: {targets:?}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure a replication target on `bucket` pointing at the in-process fake,
|
||||||
|
/// then attach an enabled replication rule for it. Returns the target ARN.
|
||||||
|
async fn configure_replication(
|
||||||
|
env: &RustFSTestEnvironment,
|
||||||
|
bucket: &str,
|
||||||
|
target: &FakeS3Target,
|
||||||
|
target_bucket: &str,
|
||||||
|
) -> Result<String, BoxError> {
|
||||||
|
let arn = set_replication_target_with_options(
|
||||||
|
env,
|
||||||
|
bucket,
|
||||||
|
ReplicationTargetOptions {
|
||||||
|
endpoint: &target.address(),
|
||||||
|
access_key: FAKE_ACCESS_KEY,
|
||||||
|
secret_key: FAKE_SECRET_KEY,
|
||||||
|
target_bucket,
|
||||||
|
secure: false,
|
||||||
|
skip_tls_verify: false,
|
||||||
|
ca_cert_pem: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
put_bucket_replication(env, bucket, &arn).await?;
|
||||||
|
Ok(arn)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_default_sse_s3_encryption(client: &Client, bucket: &str) -> TestResult {
|
||||||
|
let configuration = ServerSideEncryptionConfiguration::builder()
|
||||||
|
.rules(
|
||||||
|
ServerSideEncryptionRule::builder()
|
||||||
|
.apply_server_side_encryption_by_default(
|
||||||
|
ServerSideEncryptionByDefault::builder()
|
||||||
|
.sse_algorithm(ServerSideEncryption::Aes256)
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()?;
|
||||||
|
client
|
||||||
|
.put_bucket_encryption()
|
||||||
|
.bucket(bucket)
|
||||||
|
.server_side_encryption_configuration(configuration)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_default_sse_s3_encryption(client: &Client, bucket: &str, context: &str) -> TestResult {
|
||||||
|
let response = client.get_bucket_encryption().bucket(bucket).send().await?;
|
||||||
|
let rules = response
|
||||||
|
.server_side_encryption_configuration()
|
||||||
|
.ok_or("GetBucketEncryption omitted the configuration")?
|
||||||
|
.rules();
|
||||||
|
assert_eq!(rules.len(), 1, "{context}: expected exactly one encryption rule, got {rules:?}");
|
||||||
|
assert_eq!(
|
||||||
|
rules[0]
|
||||||
|
.apply_server_side_encryption_by_default()
|
||||||
|
.map(ServerSideEncryptionByDefault::sse_algorithm),
|
||||||
|
Some(&ServerSideEncryption::Aes256),
|
||||||
|
"{context}: the default encryption algorithm changed"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_bucket_tag(client: &Client, bucket: &str) -> TestResult {
|
||||||
|
let tagging = Tagging::builder()
|
||||||
|
.tag_set(Tag::builder().key(BUCKET_TAG_KEY).value(BUCKET_TAG_VALUE).build()?)
|
||||||
|
.build()?;
|
||||||
|
client.put_bucket_tagging().bucket(bucket).tagging(tagging).send().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_bucket_tag(client: &Client, bucket: &str, context: &str) -> TestResult {
|
||||||
|
let tags = client.get_bucket_tagging().bucket(bucket).send().await?;
|
||||||
|
let tag_set = tags.tag_set();
|
||||||
|
assert_eq!(tag_set.len(), 1, "{context}: expected exactly one bucket tag, got {tag_set:?}");
|
||||||
|
assert_eq!(tag_set[0].key(), BUCKET_TAG_KEY, "{context}: bucket tag key changed");
|
||||||
|
assert_eq!(tag_set[0].value(), BUCKET_TAG_VALUE, "{context}: bucket tag value changed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_versioning_enabled(client: &Client, bucket: &str, context: &str) -> TestResult {
|
||||||
|
let versioning = client.get_bucket_versioning().bucket(bucket).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
versioning.status(),
|
||||||
|
Some(&BucketVersioningStatus::Enabled),
|
||||||
|
"{context}: versioning is no longer Enabled on {bucket}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bucket_policy_document(bucket: &str) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Sid": "UpgradePublicRead",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": { "AWS": ["*"] },
|
||||||
|
"Action": ["s3:GetObject"],
|
||||||
|
"Resource": [format!("arn:aws:s3:::{bucket}/public/*")]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET .../on-demand-migration/{bucket}/status`.
|
||||||
|
///
|
||||||
|
/// The migration module defaults on from rustfs#7089, so a bucket that never
|
||||||
|
/// configured a source must still answer `configured: false` rather than
|
||||||
|
/// engaging the migration path.
|
||||||
|
async fn assert_migration_not_configured(env: &RustFSTestEnvironment, bucket: &str) -> TestResult {
|
||||||
|
let path = format!("/rustfs/admin/v3/on-demand-migration/{bucket}/status");
|
||||||
|
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::OK,
|
||||||
|
"the migration status endpoint must answer for an unconfigured bucket: {status} {response}"
|
||||||
|
);
|
||||||
|
let body: serde_json::Value = serde_json::from_str(&response)?;
|
||||||
|
assert_eq!(
|
||||||
|
body.get("configured"),
|
||||||
|
Some(&serde_json::Value::Bool(false)),
|
||||||
|
"a bucket upgraded from the previous release must not look migration-configured: {body}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GET for a key that was never written must be a plain `NoSuchKey`.
|
||||||
|
///
|
||||||
|
/// With the migration module on by default this is the cheap proof that an
|
||||||
|
/// unconfigured bucket never consults a source: any migration engagement would
|
||||||
|
/// surface as a different status or error code here.
|
||||||
|
async fn assert_missing_key_is_no_such_key(client: &Client, bucket: &str, key: &str) -> TestResult {
|
||||||
|
let error = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a key that was never written must not be readable");
|
||||||
|
assert_eq!(
|
||||||
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"a missing key must stay a 404 on a bucket with no migration configuration"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NoSuchKey"),
|
||||||
|
"a missing key must stay NoSuchKey on a bucket with no migration configuration"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bucket configuration written by the pinned previous release must survive an
|
||||||
|
/// upgrade to the current build unchanged, and must keep working.
|
||||||
|
///
|
||||||
|
/// This pins the three on-disk surfaces the on-demand-migration series moved:
|
||||||
|
///
|
||||||
|
/// * `BucketMetadata` grew two msgpack keys (encoded map length 44 -> 46), so
|
||||||
|
/// every configuration read below decodes a 44-key blob on 46-key code.
|
||||||
|
/// * rustfs#7172 made an unreadable `bucket-targets.json` / encryption /
|
||||||
|
/// public-access-block / quota blob "present but unreadable" instead of
|
||||||
|
/// silently defaulting, and made `list-remote-targets` fail closed on it. A
|
||||||
|
/// replication target configured by the old release must therefore still be
|
||||||
|
/// *listed*, not dropped and not an error.
|
||||||
|
/// * rustfs#7183 made the object write path refuse a PUT when the bucket's
|
||||||
|
/// encryption configuration cannot be read, so a misparsed SSE config would
|
||||||
|
/// turn every PUT to that bucket into a 500.
|
||||||
|
///
|
||||||
|
/// Not covered on purpose: on-demand-migration configuration itself, which the
|
||||||
|
/// previous release has no public API for — the reverse direction is asserted
|
||||||
|
/// instead (an upgraded bucket reports `configured: false`).
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||||
|
async fn direct_upgrade_from_previous_release_preserves_bucket_configuration() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let previous_binary = source_binary()?;
|
||||||
|
|
||||||
|
// In-process: the fake target outlives both server processes, so the
|
||||||
|
// replication target stays reachable across the upgrade.
|
||||||
|
let replication_target = FakeS3Target::start().await?;
|
||||||
|
replication_target.create_bucket(CONFIG_REPLICA_BUCKET);
|
||||||
|
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
let server_env = bucket_config_server_env();
|
||||||
|
env.start_rustfs_server_from_binary(&previous_binary, vec![], &server_env)
|
||||||
|
.await?;
|
||||||
|
let old_client = env.create_s3_client();
|
||||||
|
|
||||||
|
env.create_test_bucket(CONFIG_PLAIN_BUCKET).await?;
|
||||||
|
env.create_test_bucket(CONFIG_ENCRYPTED_BUCKET).await?;
|
||||||
|
env.create_test_bucket(CONFIG_REPLICATED_BUCKET).await?;
|
||||||
|
old_client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(CONFIG_LOCKED_BUCKET)
|
||||||
|
.object_lock_enabled_for_bucket(true)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Plain bucket: policy, tags, lifecycle, quota.
|
||||||
|
let policy = bucket_policy_document(CONFIG_PLAIN_BUCKET);
|
||||||
|
old_client
|
||||||
|
.put_bucket_policy()
|
||||||
|
.bucket(CONFIG_PLAIN_BUCKET)
|
||||||
|
.policy(policy.to_string())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
put_bucket_tag(&old_client, CONFIG_PLAIN_BUCKET).await?;
|
||||||
|
old_client
|
||||||
|
.put_bucket_lifecycle_configuration()
|
||||||
|
.bucket(CONFIG_PLAIN_BUCKET)
|
||||||
|
.lifecycle_configuration(
|
||||||
|
BucketLifecycleConfiguration::builder()
|
||||||
|
.rules(
|
||||||
|
LifecycleRule::builder()
|
||||||
|
.id(LIFECYCLE_RULE_ID)
|
||||||
|
.status(ExpirationStatus::Enabled)
|
||||||
|
.filter(LifecycleRuleFilter::builder().prefix(LIFECYCLE_PREFIX).build())
|
||||||
|
.expiration(LifecycleExpiration::builder().days(LIFECYCLE_DAYS).build())
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
set_bucket_quota(&env, CONFIG_PLAIN_BUCKET, BUCKET_QUOTA_BYTES).await?;
|
||||||
|
|
||||||
|
// Encrypted bucket: SSE-S3 default encryption plus a fully restrictive
|
||||||
|
// public access block, both of which rustfs#7172 now fails closed on.
|
||||||
|
put_default_sse_s3_encryption(&old_client, CONFIG_ENCRYPTED_BUCKET).await?;
|
||||||
|
old_client
|
||||||
|
.put_public_access_block()
|
||||||
|
.bucket(CONFIG_ENCRYPTED_BUCKET)
|
||||||
|
.public_access_block_configuration(
|
||||||
|
PublicAccessBlockConfiguration::builder()
|
||||||
|
.block_public_acls(true)
|
||||||
|
.ignore_public_acls(true)
|
||||||
|
.block_public_policy(true)
|
||||||
|
.restrict_public_buckets(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Replicated bucket: versioning, a validated remote target, a rule.
|
||||||
|
enable_versioning(&old_client, CONFIG_REPLICATED_BUCKET).await?;
|
||||||
|
let target_arn = configure_replication(&env, CONFIG_REPLICATED_BUCKET, &replication_target, CONFIG_REPLICA_BUCKET).await?;
|
||||||
|
assert_remote_target_preserved(&env, CONFIG_REPLICATED_BUCKET, &target_arn, "before the upgrade").await?;
|
||||||
|
|
||||||
|
// Object-lock bucket: a default GOVERNANCE retention on a fresh bucket.
|
||||||
|
old_client
|
||||||
|
.put_object_lock_configuration()
|
||||||
|
.bucket(CONFIG_LOCKED_BUCKET)
|
||||||
|
.object_lock_configuration(
|
||||||
|
ObjectLockConfiguration::builder()
|
||||||
|
.object_lock_enabled(ObjectLockEnabled::Enabled)
|
||||||
|
.rule(
|
||||||
|
ObjectLockRule::builder()
|
||||||
|
.default_retention(
|
||||||
|
DefaultRetention::builder()
|
||||||
|
.mode(ObjectLockRetentionMode::Governance)
|
||||||
|
.days(OBJECT_LOCK_DAYS)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let plain_key = "plain/written-by-previous";
|
||||||
|
let plain_bytes = b"plain object written by the previous RustFS release";
|
||||||
|
put_object_through_quota_warmup(&old_client, CONFIG_PLAIN_BUCKET, plain_key, plain_bytes).await?;
|
||||||
|
|
||||||
|
let encrypted_key = "encrypted/written-by-previous";
|
||||||
|
let encrypted_bytes = b"default-encrypted object written by the previous RustFS release";
|
||||||
|
old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(CONFIG_ENCRYPTED_BUCKET)
|
||||||
|
.key(encrypted_key)
|
||||||
|
.body(ByteStream::from_static(encrypted_bytes))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
read_object(&old_client, CONFIG_ENCRYPTED_BUCKET, encrypted_key, None)
|
||||||
|
.await?
|
||||||
|
.0,
|
||||||
|
Some(ServerSideEncryption::Aes256),
|
||||||
|
"the previous release must apply the bucket default encryption it just accepted"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The multipart object lives in the default-encrypted bucket so the
|
||||||
|
// upgraded build has to reassemble parts *and* re-derive the object key.
|
||||||
|
let multipart_key = "encrypted/multipart-written-by-previous";
|
||||||
|
let multipart_parts = vec![vec![b'm'; 5 * 1024 * 1024], b"final multipart bytes".to_vec()];
|
||||||
|
let multipart_bytes = multipart_parts.concat();
|
||||||
|
write_multipart(&old_client, CONFIG_ENCRYPTED_BUCKET, multipart_key, &multipart_parts).await?;
|
||||||
|
|
||||||
|
let versioned_key = "versioned/written-by-previous";
|
||||||
|
let versioned_bytes = b"versioned object written by the previous RustFS release";
|
||||||
|
let versioned_id = old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(CONFIG_REPLICATED_BUCKET)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(ByteStream::from_static(versioned_bytes))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("versioned PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||||
|
let new_client = env.create_s3_client();
|
||||||
|
|
||||||
|
// Every configuration must read back unchanged on the upgraded build.
|
||||||
|
let upgraded_policy = new_client.get_bucket_policy().bucket(CONFIG_PLAIN_BUCKET).send().await?;
|
||||||
|
let upgraded_policy: serde_json::Value =
|
||||||
|
serde_json::from_str(upgraded_policy.policy().ok_or("GetBucketPolicy omitted the document")?)?;
|
||||||
|
assert_eq!(upgraded_policy, policy, "the bucket policy changed across the upgrade");
|
||||||
|
assert_bucket_tag(&new_client, CONFIG_PLAIN_BUCKET, "after the upgrade").await?;
|
||||||
|
|
||||||
|
let lifecycle = new_client
|
||||||
|
.get_bucket_lifecycle_configuration()
|
||||||
|
.bucket(CONFIG_PLAIN_BUCKET)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let rules = lifecycle.rules();
|
||||||
|
assert_eq!(rules.len(), 1, "the lifecycle rule count changed across the upgrade: {rules:?}");
|
||||||
|
assert_eq!(rules[0].id(), Some(LIFECYCLE_RULE_ID));
|
||||||
|
assert_eq!(rules[0].status(), &ExpirationStatus::Enabled);
|
||||||
|
assert_eq!(
|
||||||
|
rules[0].expiration().and_then(LifecycleExpiration::days),
|
||||||
|
Some(LIFECYCLE_DAYS),
|
||||||
|
"the lifecycle expiration changed across the upgrade"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_bucket_quota(&env, CONFIG_PLAIN_BUCKET).await?,
|
||||||
|
Some(BUCKET_QUOTA_BYTES),
|
||||||
|
"the bucket quota changed across the upgrade"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_default_sse_s3_encryption(&new_client, CONFIG_ENCRYPTED_BUCKET, "after the upgrade").await?;
|
||||||
|
let public_access_block = new_client
|
||||||
|
.get_public_access_block()
|
||||||
|
.bucket(CONFIG_ENCRYPTED_BUCKET)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let public_access_block = public_access_block
|
||||||
|
.public_access_block_configuration()
|
||||||
|
.ok_or("GetPublicAccessBlock omitted the configuration")?;
|
||||||
|
assert_eq!(public_access_block.block_public_acls(), Some(true));
|
||||||
|
assert_eq!(public_access_block.ignore_public_acls(), Some(true));
|
||||||
|
assert_eq!(public_access_block.block_public_policy(), Some(true));
|
||||||
|
assert_eq!(public_access_block.restrict_public_buckets(), Some(true));
|
||||||
|
|
||||||
|
assert_versioning_enabled(&new_client, CONFIG_REPLICATED_BUCKET, "after the upgrade").await?;
|
||||||
|
// rustfs#7172: neither an empty list nor an error is acceptable here.
|
||||||
|
assert_remote_target_preserved(&env, CONFIG_REPLICATED_BUCKET, &target_arn, "after the upgrade").await?;
|
||||||
|
let replication = new_client
|
||||||
|
.get_bucket_replication()
|
||||||
|
.bucket(CONFIG_REPLICATED_BUCKET)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let replication_rules = replication
|
||||||
|
.replication_configuration()
|
||||||
|
.ok_or("GetBucketReplication omitted the configuration")?
|
||||||
|
.rules();
|
||||||
|
assert_eq!(
|
||||||
|
replication_rules.len(),
|
||||||
|
1,
|
||||||
|
"the replication rule count changed across the upgrade: {replication_rules:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
replication_rules[0].destination().map(|destination| destination.bucket()),
|
||||||
|
Some(target_arn.as_str()),
|
||||||
|
"the replication rule no longer points at the configured target"
|
||||||
|
);
|
||||||
|
|
||||||
|
let object_lock = new_client
|
||||||
|
.get_object_lock_configuration()
|
||||||
|
.bucket(CONFIG_LOCKED_BUCKET)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let object_lock = object_lock
|
||||||
|
.object_lock_configuration()
|
||||||
|
.ok_or("GetObjectLockConfiguration omitted the configuration")?;
|
||||||
|
assert_eq!(object_lock.object_lock_enabled(), Some(&ObjectLockEnabled::Enabled));
|
||||||
|
let retention = object_lock
|
||||||
|
.rule()
|
||||||
|
.and_then(ObjectLockRule::default_retention)
|
||||||
|
.ok_or("the object lock configuration lost its default retention")?;
|
||||||
|
assert_eq!(retention.mode(), Some(&ObjectLockRetentionMode::Governance));
|
||||||
|
assert_eq!(retention.days(), Some(OBJECT_LOCK_DAYS));
|
||||||
|
|
||||||
|
// rustfs#7183: a PUT into the default-encrypted bucket must still succeed
|
||||||
|
// and still come back encrypted.
|
||||||
|
let post_upgrade_encrypted_key = "encrypted/written-after-upgrade";
|
||||||
|
let post_upgrade_encrypted_bytes = b"default-encrypted object written by the current RustFS build";
|
||||||
|
new_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(CONFIG_ENCRYPTED_BUCKET)
|
||||||
|
.key(post_upgrade_encrypted_key)
|
||||||
|
.body(ByteStream::from_static(post_upgrade_encrypted_bytes))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let (encryption, body) = read_object(&new_client, CONFIG_ENCRYPTED_BUCKET, post_upgrade_encrypted_key, None).await?;
|
||||||
|
assert_eq!(
|
||||||
|
encryption,
|
||||||
|
Some(ServerSideEncryption::Aes256),
|
||||||
|
"a PUT after the upgrade lost the bucket default encryption"
|
||||||
|
);
|
||||||
|
assert_eq!(body, post_upgrade_encrypted_bytes);
|
||||||
|
|
||||||
|
let post_upgrade_plain_key = "plain/written-after-upgrade";
|
||||||
|
let post_upgrade_plain_bytes = b"plain object written by the current RustFS build";
|
||||||
|
put_object_through_quota_warmup(&new_client, CONFIG_PLAIN_BUCKET, post_upgrade_plain_key, post_upgrade_plain_bytes).await?;
|
||||||
|
let (encryption, body) = read_object(&new_client, CONFIG_PLAIN_BUCKET, post_upgrade_plain_key, None).await?;
|
||||||
|
assert_eq!(encryption, None, "a bucket without default encryption must not encrypt a PUT");
|
||||||
|
assert_eq!(body, post_upgrade_plain_bytes);
|
||||||
|
|
||||||
|
// Every object written by the previous release reads back byte-identical.
|
||||||
|
assert_eq!(read_object(&new_client, CONFIG_PLAIN_BUCKET, plain_key, None).await?.1, plain_bytes);
|
||||||
|
let (encryption, body) = read_object(&new_client, CONFIG_ENCRYPTED_BUCKET, encrypted_key, None).await?;
|
||||||
|
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(body, encrypted_bytes);
|
||||||
|
let (encryption, body) = read_object(&new_client, CONFIG_ENCRYPTED_BUCKET, multipart_key, None).await?;
|
||||||
|
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(body, multipart_bytes, "the multipart object did not survive the upgrade");
|
||||||
|
assert_eq!(
|
||||||
|
read_object(&new_client, CONFIG_REPLICATED_BUCKET, versioned_key, Some(&versioned_id))
|
||||||
|
.await?
|
||||||
|
.1,
|
||||||
|
versioned_bytes
|
||||||
|
);
|
||||||
|
|
||||||
|
// rustfs#7089: the migration module is on by default, but a bucket that
|
||||||
|
// never configured a source behaves exactly as before.
|
||||||
|
assert_migration_not_configured(&env, CONFIG_PLAIN_BUCKET).await?;
|
||||||
|
assert_missing_key_is_no_such_key(&new_client, CONFIG_PLAIN_BUCKET, "plain/never-written").await?;
|
||||||
|
|
||||||
|
replication_target.shutdown().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolling back to the pinned previous release must still read the bucket
|
||||||
|
/// metadata the current build wrote.
|
||||||
|
///
|
||||||
|
/// This is the other half of the `BucketMetadata` 44 -> 46 key change: the
|
||||||
|
/// current build writes a 46-key msgpack map with `OnDemandMigrationConfigJSON`
|
||||||
|
/// and `OnDemandMigrationConfigUpdatedAt`, and the previous release's decoder
|
||||||
|
/// has to skip those two unknown keys instead of failing the whole blob. If it
|
||||||
|
/// did not, every configuration read below would come back empty or error and
|
||||||
|
/// the rollback would silently discard the bucket's configuration.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||||
|
async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let previous_binary = source_binary()?;
|
||||||
|
|
||||||
|
let replication_target = FakeS3Target::start().await?;
|
||||||
|
replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET);
|
||||||
|
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
let server_env = bucket_config_server_env();
|
||||||
|
env.start_rustfs_server_with_env(vec![], &server_env).await?;
|
||||||
|
let new_client = env.create_s3_client();
|
||||||
|
|
||||||
|
env.create_test_bucket(ROLLBACK_BUCKET).await?;
|
||||||
|
enable_versioning(&new_client, ROLLBACK_BUCKET).await?;
|
||||||
|
put_default_sse_s3_encryption(&new_client, ROLLBACK_BUCKET).await?;
|
||||||
|
put_bucket_tag(&new_client, ROLLBACK_BUCKET).await?;
|
||||||
|
let target_arn = configure_replication(&env, ROLLBACK_BUCKET, &replication_target, ROLLBACK_REPLICA_BUCKET).await?;
|
||||||
|
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "before the rollback").await?;
|
||||||
|
|
||||||
|
let single_key = "rollback/single";
|
||||||
|
let single_bytes = b"single-part object written by the current RustFS build";
|
||||||
|
let single_version = new_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(ROLLBACK_BUCKET)
|
||||||
|
.key(single_key)
|
||||||
|
.body(ByteStream::from_static(single_bytes))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("versioned PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let multipart_key = "rollback/multipart";
|
||||||
|
let multipart_parts = vec![vec![b'r'; 5 * 1024 * 1024], b"final rollback bytes".to_vec()];
|
||||||
|
let multipart_bytes = multipart_parts.concat();
|
||||||
|
write_multipart(&new_client, ROLLBACK_BUCKET, multipart_key, &multipart_parts).await?;
|
||||||
|
|
||||||
|
restart_from_binary(&mut env, &previous_binary, &server_env).await?;
|
||||||
|
let old_client = env.create_s3_client();
|
||||||
|
|
||||||
|
assert_versioning_enabled(&old_client, ROLLBACK_BUCKET, "after the rollback").await?;
|
||||||
|
assert_default_sse_s3_encryption(&old_client, ROLLBACK_BUCKET, "after the rollback").await?;
|
||||||
|
assert_bucket_tag(&old_client, ROLLBACK_BUCKET, "after the rollback").await?;
|
||||||
|
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "after the rollback").await?;
|
||||||
|
|
||||||
|
let (encryption, body) = read_object(&old_client, ROLLBACK_BUCKET, single_key, Some(&single_version)).await?;
|
||||||
|
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(body, single_bytes);
|
||||||
|
let (encryption, body) = read_object(&old_client, ROLLBACK_BUCKET, multipart_key, None).await?;
|
||||||
|
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(body, multipart_bytes, "the multipart object did not survive the rollback");
|
||||||
|
|
||||||
|
// A PUT on the rolled-back release must still honour the encryption
|
||||||
|
// configuration it decoded out of the current build's metadata blob.
|
||||||
|
let post_rollback_key = "rollback/written-after-rollback";
|
||||||
|
let post_rollback_bytes = b"object written by the previous RustFS release after the rollback";
|
||||||
|
old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(ROLLBACK_BUCKET)
|
||||||
|
.key(post_rollback_key)
|
||||||
|
.body(ByteStream::from_static(post_rollback_bytes))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let (encryption, body) = read_object(&old_client, ROLLBACK_BUCKET, post_rollback_key, None).await?;
|
||||||
|
assert_eq!(
|
||||||
|
encryption,
|
||||||
|
Some(ServerSideEncryption::Aes256),
|
||||||
|
"the rolled-back release lost the bucket default encryption"
|
||||||
|
);
|
||||||
|
assert_eq!(body, post_rollback_bytes);
|
||||||
|
|
||||||
|
replication_target.shutdown().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ workspace = true
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
|
||||||
# Compiles the controlled list-objects namespace-journal chaos injector into a
|
# Compiles the controlled list-objects namespace-journal chaos injector into a
|
||||||
# production binary (it is always available to tests). Off by default so the
|
# production binary (it is always available to tests). Off by default so the
|
||||||
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
|
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
|
||||||
@@ -212,8 +213,8 @@ aws-smithy-runtime-api = { workspace = true, features = ["http-1x"] }
|
|||||||
parking_lot = { workspace = true }
|
parking_lot = { workspace = true }
|
||||||
base64-simd.workspace = true
|
base64-simd.workspace = true
|
||||||
serde_urlencoded.workspace = true
|
serde_urlencoded.workspace = true
|
||||||
google-cloud-storage = { workspace = true }
|
google-cloud-storage = { workspace = true, optional = true }
|
||||||
google-cloud-auth = { workspace = true }
|
google-cloud-auth = { workspace = true, optional = true }
|
||||||
faster-hex = { workspace = true }
|
faster-hex = { workspace = true }
|
||||||
ratelimit = { workspace = true }
|
ratelimit = { workspace = true }
|
||||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
|
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
|
||||||
|
|||||||
@@ -146,67 +146,23 @@ pub mod bucket {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod on_demand_migration {
|
|
||||||
pub use crate::bucket::on_demand_migration::{
|
|
||||||
ApplyOutcome, BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION,
|
|
||||||
Breaker, BreakerState, BreakerTransition, BreakerVerdict, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, GaugeGuard,
|
|
||||||
LastSourceError, LatencyBucketSnapshot, NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache, OdmBucketSnapshot, OdmLookup,
|
|
||||||
OdmOp, OdmOutcome, OdmStateError, OdmStats, OdmStatsSnapshot, OnDemandMigrationSys, PullError, PullFailureReason,
|
|
||||||
PullFollower, PullLeader, PullOutcome, PullPath, PullResult, PullSlot, SOURCE_LATENCY_BUCKET_BOUNDS_MS,
|
|
||||||
SourceLatencySnapshot, source_client_spec,
|
|
||||||
};
|
|
||||||
pub use crate::bucket::on_demand_migration::{
|
|
||||||
ConfigPublishHook, FilterConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_HOOK, ON_DEMAND_MIGRATION_CONFIG_VERSION,
|
|
||||||
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy,
|
|
||||||
SourceConfig, SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
|
|
||||||
};
|
|
||||||
pub use crate::bucket::on_demand_migration::{
|
|
||||||
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS,
|
|
||||||
PullCompletion, PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody,
|
|
||||||
WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
|
|
||||||
idle_guarded_body,
|
|
||||||
};
|
|
||||||
pub use crate::bucket::on_demand_migration::{
|
|
||||||
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
|
|
||||||
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
|
|
||||||
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
|
|
||||||
};
|
|
||||||
pub mod backfill {
|
|
||||||
pub use crate::bucket::on_demand_migration::backfill::{
|
|
||||||
BACKFILL_CHECKPOINT_FILE, BACKFILL_CHECKPOINT_FORMAT_VERSION, BACKFILL_FAILED_KEYS_CAPACITY, BACKFILL_LEASE,
|
|
||||||
BACKFILL_LEASE_LOCK_PREFIX, BACKFILL_LIST_PAGE_SIZE, BACKFILL_RECOVERY_INTERVAL, BACKFILL_SAVE_EVERY_KEYS,
|
|
||||||
BACKFILL_SAVE_INTERVAL, BackfillCheckpoint, BackfillContext, BackfillContextFactory, BackfillError,
|
|
||||||
BackfillLastError, BackfillOwner, BackfillRecoveryStats, BackfillRequest, BackfillRunner, BackfillState,
|
|
||||||
BucketBackfillContext, LocalBackfillObject, PriorityPullPermits, PullPermit, PullPriority, SkipExisting,
|
|
||||||
StoredCheckpoint, SysBackfillContexts, global_backfill_runner, install_global_backfill_runner, key_hash,
|
|
||||||
read_checkpoint, run_backfill_recovery_loop, spawn_backfill_recovery_loop,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
pub mod source_client {
|
|
||||||
pub use crate::bucket::on_demand_migration::source_client::{
|
|
||||||
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceListRequest, SourceObject, SourcePage,
|
|
||||||
SourceProbe, SourceProvider, SourceSse, SourceTimeouts, USER_AGENT_SUFFIX, is_multipart_etag, range_header_value,
|
|
||||||
resolve_path_style,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod metadata_sys {
|
pub mod metadata_sys {
|
||||||
#[cfg(feature = "test-util")]
|
|
||||||
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
|
|
||||||
pub use crate::bucket::metadata_sys::{
|
pub use crate::bucket::metadata_sys::{
|
||||||
BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
BUCKET_CONFIG_PUBLISH_HOOK, BucketConfigPublishHook, BucketMetadataMutationGuard, BucketMetadataSys,
|
||||||
|
ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
||||||
acquire_bucket_metadata_transaction_lock_for_incarnation, acquire_scanner_bucket_incarnation_fence,
|
acquire_bucket_metadata_transaction_lock_for_incarnation, acquire_scanner_bucket_incarnation_fence,
|
||||||
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, delete_under_transaction_lock, get,
|
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, delete_under_transaction_lock, get,
|
||||||
get_accelerate_config, get_bucket_policy, get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk,
|
get_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_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_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_on_demand_migration_config_in, get_public_access_block_config, get_quota_config, get_replication_config,
|
||||||
get_tagging_config, get_versioning_config, get_website_config, init_bucket_metadata_sys, list_bucket_targets,
|
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
|
||||||
reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata, update,
|
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
|
||||||
update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation, update_quota_if_incarnation,
|
update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
||||||
update_under_transaction_lock,
|
update_quota_if_incarnation, update_under_transaction_lock,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
pub use crate::bucket::metadata_sys::{ConfigWriteLockProbe, test_support};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod migration {
|
pub mod migration {
|
||||||
@@ -249,7 +205,7 @@ pub mod bucket {
|
|||||||
pub mod remote_s3_client {
|
pub mod remote_s3_client {
|
||||||
pub use crate::bucket::remote_s3_client::{
|
pub use crate::bucket::remote_s3_client::{
|
||||||
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_client,
|
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_client,
|
||||||
validate_remote_endpoint,
|
build_remote_s3_config, validate_remote_endpoint, validate_target_ca_pem,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,9 +451,9 @@ pub mod object {
|
|||||||
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission,
|
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission,
|
||||||
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
||||||
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitStartError,
|
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitStartError,
|
||||||
ScannerPublicationCommitState, StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook,
|
ScannerPublicationCommitState, StreamConsumer, WriteCompletion, get_object_body_cache_plaintext_len,
|
||||||
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook,
|
lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
|
||||||
unregister_object_mutation_hook,
|
unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||||
};
|
};
|
||||||
pub use crate::store::{
|
pub use crate::store::{
|
||||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||||
@@ -561,6 +517,12 @@ pub mod set_disk {
|
|||||||
pub mod test_util {
|
pub mod test_util {
|
||||||
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
|
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
|
||||||
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
|
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
|
||||||
|
|
||||||
|
/// Keep a namespace commit pending until the returned owner is dropped.
|
||||||
|
#[must_use]
|
||||||
|
pub fn hold_namespace_commit(store: &crate::store::ECStore) -> impl Send + Sync {
|
||||||
|
store.ctx.begin_namespace_commit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -489,28 +489,17 @@ impl BucketMetadata {
|
|||||||
!self.bucket_targets_config_json.is_empty() && self.bucket_target_config.is_none()
|
!self.bucket_targets_config_json.is_empty() && self.bucket_target_config.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed per-bucket durability override, if a valid one is stored.
|
/// Opaque application-owned configuration with its persisted update time.
|
||||||
///
|
/// Empty bytes mean absent or cleared; decoding belongs to the consumer.
|
||||||
/// Absent/empty/unparsable payloads all mean "no override" (the bucket
|
pub fn on_demand_migration_config(&self) -> Option<(&[u8], OffsetDateTime)> {
|
||||||
/// follows the global durability mode); a parse failure is logged so a
|
(!self.on_demand_migration_config_json.is_empty()).then_some((
|
||||||
/// corrupted entry cannot silently change fsync behavior.
|
self.on_demand_migration_config_json.as_slice(),
|
||||||
/// Parsed on-demand migration config, if one is stored.
|
self.on_demand_migration_config_updated_at,
|
||||||
///
|
))
|
||||||
/// `Ok(None)` means no config (absent or cleared). A stored payload that
|
|
||||||
/// does not parse is an error, never a default: the runtime must not
|
|
||||||
/// pull from a source it cannot describe.
|
|
||||||
pub fn on_demand_migration_config(
|
|
||||||
&self,
|
|
||||||
) -> std::result::Result<
|
|
||||||
Option<super::on_demand_migration::OnDemandMigrationConfig>,
|
|
||||||
super::on_demand_migration::OnDemandMigrationConfigError,
|
|
||||||
> {
|
|
||||||
if self.on_demand_migration_config_json.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
super::on_demand_migration::OnDemandMigrationConfig::from_json(&self.on_demand_migration_config_json).map(Some)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parsed per-bucket durability override, if a valid one is stored.
|
||||||
|
/// Invalid payloads follow the global mode after logging a parse failure.
|
||||||
pub fn durability_config(&self) -> Option<super::durability::BucketDurabilityConfig> {
|
pub fn durability_config(&self) -> Option<super::durability::BucketDurabilityConfig> {
|
||||||
if self.durability_config_json.is_empty() {
|
if self.durability_config_json.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -916,13 +905,6 @@ impl BucketMetadata {
|
|||||||
self.durability_config_updated_at = updated;
|
self.durability_config_updated_at = updated;
|
||||||
}
|
}
|
||||||
BUCKET_ON_DEMAND_MIGRATION_CONFIG => {
|
BUCKET_ON_DEMAND_MIGRATION_CONFIG => {
|
||||||
// Structural check only (shape, unknown fields); the
|
|
||||||
// deployment-relative rules run in the admin handler with a
|
|
||||||
// `ValidationContext`. A blob this build cannot read must not
|
|
||||||
// be persisted for every later reader to trip over.
|
|
||||||
if !data.is_empty() {
|
|
||||||
super::on_demand_migration::OnDemandMigrationConfig::from_json(&data).map_err(Error::other)?;
|
|
||||||
}
|
|
||||||
self.on_demand_migration_config_json = data;
|
self.on_demand_migration_config_json = data;
|
||||||
self.on_demand_migration_config_updated_at = updated;
|
self.on_demand_migration_config_updated_at = updated;
|
||||||
}
|
}
|
||||||
@@ -1978,51 +1960,30 @@ mod test {
|
|||||||
|
|
||||||
const ODM_JSON: &[u8] = br#"{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
|
const ODM_JSON: &[u8] = br#"{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
|
||||||
|
|
||||||
/// rustfs/backlog#2148: the on-demand migration config is a RustFS
|
/// The metadata codec preserves application-owned bytes and timestamps.
|
||||||
/// extension entry that round-trips through `update_config` and the
|
|
||||||
/// msgpack codec, clears on delete, and never parses corruption into a
|
|
||||||
/// default.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn on_demand_migration_config_round_trips_and_tracks_updates() {
|
fn on_demand_migration_config_round_trips_and_tracks_updates() {
|
||||||
use crate::bucket::on_demand_migration::{OnDemandMigrationConfig, OnDemandMigrationConfigError};
|
|
||||||
|
|
||||||
let mut bm = BucketMetadata::new("odm-bucket");
|
let mut bm = BucketMetadata::new("odm-bucket");
|
||||||
assert_eq!(bm.on_demand_migration_config(), Ok(None), "fresh metadata carries no config");
|
assert_eq!(bm.on_demand_migration_config(), None, "fresh metadata carries no config");
|
||||||
|
|
||||||
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
|
|
||||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||||
.expect("valid config is accepted");
|
.expect("opaque config is accepted");
|
||||||
assert_ne!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
let stamped = bm.on_demand_migration_config_updated_at;
|
||||||
assert_eq!(bm.on_demand_migration_config(), Ok(Some(expected.clone())));
|
assert_ne!(stamped, OffsetDateTime::UNIX_EPOCH);
|
||||||
|
assert_eq!(bm.on_demand_migration_config(), Some((ODM_JSON, stamped)));
|
||||||
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
|
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
|
||||||
assert_eq!(back.on_demand_migration_config_json, bm.on_demand_migration_config_json);
|
assert_eq!(back.on_demand_migration_config_json, bm.on_demand_migration_config_json);
|
||||||
assert_eq!(
|
assert_eq!(back.on_demand_migration_config_updated_at.unix_timestamp(), stamped.unix_timestamp());
|
||||||
back.on_demand_migration_config_updated_at.unix_timestamp(),
|
|
||||||
bm.on_demand_migration_config_updated_at.unix_timestamp()
|
|
||||||
);
|
|
||||||
assert_eq!(back.on_demand_migration_config(), Ok(Some(expected)));
|
|
||||||
|
|
||||||
// A blob this build cannot read is rejected at the write boundary
|
|
||||||
// rather than persisted for every reader to trip over.
|
|
||||||
let before = bm.on_demand_migration_config_json.clone();
|
|
||||||
assert!(
|
|
||||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec())
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert_eq!(bm.on_demand_migration_config_json, before, "a rejected update leaves the blob untouched");
|
|
||||||
|
|
||||||
// Delete clears the entry.
|
|
||||||
let stamped = bm.on_demand_migration_config_updated_at;
|
|
||||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, Vec::new()).unwrap();
|
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, Vec::new()).unwrap();
|
||||||
assert!(bm.on_demand_migration_config_json.is_empty());
|
assert!(bm.on_demand_migration_config_json.is_empty());
|
||||||
assert_eq!(bm.on_demand_migration_config(), Ok(None));
|
assert_eq!(bm.on_demand_migration_config(), None);
|
||||||
assert!(bm.on_demand_migration_config_updated_at >= stamped);
|
assert!(bm.on_demand_migration_config_updated_at >= stamped);
|
||||||
|
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, b"not-json".to_vec())
|
||||||
// Corruption that bypassed `update_config` (disk, another writer)
|
.unwrap();
|
||||||
// is a typed error, never a default.
|
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
|
||||||
bm.on_demand_migration_config_json = b"not-json".to_vec();
|
assert_eq!(
|
||||||
assert!(matches!(bm.on_demand_migration_config(), Err(OnDemandMigrationConfigError::Malformed(_))));
|
back.on_demand_migration_config_json, b"not-json",
|
||||||
|
"metadata must not reinterpret application bytes"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// rustfs/backlog#2148: a `.metadata.bin` written before the on-demand
|
/// rustfs/backlog#2148: a `.metadata.bin` written before the on-demand
|
||||||
@@ -2034,7 +1995,7 @@ mod test {
|
|||||||
let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata");
|
let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata");
|
||||||
assert!(bm.on_demand_migration_config_json.is_empty());
|
assert!(bm.on_demand_migration_config_json.is_empty());
|
||||||
assert_eq!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
assert_eq!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
||||||
assert_eq!(bm.on_demand_migration_config(), Ok(None));
|
assert_eq!(bm.on_demand_migration_config(), None);
|
||||||
|
|
||||||
bm.default_timestamps();
|
bm.default_timestamps();
|
||||||
assert_ne!(bm.created, OffsetDateTime::UNIX_EPOCH, "fixture must carry a real creation time");
|
assert_ne!(bm.created, OffsetDateTime::UNIX_EPOCH, "fixture must carry a real creation time");
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ use super::quota::BucketQuota;
|
|||||||
use super::target::BucketTargets;
|
use super::target::BucketTargets;
|
||||||
use crate::bucket::bucket_target_sys::BucketTargetSys;
|
use crate::bucket::bucket_target_sys::BucketTargetSys;
|
||||||
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
|
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
|
||||||
use crate::bucket::on_demand_migration::{ON_DEMAND_MIGRATION_CONFIG_HOOK, OnDemandMigrationConfig};
|
|
||||||
use crate::bucket::utils::is_meta_bucketname;
|
use crate::bucket::utils::is_meta_bucketname;
|
||||||
use crate::disk::RUSTFS_META_BUCKET;
|
use crate::disk::RUSTFS_META_BUCKET;
|
||||||
use crate::error::{Error, Result, is_err_bucket_not_found, is_err_strict_volume_not_found};
|
use crate::error::{Error, Result, is_err_bucket_not_found, is_err_strict_volume_not_found};
|
||||||
@@ -49,6 +48,11 @@ use tokio_util::sync::CancellationToken;
|
|||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Opaque bucket configuration notifications for application-owned services.
|
||||||
|
/// `None` withdraws a configuration; consumers validate nonempty bytes.
|
||||||
|
pub type BucketConfigPublishHook = Box<dyn Fn(&str, &str, Option<(&[u8], OffsetDateTime, Uuid)>) + Send + Sync>;
|
||||||
|
pub static BUCKET_CONFIG_PUBLISH_HOOK: std::sync::OnceLock<BucketConfigPublishHook> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
@@ -395,39 +399,21 @@ fn clear_bucket_durability(bucket: &str) {
|
|||||||
crate::disk::local::bucket_durability::set(bucket, None);
|
crate::disk::local::bucket_durability::set(bucket, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish the bucket's on-demand migration config (or its absence) to the
|
/// Publish application-owned bytes on every cache install path.
|
||||||
/// runtime registered in `ON_DEMAND_MIGRATION_CONFIG_HOOK`.
|
|
||||||
///
|
|
||||||
/// Called from the same five cache-install paths as
|
|
||||||
/// [`sync_bucket_durability`]. A stored payload this build cannot parse is
|
|
||||||
/// published as `None`: the runtime must stop pulling for that bucket rather
|
|
||||||
/// than keep an older config or guess.
|
|
||||||
fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) {
|
fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) {
|
||||||
let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() else {
|
if let Some(hook) = BUCKET_CONFIG_PUBLISH_HOOK.get() {
|
||||||
return;
|
hook(
|
||||||
};
|
bucket,
|
||||||
match bm.on_demand_migration_config() {
|
super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG,
|
||||||
Ok(config) => hook(bucket, config.as_ref()),
|
bm.on_demand_migration_config()
|
||||||
Err(err) => {
|
.map(|(bytes, stamp)| (bytes, stamp, bm.bucket_incarnation_id)),
|
||||||
warn!(
|
);
|
||||||
event = "bucket_metadata_parse_failed",
|
|
||||||
component = "ecstore",
|
|
||||||
subsystem = "bucket_metadata",
|
|
||||||
bucket = %bucket,
|
|
||||||
config = "on_demand_migration",
|
|
||||||
error = %err,
|
|
||||||
"Failed to parse bucket metadata config"
|
|
||||||
);
|
|
||||||
hook(bucket, None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Withdraw a bucket's on-demand migration config when its metadata leaves
|
|
||||||
/// the cache.
|
|
||||||
fn clear_on_demand_migration(bucket: &str) {
|
fn clear_on_demand_migration(bucket: &str) {
|
||||||
if let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() {
|
if let Some(hook) = BUCKET_CONFIG_PUBLISH_HOOK.get() {
|
||||||
hook(bucket, None);
|
hook(bucket, super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,15 +1035,21 @@ pub async fn get_durability_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The bucket's on-demand migration config with its update time, or
|
/// The bucket's on-demand migration config with its update time, or
|
||||||
/// `Ok(None)` when the bucket has none. A stored payload that does not parse
|
/// `Ok(None)` when the bucket has none. Bytes are opaque to the metadata owner.
|
||||||
/// is a typed error (`OnDemandMigrationConfigError` inside `Error::Io`).
|
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
|
||||||
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
|
|
||||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||||
|
|
||||||
bucket_meta_sys.get_on_demand_migration_config(bucket).await
|
bucket_meta_sys.get_on_demand_migration_config(bucket).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve opaque configuration from the store's own metadata system.
|
||||||
|
pub async fn get_on_demand_migration_config_in(api: &ECStore, bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
|
||||||
|
let sys = bucket_metadata_sys_of(&api.ctx)?;
|
||||||
|
let lock = sys.read().await;
|
||||||
|
lock.get_on_demand_migration_config(bucket).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
|
pub async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
|
||||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||||
@@ -2579,29 +2571,27 @@ impl BucketMetadataSys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// See [`get_on_demand_migration_config`].
|
/// See [`get_on_demand_migration_config`].
|
||||||
pub async fn get_on_demand_migration_config(
|
pub async fn get_on_demand_migration_config(&self, bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
|
|
||||||
let (bm, _) = self.get_config(bucket).await?;
|
let (bm, _) = self.get_config(bucket).await?;
|
||||||
|
|
||||||
let config = bm.on_demand_migration_config().map_err(Error::other)?;
|
Ok(bm
|
||||||
Ok(config.map(|config| (config, bm.on_demand_migration_config_updated_at)))
|
.on_demand_migration_config()
|
||||||
|
.map(|(bytes, updated_at)| (bytes.to_vec(), updated_at)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-only fixture shared with sibling modules (e.g. the quota checker
|
/// Test-only fixture shared with sibling modules (e.g. the quota checker
|
||||||
/// tests): a 4-disk `ECStore` on an isolated instance context, so tests
|
/// tests): a 4-disk `ECStore` on an isolated instance context, so tests
|
||||||
/// exercising the metadata system never touch ambient process state.
|
/// exercising the metadata system never touch ambient process state.
|
||||||
#[cfg(test)]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
pub(crate) mod test_support {
|
pub mod test_support {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::disk::endpoint::Endpoint;
|
use crate::disk::endpoint::Endpoint;
|
||||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||||
use crate::runtime::instance::InstanceContext;
|
use crate::runtime::instance::InstanceContext;
|
||||||
use crate::store::init_local_disks_with_instance_ctx;
|
use crate::store::init_local_disks_with_instance_ctx;
|
||||||
|
|
||||||
pub(crate) async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
pub async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||||
let mut dirs = Vec::with_capacity(4);
|
let mut dirs = Vec::with_capacity(4);
|
||||||
let mut endpoints = Vec::with_capacity(4);
|
let mut endpoints = Vec::with_capacity(4);
|
||||||
for disk_idx in 0..4 {
|
for disk_idx in 0..4 {
|
||||||
@@ -4385,19 +4375,26 @@ mod tests {
|
|||||||
|
|
||||||
const ODM_JSON: &[u8] = br#"{"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
|
const ODM_JSON: &[u8] = br#"{"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
|
||||||
|
|
||||||
|
type RecordedOdmConfig = Option<(Vec<u8>, OffsetDateTime, Uuid)>;
|
||||||
|
type RecordedOdmHookCall = (String, RecordedOdmConfig);
|
||||||
|
|
||||||
/// Every `(bucket, config)` the recording hook has seen. Tests filter by
|
/// Every `(bucket, config)` the recording hook has seen. Tests filter by
|
||||||
/// their own bucket name; the hook is process-wide and set once.
|
/// their own bucket name; the hook is process-wide and set once.
|
||||||
static ODM_HOOK_CALLS: std::sync::Mutex<Vec<(String, Option<OnDemandMigrationConfig>)>> = std::sync::Mutex::new(Vec::new());
|
static ODM_HOOK_CALLS: std::sync::Mutex<Vec<RecordedOdmHookCall>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
fn install_recording_odm_hook() {
|
fn install_recording_odm_hook() {
|
||||||
ON_DEMAND_MIGRATION_CONFIG_HOOK.get_or_init(|| {
|
BUCKET_CONFIG_PUBLISH_HOOK.get_or_init(|| {
|
||||||
Box::new(|bucket, config| {
|
Box::new(|bucket, config_file, config| {
|
||||||
ODM_HOOK_CALLS.lock().unwrap().push((bucket.to_string(), config.cloned()));
|
assert_eq!(config_file, super::super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG);
|
||||||
|
ODM_HOOK_CALLS.lock().unwrap().push((
|
||||||
|
bucket.to_string(),
|
||||||
|
config.map(|(bytes, stamp, incarnation)| (bytes.to_vec(), stamp, incarnation)),
|
||||||
|
));
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn odm_hook_calls(bucket: &str) -> Vec<Option<OnDemandMigrationConfig>> {
|
fn odm_hook_calls(bucket: &str) -> Vec<RecordedOdmConfig> {
|
||||||
ODM_HOOK_CALLS
|
ODM_HOOK_CALLS
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -4407,54 +4404,6 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// rustfs/backlog#2148: the accessor reports absence as `Ok(None)` and a
|
|
||||||
/// stored payload it cannot parse as a typed error, never as a default
|
|
||||||
/// and never as `ConfigNotFound`.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_on_demand_migration_config_distinguishes_absent_from_corrupt() {
|
|
||||||
use crate::bucket::on_demand_migration::OnDemandMigrationConfigError;
|
|
||||||
|
|
||||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
|
||||||
let sys = BucketMetadataSys::new(ecstore);
|
|
||||||
let bucket = "odm-accessor";
|
|
||||||
|
|
||||||
sys.set(bucket.to_string(), Arc::new(BucketMetadata::new(bucket))).await;
|
|
||||||
assert_eq!(sys.get_on_demand_migration_config(bucket).await.unwrap(), None);
|
|
||||||
|
|
||||||
let mut corrupt = BucketMetadata::new(bucket);
|
|
||||||
corrupt.on_demand_migration_config_json = br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec();
|
|
||||||
sys.set(bucket.to_string(), Arc::new(corrupt)).await;
|
|
||||||
let err = sys
|
|
||||||
.get_on_demand_migration_config(bucket)
|
|
||||||
.await
|
|
||||||
.expect_err("corrupt config must not read as a default");
|
|
||||||
assert_ne!(err, Error::ConfigNotFound, "corruption must not be reported as absence");
|
|
||||||
let typed = match &err {
|
|
||||||
Error::Io(io) => io
|
|
||||||
.get_ref()
|
|
||||||
.and_then(|source| source.downcast_ref::<OnDemandMigrationConfigError>()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
matches!(typed, Some(OnDemandMigrationConfigError::Malformed(_))),
|
|
||||||
"typed parse error must survive the Result boundary, got: {err:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut valid = BucketMetadata::new(bucket);
|
|
||||||
valid
|
|
||||||
.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
|
||||||
.unwrap();
|
|
||||||
let stamped = valid.on_demand_migration_config_updated_at;
|
|
||||||
sys.set(bucket.to_string(), Arc::new(valid)).await;
|
|
||||||
let (config, updated_at) = sys
|
|
||||||
.get_on_demand_migration_config(bucket)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.expect("stored config is returned");
|
|
||||||
assert_eq!(config, OnDemandMigrationConfig::from_json(ODM_JSON).unwrap());
|
|
||||||
assert_eq!(updated_at, stamped);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// rustfs/backlog#2148: the publish hook fires on every path that
|
/// rustfs/backlog#2148: the publish hook fires on every path that
|
||||||
/// installs bucket metadata into the cache (set, initial load, peer
|
/// installs bucket metadata into the cache (set, initial load, peer
|
||||||
/// reload, refresh loop, lazy load) and withdraws on removal, mirroring
|
/// reload, refresh loop, lazy load) and withdraws on removal, mirroring
|
||||||
@@ -4468,15 +4417,22 @@ mod tests {
|
|||||||
for dir in &dirs {
|
for dir in &dirs {
|
||||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist");
|
std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist");
|
||||||
}
|
}
|
||||||
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
|
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
let expect_publish = |before: usize, label: &str| {
|
let expect_publish = |before: usize, label: &str| {
|
||||||
let calls = odm_hook_calls(bucket);
|
let calls = odm_hook_calls(bucket);
|
||||||
assert_eq!(calls.len(), before + 1, "{label} must publish exactly once");
|
assert_eq!(calls.len(), before + 1, "{label} must publish exactly once");
|
||||||
assert_eq!(calls.last().unwrap().as_ref(), Some(&expected), "{label} must publish the stored config");
|
assert_eq!(
|
||||||
|
calls.last().unwrap().as_ref().map(|(bytes, _, _)| bytes.as_slice()),
|
||||||
|
Some(ODM_JSON),
|
||||||
|
"{label} must publish the stored bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(calls.last().unwrap().as_ref().map(|(_, _, id)| *id), Some(incarnation));
|
||||||
};
|
};
|
||||||
|
|
||||||
// set (via persist_new_and_set, which installs through `set`).
|
// set (via persist_new_and_set, which installs through `set`).
|
||||||
let mut bm = BucketMetadata::new(bucket);
|
let mut bm = BucketMetadata::new(bucket);
|
||||||
|
bm.bucket_incarnation_id = incarnation;
|
||||||
bm.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
bm.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let writer = BucketMetadataSys::new(ecstore.clone());
|
let writer = BucketMetadataSys::new(ecstore.clone());
|
||||||
@@ -4518,14 +4474,18 @@ mod tests {
|
|||||||
assert_eq!(calls.len(), before + 1, "remove must withdraw exactly once");
|
assert_eq!(calls.len(), before + 1, "remove must withdraw exactly once");
|
||||||
assert_eq!(calls.last().unwrap(), &None);
|
assert_eq!(calls.last().unwrap(), &None);
|
||||||
|
|
||||||
// A corrupt payload is withdrawn, never published as a config.
|
// Opaque bytes reach the application even if they are not valid JSON.
|
||||||
let mut corrupt = BucketMetadata::new(bucket);
|
let mut corrupt = BucketMetadata::new(bucket);
|
||||||
corrupt.on_demand_migration_config_json = b"not-json".to_vec();
|
corrupt.on_demand_migration_config_json = b"not-json".to_vec();
|
||||||
let before = odm_hook_calls(bucket).len();
|
let before = odm_hook_calls(bucket).len();
|
||||||
lazy.set(bucket.to_string(), Arc::new(corrupt)).await;
|
lazy.set(bucket.to_string(), Arc::new(corrupt)).await;
|
||||||
let calls = odm_hook_calls(bucket);
|
let calls = odm_hook_calls(bucket);
|
||||||
assert_eq!(calls.len(), before + 1);
|
assert_eq!(calls.len(), before + 1);
|
||||||
assert_eq!(calls.last().unwrap(), &None, "unreadable config must publish absence");
|
assert_eq!(
|
||||||
|
calls.last().unwrap().as_ref().map(|(bytes, _, _)| bytes.as_slice()),
|
||||||
|
Some(b"not-json".as_slice()),
|
||||||
|
"the application validates opaque config bytes"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ mod metadata_test;
|
|||||||
pub mod migration;
|
pub mod migration;
|
||||||
mod msgp_decode;
|
mod msgp_decode;
|
||||||
pub mod object_lock;
|
pub mod object_lock;
|
||||||
pub mod on_demand_migration;
|
|
||||||
pub mod policy_sys;
|
pub mod policy_sys;
|
||||||
pub mod quota;
|
pub mod quota;
|
||||||
pub mod remote_s3_client;
|
pub mod remote_s3_client;
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ impl RemoteS3EndpointSpec {
|
|||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum RemoteS3ClientError {
|
pub enum RemoteS3ClientError {
|
||||||
|
#[error("the {0} backend is not included in this build")]
|
||||||
|
BackendNotCompiled(&'static str),
|
||||||
#[error("remote endpoint requires credentials")]
|
#[error("remote endpoint requires credentials")]
|
||||||
MissingCredentials,
|
MissingCredentials,
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
@@ -281,9 +283,7 @@ impl Intercept for UserAgentSuffixInterceptor {
|
|||||||
|
|
||||||
/// Builds the SDK config for `spec` without finalizing it, so callers can add
|
/// Builds the SDK config for `spec` without finalizing it, so callers can add
|
||||||
/// interceptors or (in tests) swap the HTTP client before `build()`.
|
/// interceptors or (in tests) swap the HTTP client before `build()`.
|
||||||
pub(crate) async fn build_remote_s3_config(
|
pub async fn build_remote_s3_config(spec: &RemoteS3EndpointSpec) -> Result<aws_sdk_s3::config::Builder, RemoteS3ClientError> {
|
||||||
spec: &RemoteS3EndpointSpec,
|
|
||||||
) -> Result<aws_sdk_s3::config::Builder, RemoteS3ClientError> {
|
|
||||||
let Some(credentials) = &spec.credentials else {
|
let Some(credentials) = &spec.credentials else {
|
||||||
return Err(RemoteS3ClientError::MissingCredentials);
|
return Err(RemoteS3ClientError::MissingCredentials);
|
||||||
};
|
};
|
||||||
@@ -523,7 +523,7 @@ fn validate_ca_pem_bundle(ca_cert_pem: &[u8]) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_target_ca_pem(ca_cert_pem: &str) -> Result<(), RemoteS3ClientError> {
|
pub fn validate_target_ca_pem(ca_cert_pem: &str) -> Result<(), RemoteS3ClientError> {
|
||||||
validate_ca_pem_bundle(ca_cert_pem.as_bytes()).map_err(RemoteS3ClientError::InvalidCaPem)
|
validate_ca_pem_bundle(ca_cert_pem.as_bytes()).map_err(RemoteS3ClientError::InvalidCaPem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -956,6 +956,9 @@ pub struct ObjectOptions {
|
|||||||
pub preserve_etag: Option<String>,
|
pub preserve_etag: Option<String>,
|
||||||
pub metadata_chg: bool,
|
pub metadata_chg: bool,
|
||||||
pub http_preconditions: Option<HTTPPreconditions>,
|
pub http_preconditions: Option<HTTPPreconditions>,
|
||||||
|
/// Internal create-only writes may also preserve an acknowledged deletion.
|
||||||
|
/// Evaluated with `http_preconditions` under the namespace commit lock.
|
||||||
|
pub preserve_delete_marker: bool,
|
||||||
|
|
||||||
pub delete_replication: Option<ReplicationState>,
|
pub delete_replication: Option<ReplicationState>,
|
||||||
pub delete_replication_config_snapshot: Option<Arc<DeleteReplicationConfigSnapshot>>,
|
pub delete_replication_config_snapshot: Option<Arc<DeleteReplicationConfigSnapshot>>,
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ pub(crate) struct ScannerPublicationLeaseEntry {
|
|||||||
pub(crate) _operation_guard: OwnedRwLockReadGuard<()>,
|
pub(crate) _operation_guard: OwnedRwLockReadGuard<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct NamespaceCommitGuard {
|
||||||
|
ctx: Arc<InstanceContext>,
|
||||||
|
counted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for NamespaceCommitGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.counted {
|
||||||
|
// Publish the new generation before a zero-pending publication probe.
|
||||||
|
self.ctx.advance_namespace_commit_generation();
|
||||||
|
self.ctx.namespace_commits.fetch_sub(1, Ordering::AcqRel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Runtime state owned by a single `ECStore` instance.
|
/// Runtime state owned by a single `ECStore` instance.
|
||||||
///
|
///
|
||||||
/// This is intentionally minimal in the first migration slice; subsequent
|
/// This is intentionally minimal in the first migration slice; subsequent
|
||||||
@@ -209,9 +224,13 @@ pub struct InstanceContext {
|
|||||||
/// Last storage-owned movement snapshot observed under the operation
|
/// Last storage-owned movement snapshot observed under the operation
|
||||||
/// gate. SetDisks cache writers fail closed until ECStore refreshes it.
|
/// gate. SetDisks cache writers fail closed until ECStore refreshes it.
|
||||||
scanner_publication_state: AtomicU8,
|
scanner_publication_state: AtomicU8,
|
||||||
|
namespace_commits: AtomicU64,
|
||||||
|
namespace_commit_generation: AtomicU64,
|
||||||
/// Resolves object-encryption material at the application boundary.
|
/// Resolves object-encryption material at the application boundary.
|
||||||
object_encryption_resolver: OnceLock<Arc<dyn ObjectEncryptionResolver>>,
|
object_encryption_resolver: OnceLock<Arc<dyn ObjectEncryptionResolver>>,
|
||||||
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||||
|
#[cfg(test)]
|
||||||
|
suppress_tier_delete_journal_recovery: bool,
|
||||||
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||||
tier_delete_journal_recovery_wakeup: tokio::sync::Notify,
|
tier_delete_journal_recovery_wakeup: tokio::sync::Notify,
|
||||||
}
|
}
|
||||||
@@ -256,8 +275,12 @@ impl InstanceContext {
|
|||||||
data_movement_generation_exhausted: AtomicBool::new(false),
|
data_movement_generation_exhausted: AtomicBool::new(false),
|
||||||
data_movement_generation_notify: Arc::new(Notify::new()),
|
data_movement_generation_notify: Arc::new(Notify::new()),
|
||||||
scanner_publication_state: AtomicU8::new(SCANNER_PUBLICATION_STATE_UNKNOWN),
|
scanner_publication_state: AtomicU8::new(SCANNER_PUBLICATION_STATE_UNKNOWN),
|
||||||
|
namespace_commits: AtomicU64::new(0),
|
||||||
|
namespace_commit_generation: AtomicU64::new(0),
|
||||||
object_encryption_resolver: OnceLock::new(),
|
object_encryption_resolver: OnceLock::new(),
|
||||||
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||||
|
#[cfg(test)]
|
||||||
|
suppress_tier_delete_journal_recovery: false,
|
||||||
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||||
tier_delete_journal_recovery_wakeup: tokio::sync::Notify::new(),
|
tier_delete_journal_recovery_wakeup: tokio::sync::Notify::new(),
|
||||||
}
|
}
|
||||||
@@ -385,6 +408,36 @@ impl InstanceContext {
|
|||||||
&& self.scanner_publication_state.load(Ordering::Acquire) == SCANNER_PUBLICATION_STATE_ALLOWED
|
&& self.scanner_publication_state.load(Ordering::Acquire) == SCANNER_PUBLICATION_STATE_ALLOWED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn begin_namespace_commit(self: &Arc<Self>) -> Arc<NamespaceCommitGuard> {
|
||||||
|
let counted = self
|
||||||
|
.namespace_commits
|
||||||
|
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| count.checked_add(1))
|
||||||
|
.is_ok();
|
||||||
|
if counted {
|
||||||
|
self.advance_namespace_commit_generation();
|
||||||
|
} else {
|
||||||
|
self.namespace_commit_generation.store(u64::MAX, Ordering::Release);
|
||||||
|
}
|
||||||
|
Arc::new(NamespaceCommitGuard {
|
||||||
|
ctx: Arc::clone(self),
|
||||||
|
counted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_namespace_commit_generation(&self) {
|
||||||
|
let _ = self
|
||||||
|
.namespace_commit_generation
|
||||||
|
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |generation| Some(generation.saturating_add(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn namespace_commit_generation(&self) -> u64 {
|
||||||
|
self.namespace_commit_generation.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn namespace_commits_pending(&self) -> bool {
|
||||||
|
self.namespace_commits.load(Ordering::Acquire) != 0 || self.namespace_commit_generation() == u64::MAX
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn set_scanner_publication_state(&self, blocked: bool) {
|
pub(crate) fn set_scanner_publication_state(&self, blocked: bool) {
|
||||||
self.scanner_publication_state.store(
|
self.scanner_publication_state.store(
|
||||||
if blocked {
|
if blocked {
|
||||||
@@ -640,12 +693,21 @@ impl InstanceContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn mark_tier_delete_journal_recovery_started(&self, store_id: Uuid) -> bool {
|
pub(crate) fn mark_tier_delete_journal_recovery_started(&self, store_id: Uuid) -> bool {
|
||||||
|
#[cfg(test)]
|
||||||
|
if self.suppress_tier_delete_journal_recovery {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
self.tier_delete_journal_recovery_stores
|
self.tier_delete_journal_recovery_stores
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
.insert(store_id)
|
.insert(store_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn suppress_tier_delete_journal_recovery_for_test(&mut self) {
|
||||||
|
self.suppress_tier_delete_journal_recovery = true;
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn mark_transition_transaction_recovery_started(&self, store_id: Uuid) -> bool {
|
pub(crate) fn mark_transition_transaction_recovery_started(&self, store_id: Uuid) -> bool {
|
||||||
self.transition_transaction_recovery_stores
|
self.transition_transaction_recovery_stores
|
||||||
.lock()
|
.lock()
|
||||||
@@ -756,6 +818,50 @@ pub fn bootstrap_ctx() -> Arc<InstanceContext> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_commit_guards_are_instance_local_and_count_until_last_owner() {
|
||||||
|
let first = Arc::new(InstanceContext::new());
|
||||||
|
let other = Arc::new(InstanceContext::new());
|
||||||
|
first.set_scanner_publication_state(false);
|
||||||
|
other.set_scanner_publication_state(false);
|
||||||
|
assert!(first.scanner_publication_state_allowed());
|
||||||
|
let one = first.begin_namespace_commit();
|
||||||
|
let shared_owner = Arc::clone(&one);
|
||||||
|
let two = first.begin_namespace_commit();
|
||||||
|
assert!(first.namespace_commits_pending());
|
||||||
|
assert!(first.scanner_publication_state_allowed(), "pending writes must not block scan admission");
|
||||||
|
assert_eq!(first.namespace_commit_generation(), 2);
|
||||||
|
assert!(!other.namespace_commits_pending());
|
||||||
|
assert_eq!(other.namespace_commit_generation(), 0);
|
||||||
|
assert!(other.scanner_publication_state_allowed());
|
||||||
|
drop(one);
|
||||||
|
assert_eq!(first.namespace_commit_generation(), 2);
|
||||||
|
drop(shared_owner);
|
||||||
|
assert!(first.namespace_commits_pending());
|
||||||
|
assert_eq!(first.namespace_commit_generation(), 3);
|
||||||
|
drop(two);
|
||||||
|
assert!(!first.namespace_commits_pending());
|
||||||
|
assert_eq!(first.namespace_commit_generation(), 4);
|
||||||
|
assert!(first.scanner_publication_state_allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_commit_counter_exhaustion_keeps_publication_blocked() {
|
||||||
|
for (count, generation) in [(0, u64::MAX - 1), (u64::MAX, 0)] {
|
||||||
|
let ctx = Arc::new(InstanceContext::new());
|
||||||
|
ctx.set_scanner_publication_state(false);
|
||||||
|
ctx.namespace_commits.store(count, Ordering::Release);
|
||||||
|
ctx.namespace_commit_generation.store(generation, Ordering::Release);
|
||||||
|
let guard = ctx.begin_namespace_commit();
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), u64::MAX);
|
||||||
|
drop(guard);
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), u64::MAX);
|
||||||
|
assert_eq!(ctx.namespace_commits.load(Ordering::Acquire), count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The SetupType inputs must derive the exact (is_erasure,
|
// The SetupType inputs must derive the exact (is_erasure,
|
||||||
// is_dist_erasure, is_erasure_sd) triples that the original three
|
// is_dist_erasure, is_erasure_sd) triples that the original three
|
||||||
// process-global erasure bools produced via update_erasure_type().
|
// process-global erasure bools produced via update_erasure_type().
|
||||||
@@ -1073,6 +1179,12 @@ mod tests {
|
|||||||
assert!(!ctx_a.mark_tier_delete_journal_recovery_started(store_a));
|
assert!(!ctx_a.mark_tier_delete_journal_recovery_started(store_a));
|
||||||
assert!(ctx_a.mark_tier_delete_journal_recovery_started(store_b));
|
assert!(ctx_a.mark_tier_delete_journal_recovery_started(store_b));
|
||||||
assert!(ctx_b.mark_tier_delete_journal_recovery_started(store_a));
|
assert!(ctx_b.mark_tier_delete_journal_recovery_started(store_a));
|
||||||
|
|
||||||
|
let mut manual_ctx = InstanceContext::new();
|
||||||
|
manual_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||||
|
assert!(!manual_ctx.mark_tier_delete_journal_recovery_started(store_a));
|
||||||
|
assert!(!manual_ctx.mark_tier_delete_journal_recovery_started(store_b));
|
||||||
|
assert!(ctx_b.mark_tier_delete_journal_recovery_started(store_b));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub(crate) mod tier_probe_intent;
|
|||||||
pub mod warm_backend;
|
pub mod warm_backend;
|
||||||
pub mod warm_backend_aliyun;
|
pub mod warm_backend_aliyun;
|
||||||
pub mod warm_backend_azure;
|
pub mod warm_backend_azure;
|
||||||
|
#[cfg(feature = "gcs")]
|
||||||
pub mod warm_backend_gcs;
|
pub mod warm_backend_gcs;
|
||||||
pub mod warm_backend_huaweicloud;
|
pub mod warm_backend_huaweicloud;
|
||||||
pub mod warm_backend_minio;
|
pub mod warm_backend_minio;
|
||||||
|
|||||||
@@ -3541,7 +3541,7 @@ impl TierConfigMgr {
|
|||||||
// Get tier configuration and create new driver
|
// Get tier configuration and create new driver
|
||||||
let tier_config = self.tiers.get(tier_name).ok_or_else(|| ERR_TIER_NOT_FOUND.clone())?;
|
let tier_config = self.tiers.get(tier_name).ok_or_else(|| ERR_TIER_NOT_FOUND.clone())?;
|
||||||
|
|
||||||
let driver = new_warm_backend(tier_config, false).await?;
|
let driver = construct_warm_backend(tier_config).await?;
|
||||||
|
|
||||||
self.replace_driver(tier_name, driver)?;
|
self.replace_driver(tier_name, driver)?;
|
||||||
Ok(self
|
Ok(self
|
||||||
@@ -4486,6 +4486,11 @@ impl TierConfigMgr {
|
|||||||
let committed_coordinator_intent =
|
let committed_coordinator_intent =
|
||||||
committed_tier_mutation_intent(coordinator_intent.as_ref(), &committed_config_etag)
|
committed_tier_mutation_intent(coordinator_intent.as_ref(), &committed_config_etag)
|
||||||
.map_err(TierConfigUpdateError::Save)?;
|
.map_err(TierConfigUpdateError::Save)?;
|
||||||
|
// Persist Committed before notifying refresh; a Prepared disk record
|
||||||
|
// would restore the prepared block and invalidate our publish allowance.
|
||||||
|
let coordinator_commit =
|
||||||
|
commit_coordinator_tier_mutation_intent(api.clone(), coordinator_intent.as_ref(), &committed_config_etag)
|
||||||
|
.await;
|
||||||
if let Some(intent) = committed_coordinator_intent.as_ref() {
|
if let Some(intent) = committed_coordinator_intent.as_ref() {
|
||||||
TierConfigMgr::apply_committed_mutation_intent_block(&handle, intent)
|
TierConfigMgr::apply_committed_mutation_intent_block(&handle, intent)
|
||||||
.await
|
.await
|
||||||
@@ -4496,9 +4501,9 @@ impl TierConfigMgr {
|
|||||||
.map_err(TierConfigUpdateError::Publish)?,
|
.map_err(TierConfigUpdateError::Publish)?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
commit_coordinator_tier_mutation_intent(api.clone(), coordinator_intent.as_ref(), &committed_config_etag)
|
// Config is already saved: retain the committed fence and wake recovery
|
||||||
.await
|
// even when the coordinator commit failed or its outcome is unknown.
|
||||||
.map_err(TierConfigUpdateError::Save)?;
|
coordinator_commit.map_err(TierConfigUpdateError::Save)?;
|
||||||
if coordinated_config_update {
|
if coordinated_config_update {
|
||||||
drop(update.take());
|
drop(update.take());
|
||||||
drop(config_lock.take());
|
drop(config_lock.take());
|
||||||
@@ -10603,6 +10608,11 @@ mod tests {
|
|||||||
.expect_err("coordinator committed-state CAS failure must be observable");
|
.expect_err("coordinator committed-state CAS failure must be observable");
|
||||||
assert!(matches!(err, TierConfigUpdateError::Save(_)));
|
assert!(matches!(err, TierConfigUpdateError::Save(_)));
|
||||||
assert!(manager.read().await.tiers.contains_key("COLD-A"));
|
assert!(manager.read().await.tiers.contains_key("COLD-A"));
|
||||||
|
assert!(TierConfigMgr::has_committed_mutation_block(&manager).await);
|
||||||
|
let refresh = TierConfigMgr::mutation_refresh_notifier(&manager).await;
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), refresh.notified())
|
||||||
|
.await
|
||||||
|
.expect("failed coordinator commit must notify recovery after saving config");
|
||||||
let blocked = match TierConfigMgr::acquire_operation_lease(&manager, "COLD-A").await {
|
let blocked = match TierConfigMgr::acquire_operation_lease(&manager, "COLD-A").await {
|
||||||
Ok(_) => panic!("failed coordinator commit CAS must retain the local committed fence"),
|
Ok(_) => panic!("failed coordinator commit CAS must retain the local committed fence"),
|
||||||
Err(err) => err,
|
Err(err) => err,
|
||||||
@@ -14329,6 +14339,12 @@ mod tests {
|
|||||||
after_commit: bool,
|
after_commit: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct CasCoordinatorCommitBarrier {
|
||||||
|
arrived: tokio::sync::Notify,
|
||||||
|
release: tokio::sync::Notify,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct CasConfigStore {
|
struct CasConfigStore {
|
||||||
objects: tokio::sync::Mutex<HashMap<String, (Vec<u8>, String)>>,
|
objects: tokio::sync::Mutex<HashMap<String, (Vec<u8>, String)>>,
|
||||||
@@ -14341,6 +14357,7 @@ mod tests {
|
|||||||
fail_delete_prefix: tokio::sync::Mutex<Option<(String, usize)>>,
|
fail_delete_prefix: tokio::sync::Mutex<Option<(String, usize)>>,
|
||||||
delete_log: tokio::sync::Mutex<Vec<String>>,
|
delete_log: tokio::sync::Mutex<Vec<String>>,
|
||||||
list_barrier: tokio::sync::Mutex<Option<Arc<CasListBarrier>>>,
|
list_barrier: tokio::sync::Mutex<Option<Arc<CasListBarrier>>>,
|
||||||
|
coordinator_commit_barrier: tokio::sync::Mutex<Option<Arc<CasCoordinatorCommitBarrier>>>,
|
||||||
intent_list_calls: AtomicUsize,
|
intent_list_calls: AtomicUsize,
|
||||||
fail_reference_walk: AtomicBool,
|
fail_reference_walk: AtomicBool,
|
||||||
reference_walk_send_count: AtomicUsize,
|
reference_walk_send_count: AtomicUsize,
|
||||||
@@ -14363,6 +14380,7 @@ mod tests {
|
|||||||
fail_delete_prefix: tokio::sync::Mutex::new(None),
|
fail_delete_prefix: tokio::sync::Mutex::new(None),
|
||||||
delete_log: tokio::sync::Mutex::new(Vec::new()),
|
delete_log: tokio::sync::Mutex::new(Vec::new()),
|
||||||
list_barrier: tokio::sync::Mutex::new(None),
|
list_barrier: tokio::sync::Mutex::new(None),
|
||||||
|
coordinator_commit_barrier: tokio::sync::Mutex::new(None),
|
||||||
intent_list_calls: AtomicUsize::new(0),
|
intent_list_calls: AtomicUsize::new(0),
|
||||||
fail_reference_walk: AtomicBool::new(false),
|
fail_reference_walk: AtomicBool::new(false),
|
||||||
reference_walk_send_count: AtomicUsize::new(0),
|
reference_walk_send_count: AtomicUsize::new(0),
|
||||||
@@ -14554,6 +14572,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let mut payload = Vec::new();
|
let mut payload = Vec::new();
|
||||||
tokio::io::AsyncReadExt::read_to_end(&mut data.stream, &mut payload).await?;
|
tokio::io::AsyncReadExt::read_to_end(&mut data.stream, &mut payload).await?;
|
||||||
|
if object.starts_with(crate::services::tier::tier_mutation_intent::TIER_COORDINATOR_MUTATION_INTENT_RECORD_PREFIX)
|
||||||
|
&& opts
|
||||||
|
.http_preconditions
|
||||||
|
.as_ref()
|
||||||
|
.and_then(HTTPPreconditions::if_match_value)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
let barrier = self.coordinator_commit_barrier.lock().await.take();
|
||||||
|
if let Some(barrier) = barrier {
|
||||||
|
barrier.arrived.notify_one();
|
||||||
|
barrier.release.notified().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
let race_rewrite = if opts
|
let race_rewrite = if opts
|
||||||
.http_preconditions
|
.http_preconditions
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -15651,14 +15682,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
async fn assert_lifecycle_only_reference_obeys_force(clear: bool, force: bool) {
|
||||||
async fn force_remove_and_save_bypasses_lifecycle_only_reference() {
|
|
||||||
// rustfs/rustfs#6832: reproduces the admin RemoveTier path (not just the lower-level
|
|
||||||
// reference-proof function) for a tier with zero transitioned objects but a lifecycle
|
|
||||||
// rule still pointing at it — the exact shape of
|
|
||||||
// `test_manual_transition_async_tier_failure_reports_terminal_partial` in e2e_test,
|
|
||||||
// which force-removes a tier a lifecycle rule still references to simulate a
|
|
||||||
// decommissioned backend.
|
|
||||||
let store = Arc::new(CasConfigStore::default());
|
let store = Arc::new(CasConfigStore::default());
|
||||||
let tier = build_rustfs_tier("COLD-A");
|
let tier = build_rustfs_tier("COLD-A");
|
||||||
let mut persisted = empty_mgr();
|
let mut persisted = empty_mgr();
|
||||||
@@ -15699,22 +15723,55 @@ mod tests {
|
|||||||
|
|
||||||
let manager = TierConfigMgr::new();
|
let manager = TierConfigMgr::new();
|
||||||
manager.write().await.tiers.insert("COLD-A".to_string(), tier);
|
manager.write().await.tiers.insert("COLD-A".to_string(), tier);
|
||||||
TierConfigMgr::remove_and_save_with(&manager, store.clone(), "COLD-A", true)
|
let mutation = if clear {
|
||||||
.await
|
TierCandidateMutation::Clear(force)
|
||||||
.expect("force remove must bypass a lifecycle-config-only reference");
|
} else {
|
||||||
|
TierCandidateMutation::Remove("COLD-A".to_string(), force)
|
||||||
|
};
|
||||||
|
let result = TIER_DRIVER_TEST_FACTORY
|
||||||
|
.scope(
|
||||||
|
healthy_driver_factory(),
|
||||||
|
TierConfigMgr::update_candidate_with_config_lock(&manager, store.clone(), mutation),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if force {
|
||||||
|
result.expect("force mutation must bypass a lifecycle-config-only reference");
|
||||||
|
} else {
|
||||||
|
let err = result.expect_err("non-force mutation must reject a lifecycle-only reference");
|
||||||
|
let TierConfigUpdateError::Publish(err) = err else {
|
||||||
|
panic!("non-force mutation must fail during reference proof: {err:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(err.code, ERR_TIER_BACKEND_IN_USE.code);
|
||||||
|
assert!(err.message.contains("move-current"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
assert!(!manager.read().await.tiers.contains_key("COLD-A"));
|
assert_eq!(manager.read().await.tiers.contains_key("COLD-A"), !force);
|
||||||
assert!(
|
assert_eq!(
|
||||||
!load_tier_config_for_update(store)
|
load_tier_config_for_update(store)
|
||||||
.await
|
.await
|
||||||
.expect("config should still reload")
|
.expect("config should still reload")
|
||||||
.0
|
.0
|
||||||
.tiers
|
.tiers
|
||||||
.contains_key("COLD-A"),
|
.contains_key("COLD-A"),
|
||||||
"force removal must persist the empty candidate"
|
!force,
|
||||||
|
"persisted state must match the force mutation result"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remove_with_config_lock_obeys_force_for_lifecycle_only_reference() {
|
||||||
|
for force in [false, true] {
|
||||||
|
assert_lifecycle_only_reference_obeys_force(false, force).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clear_with_config_lock_obeys_force_for_lifecycle_only_reference() {
|
||||||
|
for force in [false, true] {
|
||||||
|
assert_lifecycle_only_reference_obeys_force(true, force).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn zero_reference_proof_blocks_clear_before_config_save() {
|
async fn zero_reference_proof_blocks_clear_before_config_save() {
|
||||||
let store = Arc::new(CasConfigStore::default());
|
let store = Arc::new(CasConfigStore::default());
|
||||||
@@ -17255,6 +17312,98 @@ mod tests {
|
|||||||
assert_ne!(manager_a.read().await.empty(), manager_b.read().await.empty());
|
assert_ne!(manager_a.read().await.empty(), manager_b.read().await.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn assert_coordinator_commit_refresh_succeeds(mutation: TierCandidateMutation) {
|
||||||
|
let adding = matches!(mutation, TierCandidateMutation::Add(..));
|
||||||
|
let manager = TierConfigMgr::new();
|
||||||
|
let store = Arc::new(CasConfigStore::default());
|
||||||
|
if !adding {
|
||||||
|
let mut persisted = empty_mgr();
|
||||||
|
persisted.tiers.insert("COLD-A".to_string(), build_rustfs_tier("COLD-A"));
|
||||||
|
persisted
|
||||||
|
.save_tiering_config_if_current(store.clone(), None)
|
||||||
|
.await
|
||||||
|
.expect("existing tier fixture should persist");
|
||||||
|
let mut guard = manager.write().await;
|
||||||
|
install_lease_backend(&mut guard, "COLD-A", LeaseTestBackend::ready("old"));
|
||||||
|
}
|
||||||
|
let barrier = Arc::new(CasCoordinatorCommitBarrier::default());
|
||||||
|
*store.coordinator_commit_barrier.lock().await = Some(barrier.clone());
|
||||||
|
let update_manager = manager.clone();
|
||||||
|
let update_store = store.clone();
|
||||||
|
let update = tokio::spawn(async move {
|
||||||
|
TIER_DRIVER_TEST_FACTORY
|
||||||
|
.scope(
|
||||||
|
healthy_driver_factory(),
|
||||||
|
TIER_MUTATION_TEST_PEERS.scope(
|
||||||
|
Vec::new(),
|
||||||
|
TierConfigMgr::update_candidate_with_config_lock(&update_manager, update_store, mutation),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), barrier.arrived.notified())
|
||||||
|
.await
|
||||||
|
.expect("mutation should reach coordinator commit after saving config");
|
||||||
|
assert_eq!(
|
||||||
|
load_tier_config_for_update(store.clone())
|
||||||
|
.await
|
||||||
|
.expect("saved config should be readable before coordinator commit")
|
||||||
|
.0
|
||||||
|
.tiers
|
||||||
|
.contains_key("COLD-A"),
|
||||||
|
adding
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
TierConfigMgr::load_coordinator_mutation_intents(store.clone())
|
||||||
|
.await
|
||||||
|
.expect("coordinator intent should remain readable")[0]
|
||||||
|
.state,
|
||||||
|
TierMutationIntentState::Prepared
|
||||||
|
);
|
||||||
|
|
||||||
|
let lock_requests = lock_unpoisoned(&store.lock_requests).len();
|
||||||
|
// Also exercise an independently scheduled refresh while the durable
|
||||||
|
// coordinator record is still Prepared, before its commit notification.
|
||||||
|
TierConfigMgr::request_committed_mutation_refresh(&manager).await;
|
||||||
|
TIER_MUTATION_TEST_PEERS
|
||||||
|
.scope(Vec::new(), async {
|
||||||
|
let worker = TierConfigMgr::refresh_tier_config_handle_with(manager.clone(), store.clone());
|
||||||
|
tokio::pin!(worker);
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
while lock_unpoisoned(&store.lock_requests).len() == lock_requests {
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut worker => panic!("refresh worker must remain available"),
|
||||||
|
_ = tokio::task::yield_now() => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("refresh should reconcile the Prepared record before waiting for the config lock");
|
||||||
|
barrier.release.notify_one();
|
||||||
|
let result = tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut worker => panic!("refresh worker must remain available"),
|
||||||
|
result = update => result.expect("tier mutation task should join"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("tier mutation should finish with refresh running");
|
||||||
|
result.expect("saved tier mutation must publish successfully on the first attempt");
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert_eq!(manager.read().await.tiers.contains_key("COLD-A"), adding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tier_add_succeeds_with_refresh_during_coordinator_commit() {
|
||||||
|
assert_coordinator_commit_refresh_succeeds(TierCandidateMutation::Add(build_rustfs_tier("COLD-A"), true)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tier_remove_succeeds_with_refresh_during_coordinator_commit() {
|
||||||
|
assert_coordinator_commit_refresh_succeeds(TierCandidateMutation::Remove("COLD-A".to_string(), true)).await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn committed_refresh_fixture(fail_cleanup: bool) -> (Arc<RwLock<TierConfigMgr>>, Arc<CasConfigStore>, uuid::Uuid) {
|
async fn committed_refresh_fixture(fail_cleanup: bool) -> (Arc<RwLock<TierConfigMgr>>, Arc<CasConfigStore>, uuid::Uuid) {
|
||||||
let manager = TierConfigMgr::new();
|
let manager = TierConfigMgr::new();
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,13 +19,14 @@
|
|||||||
#![allow(clippy::all)]
|
#![allow(clippy::all)]
|
||||||
|
|
||||||
use crate::error::is_err_bucket_not_found;
|
use crate::error::is_err_bucket_not_found;
|
||||||
|
#[cfg(feature = "gcs")]
|
||||||
|
use crate::services::tier::warm_backend_gcs::WarmBackendGCS;
|
||||||
use crate::services::tier::{
|
use crate::services::tier::{
|
||||||
tier::{ERR_TIER_BACKEND_IN_USE, ERR_TIER_INVALID_CONFIG, ERR_TIER_TYPE_UNSUPPORTED},
|
tier::{ERR_TIER_BACKEND_IN_USE, ERR_TIER_INVALID_CONFIG, ERR_TIER_TYPE_UNSUPPORTED},
|
||||||
tier_config::{TierConfig, TierType},
|
tier_config::{TierConfig, TierType},
|
||||||
tier_handlers::{ERR_TIER_BUCKET_NOT_FOUND, ERR_TIER_NOT_FOUND, ERR_TIER_PERM_ERR},
|
tier_handlers::{ERR_TIER_BUCKET_NOT_FOUND, ERR_TIER_NOT_FOUND, ERR_TIER_PERM_ERR},
|
||||||
warm_backend_aliyun::WarmBackendAliyun,
|
warm_backend_aliyun::WarmBackendAliyun,
|
||||||
warm_backend_azure::WarmBackendAzure,
|
warm_backend_azure::WarmBackendAzure,
|
||||||
warm_backend_gcs::WarmBackendGCS,
|
|
||||||
warm_backend_huaweicloud::WarmBackendHuaweicloud,
|
warm_backend_huaweicloud::WarmBackendHuaweicloud,
|
||||||
warm_backend_minio::WarmBackendMinIO,
|
warm_backend_minio::WarmBackendMinIO,
|
||||||
warm_backend_r2::WarmBackendR2,
|
warm_backend_r2::WarmBackendR2,
|
||||||
@@ -37,7 +38,7 @@ use crate::services::tier::{
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
use rustfs_s3_client::credentials::{Credentials, SignatureType, Static, Value};
|
use rustfs_s3_client::credentials::{Credentials, SignatureType, Static, Value};
|
||||||
use rustfs_s3_client::transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore};
|
use rustfs_s3_client::transition_api::{BucketLookupType, Options, TransitionClient, TransitionClientTimeouts, TransitionCore};
|
||||||
use rustfs_s3_client::{
|
use rustfs_s3_client::{
|
||||||
admin_handler_utils::AdminError,
|
admin_handler_utils::AdminError,
|
||||||
api_error_response::to_error_response,
|
api_error_response::to_error_response,
|
||||||
@@ -320,6 +321,27 @@ pub(crate) fn endpoint_authority(url: &url::Url) -> Result<String, std::io::Erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn transition_timeout_from_env(env_key: &str, default_secs: u64) -> Duration {
|
||||||
|
Duration::from_secs(rustfs_utils::get_env_u64(env_key, default_secs))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn transition_client_timeouts_from_env() -> TransitionClientTimeouts {
|
||||||
|
TransitionClientTimeouts::new(
|
||||||
|
transition_timeout_from_env(
|
||||||
|
rustfs_config::ENV_TIER_REMOTE_CONNECT_TIMEOUT_SECS,
|
||||||
|
rustfs_config::DEFAULT_TIER_REMOTE_CONNECT_TIMEOUT_SECS,
|
||||||
|
),
|
||||||
|
transition_timeout_from_env(
|
||||||
|
rustfs_config::ENV_TIER_REMOTE_REQUEST_TIMEOUT_SECS,
|
||||||
|
rustfs_config::DEFAULT_TIER_REMOTE_REQUEST_TIMEOUT_SECS,
|
||||||
|
),
|
||||||
|
transition_timeout_from_env(
|
||||||
|
rustfs_config::ENV_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS,
|
||||||
|
rustfs_config::DEFAULT_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the [`WarmBackendS3`] shared by the S3-compatible warm backend providers.
|
/// Build the [`WarmBackendS3`] shared by the S3-compatible warm backend providers.
|
||||||
///
|
///
|
||||||
/// Credential, bucket, and endpoint validation run in this order because the
|
/// Credential, bucket, and endpoint validation run in this order because the
|
||||||
@@ -350,6 +372,7 @@ pub(crate) async fn new_s3_compatible_warm_backend(
|
|||||||
signer_type: SignatureType::SignatureV4,
|
signer_type: SignatureType::SignatureV4,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}));
|
}));
|
||||||
|
let timeouts = transition_client_timeouts_from_env();
|
||||||
let opts = Options {
|
let opts = Options {
|
||||||
creds,
|
creds,
|
||||||
secure: u.scheme() == "https",
|
secure: u.scheme() == "https",
|
||||||
@@ -362,7 +385,7 @@ pub(crate) async fn new_s3_compatible_warm_backend(
|
|||||||
// Run the SSRF guard after the host-presence check so a host-less endpoint
|
// Run the SSRF guard after the host-presence check so a host-less endpoint
|
||||||
// keeps this constructor's stable error text.
|
// keeps this constructor's stable error text.
|
||||||
(params.validate_endpoint)(&u).map_err(|err| std::io::Error::other(format!("tier endpoint is not allowed: {err}")))?;
|
(params.validate_endpoint)(&u).map_err(|err| std::io::Error::other(format!("tier endpoint is not allowed: {err}")))?;
|
||||||
let client = TransitionClient::new(&endpoint, opts, params.provider_tag).await?;
|
let client = TransitionClient::new_with_timeouts(&endpoint, opts, params.provider_tag, timeouts).await?;
|
||||||
|
|
||||||
let client = Arc::new(client);
|
let client = Arc::new(client);
|
||||||
let core = TransitionCore(Arc::clone(&client));
|
let core = TransitionCore(Arc::clone(&client));
|
||||||
@@ -890,6 +913,15 @@ pub async fn new_warm_backend(tier: &TierConfig, probe: bool) -> Result<WarmBack
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(not(feature = "gcs"))]
|
||||||
|
TierType::GCS => {
|
||||||
|
return Err(AdminError {
|
||||||
|
code: ERR_TIER_TYPE_UNSUPPORTED.code.clone(),
|
||||||
|
message: "This build does not include the GCS backend; rebuild with the gcs feature".to_string(),
|
||||||
|
status_code: StatusCode::NOT_IMPLEMENTED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#[cfg(feature = "gcs")]
|
||||||
TierType::GCS => {
|
TierType::GCS => {
|
||||||
if let Some(gcs_config) = tier.gcs.as_ref() {
|
if let Some(gcs_config) = tier.gcs.as_ref() {
|
||||||
let dd = WarmBackendGCS::new(gcs_config, &tier.name).await;
|
let dd = WarmBackendGCS::new(gcs_config, &tier.name).await;
|
||||||
@@ -1006,6 +1038,27 @@ mod tests {
|
|||||||
|
|
||||||
const PROBE_VERSION: &str = "remote-v2";
|
const PROBE_VERSION: &str = "remote-v2";
|
||||||
|
|
||||||
|
#[cfg(not(feature = "gcs"))]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gcs_backend_not_compiled_preserves_config() {
|
||||||
|
let json = r#"{"name":"ARCHIVE","type":"gcs","gcs":{"bucket":"archive","creds":"secret"}}"#;
|
||||||
|
let tier: TierConfig = serde_json::from_str(json).expect("GCS config remains readable without the backend");
|
||||||
|
assert_eq!(tier.tier_type, TierType::GCS);
|
||||||
|
let encoded = serde_json::to_vec(&tier).expect("GCS config remains writable");
|
||||||
|
let restored: TierConfig = serde_json::from_slice(&encoded).expect("GCS config round trips");
|
||||||
|
assert_eq!(restored.tier_type, TierType::GCS);
|
||||||
|
let restored_gcs = restored.gcs.as_ref().expect("GCS settings preserved");
|
||||||
|
assert_eq!(restored_gcs.bucket, "archive");
|
||||||
|
assert_eq!(restored_gcs.creds, "secret");
|
||||||
|
assert_eq!(tier.redacted().gcs.expect("redacted GCS settings").creds, "REDACTED");
|
||||||
|
let error = match new_warm_backend(&tier, false).await {
|
||||||
|
Ok(_) => panic!("an excluded GCS backend cannot be constructed"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
assert_eq!(error.code, ERR_TIER_TYPE_UNSUPPORTED.code);
|
||||||
|
assert_eq!(error.status_code, StatusCode::NOT_IMPLEMENTED);
|
||||||
|
}
|
||||||
|
|
||||||
struct CountingBackend {
|
struct CountingBackend {
|
||||||
put_result: fn() -> Result<String, std::io::Error>,
|
put_result: fn() -> Result<String, std::io::Error>,
|
||||||
removes: Arc<AtomicUsize>,
|
removes: Arc<AtomicUsize>,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use crate::services::tier::{
|
|||||||
tier_config::TierS3,
|
tier_config::TierS3,
|
||||||
warm_backend::{
|
warm_backend::{
|
||||||
TransitionCandidateIdentity, TransitionCandidateProbe, TransitionCandidateReconciler, WarmBackend, WarmBackendGetOpts,
|
TransitionCandidateIdentity, TransitionCandidateProbe, TransitionCandidateReconciler, WarmBackend, WarmBackendGetOpts,
|
||||||
build_transition_put_options, endpoint_authority,
|
build_transition_put_options, endpoint_authority, transition_client_timeouts_from_env,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
@@ -139,6 +139,7 @@ impl WarmBackendS3 {
|
|||||||
} else {
|
} else {
|
||||||
return Err(std::io::Error::other("insufficient parameters for S3 backend authentication"));
|
return Err(std::io::Error::other("insufficient parameters for S3 backend authentication"));
|
||||||
}
|
}
|
||||||
|
let timeouts = transition_client_timeouts_from_env();
|
||||||
let opts = Options {
|
let opts = Options {
|
||||||
creds,
|
creds,
|
||||||
secure: u.scheme() == "https",
|
secure: u.scheme() == "https",
|
||||||
@@ -147,7 +148,7 @@ impl WarmBackendS3 {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let endpoint = endpoint_authority(&u)?;
|
let endpoint = endpoint_authority(&u)?;
|
||||||
let client = TransitionClient::new(&endpoint, opts, tier_type).await?;
|
let client = TransitionClient::new_with_timeouts(&endpoint, opts, tier_type, timeouts).await?;
|
||||||
|
|
||||||
let client = Arc::new(client);
|
let client = Arc::new(client);
|
||||||
let core = TransitionCore(Arc::clone(&client));
|
let core = TransitionCore(Arc::clone(&client));
|
||||||
|
|||||||
@@ -3558,6 +3558,11 @@ impl RenameRollbackReceipt {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RenameRollbackOwnership {
|
||||||
|
receipt: Option<RenameRollbackReceipt>,
|
||||||
|
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
|
||||||
|
}
|
||||||
|
|
||||||
async fn inspect_incomplete_rename_rollback(
|
async fn inspect_incomplete_rename_rollback(
|
||||||
disks: &[Option<DiskStore>],
|
disks: &[Option<DiskStore>],
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -3604,8 +3609,12 @@ async fn rollback_failed_rename(
|
|||||||
dispatch_states: &[RenameDispatchState],
|
dispatch_states: &[RenameDispatchState],
|
||||||
rollback_dirs: &[Option<Uuid>],
|
rollback_dirs: &[Option<Uuid>],
|
||||||
dst: (&str, &str),
|
dst: (&str, &str),
|
||||||
receipt: Option<RenameRollbackReceipt>,
|
ownership: RenameRollbackOwnership,
|
||||||
) {
|
) {
|
||||||
|
let RenameRollbackOwnership {
|
||||||
|
receipt,
|
||||||
|
namespace_commit_guard,
|
||||||
|
} = ownership;
|
||||||
let owned_disks = disks.to_vec();
|
let owned_disks = disks.to_vec();
|
||||||
let owned_errs = errs.to_vec();
|
let owned_errs = errs.to_vec();
|
||||||
let owned_dispatch_states = dispatch_states.to_vec();
|
let owned_dispatch_states = dispatch_states.to_vec();
|
||||||
@@ -3651,7 +3660,9 @@ async fn rollback_failed_rename(
|
|||||||
let fi = std::mem::take(&mut file_infos[disk_index]);
|
let fi = std::mem::take(&mut file_infos[disk_index]);
|
||||||
let bucket = bucket.to_string();
|
let bucket = bucket.to_string();
|
||||||
let object = object.to_string();
|
let object = object.to_string();
|
||||||
|
let disk_namespace_commit_guard = namespace_commit_guard.clone();
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
|
let _namespace_commit_guard = disk_namespace_commit_guard;
|
||||||
#[allow(clippy::let_unit_value)]
|
#[allow(clippy::let_unit_value)]
|
||||||
let _task_guard = SetDisks::rename_fanout_task_guard(&object);
|
let _task_guard = SetDisks::rename_fanout_task_guard(&object);
|
||||||
SetDisks::rename_fanout_barrier(&object, disk_index, rename_fanout_barrier_phase::ROLLBACK).await;
|
SetDisks::rename_fanout_barrier(&object, disk_index, rename_fanout_barrier_phase::ROLLBACK).await;
|
||||||
@@ -3672,6 +3683,9 @@ async fn rollback_failed_rename(
|
|||||||
});
|
});
|
||||||
tasks.push(async move { (disk_index, task.await) });
|
tasks.push(async move { (disk_index, task.await) });
|
||||||
}
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
rollback_fault_injection::after_undo_dispatch(object);
|
||||||
|
let _namespace_commit_guard = namespace_commit_guard;
|
||||||
for (disk_index, result) in join_all(tasks).await {
|
for (disk_index, result) in join_all(tasks).await {
|
||||||
outcomes[disk_index].outcome = rename_rollback_task_outcome(result);
|
outcomes[disk_index].outcome = rename_rollback_task_outcome(result);
|
||||||
}
|
}
|
||||||
@@ -3778,6 +3792,7 @@ pub(in crate::set_disk) struct RenameDataFenceOptions<'a> {
|
|||||||
write_quorum: usize,
|
write_quorum: usize,
|
||||||
scanner_publication_lease_tokens: Option<&'a HashMap<String, Uuid>>,
|
scanner_publication_lease_tokens: Option<&'a HashMap<String, Uuid>>,
|
||||||
scanner_publication_commit_scope: Option<crate::object_api::ScannerPublicationCommitScope>,
|
scanner_publication_commit_scope: Option<crate::object_api::ScannerPublicationCommitScope>,
|
||||||
|
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
|
||||||
rollback_receipt: Option<RenameRollbackReceipt>,
|
rollback_receipt: Option<RenameRollbackReceipt>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3790,6 +3805,7 @@ impl<'a> RenameDataFenceOptions<'a> {
|
|||||||
write_quorum,
|
write_quorum,
|
||||||
scanner_publication_lease_tokens,
|
scanner_publication_lease_tokens,
|
||||||
scanner_publication_commit_scope: None,
|
scanner_publication_commit_scope: None,
|
||||||
|
namespace_commit_guard: None,
|
||||||
rollback_receipt: None,
|
rollback_receipt: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3806,6 +3822,14 @@ impl<'a> RenameDataFenceOptions<'a> {
|
|||||||
self.scanner_publication_commit_scope = scanner_publication_commit_scope;
|
self.scanner_publication_commit_scope = scanner_publication_commit_scope;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(in crate::set_disk) fn with_namespace_commit_guard(
|
||||||
|
mut self,
|
||||||
|
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
|
||||||
|
) -> Self {
|
||||||
|
self.namespace_commit_guard = namespace_commit_guard;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
||||||
@@ -4164,6 +4188,7 @@ impl SetDisks {
|
|||||||
write_quorum,
|
write_quorum,
|
||||||
scanner_publication_lease_tokens,
|
scanner_publication_lease_tokens,
|
||||||
scanner_publication_commit_scope: _scanner_publication_commit_scope,
|
scanner_publication_commit_scope: _scanner_publication_commit_scope,
|
||||||
|
namespace_commit_guard,
|
||||||
rollback_receipt,
|
rollback_receipt,
|
||||||
} = fence_options;
|
} = fence_options;
|
||||||
if let Some(file_info) = disks
|
if let Some(file_info) = disks
|
||||||
@@ -4210,7 +4235,9 @@ impl SetDisks {
|
|||||||
let dst_object = fanout_dst_object.clone();
|
let dst_object = fanout_dst_object.clone();
|
||||||
let file_info = file_info.clone();
|
let file_info = file_info.clone();
|
||||||
let successful_rename_completion_rank = successful_rename_completion_rank.clone();
|
let successful_rename_completion_rank = successful_rename_completion_rank.clone();
|
||||||
|
let namespace_commit_guard = namespace_commit_guard.clone();
|
||||||
tasks.spawn(async move {
|
tasks.spawn(async move {
|
||||||
|
let _namespace_commit_guard = namespace_commit_guard;
|
||||||
let mut dispatch_state = RenameDispatchState::NotDispatched;
|
let mut dispatch_state = RenameDispatchState::NotDispatched;
|
||||||
let result = std::panic::AssertUnwindSafe(async {
|
let result = std::panic::AssertUnwindSafe(async {
|
||||||
#[allow(clippy::let_unit_value)]
|
#[allow(clippy::let_unit_value)]
|
||||||
@@ -4372,7 +4399,10 @@ impl SetDisks {
|
|||||||
&dispatch_states,
|
&dispatch_states,
|
||||||
&data_dirs,
|
&data_dirs,
|
||||||
(&fanout_dst_bucket, &fanout_dst_object),
|
(&fanout_dst_bucket, &fanout_dst_object),
|
||||||
rollback_receipt,
|
RenameRollbackOwnership {
|
||||||
|
receipt: rollback_receipt,
|
||||||
|
namespace_commit_guard,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if let Some(commit_tx) = commit_tx.take() {
|
if let Some(commit_tx) = commit_tx.take() {
|
||||||
@@ -4528,6 +4558,7 @@ impl SetDisks {
|
|||||||
write_quorum,
|
write_quorum,
|
||||||
scanner_publication_lease_tokens,
|
scanner_publication_lease_tokens,
|
||||||
scanner_publication_commit_scope,
|
scanner_publication_commit_scope,
|
||||||
|
namespace_commit_guard,
|
||||||
rollback_receipt,
|
rollback_receipt,
|
||||||
} = fence_options;
|
} = fence_options;
|
||||||
if let Some(file_info) = disks
|
if let Some(file_info) = disks
|
||||||
@@ -4561,6 +4592,7 @@ impl SetDisks {
|
|||||||
let fanout_dst_bucket = dst_bucket.clone();
|
let fanout_dst_bucket = dst_bucket.clone();
|
||||||
let fanout_dst_object = dst_object.clone();
|
let fanout_dst_object = dst_object.clone();
|
||||||
let fanout_publication_scope = scanner_publication_commit_scope.clone();
|
let fanout_publication_scope = scanner_publication_commit_scope.clone();
|
||||||
|
let fanout_namespace_commit_guard = namespace_commit_guard.clone();
|
||||||
// Keep one coordinator task so a cancelled caller cannot drop partially
|
// Keep one coordinator task so a cancelled caller cannot drop partially
|
||||||
// completed disk mutations. Per-disk futures stay ordered in `join_all`,
|
// completed disk mutations. Per-disk futures stay ordered in `join_all`,
|
||||||
// preserving slot-indexed quorum and convergence accounting without a
|
// preserving slot-indexed quorum and convergence accounting without a
|
||||||
@@ -4569,6 +4601,7 @@ impl SetDisks {
|
|||||||
// Keep the storage-owned movement permit attached to the actual
|
// Keep the storage-owned movement permit attached to the actual
|
||||||
// fan-out owner, even if the caller future is cancelled.
|
// fan-out owner, even if the caller future is cancelled.
|
||||||
let _fanout_publication_scope = fanout_publication_scope;
|
let _fanout_publication_scope = fanout_publication_scope;
|
||||||
|
let _namespace_commit_guard = fanout_namespace_commit_guard;
|
||||||
let successful_rename_completion_rank =
|
let successful_rename_completion_rank =
|
||||||
rustfs_io_metrics::put_stage_metrics_enabled().then(|| Arc::new(AtomicUsize::new(0)));
|
rustfs_io_metrics::put_stage_metrics_enabled().then(|| Arc::new(AtomicUsize::new(0)));
|
||||||
let futures = fanout_disks
|
let futures = fanout_disks
|
||||||
@@ -4790,7 +4823,10 @@ impl SetDisks {
|
|||||||
&dispatch_states,
|
&dispatch_states,
|
||||||
&data_dirs,
|
&data_dirs,
|
||||||
(&dst_bucket, &dst_object),
|
(&dst_bucket, &dst_object),
|
||||||
rollback_receipt,
|
RenameRollbackOwnership {
|
||||||
|
receipt: rollback_receipt,
|
||||||
|
namespace_commit_guard,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(ret_err);
|
return Err(ret_err);
|
||||||
@@ -6503,9 +6539,9 @@ impl SetDisks {
|
|||||||
match oi {
|
match oi {
|
||||||
Ok(oi) => {
|
Ok(oi) => {
|
||||||
// Ordinary writes may proceed past a top-level delete marker;
|
// Ordinary writes may proceed past a top-level delete marker;
|
||||||
// data movement must not replace an acknowledged deletion.
|
// data movement and guarded internal writes must preserve it.
|
||||||
if oi.delete_marker {
|
if oi.delete_marker {
|
||||||
return opts.data_movement.then_some(StorageError::PreconditionFailed);
|
return (opts.data_movement || opts.preserve_delete_marker).then_some(StorageError::PreconditionFailed);
|
||||||
}
|
}
|
||||||
let if_none_match = http_preconditions.if_none_match_value().map(str::to_owned);
|
let if_none_match = http_preconditions.if_none_match_value().map(str::to_owned);
|
||||||
let if_match = http_preconditions.if_match_value().map(str::to_owned);
|
let if_match = http_preconditions.if_match_value().map(str::to_owned);
|
||||||
@@ -6754,6 +6790,7 @@ pub(in crate::set_disk) mod rollback_fault_injection {
|
|||||||
VolumeNotFoundAfterRename,
|
VolumeNotFoundAfterRename,
|
||||||
PanicAfterRename,
|
PanicAfterRename,
|
||||||
CoordinatorPanic,
|
CoordinatorPanic,
|
||||||
|
RollbackCoordinatorPanic,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn registry() -> &'static Mutex<HashMap<String, (usize, Fault)>> {
|
fn registry() -> &'static Mutex<HashMap<String, (usize, Fault)>> {
|
||||||
@@ -6816,6 +6853,17 @@ pub(in crate::set_disk) mod rollback_fault_injection {
|
|||||||
panic!("injected rename coordinator panic");
|
panic!("injected rename coordinator panic");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn after_undo_dispatch(object: &str) {
|
||||||
|
let fault = registry()
|
||||||
|
.lock()
|
||||||
|
.expect("rollback registry should not poison")
|
||||||
|
.get(object)
|
||||||
|
.copied();
|
||||||
|
if matches!(fault, Some((_, Fault::RollbackCoordinatorPanic))) {
|
||||||
|
panic!("injected rollback coordinator panic");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-only per-disk call counters for the metadata fan-out (backlog#1325,
|
/// Test-only per-disk call counters for the metadata fan-out (backlog#1325,
|
||||||
@@ -6977,7 +7025,7 @@ pub(crate) mod rename_fanout_barrier {
|
|||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
pub use super::rename_fanout_barrier_phase::{
|
pub use super::rename_fanout_barrier_phase::{
|
||||||
CLEANUP as PHASE_CLEANUP, READ_VERSION as PHASE_READ_VERSION, RENAME as PHASE_RENAME,
|
CLEANUP as PHASE_CLEANUP, READ_VERSION as PHASE_READ_VERSION, RENAME as PHASE_RENAME, ROLLBACK as PHASE_ROLLBACK,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One armed barrier: the fan-out task matching `(disk_index, phase)` pauses.
|
/// One armed barrier: the fan-out task matching `(disk_index, phase)` pauses.
|
||||||
@@ -10814,79 +10862,177 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial(capacity_dirty_scope)]
|
#[serial_test::serial(capacity_dirty_scope)]
|
||||||
async fn rename_rollback_incomplete_receipt_waits_for_undo_barrier() {
|
async fn rename_rollback_incomplete_receipt_waits_for_undo_barrier() {
|
||||||
for cancel_caller in [false, true] {
|
temp_env::async_with_vars([(ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||||
let bucket = "rename-rollback-barrier";
|
for (allow_early_ack, cancel_caller, object) in [
|
||||||
let object = if cancel_caller {
|
(false, false, "rollback-barrier-object"),
|
||||||
"rollback-barrier-cancelled"
|
(false, true, "rollback-barrier-cancelled"),
|
||||||
} else {
|
(true, false, "rollback-barrier-early-object"),
|
||||||
"rollback-barrier-object"
|
(true, true, "rollback-barrier-early-cancelled"),
|
||||||
};
|
] {
|
||||||
let (dirs, disks) = call_counter_local_disks(bucket, 4).await;
|
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||||
prepare_rename_source_dirs(&dirs, &disks, "source").await;
|
let bucket = "rename-rollback-barrier";
|
||||||
let mut old = metadata_test_fileinfo(object);
|
let (dirs, disks) = call_counter_local_disks(bucket, 4).await;
|
||||||
old.mod_time = Some(OffsetDateTime::now_utc());
|
prepare_rename_source_dirs(&dirs, &disks, "source").await;
|
||||||
old.data = Some(Bytes::from_static(b"old-inline-body"));
|
let mut old = metadata_test_fileinfo(object);
|
||||||
old.set_inline_data();
|
old.mod_time = Some(OffsetDateTime::now_utc());
|
||||||
old.metadata.insert("etag".to_string(), "old-etag".to_string());
|
old.data = Some(Bytes::from_static(b"old-inline-body"));
|
||||||
for disk in disks.iter().flatten() {
|
old.set_inline_data();
|
||||||
disk.write_metadata(bucket, bucket, object, old.clone())
|
old.metadata.insert("etag".to_string(), "old-etag".to_string());
|
||||||
.await
|
for disk in disks.iter().flatten() {
|
||||||
.expect("old metadata should be staged");
|
disk.write_metadata(bucket, bucket, object, old.clone())
|
||||||
}
|
.await
|
||||||
let _rename_fault = rename_fault_injection::fail_rename_on(object, &[2, 3]);
|
.expect("old metadata should be staged");
|
||||||
let _undo_fault = rollback_fault_injection::arm(object, 0, rollback_fault_injection::Fault::Io);
|
|
||||||
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier_phase::ROLLBACK);
|
|
||||||
let receipt = RenameRollbackReceipt::default();
|
|
||||||
let mut rename = Box::pin(SetDisks::rename_data_owned_with_fence(
|
|
||||||
&disks,
|
|
||||||
(RUSTFS_META_TMP_BUCKET, "source"),
|
|
||||||
rename_commit_fileinfos(object, 4, "new-etag"),
|
|
||||||
(bucket, object),
|
|
||||||
false,
|
|
||||||
RenameDataFenceOptions::new(3, None).with_rollback_receipt(receipt.clone()),
|
|
||||||
));
|
|
||||||
tokio::time::timeout(BARRIER_PAUSE_GUARD, async {
|
|
||||||
tokio::select! {
|
|
||||||
() = barrier.wait_until_paused() => {}
|
|
||||||
_ = rename.as_mut() => panic!("rename returned before the armed rollback barrier"),
|
|
||||||
}
|
}
|
||||||
})
|
let _rename_fault = rename_fault_injection::fail_rename_on(object, &[2, 3]);
|
||||||
.await
|
let _undo_fault = rollback_fault_injection::arm(object, 0, rollback_fault_injection::Fault::Io);
|
||||||
.expect("undo must reach its disk barrier");
|
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier_phase::ROLLBACK);
|
||||||
assert!(receipt.0.get().is_none(), "pending undo must not be recorded as success");
|
let receipt = RenameRollbackReceipt::default();
|
||||||
if cancel_caller {
|
let mut rename = Box::pin(SetDisks::rename_data_owned_with_fence(
|
||||||
drop(rename);
|
&disks,
|
||||||
|
(RUSTFS_META_TMP_BUCKET, "source"),
|
||||||
|
rename_commit_fileinfos(object, 4, "new-etag"),
|
||||||
|
(bucket, object),
|
||||||
|
allow_early_ack,
|
||||||
|
RenameDataFenceOptions::new(3, None)
|
||||||
|
.with_rollback_receipt(receipt.clone())
|
||||||
|
.with_namespace_commit_guard(Some(ctx.begin_namespace_commit())),
|
||||||
|
));
|
||||||
|
tokio::time::timeout(BARRIER_PAUSE_GUARD, async {
|
||||||
|
tokio::select! {
|
||||||
|
() = barrier.wait_until_paused() => {}
|
||||||
|
_ = rename.as_mut() => panic!("rename returned before the armed rollback barrier"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("undo must reach its disk barrier");
|
||||||
|
assert!(receipt.0.get().is_none(), "pending undo must not be recorded as success");
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), 1);
|
||||||
|
if cancel_caller {
|
||||||
|
drop(rename);
|
||||||
|
assert!(ctx.namespace_commits_pending(), "caller cancellation must not retire pending undo work");
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), 1);
|
||||||
|
barrier.release();
|
||||||
|
tokio::time::timeout(BARRIER_PAUSE_GUARD, async {
|
||||||
|
while receipt.0.get().is_none() || ctx.namespace_commits_pending() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("cancelled caller must not cancel rollback accounting");
|
||||||
|
} else {
|
||||||
|
barrier.release();
|
||||||
|
assert!(rename.await.is_err());
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!ctx.namespace_commits_pending(),
|
||||||
|
"the completed rollback must release its namespace ownership"
|
||||||
|
);
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), 2);
|
||||||
|
assert!(receipt.is_incomplete(), "drained undo failure must survive in the receipt");
|
||||||
|
for dir in dirs.iter().skip(1) {
|
||||||
|
let reopened = reopen_local_disk(dir).await;
|
||||||
|
let restored = reopened
|
||||||
|
.read_version(
|
||||||
|
"",
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
"",
|
||||||
|
&ReadOptions {
|
||||||
|
read_data: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("old version must remain readable after caller cancellation");
|
||||||
|
assert_eq!(restored.data.as_deref(), Some(b"old-inline-body".as_slice()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(capacity_dirty_scope)]
|
||||||
|
async fn rename_rollback_children_keep_namespace_ownership_after_coordinator_panic() {
|
||||||
|
temp_env::async_with_vars([(ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||||
|
for (allow_early_ack, object) in [
|
||||||
|
(false, "rollback-coordinator-panic"),
|
||||||
|
(true, "rollback-coordinator-panic-early"),
|
||||||
|
] {
|
||||||
|
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||||
|
let bucket = "rename-rollback-coordinator-panic";
|
||||||
|
let (dirs, disks) = call_counter_local_disks(bucket, 4).await;
|
||||||
|
prepare_rename_source_dirs(&dirs, &disks, "source").await;
|
||||||
|
let mut old = metadata_test_fileinfo(object);
|
||||||
|
old.mod_time = Some(OffsetDateTime::now_utc());
|
||||||
|
old.data = Some(Bytes::from_static(b"old-inline-body"));
|
||||||
|
old.set_inline_data();
|
||||||
|
old.metadata.insert("etag".to_string(), "old-etag".to_string());
|
||||||
|
for disk in disks.iter().flatten() {
|
||||||
|
disk.write_metadata(bucket, bucket, object, old.clone())
|
||||||
|
.await
|
||||||
|
.expect("old metadata should be staged");
|
||||||
|
}
|
||||||
|
let _rename_fault = rename_fault_injection::fail_rename_on(object, &[2, 3]);
|
||||||
|
let _rollback_fault =
|
||||||
|
rollback_fault_injection::arm(object, 0, rollback_fault_injection::Fault::RollbackCoordinatorPanic);
|
||||||
|
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier_phase::ROLLBACK);
|
||||||
|
let receipt = RenameRollbackReceipt::default();
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
BARRIER_PAUSE_GUARD,
|
||||||
|
SetDisks::rename_data_owned_with_fence(
|
||||||
|
&disks,
|
||||||
|
(RUSTFS_META_TMP_BUCKET, "source"),
|
||||||
|
rename_commit_fileinfos(object, 4, "new-etag"),
|
||||||
|
(bucket, object),
|
||||||
|
allow_early_ack,
|
||||||
|
RenameDataFenceOptions::new(3, None)
|
||||||
|
.with_rollback_receipt(receipt.clone())
|
||||||
|
.with_namespace_commit_guard(Some(ctx.begin_namespace_commit())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("coordinator failure must return without waiting for detached undo tasks");
|
||||||
|
assert!(result.is_err());
|
||||||
|
tokio::time::timeout(BARRIER_PAUSE_GUARD, barrier.wait_until_paused())
|
||||||
|
.await
|
||||||
|
.expect("detached undo must reach its disk barrier");
|
||||||
|
assert!(
|
||||||
|
receipt.is_incomplete(),
|
||||||
|
"coordinator failure must preserve indeterminate recovery evidence"
|
||||||
|
);
|
||||||
|
assert!(ctx.namespace_commits_pending(), "the paused child must retain namespace ownership");
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), 1);
|
||||||
barrier.release();
|
barrier.release();
|
||||||
tokio::time::timeout(BARRIER_PAUSE_GUARD, async {
|
tokio::time::timeout(BARRIER_PAUSE_GUARD, async {
|
||||||
while receipt.0.get().is_none() {
|
while ctx.namespace_commits_pending() {
|
||||||
tokio::task::yield_now().await;
|
tokio::task::yield_now().await;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("cancelled caller must not cancel rollback accounting");
|
.expect("completed undo children must release their namespace ownership");
|
||||||
} else {
|
assert_eq!(ctx.namespace_commit_generation(), 2);
|
||||||
barrier.release();
|
for dir in &dirs {
|
||||||
assert!(rename.await.is_err());
|
let reopened = reopen_local_disk(dir).await;
|
||||||
|
let restored = reopened
|
||||||
|
.read_version(
|
||||||
|
"",
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
"",
|
||||||
|
&ReadOptions {
|
||||||
|
read_data: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("old version must remain readable after rollback coordinator failure");
|
||||||
|
assert_eq!(restored.data.as_deref(), Some(b"old-inline-body".as_slice()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assert!(receipt.is_incomplete(), "drained undo failure must survive in the receipt");
|
})
|
||||||
for dir in dirs.iter().skip(1) {
|
.await;
|
||||||
let reopened = reopen_local_disk(dir).await;
|
|
||||||
let restored = reopened
|
|
||||||
.read_version(
|
|
||||||
"",
|
|
||||||
bucket,
|
|
||||||
object,
|
|
||||||
"",
|
|
||||||
&ReadOptions {
|
|
||||||
read_data: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("old version must remain readable after caller cancellation");
|
|
||||||
assert_eq!(restored.data.as_deref(), Some(b"old-inline-body".as_slice()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -11001,9 +11147,35 @@ mod tests {
|
|||||||
let mut file_infos = rename_commit_fileinfos(object, DISKS, "fresh-rollback-etag");
|
let mut file_infos = rename_commit_fileinfos(object, DISKS, "fresh-rollback-etag");
|
||||||
file_infos[3] = FileInfo::default();
|
file_infos[3] = FileInfo::default();
|
||||||
|
|
||||||
SetDisks::rename_data(&disks, RUSTFS_META_TMP_BUCKET, "source", &file_infos, bucket, object, 4)
|
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||||
|
ctx.set_scanner_publication_state(false);
|
||||||
|
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_ROLLBACK);
|
||||||
|
let rename = SetDisks::rename_data_owned_with_fence(
|
||||||
|
&disks,
|
||||||
|
(RUSTFS_META_TMP_BUCKET, "source"),
|
||||||
|
file_infos,
|
||||||
|
(bucket, object),
|
||||||
|
false,
|
||||||
|
RenameDataFenceOptions::new(4, None).with_namespace_commit_guard(Some(ctx.begin_namespace_commit())),
|
||||||
|
);
|
||||||
|
let control = async {
|
||||||
|
barrier.wait_until_paused().await;
|
||||||
|
assert!(ctx.namespace_commits_pending(), "rollback must retain namespace publication ownership");
|
||||||
|
assert!(ctx.scanner_publication_state_allowed(), "rollback must not disable namespace walks");
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), 1);
|
||||||
|
barrier.release();
|
||||||
|
};
|
||||||
|
let (result, ()) = tokio::time::timeout(BARRIER_PAUSE_GUARD, async { tokio::join!(rename, control) })
|
||||||
.await
|
.await
|
||||||
.expect_err("three successful disks must fail a strict write quorum of four");
|
.expect("rename rollback must reach its barrier and finish after release");
|
||||||
|
assert_eq!(
|
||||||
|
result.err(),
|
||||||
|
Some(DiskError::ErasureWriteQuorum),
|
||||||
|
"three successful disks must fail a strict write quorum of four"
|
||||||
|
);
|
||||||
|
assert!(!ctx.namespace_commits_pending());
|
||||||
|
assert!(ctx.scanner_publication_state_allowed());
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), 2);
|
||||||
|
|
||||||
for (idx, dir) in dirs.iter().enumerate() {
|
for (idx, dir) in dirs.iter().enumerate() {
|
||||||
let reopened = reopen_local_disk(dir).await;
|
let reopened = reopen_local_disk(dir).await;
|
||||||
|
|||||||
@@ -2490,9 +2490,9 @@ impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
|
|||||||
return Ok((result, err.map(|e| e.into())));
|
return Ok((result, err.map(|e| e.into())));
|
||||||
}
|
}
|
||||||
|
|
||||||
let disks = self.disks.read().await;
|
// The inner heal and missing-object report read the registry again;
|
||||||
|
// release this snapshot guard before a topology writer can queue between reads.
|
||||||
let disks = disks.clone();
|
let disks = self.get_disks_internal().await;
|
||||||
let (_, errs) = Self::read_all_fileinfo(&disks, "", bucket, object, version_id, false, false, false)
|
let (_, errs) = Self::read_all_fileinfo(&disks, "", bucket, object, version_id, false, false, false)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
|
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
|
||||||
@@ -3419,6 +3419,366 @@ mod heal_result_report_tests {
|
|||||||
assert_eq!(unformatted, DiskError::UnformattedDisk);
|
assert_eq!(unformatted, DiskError::UnformattedDisk);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum InventoryWriterHealCase {
|
||||||
|
Existing,
|
||||||
|
Missing,
|
||||||
|
MissingVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_heal_object_inventory_writer(case: InventoryWriterHealCase) {
|
||||||
|
use crate::set_disk::core::io_primitives::disk_call_counters;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
let (_temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
|
||||||
|
let bucket = "heal-inventory-writer-bucket";
|
||||||
|
let object = match case {
|
||||||
|
InventoryWriterHealCase::Existing => "heal-inventory-writer-existing",
|
||||||
|
InventoryWriterHealCase::Missing => "heal-inventory-writer-missing",
|
||||||
|
InventoryWriterHealCase::MissingVersion => "heal-inventory-writer-missing-version",
|
||||||
|
};
|
||||||
|
set.make_bucket(
|
||||||
|
bucket,
|
||||||
|
&MakeBucketOptions {
|
||||||
|
versioning_enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("heal fixture bucket should be created");
|
||||||
|
let body = vec![0x67; 64 * 1024];
|
||||||
|
let stored_version = Uuid::new_v4();
|
||||||
|
let stored_version_string = stored_version.to_string();
|
||||||
|
let published = if matches!(case, InventoryWriterHealCase::Missing) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let mut reader = PutObjReader::from_vec(body.clone());
|
||||||
|
let info = set
|
||||||
|
.put_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&mut reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
versioned: true,
|
||||||
|
version_id: Some(stored_version_string.clone()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("full-fanout PUT should seed the heal fixture");
|
||||||
|
for disk in &disks {
|
||||||
|
let metadata = disk
|
||||||
|
.read_version("", bucket, object, &stored_version_string, &ReadOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the seeded version must be present on every disk");
|
||||||
|
assert_eq!(metadata.version_id, Some(stored_version));
|
||||||
|
assert_eq!(metadata.size, i64::try_from(body.len()).expect("fixture size should fit i64"));
|
||||||
|
}
|
||||||
|
Some(info)
|
||||||
|
};
|
||||||
|
let requested_version = match case {
|
||||||
|
InventoryWriterHealCase::Existing => stored_version_string.clone(),
|
||||||
|
InventoryWriterHealCase::Missing => String::new(),
|
||||||
|
InventoryWriterHealCase::MissingVersion => Uuid::new_v4().to_string(),
|
||||||
|
};
|
||||||
|
let opts = HealOpts {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let calls = disk_call_counters::observe(object);
|
||||||
|
let read_gate = set.disks.read().await;
|
||||||
|
// UFCS selects the trait's outer precheck, not the same-named inherent heal.
|
||||||
|
let heal = <SetDisks as crate::storage_api_contracts::heal::HealOperations>::heal_object(
|
||||||
|
set.as_ref(),
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&requested_version,
|
||||||
|
&opts,
|
||||||
|
);
|
||||||
|
tokio::pin!(heal);
|
||||||
|
assert!(matches!(
|
||||||
|
futures::poll!(tokio::task::unconstrained(heal.as_mut())),
|
||||||
|
std::task::Poll::Pending
|
||||||
|
));
|
||||||
|
// These tests use the current-thread runtime: full-wait metadata tasks
|
||||||
|
// have been spawned, but cannot run during the single unconstrained poll.
|
||||||
|
assert_eq!(calls.total(disk_call_counters::KIND_READ_VERSION), 0);
|
||||||
|
let writer = set.disks.write();
|
||||||
|
tokio::pin!(writer);
|
||||||
|
assert!(matches!(
|
||||||
|
futures::poll!(tokio::task::unconstrained(writer.as_mut())),
|
||||||
|
std::task::Poll::Pending
|
||||||
|
));
|
||||||
|
assert!(set.disks.try_read().is_err(), "the writer must already block new inventory readers");
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
while calls.total(disk_call_counters::KIND_READ_VERSION) < 4 {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("the suspended trait heal must have started the real metadata fanout");
|
||||||
|
for disk_index in 0..4 {
|
||||||
|
assert_eq!(calls.for_disk(disk_call_counters::KIND_READ_VERSION, disk_index), 1);
|
||||||
|
}
|
||||||
|
drop(read_gate);
|
||||||
|
|
||||||
|
let (_, outcome) =
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async { tokio::join!(async { drop(writer.await) }, heal) })
|
||||||
|
.await
|
||||||
|
.expect("trait heal must not deadlock its nested inventory read with the queued writer");
|
||||||
|
let (result, error) = outcome.expect("heal should report the object's outcome");
|
||||||
|
match case {
|
||||||
|
InventoryWriterHealCase::Existing => assert!(error.is_none(), "existing object heal failed: {error:?}"),
|
||||||
|
InventoryWriterHealCase::Missing => assert!(matches!(error, Some(Error::FileNotFound))),
|
||||||
|
InventoryWriterHealCase::MissingVersion => assert!(matches!(error, Some(Error::FileVersionNotFound))),
|
||||||
|
}
|
||||||
|
assert_eq!(result.bucket, bucket);
|
||||||
|
assert_eq!(result.object, object);
|
||||||
|
assert_eq!(result.version_id, requested_version);
|
||||||
|
assert_eq!(result.disk_count, 4);
|
||||||
|
assert_eq!(result.before.drives.len(), 4);
|
||||||
|
assert_eq!(result.after.drives.len(), 4);
|
||||||
|
for disk_index in 0..4 {
|
||||||
|
let endpoint = set.set_endpoints[disk_index].to_string();
|
||||||
|
assert_eq!(result.before.drives[disk_index].endpoint, endpoint);
|
||||||
|
assert_eq!(result.after.drives[disk_index].endpoint, endpoint);
|
||||||
|
}
|
||||||
|
if let Some(published) = published {
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
let mut reader = set
|
||||||
|
.get_object_reader(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
None,
|
||||||
|
Default::default(),
|
||||||
|
&ObjectOptions {
|
||||||
|
versioned: true,
|
||||||
|
version_id: Some(stored_version_string),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the stored version must remain readable after heal");
|
||||||
|
assert_eq!(reader.object_info.etag, published.etag);
|
||||||
|
assert_eq!(reader.object_info.version_id, Some(stored_version));
|
||||||
|
let mut observed_body = Vec::new();
|
||||||
|
reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut observed_body)
|
||||||
|
.await
|
||||||
|
.expect("stored body should stream");
|
||||||
|
assert_eq!(observed_body, body);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("GET must finish after the inventory writer and heal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn heal_object_inventory_writer_existing() {
|
||||||
|
assert_heal_object_inventory_writer(InventoryWriterHealCase::Existing).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn heal_object_inventory_writer_missing() {
|
||||||
|
assert_heal_object_inventory_writer(InventoryWriterHealCase::Missing).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn heal_object_inventory_writer_missing_version() {
|
||||||
|
assert_heal_object_inventory_writer(InventoryWriterHealCase::MissingVersion).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn heal_object_with_queued_disk_renewal() {
|
||||||
|
use crate::layout::endpoints::SetupType;
|
||||||
|
use crate::runtime::instance::InstanceContext;
|
||||||
|
use crate::set_disk::core::io_primitives::disk_call_counters;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::task::Poll;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
// renew_disk still registers local disks on the ambient context. Match
|
||||||
|
// the default serial group used by its other setup/registry fixtures,
|
||||||
|
// and restore only this temporary endpoint, including on a failed join.
|
||||||
|
struct RenewDiskTestState {
|
||||||
|
ctx: Arc<InstanceContext>,
|
||||||
|
was_dist_erasure: bool,
|
||||||
|
map: Arc<RwLock<HashMap<String, Option<DiskStore>>>>,
|
||||||
|
endpoint: String,
|
||||||
|
previous_disk: Option<Option<DiskStore>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RenewDiskTestState {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let ctx = self.ctx.clone();
|
||||||
|
let was_dist_erasure = self.was_dist_erasure;
|
||||||
|
let map = self.map.clone();
|
||||||
|
let endpoint = self.endpoint.clone();
|
||||||
|
let previous_disk = self.previous_disk.take();
|
||||||
|
let handle = tokio::runtime::Handle::current();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
handle.block_on(async move {
|
||||||
|
let mut map = map.write().await;
|
||||||
|
match previous_disk {
|
||||||
|
Some(disk) => {
|
||||||
|
map.insert(endpoint, disk);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
map.remove(&endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(map);
|
||||||
|
if was_dist_erasure {
|
||||||
|
ctx.update_erasure_type(SetupType::DistErasure).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.expect("renew fixture state restoration should finish");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
|
||||||
|
let endpoint = set.set_endpoints[0].clone();
|
||||||
|
let ctx = crate::runtime::global::current_ctx();
|
||||||
|
let map = ctx.local_disk_map();
|
||||||
|
let restore = RenewDiskTestState {
|
||||||
|
ctx: ctx.clone(),
|
||||||
|
was_dist_erasure: ctx.is_dist_erasure().await,
|
||||||
|
map: map.clone(),
|
||||||
|
endpoint: endpoint.to_string(),
|
||||||
|
previous_disk: map.read().await.get(&endpoint.to_string()).cloned(),
|
||||||
|
};
|
||||||
|
// Only distributed erasure needs an override to avoid the ambient slot array.
|
||||||
|
if restore.was_dist_erasure {
|
||||||
|
ctx.update_erasure_type(SetupType::Erasure).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bucket = "heal-disk-renewal-bucket";
|
||||||
|
let object = "heal-disk-renewal-object";
|
||||||
|
set.make_bucket(bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("renew fixture bucket should be created");
|
||||||
|
let body = vec![0x73; 64 * 1024];
|
||||||
|
let mut reader = PutObjReader::from_vec(body.clone());
|
||||||
|
let published = set
|
||||||
|
.put_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&mut reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("full-fanout PUT should seed the renewal fixture");
|
||||||
|
for disk in &disks {
|
||||||
|
let metadata = disk
|
||||||
|
.read_version("", bucket, object, "", &ReadOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the seeded object must be present on every disk");
|
||||||
|
assert_eq!(metadata.size, i64::try_from(body.len()).expect("fixture size should fit i64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let opts = HealOpts {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let calls = disk_call_counters::observe(object);
|
||||||
|
let read_gate = set.disks.read().await;
|
||||||
|
let heal = <SetDisks as crate::storage_api_contracts::heal::HealOperations>::heal_object(
|
||||||
|
set.as_ref(),
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
"",
|
||||||
|
&opts,
|
||||||
|
);
|
||||||
|
tokio::pin!(heal);
|
||||||
|
assert!(matches!(futures::poll!(tokio::task::unconstrained(heal.as_mut())), Poll::Pending));
|
||||||
|
assert_eq!(calls.total(disk_call_counters::KIND_READ_VERSION), 0);
|
||||||
|
|
||||||
|
let renew = set.renew_disk(&endpoint);
|
||||||
|
tokio::pin!(renew);
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
futures::future::poll_fn(|cx| {
|
||||||
|
assert!(
|
||||||
|
std::pin::pin!(tokio::task::unconstrained(renew.as_mut()))
|
||||||
|
.poll(cx)
|
||||||
|
.is_pending(),
|
||||||
|
"renewal must reach its inventory write before returning"
|
||||||
|
);
|
||||||
|
if set.disks.try_read().is_err() {
|
||||||
|
Poll::Ready(())
|
||||||
|
} else {
|
||||||
|
Poll::Pending
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("real renewal must queue its topology writer behind the read gate");
|
||||||
|
let registered = map
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get(&endpoint.to_string())
|
||||||
|
.cloned()
|
||||||
|
.flatten()
|
||||||
|
.expect("renewal must register the connected disk before its inventory write");
|
||||||
|
assert!(!Arc::ptr_eq(®istered, &disks[0]), "renewal must construct a new disk handle");
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
while calls.total(disk_call_counters::KIND_READ_VERSION) < 4 {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("the suspended trait heal must have started the real metadata fanout");
|
||||||
|
for disk_index in 0..4 {
|
||||||
|
assert_eq!(calls.for_disk(disk_call_counters::KIND_READ_VERSION, disk_index), 1);
|
||||||
|
}
|
||||||
|
drop(read_gate);
|
||||||
|
|
||||||
|
let (_, outcome) = tokio::time::timeout(Duration::from_secs(5), async { tokio::join!(renew, heal) })
|
||||||
|
.await
|
||||||
|
.expect("trait heal and real disk renewal must finish without a nested inventory read deadlock");
|
||||||
|
let (report, error) = outcome.expect("heal should report the existing object");
|
||||||
|
assert!(error.is_none(), "existing object heal failed after renewal: {error:?}");
|
||||||
|
assert_eq!(report.bucket, bucket);
|
||||||
|
assert_eq!(report.object, object);
|
||||||
|
assert_eq!(report.disk_count, 4);
|
||||||
|
let renewed = set.get_disks_internal().await[0]
|
||||||
|
.clone()
|
||||||
|
.expect("the renewed slot must remain online");
|
||||||
|
assert!(Arc::ptr_eq(&renewed, ®istered), "the set must publish the newly connected handle");
|
||||||
|
assert_eq!(renewed.endpoint(), endpoint);
|
||||||
|
let format = load_format_erasure(&renewed, false)
|
||||||
|
.await
|
||||||
|
.expect("renewed disk format should remain readable");
|
||||||
|
assert_eq!(format.erasure.this, set.format.erasure.sets[0][0]);
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
let mut reader = set
|
||||||
|
.get_object_reader(bucket, object, None, Default::default(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the object must remain readable after renewal and heal");
|
||||||
|
assert_eq!(reader.object_info.etag, published.etag);
|
||||||
|
let mut observed_body = Vec::new();
|
||||||
|
reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut observed_body)
|
||||||
|
.await
|
||||||
|
.expect("stored body should stream");
|
||||||
|
assert_eq!(observed_body, body);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("GET must finish after renewal and heal");
|
||||||
|
}
|
||||||
|
|
||||||
// Regression for #955: an offline disk must contribute exactly one drive
|
// Regression for #955: an offline disk must contribute exactly one drive
|
||||||
// record. Before the fix the offline branch fell through and pushed a second
|
// record. Before the fix the offline branch fell through and pushed a second
|
||||||
// (Corrupt) record for the same disk, so `before/after.drives` grew to
|
// (Corrupt) record for the same disk, so `before/after.drives` grew to
|
||||||
|
|||||||
@@ -2452,10 +2452,9 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
|||||||
let write_quorum = fi.write_quorum(self.default_write_quorum());
|
let write_quorum = fi.write_quorum(self.default_write_quorum());
|
||||||
let read_quorum = fi.read_quorum(self.default_read_quorum());
|
let read_quorum = fi.read_quorum(self.default_read_quorum());
|
||||||
|
|
||||||
let disks = self.disks.read().await;
|
// Release the registry guard before recovery and cleanup read it again:
|
||||||
|
// a queued topology writer would otherwise deadlock those nested reads.
|
||||||
let disks = disks.clone();
|
let disks = self.get_disks_internal().await;
|
||||||
// let disks = Self::shuffle_disks(&disks, &fi.erasure.distribution);
|
|
||||||
|
|
||||||
let part_path = format!("{}/{}/", upload_id_path, fi.data_dir.unwrap_or(Uuid::nil()));
|
let part_path = format!("{}/{}/", upload_id_path, fi.data_dir.unwrap_or(Uuid::nil()));
|
||||||
self.recover_part_transactions(&part_path, read_quorum, write_quorum)
|
self.recover_part_transactions(&part_path, read_quorum, write_quorum)
|
||||||
@@ -4051,6 +4050,7 @@ mod tests {
|
|||||||
let _ = drain_global_dirty_scopes();
|
let _ = drain_global_dirty_scopes();
|
||||||
|
|
||||||
let rename_barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
let rename_barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
||||||
|
let rename_tasks = rename_fanout_barrier::observe_tasks(object);
|
||||||
let complete_store = Arc::clone(&set_disks);
|
let complete_store = Arc::clone(&set_disks);
|
||||||
let mut complete = tokio::spawn(async move {
|
let mut complete = tokio::spawn(async move {
|
||||||
let mut opts = ObjectOptions::default();
|
let mut opts = ObjectOptions::default();
|
||||||
@@ -4062,16 +4062,6 @@ mod tests {
|
|||||||
tokio::time::timeout(Duration::from_secs(30), rename_barrier.wait_until_paused())
|
tokio::time::timeout(Duration::from_secs(30), rename_barrier.wait_until_paused())
|
||||||
.await
|
.await
|
||||||
.expect("multipart completion should pause one tail disk during rename");
|
.expect("multipart completion should pause one tail disk during rename");
|
||||||
assert!(
|
|
||||||
tokio::time::timeout(Duration::from_millis(100), &mut complete).await.is_err(),
|
|
||||||
"multipart completion must not publish success while a tail rename is still paused"
|
|
||||||
);
|
|
||||||
|
|
||||||
let initial = drain_global_dirty_scopes().into_iter().collect::<HashSet<_>>();
|
|
||||||
assert!(
|
|
||||||
initial.is_empty(),
|
|
||||||
"capacity must not be marked as committed before the full multipart rename finishes"
|
|
||||||
);
|
|
||||||
|
|
||||||
let abort_store = Arc::clone(&set_disks);
|
let abort_store = Arc::clone(&set_disks);
|
||||||
let abort = tokio::spawn(async move {
|
let abort = tokio::spawn(async move {
|
||||||
@@ -4080,21 +4070,46 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
});
|
});
|
||||||
signaling.wait_for_attempts(2).await;
|
signaling.wait_for_attempts(2).await;
|
||||||
assert!(!abort.is_finished(), "the in-flight completion must retain the multipart upload guard");
|
|
||||||
|
|
||||||
let retained_staging = futures::future::join_all(
|
// A paused rename does not establish that the other disks reached quorum.
|
||||||
disk_stores
|
let retained_staging = tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
.iter()
|
loop {
|
||||||
.map(|disk| disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &staged_part)),
|
let mut retained = 0;
|
||||||
)
|
for result in futures::future::join_all(
|
||||||
|
disk_stores
|
||||||
|
.iter()
|
||||||
|
.map(|disk| disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &staged_part)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
match result {
|
||||||
|
Ok(_) => retained += 1,
|
||||||
|
Err(DiskError::FileNotFound) => {}
|
||||||
|
Err(error) => panic!("staged rename source lookup failed: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if retained <= 1 && rename_tasks.running() == 1 {
|
||||||
|
break retained;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.into_iter()
|
.expect("unpaused multipart renames should finish before the tail is released");
|
||||||
.filter(|result| result.is_ok())
|
|
||||||
.count();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
retained_staging, 1,
|
retained_staging, 1,
|
||||||
"only the paused tail disk should still retain the multipart rename source"
|
"only the paused tail disk should still retain the multipart rename source"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(100), &mut complete).await.is_err(),
|
||||||
|
"multipart completion must not publish success while a tail rename is still paused"
|
||||||
|
);
|
||||||
|
let initial = drain_global_dirty_scopes().into_iter().collect::<HashSet<_>>();
|
||||||
|
assert!(
|
||||||
|
initial.is_empty(),
|
||||||
|
"capacity must not be marked as committed before the full multipart rename finishes"
|
||||||
|
);
|
||||||
|
assert!(!abort.is_finished(), "the in-flight completion must retain the multipart upload guard");
|
||||||
|
|
||||||
signaling.set_target(rustfs_lock::ObjectKey::new(bucket, object));
|
signaling.set_target(rustfs_lock::ObjectKey::new(bucket, object));
|
||||||
let object_attempt = signaling.attempts.load(Ordering::Acquire) + 1;
|
let object_attempt = signaling.attempts.load(Ordering::Acquire) + 1;
|
||||||
@@ -6743,6 +6758,87 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial]
|
||||||
|
async fn complete_multipart_releases_disk_snapshot_before_cleanup() {
|
||||||
|
let (temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||||
|
let bucket = "multipart-topology-lock-bucket";
|
||||||
|
let object = "object";
|
||||||
|
let body = vec![0x65; 4096];
|
||||||
|
make_bucket_on_all(&disk_stores, bucket).await;
|
||||||
|
let (upload_id, parts) =
|
||||||
|
stage_upload_with_create_opts(&set_disks, bucket, object, &body, &ObjectOptions::default()).await;
|
||||||
|
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
|
||||||
|
for dir in &temp_dirs {
|
||||||
|
assert!(
|
||||||
|
dir.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&upload_id_path).exists(),
|
||||||
|
"the test must create real upload staging on every disk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let barrier = MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::AfterObjectPublication);
|
||||||
|
let complete_store = set_disks.clone();
|
||||||
|
let complete_upload_id = upload_id.clone();
|
||||||
|
let complete = tokio::spawn(async move {
|
||||||
|
complete_store
|
||||||
|
.complete_multipart_upload(bucket, object, &complete_upload_id, parts, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
barrier.wait_until_paused().await;
|
||||||
|
|
||||||
|
// Hold a separate read gate so the real writer queues even when completion
|
||||||
|
// correctly releases its snapshot guard. Polling Pending proves admission
|
||||||
|
// to Tokio's write-preferring queue before the cleanup attempts another read.
|
||||||
|
let read_gate = set_disks.disks.read().await;
|
||||||
|
let writer = set_disks.disks.write();
|
||||||
|
tokio::pin!(writer);
|
||||||
|
assert!(matches!(
|
||||||
|
futures::poll!(tokio::task::unconstrained(writer.as_mut())),
|
||||||
|
std::task::Poll::Pending
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
set_disks.disks.try_read().is_err(),
|
||||||
|
"the pending writer must already block new readers before the cleanup resumes"
|
||||||
|
);
|
||||||
|
drop(read_gate);
|
||||||
|
barrier.release();
|
||||||
|
|
||||||
|
let writer_guard = tokio::time::timeout(Duration::from_secs(5), writer)
|
||||||
|
.await
|
||||||
|
.expect("a queued topology writer must not deadlock with multipart cleanup's disk snapshot");
|
||||||
|
// A reconnect can publish the same handles; this test isolates admission
|
||||||
|
// order without changing the disks that contain the committed object.
|
||||||
|
drop(writer_guard);
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), complete)
|
||||||
|
.await
|
||||||
|
.expect("multipart cleanup must finish after the topology writer releases")
|
||||||
|
.expect("completion task should not panic")
|
||||||
|
.expect("completion should preserve the successful object commit");
|
||||||
|
|
||||||
|
let mut reader = tokio::time::timeout(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
set_disks.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("GET should finish after completion")
|
||||||
|
.expect("the completed object should remain readable");
|
||||||
|
let mut observed_body = Vec::new();
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), reader.stream.read_to_end(&mut observed_body))
|
||||||
|
.await
|
||||||
|
.expect("the completed object body should finish streaming")
|
||||||
|
.expect("the completed object body should be readable");
|
||||||
|
assert_eq!(observed_body, body);
|
||||||
|
assert!(matches!(
|
||||||
|
set_disks.check_upload_id_exists(bucket, object, &upload_id, false).await,
|
||||||
|
Err(StorageError::InvalidUploadID(..))
|
||||||
|
));
|
||||||
|
for dir in &temp_dirs {
|
||||||
|
assert!(
|
||||||
|
!dir.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&upload_id_path).exists(),
|
||||||
|
"successful completion must remove its upload staging from every disk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn complete_releases_object_lock_before_cleanup_and_keeps_upload_lock() {
|
async fn complete_releases_object_lock_before_cleanup_and_keeps_upload_lock() {
|
||||||
|
|||||||
@@ -4459,7 +4459,10 @@ impl SetDisks {
|
|||||||
commit_scanner_publication_lease_tokens.as_ref(),
|
commit_scanner_publication_lease_tokens.as_ref(),
|
||||||
)
|
)
|
||||||
.with_publication_scope(commit_scanner_publication_scope.clone())
|
.with_publication_scope(commit_scanner_publication_scope.clone())
|
||||||
.with_rollback_receipt(commit_rollback_receipt.clone()),
|
.with_rollback_receipt(commit_rollback_receipt.clone())
|
||||||
|
.with_namespace_commit_guard(
|
||||||
|
(!is_meta_bucketname(&commit_bucket)).then(|| commit_set.ctx.begin_namespace_commit()),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if let Some(scope) = commit_scanner_publication_scope.as_ref() {
|
if let Some(scope) = commit_scanner_publication_scope.as_ref() {
|
||||||
|
|||||||
@@ -329,11 +329,11 @@ impl ECStore {
|
|||||||
/// reuse its result, which is sound because bucket deletion/recreation
|
/// reuse its result, which is sound because bucket deletion/recreation
|
||||||
/// requires the lifecycle WRITE lock and therefore cannot have run while
|
/// requires the lifecycle WRITE lock and therefore cannot have run while
|
||||||
/// any read guard was continuously held.
|
/// any read guard was continuously held.
|
||||||
pub(crate) async fn acquire_bucket_incarnation_fence(
|
pub async fn acquire_bucket_incarnation_fence(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
expected: uuid::Uuid,
|
expected: uuid::Uuid,
|
||||||
) -> Result<super::bucket_fence::BucketIncarnationFenceGuard> {
|
) -> Result<super::BucketIncarnationFenceGuard> {
|
||||||
let inner = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
let inner = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
||||||
let pieces = super::bucket_fence::FencePieces {
|
let pieces = super::bucket_fence::FencePieces {
|
||||||
registry: self.bucket_fence_registry.clone(),
|
registry: self.bucket_fence_registry.clone(),
|
||||||
@@ -1059,6 +1059,7 @@ mod tests {
|
|||||||
use crate::storage_api_contracts::{
|
use crate::storage_api_contracts::{
|
||||||
bucket::{BucketOperations as _, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp},
|
bucket::{BucketOperations as _, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp},
|
||||||
list::ListOperations as _,
|
list::ListOperations as _,
|
||||||
|
namespace::NamespaceLocking as _,
|
||||||
object::{ObjectIO as _, ObjectOperations as _},
|
object::{ObjectIO as _, ObjectOperations as _},
|
||||||
};
|
};
|
||||||
use crate::store::{ECStore, init_local_disks_with_instance_ctx};
|
use crate::store::{ECStore, init_local_disks_with_instance_ctx};
|
||||||
@@ -1486,10 +1487,19 @@ mod tests {
|
|||||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||||
.await
|
.await
|
||||||
.expect("object should be written");
|
.expect("object should be written");
|
||||||
|
let lock = ecstore.pools[0].disk_set[0]
|
||||||
|
.new_ns_lock(bucket, object)
|
||||||
|
.await
|
||||||
|
.expect("fixture namespace lock should be created");
|
||||||
|
drop(
|
||||||
|
lock.get_write_lock(Duration::from_secs(30))
|
||||||
|
.await
|
||||||
|
.expect("fixture rename tail should finish before checking its generation"),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ecstore.scanner_namespace_mutation_generation(),
|
ecstore.scanner_namespace_mutation_generation(),
|
||||||
generation_before_put.saturating_add(1),
|
generation_before_put.saturating_add(3),
|
||||||
"successful object creation should advance scanner namespace activity"
|
"successful object creation must observe the logical mutation and both fanout boundaries"
|
||||||
);
|
);
|
||||||
ecstore
|
ecstore
|
||||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ impl BucketFenceRegistry {
|
|||||||
/// A held bucket lifecycle read lock plus its registration in the fence
|
/// A held bucket lifecycle read lock plus its registration in the fence
|
||||||
/// registry. Dropping the guard deregisters it; the memo is cleared when the
|
/// registry. Dropping the guard deregisters it; the memo is cleared when the
|
||||||
/// last guard for the bucket drops (or a lost lock is observed).
|
/// last guard for the bucket drops (or a lost lock is observed).
|
||||||
pub(crate) struct BucketIncarnationFenceGuard {
|
pub struct BucketIncarnationFenceGuard {
|
||||||
inner: Option<NamespaceLockGuard>,
|
inner: Option<NamespaceLockGuard>,
|
||||||
registry: Arc<BucketFenceRegistry>,
|
registry: Arc<BucketFenceRegistry>,
|
||||||
bucket: String,
|
bucket: String,
|
||||||
@@ -158,6 +158,14 @@ pub(crate) struct BucketIncarnationFenceGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl BucketIncarnationFenceGuard {
|
impl BucketIncarnationFenceGuard {
|
||||||
|
/// Propagate lifecycle lock loss into the storage commit checks.
|
||||||
|
/// The caller still owns this guard until the complete write tail drains.
|
||||||
|
pub fn attach_to_object_options(&self, opts: &mut crate::object_api::ObjectOptions) {
|
||||||
|
if let Some(guard) = self.namespace_lock_guard() {
|
||||||
|
opts.add_bucket_lifecycle_lock_guard(guard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||||
self.inner.as_ref().is_some_and(NamespaceLockGuard::is_lock_lost)
|
self.inner.as_ref().is_some_and(NamespaceLockGuard::is_lock_lost)
|
||||||
}
|
}
|
||||||
@@ -346,6 +354,36 @@ mod tests {
|
|||||||
first_pieces.abandon("b", first.token);
|
first_pieces.abandon("b", first.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkpoint_options_inherit_bucket_fence_lock_loss() {
|
||||||
|
let lock = NamespaceLock::new("bucket-fence-options".to_string(), Arc::new(LocalClient::new()));
|
||||||
|
let inner = lock
|
||||||
|
.acquire_guard(&lock_request("options"))
|
||||||
|
.await
|
||||||
|
.expect("acquire")
|
||||||
|
.expect("quorum");
|
||||||
|
let pieces = FencePieces {
|
||||||
|
registry: Arc::default(),
|
||||||
|
inner,
|
||||||
|
};
|
||||||
|
let registration = pieces.enter("b");
|
||||||
|
let fence = pieces.into_guard("b", registration.token);
|
||||||
|
let mut opts = crate::object_api::ObjectOptions::default();
|
||||||
|
fence.attach_to_object_options(&mut opts);
|
||||||
|
let inherited = opts
|
||||||
|
.bucket_lifecycle_lock_fence
|
||||||
|
.as_ref()
|
||||||
|
.expect("checkpoint inherits lifecycle guard");
|
||||||
|
assert!(!inherited.is_lock_lost());
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
fence.namespace_lock_guard().expect("held guard").lock_lost_notified(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("distributed guard expires");
|
||||||
|
assert!(inherited.is_lock_lost(), "the actual pre-rename options must observe lifecycle lock loss");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn buckets_are_isolated() {
|
fn buckets_are_isolated() {
|
||||||
let reg = BucketFenceRegistry::default();
|
let reg = BucketFenceRegistry::default();
|
||||||
|
|||||||
@@ -353,6 +353,11 @@ async fn resume_rebalance_after_init(store: Arc<ECStore>, rx: CancellationToken)
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ECStore {
|
impl ECStore {
|
||||||
|
/// Shutdown token owned by this store instance.
|
||||||
|
pub fn background_cancel_token(&self) -> Option<CancellationToken> {
|
||||||
|
self.ctx.background_cancel_token()
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate topology and process storage-class overrides before any disk is opened.
|
/// Validate topology and process storage-class overrides before any disk is opened.
|
||||||
pub fn validate_startup_storage_class(endpoint_pools: &EndpointServerPools) -> Result<()> {
|
pub fn validate_startup_storage_class(endpoint_pools: &EndpointServerPools) -> Result<()> {
|
||||||
let drive_counts = startup_pool_drive_counts(endpoint_pools);
|
let drive_counts = startup_pool_drive_counts(endpoint_pools);
|
||||||
@@ -787,6 +792,12 @@ impl ECStore {
|
|||||||
pub fn single_pool(&self) -> bool {
|
pub fn single_pool(&self) -> bool {
|
||||||
self.pools.len() == 1
|
self.pools.len() == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The set-local create-only check is atomic only when every object
|
||||||
|
/// mutation uses that same, enabled namespace lock domain.
|
||||||
|
pub fn supports_atomic_create_only_write_back(&self) -> bool {
|
||||||
|
!self.ctx.lock_manager().is_disabled() && self.pools.len() == 1 && self.pools[0].disk_set.len() == 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -2127,7 +2138,7 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|&drives_per_set| (1, drives_per_set))
|
.map(|&drives_per_set| (1, drives_per_set))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
build_isolated_test_store_with_layout(temp_dir, cmd_line, &pool_layouts, shutdown).await
|
build_isolated_test_store_with_layout(temp_dir, cmd_line, &pool_layouts, shutdown, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_isolated_test_store_with_layout(
|
async fn build_isolated_test_store_with_layout(
|
||||||
@@ -2135,6 +2146,7 @@ mod tests {
|
|||||||
cmd_line: &str,
|
cmd_line: &str,
|
||||||
pool_layouts: &[(usize, usize)],
|
pool_layouts: &[(usize, usize)],
|
||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
|
instance_ctx: Option<Arc<crate::runtime::instance::InstanceContext>>,
|
||||||
) -> (
|
) -> (
|
||||||
Arc<crate::runtime::instance::InstanceContext>,
|
Arc<crate::runtime::instance::InstanceContext>,
|
||||||
Arc<crate::store::ECStore>,
|
Arc<crate::store::ECStore>,
|
||||||
@@ -2167,7 +2179,7 @@ mod tests {
|
|||||||
let endpoint_pools = EndpointServerPools(pools);
|
let endpoint_pools = EndpointServerPools(pools);
|
||||||
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
|
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
|
||||||
|
|
||||||
let instance_ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
let instance_ctx = instance_ctx.unwrap_or_else(|| Arc::new(crate::runtime::instance::InstanceContext::new()));
|
||||||
crate::store::init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
|
crate::store::init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
|
||||||
.await
|
.await
|
||||||
.expect("register local disks into the fresh context");
|
.expect("register local disks into the fresh context");
|
||||||
@@ -2535,6 +2547,348 @@ mod tests {
|
|||||||
shutdown.cancel();
|
shutdown.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
async fn early_ack_put_tails_block_scanner_publication_until_all_renames_finish() {
|
||||||
|
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||||
|
|
||||||
|
let temp_dir = tempfile::tempdir().expect("create scanner PUT tail store dir");
|
||||||
|
let (ctx, store, shutdown) =
|
||||||
|
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "scanner-put-tails", &[4])).await;
|
||||||
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||||
|
let bucket = format!("scanner-put-tails-{}", Uuid::new_v4());
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("create scanner PUT tail bucket");
|
||||||
|
let set = &store.pools[0].disk_set[0];
|
||||||
|
let objects = [("scanner-tail-a", vec![0xA1; 273]), ("scanner-tail-b", vec![0xB2; 379])];
|
||||||
|
|
||||||
|
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||||
|
let (active, blocked, movement_generation) = store.scanner_data_movement_activity().await;
|
||||||
|
assert!(!active && !blocked);
|
||||||
|
assert!(ctx.scanner_publication_state_allowed(), "the set admission cache should start allowed");
|
||||||
|
let (old_lease, _) = store
|
||||||
|
.acquire_scanner_publication_lease(movement_generation, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||||
|
.await
|
||||||
|
.expect("publication lease should be admitted before either PUT starts");
|
||||||
|
|
||||||
|
let barriers: Vec<_> = objects
|
||||||
|
.iter()
|
||||||
|
.map(|(object, _)| {
|
||||||
|
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let trackers: Vec<_> = objects
|
||||||
|
.iter()
|
||||||
|
.map(|(object, _)| crate::set_disk::rename_fanout_barrier::observe_tasks(object))
|
||||||
|
.collect();
|
||||||
|
let puts: Vec<_> = objects
|
||||||
|
.iter()
|
||||||
|
.map(|(object, body)| {
|
||||||
|
let put_store = Arc::clone(&store);
|
||||||
|
let put_bucket = bucket.clone();
|
||||||
|
let object = *object;
|
||||||
|
let body = body.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut reader = PutObjReader::from_vec(body);
|
||||||
|
put_store
|
||||||
|
.put_object(&put_bucket, object, &mut reader, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let committed = tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
for barrier in &barriers {
|
||||||
|
barrier.wait_until_paused().await;
|
||||||
|
}
|
||||||
|
let mut committed = Vec::with_capacity(puts.len());
|
||||||
|
for put in puts {
|
||||||
|
committed.push(
|
||||||
|
put.await
|
||||||
|
.expect("early-ACK PUT task should join while its tail is paused")
|
||||||
|
.expect("root PUT should return after quorum without waiting for its tail"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
committed
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("both root PUTs must quorum-ACK while their tail disks remain paused");
|
||||||
|
|
||||||
|
assert!(trackers.iter().all(|tracker| tracker.running() >= 1));
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
assert!(
|
||||||
|
ctx.scanner_publication_state_allowed(),
|
||||||
|
"pending PUT tails must not disable scanner namespace walks"
|
||||||
|
);
|
||||||
|
let (active, blocked, observed_movement_generation) = store.scanner_data_movement_activity().await;
|
||||||
|
assert!(!active, "ordinary PUT tails are not decommission or rebalance work");
|
||||||
|
assert!(!blocked, "ordinary PUT tails must not block the movement-only scan baseline");
|
||||||
|
assert_eq!(observed_movement_generation, movement_generation);
|
||||||
|
assert!(store.scanner_data_usage_publication_blocked().await);
|
||||||
|
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
assert!(set.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
for error in [
|
||||||
|
store
|
||||||
|
.acquire_scanner_publication_lease(
|
||||||
|
movement_generation,
|
||||||
|
crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("a new remote publication lease must reject pending PUT tails"),
|
||||||
|
store
|
||||||
|
.validate_scanner_publication_lease(old_lease, movement_generation)
|
||||||
|
.await
|
||||||
|
.expect_err("an existing remote lease must not bypass pending PUT tails"),
|
||||||
|
store
|
||||||
|
.acquire_scanner_publication_lease_guard(old_lease)
|
||||||
|
.await
|
||||||
|
.expect_err("target-side publication admission must reject pending PUT tails"),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("blocked"),
|
||||||
|
"publication must fail because of active tails: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
store.release_scanner_publication_lease(old_lease).await;
|
||||||
|
|
||||||
|
for (index, barrier) in barriers.iter().enumerate() {
|
||||||
|
let commit_generation = ctx.namespace_commit_generation();
|
||||||
|
let namespace_generation = store.scanner_namespace_mutation_generation();
|
||||||
|
barrier.release();
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
while trackers[index].running() != 0 || ctx.namespace_commit_generation() <= commit_generation {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
if index + 1 == barriers.len() {
|
||||||
|
while ctx.namespace_commits_pending() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("released tail must drain and publish its terminal namespace generation");
|
||||||
|
assert!(store.scanner_namespace_mutation_generation() > namespace_generation);
|
||||||
|
let pending = index + 1 < barriers.len();
|
||||||
|
assert_eq!(ctx.namespace_commits_pending(), pending);
|
||||||
|
assert_eq!(store.scanner_data_usage_publication_blocked().await, pending);
|
||||||
|
assert!(!store.scanner_data_movement_activity().await.1);
|
||||||
|
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
assert!(set.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (lease, generation) = store
|
||||||
|
.acquire_scanner_publication_lease(movement_generation, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||||
|
.await
|
||||||
|
.expect("remote publication lease should resume after both tails drain");
|
||||||
|
store
|
||||||
|
.validate_scanner_publication_lease(lease, generation)
|
||||||
|
.await
|
||||||
|
.expect("a resumed remote publication lease should validate");
|
||||||
|
drop(
|
||||||
|
store
|
||||||
|
.acquire_scanner_publication_lease_guard(lease)
|
||||||
|
.await
|
||||||
|
.expect("target-side publication admission should resume after both tails drain"),
|
||||||
|
);
|
||||||
|
assert!(store.release_scanner_publication_lease(lease).await);
|
||||||
|
|
||||||
|
let disks = set.disk_inventory().await;
|
||||||
|
assert_eq!(disks.len(), 4);
|
||||||
|
for ((object, body), committed) in objects.iter().zip(&committed) {
|
||||||
|
let logical_size = i64::try_from(body.len()).expect("fixture payload size should fit i64");
|
||||||
|
let etag = committed.etag.as_ref().expect("root PUT should return a committed ETag");
|
||||||
|
for (disk_index, disk) in disks.iter().enumerate() {
|
||||||
|
let file_info = disk
|
||||||
|
.as_ref()
|
||||||
|
.expect("every fixture disk should remain online")
|
||||||
|
.read_version(
|
||||||
|
"",
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
"",
|
||||||
|
&crate::disk::ReadOptions {
|
||||||
|
read_data: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|err| panic!("disk {disk_index} should publish {object} after its tail finishes: {err}"));
|
||||||
|
assert_eq!(file_info.size, logical_size);
|
||||||
|
assert_eq!(file_info.metadata.get(http::header::ETAG.as_str()), Some(etag));
|
||||||
|
assert!(
|
||||||
|
file_info.inline_data(),
|
||||||
|
"small fixture payloads should have an inline shard on every disk"
|
||||||
|
);
|
||||||
|
let inline_data = file_info.data.as_ref().expect("every disk should retain its inline shard");
|
||||||
|
let erasure = crate::erasure::coding::Erasure::try_new_with_options(
|
||||||
|
file_info.erasure.data_blocks,
|
||||||
|
file_info.erasure.parity_blocks,
|
||||||
|
file_info.erasure.block_size,
|
||||||
|
file_info.uses_legacy_checksum,
|
||||||
|
)
|
||||||
|
.expect("persisted erasure geometry should be valid");
|
||||||
|
let shard_size =
|
||||||
|
usize::try_from(erasure.shard_file_size(logical_size)).expect("fixture shard size should fit usize");
|
||||||
|
crate::erasure::coding::bitrot_verify(
|
||||||
|
Cursor::new(inline_data.clone()),
|
||||||
|
inline_data.len(),
|
||||||
|
shard_size,
|
||||||
|
rustfs_utils::HashAlgorithm::HighwayHash256S,
|
||||||
|
erasure.shard_size(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|err| panic!("disk {disk_index} should retain a complete valid shard for {object}: {err}"));
|
||||||
|
}
|
||||||
|
let mut reader = store
|
||||||
|
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("fully drained PUT should be readable");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
reader.stream.read_to_end(&mut actual).await.expect("PUT body should drain");
|
||||||
|
assert_eq!(&actual, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let generation_before_internal_put = ctx.namespace_commit_generation();
|
||||||
|
let internal_object = "scanner-tail-regression/internal-metadata";
|
||||||
|
let internal_body = b"scanner metadata must not invalidate its own publication";
|
||||||
|
let mut internal_reader = PutObjReader::from_vec(internal_body.to_vec());
|
||||||
|
store
|
||||||
|
.put_object(RUSTFS_META_BUCKET, internal_object, &mut internal_reader, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("internal metadata PUT should commit without scanner self-invalidation");
|
||||||
|
let internal_lock = set
|
||||||
|
.new_ns_lock(RUSTFS_META_BUCKET, internal_object)
|
||||||
|
.await
|
||||||
|
.expect("internal metadata tail lock should be available");
|
||||||
|
drop(
|
||||||
|
internal_lock
|
||||||
|
.get_write_lock(Duration::from_secs(30))
|
||||||
|
.await
|
||||||
|
.expect("internal metadata tail should drain"),
|
||||||
|
);
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), generation_before_internal_put);
|
||||||
|
assert!(!ctx.namespace_commits_pending());
|
||||||
|
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
assert!(set.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
let mut internal_reader = store
|
||||||
|
.get_object_reader(RUSTFS_META_BUCKET, internal_object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("internal metadata should remain readable");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
internal_reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("internal metadata body should drain");
|
||||||
|
assert_eq!(actual, internal_body);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
shutdown.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
async fn cancelled_early_ack_put_keeps_scanner_publication_blocked_until_tail_finishes() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("create cancelled scanner PUT tail store dir");
|
||||||
|
let (ctx, store, shutdown) =
|
||||||
|
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "scanner-cancelled-put-tail", &[4])).await;
|
||||||
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||||
|
let bucket = format!("scanner-cancelled-put-tail-{}", Uuid::new_v4());
|
||||||
|
let object = "scanner-cancelled-tail";
|
||||||
|
let body = vec![0xC3; 273];
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("create cancelled scanner PUT tail bucket");
|
||||||
|
|
||||||
|
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||||
|
let tracker = crate::set_disk::rename_fanout_barrier::observe_tasks(object);
|
||||||
|
let tail =
|
||||||
|
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME);
|
||||||
|
let quorum = crate::set_disk::PutObjectCommitBarrier::install(
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
crate::set_disk::PutObjectCommitPause::AfterRenameQuorum,
|
||||||
|
);
|
||||||
|
let handoff = crate::set_disk::PutObjectCommitBarrier::install(
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
crate::set_disk::PutObjectCommitPause::AfterRenameHandoff,
|
||||||
|
);
|
||||||
|
let put_store = Arc::clone(&store);
|
||||||
|
let put_bucket = bucket.clone();
|
||||||
|
let put_body = body.clone();
|
||||||
|
let put = tokio::spawn(async move {
|
||||||
|
let mut reader = PutObjReader::from_vec(put_body);
|
||||||
|
put_store
|
||||||
|
.put_object(&put_bucket, object, &mut reader, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), tail.wait_until_paused())
|
||||||
|
.await
|
||||||
|
.expect("cancelled PUT should pause one disk before rename");
|
||||||
|
quorum.wait_until_paused().await;
|
||||||
|
put.abort();
|
||||||
|
assert!(
|
||||||
|
put.await
|
||||||
|
.expect_err("caller should be cancelled after rename quorum")
|
||||||
|
.is_cancelled()
|
||||||
|
);
|
||||||
|
quorum.release();
|
||||||
|
handoff.wait_until_paused().await;
|
||||||
|
assert!(tracker.running() >= 1);
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
assert!(!store.scanner_data_movement_activity().await.1);
|
||||||
|
assert!(store.scanner_data_usage_publication_blocked().await);
|
||||||
|
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
assert!(
|
||||||
|
store.pools[0].disk_set[0]
|
||||||
|
.scanner_data_usage_publication_admission_guard()
|
||||||
|
.await
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
let generation = store.scanner_namespace_mutation_generation();
|
||||||
|
|
||||||
|
handoff.release();
|
||||||
|
tail.release();
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
while tracker.running() != 0 || ctx.namespace_commits_pending() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("cancelled request's detached fanout must release scanner admission after finishing");
|
||||||
|
assert!(store.scanner_namespace_mutation_generation() > generation);
|
||||||
|
assert!(!store.scanner_data_usage_publication_blocked().await);
|
||||||
|
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||||
|
for (disk_index, disk) in store.pools[0].disk_set[0].disk_inventory().await.iter().enumerate() {
|
||||||
|
let file_info = disk
|
||||||
|
.as_ref()
|
||||||
|
.expect("cancelled PUT fixture disk should remain online")
|
||||||
|
.read_version("", &bucket, object, "", &crate::disk::ReadOptions::default())
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|err| panic!("cancelled PUT must still publish on disk {disk_index}: {err}"));
|
||||||
|
assert_eq!(file_info.size, i64::try_from(body.len()).expect("fixture body size should fit i64"));
|
||||||
|
}
|
||||||
|
let mut reader = store
|
||||||
|
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("a cancelled caller must not discard its quorum-committed object");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("cancelled PUT body should drain");
|
||||||
|
assert_eq!(actual, body);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
shutdown.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
#[test]
|
#[test]
|
||||||
#[serial_test::serial(storage_class_env)]
|
#[serial_test::serial(storage_class_env)]
|
||||||
@@ -2986,8 +3340,9 @@ mod tests {
|
|||||||
) -> crate::core::pools::DecommissionTestFaultDecision {
|
) -> crate::core::pools::DecommissionTestFaultDecision {
|
||||||
let target_bucket = bucket.to_string();
|
let target_bucket = bucket.to_string();
|
||||||
let target_object = object.to_string();
|
let target_object = object.to_string();
|
||||||
Arc::new(move |stage, bucket, object, _attempt, succeeded| {
|
Arc::new(move |stage, bucket, object, attempt, succeeded| {
|
||||||
if !succeeded
|
if !succeeded
|
||||||
|
|| attempt >= crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS
|
||||||
|| stage != DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT
|
|| stage != DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT
|
||||||
|| bucket != target_bucket
|
|| bucket != target_bucket
|
||||||
|| object != target_object
|
|| object != target_object
|
||||||
@@ -2997,6 +3352,7 @@ mod tests {
|
|||||||
|
|
||||||
// Entry retries reset the local attempt; real copy errors can skip
|
// Entry retries reset the local attempt; real copy errors can skip
|
||||||
// successful attempts. Only injected faults spend this global budget.
|
// successful attempts. Only injected faults spend this global budget.
|
||||||
|
// A real failure may consume an attempt, so preserve the final chance.
|
||||||
faults
|
faults
|
||||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |faults| {
|
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |faults| {
|
||||||
(faults < crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS.saturating_sub(1))
|
(faults < crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS.saturating_sub(1))
|
||||||
@@ -5018,6 +5374,7 @@ mod tests {
|
|||||||
"decommission-delete-fence",
|
"decommission-delete-fence",
|
||||||
&[(2, 4), (1, 4)],
|
&[(2, 4), (1, 4)],
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
|
None,
|
||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
@@ -5149,7 +5506,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decommission_retry_fault_budget_counts_successes_across_attempt_changes() {
|
fn decommission_retry_fault_budget_counts_successes_across_attempt_changes() {
|
||||||
for attempts in [[1, 2, 3], [1, 1, 2], [1, 3, 3]] {
|
let cases: &[&[(usize, bool, bool)]] = &[
|
||||||
|
&[(1, true, true), (2, true, true), (3, true, false)],
|
||||||
|
&[(1, true, true), (1, true, true), (2, true, false)],
|
||||||
|
&[(1, true, true), (3, true, false), (3, true, false)],
|
||||||
|
&[(1, true, true), (2, false, false), (1, true, true), (2, true, false)],
|
||||||
|
&[(1, true, true), (2, false, false), (3, true, false)],
|
||||||
|
&[(3, true, false), (4, true, false)],
|
||||||
|
];
|
||||||
|
for case in cases {
|
||||||
let faults = Arc::new(AtomicUsize::new(0));
|
let faults = Arc::new(AtomicUsize::new(0));
|
||||||
let hook = decommission_retry_fault_hook("bucket", "object", Arc::clone(&faults));
|
let hook = decommission_retry_fault_hook("bucket", "object", Arc::clone(&faults));
|
||||||
|
|
||||||
@@ -5163,14 +5528,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(faults.load(Ordering::SeqCst), 0, "unrelated or failed copies must not consume faults");
|
assert_eq!(faults.load(Ordering::SeqCst), 0, "unrelated or failed copies must not consume faults");
|
||||||
|
|
||||||
for (index, attempt) in attempts.into_iter().enumerate() {
|
let mut expected_faults = 0;
|
||||||
|
for &(attempt, succeeded, expected) in *case {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hook(DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT, "bucket", "object", attempt, true),
|
hook(DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT, "bucket", "object", attempt, succeeded),
|
||||||
index < 2,
|
expected,
|
||||||
"attempts={attempts:?}, index={index}"
|
"fault plan {case:?} at attempt {attempt}"
|
||||||
);
|
);
|
||||||
|
expected_faults += usize::from(expected);
|
||||||
|
assert_eq!(faults.load(Ordering::SeqCst), expected_faults);
|
||||||
}
|
}
|
||||||
assert_eq!(faults.load(Ordering::SeqCst), 2, "attempts={attempts:?}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5306,6 +5673,15 @@ mod tests {
|
|||||||
changed_result.expect("SourceChanged entry retry must converge");
|
changed_result.expect("SourceChanged entry retry must converge");
|
||||||
other_result.expect("other bucket entry must continue through ordinary copy retries");
|
other_result.expect("other bucket entry must continue through ordinary copy retries");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.pool_meta.read().await.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("decommission progress should remain available")
|
||||||
|
.items_decommission_failed,
|
||||||
|
0,
|
||||||
|
"entry completion must not hide an exhausted copy failure"
|
||||||
|
);
|
||||||
assert!(!rx.is_cancelled(), "entry-level SourceChanged must not cancel the shared worker token");
|
assert!(!rx.is_cancelled(), "entry-level SourceChanged must not cancel the shared worker token");
|
||||||
assert_eq!(mutation_calls.load(Ordering::SeqCst), 2, "entry must be re-listed after SourceChanged");
|
assert_eq!(mutation_calls.load(Ordering::SeqCst), 2, "entry must be re-listed after SourceChanged");
|
||||||
assert_eq!(ordinary_faults.load(Ordering::SeqCst), 2, "ordinary copy must consume the retry budget");
|
assert_eq!(ordinary_faults.load(Ordering::SeqCst), 2, "ordinary copy must consume the retry budget");
|
||||||
@@ -5934,6 +6310,7 @@ mod tests {
|
|||||||
"reverse-decommission-fixed-target",
|
"reverse-decommission-fixed-target",
|
||||||
&[(1, 4), (1, 4)],
|
&[(1, 4), (1, 4)],
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
|
None,
|
||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
@@ -6355,6 +6732,7 @@ mod tests {
|
|||||||
"multi-set-decommission-source-cleanup",
|
"multi-set-decommission-source-cleanup",
|
||||||
&[(2, 4)],
|
&[(2, 4)],
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
|
None,
|
||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
@@ -8870,18 +9248,17 @@ mod tests {
|
|||||||
const MANIFEST_COUNT: usize = 10;
|
const MANIFEST_COUNT: usize = 10;
|
||||||
|
|
||||||
let temp_dir = tempfile::tempdir().expect("create fast manifest pass recovery store dir");
|
let temp_dir = tempfile::tempdir().expect("create fast manifest pass recovery store dir");
|
||||||
let (ctx, store, _shutdown) =
|
let mut instance_ctx = crate::runtime::instance::InstanceContext::new();
|
||||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-delete-fast-manifest-pass", &[4])).await;
|
instance_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||||
|
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
|
||||||
|
temp_dir.path(),
|
||||||
|
"tier-delete-fast-manifest-pass",
|
||||||
|
&[(1, 4)],
|
||||||
|
CancellationToken::new(),
|
||||||
|
Some(Arc::new(instance_ctx)),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
let bucket = "tier-delete-fast-manifest-pass-bucket";
|
|
||||||
store
|
|
||||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("fast manifest pass bucket should be created");
|
|
||||||
let incarnation = store
|
|
||||||
.bucket_incarnation_id(bucket)
|
|
||||||
.await
|
|
||||||
.expect("fast manifest pass bucket incarnation should resolve");
|
|
||||||
let tier_name = "FAST-MANIFEST-PASS";
|
let tier_name = "FAST-MANIFEST-PASS";
|
||||||
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
|
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
|
||||||
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
|
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
|
||||||
@@ -8889,9 +9266,19 @@ mod tests {
|
|||||||
.expect("fast manifest pass tier lease should resolve")
|
.expect("fast manifest pass tier lease should resolve")
|
||||||
.backend_identity();
|
.backend_identity();
|
||||||
for index in 0..MANIFEST_COUNT {
|
for index in 0..MANIFEST_COUNT {
|
||||||
|
// Pagination must not depend on same-bucket lock wait deadlines.
|
||||||
|
let bucket = format!("tier-delete-fast-manifest-pass-{index}");
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("fast manifest pass bucket should be created");
|
||||||
|
let incarnation = store
|
||||||
|
.bucket_incarnation_id(&bucket)
|
||||||
|
.await
|
||||||
|
.expect("fast manifest pass bucket incarnation should resolve");
|
||||||
install_aborting_dispatch_fixture(
|
install_aborting_dispatch_fixture(
|
||||||
store.clone(),
|
store.clone(),
|
||||||
bucket,
|
&bucket,
|
||||||
incarnation,
|
incarnation,
|
||||||
&format!("manifest-page-{index:06}/"),
|
&format!("manifest-page-{index:06}/"),
|
||||||
tier_name,
|
tier_name,
|
||||||
@@ -8922,12 +9309,78 @@ mod tests {
|
|||||||
"one production pass must cross the default eight-manifest page limit"
|
"one production pass must cross the default eight-manifest page limit"
|
||||||
);
|
);
|
||||||
assert_eq!(stats.manifests.scanned, MANIFEST_COUNT);
|
assert_eq!(stats.manifests.scanned, MANIFEST_COUNT);
|
||||||
assert_eq!(stats.manifests.deleted, MANIFEST_COUNT);
|
assert_eq!(stats.manifests.deleted, MANIFEST_COUNT, "full recovery result: {stats:?}");
|
||||||
assert_eq!(stats.manifests.failed, 0);
|
assert_eq!(stats.manifests.failed, 0, "full recovery result: {stats:?}");
|
||||||
assert_eq!(manifest_marker, None);
|
assert_eq!(manifest_marker, None);
|
||||||
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
|
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
|
||||||
assert_eq!(tier_delete_journal_count(store).await, 0);
|
assert_eq!(tier_delete_journal_count(store).await, 0);
|
||||||
assert_eq!(backend.remove_count().await, 0, "rollback recovery must not call the remote tier");
|
assert_eq!(backend.remove_count().await, 0, "rollback recovery must not call the remote tier");
|
||||||
|
shutdown.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
async fn tier_delete_manual_pass_retains_manifest_owned_by_startup_recovery() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("create automatic recovery ownership store dir");
|
||||||
|
let (ctx, store, shutdown) =
|
||||||
|
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-delete-auto-owner", &[4])).await;
|
||||||
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
|
let bucket = "tier-delete-auto-owner-bucket";
|
||||||
|
store
|
||||||
|
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("automatic recovery bucket should be created");
|
||||||
|
let incarnation = store.bucket_incarnation_id(bucket).await.expect("bucket incarnation");
|
||||||
|
let tier_name = "AUTO-OWNER";
|
||||||
|
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
|
||||||
|
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
|
||||||
|
.await
|
||||||
|
.expect("automatic recovery tier lease")
|
||||||
|
.backend_identity();
|
||||||
|
|
||||||
|
// The automatic worker must not observe a partially installed fixture.
|
||||||
|
let lifecycle_guard = store
|
||||||
|
.acquire_bucket_lifecycle_write_lock(bucket)
|
||||||
|
.await
|
||||||
|
.expect("fixture lifecycle lock");
|
||||||
|
let (manifest_name, entries) =
|
||||||
|
install_aborting_dispatch_fixture(store.clone(), bucket, incarnation, "auto-owner/", tier_name, identity, 1).await;
|
||||||
|
let journal_name = tier_delete_journal_object_name(&entries[0]);
|
||||||
|
let hook = TierDeleteDispatchRollbackTestHook::install_slow_delete(&journal_name, &journal_name);
|
||||||
|
drop(lifecycle_guard);
|
||||||
|
ctx.wake_tier_delete_journal_recovery();
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), hook.wait_until_delete_paused())
|
||||||
|
.await
|
||||||
|
.expect("startup recovery should own the manifest before a manual pass");
|
||||||
|
assert!(tier_delete_dispatch_manifest_recovery_inflight_for_test(&store, &manifest_name));
|
||||||
|
|
||||||
|
let stats = recover_tier_delete_dispatch_manifests(store.clone(), 8, None)
|
||||||
|
.await
|
||||||
|
.expect("manual recovery scan");
|
||||||
|
assert_eq!(stats.scanned, 1, "{stats:?}");
|
||||||
|
assert_eq!(stats.retained, 1, "{stats:?}");
|
||||||
|
assert_eq!(stats.deleted, 0, "{stats:?}");
|
||||||
|
assert_eq!(stats.failed, 0, "{stats:?}");
|
||||||
|
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 1);
|
||||||
|
assert_eq!(tier_delete_journal_count(store.clone()).await, 1);
|
||||||
|
|
||||||
|
hook.release_delete();
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
loop {
|
||||||
|
let manifest_gone = matches!(com::read_config(store.clone(), &manifest_name).await, Err(Error::ConfigNotFound));
|
||||||
|
if manifest_gone && !tier_delete_dispatch_manifest_recovery_inflight_for_test(&store, &manifest_name) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("automatic recovery should converge without a manual retry");
|
||||||
|
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
|
||||||
|
assert_eq!(tier_delete_journal_count(store).await, 0);
|
||||||
|
assert_eq!(backend.remove_count().await, 0, "rollback must not delete from the remote tier");
|
||||||
|
shutdown.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
@@ -10302,8 +10755,17 @@ mod tests {
|
|||||||
const JOURNAL_COUNT: usize = 40;
|
const JOURNAL_COUNT: usize = 40;
|
||||||
|
|
||||||
let temp_dir = tempfile::tempdir().expect("create rollback retry store dir");
|
let temp_dir = tempfile::tempdir().expect("create rollback retry store dir");
|
||||||
let (ctx, store, _shutdown) =
|
// Manual retries must own progress between fault removal and the next attempt.
|
||||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-rollback-retry", &[4])).await;
|
let mut instance_ctx = crate::runtime::instance::InstanceContext::new();
|
||||||
|
instance_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||||
|
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
|
||||||
|
temp_dir.path(),
|
||||||
|
"dispatch-rollback-retry",
|
||||||
|
&[(1, 4)],
|
||||||
|
CancellationToken::new(),
|
||||||
|
Some(Arc::new(instance_ctx)),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
let bucket = "dispatch-rollback-retry-bucket";
|
let bucket = "dispatch-rollback-retry-bucket";
|
||||||
store
|
store
|
||||||
@@ -10377,6 +10839,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
|
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
|
||||||
assert_eq!(backend.remove_count().await, 0, "rollback retries must never call the remote tier");
|
assert_eq!(backend.remove_count().await, 0, "rollback retries must never call the remote tier");
|
||||||
|
shutdown.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
@@ -13204,6 +13667,7 @@ mod tests {
|
|||||||
"partial-set-prefix-delete",
|
"partial-set-prefix-delete",
|
||||||
&[(2, 4)],
|
&[(2, 4)],
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
|
None,
|
||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
@@ -16576,6 +17040,7 @@ mod tests {
|
|||||||
"prepared-directory-recovery",
|
"prepared-directory-recovery",
|
||||||
&[(2, 4)],
|
&[(2, 4)],
|
||||||
shutdown,
|
shutdown,
|
||||||
|
None,
|
||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
@@ -17228,6 +17693,38 @@ mod tests {
|
|||||||
.expect("test thread should complete");
|
.expect("test thread should complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
async fn odm_write_back_requires_one_set_and_enabled_namespace_locking() {
|
||||||
|
for (layout, locking, supported) in [
|
||||||
|
(&[(1, 4)][..], true, true),
|
||||||
|
(&[(1, 4), (1, 4)][..], true, false),
|
||||||
|
(&[(2, 4)][..], true, false),
|
||||||
|
(&[(1, 4)][..], false, false),
|
||||||
|
] {
|
||||||
|
temp_env::async_with_vars([("RUSTFS_LOCK_ENABLED", Some(if locking { "true" } else { "false" }))], async {
|
||||||
|
let dir = tempfile::tempdir().expect("isolated topology");
|
||||||
|
let shutdown = CancellationToken::new();
|
||||||
|
let (_ctx, store, _) = without_storage_class_env(build_isolated_test_store_with_layout(
|
||||||
|
dir.path(),
|
||||||
|
"odm-topology",
|
||||||
|
layout,
|
||||||
|
shutdown.clone(),
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
store.supports_atomic_create_only_write_back(),
|
||||||
|
supported,
|
||||||
|
"layout={layout:?}, locking={locking}"
|
||||||
|
);
|
||||||
|
shutdown.cancel();
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial(storage_class_env)]
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ const MAX_UPLOADS_LIST: usize = 10000;
|
|||||||
mod bucket;
|
mod bucket;
|
||||||
mod bucket_fence;
|
mod bucket_fence;
|
||||||
pub(crate) use bucket::await_bucket_namespace_operation;
|
pub(crate) use bucket::await_bucket_namespace_operation;
|
||||||
|
pub use bucket_fence::BucketIncarnationFenceGuard;
|
||||||
mod heal;
|
mod heal;
|
||||||
mod heal_walk;
|
mod heal_walk;
|
||||||
pub use heal_walk::HealWalkVersion;
|
pub use heal_walk::HealWalkVersion;
|
||||||
@@ -848,7 +849,7 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn scanner_namespace_mutation_generation(&self) -> u64 {
|
pub fn scanner_namespace_mutation_generation(&self) -> u64 {
|
||||||
list_objects::scanner_namespace_mutation_generation()
|
list_objects::scanner_namespace_mutation_generation().saturating_add(self.ctx.namespace_commit_generation())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn scanner_data_movement_active(&self) -> bool {
|
pub async fn scanner_data_movement_active(&self) -> bool {
|
||||||
@@ -857,7 +858,7 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return the storage-owned movement state and generation as one
|
/// Return the storage-owned movement state and generation as one
|
||||||
/// authenticated activity snapshot. The read lock is acquired before
|
/// authenticated activity snapshot. The read lock is acquired before
|
||||||
/// the state locks (cancelers, pool metadata, then rebalance metadata),
|
/// the state locks (cancelers, pool metadata, then rebalance metadata),
|
||||||
/// matching the transition writer order and preventing a terminal state
|
/// matching the transition writer order and preventing a terminal state
|
||||||
/// from being reported with the preceding generation.
|
/// from being reported with the preceding generation.
|
||||||
@@ -886,11 +887,12 @@ impl ECStore {
|
|||||||
/// Returns whether scanner metadata may still be hidden by a local
|
/// Returns whether scanner metadata may still be hidden by a local
|
||||||
/// data-movement state. Terminal failed/canceled decommission entries
|
/// data-movement state. Terminal failed/canceled decommission entries
|
||||||
/// remain suspended until an operator clears or retries them, so they are
|
/// remain suspended until an operator clears or retries them, so they are
|
||||||
/// a publication barrier even after the worker has stopped.
|
/// a publication barrier even after the worker has stopped. Active PUT
|
||||||
|
/// rename fanouts also defer publication, including post-ACK tails.
|
||||||
pub async fn scanner_data_usage_publication_blocked(&self) -> bool {
|
pub async fn scanner_data_usage_publication_blocked(&self) -> bool {
|
||||||
let operation_gate = self.ctx.data_movement_operation_gate();
|
let operation_gate = self.ctx.data_movement_operation_gate();
|
||||||
let _operation_guard = operation_gate.read_owned().await;
|
let _operation_guard = operation_gate.read_owned().await;
|
||||||
self.scanner_data_usage_publication_snapshot_blocked().await
|
self.scanner_data_usage_publication_snapshot_blocked().await || self.ctx.namespace_commits_pending()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn scanner_data_movement_pause_status(&self) -> ScannerDataMovementPauseStatus {
|
pub async fn scanner_data_movement_pause_status(&self) -> ScannerDataMovementPauseStatus {
|
||||||
@@ -1070,7 +1072,7 @@ impl ECStore {
|
|||||||
{
|
{
|
||||||
return Err(Error::other("scanner publication lease generation is stale"));
|
return Err(Error::other("scanner publication lease generation is stale"));
|
||||||
}
|
}
|
||||||
if self.scanner_data_movement_snapshot_locked().await.1 {
|
if self.scanner_data_movement_snapshot_locked().await.1 || self.ctx.namespace_commits_pending() {
|
||||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1109,7 +1111,7 @@ impl ECStore {
|
|||||||
{
|
{
|
||||||
return Err(Error::other("scanner publication lease generation is stale"));
|
return Err(Error::other("scanner publication lease generation is stale"));
|
||||||
}
|
}
|
||||||
if self.scanner_data_movement_snapshot_locked().await.1 {
|
if self.scanner_data_movement_snapshot_locked().await.1 || self.ctx.namespace_commits_pending() {
|
||||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||||
}
|
}
|
||||||
if !self.ctx.scanner_publication_lease_is_active(token).await {
|
if !self.ctx.scanner_publication_lease_is_active(token).await {
|
||||||
@@ -1129,7 +1131,7 @@ impl ECStore {
|
|||||||
if self.ctx.data_movement_generation_exhausted() || self.ctx.data_movement_operation_epoch_exhausted() {
|
if self.ctx.data_movement_generation_exhausted() || self.ctx.data_movement_operation_epoch_exhausted() {
|
||||||
return Err(Error::other("scanner publication lease generation is exhausted"));
|
return Err(Error::other("scanner publication lease generation is exhausted"));
|
||||||
}
|
}
|
||||||
if self.scanner_data_movement_snapshot_locked().await.1 {
|
if self.scanner_data_movement_snapshot_locked().await.1 || self.ctx.namespace_commits_pending() {
|
||||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||||
}
|
}
|
||||||
let Some(lease_generation) = self.ctx.scanner_publication_lease_generation(token).await else {
|
let Some(lease_generation) = self.ctx.scanner_publication_lease_generation(token).await else {
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::heal::task::{HealOptions, HealPriority, HealRequest, HealType};
|
use crate::heal::task::{HealOptions, HealPriority, HealRequest, HealType};
|
||||||
|
|
||||||
|
/// Read-only inspection of committed MRF checkpoints. The legacy consumer
|
||||||
|
/// remains unchanged until ownership-aware replay is deployed.
|
||||||
|
pub mod snapshot;
|
||||||
|
|
||||||
/// Journal location inside the metadata bucket, following the resume-state
|
/// Journal location inside the metadata bucket, following the resume-state
|
||||||
/// layout.
|
/// layout.
|
||||||
pub(crate) const MRF_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal.bin";
|
pub(crate) const MRF_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal.bin";
|
||||||
|
|||||||
@@ -0,0 +1,681 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Reader-first support for owner-local MRF checkpoints.
|
||||||
|
//!
|
||||||
|
//! Each of two slots has a payload and a commit manifest. The manifest binds
|
||||||
|
//! the writer identity, persistent sequence, length and whole-payload digest.
|
||||||
|
//! Replacing the inactive slot must leave the previous committed slot intact.
|
||||||
|
//! Production publication and reclamation are deliberately not enabled here.
|
||||||
|
//! An unreadable commit path cannot prove that only legacy data exists. This
|
||||||
|
//! explicit inspection API fails closed and never mutates recovery anchors.
|
||||||
|
//! It is not wired into the legacy consumer: that transition requires the
|
||||||
|
//! ownership-aware replay and producer handoff before writer activation.
|
||||||
|
//! One surviving committed replica supports process restart recovery only;
|
||||||
|
//! this reader does not establish a replication quorum or a power-loss policy.
|
||||||
|
|
||||||
|
use super::{MRF_JOURNAL_PATH, MRF_SCOPED_JOURNAL_PATH, decode_journal};
|
||||||
|
use crate::heal::RUSTFS_META_BUCKET;
|
||||||
|
use crate::heal::storage_api::owner::{EcstoreDiskAPI, EcstoreDiskError, EcstoreDiskStore};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// Root-level control files avoid requiring a new directory before the first
|
||||||
|
// atomic commit. They remain inside the storage owner's metadata volume.
|
||||||
|
const PAYLOAD_PATHS: [&str; 2] = [".heal-mrf-snapshot.0.bin", ".heal-mrf-snapshot.1.bin"];
|
||||||
|
const MANIFEST_PATHS: [&str; 2] = [".heal-mrf-commit.0.bin", ".heal-mrf-commit.1.bin"];
|
||||||
|
const MAGIC: &[u8; 8] = b"RFMRFC01";
|
||||||
|
const MANIFEST_LEN: usize = 8 + 1 + 16 + 8 + 8 + 32 + 32;
|
||||||
|
const VERSION: u8 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SnapshotError {
|
||||||
|
#[error("MRF checkpoint has an invalid or incomplete commit record")]
|
||||||
|
Corrupt,
|
||||||
|
#[error("MRF checkpoint format is unsupported")]
|
||||||
|
Unsupported,
|
||||||
|
#[error("MRF checkpoint exceeds the configured byte limit")]
|
||||||
|
TooLarge,
|
||||||
|
#[error("MRF checkpoint replicas disagree at the same sequence")]
|
||||||
|
Conflict,
|
||||||
|
#[error("MRF checkpoint storage is unavailable")]
|
||||||
|
Disk(#[source] EcstoreDiskError),
|
||||||
|
#[error("MRF checkpoint body could not be read")]
|
||||||
|
Read(#[source] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
struct Manifest {
|
||||||
|
owner: Uuid,
|
||||||
|
sequence: u64,
|
||||||
|
payload_len: usize,
|
||||||
|
payload_digest: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Manifest {
|
||||||
|
fn decode(bytes: &[u8], limit: usize) -> Result<Self, SnapshotError> {
|
||||||
|
if bytes.len() != MANIFEST_LEN || &bytes[..8] != MAGIC {
|
||||||
|
return Err(SnapshotError::Corrupt);
|
||||||
|
}
|
||||||
|
if bytes[8] != VERSION {
|
||||||
|
return Err(SnapshotError::Unsupported);
|
||||||
|
}
|
||||||
|
let signed = MANIFEST_LEN - 32;
|
||||||
|
let checksum: [u8; 32] = Sha256::digest(&bytes[..signed]).into();
|
||||||
|
if checksum != bytes[signed..] {
|
||||||
|
return Err(SnapshotError::Corrupt);
|
||||||
|
}
|
||||||
|
let owner = Uuid::from_slice(&bytes[9..25]).map_err(|_| SnapshotError::Corrupt)?;
|
||||||
|
let sequence = u64::from_le_bytes(bytes[25..33].try_into().map_err(|_| SnapshotError::Corrupt)?);
|
||||||
|
let payload_len = u64::from_le_bytes(bytes[33..41].try_into().map_err(|_| SnapshotError::Corrupt)?);
|
||||||
|
let payload_len = usize::try_from(payload_len).map_err(|_| SnapshotError::TooLarge)?;
|
||||||
|
if owner.is_nil() || sequence == 0 || sequence == u64::MAX {
|
||||||
|
return Err(SnapshotError::Corrupt);
|
||||||
|
}
|
||||||
|
if payload_len > limit {
|
||||||
|
return Err(SnapshotError::TooLarge);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
owner,
|
||||||
|
sequence,
|
||||||
|
payload_len,
|
||||||
|
payload_digest: bytes[41..73].try_into().map_err(|_| SnapshotError::Corrupt)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CommittedSnapshot {
|
||||||
|
manifest: Manifest,
|
||||||
|
payload: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommittedSnapshot {
|
||||||
|
/// Persistent single-writer sequence, not a process UUID ordering.
|
||||||
|
pub fn sequence(&self) -> u64 {
|
||||||
|
self.manifest.sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity recorded by the committed checkpoint's writer.
|
||||||
|
pub fn owner(&self) -> Uuid {
|
||||||
|
self.manifest.owner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete, checksum-validated record bytes. Inspection does not consume
|
||||||
|
/// these records or acknowledge completion to any producer.
|
||||||
|
pub fn payload(&self) -> &[u8] {
|
||||||
|
&self.payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(manifest: &[u8], payload: Vec<u8>, limit: usize) -> Result<Self, SnapshotError> {
|
||||||
|
let manifest = Manifest::decode(manifest, limit)?;
|
||||||
|
let checksum: [u8; 32] = Sha256::digest(&payload).into();
|
||||||
|
if payload.len() != manifest.payload_len || checksum != manifest.payload_digest {
|
||||||
|
return Err(SnapshotError::Corrupt);
|
||||||
|
}
|
||||||
|
if decode_journal(&payload).1 != 0 {
|
||||||
|
return Err(SnapshotError::Corrupt);
|
||||||
|
}
|
||||||
|
Ok(Self { manifest, payload })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum RecoverySnapshot {
|
||||||
|
/// An intact legacy snapshot, without a comparable commit sequence.
|
||||||
|
Legacy(Vec<u8>),
|
||||||
|
/// A committed checkpoint requiring ownership-aware replay before use.
|
||||||
|
Committed(CommittedSnapshot),
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_bounded(disk: &EcstoreDiskStore, path: &str, limit: usize) -> Result<Option<Vec<u8>>, SnapshotError> {
|
||||||
|
let reader = match EcstoreDiskAPI::read_file(disk.as_ref(), RUSTFS_META_BUCKET, path).await {
|
||||||
|
Ok(reader) => reader,
|
||||||
|
Err(EcstoreDiskError::FileNotFound | EcstoreDiskError::VolumeNotFound) => return Ok(None),
|
||||||
|
Err(error) => return Err(SnapshotError::Disk(error)),
|
||||||
|
};
|
||||||
|
let maximum = limit.checked_add(1).ok_or(SnapshotError::TooLarge)?;
|
||||||
|
let maximum = u64::try_from(maximum).map_err(|_| SnapshotError::TooLarge)?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
reader
|
||||||
|
.take(maximum)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.await
|
||||||
|
.map_err(SnapshotError::Read)?;
|
||||||
|
if bytes.len() > limit {
|
||||||
|
return Err(SnapshotError::TooLarge);
|
||||||
|
}
|
||||||
|
Ok(Some(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_snapshot(selected: &mut Option<CommittedSnapshot>, candidate: CommittedSnapshot) -> Result<(), SnapshotError> {
|
||||||
|
if let Some(current) = selected {
|
||||||
|
if current.manifest.sequence == candidate.manifest.sequence
|
||||||
|
&& (current.manifest != candidate.manifest || current.payload != candidate.payload)
|
||||||
|
{
|
||||||
|
return Err(SnapshotError::Conflict);
|
||||||
|
}
|
||||||
|
if current.manifest.sequence >= candidate.manifest.sequence {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*selected = Some(candidate);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_committed(disks: &[EcstoreDiskStore], limit: usize) -> Result<Option<CommittedSnapshot>, SnapshotError> {
|
||||||
|
let mut selected = None;
|
||||||
|
let mut damaged = None;
|
||||||
|
let mut identities = HashMap::new();
|
||||||
|
for disk in disks {
|
||||||
|
for (manifest_path, payload_path) in MANIFEST_PATHS.into_iter().zip(PAYLOAD_PATHS) {
|
||||||
|
let candidate = async {
|
||||||
|
let Some(manifest) = read_bounded(disk, manifest_path, MANIFEST_LEN).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let header = Manifest::decode(&manifest, limit)?;
|
||||||
|
let payload = read_bounded(disk, payload_path, header.payload_len)
|
||||||
|
.await?
|
||||||
|
.ok_or(SnapshotError::Corrupt)?;
|
||||||
|
CommittedSnapshot::decode(&manifest, payload, limit).map(Some)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match candidate {
|
||||||
|
Ok(Some(candidate)) => {
|
||||||
|
let identity = (
|
||||||
|
candidate.manifest.owner,
|
||||||
|
candidate.manifest.payload_len,
|
||||||
|
candidate.manifest.payload_digest,
|
||||||
|
);
|
||||||
|
if identities
|
||||||
|
.insert(candidate.manifest.sequence, identity)
|
||||||
|
.is_some_and(|previous| previous != identity)
|
||||||
|
{
|
||||||
|
return Err(SnapshotError::Conflict);
|
||||||
|
}
|
||||||
|
select_snapshot(&mut selected, candidate)?;
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
// A future committed format may supersede all readable slots.
|
||||||
|
Err(SnapshotError::Unsupported) => return Err(SnapshotError::Unsupported),
|
||||||
|
Err(error) => damaged = Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match (selected, damaged) {
|
||||||
|
(Some(snapshot), _) => Ok(Some(snapshot)),
|
||||||
|
(None, Some(error)) => Err(error),
|
||||||
|
(None, None) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_legacy(disks: &[EcstoreDiskStore], path: &str, limit: usize) -> Result<Option<Vec<u8>>, SnapshotError> {
|
||||||
|
let mut selected = None;
|
||||||
|
let mut incomplete: Option<Vec<u8>> = None;
|
||||||
|
for disk in disks {
|
||||||
|
match read_bounded(disk, path, limit).await {
|
||||||
|
Ok(Some(payload)) if decode_journal(&payload).1 == 0 => {
|
||||||
|
if selected.as_ref().is_some_and(|current| *current != payload) {
|
||||||
|
// Legacy snapshots have no sequence. There is no evidence
|
||||||
|
// that the first, longest or nonempty replica is newest.
|
||||||
|
return Err(SnapshotError::Conflict);
|
||||||
|
}
|
||||||
|
selected = Some(payload);
|
||||||
|
}
|
||||||
|
Ok(Some(payload)) => {
|
||||||
|
if let Some(previous) = &incomplete {
|
||||||
|
if previous.starts_with(&payload) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !payload.starts_with(previous) {
|
||||||
|
return Err(SnapshotError::Corrupt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
incomplete = Some(payload);
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(prefix) = incomplete
|
||||||
|
&& !selected.as_ref().is_some_and(|payload| payload.starts_with(&prefix))
|
||||||
|
{
|
||||||
|
// In particular, an empty O_TRUNC replica cannot supersede another
|
||||||
|
// replica containing intact records followed by a torn tail.
|
||||||
|
return Err(SnapshotError::Corrupt);
|
||||||
|
}
|
||||||
|
Ok(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspect local MRF checkpoints without replaying, acknowledging or deleting.
|
||||||
|
///
|
||||||
|
/// `max_bytes` bounds each payload read. Every local replica is examined and
|
||||||
|
/// ambiguous identities, unavailable proof or unsupported formats return a
|
||||||
|
/// typed error. This API must not authorize a writer without the separate
|
||||||
|
/// ownership and mixed-version activation checks.
|
||||||
|
pub async fn inspect_local_recovery_snapshot(max_bytes: usize) -> Result<Option<RecoverySnapshot>, SnapshotError> {
|
||||||
|
read_recovery_snapshot(&super::journal_disks().await, max_bytes).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_recovery_snapshot(disks: &[EcstoreDiskStore], limit: usize) -> Result<Option<RecoverySnapshot>, SnapshotError> {
|
||||||
|
if let Some(snapshot) = read_committed(disks, limit).await? {
|
||||||
|
return Ok(Some(RecoverySnapshot::Committed(snapshot)));
|
||||||
|
}
|
||||||
|
// RUSTFS_COMPAT_TODO(backlog-2263): inspect retained legacy MRF journals. Remove after all supported upgrade and rollback readers understand committed snapshots and retained journals have migrated.
|
||||||
|
if let Some(payload) = read_legacy(disks, MRF_SCOPED_JOURNAL_PATH, limit).await? {
|
||||||
|
return Ok(Some(RecoverySnapshot::Legacy(payload)));
|
||||||
|
}
|
||||||
|
Ok(read_legacy(disks, MRF_JOURNAL_PATH, limit)
|
||||||
|
.await?
|
||||||
|
.map(RecoverySnapshot::Legacy))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::heal::mrf_queue::encode_intent;
|
||||||
|
use crate::heal::storage_api::owner::{EcstoreConditionalFileUpdate, EcstoreDiskBytes};
|
||||||
|
use crate::heal::{DiskOption, Endpoint, new_disk};
|
||||||
|
use rustfs_common::mrf_channel::{MrfIntent, MrfKind, MrfScope};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn payload(object: &str) -> Vec<u8> {
|
||||||
|
let intent = MrfIntent {
|
||||||
|
bucket: Arc::from("bucket"),
|
||||||
|
object: Arc::from(object),
|
||||||
|
version_id: None,
|
||||||
|
kind: MrfKind::PartialWrite,
|
||||||
|
scope: None,
|
||||||
|
lease: None,
|
||||||
|
enqueued_at_ms: 1234,
|
||||||
|
attempts: 0,
|
||||||
|
};
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
assert!(encode_intent(&intent, &mut bytes), "fixture must encode a full record");
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest(owner: Uuid, sequence: u64, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut bytes = Vec::with_capacity(MANIFEST_LEN);
|
||||||
|
bytes.extend_from_slice(MAGIC);
|
||||||
|
bytes.push(VERSION);
|
||||||
|
bytes.extend_from_slice(owner.as_bytes());
|
||||||
|
bytes.extend_from_slice(&sequence.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&u64::try_from(payload.len()).expect("fixture length fits").to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&Sha256::digest(payload));
|
||||||
|
bytes.extend_from_slice(&Sha256::digest(&bytes));
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn disk(root: &TempDir, name: &str) -> EcstoreDiskStore {
|
||||||
|
let path = root.path().join(name);
|
||||||
|
std::fs::create_dir_all(&path).expect("create disk directory");
|
||||||
|
let endpoint = Endpoint::try_from(path.to_string_lossy().as_ref()).expect("valid disk endpoint");
|
||||||
|
let disk = new_disk(
|
||||||
|
&endpoint,
|
||||||
|
&DiskOption {
|
||||||
|
cleanup: false,
|
||||||
|
health_check: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("open disk");
|
||||||
|
let result = EcstoreDiskAPI::make_volume(disk.as_ref(), RUSTFS_META_BUCKET).await;
|
||||||
|
assert!(
|
||||||
|
matches!(result, Ok(()) | Err(EcstoreDiskError::VolumeExists)),
|
||||||
|
"metadata volume: {result:?}"
|
||||||
|
);
|
||||||
|
disk
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise the existing storage owner's atomic CAS primitive. No production
|
||||||
|
// caller publishes this format until ownership-aware replay is available.
|
||||||
|
async fn install(disk: &EcstoreDiskStore, path: &str, bytes: &[u8]) {
|
||||||
|
let expected = EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, path).await.ok();
|
||||||
|
let result = EcstoreDiskAPI::compare_and_update_file(
|
||||||
|
disk.as_ref(),
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
path,
|
||||||
|
expected,
|
||||||
|
Some(EcstoreDiskBytes::copy_from_slice(bytes)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("atomic snapshot slot write");
|
||||||
|
assert_eq!(result, EcstoreConditionalFileUpdate::Updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit(disk: &EcstoreDiskStore, slot: usize, owner: Uuid, sequence: u64, bytes: &[u8]) {
|
||||||
|
install(disk, PAYLOAD_PATHS[slot], bytes).await;
|
||||||
|
install(disk, MANIFEST_PATHS[slot], &manifest(owner, sequence, bytes)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_validates_identity_sequence_length_and_digest() {
|
||||||
|
let bytes = payload("object");
|
||||||
|
let owner = Uuid::new_v4();
|
||||||
|
assert!(CommittedSnapshot::decode(&manifest(owner, 1, &bytes), bytes.clone(), bytes.len()).is_ok());
|
||||||
|
for (owner, sequence) in [(Uuid::nil(), 1), (owner, 0), (owner, u64::MAX)] {
|
||||||
|
assert!(matches!(
|
||||||
|
Manifest::decode(&manifest(owner, sequence, &bytes), bytes.len()),
|
||||||
|
Err(SnapshotError::Corrupt)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
Manifest::decode(&manifest(owner, 1, &bytes), bytes.len() - 1),
|
||||||
|
Err(SnapshotError::TooLarge)
|
||||||
|
));
|
||||||
|
let mut corrupt = manifest(owner, 1, &bytes);
|
||||||
|
corrupt[25] ^= 1;
|
||||||
|
assert!(matches!(Manifest::decode(&corrupt, bytes.len()), Err(SnapshotError::Corrupt)));
|
||||||
|
let mut unsupported = manifest(owner, 1, &bytes);
|
||||||
|
unsupported[8] = 2;
|
||||||
|
assert!(matches!(Manifest::decode(&unsupported, bytes.len()), Err(SnapshotError::Unsupported)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whole_payload_integrity_is_required_even_with_a_valid_manifest() {
|
||||||
|
let bytes = payload("object");
|
||||||
|
let owner = Uuid::new_v4();
|
||||||
|
let header = manifest(owner, 1, &bytes);
|
||||||
|
assert!(matches!(
|
||||||
|
CommittedSnapshot::decode(&header, bytes[..bytes.len() - 1].to_vec(), bytes.len()),
|
||||||
|
Err(SnapshotError::Corrupt)
|
||||||
|
));
|
||||||
|
let invalid = b"not an MRF record".to_vec();
|
||||||
|
assert!(matches!(
|
||||||
|
CommittedSnapshot::decode(&manifest(owner, 2, &invalid), invalid, bytes.len()),
|
||||||
|
Err(SnapshotError::Corrupt)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn newest_complete_replica_wins_in_both_disk_orders() {
|
||||||
|
let root = TempDir::new().expect("test directory");
|
||||||
|
let first = disk(&root, "first").await;
|
||||||
|
let second = disk(&root, "second").await;
|
||||||
|
let owner = Uuid::new_v4();
|
||||||
|
commit(&first, 0, owner, 1, &payload("old")).await;
|
||||||
|
commit(&second, 1, owner, 2, &payload("new")).await;
|
||||||
|
for disks in [vec![first.clone(), second.clone()], vec![second.clone(), first.clone()]] {
|
||||||
|
let recovered = read_committed(&disks, 4096)
|
||||||
|
.await
|
||||||
|
.expect("read replicas")
|
||||||
|
.expect("committed snapshot");
|
||||||
|
assert_eq!(recovered.manifest.sequence, 2);
|
||||||
|
assert_eq!(recovered.payload, payload("new"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn divergent_commits_at_same_sequence_fail_closed() {
|
||||||
|
let root = TempDir::new().expect("test directory");
|
||||||
|
let first = disk(&root, "first").await;
|
||||||
|
let second = disk(&root, "second").await;
|
||||||
|
let owner = Uuid::new_v4();
|
||||||
|
commit(&first, 0, owner, 7, &payload("a")).await;
|
||||||
|
commit(&second, 1, owner, 7, &payload("b")).await;
|
||||||
|
assert!(matches!(read_committed(&[first, second], 4096).await, Err(SnapshotError::Conflict)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn newer_slot_does_not_hide_a_conflicting_commit_history() {
|
||||||
|
let root = TempDir::new().expect("test directory");
|
||||||
|
let first = disk(&root, "first").await;
|
||||||
|
let second = disk(&root, "second").await;
|
||||||
|
let owner = Uuid::new_v4();
|
||||||
|
commit(&first, 0, owner, 8, &payload("newest")).await;
|
||||||
|
commit(&first, 1, owner, 7, &payload("a")).await;
|
||||||
|
commit(&second, 1, owner, 7, &payload("b")).await;
|
||||||
|
assert!(matches!(read_committed(&[first, second], 4096).await, Err(SnapshotError::Conflict)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn uncommitted_or_torn_successor_preserves_previous_slot() {
|
||||||
|
let root = TempDir::new().expect("test directory");
|
||||||
|
let disk = disk(&root, "disk").await;
|
||||||
|
let owner = Uuid::new_v4();
|
||||||
|
let old = payload("old");
|
||||||
|
let next = payload("next");
|
||||||
|
commit(&disk, 0, owner, 1, &old).await;
|
||||||
|
install(&disk, PAYLOAD_PATHS[1], &next).await;
|
||||||
|
let recovered = read_committed(std::slice::from_ref(&disk), 4096)
|
||||||
|
.await
|
||||||
|
.expect("staged payload is not a commit")
|
||||||
|
.expect("old snapshot");
|
||||||
|
assert_eq!(recovered.payload, old);
|
||||||
|
install(&disk, MANIFEST_PATHS[1], &manifest(owner, 2, &next)[..20]).await;
|
||||||
|
let recovered = read_committed(std::slice::from_ref(&disk), 4096)
|
||||||
|
.await
|
||||||
|
.expect("torn manifest preserves old slot")
|
||||||
|
.expect("old snapshot");
|
||||||
|
assert_eq!(recovered.manifest.sequence, 1);
|
||||||
|
install(&disk, MANIFEST_PATHS[1], &manifest(owner, 2, &next)).await;
|
||||||
|
install(&disk, PAYLOAD_PATHS[1], b"torn").await;
|
||||||
|
let recovered = read_committed(&[disk], 4096)
|
||||||
|
.await
|
||||||
|
.expect("torn payload preserves old slot")
|
||||||
|
.expect("old snapshot");
|
||||||
|
assert_eq!(recovered.manifest.sequence, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_manifest_cas_cannot_replace_committed_anchor() {
|
||||||
|
let root = TempDir::new().expect("test directory");
|
||||||
|
let disk = disk(&root, "disk").await;
|
||||||
|
let owner = Uuid::new_v4();
|
||||||
|
let bytes = payload("object");
|
||||||
|
commit(&disk, 0, owner, 1, &bytes).await;
|
||||||
|
let result = EcstoreDiskAPI::compare_and_update_file(
|
||||||
|
disk.as_ref(),
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
MANIFEST_PATHS[0],
|
||||||
|
None,
|
||||||
|
Some(manifest(owner, 2, &bytes).into()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("CAS call");
|
||||||
|
assert_eq!(result, EcstoreConditionalFileUpdate::Mismatch);
|
||||||
|
let recovered = read_committed(&[disk], 4096)
|
||||||
|
.await
|
||||||
|
.expect("read old anchor")
|
||||||
|
.expect("snapshot");
|
||||||
|
assert_eq!(recovered.manifest.sequence, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_import_requires_complete_consistent_replicas() {
|
||||||
|
let root = TempDir::new().expect("test directory");
|
||||||
|
let first = disk(&root, "first").await;
|
||||||
|
let second = disk(&root, "second").await;
|
||||||
|
let bytes = payload("object");
|
||||||
|
for (disk, data) in [(&first, &bytes[..bytes.len() - 1]), (&second, bytes.as_slice())] {
|
||||||
|
EcstoreDiskAPI::write_all(
|
||||||
|
disk.as_ref(),
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
MRF_SCOPED_JOURNAL_PATH,
|
||||||
|
EcstoreDiskBytes::copy_from_slice(data),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("legacy fixture");
|
||||||
|
}
|
||||||
|
let disks = [first.clone(), second];
|
||||||
|
assert!(
|
||||||
|
matches!(read_recovery_snapshot(&disks, 4096).await.expect("intact legacy replica"), Some(RecoverySnapshot::Legacy(data)) if data == bytes)
|
||||||
|
);
|
||||||
|
EcstoreDiskAPI::write_all(first.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, payload("different").into())
|
||||||
|
.await
|
||||||
|
.expect("divergent fixture");
|
||||||
|
assert!(matches!(read_recovery_snapshot(&disks, 4096).await, Err(SnapshotError::Conflict)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn committed_inspection_leaves_payload_and_manifest_unchanged() {
|
||||||
|
let root = TempDir::new().expect("test directory");
|
||||||
|
let disk = disk(&root, "disk").await;
|
||||||
|
let owner = Uuid::new_v4();
|
||||||
|
let bytes = payload("object");
|
||||||
|
commit(&disk, 0, owner, 3, &bytes).await;
|
||||||
|
assert!(matches!(
|
||||||
|
read_recovery_snapshot(std::slice::from_ref(&disk), 4096)
|
||||||
|
.await
|
||||||
|
.expect("new snapshot"),
|
||||||
|
Some(RecoverySnapshot::Committed(_))
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[0])
|
||||||
|
.await
|
||||||
|
.expect("manifest retained")
|
||||||
|
.as_ref(),
|
||||||
|
manifest(owner, 3, &bytes)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[0])
|
||||||
|
.await
|
||||||
|
.expect("payload retained")
|
||||||
|
.as_ref(),
|
||||||
|
bytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_inspection_rejects_complete_subsets_and_scope_ambiguity() {
|
||||||
|
let scoped = |set_index| {
|
||||||
|
let intent = MrfIntent {
|
||||||
|
bucket: Arc::from("bucket"),
|
||||||
|
object: Arc::from("a"),
|
||||||
|
version_id: None,
|
||||||
|
kind: MrfKind::PartialWrite,
|
||||||
|
scope: Some(MrfScope {
|
||||||
|
pool_index: 0,
|
||||||
|
set_index,
|
||||||
|
}),
|
||||||
|
lease: None,
|
||||||
|
enqueued_at_ms: 1234,
|
||||||
|
attempts: 0,
|
||||||
|
};
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
assert!(encode_intent(&intent, &mut bytes), "scoped fixture must encode");
|
||||||
|
bytes
|
||||||
|
};
|
||||||
|
let mut superset = payload("a");
|
||||||
|
superset.extend_from_slice(&payload("b"));
|
||||||
|
for (case, first_bytes, second_bytes) in [
|
||||||
|
("complete-subset", payload("a"), superset),
|
||||||
|
("different-set", scoped(1), scoped(2)),
|
||||||
|
("unknown-scope", payload("a"), scoped(1)),
|
||||||
|
] {
|
||||||
|
let root = TempDir::new().expect("test directory");
|
||||||
|
let first = disk(&root, "first").await;
|
||||||
|
let second = disk(&root, "second").await;
|
||||||
|
for (disk, bytes) in [(&first, &first_bytes), (&second, &second_bytes)] {
|
||||||
|
assert_eq!(decode_journal(bytes).1, 0, "{case}: complete fixture");
|
||||||
|
EcstoreDiskAPI::write_all(
|
||||||
|
disk.as_ref(),
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
MRF_SCOPED_JOURNAL_PATH,
|
||||||
|
EcstoreDiskBytes::copy_from_slice(bytes),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("write legacy replica");
|
||||||
|
}
|
||||||
|
for disks in [vec![first.clone(), second.clone()], vec![second.clone(), first.clone()]] {
|
||||||
|
assert!(
|
||||||
|
matches!(read_recovery_snapshot(&disks, 4096).await, Err(SnapshotError::Conflict)),
|
||||||
|
"{case}: neither replica order proves a latest snapshot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (disk, bytes) in [(&first, &first_bytes), (&second, &second_bytes)] {
|
||||||
|
assert_eq!(
|
||||||
|
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
|
||||||
|
.await
|
||||||
|
.expect("legacy evidence retained")
|
||||||
|
.as_ref(),
|
||||||
|
bytes.as_slice(),
|
||||||
|
"{case}: inspection must preserve both source replicas"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oversized_or_corrupt_scoped_snapshot_never_falls_back_to_legacy() {
|
||||||
|
let root = TempDir::new().expect("test directory");
|
||||||
|
let disk = disk(&root, "disk").await;
|
||||||
|
EcstoreDiskAPI::write_all(disk.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, vec![0; 1025].into())
|
||||||
|
.await
|
||||||
|
.expect("oversized fixture");
|
||||||
|
EcstoreDiskAPI::write_all(disk.as_ref(), RUSTFS_META_BUCKET, MRF_JOURNAL_PATH, payload("old").into())
|
||||||
|
.await
|
||||||
|
.expect("legacy fixture");
|
||||||
|
assert!(matches!(
|
||||||
|
read_recovery_snapshot(std::slice::from_ref(&disk), 1024).await,
|
||||||
|
Err(SnapshotError::TooLarge)
|
||||||
|
));
|
||||||
|
assert!(matches!(read_recovery_snapshot(&[disk], 2048).await, Err(SnapshotError::Corrupt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_legacy_replica_cannot_erase_records_in_a_torn_replica() {
|
||||||
|
let root = TempDir::new().expect("test directory");
|
||||||
|
let first = disk(&root, "first").await;
|
||||||
|
let second = disk(&root, "second").await;
|
||||||
|
let mut incomplete = payload("durable-object");
|
||||||
|
incomplete.extend_from_slice(b"torn");
|
||||||
|
EcstoreDiskAPI::write_all(first.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, Vec::new().into())
|
||||||
|
.await
|
||||||
|
.expect("empty truncated replica");
|
||||||
|
EcstoreDiskAPI::write_all(second.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, incomplete.clone().into())
|
||||||
|
.await
|
||||||
|
.expect("records and torn tail");
|
||||||
|
for disks in [vec![first.clone(), second.clone()], vec![second.clone(), first.clone()]] {
|
||||||
|
assert!(matches!(read_recovery_snapshot(&disks, 4096).await, Err(SnapshotError::Corrupt)));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
EcstoreDiskAPI::read_all(second.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
|
||||||
|
.await
|
||||||
|
.expect("recovery anchor preserved")
|
||||||
|
.as_ref(),
|
||||||
|
incomplete
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unreadable_commit_record_never_implies_legacy_only() {
|
||||||
|
let root = TempDir::new().expect("test directory");
|
||||||
|
let disk = disk(&root, "disk").await;
|
||||||
|
let legacy = payload("old");
|
||||||
|
EcstoreDiskAPI::write_all(disk.as_ref(), RUSTFS_META_BUCKET, MRF_JOURNAL_PATH, legacy.clone().into())
|
||||||
|
.await
|
||||||
|
.expect("legacy fixture");
|
||||||
|
// Opening a directory as a record either fails at open or at read,
|
||||||
|
// depending on the platform. Neither outcome proves absence.
|
||||||
|
std::fs::create_dir(root.path().join("disk").join(RUSTFS_META_BUCKET).join(MANIFEST_PATHS[0]))
|
||||||
|
.expect("unreadable manifest fixture");
|
||||||
|
let recovered = read_recovery_snapshot(std::slice::from_ref(&disk), 4096).await;
|
||||||
|
assert!(
|
||||||
|
matches!(recovered, Err(SnapshotError::Disk(_) | SnapshotError::Read(_))),
|
||||||
|
"must preserve unavailable proof: {recovered:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, MRF_JOURNAL_PATH)
|
||||||
|
.await
|
||||||
|
.expect("legacy remains")
|
||||||
|
.as_ref(),
|
||||||
|
legacy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,9 @@ use walkdir::WalkDir;
|
|||||||
|
|
||||||
mod storage_api;
|
mod storage_api;
|
||||||
|
|
||||||
use storage_api::integration::{BucketOperations, ECStore, MakeBucketOptions, ObjectIO as _, ObjectOperations as _};
|
use storage_api::integration::{
|
||||||
|
BucketOperations, ECStore, MakeBucketOptions, NamespaceLocking as _, ObjectIO as _, ObjectOperations as _,
|
||||||
|
};
|
||||||
|
|
||||||
/// 256 KiB + change: large enough to be stored as non-inline erasure shards
|
/// 256 KiB + change: large enough to be stored as non-inline erasure shards
|
||||||
/// (so each data version materializes as an on-disk `part.*` file we can assert
|
/// (so each data version materializes as an on-disk `part.*` file we can assert
|
||||||
@@ -106,6 +108,7 @@ async fn put_versioned(ecstore: &Arc<ECStore>, bucket: &str, object: &str, data:
|
|||||||
.put_object(bucket, object, &mut reader, &opts)
|
.put_object(bucket, object, &mut reader, &opts)
|
||||||
.await
|
.await
|
||||||
.expect("versioned put_object failed");
|
.expect("versioned put_object failed");
|
||||||
|
wait_for_put_tail(ecstore, bucket, object).await;
|
||||||
info.version_id
|
info.version_id
|
||||||
.map(|u| u.to_string())
|
.map(|u| u.to_string())
|
||||||
.expect("versioned put must return a version id")
|
.expect("versioned put must return a version id")
|
||||||
@@ -117,6 +120,7 @@ async fn put_unversioned(ecstore: &Arc<ECStore>, bucket: &str, object: &str, dat
|
|||||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||||
.await
|
.await
|
||||||
.expect("unversioned put_object failed");
|
.expect("unversioned put_object failed");
|
||||||
|
wait_for_put_tail(ecstore, bucket, object).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a delete-marker as the latest version (versioned:true, no version_id)
|
/// Create a delete-marker as the latest version (versioned:true, no version_id)
|
||||||
@@ -160,20 +164,16 @@ fn xl_meta_path(obj_dir: &Path) -> PathBuf {
|
|||||||
obj_dir.join("xl.meta")
|
obj_dir.join("xl.meta")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_two_version_copies(disks: &[PathBuf], bucket: &str, object: &str) {
|
async fn wait_for_put_tail(ecstore: &Arc<ECStore>, bucket: &str, object: &str) {
|
||||||
tokio::time::timeout(Duration::from_secs(5), async {
|
// Shards and xl.meta can exist before the detached PUT owner finishes.
|
||||||
loop {
|
let lock = ecstore
|
||||||
if disks.iter().all(|disk| {
|
.new_ns_lock(bucket, object)
|
||||||
let object_dir = object_dir(disk, bucket, object);
|
.await
|
||||||
xl_meta_path(&object_dir).exists() && count_part_files(&object_dir) >= 2
|
.expect("fixture namespace lock should be created");
|
||||||
}) {
|
let _settled = lock
|
||||||
break;
|
.get_write_lock(Duration::from_secs(30))
|
||||||
}
|
.await
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
.expect("PUT rename tail must finish before inspecting or wiping the fixture");
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("PUT rename tails must converge before wiping the versioned fixture");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recreate_heal_opts() -> HealOpts {
|
fn recreate_heal_opts() -> HealOpts {
|
||||||
@@ -305,7 +305,13 @@ mod serial_tests {
|
|||||||
let data_v2 = versioned_test_data(20);
|
let data_v2 = versioned_test_data(20);
|
||||||
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await; // OLD, non-latest
|
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await; // OLD, non-latest
|
||||||
let v2 = put_versioned(&ecstore, bucket, object, &data_v2).await; // latest
|
let v2 = put_versioned(&ecstore, bucket, object, &data_v2).await; // latest
|
||||||
wait_for_two_version_copies(&disk_paths, bucket, object).await;
|
assert!(
|
||||||
|
disk_paths.iter().all(|disk| {
|
||||||
|
let dir = object_dir(disk, bucket, object);
|
||||||
|
xl_meta_path(&dir).exists() && count_part_files(&dir) >= 2
|
||||||
|
}),
|
||||||
|
"both versions must exist on every disk before wiping the fixture"
|
||||||
|
);
|
||||||
|
|
||||||
// ── Pre-wipe: prove the fixture actually has 2 versions on disk[0] ──
|
// ── Pre-wipe: prove the fixture actually has 2 versions on disk[0] ──
|
||||||
let obj_dir0 = object_dir(&disk_paths[0], bucket, object);
|
let obj_dir0 = object_dir(&disk_paths[0], bucket, object);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub(crate) mod integration {
|
|||||||
pub(crate) use rustfs_ecstore::api::storage::ECStore;
|
pub(crate) use rustfs_ecstore::api::storage::ECStore;
|
||||||
pub(crate) use rustfs_storage_api::BucketOperations;
|
pub(crate) use rustfs_storage_api::BucketOperations;
|
||||||
pub(crate) use rustfs_storage_api::MakeBucketOptions;
|
pub(crate) use rustfs_storage_api::MakeBucketOptions;
|
||||||
|
pub(crate) use rustfs_storage_api::NamespaceLocking;
|
||||||
pub(crate) use rustfs_storage_api::ObjectIO;
|
pub(crate) use rustfs_storage_api::ObjectIO;
|
||||||
pub(crate) use rustfs_storage_api::ObjectOperations;
|
pub(crate) use rustfs_storage_api::ObjectOperations;
|
||||||
}
|
}
|
||||||
|
|||||||
+460
-44
@@ -43,6 +43,10 @@ const ERR_LIFECYCLE_BUCKET_LOCKED: &str =
|
|||||||
"ExpiredObjectAllVersions element and DelMarkerExpiration action cannot be used on an object locked bucket";
|
"ExpiredObjectAllVersions element and DelMarkerExpiration action cannot be used on an object locked bucket";
|
||||||
const ERR_LIFECYCLE_TOO_MANY_RULES: &str = "Lifecycle configuration should have at most 1000 rules";
|
const ERR_LIFECYCLE_TOO_MANY_RULES: &str = "Lifecycle configuration should have at most 1000 rules";
|
||||||
const ERR_LIFECYCLE_INVALID_EXPIRATION_DAYS: &str = "'Days' for Expiration action must be a positive integer";
|
const ERR_LIFECYCLE_INVALID_EXPIRATION_DAYS: &str = "'Days' for Expiration action must be a positive integer";
|
||||||
|
const ERR_LIFECYCLE_EXPIRATION_DAYS_DATE_CONFLICT: &str = "Expiration cannot specify both Days and Date";
|
||||||
|
const ERR_LIFECYCLE_MULTIPLE_TRANSITIONS: &str = "Only one Transition action per lifecycle rule is supported";
|
||||||
|
const ERR_LIFECYCLE_MULTIPLE_NONCURRENT_TRANSITIONS: &str =
|
||||||
|
"Only one NoncurrentVersionTransition action per lifecycle rule is supported";
|
||||||
const ERR_LIFECYCLE_INVALID_NONCURRENT_EXPIRATION_DAYS: &str =
|
const ERR_LIFECYCLE_INVALID_NONCURRENT_EXPIRATION_DAYS: &str =
|
||||||
"'NoncurrentDays' for NoncurrentVersionExpiration action must be a positive integer";
|
"'NoncurrentDays' for NoncurrentVersionExpiration action must be a positive integer";
|
||||||
const ERR_LIFECYCLE_INVALID_ABORT_INCOMPLETE_MPU_DAYS: &str =
|
const ERR_LIFECYCLE_INVALID_ABORT_INCOMPLETE_MPU_DAYS: &str =
|
||||||
@@ -361,6 +365,12 @@ impl Lifecycle for BucketLifecycleConfiguration {
|
|||||||
{
|
{
|
||||||
return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_EXPIRED_OBJECT_ALL_VERSIONS));
|
return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_EXPIRED_OBJECT_ALL_VERSIONS));
|
||||||
}
|
}
|
||||||
|
if expiration.days.is_some() && expiration.date.is_some() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
ERR_LIFECYCLE_EXPIRATION_DAYS_DATE_CONFLICT,
|
||||||
|
));
|
||||||
|
}
|
||||||
if let Some(expiration_date) = &expiration.date {
|
if let Some(expiration_date) = &expiration.date {
|
||||||
let date = OffsetDateTime::from(expiration_date.clone());
|
let date = OffsetDateTime::from(expiration_date.clone());
|
||||||
if date.hour() != 0 || date.minute() != 0 || date.second() != 0 || date.nanosecond() != 0 {
|
if date.hour() != 0 || date.minute() != 0 || date.second() != 0 || date.nanosecond() != 0 {
|
||||||
@@ -394,11 +404,20 @@ impl Lifecycle for BucketLifecycleConfiguration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(transitions) = &r.transitions {
|
if let Some(transitions) = &r.transitions {
|
||||||
|
if transitions.len() > 1 {
|
||||||
|
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, ERR_LIFECYCLE_MULTIPLE_TRANSITIONS));
|
||||||
|
}
|
||||||
for transition in transitions {
|
for transition in transitions {
|
||||||
TransitionOps::validate(transition)?;
|
TransitionOps::validate(transition)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(noncurrent_transitions) = &r.noncurrent_version_transitions {
|
if let Some(noncurrent_transitions) = &r.noncurrent_version_transitions {
|
||||||
|
if noncurrent_transitions.len() > 1 {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
ERR_LIFECYCLE_MULTIPLE_NONCURRENT_TRANSITIONS,
|
||||||
|
));
|
||||||
|
}
|
||||||
for transition in noncurrent_transitions {
|
for transition in noncurrent_transitions {
|
||||||
NoncurrentVersionTransitionOps::validate(transition)?;
|
NoncurrentVersionTransitionOps::validate(transition)?;
|
||||||
}
|
}
|
||||||
@@ -473,6 +492,8 @@ impl Lifecycle for BucketLifecycleConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn eval(&self, obj: &ObjectOpts) -> Event {
|
async fn eval(&self, obj: &ObjectOpts) -> Event {
|
||||||
|
// A single-object lookup cannot prove how many newer historical versions
|
||||||
|
// survive. Count-dependent actions wait for the complete-group evaluator.
|
||||||
self.eval_inner(obj, OffsetDateTime::now_utc(), 0).await
|
self.eval_inner(obj, OffsetDateTime::now_utc(), 0).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,23 +557,8 @@ impl Lifecycle for BucketLifecycleConfiguration {
|
|||||||
return Event::default();
|
return Event::default();
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(restore_expires) = obj.restore_expires
|
if let Some(event) = obj.restored_copy_expiry(now) {
|
||||||
&& restore_expires.unix_timestamp() != 0
|
events.push(event);
|
||||||
&& now.unix_timestamp() > restore_expires.unix_timestamp()
|
|
||||||
{
|
|
||||||
let mut action = IlmAction::DeleteRestoredAction;
|
|
||||||
if !obj.is_latest {
|
|
||||||
action = IlmAction::DeleteRestoredVersionAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
events.push(Event {
|
|
||||||
action,
|
|
||||||
due: Some(now),
|
|
||||||
rule_id: "".into(),
|
|
||||||
noncurrent_days: 0,
|
|
||||||
newer_noncurrent_versions: 0,
|
|
||||||
storage_class: "".into(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref lc_rules) = self.filter_rules(obj).await {
|
if let Some(ref lc_rules) = self.filter_rules(obj).await {
|
||||||
@@ -611,17 +617,12 @@ impl Lifecycle for BucketLifecycleConfiguration {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !obj.is_latest
|
|
||||||
&& let Some(ref noncurrent_version_expiration) = rule.noncurrent_version_expiration
|
|
||||||
&& let Some(retain_newer_noncurrent_versions) = noncurrent_version_expiration.newer_noncurrent_versions
|
|
||||||
&& newer_noncurrent_versions < usize::try_from(retain_newer_noncurrent_versions).unwrap_or(usize::MAX)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !obj.is_latest
|
if !obj.is_latest
|
||||||
&& let Some(ref noncurrent_version_expiration) = rule.noncurrent_version_expiration
|
&& let Some(ref noncurrent_version_expiration) = rule.noncurrent_version_expiration
|
||||||
&& let Some(noncurrent_days) = noncurrent_version_expiration.noncurrent_days
|
&& let Some(noncurrent_days) = noncurrent_version_expiration.noncurrent_days
|
||||||
|
&& noncurrent_version_expiration
|
||||||
|
.newer_noncurrent_versions
|
||||||
|
.is_none_or(|retain| usize::try_from(retain).is_ok_and(|retain| newer_noncurrent_versions >= retain))
|
||||||
{
|
{
|
||||||
if let Some(successor_mod_time) = obj.successor_mod_time {
|
if let Some(successor_mod_time) = obj.successor_mod_time {
|
||||||
let expected_expiry = expected_expiry_time(successor_mod_time, noncurrent_days);
|
let expected_expiry = expected_expiry_time(successor_mod_time, noncurrent_days);
|
||||||
@@ -651,7 +652,11 @@ impl Lifecycle for BucketLifecycleConfiguration {
|
|||||||
&& let Some(noncurrent_version_transition) = rule
|
&& let Some(noncurrent_version_transition) = rule
|
||||||
.noncurrent_version_transitions
|
.noncurrent_version_transitions
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
.filter(|transitions| transitions.len() == 1)
|
||||||
.and_then(|transitions| transitions.first())
|
.and_then(|transitions| transitions.first())
|
||||||
|
&& noncurrent_version_transition
|
||||||
|
.newer_noncurrent_versions
|
||||||
|
.is_none_or(|retain| usize::try_from(retain).is_ok_and(|retain| newer_noncurrent_versions >= retain))
|
||||||
&& let Some(storage_class) = noncurrent_version_transition.storage_class.as_ref()
|
&& let Some(storage_class) = noncurrent_version_transition.storage_class.as_ref()
|
||||||
&& !storage_class.as_str().is_empty()
|
&& !storage_class.as_str().is_empty()
|
||||||
&& !obj.delete_marker
|
&& !obj.delete_marker
|
||||||
@@ -735,7 +740,11 @@ impl Lifecycle for BucketLifecycleConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if obj.transition_status != TRANSITION_COMPLETE
|
if obj.transition_status != TRANSITION_COMPLETE
|
||||||
&& let Some(transition) = rule.transitions.as_ref().and_then(|transitions| transitions.first())
|
&& let Some(transition) = rule
|
||||||
|
.transitions
|
||||||
|
.as_ref()
|
||||||
|
.filter(|transitions| transitions.len() == 1)
|
||||||
|
.and_then(|transitions| transitions.first())
|
||||||
&& let Some(storage_class) = transition.storage_class.as_ref()
|
&& let Some(storage_class) = transition.storage_class.as_ref()
|
||||||
&& !storage_class.as_str().is_empty()
|
&& !storage_class.as_str().is_empty()
|
||||||
{
|
{
|
||||||
@@ -758,18 +767,15 @@ impl Lifecycle for BucketLifecycleConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !events.is_empty() {
|
if !events.is_empty() {
|
||||||
// Select the winning event using a strict total order (MinIO semantics):
|
// Eligible expiration takes precedence over transition, even when a
|
||||||
// the earliest `due` wins, and ties break toward delete-type actions. A
|
// failed transition has an earlier deadline. Within each action class,
|
||||||
// missing `due` is treated as UNIX_EPOCH. This replaces a hand-written
|
// prefer the earliest deadline using a deterministic total order.
|
||||||
// `sort_by` comparator that was not a strict weak ordering (it could return
|
|
||||||
// `Ordering::Less` for both `(a, b)` and `(b, a)`), which panics on the
|
|
||||||
// repository toolchain and did not deterministically pick the earliest event.
|
|
||||||
let event = events
|
let event = events
|
||||||
.iter()
|
.iter()
|
||||||
.min_by_key(|event| {
|
.min_by_key(|event| {
|
||||||
(
|
(
|
||||||
event.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp(),
|
|
||||||
ilm_action_priority_rank(&event.action),
|
ilm_action_priority_rank(&event.action),
|
||||||
|
event.due.unwrap_or(OffsetDateTime::UNIX_EPOCH).unix_timestamp(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -1042,6 +1048,27 @@ impl ObjectOpts {
|
|||||||
pub fn expired_object_deletemarker(&self) -> bool {
|
pub fn expired_object_deletemarker(&self) -> bool {
|
||||||
self.delete_marker && self.is_latest && self.num_versions == 1
|
self.delete_marker && self.is_latest && self.num_versions == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn restored_copy_expiry(&self, now: OffsetDateTime) -> Option<Event> {
|
||||||
|
let restore_expires = self.restore_expires?;
|
||||||
|
// Restore metadata alone does not prove that a durable remote copy exists.
|
||||||
|
if self.transition_status != TRANSITION_COMPLETE
|
||||||
|
|| restore_expires.unix_timestamp() == 0
|
||||||
|
|| now.unix_timestamp() <= restore_expires.unix_timestamp()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let action = if self.is_latest {
|
||||||
|
IlmAction::DeleteRestoredAction
|
||||||
|
} else {
|
||||||
|
IlmAction::DeleteRestoredVersionAction
|
||||||
|
};
|
||||||
|
expiration_action_has_valid_target(action, self.version_id, self.is_latest, self.delete_marker).then(|| Event {
|
||||||
|
action,
|
||||||
|
due: Some(now),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns whether an expiry action has enough identity to target the object
|
/// Returns whether an expiry action has enough identity to target the object
|
||||||
@@ -1064,11 +1091,8 @@ pub fn expiration_action_has_valid_target(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total-order rank for lifecycle actions used to break `due` ties.
|
/// Eligible logical expiration takes precedence over transition and restore-copy
|
||||||
///
|
/// cleanup. Deadlines break ties within an action class.
|
||||||
/// Delete-type actions rank before every other action so that, when two events
|
|
||||||
/// share the same `due`, a delete wins (MinIO semantics). The concrete numeric
|
|
||||||
/// values only matter relative to each other.
|
|
||||||
fn ilm_action_priority_rank(action: &IlmAction) -> u8 {
|
fn ilm_action_priority_rank(action: &IlmAction) -> u8 {
|
||||||
match action {
|
match action {
|
||||||
IlmAction::DeleteAllVersionsAction
|
IlmAction::DeleteAllVersionsAction
|
||||||
@@ -4159,6 +4183,392 @@ mod tests {
|
|||||||
assert_eq!(event.action, IlmAction::NoneAction);
|
assert_eq!(event.action, IlmAction::NoneAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod adversarial_regressions {
|
||||||
|
use super::*;
|
||||||
|
use s3s::dto::NoncurrentVersionExpiration;
|
||||||
|
|
||||||
|
fn run(test: impl std::future::Future<Output = ()>) {
|
||||||
|
with_default_ilm_process_time(|| {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.build()
|
||||||
|
.expect("lifecycle regression runtime should build")
|
||||||
|
.block_on(test);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn noncurrent_object() -> ObjectOpts {
|
||||||
|
ObjectOpts {
|
||||||
|
name: "logs/object".to_string(),
|
||||||
|
mod_time: Some(datetime!(2020-01-01 00:00:00 UTC)),
|
||||||
|
successor_mod_time: Some(datetime!(2020-01-02 00:00:00 UTC)),
|
||||||
|
version_id: Some(Uuid::from_u128(1)),
|
||||||
|
size: 1024 * 1024,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn noncurrent_transition_retains_the_requested_newer_versions() {
|
||||||
|
run(async {
|
||||||
|
let mut rule = enabled_rule(None, None, Some("retain-two-hot-versions"));
|
||||||
|
rule.filter = Some(LifecycleRuleFilter::default());
|
||||||
|
rule.noncurrent_version_transitions = Some(vec![NoncurrentVersionTransition {
|
||||||
|
noncurrent_days: Some(1),
|
||||||
|
newer_noncurrent_versions: Some(2),
|
||||||
|
storage_class: Some(TransitionStorageClass::from_static("WARM")),
|
||||||
|
}]);
|
||||||
|
let lc = Arc::new(BucketLifecycleConfiguration {
|
||||||
|
rules: vec![rule],
|
||||||
|
expiry_updated_at: None,
|
||||||
|
});
|
||||||
|
lc.validate(&ObjectLockConfiguration::default())
|
||||||
|
.await
|
||||||
|
.expect("valid noncurrent transition policy");
|
||||||
|
let objects = (0..4)
|
||||||
|
.map(|index| ObjectOpts {
|
||||||
|
mod_time: Some(datetime!(2020-01-05 00:00:00 UTC) - Duration::days(index)),
|
||||||
|
successor_mod_time: (index > 0).then_some(datetime!(2020-01-06 00:00:00 UTC) - Duration::days(index)),
|
||||||
|
version_id: Some(Uuid::from_u128(u128::try_from(index + 1).expect("small version index"))),
|
||||||
|
is_latest: index == 0,
|
||||||
|
num_versions: 4,
|
||||||
|
..noncurrent_object()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let actions = crate::Evaluator::new(lc)
|
||||||
|
.eval(&objects)
|
||||||
|
.await
|
||||||
|
.expect("complete version chain should evaluate")
|
||||||
|
.into_iter()
|
||||||
|
.map(|event| event.action)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
actions,
|
||||||
|
[
|
||||||
|
IlmAction::NoneAction,
|
||||||
|
IlmAction::NoneAction,
|
||||||
|
IlmAction::NoneAction,
|
||||||
|
IlmAction::TransitionVersionAction
|
||||||
|
],
|
||||||
|
"the two newest noncurrent versions must remain in their current storage class"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn noncurrent_transition_checks_count_age_and_single_object_context() {
|
||||||
|
run(async {
|
||||||
|
let mut rule = enabled_rule(None, None, Some("retain-two"));
|
||||||
|
rule.filter = Some(LifecycleRuleFilter::default());
|
||||||
|
rule.noncurrent_version_transitions = Some(vec![NoncurrentVersionTransition {
|
||||||
|
noncurrent_days: Some(3),
|
||||||
|
newer_noncurrent_versions: Some(2),
|
||||||
|
storage_class: Some(TransitionStorageClass::from_static("WARM")),
|
||||||
|
}]);
|
||||||
|
let mut lc = BucketLifecycleConfiguration {
|
||||||
|
rules: vec![rule],
|
||||||
|
expiry_updated_at: None,
|
||||||
|
};
|
||||||
|
lc.validate(&ObjectLockConfiguration::default())
|
||||||
|
.await
|
||||||
|
.expect("valid counted transition");
|
||||||
|
let object = noncurrent_object();
|
||||||
|
let now = datetime!(2020-01-10 00:00:00 UTC);
|
||||||
|
for (newer, expected) in [
|
||||||
|
(0, IlmAction::NoneAction),
|
||||||
|
(1, IlmAction::NoneAction),
|
||||||
|
(2, IlmAction::TransitionVersionAction),
|
||||||
|
(3, IlmAction::TransitionVersionAction),
|
||||||
|
] {
|
||||||
|
assert_eq!(lc.eval_inner(&object, now, newer).await.action, expected, "newer count: {newer}");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
lc.eval_inner(&object, datetime!(2020-01-04 00:00:00 UTC), 2).await.action,
|
||||||
|
IlmAction::NoneAction,
|
||||||
|
"the retention count does not replace the age condition"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lc.eval(&object).await.action,
|
||||||
|
IlmAction::NoneAction,
|
||||||
|
"a single-object lookup must not assume a complete version history"
|
||||||
|
);
|
||||||
|
for retain in [None, Some(0), Some(-1), Some(i32::MAX)] {
|
||||||
|
lc.rules[0]
|
||||||
|
.noncurrent_version_transitions
|
||||||
|
.as_mut()
|
||||||
|
.expect("transition exists")[0]
|
||||||
|
.newer_noncurrent_versions = retain;
|
||||||
|
let expected = if matches!(retain, None | Some(0)) {
|
||||||
|
IlmAction::TransitionVersionAction
|
||||||
|
} else {
|
||||||
|
IlmAction::NoneAction
|
||||||
|
};
|
||||||
|
assert_eq!(lc.eval_inner(&object, now, 2).await.action, expected, "retention: {retain:?}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn noncurrent_expiration_and_transition_have_independent_retention_counts() {
|
||||||
|
run(async {
|
||||||
|
let mut rule = enabled_rule(None, None, Some("independent-counts"));
|
||||||
|
rule.filter = Some(LifecycleRuleFilter::default());
|
||||||
|
rule.noncurrent_version_expiration = Some(NoncurrentVersionExpiration {
|
||||||
|
noncurrent_days: Some(90),
|
||||||
|
newer_noncurrent_versions: Some(4),
|
||||||
|
});
|
||||||
|
rule.noncurrent_version_transitions = Some(vec![NoncurrentVersionTransition {
|
||||||
|
noncurrent_days: Some(30),
|
||||||
|
newer_noncurrent_versions: Some(2),
|
||||||
|
storage_class: Some(TransitionStorageClass::from_static("WARM")),
|
||||||
|
}]);
|
||||||
|
let lc = BucketLifecycleConfiguration {
|
||||||
|
rules: vec![rule],
|
||||||
|
expiry_updated_at: None,
|
||||||
|
};
|
||||||
|
lc.validate(&ObjectLockConfiguration::default())
|
||||||
|
.await
|
||||||
|
.expect("valid independent retention limits");
|
||||||
|
let object = noncurrent_object();
|
||||||
|
let now = datetime!(2020-05-01 00:00:00 UTC);
|
||||||
|
for (newer, expected) in [
|
||||||
|
(1, IlmAction::NoneAction),
|
||||||
|
(2, IlmAction::TransitionVersionAction),
|
||||||
|
(3, IlmAction::TransitionVersionAction),
|
||||||
|
(4, IlmAction::DeleteVersionAction),
|
||||||
|
] {
|
||||||
|
assert_eq!(lc.eval_inner(&object, now, newer).await.action, expected, "newer count: {newer}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn expiration_retention_does_not_skip_an_independent_transition() {
|
||||||
|
run(async {
|
||||||
|
let mut rule = enabled_rule(None, None, Some("transition-then-expire"));
|
||||||
|
rule.filter = Some(LifecycleRuleFilter::default());
|
||||||
|
rule.noncurrent_version_transitions = Some(vec![NoncurrentVersionTransition {
|
||||||
|
noncurrent_days: Some(1),
|
||||||
|
newer_noncurrent_versions: None,
|
||||||
|
storage_class: Some(TransitionStorageClass::from_static("WARM")),
|
||||||
|
}]);
|
||||||
|
let mut lc = BucketLifecycleConfiguration {
|
||||||
|
rules: vec![rule],
|
||||||
|
expiry_updated_at: None,
|
||||||
|
};
|
||||||
|
let object = noncurrent_object();
|
||||||
|
let now = datetime!(2020-01-10 00:00:00 UTC);
|
||||||
|
let transition_only = lc.eval_inner(&object, now, 0).await;
|
||||||
|
assert_eq!(transition_only.action, IlmAction::TransitionVersionAction);
|
||||||
|
|
||||||
|
lc.rules[0].noncurrent_version_expiration = Some(NoncurrentVersionExpiration {
|
||||||
|
noncurrent_days: Some(90),
|
||||||
|
newer_noncurrent_versions: Some(2),
|
||||||
|
});
|
||||||
|
lc.validate(&ObjectLockConfiguration::default())
|
||||||
|
.await
|
||||||
|
.expect("valid combined policy");
|
||||||
|
let combined = lc.eval_inner(&object, now, 0).await;
|
||||||
|
assert_eq!(combined.action, transition_only.action, "retention limits expiration, not transition");
|
||||||
|
assert_eq!(combined.storage_class, transition_only.storage_class);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn current_transition_rejects_multiple_stages_in_any_order() {
|
||||||
|
run(async {
|
||||||
|
let mut rule = enabled_rule(None, None, Some("two-current-transitions"));
|
||||||
|
rule.transitions = Some(vec![
|
||||||
|
Transition {
|
||||||
|
date: Some(datetime!(2020-03-01 00:00:00 UTC).into()),
|
||||||
|
days: None,
|
||||||
|
storage_class: Some(TransitionStorageClass::from_static("COLD")),
|
||||||
|
},
|
||||||
|
Transition {
|
||||||
|
date: Some(datetime!(2020-01-03 00:00:00 UTC).into()),
|
||||||
|
days: None,
|
||||||
|
storage_class: Some(TransitionStorageClass::from_static("WARM")),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
let mut lc = BucketLifecycleConfiguration {
|
||||||
|
rules: vec![rule],
|
||||||
|
expiry_updated_at: None,
|
||||||
|
};
|
||||||
|
let object = ObjectOpts {
|
||||||
|
is_latest: true,
|
||||||
|
..noncurrent_object()
|
||||||
|
};
|
||||||
|
let now = datetime!(2020-01-10 00:00:00 UTC);
|
||||||
|
for status in [ExpirationStatus::ENABLED, ExpirationStatus::DISABLED] {
|
||||||
|
lc.rules[0].status = ExpirationStatus::from_static(status);
|
||||||
|
for _ in 0..2 {
|
||||||
|
let err = lc
|
||||||
|
.validate(&ObjectLockConfiguration::default())
|
||||||
|
.await
|
||||||
|
.expect_err("multiple transition stages must be rejected");
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||||
|
assert_eq!(err.to_string(), ERR_LIFECYCLE_MULTIPLE_TRANSITIONS);
|
||||||
|
assert_eq!(
|
||||||
|
lc.eval_inner(&object, now, 0).await.action,
|
||||||
|
IlmAction::NoneAction,
|
||||||
|
"legacy multi-stage configurations must not silently execute their first stage"
|
||||||
|
);
|
||||||
|
lc.rules[0]
|
||||||
|
.transitions
|
||||||
|
.as_mut()
|
||||||
|
.expect("transition array is present")
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lc.rules[0]
|
||||||
|
.transitions
|
||||||
|
.as_mut()
|
||||||
|
.expect("transition array is present")
|
||||||
|
.remove(0);
|
||||||
|
lc.rules[0].status = ExpirationStatus::from_static(ExpirationStatus::ENABLED);
|
||||||
|
lc.validate(&ObjectLockConfiguration::default())
|
||||||
|
.await
|
||||||
|
.expect("one stage is supported");
|
||||||
|
let event = lc.eval_inner(&object, now, 0).await;
|
||||||
|
assert_eq!(event.action, IlmAction::TransitionAction);
|
||||||
|
assert_eq!(event.storage_class, "WARM");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn noncurrent_transition_rejects_multiple_stages_in_any_order() {
|
||||||
|
run(async {
|
||||||
|
let mut rule = enabled_rule(None, None, Some("two-noncurrent-transitions"));
|
||||||
|
rule.noncurrent_version_transitions = Some(vec![
|
||||||
|
NoncurrentVersionTransition {
|
||||||
|
noncurrent_days: Some(30),
|
||||||
|
newer_noncurrent_versions: None,
|
||||||
|
storage_class: Some(TransitionStorageClass::from_static("COLD")),
|
||||||
|
},
|
||||||
|
NoncurrentVersionTransition {
|
||||||
|
noncurrent_days: Some(1),
|
||||||
|
newer_noncurrent_versions: None,
|
||||||
|
storage_class: Some(TransitionStorageClass::from_static("WARM")),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
let mut lc = BucketLifecycleConfiguration {
|
||||||
|
rules: vec![rule],
|
||||||
|
expiry_updated_at: None,
|
||||||
|
};
|
||||||
|
let object = noncurrent_object();
|
||||||
|
let now = datetime!(2020-01-10 00:00:00 UTC);
|
||||||
|
for status in [ExpirationStatus::ENABLED, ExpirationStatus::DISABLED] {
|
||||||
|
lc.rules[0].status = ExpirationStatus::from_static(status);
|
||||||
|
for _ in 0..2 {
|
||||||
|
let err = lc
|
||||||
|
.validate(&ObjectLockConfiguration::default())
|
||||||
|
.await
|
||||||
|
.expect_err("multiple noncurrent transition stages must be rejected");
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||||
|
assert_eq!(err.to_string(), ERR_LIFECYCLE_MULTIPLE_NONCURRENT_TRANSITIONS);
|
||||||
|
assert_eq!(
|
||||||
|
lc.eval_inner(&object, now, 0).await.action,
|
||||||
|
IlmAction::NoneAction,
|
||||||
|
"legacy multi-stage configurations must not silently execute their first stage"
|
||||||
|
);
|
||||||
|
lc.rules[0]
|
||||||
|
.noncurrent_version_transitions
|
||||||
|
.as_mut()
|
||||||
|
.expect("transition array is present")
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lc.rules[0]
|
||||||
|
.noncurrent_version_transitions
|
||||||
|
.as_mut()
|
||||||
|
.expect("transition array is present")
|
||||||
|
.remove(0);
|
||||||
|
lc.rules[0].status = ExpirationStatus::from_static(ExpirationStatus::ENABLED);
|
||||||
|
lc.validate(&ObjectLockConfiguration::default())
|
||||||
|
.await
|
||||||
|
.expect("one stage is supported");
|
||||||
|
let event = lc.eval_inner(&object, now, 0).await;
|
||||||
|
assert_eq!(event.action, IlmAction::TransitionVersionAction);
|
||||||
|
assert_eq!(event.storage_class, "WARM");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn expiration_rejects_simultaneous_days_and_date() {
|
||||||
|
run(async {
|
||||||
|
let mut lc = BucketLifecycleConfiguration {
|
||||||
|
rules: vec![enabled_rule(
|
||||||
|
Some(LifecycleExpiration {
|
||||||
|
days: Some(1),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
Some("ambiguous-expiry"),
|
||||||
|
)],
|
||||||
|
expiry_updated_at: None,
|
||||||
|
};
|
||||||
|
lc.validate(&ObjectLockConfiguration::default())
|
||||||
|
.await
|
||||||
|
.expect("a single Days expiration is valid");
|
||||||
|
lc.rules[0].expiration.as_mut().expect("expiration is present").date =
|
||||||
|
Some(datetime!(2099-01-01 00:00:00 UTC).into());
|
||||||
|
let err = lc
|
||||||
|
.validate(&ObjectLockConfiguration::default())
|
||||||
|
.await
|
||||||
|
.expect_err("Days and Date are mutually exclusive; accepting both silently overrides Days");
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||||
|
assert_eq!(err.to_string(), ERR_LIFECYCLE_EXPIRATION_DAYS_DATE_CONFLICT);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn overdue_transition_does_not_starve_permanent_expiration() {
|
||||||
|
run(async {
|
||||||
|
let mut rule = enabled_rule(
|
||||||
|
Some(LifecycleExpiration {
|
||||||
|
days: Some(90),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
Some("archive-then-delete"),
|
||||||
|
);
|
||||||
|
rule.transitions = Some(vec![Transition {
|
||||||
|
days: Some(30),
|
||||||
|
date: None,
|
||||||
|
storage_class: Some(TransitionStorageClass::from_static("WARM")),
|
||||||
|
}]);
|
||||||
|
let lc = BucketLifecycleConfiguration {
|
||||||
|
rules: vec![rule],
|
||||||
|
expiry_updated_at: None,
|
||||||
|
};
|
||||||
|
lc.validate(&ObjectLockConfiguration::default())
|
||||||
|
.await
|
||||||
|
.expect("valid transition and expiration policy");
|
||||||
|
let object = ObjectOpts {
|
||||||
|
is_latest: true,
|
||||||
|
version_id: None,
|
||||||
|
transition_status: TRANSITION_PENDING.to_string(),
|
||||||
|
..noncurrent_object()
|
||||||
|
};
|
||||||
|
let before_expiration = lc.eval_inner(&object, datetime!(2020-02-15 00:00:00 UTC), 0).await;
|
||||||
|
assert_eq!(before_expiration.action, IlmAction::TransitionAction);
|
||||||
|
let overdue = lc.eval_inner(&object, datetime!(2020-05-01 00:00:00 UTC), 0).await;
|
||||||
|
assert_eq!(
|
||||||
|
overdue.action,
|
||||||
|
IlmAction::DeleteAction,
|
||||||
|
"an unavailable tier must not prevent permanent expiration indefinitely"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Property-based tests for the rule evaluator (backlog#1148 ilm-14,
|
/// Property-based tests for the rule evaluator (backlog#1148 ilm-14,
|
||||||
/// follow-up to backlog#1030 / rustfs#4455).
|
/// follow-up to backlog#1030 / rustfs#4455).
|
||||||
///
|
///
|
||||||
@@ -4169,7 +4579,7 @@ mod tests {
|
|||||||
///
|
///
|
||||||
/// * `eval_inner` never panics and is deterministic for a fixed input;
|
/// * `eval_inner` never panics and is deterministic for a fixed input;
|
||||||
/// * the winning event matches an independently recomputed candidate set:
|
/// * the winning event matches an independently recomputed candidate set:
|
||||||
/// earliest `due` wins, ties break toward delete-class actions (the
|
/// eligible expiration wins over transition, then earliest `due` wins (the
|
||||||
/// `min_by_key` selection that replaced the rustfs#4455 comparator);
|
/// `min_by_key` selection that replaced the rustfs#4455 comparator);
|
||||||
/// * `expected_expiry_time` is monotonically non-decreasing in `days` and
|
/// * `expected_expiry_time` is monotonically non-decreasing in `days` and
|
||||||
/// always lands on the processing boundary, both at production defaults
|
/// always lands on the processing boundary, both at production defaults
|
||||||
@@ -4458,8 +4868,8 @@ mod tests {
|
|||||||
/// consider for a live current version under `selection`-shaped rules
|
/// consider for a live current version under `selection`-shaped rules
|
||||||
/// (expiration and first-transition only, no filters): expiration
|
/// (expiration and first-transition only, no filters): expiration
|
||||||
/// fires when `now >= due`, transition when `now > due` and the object
|
/// fires when `now >= due`, transition when `now > due` and the object
|
||||||
/// has not already transitioned. Selection semantics under test:
|
/// has not already transitioned. Eligible expiration wins over transition;
|
||||||
/// earliest due wins, ties prefer delete-class.
|
/// the earliest deadline wins within the selected action class.
|
||||||
fn oracle_candidates(lc: &BucketLifecycleConfiguration, obj: &ObjectOpts, now: OffsetDateTime) -> Vec<Candidate> {
|
fn oracle_candidates(lc: &BucketLifecycleConfiguration, obj: &ObjectOpts, now: OffsetDateTime) -> Vec<Candidate> {
|
||||||
let mod_time = obj.mod_time.expect("selection strategy always sets mod_time");
|
let mod_time = obj.mod_time.expect("selection strategy always sets mod_time");
|
||||||
let mut candidates = Vec::new();
|
let mut candidates = Vec::new();
|
||||||
@@ -4548,8 +4958,8 @@ mod tests {
|
|||||||
/// Differential test of winner selection (the rustfs#4455 fix):
|
/// Differential test of winner selection (the rustfs#4455 fix):
|
||||||
/// for a live current version under randomized expiration and
|
/// for a live current version under randomized expiration and
|
||||||
/// transition rules, `eval_inner`'s winner must carry the
|
/// transition rules, `eval_inner`'s winner must carry the
|
||||||
/// minimum `(due, rank)` of the independently recomputed
|
/// earliest expiration from the independently recomputed candidate
|
||||||
/// candidate set — earliest due wins, ties prefer delete-class —
|
/// set, or the earliest transition when no expiration is eligible,
|
||||||
/// and must be `NoneAction` exactly when that set is empty.
|
/// and must be `NoneAction` exactly when that set is empty.
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
@@ -4578,7 +4988,13 @@ mod tests {
|
|||||||
|
|
||||||
// Oracle and evaluator must observe the same (pinned) time env.
|
// Oracle and evaluator must observe the same (pinned) time env.
|
||||||
let (event, expected) = with_production_time_env(|| {
|
let (event, expected) = with_production_time_env(|| {
|
||||||
let expected = oracle_candidates(&lc, &obj, now).into_iter().min();
|
let candidates = oracle_candidates(&lc, &obj, now);
|
||||||
|
let expected = candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, rank)| *rank == 0)
|
||||||
|
.min()
|
||||||
|
.copied()
|
||||||
|
.or_else(|| candidates.into_iter().min());
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -116,13 +116,10 @@ impl Evaluator {
|
|||||||
break 'top_loop;
|
break 'top_loop;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IlmAction::DeleteAction
|
// Restore expiry removes only the temporary local copy; the
|
||||||
| IlmAction::DeleteRestoredAction
|
// retained logical version and its remote data remain intact.
|
||||||
| IlmAction::DeleteVersionAction
|
IlmAction::DeleteAction | IlmAction::DeleteVersionAction if self.is_object_locked(obj) => {
|
||||||
| IlmAction::DeleteRestoredVersionAction
|
event = obj.restored_copy_expiry(now).unwrap_or_default();
|
||||||
if self.is_object_locked(obj) =>
|
|
||||||
{
|
|
||||||
event = Event::default();
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -206,6 +203,95 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use rustfs_replication::{ReplicationStatusType, VersionPurgeStatusType};
|
use rustfs_replication::{ReplicationStatusType, VersionPurgeStatusType};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adversarial_restore_expiry_survives_legal_hold() {
|
||||||
|
let mut policy = (*latest_expiration_lifecycle()).clone();
|
||||||
|
policy.rules[0].status = ExpirationStatus::from_static(ExpirationStatus::DISABLED);
|
||||||
|
let policy = Arc::new(policy);
|
||||||
|
policy
|
||||||
|
.validate(&lock_enabled_without_default_retention())
|
||||||
|
.await
|
||||||
|
.expect("valid disabled lifecycle rule");
|
||||||
|
let mut objects = [true, false].map(|is_latest| ObjectOpts {
|
||||||
|
is_latest,
|
||||||
|
num_versions: 2,
|
||||||
|
mod_time: Some(
|
||||||
|
OffsetDateTime::from_unix_timestamp(if is_latest { 1_200_000 } else { 1_000_000 })
|
||||||
|
.expect("fixed version timestamp"),
|
||||||
|
),
|
||||||
|
successor_mod_time: (!is_latest)
|
||||||
|
.then(|| OffsetDateTime::from_unix_timestamp(1_200_000).expect("fixed successor timestamp")),
|
||||||
|
transition_status: crate::TRANSITION_COMPLETE.to_string(),
|
||||||
|
restore_expires: Some(OffsetDateTime::from_unix_timestamp(2_000_000).expect("fixed expired restore timestamp")),
|
||||||
|
..current_object_opts(ReplicationStatusType::Completed)
|
||||||
|
});
|
||||||
|
let evaluator = Evaluator::new(policy).with_lock_retention(Some(lock_enabled_without_default_retention()));
|
||||||
|
let expected = [IlmAction::DeleteRestoredAction, IlmAction::DeleteRestoredVersionAction];
|
||||||
|
let unlocked = evaluator
|
||||||
|
.eval(&objects)
|
||||||
|
.await
|
||||||
|
.expect("unlocked restored versions should evaluate");
|
||||||
|
assert_eq!(unlocked.iter().map(|event| event.action).collect::<Vec<_>>(), expected);
|
||||||
|
|
||||||
|
for object in &mut objects {
|
||||||
|
object
|
||||||
|
.user_defined
|
||||||
|
.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str().to_string(), "ON".to_string());
|
||||||
|
}
|
||||||
|
let locked = evaluator
|
||||||
|
.eval(&objects)
|
||||||
|
.await
|
||||||
|
.expect("locked restored versions should evaluate");
|
||||||
|
assert_eq!(
|
||||||
|
locked.iter().map(|event| event.action).collect::<Vec<_>>(),
|
||||||
|
expected,
|
||||||
|
"expiring a restored local copy preserves the retained logical version and remote object"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut expiring_policy = (*latest_expiration_lifecycle()).clone();
|
||||||
|
expiring_policy.rules[0].noncurrent_version_expiration = Some(NoncurrentVersionExpiration {
|
||||||
|
noncurrent_days: Some(1),
|
||||||
|
newer_noncurrent_versions: None,
|
||||||
|
});
|
||||||
|
let expiring_evaluator =
|
||||||
|
Evaluator::new(Arc::new(expiring_policy)).with_lock_retention(Some(lock_enabled_without_default_retention()));
|
||||||
|
let locked = expiring_evaluator
|
||||||
|
.eval(&objects)
|
||||||
|
.await
|
||||||
|
.expect("locked expired versions should evaluate");
|
||||||
|
assert_eq!(
|
||||||
|
locked.iter().map(|event| event.action).collect::<Vec<_>>(),
|
||||||
|
expected,
|
||||||
|
"blocked logical expiration must still allow an eligible restore-copy cleanup"
|
||||||
|
);
|
||||||
|
|
||||||
|
for status in [ReplicationStatusType::Pending, ReplicationStatusType::Failed] {
|
||||||
|
for object in &mut objects {
|
||||||
|
object.replication_status = status.clone();
|
||||||
|
}
|
||||||
|
for evaluator in [&evaluator, &expiring_evaluator] {
|
||||||
|
let events = evaluator.eval(&objects).await.expect("pending replication should evaluate");
|
||||||
|
assert!(events.iter().all(|event| event.action == IlmAction::NoneAction));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for object in &mut objects {
|
||||||
|
object.replication_status = ReplicationStatusType::Completed;
|
||||||
|
}
|
||||||
|
for transition_status in ["", crate::TRANSITION_PENDING, "unknown"] {
|
||||||
|
for object in &mut objects {
|
||||||
|
object.transition_status = transition_status.to_string();
|
||||||
|
}
|
||||||
|
for evaluator in [&evaluator, &expiring_evaluator] {
|
||||||
|
let events = evaluator.eval(&objects).await.expect("incomplete transition should evaluate");
|
||||||
|
assert!(
|
||||||
|
events.iter().all(|event| event.action == IlmAction::NoneAction),
|
||||||
|
"restore metadata cannot authorize cleanup without a completed transition"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn expired_marker_lifecycle() -> Arc<BucketLifecycleConfiguration> {
|
fn expired_marker_lifecycle() -> Arc<BucketLifecycleConfiguration> {
|
||||||
Arc::new(BucketLifecycleConfiguration {
|
Arc::new(BucketLifecycleConfiguration {
|
||||||
expiry_updated_at: None,
|
expiry_updated_at: None,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"bucket":"photos","config":{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"REDACTED","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null}},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}},"updated_at":"2026-09-02T10:00:00Z"}
|
{"bucket":"photos","config":{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"REDACTED","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":null,"gcs":null},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}},"updated_at":"2026-09-02T10:00:00Z"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"sourceSecretKey123","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null}},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}}
|
{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"sourceSecretKey123","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":null,"gcs":null},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"bucket":"photos","dry_run":false,"config":{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"REDACTED","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null}},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}},"updated_at":"2026-09-02T10:00:00Z","probe":{"reachable":true,"listable":true,"sample_key":"photos/2024/01.jpg"}}
|
{"bucket":"photos","dry_run":false,"config":{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"REDACTED","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":null,"gcs":null},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}},"updated_at":"2026-09-02T10:00:00Z","probe":{"reachable":true,"listable":true,"sample_key":"photos/2024/01.jpg"}}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
//! Wire types for `PUT`/`GET`/`DELETE /v3/on-demand-migration/{bucket}`,
|
//! Wire types for `PUT`/`GET`/`DELETE /v3/on-demand-migration/{bucket}`,
|
||||||
//! `GET .../status`, `POST .../backfill?op=start|cancel` and
|
//! `GET .../status`, `POST .../backfill?op=start|cancel` and
|
||||||
//! `GET .../backfill` (ODM-12), mirroring the server's config model
|
//! `GET .../backfill` (ODM-12), mirroring the server's config model
|
||||||
//! (`crates/ecstore/src/bucket/on_demand_migration/config.rs`) and handler
|
//! (`rustfs/src/on_demand_migration/config.rs`) and handler
|
||||||
//! responses (`rustfs/src/admin/handlers/on_demand_migration.rs`). The SDK
|
//! responses (`rustfs/src/admin/handlers/on_demand_migration.rs`). The SDK
|
||||||
//! owns its own copies, madmin-go style; the fixtures under
|
//! owns its own copies, madmin-go style; the fixtures under
|
||||||
//! `fixtures/on_demand_migration/` are the contract both sides pin
|
//! `fixtures/on_demand_migration/` are the contract both sides pin
|
||||||
@@ -78,10 +78,18 @@ pub struct OnDemandMigrationSource {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub path_style: OnDemandMigrationPathStyle,
|
pub path_style: OnDemandMigrationPathStyle,
|
||||||
/// `None` means anonymous access to a public source bucket.
|
/// `None` means anonymous access to a public source bucket.
|
||||||
|
/// `None` means anonymous access to a public source bucket. The native
|
||||||
|
/// providers carry their credentials in `azure` / `gcs` instead.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub credentials: Option<OnDemandMigrationCredentials>,
|
pub credentials: Option<OnDemandMigrationCredentials>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tls: OnDemandMigrationTls,
|
pub tls: OnDemandMigrationTls,
|
||||||
|
/// Required for `azure` and rejected for every other provider.
|
||||||
|
#[serde(default)]
|
||||||
|
pub azure: Option<OnDemandMigrationAzure>,
|
||||||
|
/// Required for `gcs_native` and rejected for every other provider.
|
||||||
|
#[serde(default)]
|
||||||
|
pub gcs: Option<OnDemandMigrationGcs>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -92,7 +100,49 @@ pub enum OnDemandMigrationProvider {
|
|||||||
Minio,
|
Minio,
|
||||||
Rustfs,
|
Rustfs,
|
||||||
R2,
|
R2,
|
||||||
|
/// GCS XML interoperability API with HMAC keys.
|
||||||
Gcs,
|
Gcs,
|
||||||
|
/// Native Azure Blob service.
|
||||||
|
Azure,
|
||||||
|
/// Native GCS JSON API with a service-account key.
|
||||||
|
#[serde(rename = "gcs_native")]
|
||||||
|
GcsNative,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native Azure Blob parameters. The container is `source.bucket`; exactly one
|
||||||
|
/// of `account_key` and `sas_token` is set. Responses carry both as `REDACTED`.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct OnDemandMigrationAzure {
|
||||||
|
pub account: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub account_key: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sas_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for OnDemandMigrationAzure {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("OnDemandMigrationAzure")
|
||||||
|
.field("account", &self.account)
|
||||||
|
.field("account_key", &self.account_key.as_ref().map(|_| "REDACTED"))
|
||||||
|
.field("sas_token", &self.sas_token.as_ref().map(|_| "REDACTED"))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native GCS parameters. The bucket is `source.bucket`; the key JSON embeds a
|
||||||
|
/// private key, so responses carry it as `REDACTED`.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct OnDemandMigrationGcs {
|
||||||
|
pub service_account_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for OnDemandMigrationGcs {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("OnDemandMigrationGcs")
|
||||||
|
.field("service_account_json", &"REDACTED")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
@@ -806,6 +856,8 @@ mod tests {
|
|||||||
session_token: None,
|
session_token: None,
|
||||||
}),
|
}),
|
||||||
tls: OnDemandMigrationTls::default(),
|
tls: OnDemandMigrationTls::default(),
|
||||||
|
azure: None,
|
||||||
|
gcs: None,
|
||||||
});
|
});
|
||||||
let mut expected: OnDemandMigrationConfig = serde_json::from_str(SET_REQUEST_FIXTURE.trim()).expect("fixture");
|
let mut expected: OnDemandMigrationConfig = serde_json::from_str(SET_REQUEST_FIXTURE.trim()).expect("fixture");
|
||||||
expected.filter.source_prefix = None;
|
expected.filter.source_prefix = None;
|
||||||
@@ -821,6 +873,42 @@ mod tests {
|
|||||||
assert!(minimal.source.credentials.is_none());
|
assert!(minimal.source.credentials.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_provider_documents_round_trip_and_hide_their_secrets() {
|
||||||
|
for (label, json) in [
|
||||||
|
(
|
||||||
|
"azure",
|
||||||
|
r#"{"provider":"azure","endpoint":null,"region":"auto","bucket":"legacy-photos","path_style":"auto","credentials":null,"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":{"account":"legacyaccount","account_key":null,"sas_token":"sv=2021-08-06&sig=topsecret"},"gcs":null}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gcs_native",
|
||||||
|
r#"{"provider":"gcs_native","endpoint":null,"region":"auto","bucket":"legacy-photos","path_style":"auto","credentials":null,"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":null,"gcs":{"service_account_json":"{\"type\":\"service_account\"}"}}"#,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let source: OnDemandMigrationSource = serde_json::from_str(json).unwrap_or_else(|err| panic!("{label}: {err}"));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&source).expect("re-encodes"),
|
||||||
|
json,
|
||||||
|
"{label} must reproduce the server wire shape byte for byte"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let azure = OnDemandMigrationAzure {
|
||||||
|
account: "legacyaccount".to_string(),
|
||||||
|
account_key: Some("c2VjcmV0".to_string()),
|
||||||
|
sas_token: Some("sig=topsecret".to_string()),
|
||||||
|
};
|
||||||
|
let rendered = format!("{azure:?}");
|
||||||
|
assert!(rendered.contains("legacyaccount"));
|
||||||
|
assert!(!rendered.contains("c2VjcmV0"), "{rendered}");
|
||||||
|
assert!(!rendered.contains("topsecret"), "{rendered}");
|
||||||
|
|
||||||
|
let gcs = OnDemandMigrationGcs {
|
||||||
|
service_account_json: r#"{"private_key":"-----BEGIN PRIVATE KEY-----"}"#.to_string(),
|
||||||
|
};
|
||||||
|
assert!(!format!("{gcs:?}").contains("PRIVATE KEY"), "{gcs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn credentials_debug_never_prints_secrets() {
|
fn credentials_debug_never_prints_secrets() {
|
||||||
let credentials = OnDemandMigrationCredentials {
|
let credentials = OnDemandMigrationCredentials {
|
||||||
|
|||||||
@@ -38,3 +38,4 @@ pub(crate) use storage_api::metrics::{
|
|||||||
obs_on_demand_migration_snapshot, obs_replication_site_stats_snapshot, obs_resolve_object_store_handle,
|
obs_on_demand_migration_snapshot, obs_replication_site_stats_snapshot, obs_resolve_object_store_handle,
|
||||||
obs_transition_state_handle,
|
obs_transition_state_handle,
|
||||||
};
|
};
|
||||||
|
pub use storage_api::register_on_demand_migration_metrics_source;
|
||||||
|
|||||||
@@ -17,13 +17,6 @@ use std::time::Duration;
|
|||||||
|
|
||||||
pub(crate) use rustfs_ecstore::api::bucket::bandwidth::monitor::Monitor as ObsBucketBandwidthMonitor;
|
pub(crate) use rustfs_ecstore::api::bucket::bandwidth::monitor::Monitor as ObsBucketBandwidthMonitor;
|
||||||
pub(crate) use rustfs_ecstore::api::bucket::metadata_sys::get_quota_config as obs_get_quota_config;
|
pub(crate) use rustfs_ecstore::api::bucket::metadata_sys::get_quota_config as obs_get_quota_config;
|
||||||
use rustfs_ecstore::api::bucket::on_demand_migration::backfill::{
|
|
||||||
BackfillCheckpoint as SourceBackfillCheckpoint, global_backfill_runner as source_global_backfill_runner,
|
|
||||||
};
|
|
||||||
use rustfs_ecstore::api::bucket::on_demand_migration::{
|
|
||||||
BreakerState as SourceOdmBreakerState, OdmBucketSnapshot as SourceOdmBucketSnapshot,
|
|
||||||
OnDemandMigrationSys as SourceOnDemandMigrationSys,
|
|
||||||
};
|
|
||||||
use rustfs_ecstore::api::bucket::replication::{
|
use rustfs_ecstore::api::bucket::replication::{
|
||||||
BucketReplicationStats as SourceBucketReplicationStats, DurableMrfBucketBacklog, DurableMrfTargetBacklog,
|
BucketReplicationStats as SourceBucketReplicationStats, DurableMrfBucketBacklog, DurableMrfTargetBacklog,
|
||||||
MrfBucketBacklogObservability, RuntimeReplicationTargetBacklog, durable_mrf_backlog_summary_snapshot,
|
MrfBucketBacklogObservability, RuntimeReplicationTargetBacklog, durable_mrf_backlog_summary_snapshot,
|
||||||
@@ -44,9 +37,7 @@ pub(crate) use rustfs_ecstore::api::runtime::{
|
|||||||
pub(crate) use rustfs_ecstore::api::storage::ECStore as ObsStore;
|
pub(crate) use rustfs_ecstore::api::storage::ECStore as ObsStore;
|
||||||
use rustfs_storage_api as storage_contracts;
|
use rustfs_storage_api as storage_contracts;
|
||||||
|
|
||||||
use crate::metrics::collectors::{
|
use crate::metrics::collectors::{OdmBackfillBucketStats, OdmBackfillRuntimeStats, OnDemandMigrationBucketStats};
|
||||||
OdmBackfillBucketStats, OdmBackfillRuntimeStats, OnDemandMigrationBreakerState, OnDemandMigrationBucketStats,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub(crate) struct ObsBucketReplicationTargetStatsSnapshot {
|
pub(crate) struct ObsBucketReplicationTargetStatsSnapshot {
|
||||||
@@ -465,70 +456,37 @@ pub(crate) async fn obs_bucket_replication_stats_snapshot() -> Vec<ObsBucketRepl
|
|||||||
buckets
|
buckets
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_demand_migration_stats_from_snapshot(snapshot: SourceOdmBucketSnapshot) -> OnDemandMigrationBucketStats {
|
struct OnDemandMigrationMetricsSource {
|
||||||
let stats = snapshot.stats;
|
snapshot: fn() -> Vec<OnDemandMigrationBucketStats>,
|
||||||
OnDemandMigrationBucketStats {
|
backfill_snapshot: fn() -> Vec<OdmBackfillBucketStats>,
|
||||||
bucket: snapshot.bucket,
|
|
||||||
requests_total: stats.requests_total,
|
|
||||||
pulled_bytes_total: stats.pulled_bytes_total,
|
|
||||||
pulled_objects_total: stats.pulled_objects_total,
|
|
||||||
pull_failures_total: stats.pull_failures_total,
|
|
||||||
inflight_pulls: stats.inflight_pulls,
|
|
||||||
queue_depth: stats.queue_depth,
|
|
||||||
source_latency_buckets: stats
|
|
||||||
.source_latency
|
|
||||||
.buckets
|
|
||||||
.into_iter()
|
|
||||||
.map(|bucket| (bucket.le_ms, bucket.count))
|
|
||||||
.collect(),
|
|
||||||
source_latency_count: stats.source_latency.count,
|
|
||||||
source_latency_sum_ms: stats.source_latency.sum_ms,
|
|
||||||
breaker_state: match stats.breaker_state {
|
|
||||||
SourceOdmBreakerState::Closed => OnDemandMigrationBreakerState::Closed,
|
|
||||||
SourceOdmBreakerState::HalfOpen => OnDemandMigrationBreakerState::HalfOpen,
|
|
||||||
SourceOdmBreakerState::Open => OnDemandMigrationBreakerState::Open,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every bucket with live on-demand migration state on this node, sorted by
|
static ON_DEMAND_MIGRATION_METRICS_SOURCE: std::sync::OnceLock<OnDemandMigrationMetricsSource> = std::sync::OnceLock::new();
|
||||||
/// name. Empty while the module switch is off.
|
|
||||||
pub(crate) fn obs_on_demand_migration_snapshot() -> Vec<OnDemandMigrationBucketStats> {
|
|
||||||
SourceOnDemandMigrationSys::get()
|
|
||||||
.snapshot()
|
|
||||||
.into_iter()
|
|
||||||
.map(on_demand_migration_stats_from_snapshot)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_demand_migration_backfill_stats_from_checkpoint(
|
/// Register the application-owned ODM snapshots before starting the collector.
|
||||||
bucket: String,
|
pub fn register_on_demand_migration_metrics_source(
|
||||||
checkpoint: SourceBackfillCheckpoint,
|
snapshot: fn() -> Vec<OnDemandMigrationBucketStats>,
|
||||||
) -> OdmBackfillBucketStats {
|
backfill_snapshot: fn() -> Vec<OdmBackfillBucketStats>,
|
||||||
OdmBackfillBucketStats {
|
) -> bool {
|
||||||
bucket,
|
ON_DEMAND_MIGRATION_METRICS_SOURCE
|
||||||
state: checkpoint.state.as_str().to_string(),
|
.set(OnDemandMigrationMetricsSource {
|
||||||
listed: checkpoint.listed,
|
snapshot,
|
||||||
enqueued: checkpoint.enqueued,
|
backfill_snapshot,
|
||||||
pulled: checkpoint.pulled,
|
|
||||||
skipped_existing: checkpoint.skipped_existing,
|
|
||||||
failed: checkpoint.failed,
|
|
||||||
bytes: checkpoint.bytes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backfill jobs running on this node, sorted by bucket. Empty until the
|
|
||||||
/// runner is installed, and empty again once a job finishes: the series are
|
|
||||||
/// per-node job progress, not a cluster-wide history.
|
|
||||||
pub(crate) fn obs_on_demand_migration_backfill_snapshot(server: String) -> OdmBackfillRuntimeStats {
|
|
||||||
let buckets = source_global_backfill_runner()
|
|
||||||
.map(|runner| {
|
|
||||||
runner
|
|
||||||
.local_job_snapshots()
|
|
||||||
.into_iter()
|
|
||||||
.map(|(bucket, checkpoint)| on_demand_migration_backfill_stats_from_checkpoint(bucket, checkpoint))
|
|
||||||
.collect()
|
|
||||||
})
|
})
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn obs_on_demand_migration_snapshot() -> Vec<OnDemandMigrationBucketStats> {
|
||||||
|
ON_DEMAND_MIGRATION_METRICS_SOURCE
|
||||||
|
.get()
|
||||||
|
.map(|source| (source.snapshot)())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn obs_on_demand_migration_backfill_snapshot(server: String) -> OdmBackfillRuntimeStats {
|
||||||
|
let buckets = ON_DEMAND_MIGRATION_METRICS_SOURCE
|
||||||
|
.get()
|
||||||
|
.map(|source| (source.backfill_snapshot)())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
OdmBackfillRuntimeStats { server, buckets }
|
OdmBackfillRuntimeStats { server, buckets }
|
||||||
}
|
}
|
||||||
@@ -580,6 +538,31 @@ pub(crate) async fn obs_replication_site_stats_snapshot(current_data_transfer_ra
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn on_demand_migration_callbacks_supply_runtime_snapshots() {
|
||||||
|
assert!(register_on_demand_migration_metrics_source(
|
||||||
|
|| vec![OnDemandMigrationBucketStats {
|
||||||
|
bucket: "configured".into(),
|
||||||
|
pulled_bytes_total: 4096,
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
|| vec![OdmBackfillBucketStats {
|
||||||
|
bucket: "backfill".into(),
|
||||||
|
pulled: 3,
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
));
|
||||||
|
let snapshot = obs_on_demand_migration_snapshot();
|
||||||
|
assert_eq!(snapshot.len(), 1);
|
||||||
|
assert_eq!(snapshot[0].bucket, "configured");
|
||||||
|
assert_eq!(snapshot[0].pulled_bytes_total, 4096);
|
||||||
|
let backfill = obs_on_demand_migration_backfill_snapshot("node-a".into());
|
||||||
|
assert_eq!(backfill.server, "node-a");
|
||||||
|
assert_eq!(backfill.buckets.len(), 1);
|
||||||
|
assert_eq!(backfill.buckets[0].bucket, "backfill");
|
||||||
|
assert_eq!(backfill.buckets[0].pulled, 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn obs_replication_numeric_conversions_floor_negative_values() {
|
fn obs_replication_numeric_conversions_floor_negative_values() {
|
||||||
assert_eq!(i64_to_u64_floor_zero(-1), 0);
|
assert_eq!(i64_to_u64_floor_zero(-1), 0);
|
||||||
@@ -772,51 +755,6 @@ mod tests {
|
|||||||
assert_eq!(snapshot.mrf_last_flush_duration_millis, 4);
|
assert_eq!(snapshot.mrf_last_flush_duration_millis, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn on_demand_migration_snapshot_projects_counters_and_breaker_state() {
|
|
||||||
// Built from JSON: the snapshot's timestamps use `time`, which obs does not depend on.
|
|
||||||
let snapshot: SourceOdmBucketSnapshot = serde_json::from_value(serde_json::json!({
|
|
||||||
"bucket": "photos",
|
|
||||||
"provider": "minio",
|
|
||||||
"endpoint_host": "source.example.com",
|
|
||||||
"applied_at": "2026-09-02T10:00:00Z",
|
|
||||||
"client_error": null,
|
|
||||||
"negative_cache_entries": 0,
|
|
||||||
"inflight_keys": 1,
|
|
||||||
"max_concurrent_pulls": 8,
|
|
||||||
"stats": {
|
|
||||||
"requests_total": {"get": {"source_hit": 2}},
|
|
||||||
"pulled_bytes_total": 4096,
|
|
||||||
"pulled_objects_total": {"inline": 1},
|
|
||||||
"pull_failures_total": {"source_timeout": 1},
|
|
||||||
"inflight_pulls": 1,
|
|
||||||
"queue_depth": 2,
|
|
||||||
"source_latency": {
|
|
||||||
"buckets": [{"le_ms": 5, "count": 1}, {"le_ms": 10, "count": 2}],
|
|
||||||
"count": 3,
|
|
||||||
"sum_ms": 90753
|
|
||||||
},
|
|
||||||
"last_source_error": {"class": "server_error", "at": "2026-09-02T10:00:00Z"},
|
|
||||||
"breaker_state": "open"
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.expect("runtime snapshot decodes");
|
|
||||||
|
|
||||||
let stats = on_demand_migration_stats_from_snapshot(snapshot);
|
|
||||||
|
|
||||||
assert_eq!(stats.bucket, "photos");
|
|
||||||
assert_eq!(stats.requests_total["get"]["source_hit"], 2);
|
|
||||||
assert_eq!(stats.pulled_bytes_total, 4096);
|
|
||||||
assert_eq!(stats.pulled_objects_total["inline"], 1);
|
|
||||||
assert_eq!(stats.pull_failures_total["source_timeout"], 1);
|
|
||||||
assert_eq!(stats.inflight_pulls, 1);
|
|
||||||
assert_eq!(stats.queue_depth, 2);
|
|
||||||
assert_eq!(stats.source_latency_buckets, vec![(5, 1), (10, 2)]);
|
|
||||||
assert_eq!(stats.source_latency_count, 3);
|
|
||||||
assert_eq!(stats.source_latency_sum_ms, 90_753);
|
|
||||||
assert_eq!(stats.breaker_state, OnDemandMigrationBreakerState::Open);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bucket_replication_snapshot_preserves_durable_mrf_unavailable_state() {
|
fn bucket_replication_snapshot_preserves_durable_mrf_unavailable_state() {
|
||||||
let snapshot = bucket_replication_stats_snapshot_from_parts(
|
let snapshot = bucket_replication_stats_snapshot_from_parts(
|
||||||
|
|||||||
@@ -120,18 +120,10 @@ impl TransitionClient {
|
|||||||
|
|
||||||
let h = resp.headers().clone();
|
let h = resp.headers().clone();
|
||||||
|
|
||||||
let mut body = resp.into_body();
|
|
||||||
let body_vec = if let Some(limit) = max_response_bytes {
|
let body_vec = if let Some(limit) = max_response_bytes {
|
||||||
collect_response_body(body, limit).await?
|
self.collect_response_body(resp.into_body(), limit).await?
|
||||||
} else {
|
} else {
|
||||||
let mut body_vec = Vec::new();
|
self.collect_response_body_unbounded(resp.into_body()).await?
|
||||||
while let Some(frame) = body.frame().await {
|
|
||||||
let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
|
||||||
if let Some(data) = frame.data_ref() {
|
|
||||||
body_vec.extend_from_slice(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body_vec
|
|
||||||
};
|
};
|
||||||
Ok((object_stat, h, BufReader::new(Cursor::new(body_vec))))
|
Ok((object_stat, h, BufReader::new(Cursor::new(body_vec))))
|
||||||
}
|
}
|
||||||
@@ -143,7 +135,7 @@ mod bounded_response_tests {
|
|||||||
use crate::{
|
use crate::{
|
||||||
api_get_options::GetObjectOptions,
|
api_get_options::GetObjectOptions,
|
||||||
credentials::{Credentials, SignatureType, Static, Value},
|
credentials::{Credentials, SignatureType, Static, Value},
|
||||||
transition_api::{BucketLookupType, Options, TransitionClient, collect_response_body},
|
transition_api::{BucketLookupType, Options, TransitionClient, TransitionClientTimeouts, collect_response_body},
|
||||||
};
|
};
|
||||||
use http_body_util::Full;
|
use http_body_util::Full;
|
||||||
use hyper::body::Bytes;
|
use hyper::body::Bytes;
|
||||||
@@ -175,7 +167,31 @@ mod bounded_response_tests {
|
|||||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn bounded_get_fixture(body: &'static [u8]) -> Option<(TransitionClient, tokio::task::JoinHandle<String>)> {
|
fn test_options() -> Options {
|
||||||
|
Options {
|
||||||
|
creds: Credentials::new(Static(Value {
|
||||||
|
access_key_id: "access-key".to_string(),
|
||||||
|
secret_access_key: "secret-key".to_string(),
|
||||||
|
signer_type: SignatureType::SignatureV4,
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
region: "us-east-1".to_string(),
|
||||||
|
bucket_lookup: BucketLookupType::BucketLookupPath,
|
||||||
|
max_retries: 1,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn client_for_endpoint(endpoint: &str, timeouts: TransitionClientTimeouts) -> TransitionClient {
|
||||||
|
TransitionClient::new_with_timeouts(endpoint, test_options(), "", timeouts)
|
||||||
|
.await
|
||||||
|
.expect("fixture client should build")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bounded_get_fixture_with_timeouts(
|
||||||
|
body: &'static [u8],
|
||||||
|
timeouts: TransitionClientTimeouts,
|
||||||
|
) -> Option<(TransitionClient, tokio::task::JoinHandle<String>)> {
|
||||||
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
Ok(listener) => listener,
|
Ok(listener) => listener,
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None,
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None,
|
||||||
@@ -209,27 +225,14 @@ mod bounded_response_tests {
|
|||||||
stream.write_all(body).await.expect("fixture should write response body");
|
stream.write_all(body).await.expect("fixture should write response body");
|
||||||
request
|
request
|
||||||
});
|
});
|
||||||
let client = TransitionClient::new(
|
let client = client_for_endpoint(&endpoint, timeouts).await;
|
||||||
&endpoint,
|
|
||||||
Options {
|
|
||||||
creds: Credentials::new(Static(Value {
|
|
||||||
access_key_id: "access-key".to_string(),
|
|
||||||
secret_access_key: "secret-key".to_string(),
|
|
||||||
signer_type: SignatureType::SignatureV4,
|
|
||||||
..Default::default()
|
|
||||||
})),
|
|
||||||
region: "us-east-1".to_string(),
|
|
||||||
bucket_lookup: BucketLookupType::BucketLookupPath,
|
|
||||||
max_retries: 1,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("fixture client should build");
|
|
||||||
Some((client, request))
|
Some((client, request))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn bounded_get_fixture(body: &'static [u8]) -> Option<(TransitionClient, tokio::task::JoinHandle<String>)> {
|
||||||
|
bounded_get_fixture_with_timeouts(body, TransitionClientTimeouts::default()).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn real_transport_accepts_the_exact_closed_range_length() {
|
async fn real_transport_accepts_the_exact_closed_range_length() {
|
||||||
let Some((client, request)) = bounded_get_fixture(b"RustFS!").await else {
|
let Some((client, request)) = bounded_get_fixture(b"RustFS!").await else {
|
||||||
@@ -292,24 +295,7 @@ mod bounded_response_tests {
|
|||||||
.local_addr()
|
.local_addr()
|
||||||
.expect("listener local address should be available")
|
.expect("listener local address should be available")
|
||||||
.to_string();
|
.to_string();
|
||||||
let client = TransitionClient::new(
|
let client = client_for_endpoint(&endpoint, TransitionClientTimeouts::default()).await;
|
||||||
&endpoint,
|
|
||||||
Options {
|
|
||||||
creds: Credentials::new(Static(Value {
|
|
||||||
access_key_id: "access-key".to_string(),
|
|
||||||
secret_access_key: "secret-key".to_string(),
|
|
||||||
signer_type: SignatureType::SignatureV4,
|
|
||||||
..Default::default()
|
|
||||||
})),
|
|
||||||
region: "us-east-1".to_string(),
|
|
||||||
bucket_lookup: BucketLookupType::BucketLookupPath,
|
|
||||||
max_retries: 1,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("fixture client should build");
|
|
||||||
let mut opts = GetObjectOptions::default();
|
let mut opts = GetObjectOptions::default();
|
||||||
opts.headers
|
opts.headers
|
||||||
.insert("range".to_string(), "bytes=0-18446744073709551615".to_string());
|
.insert("range".to_string(), "bytes=0-18446744073709551615".to_string());
|
||||||
@@ -326,6 +312,176 @@ mod bounded_response_tests {
|
|||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connection_refused_returns_without_waiting_for_the_request_timeout() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
|
||||||
|
Err(err) => panic!("test listener should bind: {err}"),
|
||||||
|
};
|
||||||
|
let endpoint = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("listener local address should be available")
|
||||||
|
.to_string();
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let client = client_for_endpoint(
|
||||||
|
&endpoint,
|
||||||
|
TransitionClientTimeouts::new(Duration::from_secs(1), Duration::from_secs(5), Duration::from_secs(1)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut opts = GetObjectOptions::default();
|
||||||
|
opts.set_range(0, 6).expect("the probe range should be valid");
|
||||||
|
|
||||||
|
let result = tokio::time::timeout(Duration::from_secs(2), client.get_object_inner("bucket", "probe", &opts))
|
||||||
|
.await
|
||||||
|
.expect("connection refused should return before the broader request timeout");
|
||||||
|
|
||||||
|
assert!(result.is_err(), "connection refused must fail instead of hanging");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn response_header_stall_returns_timed_out() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
|
||||||
|
Err(err) => panic!("test listener should bind: {err}"),
|
||||||
|
};
|
||||||
|
let endpoint = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("listener local address should be available")
|
||||||
|
.to_string();
|
||||||
|
let fixture = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("fixture should accept one GET");
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut buffer = [0; 1024];
|
||||||
|
loop {
|
||||||
|
let read = stream.read(&mut buffer).await.expect("fixture should read request headers");
|
||||||
|
assert_ne!(read, 0, "connection closed before request headers were received");
|
||||||
|
request.extend_from_slice(&buffer[..read]);
|
||||||
|
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
});
|
||||||
|
let client = client_for_endpoint(
|
||||||
|
&endpoint,
|
||||||
|
TransitionClientTimeouts::new(Duration::from_secs(1), Duration::from_millis(50), Duration::from_secs(1)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut opts = GetObjectOptions::default();
|
||||||
|
opts.set_range(0, 6).expect("the probe range should be valid");
|
||||||
|
|
||||||
|
let err = client
|
||||||
|
.get_object_inner("bucket", "probe", &opts)
|
||||||
|
.await
|
||||||
|
.expect_err("response header stalls must be bounded");
|
||||||
|
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
|
||||||
|
fixture.await.expect("fixture should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn response_body_idle_stall_returns_timed_out() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
|
||||||
|
Err(err) => panic!("test listener should bind: {err}"),
|
||||||
|
};
|
||||||
|
let endpoint = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("listener local address should be available")
|
||||||
|
.to_string();
|
||||||
|
let fixture = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("fixture should accept one GET");
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut buffer = [0; 1024];
|
||||||
|
loop {
|
||||||
|
let read = stream.read(&mut buffer).await.expect("fixture should read request headers");
|
||||||
|
assert_ne!(read, 0, "connection closed before request headers were received");
|
||||||
|
request.extend_from_slice(&buffer[..read]);
|
||||||
|
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 206 Partial Content\r\nContent-Length: 7\r\nConnection: close\r\n\r\nRu")
|
||||||
|
.await
|
||||||
|
.expect("fixture should write the first body chunk");
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
});
|
||||||
|
let client = client_for_endpoint(
|
||||||
|
&endpoint,
|
||||||
|
TransitionClientTimeouts::new(Duration::from_secs(1), Duration::from_secs(1), Duration::from_millis(50)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut opts = GetObjectOptions::default();
|
||||||
|
opts.set_range(0, 6).expect("the probe range should be valid");
|
||||||
|
|
||||||
|
let err = client
|
||||||
|
.get_object_inner("bucket", "probe", &opts)
|
||||||
|
.await
|
||||||
|
.expect_err("body stalls after partial progress must be bounded");
|
||||||
|
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
|
||||||
|
fixture.await.expect("fixture should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn response_body_idle_timer_resets_on_progress() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
|
||||||
|
Err(err) => panic!("test listener should bind: {err}"),
|
||||||
|
};
|
||||||
|
let endpoint = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("listener local address should be available")
|
||||||
|
.to_string();
|
||||||
|
let fixture = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("fixture should accept one GET");
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut buffer = [0; 1024];
|
||||||
|
loop {
|
||||||
|
let read = stream.read(&mut buffer).await.expect("fixture should read request headers");
|
||||||
|
assert_ne!(read, 0, "connection closed before request headers were received");
|
||||||
|
request.extend_from_slice(&buffer[..read]);
|
||||||
|
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 206 Partial Content\r\nContent-Length: 7\r\nConnection: close\r\n\r\n")
|
||||||
|
.await
|
||||||
|
.expect("fixture should write response headers");
|
||||||
|
for byte in b"RustFS!" {
|
||||||
|
stream.write_all(&[*byte]).await.expect("fixture should write body progress");
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let client = client_for_endpoint(
|
||||||
|
&endpoint,
|
||||||
|
TransitionClientTimeouts::new(Duration::from_millis(10), Duration::from_secs(1), Duration::from_millis(100)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut opts = GetObjectOptions::default();
|
||||||
|
opts.set_range(0, 6).expect("the probe range should be valid");
|
||||||
|
|
||||||
|
let (_, _, mut reader) = client
|
||||||
|
.get_object_inner("bucket", "probe", &opts)
|
||||||
|
.await
|
||||||
|
.expect("continuous body progress must not be killed by the idle timer");
|
||||||
|
let mut body = Vec::new();
|
||||||
|
reader
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.await
|
||||||
|
.expect("bounded response should be readable");
|
||||||
|
|
||||||
|
assert_eq!(body, b"RustFS!");
|
||||||
|
fixture.await.expect("fixture should join");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ use crate::{
|
|||||||
transition_api::{ReaderImpl, RequestMetadata, TransitionClient, collect_response_body},
|
transition_api::{ReaderImpl, RequestMetadata, TransitionClient, collect_response_body},
|
||||||
};
|
};
|
||||||
use http::{HeaderMap, StatusCode};
|
use http::{HeaderMap, StatusCode};
|
||||||
use http_body_util::BodyExt;
|
|
||||||
use hyper::body::Body;
|
use hyper::body::Body;
|
||||||
use hyper::body::Bytes;
|
use hyper::body::Bytes;
|
||||||
use rustfs_config::MAX_S3_CLIENT_RESPONSE_SIZE;
|
use rustfs_config::MAX_S3_CLIENT_RESPONSE_SIZE;
|
||||||
@@ -124,14 +123,9 @@ impl TransitionClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//let mut list_bucket_result = ListBucketV2Result::default();
|
//let mut list_bucket_result = ListBucketV2Result::default();
|
||||||
let mut body_vec = Vec::new();
|
let body_vec = self
|
||||||
let mut body = resp.into_body();
|
.collect_response_body(resp.into_body(), MAX_S3_CLIENT_RESPONSE_SIZE)
|
||||||
while let Some(frame) = body.frame().await {
|
.await?;
|
||||||
let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
|
||||||
if let Some(data) = frame.data_ref() {
|
|
||||||
body_vec.extend_from_slice(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut list_bucket_result = match quick_xml::de::from_str::<ListBucketV2Result>(&String::from_utf8_lossy(&body_vec)) {
|
let mut list_bucket_result = match quick_xml::de::from_str::<ListBucketV2Result>(&String::from_utf8_lossy(&body_vec)) {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -214,7 +208,9 @@ impl TransitionClient {
|
|||||||
|
|
||||||
let resp_status = resp.status();
|
let resp_status = resp.status();
|
||||||
let headers = resp.headers().clone();
|
let headers = resp.headers().clone();
|
||||||
let body = collect_response_body(resp.into_body(), MAX_S3_CLIENT_RESPONSE_SIZE).await?;
|
let body = self
|
||||||
|
.collect_response_body(resp.into_body(), MAX_S3_CLIENT_RESPONSE_SIZE)
|
||||||
|
.await?;
|
||||||
if resp_status != StatusCode::OK {
|
if resp_status != StatusCode::OK {
|
||||||
return Err(std::io::Error::other(http_resp_to_error_response(
|
return Err(std::io::Error::other(http_resp_to_error_response(
|
||||||
resp_status,
|
resp_status,
|
||||||
@@ -428,6 +424,30 @@ fn decode_s3_name(name: &str, encoding_type: &str) -> Result<String, std::io::Er
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
credentials::{Credentials, SignatureType, Static, Value},
|
||||||
|
transition_api::{BucketLookupType, Options, TransitionClientTimeouts},
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
|
net::TcpListener,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn timeout_test_options() -> Options {
|
||||||
|
Options {
|
||||||
|
creds: Credentials::new(Static(Value {
|
||||||
|
access_key_id: "access-key".to_string(),
|
||||||
|
secret_access_key: "secret-key".to_string(),
|
||||||
|
signer_type: SignatureType::SignatureV4,
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
region: "us-east-1".to_string(),
|
||||||
|
bucket_lookup: BucketLookupType::BucketLookupPath,
|
||||||
|
max_retries: 1,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_versions_xml_preserves_versions_and_delete_markers() {
|
fn list_versions_xml_preserves_versions_and_delete_markers() {
|
||||||
@@ -525,4 +545,56 @@ mod tests {
|
|||||||
assert_eq!(parsed.common_prefixes.len(), 1);
|
assert_eq!(parsed.common_prefixes.len(), 1);
|
||||||
assert_eq!(parsed.common_prefixes[0].prefix, "subdir/");
|
assert_eq!(parsed.common_prefixes[0].prefix, "subdir/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_objects_v2_body_stall_returns_timed_out() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
|
||||||
|
Err(err) => panic!("test listener should bind: {err}"),
|
||||||
|
};
|
||||||
|
let endpoint = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("listener local address should be available")
|
||||||
|
.to_string();
|
||||||
|
let fixture = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("fixture should accept one list request");
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut buffer = [0; 1024];
|
||||||
|
loop {
|
||||||
|
let read = stream.read(&mut buffer).await.expect("fixture should read request headers");
|
||||||
|
assert_ne!(read, 0, "connection closed before request headers were received");
|
||||||
|
request.extend_from_slice(&buffer[..read]);
|
||||||
|
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 512\r\nConnection: close\r\n\r\n<ListBucketResult><Name>warm")
|
||||||
|
.await
|
||||||
|
.expect("fixture should write a partial list response");
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
});
|
||||||
|
let client = TransitionClient::new_with_timeouts(
|
||||||
|
&endpoint,
|
||||||
|
timeout_test_options(),
|
||||||
|
"",
|
||||||
|
TransitionClientTimeouts::new(Duration::from_secs(1), Duration::from_secs(1), Duration::from_millis(50)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fixture client should build");
|
||||||
|
client
|
||||||
|
.bucket_loc_cache
|
||||||
|
.lock()
|
||||||
|
.expect("location cache should lock")
|
||||||
|
.set("bucket", "us-east-1");
|
||||||
|
|
||||||
|
let err = client
|
||||||
|
.list_objects_v2_query("bucket", "", "", false, false, "", "", 1, HeaderMap::new())
|
||||||
|
.await
|
||||||
|
.expect_err("a stalled ListObjectsV2 body must be bounded");
|
||||||
|
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
|
||||||
|
fixture.await.expect("fixture should join");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
#![allow(clippy::all)]
|
#![allow(clippy::all)]
|
||||||
|
|
||||||
use http::{HeaderMap, HeaderName, StatusCode};
|
use http::{HeaderMap, HeaderName, StatusCode};
|
||||||
use http_body_util::BodyExt;
|
|
||||||
use hyper::body::Bytes;
|
use hyper::body::Bytes;
|
||||||
use s3s::S3ErrorCode;
|
use s3s::S3ErrorCode;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -247,14 +246,9 @@ impl TransitionClient {
|
|||||||
// Parse the CreateMultipartUpload response for the UploadId. Returning a
|
// Parse the CreateMultipartUpload response for the UploadId. Returning a
|
||||||
// default (empty) result here made every multipart transition fail at the
|
// default (empty) result here made every multipart transition fail at the
|
||||||
// first UploadPart with "UploadID cannot be empty" (rustfs/rustfs#4811).
|
// first UploadPart with "UploadID cannot be empty" (rustfs/rustfs#4811).
|
||||||
let mut body_vec = Vec::new();
|
let body_vec = self
|
||||||
let mut body = resp.into_body();
|
.collect_response_body(resp.into_body(), rustfs_config::MAX_S3_CLIENT_RESPONSE_SIZE)
|
||||||
while let Some(frame) = body.frame().await {
|
.await?;
|
||||||
let frame = frame.map_err(|e| std::io::Error::other(e.to_string()))?;
|
|
||||||
if let Some(data) = frame.data_ref() {
|
|
||||||
body_vec.extend_from_slice(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let initiate_multipart_upload_result =
|
let initiate_multipart_upload_result =
|
||||||
quick_xml::de::from_str::<InitiateMultipartUploadResult>(&String::from_utf8_lossy(&body_vec))
|
quick_xml::de::from_str::<InitiateMultipartUploadResult>(&String::from_utf8_lossy(&body_vec))
|
||||||
.map_err(|e| std::io::Error::other(format!("failed to parse CreateMultipartUpload response: {e}")))?;
|
.map_err(|e| std::io::Error::other(format!("failed to parse CreateMultipartUpload response: {e}")))?;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
#![allow(clippy::all)]
|
#![allow(clippy::all)]
|
||||||
|
|
||||||
use http::{HeaderMap, HeaderValue, Method, StatusCode};
|
use http::{HeaderMap, HeaderValue, Method, StatusCode};
|
||||||
use http_body_util::BodyExt;
|
|
||||||
use hyper::body::Body;
|
use hyper::body::Body;
|
||||||
use hyper::body::Bytes;
|
use hyper::body::Bytes;
|
||||||
use rustfs_utils::HashAlgorithm;
|
use rustfs_utils::HashAlgorithm;
|
||||||
@@ -351,14 +350,9 @@ impl TransitionClient {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut body_vec = Vec::new();
|
let body_vec = self
|
||||||
let mut body = resp.into_body();
|
.collect_response_body(resp.into_body(), rustfs_config::MAX_S3_CLIENT_RESPONSE_SIZE)
|
||||||
while let Some(frame) = body.frame().await {
|
.await?;
|
||||||
let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
|
||||||
if let Some(data) = frame.data_ref() {
|
|
||||||
body_vec.extend_from_slice(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
process_remove_multi_objects_response(
|
process_remove_multi_objects_response(
|
||||||
ReaderImpl::Body(Bytes::from(body_vec)),
|
ReaderImpl::Body(Bytes::from(body_vec)),
|
||||||
bucket_name,
|
bucket_name,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
#![allow(clippy::all)]
|
#![allow(clippy::all)]
|
||||||
|
|
||||||
use http::{HeaderMap, HeaderValue, StatusCode};
|
use http::{HeaderMap, HeaderValue, StatusCode};
|
||||||
use http_body_util::BodyExt;
|
|
||||||
use hyper::body::Body;
|
use hyper::body::Body;
|
||||||
use hyper::body::Bytes;
|
use hyper::body::Bytes;
|
||||||
use rustfs_utils::EMPTY_STRING_SHA256_HASH;
|
use rustfs_utils::EMPTY_STRING_SHA256_HASH;
|
||||||
@@ -119,14 +118,9 @@ impl TransitionClient {
|
|||||||
let resp_status = resp.status();
|
let resp_status = resp.status();
|
||||||
let h = resp.headers().clone();
|
let h = resp.headers().clone();
|
||||||
|
|
||||||
let mut body_vec = Vec::new();
|
let body_vec = self
|
||||||
let mut body = resp.into_body();
|
.collect_response_body(resp.into_body(), rustfs_config::MAX_S3_CLIENT_RESPONSE_SIZE)
|
||||||
while let Some(frame) = body.frame().await {
|
.await?;
|
||||||
let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
|
||||||
if let Some(data) = frame.data_ref() {
|
|
||||||
body_vec.extend_from_slice(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let resperr = http_resp_to_error_response(resp_status, &h, body_vec, bucket_name, "");
|
let resperr = http_resp_to_error_response(resp_status, &h, body_vec, bucket_name, "");
|
||||||
|
|
||||||
warn!("bucket exists, resperr: {:?}", resperr);
|
warn!("bucket exists, resperr: {:?}", resperr);
|
||||||
@@ -170,11 +164,13 @@ impl TransitionClient {
|
|||||||
let resp_status = resp.status();
|
let resp_status = resp.status();
|
||||||
let h = resp.headers().clone();
|
let h = resp.headers().clone();
|
||||||
|
|
||||||
let body_vec = collect_response_body(resp.into_body(), rustfs_config::MAX_S3_CLIENT_RESPONSE_SIZE).await?;
|
let body_vec = self
|
||||||
|
.collect_response_body(resp.into_body(), rustfs_config::MAX_S3_CLIENT_RESPONSE_SIZE)
|
||||||
|
.await?;
|
||||||
parse_bucket_versioning_response(resp_status, &h, body_vec, bucket_name)
|
parse_bucket_versioning_response(resp_status, &h, body_vec, bucket_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(err) => Err(std::io::Error::other(err)),
|
Err(err) => Err(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,8 +270,14 @@ impl TransitionClient {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::parse_bucket_versioning_response;
|
use super::parse_bucket_versioning_response;
|
||||||
|
use crate::{
|
||||||
|
credentials::{Credentials, SignatureType, Static, Value},
|
||||||
|
transition_api::{BucketLookupType, Options, TransitionClient, TransitionClientTimeouts},
|
||||||
|
};
|
||||||
use http::{HeaderMap, StatusCode};
|
use http::{HeaderMap, StatusCode};
|
||||||
use s3s::dto::BucketVersioningStatus;
|
use s3s::dto::BucketVersioningStatus;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::{io::AsyncReadExt, net::TcpListener};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_bucket_versioning_statuses_mfa_delete_and_unversioned_state() {
|
fn parses_bucket_versioning_statuses_mfa_delete_and_unversioned_state() {
|
||||||
@@ -338,4 +340,63 @@ mod tests {
|
|||||||
assert_eq!(strict_err.kind(), std::io::ErrorKind::InvalidData);
|
assert_eq!(strict_err.kind(), std::io::ErrorKind::InvalidData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_bucket_versioning_preserves_request_timeout_kind() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
|
||||||
|
Err(err) => panic!("test listener should bind: {err}"),
|
||||||
|
};
|
||||||
|
let endpoint = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("listener local address should be available")
|
||||||
|
.to_string();
|
||||||
|
let fixture = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("fixture should accept one versioning request");
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut buffer = [0; 1024];
|
||||||
|
loop {
|
||||||
|
let read = stream.read(&mut buffer).await.expect("fixture should read request headers");
|
||||||
|
assert_ne!(read, 0, "connection closed before request headers were received");
|
||||||
|
request.extend_from_slice(&buffer[..read]);
|
||||||
|
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
});
|
||||||
|
let client = TransitionClient::new_with_timeouts(
|
||||||
|
&endpoint,
|
||||||
|
Options {
|
||||||
|
creds: Credentials::new(Static(Value {
|
||||||
|
access_key_id: "access-key".to_string(),
|
||||||
|
secret_access_key: "secret-key".to_string(),
|
||||||
|
signer_type: SignatureType::SignatureV4,
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
region: "us-east-1".to_string(),
|
||||||
|
bucket_lookup: BucketLookupType::BucketLookupPath,
|
||||||
|
max_retries: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
TransitionClientTimeouts::new(Duration::from_secs(1), Duration::from_millis(50), Duration::from_secs(1)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fixture client should build");
|
||||||
|
client
|
||||||
|
.bucket_loc_cache
|
||||||
|
.lock()
|
||||||
|
.expect("location cache should lock")
|
||||||
|
.set("bucket", "us-east-1");
|
||||||
|
|
||||||
|
let err = client
|
||||||
|
.get_bucket_versioning("bucket")
|
||||||
|
.await
|
||||||
|
.expect_err("a stalled versioning request must time out");
|
||||||
|
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
|
||||||
|
fixture.await.expect("fixture should join");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ use crate::{
|
|||||||
transition_api::{CreateBucketConfiguration, LocationConstraint, TransitionClient},
|
transition_api::{CreateBucketConfiguration, LocationConstraint, TransitionClient},
|
||||||
};
|
};
|
||||||
use http::Request;
|
use http::Request;
|
||||||
use http_body_util::BodyExt;
|
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use hyper::body::Body;
|
use hyper::body::Body;
|
||||||
use hyper::body::Bytes;
|
use hyper::body::Bytes;
|
||||||
@@ -86,7 +85,7 @@ impl TransitionClient {
|
|||||||
let req = self.get_bucket_location_request(bucket_name)?;
|
let req = self.get_bucket_location_request(bucket_name)?;
|
||||||
|
|
||||||
let mut resp = self.doit(req).await?;
|
let mut resp = self.doit(req).await?;
|
||||||
location = process_bucket_location_response(resp, bucket_name, &self.tier_type).await?;
|
location = process_bucket_location_response(self, resp, bucket_name, &self.tier_type).await?;
|
||||||
{
|
{
|
||||||
if let Ok(mut bucket_loc_cache) = self.bucket_loc_cache.lock() {
|
if let Ok(mut bucket_loc_cache) = self.bucket_loc_cache.lock() {
|
||||||
bucket_loc_cache.set(bucket_name, &location);
|
bucket_loc_cache.set(bucket_name, &location);
|
||||||
@@ -198,6 +197,7 @@ impl TransitionClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn process_bucket_location_response(
|
async fn process_bucket_location_response(
|
||||||
|
client: &TransitionClient,
|
||||||
mut resp: http::Response<Incoming>,
|
mut resp: http::Response<Incoming>,
|
||||||
bucket_name: &str,
|
bucket_name: &str,
|
||||||
tier_type: &str,
|
tier_type: &str,
|
||||||
@@ -237,14 +237,9 @@ async fn process_bucket_location_response(
|
|||||||
}
|
}
|
||||||
//}
|
//}
|
||||||
|
|
||||||
let mut body_vec = Vec::new();
|
let body_vec = client
|
||||||
let mut body = resp.into_body();
|
.collect_response_body(resp.into_body(), MAX_S3_CLIENT_RESPONSE_SIZE)
|
||||||
while let Some(frame) = body.frame().await {
|
.await?;
|
||||||
let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
|
||||||
if let Some(data) = frame.data_ref() {
|
|
||||||
body_vec.extend_from_slice(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut location = "".to_string();
|
let mut location = "".to_string();
|
||||||
if tier_type == "huaweicloud" {
|
if tier_type == "huaweicloud" {
|
||||||
if let Ok(body_str) = String::from_utf8(body_vec) {
|
if let Ok(body_str) = String::from_utf8(body_vec) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ use http::{
|
|||||||
request::{Builder, Request},
|
request::{Builder, Request},
|
||||||
};
|
};
|
||||||
use http_body::Body;
|
use http_body::Body;
|
||||||
use http_body_util::{BodyExt, LengthLimitError, Limited};
|
use http_body_util::BodyExt;
|
||||||
use hyper::body::Bytes;
|
use hyper::body::Bytes;
|
||||||
use hyper::body::Incoming;
|
use hyper::body::Incoming;
|
||||||
use hyper_rustls::{ConfigBuilderExt, HttpsConnector};
|
use hyper_rustls::{ConfigBuilderExt, HttpsConnector};
|
||||||
@@ -67,10 +67,12 @@ use s3s::dto::Owner;
|
|||||||
use s3s::dto::ReplicationStatus;
|
use s3s::dto::ReplicationStatus;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
|
use std::error::Error as StdError;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::atomic::{AtomicI32, Ordering};
|
use std::sync::atomic::{AtomicI32, Ordering};
|
||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
|
use std::time::Duration as StdDuration;
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
@@ -79,28 +81,108 @@ use time::Duration;
|
|||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tokio::io::BufReader;
|
use tokio::io::BufReader;
|
||||||
use tokio::io::{AsyncRead, AsyncReadExt};
|
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||||
use tracing::{debug, error, warn};
|
use tracing::{debug, error, trace, warn};
|
||||||
use url::{Url, form_urlencoded};
|
use url::{Url, form_urlencoded};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const C_USER_AGENT: &str = "RustFS (linux; x86)";
|
const C_USER_AGENT: &str = "RustFS (linux; x86)";
|
||||||
pub const MAX_S3_ERROR_RESPONSE_SIZE: usize = 64 * 1024;
|
pub const MAX_S3_ERROR_RESPONSE_SIZE: usize = 64 * 1024;
|
||||||
|
const EVENT_TIER_REMOTE_TRANSPORT: &str = "tier_remote_transport";
|
||||||
|
const LOG_COMPONENT_S3_CLIENT: &str = "s3_client";
|
||||||
|
const LOG_SUBSYSTEM_TIER: &str = "tier";
|
||||||
|
|
||||||
const SUCCESS_STATUS: [StatusCode; 3] = [StatusCode::OK, StatusCode::NO_CONTENT, StatusCode::PARTIAL_CONTENT];
|
const SUCCESS_STATUS: [StatusCode; 3] = [StatusCode::OK, StatusCode::NO_CONTENT, StatusCode::PARTIAL_CONTENT];
|
||||||
|
|
||||||
|
fn response_body_exceeds_limit_error() -> std::io::Error {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::InvalidData, "remote tier response body exceeds limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_tier_timeout_error(message: &'static str) -> std::io::Error {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::TimedOut, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_chain_has_io_kind(error: &(dyn StdError + 'static), kind: std::io::ErrorKind) -> bool {
|
||||||
|
let mut current = Some(error);
|
||||||
|
while let Some(error) = current {
|
||||||
|
if error
|
||||||
|
.downcast_ref::<std::io::Error>()
|
||||||
|
.is_some_and(|io_error| io_error.kind() == kind)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = error.source();
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transition_transport_error(err: hyper_util::client::legacy::Error) -> std::io::Error {
|
||||||
|
if source_chain_has_io_kind(&err, std::io::ErrorKind::TimedOut) {
|
||||||
|
return remote_tier_timeout_error("remote tier connection timed out");
|
||||||
|
}
|
||||||
|
std::io::Error::other(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next_response_body_data<B>(
|
||||||
|
mut body: Pin<&mut B>,
|
||||||
|
idle_timeout: Option<StdDuration>,
|
||||||
|
) -> Result<Option<Bytes>, std::io::Error>
|
||||||
|
where
|
||||||
|
B: Body<Data = Bytes>,
|
||||||
|
B::Error: Into<Box<dyn StdError + Send + Sync>>,
|
||||||
|
{
|
||||||
|
let next_nonempty_data = async {
|
||||||
|
loop {
|
||||||
|
let Some(frame) = std::future::poll_fn(|cx| body.as_mut().poll_frame(cx)).await else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let frame = frame.map_err(std::io::Error::other)?;
|
||||||
|
let Ok(data) = frame.into_data() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !data.is_empty() {
|
||||||
|
return Ok(Some(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(idle_timeout) = idle_timeout {
|
||||||
|
tokio::time::timeout(idle_timeout, next_nonempty_data)
|
||||||
|
.await
|
||||||
|
.map_err(|_| remote_tier_timeout_error("remote tier response body stalled"))?
|
||||||
|
} else {
|
||||||
|
next_nonempty_data.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_response_body_inner<B>(
|
||||||
|
body: B,
|
||||||
|
limit: Option<usize>,
|
||||||
|
idle_timeout: Option<StdDuration>,
|
||||||
|
) -> Result<Vec<u8>, std::io::Error>
|
||||||
|
where
|
||||||
|
B: Body<Data = Bytes>,
|
||||||
|
B::Error: Into<Box<dyn StdError + Send + Sync>>,
|
||||||
|
{
|
||||||
|
let mut body_vec = Vec::new();
|
||||||
|
let mut body = std::pin::pin!(body);
|
||||||
|
while let Some(data) = next_response_body_data(body.as_mut(), idle_timeout).await? {
|
||||||
|
let Some(new_len) = body_vec.len().checked_add(data.len()) else {
|
||||||
|
return Err(response_body_exceeds_limit_error());
|
||||||
|
};
|
||||||
|
if limit.is_some_and(|limit| new_len > limit) {
|
||||||
|
return Err(response_body_exceeds_limit_error());
|
||||||
|
}
|
||||||
|
body_vec.extend_from_slice(&data);
|
||||||
|
}
|
||||||
|
Ok(body_vec)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn collect_response_body<B>(body: B, limit: usize) -> Result<Vec<u8>, std::io::Error>
|
pub async fn collect_response_body<B>(body: B, limit: usize) -> Result<Vec<u8>, std::io::Error>
|
||||||
where
|
where
|
||||||
B: Body<Data = Bytes>,
|
B: Body<Data = Bytes>,
|
||||||
B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
|
B::Error: Into<Box<dyn StdError + Send + Sync>>,
|
||||||
{
|
{
|
||||||
let body = Limited::new(body, limit).collect().await.map_err(|err| {
|
collect_response_body_inner(body, Some(limit), None).await
|
||||||
if err.is::<LengthLimitError>() {
|
|
||||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "remote tier response body exceeds limit")
|
|
||||||
} else {
|
|
||||||
std::io::Error::other(err)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(body.to_bytes().to_vec())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const C_UNKNOWN: i32 = -1;
|
const C_UNKNOWN: i32 = -1;
|
||||||
@@ -196,6 +278,62 @@ pub struct TransitionClient {
|
|||||||
pub trailing_header_support: bool,
|
pub trailing_header_support: bool,
|
||||||
pub max_retries: i64,
|
pub max_retries: i64,
|
||||||
pub tier_type: String,
|
pub tier_type: String,
|
||||||
|
pub timeouts: TransitionClientTimeouts,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct TransitionClientTimeouts {
|
||||||
|
pub connect_timeout: StdDuration,
|
||||||
|
pub request_timeout: StdDuration,
|
||||||
|
pub response_body_idle_timeout: StdDuration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransitionClientTimeouts {
|
||||||
|
pub const fn new(
|
||||||
|
connect_timeout: StdDuration,
|
||||||
|
request_timeout: StdDuration,
|
||||||
|
response_body_idle_timeout: StdDuration,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
connect_timeout,
|
||||||
|
request_timeout,
|
||||||
|
response_body_idle_timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(self) -> Result<Self, std::io::Error> {
|
||||||
|
if self.connect_timeout.is_zero() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"remote tier connect timeout must be greater than zero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.request_timeout.is_zero() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"remote tier request timeout must be greater than zero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.response_body_idle_timeout.is_zero() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"remote tier response body idle timeout must be greater than zero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TransitionClientTimeouts {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
connect_timeout: StdDuration::from_secs(rustfs_config::DEFAULT_TIER_REMOTE_CONNECT_TIMEOUT_SECS),
|
||||||
|
request_timeout: StdDuration::from_secs(rustfs_config::DEFAULT_TIER_REMOTE_REQUEST_TIMEOUT_SECS),
|
||||||
|
response_body_idle_timeout: StdDuration::from_secs(
|
||||||
|
rustfs_config::DEFAULT_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -288,12 +426,28 @@ async fn build_tls_config() -> Result<rustls::ClientConfig, std::io::Error> {
|
|||||||
|
|
||||||
impl TransitionClient {
|
impl TransitionClient {
|
||||||
pub async fn new(endpoint: &str, opts: Options, tier_type: &str) -> Result<TransitionClient, std::io::Error> {
|
pub async fn new(endpoint: &str, opts: Options, tier_type: &str) -> Result<TransitionClient, std::io::Error> {
|
||||||
let client = Self::private_new(endpoint, opts, tier_type).await?;
|
Self::private_new(endpoint, opts, tier_type, TransitionClientTimeouts::default()).await
|
||||||
|
|
||||||
Ok(client)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn private_new(endpoint: &str, opts: Options, tier_type: &str) -> Result<TransitionClient, std::io::Error> {
|
/// Builds a transition client with explicit transport timeout budgets.
|
||||||
|
///
|
||||||
|
/// [`Self::new`] keeps the historical constructor surface and uses the
|
||||||
|
/// production defaults from [`TransitionClientTimeouts::default`].
|
||||||
|
pub async fn new_with_timeouts(
|
||||||
|
endpoint: &str,
|
||||||
|
opts: Options,
|
||||||
|
tier_type: &str,
|
||||||
|
timeouts: TransitionClientTimeouts,
|
||||||
|
) -> Result<TransitionClient, std::io::Error> {
|
||||||
|
Self::private_new(endpoint, opts, tier_type, timeouts).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn private_new(
|
||||||
|
endpoint: &str,
|
||||||
|
opts: Options,
|
||||||
|
tier_type: &str,
|
||||||
|
timeouts: TransitionClientTimeouts,
|
||||||
|
) -> Result<TransitionClient, std::io::Error> {
|
||||||
if rustls::crypto::CryptoProvider::get_default().is_none() {
|
if rustls::crypto::CryptoProvider::get_default().is_none() {
|
||||||
// No default provider is set yet; try to install aws-lc-rs.
|
// No default provider is set yet; try to install aws-lc-rs.
|
||||||
// `install_default` can only fail if another thread races us and installs a provider
|
// `install_default` can only fail if another thread races us and installs a provider
|
||||||
@@ -306,15 +460,19 @@ impl TransitionClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let endpoint_url = get_endpoint_url(endpoint, opts.secure)?;
|
let endpoint_url = get_endpoint_url(endpoint, opts.secure)?;
|
||||||
|
let timeouts = timeouts.validate()?;
|
||||||
|
|
||||||
let tls = build_tls_config().await?;
|
let tls = build_tls_config().await?;
|
||||||
|
|
||||||
|
let mut http = HttpConnector::new();
|
||||||
|
http.enforce_http(false);
|
||||||
|
http.set_connect_timeout(Some(timeouts.connect_timeout));
|
||||||
let https = hyper_rustls::HttpsConnectorBuilder::new()
|
let https = hyper_rustls::HttpsConnectorBuilder::new()
|
||||||
.with_tls_config(tls)
|
.with_tls_config(tls)
|
||||||
.https_or_http()
|
.https_or_http()
|
||||||
.enable_http1()
|
.enable_http1()
|
||||||
.enable_http2()
|
.enable_http2()
|
||||||
.build();
|
.wrap_connector(http);
|
||||||
let http_client = Client::builder(TokioExecutor::new()).build(https);
|
let http_client = Client::builder(TokioExecutor::new()).build(https);
|
||||||
|
|
||||||
let mut client = TransitionClient {
|
let mut client = TransitionClient {
|
||||||
@@ -337,6 +495,7 @@ impl TransitionClient {
|
|||||||
trailing_header_support: opts.trailing_headers,
|
trailing_header_support: opts.trailing_headers,
|
||||||
max_retries: opts.max_retries,
|
max_retries: opts.max_retries,
|
||||||
tier_type: tier_type.to_string(),
|
tier_type: tier_type.to_string(),
|
||||||
|
timeouts,
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -501,29 +660,43 @@ impl TransitionClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn doit(&self, req: Request<s3s::Body>) -> Result<Response<Incoming>, std::io::Error> {
|
pub async fn doit(&self, req: Request<s3s::Body>) -> Result<Response<Incoming>, std::io::Error> {
|
||||||
let req_method;
|
|
||||||
let req_uri;
|
|
||||||
let resp;
|
|
||||||
let http_client = self.http_client.clone();
|
let http_client = self.http_client.clone();
|
||||||
{
|
let req_method = req.method().clone();
|
||||||
req_method = req.method().clone();
|
let resp = tokio::time::timeout(self.timeouts.request_timeout, http_client.request(req)).await;
|
||||||
req_uri = req.uri().clone();
|
|
||||||
|
|
||||||
debug!("endpoint_url: {}", self.endpoint_url.as_str().to_string());
|
|
||||||
resp = http_client.request(req);
|
|
||||||
}
|
|
||||||
let resp = resp.await;
|
|
||||||
debug!("http_client url: {} {}", req_method, req_uri);
|
|
||||||
if let Err(err) = resp {
|
|
||||||
error!("http_client call error: {:?}", err);
|
|
||||||
return Err(std::io::Error::other(err));
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp = match resp {
|
let resp = match resp {
|
||||||
Ok(r) => r,
|
Ok(Ok(resp)) => resp,
|
||||||
Err(_) => return Err(std::io::Error::other("Unexpected error in response")),
|
Ok(Err(err)) => {
|
||||||
|
let err = transition_transport_error(err);
|
||||||
|
error!(
|
||||||
|
event = EVENT_TIER_REMOTE_TRANSPORT,
|
||||||
|
component = LOG_COMPONENT_S3_CLIENT,
|
||||||
|
subsystem = LOG_SUBSYSTEM_TIER,
|
||||||
|
method = %req_method,
|
||||||
|
error_kind = ?err.kind(),
|
||||||
|
"remote tier request failed"
|
||||||
|
);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn!(
|
||||||
|
event = EVENT_TIER_REMOTE_TRANSPORT,
|
||||||
|
component = LOG_COMPONENT_S3_CLIENT,
|
||||||
|
subsystem = LOG_SUBSYSTEM_TIER,
|
||||||
|
method = %req_method,
|
||||||
|
timeout_ms = self.timeouts.request_timeout.as_millis(),
|
||||||
|
"remote tier request timed out before response headers"
|
||||||
|
);
|
||||||
|
return Err(remote_tier_timeout_error("remote tier request timed out before response headers"));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
debug!(status = %resp.status(), "remote tier response received");
|
trace!(
|
||||||
|
event = EVENT_TIER_REMOTE_TRANSPORT,
|
||||||
|
component = LOG_COMPONENT_S3_CLIENT,
|
||||||
|
subsystem = LOG_SUBSYSTEM_TIER,
|
||||||
|
method = %req_method,
|
||||||
|
status = %resp.status(),
|
||||||
|
"remote tier response received"
|
||||||
|
);
|
||||||
|
|
||||||
//let b = resp.body_mut().store_all_unlimited().await.unwrap().to_vec();
|
//let b = resp.body_mut().store_all_unlimited().await.unwrap().to_vec();
|
||||||
//debug!("http_resp_body: {}", String::from_utf8(b).unwrap());
|
//debug!("http_resp_body: {}", String::from_utf8(b).unwrap());
|
||||||
@@ -537,7 +710,15 @@ impl TransitionClient {
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string();
|
.to_string();
|
||||||
warn!(status = %status, request_id, "remote tier request rejected");
|
warn!(
|
||||||
|
event = EVENT_TIER_REMOTE_TRANSPORT,
|
||||||
|
component = LOG_COMPONENT_S3_CLIENT,
|
||||||
|
subsystem = LOG_SUBSYSTEM_TIER,
|
||||||
|
method = %req_method,
|
||||||
|
status = %status,
|
||||||
|
request_id,
|
||||||
|
"remote tier request rejected"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(resp)
|
Ok(resp)
|
||||||
}
|
}
|
||||||
@@ -581,7 +762,9 @@ impl TransitionClient {
|
|||||||
let resp_status = resp.status();
|
let resp_status = resp.status();
|
||||||
let h = resp.headers().clone();
|
let h = resp.headers().clone();
|
||||||
|
|
||||||
let body_vec = collect_response_body(resp.into_body(), MAX_S3_ERROR_RESPONSE_SIZE).await?;
|
let body_vec = self
|
||||||
|
.collect_response_body(resp.into_body(), MAX_S3_ERROR_RESPONSE_SIZE)
|
||||||
|
.await?;
|
||||||
let parsed_error =
|
let parsed_error =
|
||||||
http_resp_to_error_response(resp_status, &h, body_vec, &metadata.bucket_name, &metadata.object_name);
|
http_resp_to_error_response(resp_status, &h, body_vec, &metadata.bucket_name, &metadata.object_name);
|
||||||
let routing_region = parsed_error.region;
|
let routing_region = parsed_error.region;
|
||||||
@@ -635,6 +818,22 @@ impl TransitionClient {
|
|||||||
Err(std::io::Error::other("remote tier request did not produce a response"))
|
Err(std::io::Error::other("remote tier request did not produce a response"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn collect_response_body<B>(&self, body: B, limit: usize) -> Result<Vec<u8>, std::io::Error>
|
||||||
|
where
|
||||||
|
B: Body<Data = Bytes>,
|
||||||
|
B::Error: Into<Box<dyn StdError + Send + Sync>>,
|
||||||
|
{
|
||||||
|
collect_response_body_inner(body, Some(limit), Some(self.timeouts.response_body_idle_timeout)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn collect_response_body_unbounded<B>(&self, body: B) -> Result<Vec<u8>, std::io::Error>
|
||||||
|
where
|
||||||
|
B: Body<Data = Bytes>,
|
||||||
|
B::Error: Into<Box<dyn StdError + Send + Sync>>,
|
||||||
|
{
|
||||||
|
collect_response_body_inner(body, None, Some(self.timeouts.response_body_idle_timeout)).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn new_request(
|
async fn new_request(
|
||||||
&self,
|
&self,
|
||||||
method: &http::Method,
|
method: &http::Method,
|
||||||
@@ -1504,12 +1703,17 @@ pub struct CreateBucketConfiguration {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
MAX_S3_CLIENT_RESPONSE_SIZE, MAX_S3_ERROR_RESPONSE_SIZE, SignatureType, build_tls_config, collect_response_body,
|
MAX_S3_CLIENT_RESPONSE_SIZE, MAX_S3_ERROR_RESPONSE_SIZE, SignatureType, build_tls_config, collect_response_body,
|
||||||
signer_error_to_io_error, to_object_info_for_provider, validate_header_values, with_rustls_init_guard,
|
collect_response_body_inner, signer_error_to_io_error, to_object_info_for_provider, validate_header_values,
|
||||||
|
with_rustls_init_guard,
|
||||||
};
|
};
|
||||||
use crate::provider_versions::{BucketVersioningState, ProviderVersionCapabilities, RemoteVersion};
|
use crate::provider_versions::{BucketVersioningState, ProviderVersionCapabilities, RemoteVersion};
|
||||||
use http::{HeaderMap, HeaderValue};
|
use futures::stream;
|
||||||
use http_body_util::Full;
|
use http::{HeaderMap, HeaderValue, Request};
|
||||||
|
use http_body::Frame;
|
||||||
|
use http_body_util::{Full, StreamBody};
|
||||||
use hyper::body::Bytes;
|
use hyper::body::Bytes;
|
||||||
|
use std::time::Duration as StdDuration;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1540,6 +1744,77 @@ mod tests {
|
|||||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_data_frames_do_not_reset_the_body_idle_timeout() {
|
||||||
|
let frames = stream::unfold((), |_| async {
|
||||||
|
tokio::time::sleep(StdDuration::from_millis(10)).await;
|
||||||
|
Some((Ok::<_, std::io::Error>(Frame::data(Bytes::new())), ()))
|
||||||
|
});
|
||||||
|
let body = StreamBody::new(Box::pin(frames));
|
||||||
|
|
||||||
|
let err = tokio::time::timeout(
|
||||||
|
StdDuration::from_millis(200),
|
||||||
|
collect_response_body_inner(body, Some(1), Some(StdDuration::from_millis(50))),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the collector should enforce its own body idle timeout")
|
||||||
|
.expect_err("empty frames must not count as body progress");
|
||||||
|
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn public_body_collector_accepts_non_unpin_bodies() {
|
||||||
|
let body = StreamBody::new(stream::once(async { Ok::<_, std::io::Error>(Frame::data(Bytes::from_static(b"ok"))) }));
|
||||||
|
|
||||||
|
let collected = collect_response_body(body, 2)
|
||||||
|
.await
|
||||||
|
.expect("the public collector should pin non-Unpin bodies internally");
|
||||||
|
|
||||||
|
assert_eq!(collected, b"ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn https_endpoints_reach_the_transport_connector() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return,
|
||||||
|
Err(err) => panic!("test listener should bind: {err}"),
|
||||||
|
};
|
||||||
|
let endpoint = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("listener local address should be available")
|
||||||
|
.to_string();
|
||||||
|
let accepted = tokio::spawn(async move {
|
||||||
|
let (stream, _) = tokio::time::timeout(StdDuration::from_secs(1), listener.accept())
|
||||||
|
.await
|
||||||
|
.expect("HTTPS connector should reach the TCP listener")
|
||||||
|
.expect("fixture should accept the HTTPS connection");
|
||||||
|
drop(stream);
|
||||||
|
});
|
||||||
|
let client = super::TransitionClient::new_with_timeouts(
|
||||||
|
&endpoint,
|
||||||
|
super::Options {
|
||||||
|
secure: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
super::TransitionClientTimeouts::new(StdDuration::from_secs(1), StdDuration::from_secs(1), StdDuration::from_secs(1)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fixture client should build");
|
||||||
|
let request = Request::builder()
|
||||||
|
.uri(format!("https://{endpoint}/"))
|
||||||
|
.body(s3s::Body::empty())
|
||||||
|
.expect("fixture request should build");
|
||||||
|
|
||||||
|
client
|
||||||
|
.doit(request)
|
||||||
|
.await
|
||||||
|
.expect_err("the fixture closes before completing the TLS handshake");
|
||||||
|
accepted.await.expect("fixture should join");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rustls_guard_converts_panics_to_io_errors() {
|
fn rustls_guard_converts_panics_to_io_errors() {
|
||||||
let err = with_rustls_init_guard(|| -> Result<(), std::io::Error> { panic!("missing provider") })
|
let err = with_rustls_init_guard(|| -> Result<(), std::io::Error> { panic!("missing provider") })
|
||||||
@@ -1573,6 +1848,18 @@ mod tests {
|
|||||||
assert!(outcome.is_ok(), "provider install guard must not panic when a provider is already set");
|
assert!(outcome.is_ok(), "provider install guard must not panic when a provider is already set");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transition_timeouts_reject_zero_budgets() {
|
||||||
|
for timeouts in [
|
||||||
|
super::TransitionClientTimeouts::new(StdDuration::ZERO, StdDuration::from_secs(1), StdDuration::from_secs(1)),
|
||||||
|
super::TransitionClientTimeouts::new(StdDuration::from_secs(1), StdDuration::ZERO, StdDuration::from_secs(1)),
|
||||||
|
super::TransitionClientTimeouts::new(StdDuration::from_secs(1), StdDuration::from_secs(1), StdDuration::ZERO),
|
||||||
|
] {
|
||||||
|
let err = timeouts.validate().expect_err("zero timeout budgets must fail closed");
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_header_values_returns_header_name_for_non_utf8_values() {
|
fn validate_header_values_returns_header_name_for_non_utf8_values() {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ pub(crate) async fn read_config_revision<S: ScannerObjectIO>(store: Arc<S>, path
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub(crate) struct DataUsageCacheRevisions {
|
pub(crate) struct DataUsageCacheRevisions {
|
||||||
main: DataUsageCacheRevision,
|
main: DataUsageCacheRevision,
|
||||||
backup: Option<DataUsageCacheRevision>,
|
backup: Option<DataUsageCacheRevision>,
|
||||||
@@ -503,6 +503,10 @@ pub struct DataUsageCacheInfo {
|
|||||||
pub lkg_leader_epoch: Option<u64>,
|
pub lkg_leader_epoch: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub lkg_scan_plan_digest: Option<DataUsageScanPlanDigest>,
|
pub lkg_scan_plan_digest: Option<DataUsageScanPlanDigest>,
|
||||||
|
/// Activity-sensitive identity for same-cycle set snapshot reuse. The
|
||||||
|
/// structural plan remains reusable across ordinary bucket writes.
|
||||||
|
#[serde(default)]
|
||||||
|
pub scan_execution_digest: Option<DataUsageScanPlanDigest>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Serialize for DataUsageCacheInfo {
|
impl Serialize for DataUsageCacheInfo {
|
||||||
@@ -519,7 +523,8 @@ impl Serialize for DataUsageCacheInfo {
|
|||||||
+ usize::from(self.lkg_next_cycle.is_some())
|
+ usize::from(self.lkg_next_cycle.is_some())
|
||||||
+ usize::from(self.lkg_last_update.is_some())
|
+ usize::from(self.lkg_last_update.is_some())
|
||||||
+ usize::from(self.lkg_leader_epoch.is_some())
|
+ usize::from(self.lkg_leader_epoch.is_some())
|
||||||
+ usize::from(self.lkg_scan_plan_digest.is_some());
|
+ usize::from(self.lkg_scan_plan_digest.is_some())
|
||||||
|
+ usize::from(self.scan_execution_digest.is_some());
|
||||||
let mut state = serializer.serialize_map(Some(field_count))?;
|
let mut state = serializer.serialize_map(Some(field_count))?;
|
||||||
state.serialize_entry("name", &self.name)?;
|
state.serialize_entry("name", &self.name)?;
|
||||||
state.serialize_entry("next_cycle", &self.next_cycle)?;
|
state.serialize_entry("next_cycle", &self.next_cycle)?;
|
||||||
@@ -558,6 +563,9 @@ impl Serialize for DataUsageCacheInfo {
|
|||||||
if let Some(scan_plan_digest) = self.lkg_scan_plan_digest {
|
if let Some(scan_plan_digest) = self.lkg_scan_plan_digest {
|
||||||
state.serialize_entry("lkg_scan_plan_digest", &scan_plan_digest)?;
|
state.serialize_entry("lkg_scan_plan_digest", &scan_plan_digest)?;
|
||||||
}
|
}
|
||||||
|
if let Some(scan_execution_digest) = self.scan_execution_digest {
|
||||||
|
state.serialize_entry("scan_execution_digest", &scan_execution_digest)?;
|
||||||
|
}
|
||||||
state.end()
|
state.end()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1067,6 +1067,7 @@ fn test_data_usage_cache_info_deserialize_defaults_scan_resume_after() {
|
|||||||
assert!(decoded.source.is_none());
|
assert!(decoded.source.is_none());
|
||||||
assert!(!decoded.snapshot_complete);
|
assert!(!decoded.snapshot_complete);
|
||||||
assert!(decoded.scan_plan_digest.is_none());
|
assert!(decoded.scan_plan_digest.is_none());
|
||||||
|
assert!(decoded.scan_execution_digest.is_none());
|
||||||
assert_eq!(decoded.cache_key_format, 0);
|
assert_eq!(decoded.cache_key_format, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1109,6 +1110,7 @@ fn test_data_usage_cache_info_unmarshal_old_msgpack_defaults_scan_resume_after()
|
|||||||
assert!(decoded.source.is_none());
|
assert!(decoded.source.is_none());
|
||||||
assert!(!decoded.snapshot_complete);
|
assert!(!decoded.snapshot_complete);
|
||||||
assert!(decoded.scan_plan_digest.is_none());
|
assert!(decoded.scan_plan_digest.is_none());
|
||||||
|
assert!(decoded.scan_execution_digest.is_none());
|
||||||
assert_eq!(decoded.cache_key_format, 0);
|
assert_eq!(decoded.cache_key_format, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,6 +1147,7 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
|
|||||||
source: Some(DataUsageCacheSource::new(1, 2)),
|
source: Some(DataUsageCacheSource::new(1, 2)),
|
||||||
snapshot_complete: true,
|
snapshot_complete: true,
|
||||||
scan_plan_digest: Some(TEST_PLAN_DIGEST),
|
scan_plan_digest: Some(TEST_PLAN_DIGEST),
|
||||||
|
scan_execution_digest: Some(DataUsageScanPlanDigest([42; 32])),
|
||||||
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -1164,6 +1167,7 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
|
|||||||
assert_eq!(current.info.source, Some(DataUsageCacheSource::new(1, 2)));
|
assert_eq!(current.info.source, Some(DataUsageCacheSource::new(1, 2)));
|
||||||
assert!(current.info.snapshot_complete);
|
assert!(current.info.snapshot_complete);
|
||||||
assert_eq!(current.info.scan_plan_digest, Some(TEST_PLAN_DIGEST));
|
assert_eq!(current.info.scan_plan_digest, Some(TEST_PLAN_DIGEST));
|
||||||
|
assert_eq!(current.info.scan_execution_digest, Some(DataUsageScanPlanDigest([42; 32])));
|
||||||
assert_eq!(current.info.cache_key_format, DATA_USAGE_CACHE_KEY_FORMAT);
|
assert_eq!(current.info.cache_key_format, DATA_USAGE_CACHE_KEY_FORMAT);
|
||||||
assert_eq!(current.find("bucket").map(|entry| entry.objects), Some(3));
|
assert_eq!(current.find("bucket").map(|entry| entry.objects), Some(3));
|
||||||
|
|
||||||
|
|||||||
@@ -1616,7 +1616,7 @@ where
|
|||||||
// Refresh the storage-owned movement snapshot before reading background
|
// Refresh the storage-owned movement snapshot before reading background
|
||||||
// heal state. A missing heal object yields an in-memory default; do not
|
// heal state. A missing heal object yields an in-memory default; do not
|
||||||
// let that default influence a cycle while publication is blocked.
|
// let that default influence a cycle while publication is blocked.
|
||||||
if storeapi.scanner_data_usage_publication_blocked().await {
|
if storeapi.scanner_data_movement_pause_status().await.paused {
|
||||||
mark_scan_cycle_idle(cycle_info, &mut cycle_metrics_guard).await;
|
mark_scan_cycle_idle(cycle_info, &mut cycle_metrics_guard).await;
|
||||||
return ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::DataMovement);
|
return ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::DataMovement);
|
||||||
}
|
}
|
||||||
@@ -1816,6 +1816,19 @@ where
|
|||||||
let publication_defer_reason = publication_defer_reason
|
let publication_defer_reason = publication_defer_reason
|
||||||
.or(remote_lease_defer_reason)
|
.or(remote_lease_defer_reason)
|
||||||
.or(remote_lease_fence_defer_reason);
|
.or(remote_lease_fence_defer_reason);
|
||||||
|
// A PUT tail can finish between the walk and lease acquisition without
|
||||||
|
// changing the movement epoch accepted by those leases. Re-prove the
|
||||||
|
// namespace baseline only after every peer has granted publication.
|
||||||
|
let post_lease_activity_defer_reason = if publication_defer_reason.is_none()
|
||||||
|
&& remote_publication_leases.is_some()
|
||||||
|
&& let Ok(result) = &scan_result
|
||||||
|
&& result.status == ScannerCycleStatus::Complete
|
||||||
|
{
|
||||||
|
scanner_post_lease_activity_defer_reason(result.activity_digest(), probe_scanner_activity(storeapi.as_ref(), true).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let publication_defer_reason = publication_defer_reason.or(post_lease_activity_defer_reason);
|
||||||
// Include reasons discovered while acquiring or validating remote leases.
|
// Include reasons discovered while acquiring or validating remote leases.
|
||||||
let publication_deferred = publication_defer_reason.is_some();
|
let publication_deferred = publication_defer_reason.is_some();
|
||||||
let budget_elapsed = cycle_budget.budget_elapsed() && !ctx.is_cancelled();
|
let budget_elapsed = cycle_budget.budget_elapsed() && !ctx.is_cancelled();
|
||||||
@@ -3240,6 +3253,21 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scanner_post_lease_activity_defer_reason(
|
||||||
|
expected_digest: Option<[u8; 32]>,
|
||||||
|
activity: Result<ScannerActivitySnapshot, String>,
|
||||||
|
) -> Option<ScannerCycleDeferReason> {
|
||||||
|
match activity {
|
||||||
|
Ok(snapshot)
|
||||||
|
if scanner_activity_allows_usage_publication(&snapshot)
|
||||||
|
&& expected_digest == Some(scanner_activity_snapshot_digest(&snapshot)) =>
|
||||||
|
{
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Ok(_) | Err(_) => Some(ScannerCycleDeferReason::ActivityBaselineUnavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
enum ScannerCyclePreCommitOutcome {
|
enum ScannerCyclePreCommitOutcome {
|
||||||
RecoverCacheCycle(u64),
|
RecoverCacheCycle(u64),
|
||||||
@@ -3428,13 +3456,11 @@ use cycle_state::*;
|
|||||||
use leadership::*;
|
use leadership::*;
|
||||||
use usage_store::*;
|
use usage_store::*;
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use activity::scanner_activity_snapshot_digest;
|
|
||||||
pub use activity::scanner_topology_digest;
|
pub use activity::scanner_topology_digest;
|
||||||
pub(crate) use activity::{
|
pub(crate) use activity::{
|
||||||
ScannerActivitySnapshot, ScannerDirtyUsageAcknowledgement, probe_scanner_activity, scanner_activity_allows_usage_publication,
|
ScannerActivitySnapshot, ScannerDirtyUsageAcknowledgement, probe_scanner_activity, scanner_activity_allows_usage_publication,
|
||||||
scanner_activity_dirty_usage_state_for_host, scanner_activity_publication_lease_targets, scanner_activity_structural_digest,
|
scanner_activity_dirty_usage_state_for_host, scanner_activity_publication_lease_targets, scanner_activity_snapshot_digest,
|
||||||
scanner_dirty_usage_acknowledgements,
|
scanner_activity_structural_digest, scanner_dirty_usage_acknowledgements,
|
||||||
};
|
};
|
||||||
pub(crate) use activity::{ScannerCycleOutcome, scanner_cycle_outcome_with_pending_maintenance};
|
pub(crate) use activity::{ScannerCycleOutcome, scanner_cycle_outcome_with_pending_maintenance};
|
||||||
pub use backlog::{
|
pub use backlog::{
|
||||||
|
|||||||
@@ -902,7 +902,6 @@ where
|
|||||||
observation
|
observation
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn scanner_activity_snapshot_digest(snapshot: &ScannerActivitySnapshot) -> [u8; 32] {
|
pub(crate) fn scanner_activity_snapshot_digest(snapshot: &ScannerActivitySnapshot) -> [u8; 32] {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(u64::try_from(snapshot.len()).unwrap_or(u64::MAX).to_be_bytes());
|
hasher.update(u64::try_from(snapshot.len()).unwrap_or(u64::MAX).to_be_bytes());
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
use super::heal_info::{classify_background_heal_read_error, decode_background_heal_info};
|
use super::heal_info::{classify_background_heal_read_error, decode_background_heal_info};
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::EcstoreResult;
|
use crate::EcstoreResult;
|
||||||
use crate::storage_api::scan::BucketOperations as _;
|
use crate::storage_api::owner::ecstore_hold_namespace_commit;
|
||||||
|
use crate::storage_api::scan::{BucketOperations as _, ObjectIO as _};
|
||||||
use crate::{
|
use crate::{
|
||||||
DATA_USAGE_BLOOM_RECOVERY_PATH, DATA_USAGE_CACHE_KEY_FORMAT, DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT,
|
DATA_USAGE_BLOOM_RECOVERY_PATH, DATA_USAGE_CACHE_KEY_FORMAT, DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT,
|
||||||
DataUsageCachePrepareOutcome, DataUsageCacheSource, DataUsageEntry, DataUsageScanPlanDigest, Endpoint, EndpointServerPools,
|
DataUsageCachePrepareOutcome, DataUsageCacheSource, DataUsageEntry, DataUsageScanPlanDigest, Endpoint, EndpointServerPools,
|
||||||
@@ -1165,6 +1166,116 @@ async fn run_data_scanner_cycle_publishes_activity_for_owner_lifetime() {
|
|||||||
global_metrics().set_cycle(None).await;
|
global_metrics().set_cycle(None).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn coordinator_walks_during_pending_put_without_persisting_or_acknowledging_usage() {
|
||||||
|
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
let bucket = format!("scanner-coordinator-pending-{}", Uuid::new_v4().simple());
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &crate::storage_api::scan::MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("fixture bucket should be created");
|
||||||
|
let mut reader = PutObjReader::from_vec(b"first".to_vec());
|
||||||
|
store.pools[0].disk_set[0]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
"object",
|
||||||
|
&mut reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fixture object should finish its rename fanout");
|
||||||
|
crate::scanner_io::record_dirty_usage_bucket(&bucket);
|
||||||
|
let dirty_before = crate::scanner_io::dirty_usage_buckets_for_tests();
|
||||||
|
let baseline = read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("fixture usage baseline should be readable");
|
||||||
|
let pending = ecstore_hold_namespace_commit(store.as_ref());
|
||||||
|
let ctx = CancellationToken::new();
|
||||||
|
let budget = ScannerCycleBudget::new_with_progress_tracking(&ctx, ScannerCycleBudgetConfig::default());
|
||||||
|
let mut cycle_info = CurrentCycle {
|
||||||
|
next: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut revision = DataUsageCacheRevision::Missing;
|
||||||
|
let outcome = tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
run_data_scanner_cycle_with_budget(&ctx, &store, &mut cycle_info, &mut revision, 1, Arc::clone(&budget)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the coordinator must finish its namespace walk while a PUT is pending");
|
||||||
|
assert_eq!(budget.progress().0, 1, "the coordinator must reach actual object traversal");
|
||||||
|
assert_eq!(outcome, ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::DataMovement));
|
||||||
|
assert_eq!(cycle_info.next, 1, "a rejected publication must not advance the cycle");
|
||||||
|
assert_eq!(revision, DataUsageCacheRevision::Missing);
|
||||||
|
assert_eq!(crate::scanner_io::dirty_usage_buckets_for_tests(), dirty_before);
|
||||||
|
assert_eq!(
|
||||||
|
read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("the prior authoritative usage must remain readable"),
|
||||||
|
baseline,
|
||||||
|
"the pending candidate must not replace the authoritative baseline"
|
||||||
|
);
|
||||||
|
|
||||||
|
let committed_body = b"committed-after-walk";
|
||||||
|
let mut reader = PutObjReader::from_vec(committed_body.to_vec());
|
||||||
|
store.pools[0].disk_set[0]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
"object",
|
||||||
|
&mut reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the pending tail must change the physical object before it drains");
|
||||||
|
assert_eq!(crate::scanner_io::dirty_usage_buckets_for_tests(), dirty_before);
|
||||||
|
drop(pending);
|
||||||
|
let retry_budget = ScannerCycleBudget::new_with_progress_tracking(&ctx, ScannerCycleBudgetConfig::default());
|
||||||
|
let outcome = tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
run_data_scanner_cycle_with_budget(&ctx, &store, &mut cycle_info, &mut revision, 1, Arc::clone(&retry_budget)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the same cycle must converge after the pending PUT drains");
|
||||||
|
assert_eq!(
|
||||||
|
retry_budget.progress().0,
|
||||||
|
1,
|
||||||
|
"the same-cycle retry must not reuse the pre-tail bucket cache"
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
outcome,
|
||||||
|
ScannerCycleOutcome::Completed | ScannerCycleOutcome::CompletedWithPendingMaintenance
|
||||||
|
));
|
||||||
|
assert_eq!(cycle_info.next, 2);
|
||||||
|
assert!(!crate::scanner_io::dirty_usage_buckets_for_tests().contains_key(&bucket));
|
||||||
|
let usage = read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("the converged usage should be persisted");
|
||||||
|
let usage: DataUsageInfo = serde_json::from_slice(&usage).expect("the persisted usage should decode");
|
||||||
|
assert_eq!(usage.usage_snapshot_converged, Some(true));
|
||||||
|
assert_eq!(usage.scanner_cycle, Some(1));
|
||||||
|
assert_eq!(usage.objects_total_count, 1);
|
||||||
|
assert_eq!(
|
||||||
|
usage.objects_total_size,
|
||||||
|
u64::try_from(committed_body.len()).expect("fixture body length")
|
||||||
|
);
|
||||||
|
let bucket_usage = usage
|
||||||
|
.buckets_usage
|
||||||
|
.get(&bucket)
|
||||||
|
.expect("the scanned bucket should be published");
|
||||||
|
assert_eq!(bucket_usage.objects_count, 1);
|
||||||
|
assert_eq!(bucket_usage.size, u64::try_from(committed_body.len()).expect("fixture body length"));
|
||||||
|
global_metrics().set_cycle(None).await;
|
||||||
|
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn test_finalize_partial_scan_cycle_advances_and_persists_counter() {
|
async fn test_finalize_partial_scan_cycle_advances_and_persists_counter() {
|
||||||
@@ -8485,6 +8596,66 @@ fn scanner_node_activity(epoch: &str, namespace_generation: u64, maintenance_gen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_lease_activity_proof_rejects_a_put_tail_that_finished_before_lease_acquisition() {
|
||||||
|
let before = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3))]);
|
||||||
|
let expected_digest = Some(scanner_activity_snapshot_digest(&before));
|
||||||
|
assert_eq!(scanner_post_lease_activity_defer_reason(expected_digest, Ok(before.clone())), None);
|
||||||
|
|
||||||
|
let mut after = before.clone();
|
||||||
|
after
|
||||||
|
.get_mut("node-2")
|
||||||
|
.expect("writer should be present")
|
||||||
|
.namespace_generation += 1;
|
||||||
|
assert_eq!(
|
||||||
|
before["node-2"].movement_generation, after["node-2"].movement_generation,
|
||||||
|
"the existing movement-only lease remains valid after a PUT tail drains"
|
||||||
|
);
|
||||||
|
assert!(scanner_activity_allows_usage_publication(&after));
|
||||||
|
let reason = scanner_post_lease_activity_defer_reason(expected_digest, Ok(after));
|
||||||
|
assert_eq!(reason, Some(ScannerCycleDeferReason::ActivityBaselineUnavailable));
|
||||||
|
|
||||||
|
let result = ScannerCycleResult::new(ScannerCycleStatus::Complete, None).with_remote_dirty_usage_acknowledgements(vec![
|
||||||
|
ScannerDirtyUsageAcknowledgement {
|
||||||
|
host: "node-2".to_string(),
|
||||||
|
instance_id: "epoch-a".to_string(),
|
||||||
|
generation: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
let (outcome, _, acknowledgements) = finalize_scanner_cycle_result(
|
||||||
|
result,
|
||||||
|
DataUsagePersistOutcome::Deferred(reason.expect("changed namespace should defer publication")),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
outcome,
|
||||||
|
ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
acknowledgements.is_empty(),
|
||||||
|
"a rejected publication must not acknowledge the peer's dirty usage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_lease_activity_proof_requires_a_complete_matching_baseline() {
|
||||||
|
let before = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3))]);
|
||||||
|
let digest = scanner_activity_snapshot_digest(&before);
|
||||||
|
let mut blocked = before.clone();
|
||||||
|
blocked.get_mut("node-2").expect("peer should be present").publication_blocked = true;
|
||||||
|
let blocked_digest = scanner_activity_snapshot_digest(&blocked);
|
||||||
|
for (expected, observed) in [
|
||||||
|
(None, Ok(before)),
|
||||||
|
(Some(digest), Err("peer is unavailable".to_string())),
|
||||||
|
(Some(digest), Ok(BTreeMap::new())),
|
||||||
|
(Some(blocked_digest), Ok(blocked)),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
scanner_post_lease_activity_defer_reason(expected, observed),
|
||||||
|
Some(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scanner_activity_snapshot_digest_fences_storage_topology() {
|
fn scanner_activity_snapshot_digest_fences_storage_topology() {
|
||||||
let first = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3))]);
|
let first = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3))]);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::data_usage_define::DATA_USAGE_CACHE_KEY_FORMAT;
|
use crate::data_usage_define::{DATA_USAGE_CACHE_KEY_FORMAT, DataUsageCacheRevisions};
|
||||||
use crate::scanner_budget::ScannerCycleBudget;
|
use crate::scanner_budget::ScannerCycleBudget;
|
||||||
use crate::scanner_folder::{ScannerItem, scan_data_folder};
|
use crate::scanner_folder::{ScannerItem, scan_data_folder};
|
||||||
use crate::sleeper::SCANNER_SLEEPER;
|
use crate::sleeper::SCANNER_SLEEPER;
|
||||||
@@ -271,6 +271,8 @@ pub struct ScannerBucketScanPlan {
|
|||||||
all_buckets: Arc<Vec<BucketInfo>>,
|
all_buckets: Arc<Vec<BucketInfo>>,
|
||||||
scope: ScannerBucketScanScope,
|
scope: ScannerBucketScanScope,
|
||||||
digest: DataUsageScanPlanDigest,
|
digest: DataUsageScanPlanDigest,
|
||||||
|
// Cache work must invalidate on namespace completion even when its scoped baseline remains reusable.
|
||||||
|
execution_digest: DataUsageScanPlanDigest,
|
||||||
leader_epoch: u64,
|
leader_epoch: u64,
|
||||||
tier_registry_generation: u64,
|
tier_registry_generation: u64,
|
||||||
/// Epoch captured once for the whole scanner cycle. `None` is retained
|
/// Epoch captured once for the whole scanner cycle. `None` is retained
|
||||||
@@ -456,9 +458,12 @@ async fn scanner_cycle_activity_status<S>(
|
|||||||
where
|
where
|
||||||
S: ScannerStorage,
|
S: ScannerStorage,
|
||||||
{
|
{
|
||||||
|
// Read the pending-commit barrier before sampling its completion generation.
|
||||||
|
// A tail that drains during this await must invalidate the earlier baseline.
|
||||||
|
let publication_blocked = store.scanner_data_usage_publication_blocked().await;
|
||||||
match crate::scanner::probe_scanner_activity(store, distributed).await {
|
match crate::scanner::probe_scanner_activity(store, distributed).await {
|
||||||
Ok(after) => {
|
Ok(after) => {
|
||||||
let status = if after == *before {
|
let status = if !publication_blocked && after == *before {
|
||||||
ScannerCycleActivityStatus::Unchanged
|
ScannerCycleActivityStatus::Unchanged
|
||||||
} else {
|
} else {
|
||||||
ScannerCycleActivityStatus::Changed
|
ScannerCycleActivityStatus::Changed
|
||||||
@@ -760,6 +765,7 @@ fn scanner_activity_preflight(
|
|||||||
pub(crate) struct ScannerCycleResult {
|
pub(crate) struct ScannerCycleResult {
|
||||||
pub(crate) status: ScannerCycleStatus,
|
pub(crate) status: ScannerCycleStatus,
|
||||||
publication_epoch: Option<u64>,
|
publication_epoch: Option<u64>,
|
||||||
|
activity_digest: Option<[u8; 32]>,
|
||||||
observational_snapshot_published: bool,
|
observational_snapshot_published: bool,
|
||||||
dirty_usage_clear: Option<DirtyUsageBuckets>,
|
dirty_usage_clear: Option<DirtyUsageBuckets>,
|
||||||
remote_dirty_usage_acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
|
remote_dirty_usage_acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
|
||||||
@@ -774,6 +780,7 @@ impl ScannerCycleResult {
|
|||||||
Self {
|
Self {
|
||||||
status,
|
status,
|
||||||
publication_epoch: None,
|
publication_epoch: None,
|
||||||
|
activity_digest: None,
|
||||||
observational_snapshot_published: false,
|
observational_snapshot_published: false,
|
||||||
dirty_usage_clear,
|
dirty_usage_clear,
|
||||||
remote_dirty_usage_acknowledgements: Vec::new(),
|
remote_dirty_usage_acknowledgements: Vec::new(),
|
||||||
@@ -793,6 +800,15 @@ impl ScannerCycleResult {
|
|||||||
self.publication_epoch
|
self.publication_epoch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn with_activity_digest(mut self, activity_digest: [u8; 32]) -> Self {
|
||||||
|
self.activity_digest = Some(activity_digest);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn activity_digest(&self) -> Option<[u8; 32]> {
|
||||||
|
self.activity_digest
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn with_observational_snapshot_published(mut self, published: bool) -> Self {
|
pub(crate) fn with_observational_snapshot_published(mut self, published: bool) -> Self {
|
||||||
self.observational_snapshot_published = published;
|
self.observational_snapshot_published = published;
|
||||||
self
|
self
|
||||||
|
|||||||
@@ -604,10 +604,12 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
|||||||
store: Arc<SetDisks>,
|
store: Arc<SetDisks>,
|
||||||
updates: &mpsc::Sender<DataUsageCache>,
|
updates: &mpsc::Sender<DataUsageCache>,
|
||||||
mut cache_snapshot: DataUsageCache,
|
mut cache_snapshot: DataUsageCache,
|
||||||
|
initial_revisions: Option<&DataUsageCacheRevisions>,
|
||||||
cache_cycle_floor: &AtomicU64,
|
cache_cycle_floor: &AtomicU64,
|
||||||
expected_publication_epoch: u64,
|
expected_publication_epoch: u64,
|
||||||
) -> Option<SystemTime> {
|
) -> Option<SystemTime> {
|
||||||
let source = cache_snapshot.info.source?;
|
let source = cache_snapshot.info.source?;
|
||||||
|
let execution_digest = cache_snapshot.info.scan_execution_digest?;
|
||||||
let guard = match acquire_scanner_cache_locks(store.as_ref(), DATA_USAGE_CACHE_NAME, source).await {
|
let guard = match acquire_scanner_cache_locks(store.as_ref(), DATA_USAGE_CACHE_NAME, source).await {
|
||||||
Ok(guard) => guard,
|
Ok(guard) => guard,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -672,20 +674,36 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
|||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if matches!(
|
if persisted.info.scan_execution_digest == Some(execution_digest)
|
||||||
current_cache_root_entry_with_generation(
|
&& matches!(
|
||||||
&persisted,
|
current_cache_root_entry_with_generation(
|
||||||
DATA_USAGE_ROOT,
|
&persisted,
|
||||||
source,
|
DATA_USAGE_ROOT,
|
||||||
cache_snapshot.info.next_cycle,
|
source,
|
||||||
cache_snapshot.info.leader_epoch,
|
cache_snapshot.info.next_cycle,
|
||||||
scan_plan_digest,
|
cache_snapshot.info.leader_epoch,
|
||||||
cache_snapshot.info.tier_registry_generation,
|
scan_plan_digest,
|
||||||
),
|
cache_snapshot.info.tier_registry_generation,
|
||||||
Ok(Some(_))
|
),
|
||||||
) {
|
Ok(Some(_))
|
||||||
|
)
|
||||||
|
{
|
||||||
cache_snapshot = persisted;
|
cache_snapshot = persisted;
|
||||||
} else {
|
} else {
|
||||||
|
// A later execution may have completed while this scan was walking.
|
||||||
|
// Only replace the cache revision from which this scan started.
|
||||||
|
if initial_revisions != Some(&revisions) {
|
||||||
|
warn!(
|
||||||
|
target: "rustfs::scanner::io",
|
||||||
|
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||||
|
component = LOG_COMPONENT_SCANNER,
|
||||||
|
subsystem = LOG_SUBSYSTEM_IO,
|
||||||
|
state = "scan_baseline_revision_changed",
|
||||||
|
cache_name = DATA_USAGE_CACHE_NAME,
|
||||||
|
"Scanner skipped set snapshot without an unchanged baseline revision"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if guard.is_lock_lost() {
|
if guard.is_lock_lost() {
|
||||||
error!(
|
error!(
|
||||||
target: "rustfs::scanner::io",
|
target: "rustfs::scanner::io",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user