mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-12 05:49:01 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c447ad66e2 | |||
| eb96b402b1 | |||
| 01d8e4347f | |||
| 2909b1bfe1 | |||
| 414176c47f | |||
| 3fd1ce414d | |||
| 666dfd9f9f | |||
| 4d31621f0a | |||
| d9e47d2813 | |||
| d20059ab5a | |||
| 509a0fa90c | |||
| 7c47c48e85 | |||
| 853ae63b6a | |||
| f02bc947cd | |||
| 0cbc3ffe61 | |||
| b1cc286cac | |||
| 50b31bc75b | |||
| 5cd58319ed | |||
| c677590d50 | |||
| 94d875ea07 | |||
| 2f5db4e8f2 | |||
| 5c17012fe2 | |||
| aeaba86d73 | |||
| 8f150d1d8e | |||
| da762e0b02 |
@@ -1,2 +1,2 @@
|
||||
sha256-linux=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
|
||||
sha256-darwin=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
|
||||
sha256-linux=775825dcb2b4997c4fa24bd9ba9c0546316c503f4d5e369c39abff0678c95e8e
|
||||
sha256-darwin=775825dcb2b4997c4fa24bd9ba9c0546316c503f4d5e369c39abff0678c95e8e
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=15cb0cf9909bfbfc5a835fb08bd3675516c641db1b92ecc1e170a1cfa0fa2fd5
|
||||
sha256-linux=3163fdd29df5def86cf511ca7db05880d5c0caaa608a7031a71394327f7c217a
|
||||
sha256-darwin=65024bb9e11c9bc1b4733d5634e955e6b00683b409cc54896624c14d9657a3c9
|
||||
sha256-linux=63540e78a72132cb0fcc3865db6ebabe6f4f3d64b9360f7214872b4664d7b790
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=5fbb230b89212b7c3d7229d6cef3e7e2d16f0ecfec62237ebc770785706f67d9
|
||||
sha256=6d18f9cce820c51d5589de944e8cc185f73eeca0ea9a9916651943e3759169d0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
8bb710ef325d4b3f891e70deb671348a1f1d850a
|
||||
@@ -49,11 +49,15 @@ script-tests: ## Run shell script tests
|
||||
./scripts/test_python_bin.sh
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.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/test_security_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_nightly_candidate.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_functional_chain.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_functional_chain_health.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_timing_report.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||
|
||||
@@ -539,13 +539,6 @@ path = "junit.xml"
|
||||
filter = 'package(e2e_test)'
|
||||
test-group = 'e2e-cluster-nightly'
|
||||
|
||||
# The EC8+4 multi-set heal proof deliberately uploads a larger workload so the
|
||||
# background root-heal pass can be interrupted after targeting the replacement
|
||||
# drive's erasure slots. Keep the extended budget scoped to this proof case.
|
||||
[[profile.e2e-nightly.overrides]]
|
||||
filter = 'package(e2e_test) & test(=heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_across_multi_set_after_background_target_restart)'
|
||||
slow-timeout = { period = "120s", terminate-after = 12, grace-period = "10s" }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-distributed profile — 4-node 4-disk Actions suite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+52
-14
@@ -259,9 +259,6 @@ jobs:
|
||||
} > artifacts/test-and-lint/doctest-diagnostics.txt
|
||||
exit "${status}"
|
||||
|
||||
- name: Check offline enrollment E2E root boundary
|
||||
run: ./scripts/check_offline_enrollment_e2e.sh
|
||||
|
||||
- name: Upload test reports and diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
@@ -296,6 +293,36 @@ jobs:
|
||||
- name: Run rebalance/decommission migration proofs
|
||||
run: ./scripts/check_migration_gate_count.sh
|
||||
|
||||
# The root boundary requires fresh CLI and integration-test builds. Give it
|
||||
# its own time budget instead of sharing the workspace lint/test budget.
|
||||
offline-enrollment-root-boundary:
|
||||
name: Offline Enrollment Root Boundary
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
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-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Protect Connect test home
|
||||
run: chmod go-w "$(realpath "$HOME")"
|
||||
|
||||
- name: Check offline enrollment E2E root boundary
|
||||
run: ./scripts/check_offline_enrollment_e2e.sh
|
||||
|
||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
||||
@@ -335,7 +362,11 @@ jobs:
|
||||
# re-enabled by backlog#1304 (restore accepts serialize on a short CAS
|
||||
# guard; the copy-back no longer holds the #4877 whole-copy-back lock,
|
||||
# so the mid-restore ongoing read and fast 409 rejection it asserts are
|
||||
# the implemented contract).
|
||||
# the implemented contract). The remaining exclusions each hit a
|
||||
# DIFFERENT, independent issue (all tracked under rustfs/backlog#1148;
|
||||
# they keep #[ignore] with a backlog reference):
|
||||
# - test_noncurrent_{expiry,transition}_still_works_after_immediate_compensation_transition:
|
||||
# noncurrent transition/expiry after an immediate compensation transition.
|
||||
- name: Run ignored ILM integration tests serially
|
||||
env:
|
||||
# Match the measured Test and Lint link budget. The default exposed
|
||||
@@ -348,7 +379,7 @@ jobs:
|
||||
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 80m \
|
||||
cargo nextest run -j1 --run-ignored ignored-only \
|
||||
-p rustfs-scanner -p rustfs \
|
||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))' \
|
||||
-E '(binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))) and not (test(test_noncurrent_expiry_still_works_after_immediate_compensation_transition) or test(test_noncurrent_transition_still_works_after_immediate_compensation_transition))' \
|
||||
--status-level all --final-status-level all \
|
||||
2>&1 | tee artifacts/ilm-integration/nextest.log
|
||||
status=${PIPESTATUS[0]}
|
||||
@@ -564,7 +595,7 @@ jobs:
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
argv = ["cargo", "build", "-p", "rustfs", "--bins", "--features", "e2e-test-hooks"]
|
||||
argv = ["python3", "scripts/e2e_binary.py", "build", "--bins", "--features", "e2e-test-hooks"]
|
||||
commit, tree = git("rev-parse", "HEAD"), git("rev-parse", "HEAD^{tree}")
|
||||
clean_before = not git("status", "--porcelain", "--untracked-files=normal")
|
||||
if not clean_before:
|
||||
@@ -598,6 +629,7 @@ jobs:
|
||||
name: rustfs-debug-binary
|
||||
path: |
|
||||
target/debug/rustfs
|
||||
target/debug/rustfs.e2e.json
|
||||
target/debug/rustfs.e2e-startup-cas-build.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
@@ -630,13 +662,15 @@ jobs:
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build debug binary with rio-v2
|
||||
run: cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
||||
run: python3 scripts/e2e_binary.py build --bins --features rio-v2,e2e-test-hooks
|
||||
|
||||
- name: Upload debug binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-debug-binary-rio-v2
|
||||
path: target/debug/rustfs
|
||||
path: |
|
||||
target/debug/rustfs
|
||||
target/debug/rustfs.e2e.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -785,7 +819,7 @@ jobs:
|
||||
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
|
||||
run: |
|
||||
cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
||||
--status-level all --final-status-level all --failure-output final
|
||||
|
||||
- name: Upload e2e smoke diagnostics
|
||||
@@ -821,7 +855,7 @@ jobs:
|
||||
RUSTFS_TEST_PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')"
|
||||
RUSTFS_TEST_PORT="${RUSTFS_TEST_PORT}" \
|
||||
RUSTFS_TEST_LOG="${RUN_ROOT}/rustfs.log" \
|
||||
./scripts/e2e-run.sh ./target/debug/rustfs "${RUN_ROOT}/data"
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/e2e-run.sh ./target/debug/rustfs "${RUN_ROOT}/data"
|
||||
|
||||
- name: Upload test logs
|
||||
if: failure()
|
||||
@@ -950,6 +984,7 @@ jobs:
|
||||
if manifest["binary_sha256"] != digest.hexdigest() or manifest["commit"] != commit:
|
||||
raise SystemExit("downloaded hooks binary identity mismatch")
|
||||
shutil.copy2(manifest_path, target / manifest_path.name)
|
||||
shutil.copy2(source.with_name("rustfs.e2e.json"), target / "rustfs.e2e.json")
|
||||
binary.chmod(0o755)
|
||||
PYINPUT
|
||||
|
||||
@@ -966,10 +1001,11 @@ jobs:
|
||||
# debug binary; each test spawns its own rustfs server on a random port.
|
||||
- name: Run e2e full suite
|
||||
env:
|
||||
CARGO_BIN_EXE_rustfs: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
|
||||
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
|
||||
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
|
||||
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
|
||||
run: cargo nextest run --profile e2e-full -p e2e_test
|
||||
run: python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
||||
|
||||
- name: Upload junit
|
||||
if: always()
|
||||
@@ -1041,7 +1077,7 @@ jobs:
|
||||
- name: Run end-to-end tests
|
||||
run: |
|
||||
s3s-e2e --version
|
||||
./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs
|
||||
python3 scripts/e2e_binary.py run --features rio-v2,e2e-test-hooks -- ./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs
|
||||
|
||||
- name: Upload test logs
|
||||
if: failure()
|
||||
@@ -1084,7 +1120,7 @@ jobs:
|
||||
S3_PORT="${S3_PORT}" \
|
||||
DATA_ROOT="${RUN_ROOT}" \
|
||||
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
||||
./scripts/s3-tests/run.sh
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/s3-tests/run.sh
|
||||
|
||||
- name: Upload s3 test artifacts
|
||||
if: always()
|
||||
@@ -1166,7 +1202,7 @@ jobs:
|
||||
S3_PORT="${S3_PORT}" \
|
||||
DATA_ROOT="${RUN_ROOT}" \
|
||||
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
||||
./scripts/s3-tests/run.sh
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/s3-tests/run.sh
|
||||
|
||||
- name: Upload s3 test artifacts
|
||||
if: always()
|
||||
@@ -1185,6 +1221,7 @@ jobs:
|
||||
- typos
|
||||
- quick-checks
|
||||
- test-and-lint
|
||||
- offline-enrollment-root-boundary
|
||||
- test-ilm-integration-serial
|
||||
- test-and-lint-rio-v2
|
||||
- connect-short-credential-boundary
|
||||
@@ -1218,6 +1255,7 @@ jobs:
|
||||
- typos
|
||||
- quick-checks
|
||||
- test-and-lint
|
||||
- offline-enrollment-root-boundary
|
||||
- test-ilm-integration-serial
|
||||
- test-and-lint-rio-v2
|
||||
- test-and-lint-protocols
|
||||
|
||||
@@ -151,8 +151,7 @@ jobs:
|
||||
|
||||
- name: Build rustfs binary
|
||||
run: |
|
||||
cargo build -p rustfs --bins
|
||||
: > target/debug/rustfs.features
|
||||
python3 scripts/e2e_binary.py build --bins
|
||||
|
||||
- name: Verify distributed e2e membership
|
||||
env:
|
||||
@@ -168,9 +167,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${FILTER}" ]; then
|
||||
cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||
else
|
||||
cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||
fi
|
||||
|
||||
- name: Upload distributed e2e diagnostics
|
||||
|
||||
@@ -89,14 +89,10 @@ jobs:
|
||||
- name: Verify awscurl
|
||||
run: test -x "$AWSCURL_PATH"
|
||||
|
||||
# Build the rustfs binary once up front. The e2e tests spawn it as a
|
||||
# child process (crates/e2e_test/src/common.rs) and will build it on
|
||||
# demand otherwise, but a single explicit build avoids several parallel
|
||||
# nextest test processes racing to build it at once.
|
||||
# Build once and carry its source/binary identity into the test invocation.
|
||||
- name: Build rustfs binary
|
||||
run: |
|
||||
cargo build -p rustfs --bins
|
||||
: > target/debug/rustfs.features
|
||||
python3 scripts/e2e_binary.py build --bins
|
||||
|
||||
- name: Verify replication e2e membership
|
||||
env:
|
||||
@@ -108,7 +104,7 @@ jobs:
|
||||
- name: Run replication e2e nightly suite
|
||||
env:
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-repl-nightly-logs
|
||||
run: cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||
run: python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||
|
||||
- name: Upload nextest junit report
|
||||
if: always()
|
||||
@@ -144,8 +140,7 @@ jobs:
|
||||
|
||||
- name: Build rustfs binary
|
||||
run: |
|
||||
cargo build -p rustfs --bins --features e2e-test-hooks
|
||||
: > target/debug/rustfs.features
|
||||
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||
|
||||
- name: Verify cluster fault e2e membership
|
||||
env:
|
||||
@@ -156,8 +151,9 @@ jobs:
|
||||
|
||||
- name: Run cluster fault e2e nightly suite
|
||||
env:
|
||||
CARGO_BIN_EXE_rustfs: ${{ github.workspace }}/target/debug/rustfs
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-nightly-logs
|
||||
run: cargo nextest run --profile e2e-nightly -p e2e_test
|
||||
run: python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
||||
|
||||
- name: Upload cluster fault diagnostics
|
||||
if: always()
|
||||
@@ -198,6 +194,9 @@ jobs:
|
||||
sudo apt-get install -y -qq iproute2
|
||||
ss -tn state CLOSE-WAIT >/dev/null
|
||||
|
||||
- name: Build protocol server
|
||||
run: python3 scripts/e2e_binary.py build --features "$RUSTFS_BUILD_FEATURES"
|
||||
|
||||
# The suite owns fixed protocol ports and serializes its internal cases.
|
||||
- name: Verify protocol e2e membership
|
||||
env:
|
||||
@@ -210,7 +209,7 @@ jobs:
|
||||
env:
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-protocol-e2e-logs
|
||||
run: >-
|
||||
cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||
python3 scripts/e2e_binary.py run --features "$RUSTFS_BUILD_FEATURES" -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||
|
||||
- name: Upload protocol diagnostics
|
||||
if: always()
|
||||
|
||||
@@ -125,14 +125,13 @@ jobs:
|
||||
|
||||
- name: Build current RustFS binary
|
||||
run: |
|
||||
cargo build --locked -p rustfs --bin rustfs
|
||||
: > target/debug/rustfs.features
|
||||
python3 scripts/e2e_binary.py build
|
||||
|
||||
- name: Run upgrade compatibility test
|
||||
env:
|
||||
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||
run: |
|
||||
cargo test --locked -p e2e_test \
|
||||
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
|
||||
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||
-- --ignored --exact --nocapture
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Functional chain health
|
||||
on:
|
||||
schedule:
|
||||
- cron: '23 * * * *'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
concurrency:
|
||||
group: functional-chain-health
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
collect:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Collect and publish verified chain health
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: python3 scripts/functional_chain_health.py --publish --output "${RUNNER_TEMP}/chain-health.json"
|
||||
- name: Retain health observation
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-chain-health-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-health.json
|
||||
if-no-files-found: error
|
||||
@@ -42,18 +42,39 @@ env:
|
||||
NIGHTLY_BUILD_REF: ${{ github.event_name == 'schedule' && (vars.NIGHTLY_BRANCH || 'main') || (inputs.branch || github.ref_name) }}
|
||||
|
||||
jobs:
|
||||
resolve-source:
|
||||
name: Resolve nightly source
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
source_sha: ${{ steps.source.outputs.sha }}
|
||||
source_ref: ${{ steps.source.outputs.ref }}
|
||||
steps:
|
||||
- name: Checkout selected source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ env.NIGHTLY_BUILD_REF }}
|
||||
- name: Record immutable source
|
||||
id: source
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=${NIGHTLY_BUILD_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: resolve-source
|
||||
name: Build x86_64 GNU
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
NIGHTLY_BUILD_REF: ${{ needs.resolve-source.outputs.source_ref }}
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ env.NIGHTLY_BUILD_REF }}
|
||||
ref: ${{ needs.resolve-source.outputs.source_sha }}
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -334,9 +355,10 @@ jobs:
|
||||
|
||||
CANDIDATE_FILE="${RUNNER_TEMP}/nightly-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
|
||||
jq -n --arg source_sha "${SOURCE_SHA}" \
|
||||
--arg workflow_sha "${GITHUB_SHA}" --arg source_ref "${NIGHTLY_BUILD_REF}" \
|
||||
--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}' \
|
||||
'{schema: 2, workflow_sha: $workflow_sha, source_ref: $source_ref, 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}"
|
||||
|
||||
@@ -378,6 +400,7 @@ jobs:
|
||||
# self-hosted fleet is heterogeneous — a docker-dependent workflow has been
|
||||
# burned by it before (see the banner in e2e-s3tests.yml, rustfs/backlog#1149).
|
||||
kms-vault-lane:
|
||||
needs: resolve-source
|
||||
name: KMS live Vault lane
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
@@ -399,7 +422,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ env.NIGHTLY_BUILD_REF }}
|
||||
ref: ${{ needs.resolve-source.outputs.source_sha }}
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -477,6 +500,7 @@ jobs:
|
||||
# flake cannot mask the main lane's verdict, and vice versa. The script
|
||||
# provisions and tears down its own Docker cluster.
|
||||
kms-vault-ha-failover:
|
||||
needs: resolve-source
|
||||
name: KMS Vault HA failover lane
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
@@ -488,7 +512,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ env.NIGHTLY_BUILD_REF }}
|
||||
ref: ${{ needs.resolve-source.outputs.source_sha }}
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
|
||||
|
||||
- name: Build the RustFS binary under test
|
||||
run: cargo build --locked -p rustfs --bins
|
||||
run: python3 scripts/e2e_binary.py build --bins
|
||||
|
||||
# The lane selects tests by module, so a rename would quietly shrink it.
|
||||
# The committed digest in .config/e2e-odm-interop-selection.txt fails
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
|
||||
|
||||
- name: Run the interop cases against MinIO
|
||||
run: cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
|
||||
run: python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
|
||||
|
||||
- name: Build the MinIO interop report
|
||||
if: always()
|
||||
@@ -251,7 +251,7 @@ jobs:
|
||||
|
||||
- name: Build the RustFS binary under test
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
run: cargo build --locked -p rustfs --bins
|
||||
run: python3 scripts/e2e_binary.py build --bins
|
||||
|
||||
# A filterset that matches nothing is valid, so the count is asserted
|
||||
# rather than inferred from a green run.
|
||||
@@ -272,7 +272,7 @@ jobs:
|
||||
- name: Run the three-case minimum
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
run: |
|
||||
cargo nextest run --profile e2e-odm-interop -p e2e_test \
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test \
|
||||
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
|
||||
|
||||
- name: Build the ${{ matrix.provider }} interop report
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
# RustFS Fault-Tolerance (degradation) Test
|
||||
#
|
||||
# Scenario suite for the 2026-09 degradation report: verifies read/write
|
||||
# behavior under drive and node loss against the erasure-coding contract and
|
||||
# snapshots health-endpoint responses at every tier.
|
||||
#
|
||||
# A single-node 4 drives (SNMD): hide 1/2/3 drives, restore
|
||||
# B multi-node 4x1 (one drive per node): stop 1/2/3 nodes, restore
|
||||
# C multi-node 4x4 (16 drives, EC:4): stop 1 node (read-quorum boundary),
|
||||
# stop 2 nodes, restore
|
||||
# C2 multi-node 4x4 with EC:8: 2 nodes down puts 8 drives online -- reads
|
||||
# satisfy the EC read quorum while the lock majority is broken (the
|
||||
# reported divergence window: reads 503 with lock_quorum_unavailable)
|
||||
#
|
||||
# Expectations come from product source (default_parity_count, erasure set
|
||||
# sizing). By default a "reads refused although the read quorum is met"
|
||||
# observation is reported as known-divergence without failing the suite; the
|
||||
# strict input turns those into failures once the product behavior changes.
|
||||
|
||||
name: RustFS Fault-Tolerance Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package_url:
|
||||
description: 'Direct .deb URL. Required unless the nightly default is wanted.'
|
||||
required: false
|
||||
type: string
|
||||
strict:
|
||||
description: 'Fail the suite when reads are refused despite a met read quorum'
|
||||
type: boolean
|
||||
default: false
|
||||
cleanup_before:
|
||||
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
||||
type: boolean
|
||||
default: true
|
||||
cleanup_after:
|
||||
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||
type: boolean
|
||||
default: true
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the replication suite finishes, ahead of
|
||||
# the performance suite.
|
||||
types: [rustfs-chain-fault-tolerance]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# The suite stops services and hides drive dirs on the shared fleet; only one
|
||||
# functional suite may touch the environment at a time.
|
||||
concurrency:
|
||||
group: rustfs-shared-functional-tests
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
|
||||
jobs:
|
||||
fault-tolerance-test:
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 480
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-ft-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}/evidence"
|
||||
{
|
||||
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 'EVIDENCE_DIR=%s/evidence\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
uname -a
|
||||
jq --version
|
||||
aws --version
|
||||
df -h /data | tail -1
|
||||
|
||||
- name: Cleanup environment (before)
|
||||
if: ${{ inputs.cleanup_before != 'false' }}
|
||||
run: |
|
||||
./auto-testing/rustfs-fault-tolerance-test.sh --cleanup -y --log-file "${LOG_FILE}"
|
||||
|
||||
- name: Run fault-tolerance scenarios (A, B, C, C2)
|
||||
id: test
|
||||
run: |
|
||||
ARGS=(--all -y --package-url "${{ inputs.package_url || env.RUSTFS_NIGHTLY_PACKAGE_URL }}" --log-file "${LOG_FILE}")
|
||||
if [ "${{ inputs.strict }}" = "true" ]; then
|
||||
ARGS+=(--strict)
|
||||
fi
|
||||
./auto-testing/rustfs-fault-tolerance-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "# RustFS fault-tolerance test report"
|
||||
echo ""
|
||||
echo "- Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Package: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||
echo "- Strict mode: ${{ inputs.strict || 'false' }}"
|
||||
echo ""
|
||||
echo "## Per-probe results"
|
||||
echo ""
|
||||
echo '```'
|
||||
grep -E '^FT-(CASE|SUMMARY|REPORT)' "${LOG_FILE}" || echo "(no FT-CASE lines found)"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "## Health snapshots"
|
||||
echo ""
|
||||
for f in "${FUNCTIONAL_ARTIFACTS_DIR}"/evidence/*.code; do
|
||||
[ -e "${f}" ] || continue
|
||||
printf '%s -> %s\n' "$(basename "${f}" .code)" "$(cat "${f}")"
|
||||
done
|
||||
} > "${REPORT_FILE}"
|
||||
|
||||
- name: Manage backlog issues (dedup / label / auto-close)
|
||||
# Signal-based lifecycle: dedups against open issues by label
|
||||
# (fault-tolerance + FT case), labels new issues (functional-test,
|
||||
# category, case IDs, env), and closes fixed issues after a fully
|
||||
# green run. Never acts on cancelled runs. Logic lives in
|
||||
# auto-testing/scripts/issue_manager.py, which parses the
|
||||
# FT-CASE verdict lines from the suite log.
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'fault-tolerance'
|
||||
SUITE_LABEL: 'Fault-Tolerance'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue management"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f auto-testing/scripts/issue_manager.py ]; then
|
||||
echo "issue_manager.py not found in auto-testing checkout; skipping"
|
||||
exit 0
|
||||
fi
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
PACKAGE_SOURCE=""
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
python3 auto-testing/scripts/issue_manager.py handle \
|
||||
--repo rustfs/backlog \
|
||||
--suite "${SUITE}" --category "${SUITE}" --suite-label "${SUITE_LABEL}" \
|
||||
--outcome "${{ steps.test.outcome }}" \
|
||||
--report-file "${REPORT_FILE}" \
|
||||
--log "${LOG_FILE}" \
|
||||
--run-url "${RUN_URL}" \
|
||||
--run-id "${GITHUB_RUN_ID}" \
|
||||
--attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--commit "${GITHUB_SHA}" \
|
||||
--trigger "${{ github.event_name }}" \
|
||||
--package-source "${PACKAGE_SOURCE}" \
|
||||
--date "$(date -u +%Y-%m-%d)"
|
||||
|
||||
- name: Upload test logs & evidence
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-fault-tolerance-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/evidence/
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||
run: |
|
||||
./auto-testing/rustfs-fault-tolerance-test.sh --cleanup -y --log-file "${LOG_FILE}" || true
|
||||
|
||||
- name: "Continue functional chain (next: Performance)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff retries, then files an alert issue in rustfs/backlog.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-performance' \
|
||||
-F 'client_payload[from_suite]=fault-tolerance'; 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 fault-tolerance (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **fault-tolerance** 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
|
||||
if: failure()
|
||||
run: |
|
||||
echo "RustFS fault-tolerance test failed"
|
||||
echo "Package source: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded log artifact and FT-CASE lines for details."
|
||||
@@ -12,50 +12,165 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Functional chain driver: runs the ten functional suites in a fixed order
|
||||
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
|
||||
# replication -> fault-tolerance -> performance). Each suite attempts the next handoff even
|
||||
# when its tests fail.
|
||||
#
|
||||
# Each suite workflow can still be dispatched standalone (workflow_dispatch);
|
||||
# only chain-triggered runs forward to the next suite via repository_dispatch,
|
||||
# so a standalone run never drags the rest of the chain behind it.
|
||||
#
|
||||
# Why not workflow_run chaining: GitHub does not guarantee delivery of
|
||||
# workflow_run events (they are fire-and-forget), and the head-SHA filter made
|
||||
# newly added suites (storage) unable to trigger at all. Explicit
|
||||
# repository_dispatch handoffs are verifiable and re-drivable.
|
||||
|
||||
# Reusable workflows run from this driver's commit in one Actions run. Each
|
||||
# suite still runs after an earlier suite fails; the final job requires all ten.
|
||||
name: RustFS Functional Chain
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_run_id:
|
||||
description: Successful nightly build run on main
|
||||
type: string
|
||||
required: true
|
||||
build_run_attempt:
|
||||
description: Exact successful build attempt
|
||||
type: string
|
||||
required: true
|
||||
workflow_run:
|
||||
# Entry point: start the chain after the nightly build completes. The
|
||||
# build's own conclusion does not gate the chain; each suite reports its
|
||||
# own result to rustfs/backlog and the dashboard.
|
||||
workflows: ["Nightly GNU Build"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: rustfs-functional-chain-runs
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
start-chain:
|
||||
name: Start functional chain (upgrade first)
|
||||
prepare:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'schedule') }}
|
||||
outputs:
|
||||
manifest: ${{ steps.candidate.outputs.manifest }}
|
||||
steps:
|
||||
- name: Dispatch first suite (upgrade)
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Resolve published candidate
|
||||
id: candidate
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot start the functional chain" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-upgrade' \
|
||||
-F 'client_payload[from_suite]=nightly-build'
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BUILD_RUN_ID: ${{ inputs.build_run_id }}
|
||||
BUILD_RUN_ATTEMPT: ${{ inputs.build_run_attempt }}
|
||||
CHAIN_OUTPUT: ${{ runner.temp }}/chain-candidate.json
|
||||
run: python3 scripts/resolve_functional_candidate.py
|
||||
- name: Retain candidate identity
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-candidate-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-candidate.json
|
||||
if-no-files-found: error
|
||||
|
||||
upgrade:
|
||||
needs: [prepare]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
uses: ./.github/workflows/rustfs-upgrade-test.yml
|
||||
with:
|
||||
chain_manifest: ${{ needs.prepare.outputs.manifest }}
|
||||
secrets: inherit
|
||||
|
||||
s3:
|
||||
needs: [prepare, upgrade]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
uses: ./.github/workflows/rustfs-s3-compat-test.yml
|
||||
with:
|
||||
chain_manifest: ${{ needs.prepare.outputs.manifest }}
|
||||
secrets: inherit
|
||||
|
||||
kms:
|
||||
needs: [prepare, s3]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
uses: ./.github/workflows/rustfs-kms-test.yml
|
||||
with:
|
||||
chain_manifest: ${{ needs.prepare.outputs.manifest }}
|
||||
secrets: inherit
|
||||
|
||||
tier:
|
||||
needs: [prepare, kms]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
uses: ./.github/workflows/rustfs-tier-test.yml
|
||||
with:
|
||||
chain_manifest: ${{ needs.prepare.outputs.manifest }}
|
||||
secrets: inherit
|
||||
|
||||
storage:
|
||||
needs: [prepare, tier]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
uses: ./.github/workflows/rustfs-storage-test.yml
|
||||
with:
|
||||
chain_manifest: ${{ needs.prepare.outputs.manifest }}
|
||||
secrets: inherit
|
||||
|
||||
heal:
|
||||
needs: [prepare, storage]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
uses: ./.github/workflows/rustfs-heal-test.yml
|
||||
with:
|
||||
chain_manifest: ${{ needs.prepare.outputs.manifest }}
|
||||
secrets: inherit
|
||||
|
||||
pool:
|
||||
needs: [prepare, heal]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
uses: ./.github/workflows/rustfs-pool-expand-test.yml
|
||||
with:
|
||||
chain_manifest: ${{ needs.prepare.outputs.manifest }}
|
||||
secrets: inherit
|
||||
|
||||
security:
|
||||
needs: [prepare, pool]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
uses: ./.github/workflows/rustfs-security-test.yml
|
||||
with:
|
||||
chain_manifest: ${{ needs.prepare.outputs.manifest }}
|
||||
secrets: inherit
|
||||
|
||||
replication:
|
||||
needs: [prepare, security]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
uses: ./.github/workflows/rustfs-replication-test.yml
|
||||
with:
|
||||
chain_manifest: ${{ needs.prepare.outputs.manifest }}
|
||||
secrets: inherit
|
||||
|
||||
performance:
|
||||
needs: [prepare, replication]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
uses: ./.github/workflows/rustfs-performance-test.yml
|
||||
with:
|
||||
chain_manifest: ${{ needs.prepare.outputs.manifest }}
|
||||
secrets: inherit
|
||||
|
||||
complete-chain:
|
||||
needs: [prepare, upgrade, s3, kms, tier, storage, heal, pool, security, replication, performance]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download suite evidence
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: functional-chain-*-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-evidence
|
||||
merge-multiple: true
|
||||
- name: Verify every required suite
|
||||
env:
|
||||
CHAIN_MANIFEST: ${{ needs.prepare.outputs.manifest }}
|
||||
CHAIN_NEEDS: ${{ toJSON(needs) }}
|
||||
run: >-
|
||||
python3 scripts/functional_chain_evidence.py aggregate
|
||||
--directory "${RUNNER_TEMP}/chain-evidence"
|
||||
--output "${RUNNER_TEMP}/chain-complete.json"
|
||||
- name: Upload complete-chain evidence
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-chain-complete-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-complete.json
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
name: RustFS Heal Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
chain_manifest:
|
||||
description: Verified candidate and chain attempt from the chain driver
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package_url:
|
||||
@@ -57,8 +63,13 @@ jobs:
|
||||
timeout-minutes: 480
|
||||
# Standalone manual run, or one link of the nightly functional chain
|
||||
# (storage -> heal -> pool). Pool expansion no longer re-runs heal.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout chain tooling
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
@@ -74,25 +85,21 @@ jobs:
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
- name: Bind functional candidate
|
||||
id: chain
|
||||
if: ${{ inputs.chain_manifest != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
run: python3 scripts/functional_chain_evidence.py consume
|
||||
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -152,6 +159,7 @@ jobs:
|
||||
--log-file "${LOG_FILE}"
|
||||
|
||||
- name: Generate report
|
||||
id: chain_report
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -280,51 +288,65 @@ jobs:
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: Manage backlog issues (dedup / label / auto-close)
|
||||
# Replaces the old per-run failure filing. One entry point that:
|
||||
# - dedups by signal: failing cases are matched against open backlog
|
||||
# issues by label (category + case ID); covered cases become a
|
||||
# comment on the existing issue, only uncovered cases file a new one
|
||||
# - labels new issues (functional-test, category, case IDs, env)
|
||||
# - closes fixed issues after a fully green run
|
||||
# - never files or closes on cancelled runs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 'heal'
|
||||
SUITE_LABEL: 'Heal'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue management"
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f auto-testing/scripts/issue_manager.py ]; then
|
||||
echo "issue_manager.py not found in auto-testing checkout; skipping"
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
PACKAGE_SOURCE=""
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
python3 auto-testing/scripts/issue_manager.py handle \
|
||||
--repo rustfs/backlog \
|
||||
--suite "${SUITE}" --category "${SUITE}" --suite-label "${SUITE_LABEL}" \
|
||||
--outcome "${{ steps.test.outcome }}" \
|
||||
--report-file "${REPORT_FILE}" \
|
||||
--log "${LOG_FILE}" \
|
||||
--run-url "${RUN_URL}" \
|
||||
--run-id "${GITHUB_RUN_ID}" \
|
||||
--attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--commit "${GITHUB_SHA}" \
|
||||
--trigger "${{ github.event_name }}" \
|
||||
--package-source "${PACKAGE_SOURCE}" \
|
||||
--date "$(date -u +%Y-%m-%d)"
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload test logs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
@@ -412,3 +434,24 @@ jobs:
|
||||
echo "RustFS heal test failed"
|
||||
echo "Package source: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded log artifact for details."
|
||||
|
||||
- name: Record chain evidence
|
||||
id: chain_record
|
||||
if: ${{ always() && inputs.chain_manifest != '' && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
CHAIN_JOB_STATUS: ${{ job.status }}
|
||||
CHAIN_TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
CHAIN_REPORT_OUTCOME: ${{ steps.chain_report.outcome }}
|
||||
run: >-
|
||||
python3 scripts/functional_chain_evidence.py record --suite heal
|
||||
--report "${FUNCTIONAL_ARTIFACTS_DIR}/steps.md"
|
||||
--output "${RUNNER_TEMP}/chain-heal-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/heal.json"
|
||||
|
||||
- name: Upload chain evidence
|
||||
if: ${{ always() && steps.chain_record.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-chain-heal-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-heal-${{ github.run_id }}-${{ github.run_attempt }}/heal.json
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
name: RustFS KMS Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
chain_manifest:
|
||||
description: Verified candidate and chain attempt from the chain driver
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
description: 'RustFS release tag to test (leave empty to use the latest nightly deb)'
|
||||
description: 'RustFS release tag to test. Leave empty for nightly.'
|
||||
required: false
|
||||
default: ''
|
||||
package_url:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
@@ -49,7 +56,7 @@ jobs:
|
||||
kms-test:
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 420
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
@@ -70,25 +77,21 @@ jobs:
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
- name: Bind functional candidate
|
||||
id: chain
|
||||
if: ${{ inputs.chain_manifest != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
run: python3 scripts/functional_chain_evidence.py consume
|
||||
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -155,6 +158,7 @@ jobs:
|
||||
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
id: chain_report
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -230,55 +234,65 @@ jobs:
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: Manage backlog issues (dedup / label / auto-close)
|
||||
# Replaces the old per-run failure filing. One entry point that:
|
||||
# - dedups by signal: failing cases are matched against open backlog
|
||||
# issues by label (category + case ID); covered cases become a
|
||||
# comment on the existing issue, only uncovered cases file a new one
|
||||
# - labels new issues (functional-test, category, case IDs, env)
|
||||
# - closes fixed issues after a fully green run
|
||||
# - never files or closes on cancelled runs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 'kms'
|
||||
SUITE_LABEL: 'KMS'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue management"
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f auto-testing/scripts/issue_manager.py ]; then
|
||||
echo "issue_manager.py not found in auto-testing checkout; skipping"
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
PACKAGE_SOURCE=""
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
python3 auto-testing/scripts/issue_manager.py handle \
|
||||
--repo rustfs/backlog \
|
||||
--suite "${SUITE}" --category "${SUITE}" --suite-label "${SUITE_LABEL}" \
|
||||
--outcome "${{ steps.test.outcome }}" \
|
||||
--report "${FUNCTIONAL_ARTIFACTS_DIR}/cases.md" \
|
||||
--report-file "${REPORT_FILE}" \
|
||||
--log "${LOG_FILE}" \
|
||||
--run-url "${RUN_URL}" \
|
||||
--run-id "${GITHUB_RUN_ID}" \
|
||||
--attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--commit "${GITHUB_SHA}" \
|
||||
--trigger "${{ github.event_name }}" \
|
||||
--package-source "${PACKAGE_SOURCE}" \
|
||||
--date "$(date -u +%Y-%m-%d)"
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
@@ -364,3 +378,24 @@ jobs:
|
||||
run: |
|
||||
echo "RustFS KMS suite failed"
|
||||
echo "See the uploaded report and log artifacts for details."
|
||||
|
||||
- name: Record chain evidence
|
||||
id: chain_record
|
||||
if: ${{ always() && inputs.chain_manifest != '' && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
CHAIN_JOB_STATUS: ${{ job.status }}
|
||||
CHAIN_TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
CHAIN_REPORT_OUTCOME: ${{ steps.chain_report.outcome }}
|
||||
run: >-
|
||||
python3 scripts/functional_chain_evidence.py record --suite kms
|
||||
--report "${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
--output "${RUNNER_TEMP}/chain-kms-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/kms.json"
|
||||
|
||||
- name: Upload chain evidence
|
||||
if: ${{ always() && steps.chain_record.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-chain-kms-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-kms-${{ github.run_id }}-${{ github.run_attempt }}/kms.json
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
name: RustFS Performance Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
chain_manifest:
|
||||
description: Verified candidate and chain attempt from the chain driver
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package_url:
|
||||
@@ -90,8 +96,13 @@ jobs:
|
||||
RUSTFS_WARP_CONCURRENCY: ${{ inputs.warp_concurrency || '64' }}
|
||||
# Run on manual dispatch, or when the nightly build completed successfully.
|
||||
# Skipped when nightly failed.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout chain tooling
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
@@ -108,25 +119,21 @@ jobs:
|
||||
printf 'VERSION_FILE=%s/version.txt\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
- name: Bind functional candidate
|
||||
id: chain
|
||||
if: ${{ inputs.chain_manifest != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
run: python3 scripts/functional_chain_evidence.py consume
|
||||
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -192,6 +199,7 @@ jobs:
|
||||
} > "${VERSION_FILE}"
|
||||
|
||||
- name: Upload report to dashboard (reports/YYYY-MM-DD.md)
|
||||
id: chain_report
|
||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
@@ -237,48 +245,65 @@ jobs:
|
||||
echo "created ${REPORT_PATH} in rustfs/dashboard"
|
||||
fi
|
||||
|
||||
- name: Manage backlog issues (dedup / label / auto-close)
|
||||
# Signal-based lifecycle: dedups against open issues by label,
|
||||
# labels new issues (functional-test, category, case IDs, env),
|
||||
# closes fixed issues after a fully green run. Never acts on
|
||||
# cancelled runs. Logic lives in auto-testing/scripts/.
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.benchmark.outcome == 'failure' || steps.benchmark.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 'performance'
|
||||
SUITE_LABEL: 'Performance'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue management"
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f auto-testing/scripts/issue_manager.py ]; then
|
||||
echo "issue_manager.py not found in auto-testing checkout; skipping"
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
PACKAGE_SOURCE=""
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
python3 auto-testing/scripts/issue_manager.py handle \
|
||||
--repo rustfs/backlog \
|
||||
--suite "${SUITE}" --category "${SUITE}" --suite-label "${SUITE_LABEL}" \
|
||||
--outcome "${{ steps.benchmark.outcome }}" \
|
||||
--report-file "${REPORT_FILE}" \
|
||||
--log "${LOG_FILE}" \
|
||||
--run-url "${RUN_URL}" \
|
||||
--run-id "${GITHUB_RUN_ID}" \
|
||||
--attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--commit "${GITHUB_SHA}" \
|
||||
--trigger "${{ github.event_name }}" \
|
||||
--package-source "${PACKAGE_SOURCE}" \
|
||||
--date "$(date -u +%Y-%m-%d)"
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload test logs & results
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
@@ -308,3 +333,24 @@ jobs:
|
||||
echo "RustFS performance test failed"
|
||||
echo "Package source: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded log artifact for details."
|
||||
|
||||
- name: Record chain evidence
|
||||
id: chain_record
|
||||
if: ${{ always() && inputs.chain_manifest != '' && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
CHAIN_JOB_STATUS: ${{ job.status }}
|
||||
CHAIN_TEST_OUTCOME: ${{ steps.benchmark.outcome }}
|
||||
CHAIN_REPORT_OUTCOME: ${{ steps.chain_report.outcome }}
|
||||
run: >-
|
||||
python3 scripts/functional_chain_evidence.py record --suite performance
|
||||
--report "${RUSTFS_RESULT_DIR}/summary.tsv"
|
||||
--output "${RUNNER_TEMP}/chain-performance-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/performance.json"
|
||||
|
||||
- name: Upload chain evidence
|
||||
if: ${{ always() && steps.chain_record.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-chain-performance-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-performance-${{ github.run_id }}-${{ github.run_attempt }}/performance.json
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
name: RustFS Pool Expansion Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
chain_manifest:
|
||||
description: Verified candidate and chain attempt from the chain driver
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
@@ -77,7 +83,7 @@ jobs:
|
||||
name: Pool expansion / decommission test
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
env:
|
||||
RUSTFS_POOL_ADMIN_ENDPOINT: ${{ secrets.RUSTFS_POOL_ADMIN_ENDPOINT || vars.RUSTFS_POOL_ADMIN_ENDPOINT || 'http://rustfs-node1:9000' }}
|
||||
RUSTFS_POOL_PROXY_ENDPOINT: http://127.0.0.1:19000
|
||||
@@ -85,27 +91,29 @@ jobs:
|
||||
RUSTFS_SHARED_PROXY_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
||||
RUSTFS_POOL_NODE_ENDPOINTS: ${{ secrets.RUSTFS_POOL_NODE_ENDPOINTS || vars.RUSTFS_POOL_NODE_ENDPOINTS || 'http://rustfs-node1:9000 http://rustfs-node2:9000 http://rustfs-node3:9000' }}
|
||||
steps:
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
- name: Checkout chain tooling
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Bind functional candidate
|
||||
id: chain
|
||||
if: ${{ inputs.chain_manifest != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
run: python3 scripts/functional_chain_evidence.py consume
|
||||
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize pool test artifacts
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ARTIFACT_DIR="${RUNNER_TEMP}/rustfs-pool-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
@@ -363,6 +371,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Generate report
|
||||
id: chain_report
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -556,54 +565,64 @@ jobs:
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: Manage backlog issues (dedup / label / auto-close)
|
||||
# Replaces the old per-run failure filing. One entry point that:
|
||||
# - dedups by signal: failing cases are matched against open backlog
|
||||
# issues by label (category + case ID); covered cases become a
|
||||
# comment on the existing issue, only uncovered cases file a new one
|
||||
# - labels new issues (functional-test, category, case IDs, env)
|
||||
# - closes fixed issues after a fully green run
|
||||
# - never files or closes on cancelled runs
|
||||
if: ${{ always() }}
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.pool_test.outcome == 'failure' || steps.pool_test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'pool'
|
||||
SUITE_LABEL: 'Pool expansion'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '${{ env.POOL_ARTIFACT_DIR }}/pool-report.md'
|
||||
LOG_FILE: '${{ env.POOL_ARTIFACT_DIR }}/pool-test.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue management"
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f auto-testing/scripts/issue_manager.py ]; then
|
||||
echo "issue_manager.py not found in auto-testing checkout; skipping"
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
PACKAGE_SOURCE=""
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
python3 auto-testing/scripts/issue_manager.py handle \
|
||||
--repo rustfs/backlog \
|
||||
--suite "${SUITE}" --category "${SUITE}" --suite-label "${SUITE_LABEL}" \
|
||||
--outcome "${{ steps.pool_test.outcome }}" \
|
||||
--report-file "${POOL_ARTIFACT_DIR}/pool-report.md" \
|
||||
--log "${POOL_ARTIFACT_DIR}/pool-test.log" \
|
||||
--run-url "${RUN_URL}" \
|
||||
--run-id "${GITHUB_RUN_ID}" \
|
||||
--attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--commit "${GITHUB_SHA}" \
|
||||
--trigger "${{ github.event_name }}" \
|
||||
--package-source "${PACKAGE_SOURCE}" \
|
||||
--date "$(date -u +%Y-%m-%d)"
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload test logs
|
||||
if: always()
|
||||
@@ -697,3 +716,24 @@ jobs:
|
||||
echo "RustFS pool expansion test failed"
|
||||
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded log artifact for details."
|
||||
|
||||
- name: Record chain evidence
|
||||
id: chain_record
|
||||
if: ${{ always() && inputs.chain_manifest != '' && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
CHAIN_JOB_STATUS: ${{ job.status }}
|
||||
CHAIN_TEST_OUTCOME: ${{ steps.pool_test.outcome }}
|
||||
CHAIN_REPORT_OUTCOME: ${{ steps.chain_report.outcome }}
|
||||
run: >-
|
||||
python3 scripts/functional_chain_evidence.py record --suite pool
|
||||
--report "${POOL_ARTIFACT_DIR}/pool-steps.md"
|
||||
--output "${RUNNER_TEMP}/chain-pool-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/pool.json"
|
||||
|
||||
- name: Upload chain evidence
|
||||
if: ${{ always() && steps.chain_record.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-chain-pool-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-pool-${{ github.run_id }}-${{ github.run_attempt }}/pool.json
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -15,11 +15,18 @@
|
||||
name: RustFS Replication Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
chain_manifest:
|
||||
description: Verified candidate and chain attempt from the chain driver
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
description: 'RustFS release tag to test (leave empty to use the latest nightly deb)'
|
||||
description: 'RustFS release tag to test. Leave empty for nightly.'
|
||||
required: false
|
||||
default: ''
|
||||
package_url:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
@@ -61,7 +68,7 @@ jobs:
|
||||
replication-test:
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
@@ -82,25 +89,21 @@ jobs:
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
- name: Bind functional candidate
|
||||
id: chain
|
||||
if: ${{ inputs.chain_manifest != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
run: python3 scripts/functional_chain_evidence.py consume
|
||||
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -152,6 +155,7 @@ jobs:
|
||||
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
id: chain_report
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -238,55 +242,65 @@ jobs:
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: Manage backlog issues (dedup / label / auto-close)
|
||||
# Replaces the old per-run failure filing. One entry point that:
|
||||
# - dedups by signal: failing cases are matched against open backlog
|
||||
# issues by label (category + case ID); covered cases become a
|
||||
# comment on the existing issue, only uncovered cases file a new one
|
||||
# - labels new issues (functional-test, category, case IDs, env)
|
||||
# - closes fixed issues after a fully green run
|
||||
# - never files or closes on cancelled runs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 'replication'
|
||||
SUITE_LABEL: 'Replication (bucket + site)'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue management"
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f auto-testing/scripts/issue_manager.py ]; then
|
||||
echo "issue_manager.py not found in auto-testing checkout; skipping"
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
PACKAGE_SOURCE=""
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
python3 auto-testing/scripts/issue_manager.py handle \
|
||||
--repo rustfs/backlog \
|
||||
--suite "${SUITE}" --category "${SUITE}" --suite-label "${SUITE_LABEL}" \
|
||||
--outcome "${{ steps.test.outcome }}" \
|
||||
--report "${FUNCTIONAL_ARTIFACTS_DIR}/cases.md" \
|
||||
--report-file "${REPORT_FILE}" \
|
||||
--log "${LOG_FILE}" \
|
||||
--run-url "${RUN_URL}" \
|
||||
--run-id "${GITHUB_RUN_ID}" \
|
||||
--attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--commit "${GITHUB_SHA}" \
|
||||
--trigger "${{ github.event_name }}" \
|
||||
--package-source "${PACKAGE_SOURCE}" \
|
||||
--date "$(date -u +%Y-%m-%d)"
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
@@ -318,7 +332,7 @@ jobs:
|
||||
'
|
||||
done
|
||||
|
||||
- name: "Continue functional chain (next: Fault tolerance)"
|
||||
- name: "Continue functional chain (next: Performance)"
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
@@ -331,9 +345,9 @@ jobs:
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-fault-tolerance' \
|
||||
-f event_type='rustfs-chain-performance' \
|
||||
-F 'client_payload[from_suite]=replication'; then
|
||||
echo "dispatched next suite Fault tolerance (attempt ${attempt})"
|
||||
echo "dispatched next suite Performance (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
@@ -341,19 +355,19 @@ jobs:
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Fault tolerance after 3 attempts" >&2
|
||||
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 **Fault tolerance** after 3 attempts."
|
||||
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-fault-tolerance'"
|
||||
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-fault-tolerance'"
|
||||
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}" \
|
||||
@@ -369,3 +383,24 @@ jobs:
|
||||
echo "RustFS replication suite failed"
|
||||
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded report and log artifacts for details."
|
||||
|
||||
- name: Record chain evidence
|
||||
id: chain_record
|
||||
if: ${{ always() && inputs.chain_manifest != '' && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
CHAIN_JOB_STATUS: ${{ job.status }}
|
||||
CHAIN_TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
CHAIN_REPORT_OUTCOME: ${{ steps.chain_report.outcome }}
|
||||
run: >-
|
||||
python3 scripts/functional_chain_evidence.py record --suite replication
|
||||
--report "${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
--output "${RUNNER_TEMP}/chain-replication-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/replication.json"
|
||||
|
||||
- name: Upload chain evidence
|
||||
if: ${{ always() && steps.chain_record.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-chain-replication-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-replication-${{ github.run_id }}-${{ github.run_attempt }}/replication.json
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
name: RustFS S3 Compatibility Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
chain_manifest:
|
||||
description: Verified candidate and chain attempt from the chain driver
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
description: 'RustFS release tag to test (leave empty to use the latest nightly deb)'
|
||||
description: 'RustFS release tag to test. Leave empty for nightly.'
|
||||
required: false
|
||||
default: ''
|
||||
package_url:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
@@ -37,7 +44,7 @@ jobs:
|
||||
s3-compat-test:
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
@@ -58,25 +65,21 @@ jobs:
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
- name: Bind functional candidate
|
||||
id: chain
|
||||
if: ${{ inputs.chain_manifest != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
run: python3 scripts/functional_chain_evidence.py consume
|
||||
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -121,6 +124,7 @@ jobs:
|
||||
./auto-testing/rustfs-s3-compat-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
id: chain_report
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -207,55 +211,65 @@ jobs:
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: Manage backlog issues (dedup / label / auto-close)
|
||||
# Replaces the old per-run failure filing. One entry point that:
|
||||
# - dedups by signal: failing cases are matched against open backlog
|
||||
# issues by label (category + case ID); covered cases become a
|
||||
# comment on the existing issue, only uncovered cases file a new one
|
||||
# - labels new issues (functional-test, category, case IDs, env)
|
||||
# - closes fixed issues after a fully green run
|
||||
# - never files or closes on cancelled runs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 's3'
|
||||
SUITE_LABEL: 'S3 compatibility'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue management"
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f auto-testing/scripts/issue_manager.py ]; then
|
||||
echo "issue_manager.py not found in auto-testing checkout; skipping"
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
PACKAGE_SOURCE=""
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
python3 auto-testing/scripts/issue_manager.py handle \
|
||||
--repo rustfs/backlog \
|
||||
--suite "${SUITE}" --category "${SUITE}" --suite-label "${SUITE_LABEL}" \
|
||||
--outcome "${{ steps.test.outcome }}" \
|
||||
--report "${FUNCTIONAL_ARTIFACTS_DIR}/cases.md" \
|
||||
--report-file "${REPORT_FILE}" \
|
||||
--log "${LOG_FILE}" \
|
||||
--run-url "${RUN_URL}" \
|
||||
--run-id "${GITHUB_RUN_ID}" \
|
||||
--attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--commit "${GITHUB_SHA}" \
|
||||
--trigger "${{ github.event_name }}" \
|
||||
--package-source "${PACKAGE_SOURCE}" \
|
||||
--date "$(date -u +%Y-%m-%d)"
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
@@ -341,3 +355,24 @@ jobs:
|
||||
run: |
|
||||
echo "RustFS S3 compatibility suite failed"
|
||||
echo "See the uploaded report and log artifacts for details."
|
||||
|
||||
- name: Record chain evidence
|
||||
id: chain_record
|
||||
if: ${{ always() && inputs.chain_manifest != '' && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
CHAIN_JOB_STATUS: ${{ job.status }}
|
||||
CHAIN_TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
CHAIN_REPORT_OUTCOME: ${{ steps.chain_report.outcome }}
|
||||
run: >-
|
||||
python3 scripts/functional_chain_evidence.py record --suite s3
|
||||
--report "${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
--output "${RUNNER_TEMP}/chain-s3-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/s3.json"
|
||||
|
||||
- name: Upload chain evidence
|
||||
if: ${{ always() && steps.chain_record.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-chain-s3-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-s3-${{ github.run_id }}-${{ github.run_attempt }}/s3.json
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -15,11 +15,18 @@
|
||||
name: RustFS Security Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
chain_manifest:
|
||||
description: Verified candidate and chain attempt from the chain driver
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
description: 'RustFS release tag to test (leave empty to use the latest nightly deb)'
|
||||
description: 'RustFS release tag to test. Leave empty for nightly.'
|
||||
required: false
|
||||
default: ''
|
||||
package_url:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
@@ -74,7 +81,7 @@ jobs:
|
||||
security-test:
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
# Checkout the repository into its own subdirectory. Checking out at
|
||||
# the workspace root would wipe the auto-testing clone above (that is
|
||||
@@ -94,25 +101,21 @@ jobs:
|
||||
mkdir -- "${SECURITY_ARTIFACTS_DIR}" "${SECURITY_ARTIFACTS_DIR}-scratch"
|
||||
printf 'SECURITY_ARTIFACTS_DIR=%s\n' "${SECURITY_ARTIFACTS_DIR}" >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
- name: Bind functional candidate
|
||||
id: chain
|
||||
if: ${{ inputs.chain_manifest != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
run: python3 scripts/functional_chain_evidence.py consume
|
||||
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -239,54 +242,65 @@ jobs:
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: Manage backlog issues (dedup / label / auto-close)
|
||||
# Replaces the old per-run failure filing. One entry point that:
|
||||
# - dedups by signal: failing cases are matched against open backlog
|
||||
# issues by label (category + case ID); covered cases become a
|
||||
# comment on the existing issue, only uncovered cases file a new one
|
||||
# - labels new issues (functional-test, category, case IDs, env)
|
||||
# - closes fixed issues after a fully green run
|
||||
# - never files or closes on cancelled runs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'security'
|
||||
SUITE_LABEL: 'Security'
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
|
||||
LOG_FILE: ''
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue management"
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f auto-testing/scripts/issue_manager.py ]; then
|
||||
echo "issue_manager.py not found in auto-testing checkout; skipping"
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
PACKAGE_SOURCE=""
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
python3 auto-testing/scripts/issue_manager.py handle \
|
||||
--repo rustfs/backlog \
|
||||
--suite "${SUITE}" --category "${SUITE}" --suite-label "${SUITE_LABEL}" \
|
||||
--outcome "${{ steps.test.outcome }}" \
|
||||
--report-file "${SECURITY_ARTIFACTS_DIR}/report.md" \
|
||||
--log "${SECURITY_ARTIFACTS_DIR}/suite.log" \
|
||||
--run-url "${RUN_URL}" \
|
||||
--run-id "${GITHUB_RUN_ID}" \
|
||||
--attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--commit "${GITHUB_SHA}" \
|
||||
--trigger "${{ github.event_name }}" \
|
||||
--package-source "${PACKAGE_SOURCE}" \
|
||||
--date "$(date -u +%Y-%m-%d)"
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
@@ -343,3 +357,24 @@ jobs:
|
||||
echo "RustFS security test failed"
|
||||
echo "Package source: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded report and logs for details."
|
||||
|
||||
- name: Record chain evidence
|
||||
id: chain_record
|
||||
if: ${{ always() && inputs.chain_manifest != '' && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
CHAIN_JOB_STATUS: ${{ job.status }}
|
||||
CHAIN_TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
CHAIN_REPORT_OUTCOME: ${{ steps.report.outcome }}
|
||||
run: >-
|
||||
python3 scripts/functional_chain_evidence.py record --suite security
|
||||
--report "${SECURITY_ARTIFACTS_DIR}/suite-report.md"
|
||||
--output "${RUNNER_TEMP}/chain-security-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/security.json"
|
||||
|
||||
- name: Upload chain evidence
|
||||
if: ${{ always() && steps.chain_record.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-chain-security-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-security-${{ github.run_id }}-${{ github.run_attempt }}/security.json
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
name: RustFS Storage Engine Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
chain_manifest:
|
||||
description: Verified candidate and chain attempt from the chain driver
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
description: 'RustFS release tag to test (leave empty to use the latest nightly deb)'
|
||||
description: 'RustFS release tag to test. Leave empty for nightly.'
|
||||
required: false
|
||||
default: ''
|
||||
package_url:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
@@ -46,7 +53,7 @@ jobs:
|
||||
storage-test:
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
@@ -67,25 +74,21 @@ jobs:
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
- name: Bind functional candidate
|
||||
id: chain
|
||||
if: ${{ inputs.chain_manifest != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
run: python3 scripts/functional_chain_evidence.py consume
|
||||
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -136,6 +139,7 @@ jobs:
|
||||
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
id: chain_report
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -222,55 +226,65 @@ jobs:
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: Manage backlog issues (dedup / label / auto-close)
|
||||
# Replaces the old per-run failure filing. One entry point that:
|
||||
# - dedups by signal: failing cases are matched against open backlog
|
||||
# issues by label (category + case ID); covered cases become a
|
||||
# comment on the existing issue, only uncovered cases file a new one
|
||||
# - labels new issues (functional-test, category, case IDs, env)
|
||||
# - closes fixed issues after a fully green run
|
||||
# - never files or closes on cancelled runs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 'storage'
|
||||
SUITE_LABEL: 'Storage engine'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue management"
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f auto-testing/scripts/issue_manager.py ]; then
|
||||
echo "issue_manager.py not found in auto-testing checkout; skipping"
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
PACKAGE_SOURCE=""
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
python3 auto-testing/scripts/issue_manager.py handle \
|
||||
--repo rustfs/backlog \
|
||||
--suite "${SUITE}" --category "${SUITE}" --suite-label "${SUITE_LABEL}" \
|
||||
--outcome "${{ steps.test.outcome }}" \
|
||||
--report "${FUNCTIONAL_ARTIFACTS_DIR}/cases.md" \
|
||||
--report-file "${REPORT_FILE}" \
|
||||
--log "${LOG_FILE}" \
|
||||
--run-url "${RUN_URL}" \
|
||||
--run-id "${GITHUB_RUN_ID}" \
|
||||
--attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--commit "${GITHUB_SHA}" \
|
||||
--trigger "${{ github.event_name }}" \
|
||||
--package-source "${PACKAGE_SOURCE}" \
|
||||
--date "$(date -u +%Y-%m-%d)"
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
@@ -356,3 +370,24 @@ jobs:
|
||||
run: |
|
||||
echo "RustFS storage engine suite failed"
|
||||
echo "See the uploaded report and log artifacts for details."
|
||||
|
||||
- name: Record chain evidence
|
||||
id: chain_record
|
||||
if: ${{ always() && inputs.chain_manifest != '' && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
CHAIN_JOB_STATUS: ${{ job.status }}
|
||||
CHAIN_TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
CHAIN_REPORT_OUTCOME: ${{ steps.chain_report.outcome }}
|
||||
run: >-
|
||||
python3 scripts/functional_chain_evidence.py record --suite storage
|
||||
--report "${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
--output "${RUNNER_TEMP}/chain-storage-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/storage.json"
|
||||
|
||||
- name: Upload chain evidence
|
||||
if: ${{ always() && steps.chain_record.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-chain-storage-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-storage-${{ github.run_id }}-${{ github.run_attempt }}/storage.json
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
name: RustFS Tier Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
chain_manifest:
|
||||
description: Verified candidate and chain attempt from the chain driver
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
description: 'RustFS release tag to test (leave empty to use the latest nightly deb)'
|
||||
description: 'RustFS release tag to test. Leave empty for nightly.'
|
||||
required: false
|
||||
default: ''
|
||||
package_url:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
@@ -61,8 +68,13 @@ jobs:
|
||||
tier-test:
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 420
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout chain tooling
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize run evidence directory
|
||||
id: evidence
|
||||
run: |
|
||||
@@ -75,25 +87,21 @@ jobs:
|
||||
test -d "${TIER_ARTIFACTS_DIR}"
|
||||
test ! -L "${TIER_ARTIFACTS_DIR}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
- name: Bind functional candidate
|
||||
id: chain
|
||||
if: ${{ inputs.chain_manifest != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
run: python3 scripts/functional_chain_evidence.py consume
|
||||
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare pinned RustFS CLI
|
||||
id: rc
|
||||
@@ -298,6 +306,7 @@ jobs:
|
||||
mv "${TMP_FILE}" "${RESULT_FILE}"
|
||||
|
||||
- name: Generate report
|
||||
id: chain_report
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
|
||||
@@ -494,55 +503,75 @@ jobs:
|
||||
fi
|
||||
[ "${failed}" -eq 0 ]
|
||||
|
||||
- name: Manage backlog issues (dedup / label / auto-close)
|
||||
# Replaces the old per-run failure filing. One entry point that:
|
||||
# - dedups by signal: failing cases are matched against open backlog
|
||||
# issues by label (category + case ID); covered cases become a
|
||||
# comment on the existing issue, only uncovered cases file a new one
|
||||
# - labels new issues (functional-test, category, case IDs, env)
|
||||
# - closes fixed issues after a fully green run
|
||||
# - never files or closes on cancelled runs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled' || steps.evidence_verify.outcome == 'failure' || steps.evidence_verify.outcome == 'cancelled' || steps.gate.outcome == 'failure' || steps.gate.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'tier'
|
||||
SUITE_LABEL: 'Tier'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EVIDENCE_DIR: ${{ env.TIER_ARTIFACTS_DIR }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
VERIFY_OUTCOME: ${{ steps.evidence_verify.outcome }}
|
||||
GATE_OUTCOME: ${{ steps.gate.outcome }}
|
||||
REPORT_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier-report.md
|
||||
LOG_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue management"
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f auto-testing/scripts/issue_manager.py ]; then
|
||||
echo "issue_manager.py not found in auto-testing checkout; skipping"
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
PACKAGE_SOURCE=""
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo "- Evidence initialization: ${EVIDENCE_OUTCOME}"
|
||||
echo "- Evidence verification: ${VERIFY_OUTCOME}"
|
||||
echo "- Final gate: ${GATE_OUTCOME}"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ "${EVIDENCE_OUTCOME}" != "success" ]; then
|
||||
echo "(the run evidence directory was rejected; its contents were not read)"
|
||||
elif [ ! -d "${EVIDENCE_DIR}" ] || [ -L "${EVIDENCE_DIR}" ]; then
|
||||
echo "(the run evidence directory is missing or unsafe; its contents were not read)"
|
||||
elif [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
python3 auto-testing/scripts/issue_manager.py handle \
|
||||
--repo rustfs/backlog \
|
||||
--suite "${SUITE}" --category "${SUITE}" --suite-label "${SUITE_LABEL}" \
|
||||
--outcome "${{ steps.test.outcome }}" \
|
||||
--report "${TIER_ARTIFACTS_DIR}/rustfs-tier-cases.md" \
|
||||
--report-file "${TIER_ARTIFACTS_DIR}/rustfs-tier-report.md" \
|
||||
--log "${TIER_ARTIFACTS_DIR}/rustfs-tier.log" \
|
||||
--run-url "${RUN_URL}" \
|
||||
--run-id "${GITHUB_RUN_ID}" \
|
||||
--attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--commit "${GITHUB_SHA}" \
|
||||
--trigger "${{ github.event_name }}" \
|
||||
--package-source "${PACKAGE_SOURCE}" \
|
||||
--date "$(date -u +%Y-%m-%d)"
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: "Continue functional chain (next: Storage engine)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
@@ -598,3 +627,24 @@ jobs:
|
||||
run: |
|
||||
echo "RustFS tier suite failed"
|
||||
echo "See the uploaded report and log artifacts for details."
|
||||
|
||||
- name: Record chain evidence
|
||||
id: chain_record
|
||||
if: ${{ always() && inputs.chain_manifest != '' && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
CHAIN_JOB_STATUS: ${{ job.status }}
|
||||
CHAIN_TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
CHAIN_REPORT_OUTCOME: ${{ steps.chain_report.outcome }}
|
||||
run: >-
|
||||
python3 scripts/functional_chain_evidence.py record --suite tier
|
||||
--report "${TIER_ARTIFACTS_DIR}/rustfs-tier-cases.md"
|
||||
--output "${RUNNER_TEMP}/chain-tier-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/tier.json"
|
||||
|
||||
- name: Upload chain evidence
|
||||
if: ${{ always() && steps.chain_record.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-chain-tier-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-tier-${{ github.run_id }}-${{ github.run_attempt }}/tier.json
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
name: RustFS Upgrade Test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
chain_manifest:
|
||||
description: Verified candidate and chain attempt from the chain driver
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
from_version:
|
||||
@@ -80,7 +86,7 @@ jobs:
|
||||
upgrade-test:
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 420
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
@@ -101,25 +107,21 @@ jobs:
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
- name: Bind functional candidate
|
||||
id: chain
|
||||
if: ${{ inputs.chain_manifest != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
run: python3 scripts/functional_chain_evidence.py consume
|
||||
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -218,6 +220,7 @@ jobs:
|
||||
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
id: chain_report
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -306,62 +309,65 @@ jobs:
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: Manage backlog issues (dedup / label / auto-close)
|
||||
# Replaces the old per-run failure filing. One entry point that:
|
||||
# - dedups by signal: failing cases are matched against open backlog
|
||||
# issues by label (category + case ID); covered cases become a
|
||||
# comment on the existing issue, only uncovered cases file a new one
|
||||
# - labels new issues (functional-test, category, case IDs, env)
|
||||
# - closes fixed issues after a fully green run
|
||||
# - never files or closes on cancelled runs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 'upgrade'
|
||||
SUITE_LABEL: 'Upgrade compatibility'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue management"
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f auto-testing/scripts/issue_manager.py ]; then
|
||||
echo "issue_manager.py not found in auto-testing checkout; skipping"
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
FROM_URL='${{ inputs.from_url }}'
|
||||
FROM_VERSION='${{ inputs.from_version }}'
|
||||
TO_URL='${{ inputs.to_url }}'
|
||||
TO_VERSION='${{ inputs.to_version }}'
|
||||
PACKAGE_SOURCE=""
|
||||
if [ -n "${TO_URL}" ]; then
|
||||
PACKAGE_SOURCE="to ${TO_URL}"
|
||||
elif [ -n "${TO_VERSION}" ]; then
|
||||
PACKAGE_SOURCE="to version ${TO_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="to ${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
if [ -n "${FROM_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_SOURCE}; from ${FROM_URL}"
|
||||
elif [ -n "${FROM_VERSION}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_SOURCE}; from version ${FROM_VERSION}"
|
||||
fi
|
||||
python3 auto-testing/scripts/issue_manager.py handle \
|
||||
--repo rustfs/backlog \
|
||||
--suite "${SUITE}" --category "${SUITE}" --suite-label "${SUITE_LABEL}" \
|
||||
--outcome "${{ steps.test.outcome }}" \
|
||||
--report "${FUNCTIONAL_ARTIFACTS_DIR}/cases.md" \
|
||||
--report-file "${REPORT_FILE}" \
|
||||
--log "${LOG_FILE}" \
|
||||
--run-url "${RUN_URL}" \
|
||||
--run-id "${GITHUB_RUN_ID}" \
|
||||
--attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--commit "${GITHUB_SHA}" \
|
||||
--trigger "${{ github.event_name }}" \
|
||||
--package-source "${PACKAGE_SOURCE}" \
|
||||
--date "$(date -u +%Y-%m-%d)"
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
@@ -451,3 +457,24 @@ jobs:
|
||||
echo "From: ${{ inputs.from_url || inputs.from_version || 'release (default)' }}"
|
||||
echo "To: ${{ inputs.to_url || inputs.to_version || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded report and logs for details."
|
||||
|
||||
- name: Record chain evidence
|
||||
id: chain_record
|
||||
if: ${{ always() && inputs.chain_manifest != '' && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
|
||||
CHAIN_JOB_STATUS: ${{ job.status }}
|
||||
CHAIN_TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
CHAIN_REPORT_OUTCOME: ${{ steps.chain_report.outcome }}
|
||||
run: >-
|
||||
python3 scripts/functional_chain_evidence.py record --suite upgrade
|
||||
--report "${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
--output "${RUNNER_TEMP}/chain-upgrade-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/upgrade.json"
|
||||
|
||||
- name: Upload chain evidence
|
||||
if: ${{ always() && steps.chain_record.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: functional-chain-upgrade-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/chain-upgrade-${{ github.run_id }}-${{ github.run_attempt }}/upgrade.json
|
||||
if-no-files-found: error
|
||||
|
||||
Generated
+79
-79
@@ -271,7 +271,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -282,7 +282,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -688,9 +688,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-compat"
|
||||
version = "0.2.6"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c97d7ff3c25d6c10d64170c12acaf5d4245e76dece3779c1d92b153a64f11df"
|
||||
checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
@@ -1906,7 +1906,7 @@ dependencies = [
|
||||
"maybe-owned",
|
||||
"rustix",
|
||||
"rustix-linux-procfs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
"winx",
|
||||
]
|
||||
|
||||
@@ -3942,7 +3942,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3986,7 +3986,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "e2e_test"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -4287,7 +4287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5155,9 +5155,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hickory-net"
|
||||
version = "0.26.3"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d"
|
||||
checksum = "084e7bd6a377435d568f652153e571b50970d7ccc1d1eeec0519f834632287e1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cfg-if",
|
||||
@@ -5179,9 +5179,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hickory-proto"
|
||||
version = "0.26.3"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d"
|
||||
checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"idna",
|
||||
@@ -5199,9 +5199,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hickory-resolver"
|
||||
version = "0.26.3"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981"
|
||||
checksum = "0e4f9f4603319422d482738f3f6fe5aac03157fdbfed1cd85a3ff45adb09072f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -6963,7 +6963,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8712,7 +8712,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9476,7 +9476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -9624,7 +9624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-audit"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"const-str",
|
||||
"futures",
|
||||
@@ -9646,7 +9646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-checksums"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -9662,7 +9662,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-common"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"metrics",
|
||||
@@ -9675,7 +9675,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-concurrency"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"insta",
|
||||
@@ -9688,7 +9688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-config"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"const-str",
|
||||
"hotpath",
|
||||
@@ -9698,7 +9698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-credentials"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"hmac 0.13.0",
|
||||
@@ -9712,7 +9712,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-crypto"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -9733,7 +9733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-data-usage"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rmp-serde",
|
||||
@@ -9743,7 +9743,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-ecstore"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-channel",
|
||||
@@ -9879,7 +9879,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-extension-schema"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"serde",
|
||||
@@ -9889,7 +9889,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-filemeta"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"byteorder",
|
||||
@@ -9917,7 +9917,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-heal"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64-simd",
|
||||
@@ -9954,7 +9954,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-heal-contracts"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -9964,7 +9964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-iam"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -10013,7 +10013,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-io-core"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hotpath",
|
||||
@@ -10025,7 +10025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-io-metrics"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"hotpath",
|
||||
@@ -10089,7 +10089,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-keystone"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -10116,7 +10116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-kms"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -10166,14 +10166,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-license"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-lifecycle"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hotpath",
|
||||
@@ -10195,7 +10195,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-lock"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"compact_str",
|
||||
@@ -10218,7 +10218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-log-analyzer"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"flate2",
|
||||
@@ -10237,7 +10237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-madmin"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
@@ -10275,7 +10275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-notify"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -10310,7 +10310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-object-capacity"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"futures",
|
||||
@@ -10329,7 +10329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-object-data-cache"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"criterion",
|
||||
@@ -10346,7 +10346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-obs"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crossbeam-channel",
|
||||
@@ -10404,7 +10404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-policy"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64-simd",
|
||||
@@ -10435,7 +10435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-protocols"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"astral-tokio-tar",
|
||||
"async-compression",
|
||||
@@ -10497,7 +10497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-protos"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"flatbuffers",
|
||||
"hotpath",
|
||||
@@ -10522,7 +10522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-replication"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -10540,7 +10540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-rio"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"arc-swap",
|
||||
@@ -10584,7 +10584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-rio-v2"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -10607,7 +10607,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3-client"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -10651,7 +10651,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3-ops"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rustfs-s3-types",
|
||||
@@ -10659,7 +10659,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3-types"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"serde",
|
||||
@@ -10668,7 +10668,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3select-api"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-compression",
|
||||
@@ -10703,7 +10703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3select-query"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
@@ -10724,7 +10724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-scanner"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -10770,7 +10770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-scanner-metrics"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"jiff",
|
||||
@@ -10785,7 +10785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-security-governance"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"thiserror 2.0.20",
|
||||
@@ -10793,7 +10793,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-signer"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -10811,7 +10811,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-storage-api"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hotpath",
|
||||
@@ -10826,7 +10826,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-targets"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-nats",
|
||||
@@ -10880,7 +10880,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-test-utils"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rustfs-data-usage",
|
||||
@@ -10896,7 +10896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-tls-runtime"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"hotpath",
|
||||
@@ -10917,7 +10917,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-trusted-proxies"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -10954,7 +10954,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-utils"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"blake2",
|
||||
@@ -10996,7 +10996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-zip"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
dependencies = [
|
||||
"astral-tokio-tar",
|
||||
"async-compression",
|
||||
@@ -11065,7 +11065,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11148,7 +11148,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11205,7 +11205,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
[[package]]
|
||||
name = "s3s"
|
||||
version = "0.15.0"
|
||||
source = "git+https://github.com/rustfs/s3s.git?rev=bdcb6259339c41369f9f1c60e3a42b5ab8da607b#bdcb6259339c41369f9f1c60e3a42b5ab8da607b"
|
||||
source = "git+https://github.com/s3s-project/s3s.git?rev=f3e17541f366696bf0cbaf380fcbd8b44c17eba4#f3e17541f366696bf0cbaf380fcbd8b44c17eba4"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arrayvec",
|
||||
@@ -11263,7 +11263,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "s3s-rfc2047"
|
||||
version = "0.16.0-alpha.1"
|
||||
source = "git+https://github.com/rustfs/s3s.git?rev=bdcb6259339c41369f9f1c60e3a42b5ab8da607b#bdcb6259339c41369f9f1c60e3a42b5ab8da607b"
|
||||
source = "git+https://github.com/s3s-project/s3s.git?rev=f3e17541f366696bf0cbaf380fcbd8b44c17eba4#f3e17541f366696bf0cbaf380fcbd8b44c17eba4"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"thiserror 2.0.20",
|
||||
@@ -11272,7 +11272,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "s3s-sigv2"
|
||||
version = "0.16.0-alpha.1"
|
||||
source = "git+https://github.com/rustfs/s3s.git?rev=bdcb6259339c41369f9f1c60e3a42b5ab8da607b#bdcb6259339c41369f9f1c60e3a42b5ab8da607b"
|
||||
source = "git+https://github.com/s3s-project/s3s.git?rev=f3e17541f366696bf0cbaf380fcbd8b44c17eba4#f3e17541f366696bf0cbaf380fcbd8b44c17eba4"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"hmac 0.13.0",
|
||||
@@ -11285,7 +11285,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "s3s-sigv4"
|
||||
version = "0.16.0-alpha.1"
|
||||
source = "git+https://github.com/rustfs/s3s.git?rev=bdcb6259339c41369f9f1c60e3a42b5ab8da607b#bdcb6259339c41369f9f1c60e3a42b5ab8da607b"
|
||||
source = "git+https://github.com/s3s-project/s3s.git?rev=f3e17541f366696bf0cbaf380fcbd8b44c17eba4#f3e17541f366696bf0cbaf380fcbd8b44c17eba4"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"base64-simd",
|
||||
@@ -11949,7 +11949,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12100,7 +12100,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12405,10 +12405,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12761,9 +12761,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.14+spec-1.1.0"
|
||||
version = "0.25.13+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2195eec204e2764644a4ea619704f9fbe5e0673038eded55ad9956f24fca0cc"
|
||||
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
|
||||
dependencies = [
|
||||
"indexmap 2.14.2",
|
||||
"toml_datetime",
|
||||
@@ -13510,7 +13510,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+52
-52
@@ -73,7 +73,7 @@ edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.98.0"
|
||||
version = "1.0.0-rc.5"
|
||||
version = "1.0.0-rc.6"
|
||||
homepage = "https://rustfs.com"
|
||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||
@@ -90,56 +90,56 @@ redundant_clone = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.5" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.5" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.5" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.0-rc.5" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.5" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.5" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.5" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.5" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.5" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.5" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.5" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.5" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.5" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.5" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.5" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.5" }
|
||||
rustfs-license = { path = "crates/license", version = "1.0.0-rc.5" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.5" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.5" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.5" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.5" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.5" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.5" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.5" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.5" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.5", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.5" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.5" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.5" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.5" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.5" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.5" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.5" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.5" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.5" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.5" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.5" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.5" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.5" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.5" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.5" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.5" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.5" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.5" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.5" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.5" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.5" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.5" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.5" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.5" }
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.6" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.6" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.6" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.0-rc.6" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.6" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.6" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.6" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.6" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.6" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.6" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.6" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.6" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.6" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.6" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.6" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.6" }
|
||||
rustfs-license = { path = "crates/license", version = "1.0.0-rc.6" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.6" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.6" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.6" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.6" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.6" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.6" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.6" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.6" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.6", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.6" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.6" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.6" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.6" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.6" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.6" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.6" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.6" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.6" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.6" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.6" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.6" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.6" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.6" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.6" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.6" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.6" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.6" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.6" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.6" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.6" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.6" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.6" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.6" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
@@ -312,7 +312,7 @@ rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.4" }
|
||||
rust-embed = { version = "8.12.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "bdcb6259339c41369f9f1c60e3a42b5ab8da607b", version = "0.15.0", features = ["minio"] }
|
||||
s3s = { git = "https://github.com/s3s-project/s3s.git", rev = "f3e17541f366696bf0cbaf380fcbd8b44c17eba4", version = "0.15.0", features = ["minio"] }
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.3"
|
||||
|
||||
@@ -141,7 +141,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# Using specific version
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.5
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.6
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# 使用指定版本运行
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.5
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.6
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
|
||||
@@ -62,6 +62,37 @@ Current guidance:
|
||||
- `RUSTFS_CORS_ALLOWED_ORIGINS` defaults to empty, so the S3 endpoint emits no generic CORS headers unless configured. Set `*` for wildcard origins without credentials, or a comma-separated allow-list for credentialed explicit origins.
|
||||
- `RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS` defaults to `*` for the console service.
|
||||
|
||||
## Console URL prefix
|
||||
|
||||
`RUSTFS_CONSOLE_PREFIX` changes the embedded console URL prefix. The default is
|
||||
`/rustfs/console`. For example, `RUSTFS_CONSOLE_PREFIX=/console` serves the UI at
|
||||
`http://localhost:9001/console/`. Nested prefixes such as `/management/console`
|
||||
are supported; one trailing slash is removed. Restart the server after changing it.
|
||||
|
||||
The prefix must be a non-root absolute path of at most 256 bytes, with nonempty
|
||||
segments containing only ASCII letters, digits, `-`, `_`, `.`, or `~`. Dot
|
||||
segments, encoded characters, and overlaps with reserved admin, RPC, health,
|
||||
profiling, browser entry, and icon routes are rejected at startup. `/` is not supported.
|
||||
Choose a prefix that does not collide with S3 bucket paths.
|
||||
|
||||
The console routes, embedded frontend asset URLs, browser redirects, and OIDC
|
||||
console redirects use this prefix. Admin API paths and the identity provider's
|
||||
`/rustfs/admin/v3/oidc/callback/...` URL remain unchanged. `RUSTFS_CONSOLE_ADDRESS`
|
||||
continues to control only the listening address and port. The server adapts bundled
|
||||
console asset references from their build-time base path to the runtime prefix.
|
||||
|
||||
OEM builds can set `RUSTFS_CONSOLE_BASE_PATH` when compiling RustFS to embed a
|
||||
different default, such as `/nuofans/console`. Build the bundled console with the
|
||||
same `NEXT_PUBLIC_BASE_PATH`. An unset or empty build variable retains
|
||||
`/rustfs/console`. The build path must satisfy the validation rules above and must
|
||||
not have a trailing slash.
|
||||
|
||||
At startup, `RUSTFS_CONSOLE_PREFIX` takes precedence over the compiled default.
|
||||
Changing `RUSTFS_CONSOLE_BASE_PATH` when starting an existing binary has no effect;
|
||||
rebuild both components to change the embedded default. If a runtime prefix is
|
||||
configured, asset adaptation uses the compiled base path as its source, including
|
||||
when restoring `/rustfs/console` for a custom OEM build.
|
||||
|
||||
## Browser redirect environment variables
|
||||
|
||||
- `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node.
|
||||
|
||||
@@ -213,6 +213,11 @@ pub const ENV_RUSTFS_CONSOLE_ENABLE: &str = "RUSTFS_CONSOLE_ENABLE";
|
||||
/// Environment variable for console server address.
|
||||
pub const ENV_RUSTFS_CONSOLE_ADDRESS: &str = "RUSTFS_CONSOLE_ADDRESS";
|
||||
|
||||
/// URL path prefix for the embedded console, read once at server startup.
|
||||
pub const ENV_RUSTFS_CONSOLE_PREFIX: &str = "RUSTFS_CONSOLE_PREFIX";
|
||||
/// Default embedded console URL path prefix.
|
||||
pub const DEFAULT_CONSOLE_PREFIX: &str = "/rustfs/console";
|
||||
|
||||
/// Public browser entrypoint used to build OIDC callback and console redirects.
|
||||
///
|
||||
/// This should be the externally reachable scheme and authority, without a path.
|
||||
|
||||
@@ -64,6 +64,14 @@ pub const MAX_HEAL_REQUEST_SIZE: usize = 1024 * 1024; // 1 MB
|
||||
/// memory exhaustion from malicious or misconfigured remote services.
|
||||
pub const MAX_S3_CLIENT_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// Maximum body size accepted by a single `PutObject` or `UploadPart` request (5 GiB).
|
||||
/// Used for: the s3s streaming-body limit and the request-header admission check.
|
||||
/// Rationale: matches the AWS S3 single-PUT / single-part ceiling. Larger objects
|
||||
/// must use multipart upload. The header check rejects an oversize
|
||||
/// `Content-Length` before any body byte is read so the client gets
|
||||
/// `EntityTooLarge` immediately instead of streaming 5 GiB into a mid-stream failure.
|
||||
pub const MAX_SINGLE_PUT_OBJECT_SIZE: u64 = 5 * 1024 * 1024 * 1024; // 5 GiB
|
||||
|
||||
/// Maximum size for OIDC provider response bodies (1 MB)
|
||||
/// Used for: discovery documents, JWKS documents and token endpoint responses
|
||||
/// Rationale: a hostile or compromised identity provider must not be able to exhaust
|
||||
|
||||
@@ -27,4 +27,4 @@ follow.
|
||||
|
||||
## Suggested Validation
|
||||
|
||||
- `cargo test --package e2e_test`
|
||||
- `python3 scripts/e2e_binary.py build --features e2e-test-hooks`, then `python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo test --package e2e_test` (running `cargo test -p e2e_test` directly fails with a missing E2E run receipt; see [`README.md`](README.md#how-to-run))
|
||||
|
||||
+49
-47
@@ -1,7 +1,7 @@
|
||||
# e2e_test
|
||||
|
||||
End-to-end test suite for RustFS. Each test spawns a **real `rustfs` binary**
|
||||
(built on demand from the workspace) and drives it over the network with the
|
||||
(built and identified before the test invocation) and drives it over the network with the
|
||||
AWS SDK (`aws-sdk-s3`), raw HTTP (`reqwest` / `awscurl`), or a protocol client
|
||||
(FTPS / WebDAV / SFTP). This is the black-box integration layer: exhaustive
|
||||
end-to-end behavior lives here, unit behavior stays in the source crates
|
||||
@@ -34,32 +34,35 @@ The external-tool `storage_metric_ownership_test` validates the OTLP/Collector/P
|
||||
|
||||
## How to run
|
||||
|
||||
All commands assume repo root. `cargo test` triggers an on-demand build of the
|
||||
`rustfs` binary from [`src/common.rs`](src/common.rs) (`rustfs_binary_path`) on
|
||||
first use — the first invocation is slow, later ones reuse the binary.
|
||||
All commands assume repo root and Python 3.9 or newer on Linux or macOS. Build the server once through the provenance entry point, then run the test command through the same script:
|
||||
|
||||
```bash
|
||||
# Whole crate (default = ignored tests skipped)
|
||||
cargo nextest run -p e2e_test
|
||||
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
||||
|
||||
# Whole crate (ignored tests remain skipped)
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run -p e2e_test
|
||||
|
||||
# One module
|
||||
cargo nextest run -p e2e_test -E 'test(list_objects_v2_pagination_test)'
|
||||
|
||||
# PR smoke subset (see "CI smoke subset" below)
|
||||
cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
|
||||
# ILM serial lane — ignored lifecycle tests, single-threaded (mirrors CI)
|
||||
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run -p e2e_test -E 'test(list_objects_v2_pagination_test)'
|
||||
|
||||
# PR smoke subset
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
```
|
||||
|
||||
The protocols suite has its own contract (fixed bind ports 9022–9301,
|
||||
single-worker execution, feature-gated scheduling) documented in
|
||||
[`src/protocols/README.md`](src/protocols/README.md). `RUSTFS_BUILD_FEATURES`
|
||||
selects which features the spawned binary is built with; leave it unset to run
|
||||
every protocol entry. Use the exact profile command under
|
||||
[Troubleshooting](#troubleshooting) for CI-equivalent execution.
|
||||
Root-heal interruption scenarios use a test-only commit barrier, so build and run them with `e2e-test-hooks`:
|
||||
|
||||
```bash
|
||||
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run -p e2e_test -E 'test(heal_erasure_disk_rebuild_test)'
|
||||
```
|
||||
|
||||
`build` records the source contents, HEAD, resolved Cargo features, profile, toolchain, and binary SHA-256 beside the executable in `rustfs.e2e.json`. `run` validates that identity before and after the command, preserves command failures, and removes its temporary run receipt on completion. The Rust harness checks that receipt before starting each server; it never compiles a server inside a test process. Source or binary changes during a run invalidate the result, even when the test command succeeds. Use an isolated worktree and keep it unchanged until the command finishes.
|
||||
|
||||
The additional `--features` arguments must match between `build` and `run`; Cargo defaults remain enabled. The wrapper supplies `RUSTFS_BUILD_FEATURES` from Cargo's resolved feature list, including features enabled by `full`. Protocol helpers require a subset of that list. `CARGO_TARGET_DIR` and `--profile release` are supported. An in-workspace target directory must be Git-ignored; tracked files are always included in the source identity. `build --bins` preserves CI lanes that compile all RustFS binary targets. For a downloaded artifact, copy both the executable and its sidecar, then use `run`; do not generate a new identity for an arbitrary prebuilt binary. `CARGO_BIN_EXE_rustfs` cannot override the verified executable.
|
||||
|
||||
Each build/run holds an exclusive `rustfs.e2e.lock` marker beside the binary; concurrent wrappers fail immediately. Use a private target directory and do not run ordinary Cargo builds against it while tests are active: Cargo does not honor this marker. Interrupted runs fail and terminate their command group. After an uncatchable kill, inspect the PID recorded in a leftover marker and remove it only after confirming its owner has stopped. Embedded file symlinks are hashed through their target; embedded directory symlinks are rejected because their contents cannot be enumerated safely by this entry point.
|
||||
|
||||
The protocols suite has its own fixed-port and single-worker contract in [`src/protocols/README.md`](src/protocols/README.md). Use its command under [Troubleshooting](#troubleshooting).
|
||||
|
||||
### `#[ignore]` semantics
|
||||
|
||||
@@ -125,7 +128,7 @@ via `create_s3_client(idx)` / `create_all_clients()`. See
|
||||
| `wait_for_server_ready` | Poll readiness before issuing requests |
|
||||
| `create_s3_client` / `create_test_bucket` / `delete_test_bucket` | aws-sdk-s3 client + bucket lifecycle |
|
||||
| `find_available_port` | Random free port (isolation primitive) |
|
||||
| `rustfs_binary_path` / `_with_features` | Locate/build the binary; honors `RUSTFS_BUILD_FEATURES` |
|
||||
| `rustfs_binary_path` / `_with_features` | Verify this run's binary receipt and required feature subset |
|
||||
| `requested_rustfs_build_features` / `rustfs_build_feature_enabled` | Feature-gate a test to what the binary was built with |
|
||||
| `execute_awscurl` / `awscurl_post` / `_get` / `_put` / `_delete` / `awscurl_post_sts_form_urlencoded` | Admin/STS API calls via `awscurl`; missing binaries are test failures |
|
||||
| `replication_fast_env` | Env vars that shrink replication timers (from repl-4); pass to `start_rustfs_server_with_env` |
|
||||
@@ -191,35 +194,33 @@ the wiring source of truth. Committed test-ID digests under
|
||||
**Reproduce a CI failure locally** — run the exact profile/lane:
|
||||
|
||||
```bash
|
||||
# Smoke (e2e-tests job) — includes the 20 fast replication tests
|
||||
cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
# Full single-node merge/main lane
|
||||
cargo nextest run --profile e2e-full -p e2e_test
|
||||
# Cluster fault nightly lane
|
||||
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
|
||||
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||
# Fixed-port protocol nightly lane
|
||||
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp \
|
||||
cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||
# ILM serial lane
|
||||
# Smoke, full, and cluster lanes share a server with fault-test hooks.
|
||||
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
||||
|
||||
# Distributed 4-node 4-disk lane uses the default server.
|
||||
# Upgrade cases require RUSTFS_UPGRADE_SOURCE_BINARY and fail closed without it.
|
||||
python3 scripts/e2e_binary.py build
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test
|
||||
|
||||
# Replication nightly uses the default server; awscurl is required for STS.
|
||||
python3 scripts/e2e_binary.py build
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||
|
||||
# Protocol nightly owns fixed ports.
|
||||
python3 scripts/e2e_binary.py build --features ftps,webdav,sftp
|
||||
python3 scripts/e2e_binary.py run --features ftps,webdav,sftp -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||
|
||||
# The ILM serial lane does not use this server harness.
|
||||
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
||||
# s3s-e2e black box
|
||||
./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs-e2e-data
|
||||
```
|
||||
|
||||
**Stale binary.** Tests build the `rustfs` binary once and reuse it. To avoid
|
||||
rebuilding while iterating on tests, `common.rs` reuses an existing binary when
|
||||
running *inside* the e2e test process even if sources changed
|
||||
(`can_reuse_inside_e2e`, [`src/common.rs`](src/common.rs) line 98). Downside: if
|
||||
you changed **server** code, force a rebuild with
|
||||
`cargo build -p rustfs` (or `touch` a source file outside the reuse window)
|
||||
before re-running, or CI's freshly built artifact will diverge from your local
|
||||
one.
|
||||
The full lane also requires the startup-CAS build manifest generated by the `Build debug binary` step in `.github/workflows/ci.yml`. Preserve that binary and both sidecars as its `Preserve startup CAS binary input` step does, and use the same `RUSTFS_E2E_STARTUP_CAS_*` environment as `Run e2e full suite`. A generic local build alone does not supply that fixture evidence.
|
||||
|
||||
**Stale or unverified binary.** Re-run the matching `build` command after changing source or features, then invoke tests through `run`. A missing receipt, copied old executable, or mismatched build identity is a prerequisite failure. Bare Cargo invocations that start a server deliberately fail; unit tests that do not start a server can still run directly.
|
||||
|
||||
**Port already in use / orphan processes.** A hard-killed run can leak a
|
||||
`rustfs` child holding its port. Find and kill it:
|
||||
@@ -251,7 +252,8 @@ spawn error. Install the pinned CI version before running their profiles.
|
||||
A subset of this crate runs on every PR via the `e2e-tests` job:
|
||||
|
||||
```bash
|
||||
cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
```
|
||||
|
||||
The selection lives in `.config/nextest.toml` under `[profile.e2e-smoke]`
|
||||
|
||||
@@ -61,7 +61,6 @@ pub(crate) struct VersionShardCensus {
|
||||
pub has_xl_meta: bool,
|
||||
pub data_dir: Option<String>,
|
||||
pub erasure_index: Option<usize>,
|
||||
pub erasure_distribution: Option<Vec<usize>>,
|
||||
pub data_blocks: Option<usize>,
|
||||
pub parity_blocks: Option<usize>,
|
||||
pub expected_part_numbers: BTreeSet<usize>,
|
||||
@@ -91,7 +90,6 @@ impl VersionShardCensus {
|
||||
&& manifest.is_complete()
|
||||
&& self.data_dir == manifest.data_dir
|
||||
&& self.erasure_index == manifest.erasure_index
|
||||
&& self.erasure_distribution == manifest.erasure_distribution
|
||||
&& self.data_blocks == manifest.data_blocks
|
||||
&& self.parity_blocks == manifest.parity_blocks
|
||||
&& self.expected_part_numbers == manifest.expected_part_numbers
|
||||
@@ -319,7 +317,6 @@ pub(crate) fn census_object_version_on_disk(
|
||||
has_xl_meta: false,
|
||||
data_dir: None,
|
||||
erasure_index: None,
|
||||
erasure_distribution: None,
|
||||
data_blocks: None,
|
||||
parity_blocks: None,
|
||||
expected_part_numbers: BTreeSet::new(),
|
||||
@@ -337,7 +334,6 @@ pub(crate) fn census_object_version_on_disk(
|
||||
};
|
||||
let data_dir = file_info.data_dir.map(|id| id.to_string());
|
||||
let erasure_index = Some(file_info.erasure.index);
|
||||
let erasure_distribution = Some(file_info.erasure.distribution.clone());
|
||||
let inline_data_fingerprint = file_info.data.as_deref().map(shard_fingerprint).transpose()?;
|
||||
let part_dir = data_dir.as_ref().map_or_else(|| object_dir.clone(), |id| object_dir.join(id));
|
||||
let present_part_fingerprints = match std::fs::read_dir(&part_dir) {
|
||||
@@ -370,7 +366,6 @@ pub(crate) fn census_object_version_on_disk(
|
||||
has_xl_meta: true,
|
||||
data_dir,
|
||||
erasure_index,
|
||||
erasure_distribution,
|
||||
data_blocks: Some(file_info.erasure.data_blocks),
|
||||
parity_blocks: Some(file_info.erasure.parity_blocks),
|
||||
expected_part_numbers,
|
||||
@@ -426,7 +421,6 @@ mod tests {
|
||||
has_xl_meta: true,
|
||||
data_dir: Some("data-dir".to_string()),
|
||||
erasure_index: Some(3),
|
||||
erasure_distribution: Some(vec![1, 2, 3, 4]),
|
||||
data_blocks: Some(2),
|
||||
parity_blocks: Some(2),
|
||||
expected_part_numbers: BTreeSet::from([1]),
|
||||
|
||||
+123
-149
@@ -31,7 +31,6 @@ use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use serde_json;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs as stdfs;
|
||||
use std::io::ErrorKind;
|
||||
use std::net::SocketAddr;
|
||||
@@ -44,7 +43,6 @@ use tokio::net::TcpStream;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// Common constants for all E2E tests
|
||||
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
||||
@@ -437,59 +435,75 @@ fn resolve_rustfs_binary_path(workspace: &Path, configured_target_dir: Option<&P
|
||||
path
|
||||
}
|
||||
|
||||
/// Resolve the RustFS binary relative to the workspace, optionally requesting build features.
|
||||
/// Resolve the server verified by `scripts/e2e_binary.py run` for this test invocation.
|
||||
/// Requested features are a required subset of the server's resolved Cargo features.
|
||||
pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_rustfs") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
let requested_features = requested_features.and_then(normalize_rustfs_build_features);
|
||||
|
||||
let workspace = workspace_root();
|
||||
let configured_target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from);
|
||||
let binary_path = resolve_rustfs_binary_path(&workspace, configured_target_dir.as_deref());
|
||||
let binary_path = std::env::var_os("CARGO_BIN_EXE_rustfs")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| resolve_rustfs_binary_path(&workspace, configured_target_dir.as_deref()));
|
||||
let receipt_path = std::env::var_os("RUSTFS_E2E_BINARY_RECEIPT").map(PathBuf::from);
|
||||
receipt_path
|
||||
.ok_or_else(|| std::io::Error::new(ErrorKind::NotFound, "missing E2E run receipt"))
|
||||
.and_then(|receipt| verify_e2e_binary_receipt(&receipt, &workspace, &binary_path, requested_features))
|
||||
.unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"E2E server prerequisite failed: {error}. Build with `python3 scripts/e2e_binary.py build --features <features>` and run tests with `python3 scripts/e2e_binary.py run --features <features> -- cargo nextest run ...`"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
let features_match = binary_features_match(&binary_path, requested_features.as_deref());
|
||||
let source_is_newer = workspace_sources_newer_than_binary(&binary_path);
|
||||
let can_reuse_inside_e2e = running_inside_e2e_test_binary() && requested_features.is_none() && features_match;
|
||||
if binary_path.is_file() && features_match && (!source_is_newer || can_reuse_inside_e2e) {
|
||||
if source_is_newer {
|
||||
warn!(
|
||||
"RustFS binary at {:?} appears older than workspace sources; reusing it inside cargo test to avoid nested builds",
|
||||
binary_path
|
||||
);
|
||||
}
|
||||
info!("Using existing RustFS binary at {:?}", binary_path);
|
||||
return binary_path;
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct E2eBinaryReceipt {
|
||||
schema: u32,
|
||||
workspace: PathBuf,
|
||||
binary: PathBuf,
|
||||
size: u64,
|
||||
modified_ns: u128,
|
||||
features: Vec<String>,
|
||||
}
|
||||
|
||||
fn verify_e2e_binary_receipt(
|
||||
receipt_path: &Path,
|
||||
workspace: &Path,
|
||||
binary_path: &Path,
|
||||
requested_features: Option<&str>,
|
||||
) -> std::io::Result<PathBuf> {
|
||||
let receipt: E2eBinaryReceipt = serde_json::from_slice(&stdfs::read(receipt_path)?)?;
|
||||
let binary = binary_path.canonicalize()?;
|
||||
let metadata = binary.metadata()?;
|
||||
let modified_ns = metadata
|
||||
.modified()?
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(std::io::Error::other)?
|
||||
.as_nanos();
|
||||
// The runner hashes source and binary before/after the entire suite. Each
|
||||
// nextest process checks only this invocation's path, features, and file stat.
|
||||
if receipt.schema != 1
|
||||
|| receipt.workspace != workspace.canonicalize()?
|
||||
|| receipt.binary != binary
|
||||
|| !metadata.is_file()
|
||||
|| receipt.size != metadata.len()
|
||||
|| receipt.modified_ns != modified_ns
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
"E2E server differs from this run's verified binary",
|
||||
));
|
||||
}
|
||||
|
||||
info!("Building RustFS binary to ensure it's up to date...");
|
||||
build_rustfs_binary(requested_features.as_deref(), &binary_path);
|
||||
|
||||
info!("Using RustFS binary at {:?}", binary_path);
|
||||
binary_path
|
||||
}
|
||||
|
||||
fn workspace_sources_newer_than_binary(binary_path: &PathBuf) -> bool {
|
||||
let Ok(binary_meta) = std::fs::metadata(binary_path) else {
|
||||
return true;
|
||||
};
|
||||
let Ok(binary_modified) = binary_meta.modified() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let workspace = workspace_root();
|
||||
let watch_roots = [
|
||||
workspace.join("Cargo.toml"),
|
||||
workspace.join("Cargo.lock"),
|
||||
workspace.join("rustfs"),
|
||||
workspace.join("crates"),
|
||||
];
|
||||
|
||||
watch_roots.iter().any(|path| path_is_newer_than(binary_modified, path))
|
||||
}
|
||||
|
||||
fn running_inside_e2e_test_binary() -> bool {
|
||||
std::env::var("CARGO_PKG_NAME").is_ok_and(|value| value == "e2e_test")
|
||||
if let Some(requested) = requested_features.and_then(normalize_rustfs_build_features)
|
||||
&& requested
|
||||
.split(',')
|
||||
.any(|feature| !receipt.features.iter().any(|actual| actual == feature))
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"E2E server is missing a requested build feature",
|
||||
));
|
||||
}
|
||||
Ok(binary)
|
||||
}
|
||||
|
||||
pub fn requested_rustfs_build_features() -> Option<String> {
|
||||
@@ -519,96 +533,6 @@ pub fn rustfs_build_feature_enabled(requested_features: Option<&str>, required_f
|
||||
.any(|feature| feature.eq_ignore_ascii_case(RUSTFS_FULL_FEATURE) || feature.eq_ignore_ascii_case(required_feature))
|
||||
}
|
||||
|
||||
fn rustfs_binary_features_stamp_path(binary_path: &Path) -> PathBuf {
|
||||
binary_path.with_extension("features")
|
||||
}
|
||||
|
||||
fn binary_features_match(binary_path: &Path, requested_features: Option<&str>) -> bool {
|
||||
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
|
||||
let recorded = stdfs::read_to_string(stamp_path)
|
||||
.ok()
|
||||
.and_then(|value| normalize_rustfs_build_features(&value));
|
||||
let requested = requested_features.and_then(normalize_rustfs_build_features);
|
||||
|
||||
match requested.as_deref() {
|
||||
Some(features) => recorded.as_deref() == Some(features),
|
||||
None => recorded.is_none(),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_is_newer_than(binary_modified: std::time::SystemTime, path: &Path) -> bool {
|
||||
if path.is_file() {
|
||||
return std::fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.map(|modified| modified > binary_modified)
|
||||
.unwrap_or(false);
|
||||
}
|
||||
|
||||
if !path.is_dir() {
|
||||
return false;
|
||||
}
|
||||
|
||||
WalkDir::new(path)
|
||||
.into_iter()
|
||||
.filter_entry(|entry| {
|
||||
let name = entry.file_name();
|
||||
name != OsStr::new("target") && name != OsStr::new(".git")
|
||||
})
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| entry.file_type().is_file())
|
||||
.any(|entry| {
|
||||
std::fs::metadata(entry.path())
|
||||
.and_then(|meta| meta.modified())
|
||||
.map(|modified| modified > binary_modified)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the RustFS binary using cargo
|
||||
fn build_rustfs_binary(requested_features: Option<&str>, binary_path: &Path) {
|
||||
let workspace = workspace_root();
|
||||
info!("Building RustFS binary from workspace: {:?}", workspace);
|
||||
|
||||
let _profile = if cfg!(debug_assertions) {
|
||||
info!("Building in debug mode");
|
||||
"dev"
|
||||
} else {
|
||||
info!("Building in release mode");
|
||||
"release"
|
||||
};
|
||||
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.current_dir(&workspace).args(["build", "--bin", "rustfs"]);
|
||||
|
||||
if let Some(features) = requested_features {
|
||||
cmd.arg("--features").arg(features);
|
||||
info!("Building with features: {}", features);
|
||||
}
|
||||
|
||||
if !cfg!(debug_assertions) {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
|
||||
info!(
|
||||
"Executing: cargo build --bin rustfs {}",
|
||||
if cfg!(debug_assertions) { "" } else { "--release" }
|
||||
);
|
||||
|
||||
let output = cmd.output().expect("Failed to execute cargo build command");
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
panic!("Failed to build RustFS binary. Error: {stderr}");
|
||||
}
|
||||
|
||||
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
|
||||
if let Err(err) = stdfs::write(&stamp_path, requested_features.unwrap_or_default()) {
|
||||
warn!("Failed to write RustFS feature stamp {:?}: {}", stamp_path, err);
|
||||
}
|
||||
|
||||
info!("✅ RustFS binary built successfully");
|
||||
}
|
||||
|
||||
fn awscurl_binary_path() -> PathBuf {
|
||||
std::env::var_os("AWSCURL_PATH")
|
||||
.map(PathBuf::from)
|
||||
@@ -2255,16 +2179,66 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_feature_stamp_matching_uses_normalized_features() {
|
||||
let binary_path = std::env::temp_dir().join(format!("rustfs-feature-stamp-test-{}", Uuid::new_v4()));
|
||||
let stamp_path = rustfs_binary_features_stamp_path(&binary_path);
|
||||
fn explicit_binary_without_run_receipt_is_rejected() {
|
||||
const CHILD_ENV: &str = "RUSTFS_E2E_RECEIPT_TEST_CHILD";
|
||||
if std::env::var_os(CHILD_ENV).is_some() {
|
||||
rustfs_binary_path_with_features(None);
|
||||
return;
|
||||
}
|
||||
let executable = std::env::current_exe().expect("locate isolated test process");
|
||||
let output = Command::new(&executable)
|
||||
.args([
|
||||
"--exact",
|
||||
"common::tests::explicit_binary_without_run_receipt_is_rejected",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
.env("CARGO_BIN_EXE_rustfs", &executable)
|
||||
.env_remove("RUSTFS_E2E_BINARY_RECEIPT")
|
||||
.output()
|
||||
.expect("run the missing-receipt scenario with isolated environment variables");
|
||||
assert!(!output.status.success(), "an explicit binary must not bypass run verification");
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("missing E2E run receipt"));
|
||||
}
|
||||
|
||||
stdfs::write(&stamp_path, " SFTP, ftps ").expect("write feature stamp");
|
||||
assert!(binary_features_match(&binary_path, Some("sftp,ftps")));
|
||||
assert!(binary_features_match(&binary_path, Some(" SFTP, FTPS ")));
|
||||
assert!(!binary_features_match(&binary_path, Some("sftp")));
|
||||
|
||||
stdfs::remove_file(stamp_path).ok();
|
||||
#[test]
|
||||
fn e2e_run_receipt_rejects_replaced_binary_and_missing_features() {
|
||||
let directory = std::env::temp_dir().join(format!("rustfs-e2e-receipt-test-{}", Uuid::new_v4()));
|
||||
stdfs::create_dir(&directory).expect("create receipt fixture");
|
||||
let binary = directory.join("rustfs");
|
||||
let receipt = directory.join("receipt.json");
|
||||
stdfs::write(&binary, "server").expect("write fixture binary");
|
||||
let metadata = binary.metadata().expect("stat fixture binary");
|
||||
let record = serde_json::json!({
|
||||
"schema": 1,
|
||||
"workspace": directory.canonicalize().expect("canonical workspace"),
|
||||
"binary": binary.canonicalize().expect("canonical binary"),
|
||||
"size": metadata.len(),
|
||||
"modified_ns": metadata.modified().expect("modified time").duration_since(std::time::UNIX_EPOCH).expect("positive timestamp").as_nanos(),
|
||||
"features": ["default", "full", "ftps", "webdav", "sftp"]
|
||||
});
|
||||
stdfs::write(&receipt, serde_json::to_vec(&record).expect("serialize receipt")).expect("write receipt");
|
||||
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("sftp,webdav")).expect("resolved feature subset");
|
||||
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("full")).expect("full was actually requested");
|
||||
assert_eq!(
|
||||
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("rio-v2"))
|
||||
.expect_err("full does not enable rio-v2")
|
||||
.kind(),
|
||||
ErrorKind::InvalidInput
|
||||
);
|
||||
let other = directory.join("old-server");
|
||||
stdfs::write(&other, "server").expect("write alternate binary");
|
||||
assert!(verify_e2e_binary_receipt(&receipt, &directory, &other, None).is_err());
|
||||
stdfs::write(&binary, "different server").expect("replace fixture binary");
|
||||
assert!(verify_e2e_binary_receipt(&receipt, &directory, &binary, None).is_err());
|
||||
stdfs::remove_file(&receipt).expect("remove expired receipt");
|
||||
assert_eq!(
|
||||
verify_e2e_binary_receipt(&receipt, &directory, &binary, None)
|
||||
.expect_err("expired receipt")
|
||||
.kind(),
|
||||
ErrorKind::NotFound
|
||||
);
|
||||
stdfs::remove_dir_all(directory).expect("remove receipt fixture");
|
||||
}
|
||||
|
||||
/// Build a cluster environment struct in-memory (no ports, no processes) so
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
// 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.
|
||||
|
||||
//! Standard S3 deletion permissions and the explicit recursive-delete extension.
|
||||
|
||||
use crate::common::{
|
||||
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
|
||||
init_logging,
|
||||
};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||
use aws_sdk_s3::operation::delete_object::{DeleteObjectError, DeleteObjectOutput};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
|
||||
use futures::{StreamExt, TryStreamExt, stream};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
use std::error::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn Error + Send + Sync>>;
|
||||
type VersionSnapshot = BTreeSet<(String, String, bool)>;
|
||||
|
||||
async fn set_policy(env: &RustFSTestEnvironment, name: &str, policy: &Value) -> TestResult {
|
||||
admin_add_canned_policy_via(
|
||||
AdminTransport::Signed,
|
||||
&env.url,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
name,
|
||||
&policy.to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn policy_user(env: &RustFSTestEnvironment, policy_name: &str, policy: Option<Value>) -> TestResult<Client> {
|
||||
let username = Uuid::new_v4().simple().to_string();
|
||||
let secret = Uuid::new_v4().simple().to_string();
|
||||
admin_create_user(env, &username, &secret).await?;
|
||||
if let Some(policy) = policy {
|
||||
set_policy(env, policy_name, &policy).await?;
|
||||
}
|
||||
admin_attach_user_policy_via(AdminTransport::Signed, &env.url, &env.access_key, &env.secret_key, policy_name, &username)
|
||||
.await?;
|
||||
Ok(env.create_s3_client_with_credentials(&username, &secret))
|
||||
}
|
||||
|
||||
async fn versioning(client: &Client, bucket: &str, status: BucketVersioningStatus) -> TestResult {
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(VersioningConfiguration::builder().status(status).build())
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn put(client: &Client, bucket: &str, key: &str) -> TestResult<String> {
|
||||
let result = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"delete authorization fixture"))
|
||||
.send()
|
||||
.await?;
|
||||
Ok(result.version_id().unwrap_or("null").to_string())
|
||||
}
|
||||
|
||||
async fn versions(client: &Client, bucket: &str, prefix: &str) -> TestResult<VersionSnapshot> {
|
||||
let mut result = BTreeSet::new();
|
||||
let mut markers = (None, None);
|
||||
loop {
|
||||
let page = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.prefix(prefix)
|
||||
.set_key_marker(markers.0.clone())
|
||||
.set_version_id_marker(markers.1.clone())
|
||||
.send()
|
||||
.await?;
|
||||
for version in page.versions() {
|
||||
result.insert((
|
||||
version.key().ok_or("listed version missing key")?.to_string(),
|
||||
version.version_id().ok_or("listed version missing ID")?.to_string(),
|
||||
false,
|
||||
));
|
||||
}
|
||||
for marker in page.delete_markers() {
|
||||
result.insert((
|
||||
marker.key().ok_or("listed delete marker missing key")?.to_string(),
|
||||
marker.version_id().ok_or("listed delete marker missing ID")?.to_string(),
|
||||
true,
|
||||
));
|
||||
}
|
||||
if page.is_truncated() != Some(true) {
|
||||
return Ok(result);
|
||||
}
|
||||
let next = (
|
||||
Some(
|
||||
page.next_key_marker()
|
||||
.ok_or("truncated versions page missing next key marker")?
|
||||
.to_string(),
|
||||
),
|
||||
page.next_version_id_marker().map(str::to_string),
|
||||
);
|
||||
assert_ne!(markers, next, "ListObjectVersions pagination must advance");
|
||||
markers = next;
|
||||
}
|
||||
}
|
||||
|
||||
async fn force_delete(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
) -> Result<DeleteObjectOutput, Box<SdkError<DeleteObjectError>>> {
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(prefix)
|
||||
.customize()
|
||||
.mutate_request(|request| {
|
||||
request.headers_mut().insert("x-rustfs-force-delete", "true");
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(Box::new)
|
||||
}
|
||||
|
||||
async fn replica_force_delete(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
) -> Result<DeleteObjectOutput, Box<SdkError<DeleteObjectError>>> {
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(prefix)
|
||||
.customize()
|
||||
.mutate_request(|request| {
|
||||
request.headers_mut().insert("x-rustfs-force-delete", "true");
|
||||
request.headers_mut().insert("x-amz-replication-status", "REPLICA");
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(Box::new)
|
||||
}
|
||||
|
||||
fn assert_denied<T, E>(result: Result<T, SdkError<E>>)
|
||||
where
|
||||
T: std::fmt::Debug,
|
||||
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||
{
|
||||
let error = result.expect_err("request must be denied by its S3 permission");
|
||||
assert_eq!(
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"expected an S3 authorization denial, got {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_boxed_denied<T, E>(result: Result<T, Box<SdkError<E>>>)
|
||||
where
|
||||
T: std::fmt::Debug,
|
||||
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||
{
|
||||
assert_denied(result.map_err(|error| *error));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sdk_version_deletion_requires_only_delete_object_version() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let root = env.create_s3_client();
|
||||
let bucket = "delete-version-permissions";
|
||||
root.create_bucket().bucket(bucket).send().await?;
|
||||
put(&root, bucket, "single-null.txt").await?;
|
||||
put(&root, bucket, "batch-null.txt").await?;
|
||||
versioning(&root, bucket, BucketVersioningStatus::Enabled).await?;
|
||||
let old = put(&root, bucket, "single.txt").await?;
|
||||
let current = put(&root, bucket, "single.txt").await?;
|
||||
let batch_version = put(&root, bucket, "batch.txt").await?;
|
||||
let ordinary_version = put(&root, bucket, "ordinary.txt").await?;
|
||||
let user = policy_user(
|
||||
&env,
|
||||
"version-deleter",
|
||||
Some(json!({"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":"s3:DeleteObjectVersion","Resource":format!("arn:aws:s3:::{bucket}/*")},
|
||||
{"Effect":"Deny","Action":"s3:DeleteObject","Resource":format!("arn:aws:s3:::{bucket}/*")}
|
||||
]})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
user.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("single.txt")
|
||||
.version_id(&old)
|
||||
.send()
|
||||
.await?;
|
||||
user.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("single-null.txt")
|
||||
.version_id("null")
|
||||
.send()
|
||||
.await?;
|
||||
assert_denied(user.delete_object().bucket(bucket).key("ordinary.txt").send().await);
|
||||
|
||||
let batch = user
|
||||
.delete_objects()
|
||||
.bucket(bucket)
|
||||
.delete(
|
||||
Delete::builder()
|
||||
.objects(
|
||||
ObjectIdentifier::builder()
|
||||
.key("batch.txt")
|
||||
.version_id(&batch_version)
|
||||
.build()?,
|
||||
)
|
||||
.objects(ObjectIdentifier::builder().key("batch-null.txt").version_id("null").build()?)
|
||||
.objects(ObjectIdentifier::builder().key("ordinary.txt").build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(batch.deleted().len(), 2, "both explicit version items must succeed");
|
||||
assert_eq!(batch.errors().len(), 1, "only the unversioned item must be denied");
|
||||
assert_eq!(batch.errors()[0].key(), Some("ordinary.txt"));
|
||||
assert_eq!(batch.errors()[0].code(), Some("AccessDenied"));
|
||||
assert_eq!(
|
||||
versions(&root, bucket, "").await?,
|
||||
BTreeSet::from([
|
||||
("single.txt".into(), current, false),
|
||||
("ordinary.txt".into(), ordinary_version, false)
|
||||
]),
|
||||
"version-only deletion must preserve the current single-object version and denied object"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sdk_list_bucket_and_list_bucket_versions_permissions_are_independent() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let root = env.create_s3_client();
|
||||
let bucket = "list-version-permissions";
|
||||
root.create_bucket().bucket(bucket).send().await?;
|
||||
versioning(&root, bucket, BucketVersioningStatus::Enabled).await?;
|
||||
put(&root, bucket, "visible.txt").await?;
|
||||
for (action, name) in [
|
||||
("s3:ListBucket", "object-lister"),
|
||||
("s3:ListBucketVersions", "version-lister"),
|
||||
] {
|
||||
let user = policy_user(
|
||||
&env,
|
||||
name,
|
||||
Some(json!({"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":action,"Resource":format!("arn:aws:s3:::{bucket}")}
|
||||
]})),
|
||||
)
|
||||
.await?;
|
||||
if action == "s3:ListBucket" {
|
||||
assert_eq!(user.list_objects_v2().bucket(bucket).send().await?.contents().len(), 1);
|
||||
assert_denied(user.list_object_versions().bucket(bucket).send().await);
|
||||
} else {
|
||||
assert_eq!(user.list_object_versions().bucket(bucket).send().await?.versions().len(), 1);
|
||||
assert_denied(user.list_objects_v2().bucket(bucket).send().await);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn console_admin_force_delete_removes_prefix_versions_and_delete_markers() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let root = env.create_s3_client();
|
||||
let bucket = "force-console-admin";
|
||||
root.create_bucket().bucket(bucket).send().await?;
|
||||
put(&root, bucket, "folder/null.txt").await?;
|
||||
versioning(&root, bucket, BucketVersioningStatus::Enabled).await?;
|
||||
for key in ["folder/a.txt", "folder/deep/b.txt", "single.txt"] {
|
||||
put(&root, bucket, key).await?;
|
||||
put(&root, bucket, key).await?;
|
||||
root.delete_object().bucket(bucket).key(key).send().await?;
|
||||
}
|
||||
put(&root, bucket, "keep.txt").await?;
|
||||
put(&root, bucket, "folder-sibling/keep.txt").await?;
|
||||
let keep = versions(&root, bucket, "keep.txt").await?;
|
||||
let sibling = versions(&root, bucket, "folder-sibling/").await?;
|
||||
let user = policy_user(&env, "consoleAdmin", None).await?;
|
||||
|
||||
force_delete(&user, bucket, "folder/").await?;
|
||||
assert!(
|
||||
versions(&root, bucket, "folder/").await?.is_empty(),
|
||||
"force prefix deletion must remove null versions and markers"
|
||||
);
|
||||
assert_eq!(versions(&root, bucket, "folder-sibling/").await?, sibling);
|
||||
assert_eq!(
|
||||
versions(&root, bucket, "single.txt").await?.len(),
|
||||
3,
|
||||
"the separate key must survive folder deletion"
|
||||
);
|
||||
force_delete(&user, bucket, "single.txt").await?;
|
||||
assert!(
|
||||
versions(&root, bucket, "single.txt").await?.is_empty(),
|
||||
"explicit force deletion must remove every version of the selected key"
|
||||
);
|
||||
assert_eq!(versions(&root, bucket, "keep.txt").await?, keep);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_delete_authorizes_only_its_path_scope_without_list_permissions() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let root = env.create_s3_client();
|
||||
let bucket = "force-delete-only";
|
||||
root.create_bucket().bucket(bucket).send().await?;
|
||||
versioning(&root, bucket, BucketVersioningStatus::Enabled).await?;
|
||||
put(&root, bucket, "selected.txt").await?;
|
||||
put(&root, bucket, "selected.txt/child.txt").await?;
|
||||
put(&root, bucket, "selected.txt-sibling").await?;
|
||||
let sibling = versions(&root, bucket, "selected.txt-sibling").await?;
|
||||
let user = policy_user(
|
||||
&env,
|
||||
"delete-only",
|
||||
Some(json!({"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":[
|
||||
format!("arn:aws:s3:::{bucket}/selected.txt"), format!("arn:aws:s3:::{bucket}/selected.txt/*")
|
||||
]},
|
||||
{"Effect":"Deny","Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":format!("arn:aws:s3:::{bucket}/selected.txt-sibling")}
|
||||
]})),
|
||||
)
|
||||
.await?;
|
||||
assert_denied(user.list_objects_v2().bucket(bucket).send().await);
|
||||
assert_denied(user.list_object_versions().bucket(bucket).send().await);
|
||||
force_delete(&user, bucket, "selected.txt").await?;
|
||||
assert_eq!(
|
||||
versions(&root, bucket, "selected.txt").await?,
|
||||
sibling,
|
||||
"force deletion must remove the selected path and descendants without authorizing or deleting its similarly prefixed sibling"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_directory_delete_cannot_remove_an_unauthorized_colliding_parent() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let root = env.create_s3_client();
|
||||
let bucket = "force-directory-collision";
|
||||
root.create_bucket().bucket(bucket).send().await?;
|
||||
versioning(&root, bucket, BucketVersioningStatus::Enabled).await?;
|
||||
let protected_parent_version = put(&root, bucket, "collision.txt").await?;
|
||||
for key in ["collision.txt/child", "collision.txt-sibling"] {
|
||||
put(&root, bucket, key).await?;
|
||||
}
|
||||
put(&root, bucket, "collision.txt").await?;
|
||||
root.delete_object().bucket(bucket).key("collision.txt").send().await?;
|
||||
let user = policy_user(
|
||||
&env,
|
||||
"parent-denier",
|
||||
Some(json!({"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":format!("arn:aws:s3:::{bucket}/*")},
|
||||
{"Effect":"Deny","Action":"s3:DeleteObjectVersion","Resource":format!("arn:aws:s3:::{bucket}/collision.txt"),
|
||||
"Condition":{"StringEquals":{"s3:VersionId":protected_parent_version}}}
|
||||
]})),
|
||||
)
|
||||
.await?;
|
||||
let mut expected = versions(&root, bucket, "").await?;
|
||||
expected.retain(|(key, _, _)| key != "collision.txt/child");
|
||||
force_delete(&user, bucket, "collision.txt/").await?;
|
||||
assert_eq!(
|
||||
versions(&root, bucket, "").await?,
|
||||
expected,
|
||||
"folder deletion must preserve the denied parent's historical versions and delete marker, plus its sibling"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_unversioned_directory_requires_only_delete_object() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let root = env.create_s3_client();
|
||||
let bucket = "force-unversioned-permissions";
|
||||
root.create_bucket().bucket(bucket).send().await?;
|
||||
for key in ["folder/", "folder/child.txt", "outside.txt"] {
|
||||
put(&root, bucket, key).await?;
|
||||
}
|
||||
let outside = versions(&root, bucket, "outside.txt").await?;
|
||||
let user = policy_user(
|
||||
&env,
|
||||
"unversioned-deleter",
|
||||
Some(json!({"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":"s3:DeleteObject","Resource":format!("arn:aws:s3:::{bucket}/*")},
|
||||
{"Effect":"Deny","Action":"s3:DeleteObjectVersion","Resource":format!("arn:aws:s3:::{bucket}/*")}
|
||||
]})),
|
||||
)
|
||||
.await?;
|
||||
force_delete(&user, bucket, "folder/").await?;
|
||||
assert_eq!(
|
||||
versions(&root, bucket, "").await?,
|
||||
outside,
|
||||
"unversioned force deletion, including a synthetic nil directory marker, must use DeleteObject permission"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_delete_denied_child_preserves_every_object_despite_bucket_allow() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let root = env.create_s3_client();
|
||||
let bucket = "force-child-denial";
|
||||
root.create_bucket().bucket(bucket).send().await?;
|
||||
for key in ["folder/a-allowed.txt", "folder/z-denied.txt"] {
|
||||
put(&root, bucket, key).await?;
|
||||
}
|
||||
let user = policy_user(
|
||||
&env,
|
||||
"child-denier",
|
||||
Some(json!({"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion","s3:ReplicateDelete"],"Resource":format!("arn:aws:s3:::{bucket}/*")},
|
||||
{"Effect":"Deny","Action":["s3:DeleteObject","s3:DeleteObjectVersion","s3:ReplicateDelete"],"Resource":format!("arn:aws:s3:::{bucket}/folder/z-denied.txt")}
|
||||
]})),
|
||||
)
|
||||
.await?;
|
||||
root.put_bucket_policy()
|
||||
.bucket(bucket)
|
||||
.policy(json!({"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Principal":"*","Action":["s3:DeleteObject","s3:DeleteObjectVersion","s3:ReplicateDelete"],"Resource":format!("arn:aws:s3:::{bucket}/*")}
|
||||
]}).to_string())
|
||||
.send().await?;
|
||||
let before = versions(&root, bucket, "folder/").await?;
|
||||
assert_boxed_denied(force_delete(&user, bucket, "folder/").await);
|
||||
assert_eq!(
|
||||
versions(&root, bucket, "folder/").await?,
|
||||
before,
|
||||
"a denied descendant must prevent every mutation in the force scope"
|
||||
);
|
||||
assert_boxed_denied(replica_force_delete(&user, bucket, "folder/").await);
|
||||
assert_eq!(
|
||||
versions(&root, bucket, "folder/").await?,
|
||||
before,
|
||||
"the REPLICA header must not bypass a descendant's ReplicateDelete denial"
|
||||
);
|
||||
|
||||
root.delete_bucket_policy().bucket(bucket).send().await?;
|
||||
let replica_user = policy_user(
|
||||
&env,
|
||||
"replica-deleter",
|
||||
Some(json!({"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":"s3:DeleteObject","Resource":format!("arn:aws:s3:::{bucket}/*")},
|
||||
{"Effect":"Allow","Action":"s3:ReplicateDelete","Resource":format!("arn:aws:s3:::{bucket}/*")}
|
||||
]})),
|
||||
)
|
||||
.await?;
|
||||
replica_force_delete(&replica_user, bucket, "folder/").await?;
|
||||
assert!(
|
||||
versions(&root, bucket, "folder/").await?.is_empty(),
|
||||
"an authorized replica force request must check ReplicateDelete for its descendants"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_delete_denied_historical_version_preserves_versions_and_markers() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let root = env.create_s3_client();
|
||||
let bucket = "force-version-denial";
|
||||
root.create_bucket().bucket(bucket).send().await?;
|
||||
put(&root, bucket, "folder/null.txt").await?;
|
||||
versioning(&root, bucket, BucketVersioningStatus::Enabled).await?;
|
||||
let protected_version = put(&root, bucket, "folder/versioned.txt").await?;
|
||||
put(&root, bucket, "folder/versioned.txt").await?;
|
||||
let marker = root.delete_object().bucket(bucket).key("folder/versioned.txt").send().await?;
|
||||
let marker_version = marker
|
||||
.version_id()
|
||||
.ok_or("versioned delete must return a marker version ID")?;
|
||||
put(&root, bucket, "folder/a-allowed.txt").await?;
|
||||
let policy = |version: &str| {
|
||||
json!({"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":format!("arn:aws:s3:::{bucket}/*")},
|
||||
{"Effect":"Deny","Action":"s3:DeleteObjectVersion","Resource":format!("arn:aws:s3:::{bucket}/folder/*"),
|
||||
"Condition":{"StringEquals":{"s3:VersionId":version}}}
|
||||
]})
|
||||
};
|
||||
let user = policy_user(&env, "version-denier", Some(policy(&protected_version))).await?;
|
||||
let before = versions(&root, bucket, "folder/").await?;
|
||||
for version in [protected_version.as_str(), "null", marker_version] {
|
||||
set_policy(&env, "version-denier", &policy(version)).await?;
|
||||
assert_boxed_denied(force_delete(&user, bucket, "folder/").await);
|
||||
assert_eq!(
|
||||
versions(&root, bucket, "folder/").await?,
|
||||
before,
|
||||
"denial of a historical, null, or delete-marker version must prevent recursive deletion"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sdk_ordinary_deletion_preserves_versions_and_directory_children() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let root = env.create_s3_client();
|
||||
let user = policy_user(
|
||||
&env,
|
||||
"ordinary-deleter",
|
||||
Some(json!({"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":"s3:DeleteObject","Resource":"arn:aws:s3:::*/*"},
|
||||
{"Effect":"Deny","Action":"s3:DeleteObjectVersion","Resource":"arn:aws:s3:::*/*"}
|
||||
]})),
|
||||
)
|
||||
.await?;
|
||||
for state in ["unversioned", "enabled", "suspended"] {
|
||||
let bucket = format!("ordinary-directory-{state}");
|
||||
root.create_bucket().bucket(&bucket).send().await?;
|
||||
if state != "unversioned" {
|
||||
versioning(&root, &bucket, BucketVersioningStatus::Enabled).await?;
|
||||
}
|
||||
let historical = put(&root, &bucket, "object.txt").await?;
|
||||
if state == "suspended" {
|
||||
versioning(&root, &bucket, BucketVersioningStatus::Suspended).await?;
|
||||
put(&root, &bucket, "object.txt").await?;
|
||||
}
|
||||
put(&root, &bucket, "folder/").await?;
|
||||
put(&root, &bucket, "folder/child.txt").await?;
|
||||
let child_before = versions(&root, &bucket, "folder/child.txt").await?;
|
||||
user.delete_object().bucket(&bucket).key("folder/").send().await?;
|
||||
assert_eq!(
|
||||
versions(&root, &bucket, "folder/").await?,
|
||||
child_before,
|
||||
"ordinary {state} directory-key deletion must remove only its synthetic marker and preserve children"
|
||||
);
|
||||
let deleted = user.delete_object().bucket(&bucket).key("object.txt").send().await?;
|
||||
let object_versions = versions(&root, &bucket, "object.txt").await?;
|
||||
if state == "unversioned" {
|
||||
assert!(object_versions.is_empty());
|
||||
} else {
|
||||
assert_eq!(deleted.delete_marker(), Some(true));
|
||||
assert_eq!(
|
||||
object_versions.len(),
|
||||
2,
|
||||
"ordinary {state} deletion must retain its historical data version"
|
||||
);
|
||||
assert!(object_versions.contains(&("object.txt".into(), historical, false)));
|
||||
if state == "suspended" {
|
||||
assert!(object_versions.contains(&("object.txt".into(), "null".into(), true)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sdk_delete_objects_force_header_keeps_explicit_item_scope() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let root = env.create_s3_client();
|
||||
let bucket = "batch-force-explicit-scope";
|
||||
root.create_bucket().bucket(bucket).send().await?;
|
||||
put(&root, bucket, "folder/").await?;
|
||||
put(&root, bucket, "folder/child.txt").await?;
|
||||
let child = versions(&root, bucket, "folder/child.txt").await?;
|
||||
let user = policy_user(&env, "consoleAdmin", None).await?;
|
||||
let result = user
|
||||
.delete_objects()
|
||||
.bucket(bucket)
|
||||
.delete(
|
||||
Delete::builder()
|
||||
.objects(ObjectIdentifier::builder().key("folder/").build()?)
|
||||
.build()?,
|
||||
)
|
||||
.customize()
|
||||
.mutate_request(|request| {
|
||||
request.headers_mut().insert("x-rustfs-force-delete", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
assert!(result.errors().is_empty());
|
||||
assert_eq!(result.deleted().len(), 1);
|
||||
assert_eq!(
|
||||
versions(&root, bucket, "folder/").await?,
|
||||
child,
|
||||
"batch deletion must remove only the explicit directory marker even with the force header"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_delete_checks_every_version_page_before_mutation() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let root = env.create_s3_client();
|
||||
let bucket = "force-delete-pagination";
|
||||
root.create_bucket().bucket(bucket).send().await?;
|
||||
versioning(&root, bucket, BucketVersioningStatus::Enabled).await?;
|
||||
stream::iter(0..1000)
|
||||
.map(|index| {
|
||||
let root = &root;
|
||||
async move { put(root, bucket, &format!("folder/{index:04}.txt")).await.map(|_| ()) }
|
||||
})
|
||||
.buffer_unordered(16)
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
put(&root, bucket, "folder/z-denied.txt").await?;
|
||||
let allow = json!({"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":format!("arn:aws:s3:::{bucket}/*")});
|
||||
let user = policy_user(
|
||||
&env,
|
||||
"paged-deleter",
|
||||
Some(json!({"Version":"2012-10-17","Statement":[allow.clone(),
|
||||
{"Effect":"Deny","Action":"s3:DeleteObjectVersion","Resource":format!("arn:aws:s3:::{bucket}/folder/z-denied.txt")}
|
||||
]})),
|
||||
)
|
||||
.await?;
|
||||
let before = versions(&root, bucket, "folder/").await?;
|
||||
assert_eq!(before.len(), 1001, "the denied key must be beyond one default versions page");
|
||||
assert_boxed_denied(force_delete(&user, bucket, "folder/").await);
|
||||
assert_eq!(
|
||||
versions(&root, bucket, "folder/").await?,
|
||||
before,
|
||||
"a denial on the second page must preserve the first page too"
|
||||
);
|
||||
|
||||
set_policy(&env, "paged-deleter", &json!({"Version":"2012-10-17","Statement":[allow]})).await?;
|
||||
force_delete(&user, bucket, "folder/").await?;
|
||||
assert!(
|
||||
versions(&root, bucket, "folder/").await?.is_empty(),
|
||||
"authorized recursive deletion must cover all pages"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -32,7 +32,6 @@ const EC84_NODE_COUNT: usize = 3;
|
||||
const EC84_DRIVES_PER_NODE: usize = 4;
|
||||
const EC84_DATA_BLOCKS: usize = 8;
|
||||
const EC84_PARITY_BLOCKS: usize = 4;
|
||||
const EC84_ERASURE_SET_DRIVE_COUNT: usize = EC84_DATA_BLOCKS + EC84_PARITY_BLOCKS;
|
||||
const EC84_TARGET_DRIVE_RESTART_CASE: &str = "ec84-target-drive-restart";
|
||||
const EC84_TARGET_DRIVE_RESTART_ORACLE: &str = "ec84-target-drive-restart.json";
|
||||
const EC84_HEAL_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
@@ -186,7 +185,6 @@ async fn write_scanner_heal_evidence(context: ScannerHealEvidenceContext, payloa
|
||||
"binary_sha256": string_field(&context.run, "binary.sha256")?,
|
||||
"test_binary_sha256": string_field(&context.run, "test_binary.sha256")?,
|
||||
"topology": {"nodes": EC84_NODE_COUNT, "drives_per_node": EC84_DRIVES_PER_NODE},
|
||||
"erasure_set_drive_count": EC84_ERASURE_SET_DRIVE_COUNT,
|
||||
"pid_before": payload.pid_before,
|
||||
"pid_after": payload.pid_after,
|
||||
"unclean_shutdown_marker": false,
|
||||
@@ -253,7 +251,6 @@ async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<Ex
|
||||
has_xl_meta: false,
|
||||
data_dir: None,
|
||||
erasure_index: None,
|
||||
erasure_distribution: None,
|
||||
data_blocks: None,
|
||||
parity_blocks: None,
|
||||
expected_part_numbers: Default::default(),
|
||||
@@ -413,16 +410,4 @@ mod tests {
|
||||
"admin POST failed: 503 Service Unavailable cluster heal coordination unavailable".into();
|
||||
assert!(!is_cluster_heal_coordination_unavailable(wrong_status.as_ref()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ec84_drive_restart_evidence_shape_matches_registry() {
|
||||
assert_eq!(EC84_ERASURE_SET_DRIVE_COUNT, EC84_NODE_COUNT * EC84_DRIVES_PER_NODE);
|
||||
|
||||
let registry: Value = serde_json::from_str(include_str!("../../../../.config/scanner-heal-required-tests.json"))
|
||||
.expect("scanner/heal registry is valid JSON");
|
||||
let case = ®istry["cases"][EC84_TARGET_DRIVE_RESTART_CASE];
|
||||
assert_eq!(case["erasure_set_drive_count"], EC84_ERASURE_SET_DRIVE_COUNT);
|
||||
assert_eq!(case["topology"]["nodes"], EC84_NODE_COUNT);
|
||||
assert_eq!(case["topology"]["drives_per_node"], EC84_DRIVES_PER_NODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ mod harness;
|
||||
mod heal_test;
|
||||
mod object_lock_test;
|
||||
mod observability_test;
|
||||
mod replication_delete_marker_test;
|
||||
mod replication_quota_test;
|
||||
mod s3_basic_test;
|
||||
mod s3_during_data_movement_test;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// 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.
|
||||
|
||||
//! Functional REP-105 (rustfs/backlog#2195 item 4): a delete marker created
|
||||
//! on a multi-node source cluster must replicate to the bucket-replication
|
||||
//! target. Objects converged in seconds while delete markers did not arrive
|
||||
//! within 180 s on the shared 3-node functional environment; the single-node
|
||||
//! e2e never saw it.
|
||||
|
||||
use super::harness::{
|
||||
DistCluster, DistLayout, TestResult, enable_versioning, put_bucket_replication, put_object, set_remote_target, unique_bucket,
|
||||
wait_for_replicated_bytes, wait_until,
|
||||
};
|
||||
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, RustFSTestEnvironment, init_logging, replication_fast_env, signed_request};
|
||||
use crate::replication_extension_test::LOOPBACK_REPLICATION_TARGET_ENV;
|
||||
use aws_sdk_s3::Client;
|
||||
use http::{Method, StatusCode};
|
||||
use std::time::Duration;
|
||||
|
||||
async fn target_has_delete_marker(client: &Client, bucket: &str, key: &str) -> TestResult<bool> {
|
||||
let versions = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
|
||||
Ok(versions.delete_markers().iter().any(|marker| marker.key() == Some(key)))
|
||||
}
|
||||
|
||||
async fn delete_marker_replicates(
|
||||
source: &DistCluster,
|
||||
source_bucket: &str,
|
||||
target_client: &Client,
|
||||
target_bucket: &str,
|
||||
) -> TestResult {
|
||||
let key = "delete-marker/object.bin";
|
||||
let body = b"delete marker replication payload".to_vec();
|
||||
// Write through one node, delete through another: behind a load
|
||||
// balancer consecutive requests land on different nodes.
|
||||
put_object(&source.client(1)?, source_bucket, key, body.clone()).await?;
|
||||
wait_for_replicated_bytes(target_client, target_bucket, key, &body, Duration::from_secs(60)).await?;
|
||||
|
||||
let delete = source
|
||||
.client(2)?
|
||||
.delete_object()
|
||||
.bucket(source_bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
delete.delete_marker(),
|
||||
Some(true),
|
||||
"a versioned DELETE without versionId must create a marker"
|
||||
);
|
||||
|
||||
wait_until(
|
||||
Duration::from_secs(90),
|
||||
|| async { target_has_delete_marker(target_client, target_bucket, key).await },
|
||||
"delete marker replicated to the target bucket",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_bucket_replication_replicates_delete_marker_to_peer_cluster() -> TestResult {
|
||||
init_logging();
|
||||
let (source, target) = DistCluster::start_replication_pair().await?;
|
||||
let source_bucket = unique_bucket("dm-src");
|
||||
let target_bucket = unique_bucket("dm-dst");
|
||||
source.create_bucket(&source_bucket).await?;
|
||||
target.create_bucket(&target_bucket).await?;
|
||||
enable_versioning(&source.client(0)?, &source_bucket).await?;
|
||||
enable_versioning(&target.client(0)?, &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?;
|
||||
|
||||
delete_marker_replicates(&source, &source_bucket, &target.client(0)?, &target_bucket).await
|
||||
}
|
||||
|
||||
/// The functional environment replicates from a 3-node site to a single-node
|
||||
/// target; keep that shape as its own case.
|
||||
#[tokio::test]
|
||||
async fn four_node_bucket_replication_replicates_delete_marker_to_single_node_target() -> TestResult {
|
||||
init_logging();
|
||||
let mut extra: Vec<(&str, &str)> = replication_fast_env();
|
||||
extra.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
extra.extend_from_slice(FAST_DATA_USAGE_SCANNER_ENV);
|
||||
let source = DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &extra).await?;
|
||||
let mut target = RustFSTestEnvironment::new().await?;
|
||||
target.start_rustfs_server_without_cleanup(vec![]).await?;
|
||||
|
||||
let source_bucket = unique_bucket("dm-src");
|
||||
let target_bucket = unique_bucket("dm-dst");
|
||||
source.create_bucket(&source_bucket).await?;
|
||||
let target_client = target.create_s3_client();
|
||||
target_client.create_bucket().bucket(&target_bucket).send().await?;
|
||||
enable_versioning(&source.client(0)?, &source_bucket).await?;
|
||||
enable_versioning(&target_client, &target_bucket).await?;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"endpoint": target.address,
|
||||
"credentials": { "accessKey": target.access_key, "secretKey": target.secret_key },
|
||||
"targetbucket": target_bucket,
|
||||
"secure": false,
|
||||
"type": "replication"
|
||||
});
|
||||
let url = format!(
|
||||
"{}/rustfs/admin/v3/set-remote-target?bucket={}",
|
||||
source.cluster.nodes[0].url,
|
||||
urlencoding::encode(&source_bucket)
|
||||
);
|
||||
let response = signed_request(
|
||||
Method::PUT,
|
||||
&url,
|
||||
&source.cluster.access_key,
|
||||
&source.cluster.secret_key,
|
||||
Some(body.to_string().into_bytes()),
|
||||
Some("application/json"),
|
||||
)
|
||||
.await?;
|
||||
if response.status() != StatusCode::OK {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("set remote target failed: {status} {body}").into());
|
||||
}
|
||||
let arn: String = serde_json::from_slice(&response.bytes().await?)?;
|
||||
put_bucket_replication(&source.cluster, &source_bucket, &arn).await?;
|
||||
|
||||
delete_marker_replicates(&source, &source_bucket, &target_client, &target_bucket).await
|
||||
}
|
||||
@@ -126,3 +126,165 @@ async fn four_node_site_replication_replicates_object_to_peer_site() -> TestResu
|
||||
wait_for_replicated_bytes(&site_a.client(3)?, &bucket, reverse_key, &reverse_body, Duration::from_secs(60)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn node_admin(
|
||||
cluster: &crate::common::RustFSTestClusterEnvironment,
|
||||
node_idx: usize,
|
||||
method: Method,
|
||||
path_and_query: &str,
|
||||
body: Option<String>,
|
||||
) -> TestResult<(StatusCode, String)> {
|
||||
crate::common::admin_request(
|
||||
&cluster.nodes[node_idx].url,
|
||||
method,
|
||||
path_and_query,
|
||||
body,
|
||||
&cluster.access_key,
|
||||
&cluster.secret_key,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Pair two clusters through site A's first node and wait until both report
|
||||
/// the two-site topology as enabled.
|
||||
async fn pair_sites(site_a: &DistCluster, site_b: &DistCluster) -> TestResult {
|
||||
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?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_users_contains(
|
||||
cluster: &crate::common::RustFSTestClusterEnvironment,
|
||||
node_idx: usize,
|
||||
access_key: &str,
|
||||
) -> TestResult<bool> {
|
||||
let (status, body) = node_admin(cluster, node_idx, Method::GET, "/rustfs/admin/v3/list-users", None).await?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("list-users on node {node_idx} failed: {status} {body}").into());
|
||||
}
|
||||
let users: serde_json::Value = serde_json::from_str(&body)?;
|
||||
Ok(users.get(access_key).is_some())
|
||||
}
|
||||
|
||||
/// backlog#2367 A-7 / functional SITE-102: an IAM change handled by a node
|
||||
/// other than the one that ran `site-replication/add` must still reach the
|
||||
/// peer site. Behind a load balancer every admin call may land on a
|
||||
/// different node, so the coordinator node is not special.
|
||||
#[tokio::test]
|
||||
async fn four_node_site_replication_converges_iam_user_created_on_a_non_coordinator_node() -> TestResult {
|
||||
init_logging();
|
||||
let (site_a, site_b) = DistCluster::start_replication_pair().await?;
|
||||
pair_sites(&site_a, &site_b).await?;
|
||||
|
||||
let user = format!("siteuser-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||
let body = serde_json::json!({ "secretKey": "siteuser-secret-key-1234", "status": "enabled" }).to_string();
|
||||
let (status, response) = node_admin(
|
||||
&site_a.cluster,
|
||||
1,
|
||||
Method::PUT,
|
||||
&format!("/rustfs/admin/v3/add-user?accessKey={user}"),
|
||||
Some(body),
|
||||
)
|
||||
.await?;
|
||||
assert!(status.is_success(), "add-user on site A node 1 failed: {status} {response}");
|
||||
|
||||
let site_b_cluster = &site_b.cluster;
|
||||
let user_ref = user.as_str();
|
||||
wait_until(
|
||||
Duration::from_secs(90),
|
||||
|| async move { list_users_contains(site_b_cluster, 0, user_ref).await },
|
||||
"user created on site A node 1 visible on site B",
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
list_users_contains(&site_a.cluster, 2, &user).await?,
|
||||
"the user must be visible on every site A node"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// backlog#2367 A-5 / functional SITE-105: a resync started right after
|
||||
/// pairing must not report buckets as failed. The bucket carrying an
|
||||
/// operator-configured bucket-replication target to the peer (the shape the
|
||||
/// functional suite leaves behind) and a plain versioned bucket are both
|
||||
/// wired by the pairing itself.
|
||||
#[tokio::test]
|
||||
async fn four_node_site_replication_resync_start_right_after_pairing_reports_no_failed_bucket() -> TestResult {
|
||||
init_logging();
|
||||
let (site_a, site_b) = DistCluster::start_replication_pair().await?;
|
||||
|
||||
let pre_src = unique_bucket("pre-src");
|
||||
let pre_dst = unique_bucket("pre-dst");
|
||||
let plain = unique_bucket("plain");
|
||||
site_a.create_bucket(&pre_src).await?;
|
||||
site_b.create_bucket(&pre_dst).await?;
|
||||
site_a.create_bucket(&plain).await?;
|
||||
enable_versioning(&site_a.client(0)?, &pre_src).await?;
|
||||
enable_versioning(&site_b.client(0)?, &pre_dst).await?;
|
||||
enable_versioning(&site_a.client(0)?, &plain).await?;
|
||||
let arn = super::harness::set_remote_target(&site_a.cluster, &pre_src, &site_b.cluster, &pre_dst).await?;
|
||||
super::harness::put_bucket_replication(&site_a.cluster, &pre_src, &arn).await?;
|
||||
|
||||
pair_sites(&site_a, &site_b).await?;
|
||||
|
||||
let (status, info) = node_admin(&site_a.cluster, 1, Method::GET, "/rustfs/admin/v3/site-replication/info", None).await?;
|
||||
assert!(status.is_success(), "site-replication/info failed: {status} {info}");
|
||||
let info: serde_json::Value = serde_json::from_str(&info)?;
|
||||
let peer = info["sites"]
|
||||
.as_array()
|
||||
.and_then(|sites| sites.iter().find(|site| site["name"] == "site-b"))
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("site-b peer missing from info: {info}"))?;
|
||||
|
||||
// Through a non-coordinator node, like a load-balanced admin call.
|
||||
let (status, response) = node_admin(
|
||||
&site_a.cluster,
|
||||
1,
|
||||
Method::PUT,
|
||||
"/rustfs/admin/v3/site-replication/resync/op?operation=start",
|
||||
Some(peer.to_string()),
|
||||
)
|
||||
.await?;
|
||||
assert!(status.is_success(), "resync start failed: {status} {response}");
|
||||
let resync: rustfs_madmin::SRResyncOpStatus = serde_json::from_str(&response)?;
|
||||
let failed: Vec<String> = resync
|
||||
.buckets
|
||||
.iter()
|
||||
.filter(|bucket| bucket.status == "failed")
|
||||
.map(|bucket| format!("{}: {}", bucket.bucket, bucket.err_detail))
|
||||
.collect();
|
||||
assert!(
|
||||
failed.is_empty(),
|
||||
"resync right after pairing reported failed buckets: {failed:?} (status={}, detail={})",
|
||||
resync.status,
|
||||
resync.err_detail
|
||||
);
|
||||
assert!(
|
||||
resync.buckets.iter().any(|bucket| bucket.bucket == pre_src)
|
||||
&& resync.buckets.iter().any(|bucket| bucket.bucket == plain),
|
||||
"both buckets must be part of the resync: {:?}",
|
||||
resync.buckets
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ mod tests {
|
||||
use crate::storage_api::RUSTFS_META_BUCKET;
|
||||
use aws_sdk_s3::{
|
||||
error::{ProvideErrorMetadata, SdkError},
|
||||
operation::{delete_object::DeleteObjectError, put_object::PutObjectError},
|
||||
operation::put_object::PutObjectError,
|
||||
primitives::ByteStream,
|
||||
};
|
||||
use http::Method;
|
||||
@@ -45,7 +45,6 @@ mod tests {
|
||||
|
||||
struct ReplacementDriveSelection {
|
||||
replaced_disk: PathBuf,
|
||||
drive_index: usize,
|
||||
replacement_format_path: PathBuf,
|
||||
replacement_format: Vec<u8>,
|
||||
expected_pool_metadata: Option<VersionShardCensus>,
|
||||
@@ -462,12 +461,6 @@ mod tests {
|
||||
shard_census: VersionShardCensus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct OutagePeerManifest {
|
||||
erasure_indices: HashSet<usize>,
|
||||
erasure_distribution: Option<Vec<usize>>,
|
||||
}
|
||||
|
||||
fn deterministic_object_body(len: usize, seed: u8) -> Vec<u8> {
|
||||
let mut value = seed;
|
||||
std::iter::repeat_with(|| {
|
||||
@@ -493,81 +486,6 @@ mod tests {
|
||||
Ok(matching)
|
||||
}
|
||||
|
||||
fn collect_outage_peer_manifest(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
offline_node_index: usize,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
erasure_set_drive_count: usize,
|
||||
) -> Result<OutagePeerManifest, Box<dyn Error + Send + Sync>> {
|
||||
let mut manifest = OutagePeerManifest::default();
|
||||
for (node_index, node) in cluster.nodes.iter().enumerate() {
|
||||
if node_index == offline_node_index {
|
||||
continue;
|
||||
}
|
||||
for (drive_index, drive) in node.data_dirs.iter().enumerate() {
|
||||
let census = census_object_version_on_disk(Path::new(drive), bucket, key, None)?;
|
||||
if !census.has_xl_meta {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
census.is_complete(),
|
||||
"online node {node_index} drive {drive_index} must hold a complete outage-object shard: {census:?}"
|
||||
);
|
||||
let erasure_index = census.erasure_index.ok_or_else(|| {
|
||||
format!("online node {node_index} drive {drive_index} outage-object shard has no erasure index: {census:?}")
|
||||
})?;
|
||||
assert!(
|
||||
(1..=erasure_set_drive_count).contains(&erasure_index),
|
||||
"online node {node_index} drive {drive_index} outage-object erasure index is out of range: {census:?}"
|
||||
);
|
||||
let distribution = census.erasure_distribution.as_ref().ok_or_else(|| {
|
||||
format!(
|
||||
"online node {node_index} drive {drive_index} outage-object shard has no erasure distribution: {census:?}"
|
||||
)
|
||||
})?;
|
||||
assert_eq!(
|
||||
distribution.len(),
|
||||
erasure_set_drive_count,
|
||||
"online node {node_index} drive {drive_index} outage-object distribution must match the erasure set: {census:?}"
|
||||
);
|
||||
match &manifest.erasure_distribution {
|
||||
Some(existing) => {
|
||||
assert_eq!(existing, distribution, "outage-object shards must agree on one erasure distribution")
|
||||
}
|
||||
None => manifest.erasure_distribution = Some(distribution.clone()),
|
||||
}
|
||||
assert!(
|
||||
manifest.erasure_indices.insert(erasure_index),
|
||||
"outage-object erasure index {erasure_index} is duplicated across online drives"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn outage_candidate_replacement_erasure_index(
|
||||
peer_manifest: &OutagePeerManifest,
|
||||
erasure_set_drive_count: usize,
|
||||
replacement_set_slot: usize,
|
||||
) -> Option<usize> {
|
||||
let distribution = peer_manifest.erasure_distribution.as_ref()?;
|
||||
distribution.get(replacement_set_slot).copied().filter(|replacement_index| {
|
||||
(1..=erasure_set_drive_count).contains(replacement_index)
|
||||
&& !peer_manifest.erasure_indices.contains(replacement_index)
|
||||
})
|
||||
}
|
||||
|
||||
fn outage_candidate_targets_replacement(
|
||||
peer_manifest: &OutagePeerManifest,
|
||||
erasure_set_drive_count: usize,
|
||||
replacement_set_slot: usize,
|
||||
) -> bool {
|
||||
let min_online_data_shards = erasure_set_drive_count.saturating_sub(4);
|
||||
peer_manifest.erasure_indices.len() >= min_online_data_shards
|
||||
&& outage_candidate_replacement_erasure_index(peer_manifest, erasure_set_drive_count, replacement_set_slot).is_some()
|
||||
}
|
||||
|
||||
fn metadata_count(disk: &Path, bucket: &str, expected_manifests: &[PhysicalObjectManifest]) -> usize {
|
||||
expected_manifests
|
||||
.iter()
|
||||
@@ -683,10 +601,6 @@ mod tests {
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("ServiceUnavailable")
|
||||
}
|
||||
|
||||
fn is_service_unavailable_delete(error: &SdkError<DeleteObjectError>) -> bool {
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("ServiceUnavailable")
|
||||
}
|
||||
|
||||
fn select_replacement_drive(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
node_index: usize,
|
||||
@@ -698,7 +612,7 @@ mod tests {
|
||||
.ok_or_else(|| format!("replacement node {node_index} is absent"))?;
|
||||
let mut incomplete_pool_metadata = Vec::new();
|
||||
|
||||
for (drive_index, drive) in node.data_dirs.iter().enumerate() {
|
||||
for drive in &node.data_dirs {
|
||||
let replaced_disk = PathBuf::from(drive);
|
||||
let replacement_format_path = replaced_disk.join(".rustfs.sys").join("format.json");
|
||||
let replacement_format = std::fs::read(&replacement_format_path).map_err(|err| {
|
||||
@@ -707,7 +621,6 @@ mod tests {
|
||||
if !require_pool_metadata {
|
||||
return Ok(ReplacementDriveSelection {
|
||||
replaced_disk,
|
||||
drive_index,
|
||||
replacement_format_path,
|
||||
replacement_format,
|
||||
expected_pool_metadata: None,
|
||||
@@ -718,7 +631,6 @@ mod tests {
|
||||
if census.is_complete() {
|
||||
return Ok(ReplacementDriveSelection {
|
||||
replaced_disk,
|
||||
drive_index,
|
||||
replacement_format_path,
|
||||
replacement_format,
|
||||
expected_pool_metadata: Some(census),
|
||||
@@ -1268,7 +1180,7 @@ mod tests {
|
||||
async fn test_cluster_root_heal_recovers_ec84_shards_across_multi_set_after_background_target_restart()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
timeout(
|
||||
Duration::from_secs(900),
|
||||
Duration::from_secs(600),
|
||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84MultiSet),
|
||||
)
|
||||
.await?
|
||||
@@ -1420,13 +1332,10 @@ mod tests {
|
||||
|
||||
let ReplacementDriveSelection {
|
||||
replaced_disk,
|
||||
drive_index: replacement_drive_index,
|
||||
replacement_format_path,
|
||||
replacement_format,
|
||||
expected_pool_metadata,
|
||||
} = select_replacement_drive(&cluster, 1, background_enabled)?;
|
||||
let replacement_global_drive_index = topology.drives_per_node + replacement_drive_index;
|
||||
let replacement_set_slot = replacement_global_drive_index % erasure_set_drive_count;
|
||||
let default_online_object_count = if !outage_target_manifest_required { 64 } else { 24 };
|
||||
let online_object_count = std::env::var("RUSTFS_HEAL_CHAOS_OBJECT_COUNT")
|
||||
.ok()
|
||||
@@ -1482,23 +1391,6 @@ mod tests {
|
||||
expected_manifests.push(PhysicalObjectManifest { key, shard_census });
|
||||
attempt_count += 1;
|
||||
}
|
||||
for manifest in &expected_manifests {
|
||||
let distribution = manifest
|
||||
.shard_census
|
||||
.erasure_distribution
|
||||
.as_ref()
|
||||
.ok_or_else(|| format!("replacement baseline manifest has no erasure distribution: {manifest:?}"))?;
|
||||
assert_eq!(
|
||||
distribution.len(),
|
||||
erasure_set_drive_count,
|
||||
"replacement baseline distribution must match the erasure set: {manifest:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.shard_census.erasure_index,
|
||||
distribution.get(replacement_set_slot).copied(),
|
||||
"replacement baseline shard must match the selected drive's erasure-set slot"
|
||||
);
|
||||
}
|
||||
|
||||
if background_enabled {
|
||||
wait_for_scanner_cycle_after(&cluster, 0).await?;
|
||||
@@ -1523,9 +1415,6 @@ mod tests {
|
||||
let mut outage_write_deferred_until_rejoin = false;
|
||||
let mut service_unavailable_outage_writes = 0usize;
|
||||
let mut last_service_unavailable = None;
|
||||
let mut outage_peer_manifest = OutagePeerManifest::default();
|
||||
let mut replacement_outage_erasure_index = None;
|
||||
let mut rejected_outage_keys = Vec::new();
|
||||
for attempt in 0..max_outage_write_attempts {
|
||||
let candidate_key = format!("cluster/written-while-node-down-{attempt:04}.bin");
|
||||
let put_result = timeout(
|
||||
@@ -1540,24 +1429,6 @@ mod tests {
|
||||
.await;
|
||||
match put_result {
|
||||
Ok(Ok(_)) => {
|
||||
if outage_target_manifest_required {
|
||||
let candidate_peer_manifest =
|
||||
collect_outage_peer_manifest(&cluster, 1, bucket, &candidate_key, erasure_set_drive_count)?;
|
||||
if !outage_candidate_targets_replacement(
|
||||
&candidate_peer_manifest,
|
||||
erasure_set_drive_count,
|
||||
replacement_set_slot,
|
||||
) {
|
||||
rejected_outage_keys.push(candidate_key);
|
||||
continue;
|
||||
}
|
||||
replacement_outage_erasure_index = outage_candidate_replacement_erasure_index(
|
||||
&candidate_peer_manifest,
|
||||
erasure_set_drive_count,
|
||||
replacement_set_slot,
|
||||
);
|
||||
outage_peer_manifest = candidate_peer_manifest;
|
||||
}
|
||||
outage_key = Some(candidate_key);
|
||||
break;
|
||||
}
|
||||
@@ -1585,37 +1456,57 @@ mod tests {
|
||||
}
|
||||
};
|
||||
|
||||
if !outage_write_deferred_until_rejoin && outage_peer_manifest.erasure_indices.is_empty() {
|
||||
outage_peer_manifest = collect_outage_peer_manifest(&cluster, 1, bucket, &outage_key, erasure_set_drive_count)?;
|
||||
replacement_outage_erasure_index =
|
||||
outage_candidate_replacement_erasure_index(&outage_peer_manifest, erasure_set_drive_count, replacement_set_slot);
|
||||
let mut outage_peer_erasure_indices = HashSet::new();
|
||||
if !outage_write_deferred_until_rejoin {
|
||||
for (node_index, node) in cluster.nodes.iter().enumerate() {
|
||||
if node_index == 1 {
|
||||
continue;
|
||||
}
|
||||
for (drive_index, drive) in node.data_dirs.iter().enumerate() {
|
||||
let census = census_object_version_on_disk(Path::new(drive), bucket, &outage_key, None)?;
|
||||
if !census.has_xl_meta {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
census.is_complete(),
|
||||
"online node {node_index} drive {drive_index} must hold a complete outage-object shard: {census:?}"
|
||||
);
|
||||
let erasure_index = census.erasure_index.ok_or_else(|| {
|
||||
format!(
|
||||
"online node {node_index} drive {drive_index} outage-object shard has no erasure index: {census:?}"
|
||||
)
|
||||
})?;
|
||||
assert!(
|
||||
(1..=erasure_set_drive_count).contains(&erasure_index),
|
||||
"online node {node_index} drive {drive_index} outage-object erasure index is out of range: {census:?}"
|
||||
);
|
||||
assert!(
|
||||
outage_peer_erasure_indices.insert(erasure_index),
|
||||
"outage-object erasure index {erasure_index} is duplicated across online drives"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
outage_write_deferred_until_rejoin
|
||||
|| (!outage_peer_manifest.erasure_indices.is_empty()
|
||||
&& outage_peer_manifest.erasure_indices.len() <= erasure_set_drive_count),
|
||||
|| (!outage_peer_erasure_indices.is_empty() && outage_peer_erasure_indices.len() <= erasure_set_drive_count),
|
||||
"outage-object must occupy one non-empty erasure set"
|
||||
);
|
||||
if outage_target_manifest_required {
|
||||
let min_online_data_shards = erasure_set_drive_count.saturating_sub(4);
|
||||
assert!(
|
||||
outage_peer_manifest.erasure_indices.len() >= min_online_data_shards,
|
||||
outage_peer_erasure_indices.len() >= min_online_data_shards,
|
||||
"online drives in the selected erasure set must retain at least the EC data quorum"
|
||||
);
|
||||
}
|
||||
let missing_outage_erasure_indices = (1..=erasure_set_drive_count)
|
||||
.filter(|index| !outage_peer_manifest.erasure_indices.contains(index))
|
||||
.filter(|index| !outage_peer_erasure_indices.contains(index))
|
||||
.collect::<HashSet<_>>();
|
||||
if outage_target_manifest_required {
|
||||
assert!(
|
||||
!missing_outage_erasure_indices.is_empty(),
|
||||
"the stopped target must account for at least one missing outage-object erasure index"
|
||||
);
|
||||
assert_eq!(
|
||||
replacement_outage_erasure_index.filter(|index| missing_outage_erasure_indices.contains(index)),
|
||||
replacement_outage_erasure_index,
|
||||
"the outage object must target the selected replacement drive's erasure-set slot"
|
||||
);
|
||||
}
|
||||
|
||||
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||
@@ -1639,25 +1530,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the partial-repair checkpoint stable across readiness and admin
|
||||
// requests. Endpoint-blackhole tests must prove their own network stall.
|
||||
let commit_barrier = if scenario != InterruptionScenario::TargetEndpointBlackhole {
|
||||
let barrier = replaced_disk.join(".rustfs.sys/e2e-heal-commit-barrier");
|
||||
std::fs::create_dir_all(barrier.parent().ok_or("commit barrier has no parent")?)?;
|
||||
std::fs::write(&barrier, format!("{bucket}/cluster/online/"))?;
|
||||
Some(barrier)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
cluster.start_node_from_binary(1, &server_binary).await?;
|
||||
for rejected_key in rejected_outage_keys {
|
||||
let delete_deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let delete_result = timeout(
|
||||
Duration::from_secs(30),
|
||||
clients[0].delete_object().bucket(bucket).key(&rejected_key).send(),
|
||||
)
|
||||
.await;
|
||||
match delete_result {
|
||||
Ok(Ok(_)) => break,
|
||||
Ok(Err(error)) if is_service_unavailable_delete(&error) && Instant::now() < delete_deadline => {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
Ok(Err(error)) => return Err(error.into()),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status_url = format!("{}/rustfs/admin/v3/background-heal/status", cluster.nodes[0].url);
|
||||
let recovery_deadline = Instant::now() + Duration::from_secs(60);
|
||||
@@ -1790,6 +1673,12 @@ mod tests {
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
};
|
||||
|
||||
if let Some(barrier) = &commit_barrier {
|
||||
assert!(
|
||||
barrier.with_extension("admitted").is_file(),
|
||||
"interruption tests require a server built with e2e-test-hooks"
|
||||
);
|
||||
}
|
||||
let pre_interrupt_status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
||||
let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body)
|
||||
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?;
|
||||
@@ -1995,6 +1884,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(barrier) = &commit_barrier {
|
||||
std::fs::remove_file(barrier)?;
|
||||
}
|
||||
cluster.start_node_from_binary(interruption_node, &server_binary).await?;
|
||||
if interruption_node == 0 {
|
||||
let target = cluster.nodes[1]
|
||||
@@ -2122,9 +2014,9 @@ mod tests {
|
||||
assert_eq!(
|
||||
outage_census
|
||||
.erasure_index
|
||||
.filter(|index| replacement_outage_erasure_index == Some(*index)),
|
||||
.filter(|index| missing_outage_erasure_indices.contains(index)),
|
||||
outage_census.erasure_index,
|
||||
"the outage object must be rebuilt into the selected replacement drive's erasure-set slot"
|
||||
"the outage object must be rebuilt into one of the stopped node's missing erasure slots"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2404,28 +2296,4 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outage_candidate_must_target_replacement_erasure_index() {
|
||||
let distribution = vec![4, 7, 10, 1, 5, 8, 11, 2, 6, 9, 12, 3];
|
||||
let replacement_set_slot = 8;
|
||||
let replacement_index = distribution[replacement_set_slot];
|
||||
let peers_missing_replacement = OutagePeerManifest {
|
||||
erasure_indices: (1..=12).filter(|index| *index != replacement_index).collect(),
|
||||
erasure_distribution: Some(distribution.clone()),
|
||||
};
|
||||
assert!(outage_candidate_targets_replacement(&peers_missing_replacement, 12, replacement_set_slot));
|
||||
|
||||
let peers_missing_other_slot = OutagePeerManifest {
|
||||
erasure_indices: (1..=12).filter(|index| *index != 9).collect(),
|
||||
erasure_distribution: Some(distribution),
|
||||
};
|
||||
assert!(!outage_candidate_targets_replacement(&peers_missing_other_slot, 12, replacement_set_slot));
|
||||
|
||||
let insufficient_peer_shards = OutagePeerManifest {
|
||||
erasure_indices: [1, 3, 4, 5, 6, 7, 8].into_iter().collect(),
|
||||
erasure_distribution: Some(vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]),
|
||||
};
|
||||
assert!(!outage_candidate_targets_replacement(&insufficient_peer_shards, 12, replacement_set_slot));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,50 +57,46 @@ Broad integration tests that exercise:
|
||||
pip install awscurl
|
||||
```
|
||||
|
||||
2. **Build RustFS**
|
||||
2. **Build RustFS** (from the repository root)
|
||||
```bash
|
||||
cargo build
|
||||
python3 scripts/e2e_binary.py build
|
||||
```
|
||||
|
||||
### Run individual suites
|
||||
|
||||
Run every command below from the repository root through `scripts/e2e_binary.py run`; plain `cargo test -p e2e_test` fails with a missing E2E run receipt.
|
||||
|
||||
#### Local backend
|
||||
```bash
|
||||
cd crates/e2e_test
|
||||
cargo test test_local_kms_end_to_end -- --nocapture
|
||||
python3 scripts/e2e_binary.py run -- cargo test -p e2e_test test_local_kms_end_to_end -- --nocapture
|
||||
```
|
||||
|
||||
#### Vault backend
|
||||
```bash
|
||||
cd crates/e2e_test
|
||||
cargo test test_vault_kms_end_to_end -- --nocapture
|
||||
python3 scripts/e2e_binary.py run -- cargo test -p e2e_test test_vault_kms_end_to_end -- --nocapture
|
||||
```
|
||||
|
||||
#### High availability
|
||||
```bash
|
||||
cd crates/e2e_test
|
||||
cargo test test_vault_kms_high_availability -- --nocapture
|
||||
python3 scripts/e2e_binary.py run -- cargo test -p e2e_test test_vault_kms_high_availability -- --nocapture
|
||||
```
|
||||
|
||||
#### Comprehensive features (disabled)
|
||||
```bash
|
||||
cd crates/e2e_test
|
||||
# Disabled due to AWS SDK compatibility gaps
|
||||
# cargo test test_comprehensive_kms_functionality -- --nocapture
|
||||
# cargo test test_sse_modes_compatibility -- --nocapture
|
||||
# cargo test test_kms_api_comprehensive -- --nocapture
|
||||
# python3 scripts/e2e_binary.py run -- cargo test -p e2e_test test_comprehensive_kms_functionality -- --nocapture
|
||||
# python3 scripts/e2e_binary.py run -- cargo test -p e2e_test test_sse_modes_compatibility -- --nocapture
|
||||
# python3 scripts/e2e_binary.py run -- cargo test -p e2e_test test_kms_api_comprehensive -- --nocapture
|
||||
```
|
||||
|
||||
### Run all KMS suites
|
||||
```bash
|
||||
cd crates/e2e_test
|
||||
cargo test kms -- --nocapture
|
||||
python3 scripts/e2e_binary.py run -- cargo test -p e2e_test kms -- --nocapture
|
||||
```
|
||||
|
||||
### Run serially (avoid port conflicts)
|
||||
```bash
|
||||
cd crates/e2e_test
|
||||
cargo test kms -- --nocapture --test-threads=1
|
||||
python3 scripts/e2e_binary.py run -- cargo test -p e2e_test kms -- --nocapture --test-threads=1
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
@@ -120,7 +116,7 @@ export RUST_LOG=debug
|
||||
### Required binaries
|
||||
|
||||
Tests look for:
|
||||
- `../../target/debug/rustfs` – RustFS server
|
||||
- RustFS server – the binary verified by `scripts/e2e_binary.py` (default `target/debug/rustfs`)
|
||||
- `vault` – Vault CLI (must be on PATH)
|
||||
- `/Users/dandan/Library/Python/3.9/bin/awscurl` – AWS SigV4 helper
|
||||
|
||||
@@ -174,14 +170,14 @@ which awscurl # Update the path in tests accordingly
|
||||
|
||||
**Q: Tests time out**
|
||||
```bash
|
||||
RUST_LOG=debug cargo test test_local_kms_end_to_end -- --nocapture
|
||||
RUST_LOG=debug python3 scripts/e2e_binary.py run -- cargo test -p e2e_test test_local_kms_end_to_end -- --nocapture
|
||||
```
|
||||
|
||||
### Debug tips
|
||||
|
||||
1. **Enable verbose logs**
|
||||
```bash
|
||||
RUST_LOG=rustfs_kms=debug,rustfs=info cargo test -- --nocapture
|
||||
RUST_LOG=rustfs_kms=debug,rustfs=info python3 scripts/e2e_binary.py run -- cargo test -p e2e_test kms -- --nocapture
|
||||
```
|
||||
|
||||
2. **Keep temporary files** – comment out cleanup logic to inspect generated configs
|
||||
@@ -237,9 +233,8 @@ Designed to run inside CI/CD pipelines:
|
||||
sudo apt-get install -y vault
|
||||
pip install awscurl
|
||||
|
||||
cargo build
|
||||
cd crates/e2e_test
|
||||
cargo test kms -- --nocapture --test-threads=1
|
||||
python3 scripts/e2e_binary.py build
|
||||
python3 scripts/e2e_binary.py run -- cargo test -p e2e_test kms -- --nocapture --test-threads=1
|
||||
```
|
||||
|
||||
## 📚 References
|
||||
|
||||
@@ -179,6 +179,9 @@ mod compression_test;
|
||||
#[cfg(test)]
|
||||
mod delete_objects_versioning_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod delete_authorization_test;
|
||||
|
||||
// Regression test for signed DELETE Object?versionId requests without Content-Length.
|
||||
#[cfg(test)]
|
||||
mod delete_object_no_content_length_test;
|
||||
|
||||
@@ -24,7 +24,8 @@ The tests cover the following AWS policy variable scenarios:
|
||||
|
||||
```bash
|
||||
# From the project root directory
|
||||
cargo test -p e2e_test policy:: -- --nocapture
|
||||
python3 scripts/e2e_binary.py build
|
||||
python3 scripts/e2e_binary.py run -- cargo test -p e2e_test policy:: -- --nocapture
|
||||
```
|
||||
|
||||
Each test starts an isolated RustFS server on a dynamically allocated local port and cleans it up afterward.
|
||||
|
||||
@@ -17,15 +17,13 @@ Use the canonical CI-equivalent protocol command in the parent
|
||||
For targeted debugging of the core suite only:
|
||||
|
||||
```bash
|
||||
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
|
||||
python3 scripts/e2e_binary.py build --features ftps,webdav,sftp
|
||||
python3 scripts/e2e_binary.py run --features ftps,webdav,sftp -- cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
|
||||
```
|
||||
|
||||
This targeted command does not cover the full `e2e-protocols` profile.
|
||||
|
||||
`RUSTFS_BUILD_FEATURES` controls which features the test rustfs binary is
|
||||
built with. When this variable is set, the protocol test runner schedules
|
||||
only entries whose protocol is present in the requested feature list. Leave
|
||||
it unset to run every protocol entry.
|
||||
`e2e_binary.py` supplies `RUSTFS_BUILD_FEATURES` from the verified server's resolved Cargo features. The protocol runner schedules only entries present in that feature list; helpers check that their required features are available without rebuilding the server.
|
||||
`--test-threads=1` is required because every entry spawns a rustfs server
|
||||
on fixed bind ports.
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus,
|
||||
LifecycleRule, LifecycleRuleFilter, MetadataDirective, NoncurrentVersionTransition, RestoreRequest, Transition,
|
||||
TransitionStorageClass, VersioningConfiguration,
|
||||
LifecycleRule, LifecycleRuleFilter, NoncurrentVersionTransition, RestoreRequest, Transition, TransitionStorageClass,
|
||||
VersioningConfiguration,
|
||||
};
|
||||
use http::Method;
|
||||
use serde::Deserialize;
|
||||
@@ -963,81 +963,6 @@ async fn test_hermetic_transition_main_path() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PUT and materialized self-copy must retain cleanup ownership of a replaced
|
||||
/// transitioned null version while publishing the new bytes and metadata.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_hermetic_transition_overwrite_and_self_copy() -> TestResult {
|
||||
let mut cold = RustFSTestEnvironment::new().await?;
|
||||
cold.access_key = "coldtieradmin".to_string();
|
||||
cold.secret_key = "coldtiersecret".to_string();
|
||||
cold.start_rustfs_server_without_cleanup(vec![]).await?;
|
||||
let cold_client = cold.create_s3_client();
|
||||
cold_client.create_bucket().bucket(TIER_BUCKET).send().await?;
|
||||
|
||||
let mut hot = RustFSTestEnvironment::new().await?;
|
||||
start_tier_source(&mut hot, crate::common::FAST_DATA_USAGE_SCANNER_ENV).await?;
|
||||
let hot_client = hot.create_s3_client();
|
||||
add_rustfs_tier(&hot, &cold).await?;
|
||||
hot_client.create_bucket().bucket(SOURCE_BUCKET).send().await?;
|
||||
|
||||
let data = payload();
|
||||
for self_copy in [false, true] {
|
||||
hot_client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.lifecycle_configuration(BucketLifecycleConfiguration::builder().rules(transition_rule()?).build()?)
|
||||
.send()
|
||||
.await?;
|
||||
put_multipart_object(&hot_client, SOURCE_BUCKET, OBJECT_KEY, &data).await?;
|
||||
wait_for_transition(&hot_client, SOURCE_BUCKET, OBJECT_KEY, StdDuration::from_secs(90)).await?;
|
||||
assert_eq!(cold_tier_object_count(&cold_client).await?, 1);
|
||||
// Keep the replacement local so disappearance of the old remote
|
||||
// object cannot be confused with another automatic transition.
|
||||
hot_client.delete_bucket_lifecycle().bucket(SOURCE_BUCKET).send().await?;
|
||||
|
||||
let expected = if self_copy { data.clone() } else { vec![0x73; 513] };
|
||||
if self_copy {
|
||||
hot_client
|
||||
.copy_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key(OBJECT_KEY)
|
||||
.copy_source(format!("{SOURCE_BUCKET}/{}", urlencoding::encode(OBJECT_KEY)))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.content_type("text/plain")
|
||||
.metadata("replacement", "kept")
|
||||
.send()
|
||||
.await?;
|
||||
} else {
|
||||
hot_client
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key(OBJECT_KEY)
|
||||
.body(ByteStream::from(expected.clone()))
|
||||
.content_type("text/plain")
|
||||
.metadata("replacement", "kept")
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
wait_for_cold_tier_empty(&cold_client, StdDuration::from_secs(90)).await?;
|
||||
let current = hot_client.get_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await?;
|
||||
assert_eq!(current.content_type(), Some("text/plain"));
|
||||
assert_eq!(current.metadata().and_then(|m| m.get("replacement")).map(String::as_str), Some("kept"));
|
||||
assert!(
|
||||
current
|
||||
.metadata()
|
||||
.is_none_or(|metadata| !metadata.contains_key(USER_META_KEY))
|
||||
);
|
||||
assert_eq!(current.body.collect().await?.into_bytes().as_ref(), expected.as_slice());
|
||||
hot_client
|
||||
.delete_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key(OBJECT_KEY)
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore a transitioned object through a real RustFS remote tier.
|
||||
///
|
||||
/// The test covers the externally visible copy-back contract that a mock tier
|
||||
|
||||
@@ -6383,6 +6383,18 @@ async fn test_site_replication_edit_and_status_peer_state_real_three_node() -> R
|
||||
let relayed_key = "after-edit-from-relay.txt";
|
||||
let relayed_payload = b"site replication after endpoint edit from relay".to_vec();
|
||||
|
||||
// The first joining receiver owns data before the third site has the
|
||||
// shared account. Initial probes and backfill must wait for every join.
|
||||
target_client.create_bucket().bucket(bucket).send().await?;
|
||||
enable_bucket_versioning(&target_env, bucket).await?;
|
||||
target_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(baseline_key)
|
||||
.body(ByteStream::from(baseline_payload.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let add_status = site_replication_add(
|
||||
&source_env,
|
||||
&[
|
||||
@@ -6410,7 +6422,10 @@ async fn test_site_replication_edit_and_status_peer_state_real_three_node() -> R
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
assert!(add_status.success, "unexpected site add result: {:?}", add_status);
|
||||
assert!(
|
||||
add_status.success && add_status.err_detail.is_empty() && add_status.initial_sync_error_message.is_empty(),
|
||||
"unexpected site add result: {add_status:?}"
|
||||
);
|
||||
|
||||
let source_info = wait_for_site_replication_enabled(&source_env, 3).await?;
|
||||
let _target_info = wait_for_site_replication_enabled(&target_env, 3).await?;
|
||||
@@ -6421,19 +6436,11 @@ async fn test_site_replication_edit_and_status_peer_state_real_three_node() -> R
|
||||
.find(|peer| peer.endpoint == target_env.url)
|
||||
.ok_or("target peer missing from source site replication info")?;
|
||||
|
||||
source_client.create_bucket().bucket(bucket).send().await?;
|
||||
enable_bucket_versioning(&source_env, bucket).await?;
|
||||
wait_for_bucket_on_target(&target_client, bucket).await?;
|
||||
wait_for_bucket_on_target(&relay_client, bucket).await?;
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(baseline_key)
|
||||
.body(ByteStream::from(baseline_payload.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
let replicated_baseline = wait_for_object_on_target(&target_client, bucket, baseline_key).await?;
|
||||
assert_eq!(replicated_baseline, baseline_payload);
|
||||
for client in [&source_client, &relay_client] {
|
||||
wait_for_bucket_on_target(client, bucket).await?;
|
||||
let backfilled = wait_for_object_on_target(client, bucket, baseline_key).await?;
|
||||
assert_eq!(backfilled, baseline_payload);
|
||||
}
|
||||
|
||||
let old_target_address = target_env.address.clone();
|
||||
let new_target_port = RustFSTestEnvironment::find_available_port().await?;
|
||||
|
||||
@@ -27,9 +27,7 @@ mod tests {
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, Tag, Tagging, VersioningConfiguration,
|
||||
};
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
|
||||
use tracing::info;
|
||||
|
||||
fn create_s3_client(env: &RustFSTestEnvironment) -> Client {
|
||||
@@ -85,156 +83,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_version_tags(client: &Client, bucket: &str, key: &str, version: Option<&str>, value: Option<&str>) {
|
||||
let tags = client
|
||||
.get_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.set_version_id(version.map(str::to_owned))
|
||||
.send()
|
||||
.await
|
||||
.expect("GetObjectTagging must accept the exact version selector");
|
||||
let expected = value
|
||||
.map(|value| vec![Tag::builder().key("generation").value(value).build().expect("valid tag")])
|
||||
.unwrap_or_default();
|
||||
assert_eq!(tags.tag_set(), expected, "version selector: {version:?}");
|
||||
}
|
||||
|
||||
async fn assert_null_tagging_across_versioning_changes(client: &Client, bucket: &str, key: &str) {
|
||||
assert_version_tags(client, bucket, key, None, None).await;
|
||||
assert_version_tags(client, bucket, key, Some("null"), None).await;
|
||||
client
|
||||
.put_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id("null")
|
||||
.tagging(
|
||||
Tagging::builder()
|
||||
.tag_set(
|
||||
Tag::builder()
|
||||
.key("generation")
|
||||
.value("original-null")
|
||||
.build()
|
||||
.expect("valid tag"),
|
||||
)
|
||||
.build()
|
||||
.expect("valid tagging"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("tag the original null version");
|
||||
|
||||
enable_versioning(client, bucket)
|
||||
.await
|
||||
.expect("enable versioning over a null version");
|
||||
let versioned = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.tagging("generation=versioned")
|
||||
.body(ByteStream::from_static(b"new version"))
|
||||
.send()
|
||||
.await
|
||||
.expect("write a newer UUID version");
|
||||
let version = versioned.version_id().expect("versioned PUT must return a UUID");
|
||||
assert_ne!(version, "null");
|
||||
assert_version_tags(client, bucket, key, None, Some("versioned")).await;
|
||||
assert_version_tags(client, bucket, key, Some(version), Some("versioned")).await;
|
||||
assert_version_tags(client, bucket, key, Some("null"), Some("original-null")).await;
|
||||
|
||||
client
|
||||
.put_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id("null")
|
||||
.tagging(
|
||||
Tagging::builder()
|
||||
.tag_set(
|
||||
Tag::builder()
|
||||
.key("generation")
|
||||
.value("updated-null")
|
||||
.build()
|
||||
.expect("valid tag"),
|
||||
)
|
||||
.build()
|
||||
.expect("valid tagging"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("update tags on the noncurrent null version");
|
||||
assert_version_tags(client, bucket, key, Some("null"), Some("updated-null")).await;
|
||||
assert_version_tags(client, bucket, key, None, Some("versioned")).await;
|
||||
client
|
||||
.delete_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id("null")
|
||||
.send()
|
||||
.await
|
||||
.expect("delete only the noncurrent null version tags");
|
||||
assert_version_tags(client, bucket, key, Some("null"), None).await;
|
||||
assert_version_tags(client, bucket, key, Some(version), Some("versioned")).await;
|
||||
|
||||
suspend_versioning(client, bucket).await.expect("suspend versioning");
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.tagging("generation=suspended-null")
|
||||
.body(ByteStream::from_static(b"replacement null version"))
|
||||
.send()
|
||||
.await
|
||||
.expect("replace the null version while suspended");
|
||||
assert_version_tags(client, bucket, key, None, Some("suspended-null")).await;
|
||||
assert_version_tags(client, bucket, key, Some("null"), Some("suspended-null")).await;
|
||||
assert_version_tags(client, bucket, key, Some(version), Some("versioned")).await;
|
||||
|
||||
enable_versioning(client, bucket).await.expect("re-enable versioning");
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.tagging("generation=latest")
|
||||
.body(ByteStream::from_static(b"latest version"))
|
||||
.send()
|
||||
.await
|
||||
.expect("write a new latest version");
|
||||
assert_version_tags(client, bucket, key, Some("null"), Some("suspended-null")).await;
|
||||
assert_version_tags(client, bucket, key, None, Some("latest")).await;
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id("null")
|
||||
.send()
|
||||
.await
|
||||
.expect("remove only the null version");
|
||||
assert_version_tags(client, bucket, key, None, Some("latest")).await;
|
||||
let absent_version = uuid::Uuid::new_v4().to_string();
|
||||
for (missing_key, selector, expected_code) in [
|
||||
(key, Some("null"), "NoSuchVersion"),
|
||||
(key, Some(absent_version.as_str()), "NoSuchVersion"),
|
||||
("never-created", None, "NoSuchKey"),
|
||||
("never-created", Some("null"), "NoSuchVersion"),
|
||||
("never-created", Some(absent_version.as_str()), "NoSuchVersion"),
|
||||
] {
|
||||
let missing = client
|
||||
.get_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key(missing_key)
|
||||
.set_version_id(selector.map(str::to_owned))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a missing version must not fall back to latest");
|
||||
assert_eq!(
|
||||
missing.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some(expected_code),
|
||||
"key: {missing_key}, version selector: {selector:?}"
|
||||
);
|
||||
}
|
||||
assert_version_tags(client, bucket, key, None, Some("latest")).await;
|
||||
}
|
||||
|
||||
/// Test 1: PutObject should return version_id when versioning is enabled
|
||||
/// This directly addresses the Veeam issue from #1066
|
||||
#[tokio::test]
|
||||
@@ -414,9 +262,7 @@ mod tests {
|
||||
info!("🧪 TEST: PutObject behavior without versioning (no regression)");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server_without_cleanup(vec![])
|
||||
.await
|
||||
.expect("Failed to start isolated RustFS");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-no-versioning";
|
||||
@@ -444,9 +290,6 @@ mod tests {
|
||||
output.version_id().is_none() || output.version_id() == Some("null"),
|
||||
"non-versioned PUT must omit version ID or return the S3 null version"
|
||||
);
|
||||
// Reuse this unversioned fixture to prove explicit null never becomes
|
||||
// an implicit latest-version read after enable/suspend transitions.
|
||||
assert_null_tagging_across_versioning_changes(&client, bucket, key).await;
|
||||
info!("✅ PASSED: PutObject works correctly without versioning");
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,9 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod migration {
|
||||
pub use crate::bucket::migration::{LegacyBlobDecryptFn, try_migrate_bucket_metadata, try_migrate_iam_config};
|
||||
pub use crate::bucket::migration::{
|
||||
LegacyBlobDecryptFn, migration_startup_error, try_migrate_bucket_metadata, try_migrate_iam_config,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod object_lock {
|
||||
|
||||
@@ -779,7 +779,7 @@ fn free_version_physical_topology_generation(api: &ECStore) -> String {
|
||||
rustfs_utils::crypto::hex(hasher.finalize().as_slice())
|
||||
}
|
||||
|
||||
pub(crate) fn free_version_remote_tuple_matches(candidate: &ObjectInfo, expected: &ObjectInfo) -> std::io::Result<bool> {
|
||||
fn free_version_remote_tuple_matches(candidate: &ObjectInfo, expected: &ObjectInfo) -> std::io::Result<bool> {
|
||||
if candidate.transitioned_object.tier != expected.transitioned_object.tier
|
||||
|| candidate.transitioned_object.name != expected.transitioned_object.name
|
||||
{
|
||||
@@ -822,7 +822,7 @@ async fn scan_exact_free_version_targets(
|
||||
let mut targets = Vec::new();
|
||||
for pool in &api.pools {
|
||||
for set in &pool.disk_set {
|
||||
let versions = match set.load_file_info_versions_for_tier_cleanup(&oi.bucket, &oi.name).await {
|
||||
let versions = match set.load_file_info_versions_exact(&oi.bucket, &oi.name).await {
|
||||
Ok(Some(versions)) => versions,
|
||||
Ok(None) => continue,
|
||||
Err(err) if is_err_strict_volume_not_found(&err) => continue,
|
||||
@@ -8100,75 +8100,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn tier_overwrite_cleanup_retains_a_minority_live_remote_reference() {
|
||||
let (disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("overwrite-minority-{}", Uuid::new_v4());
|
||||
let object = "still-referenced";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
let (backend, identity) = register_recovery_mock_tier(&ecstore).await;
|
||||
seed_recoverable_free_version(&disk_paths, &bucket, object, None, Some(identity.clone())).await;
|
||||
let page = list_tier_free_versions(Arc::clone(&ecstore), 100, None, None, CancellationToken::new())
|
||||
.await
|
||||
.expect("list persisted cleanup owner");
|
||||
let owner = page.items.into_iter().find(|oi| oi.bucket == bucket).expect("seeded owner");
|
||||
backend
|
||||
.set_put_remote_version(Some(owner.transitioned_object.version_id.clone()))
|
||||
.await;
|
||||
let lease = TierConfigMgr::acquire_operation_lease(&ecstore.tier_config_mgr(), "WARM")
|
||||
.await
|
||||
.expect("remote fixture lease");
|
||||
lease
|
||||
.put(
|
||||
&owner.transitioned_object.name,
|
||||
rustfs_s3_client::transition_api::ReaderImpl::Body(bytes::Bytes::from_static(b"old")),
|
||||
3,
|
||||
)
|
||||
.await
|
||||
.expect("seed referenced remote bytes");
|
||||
drop(lease);
|
||||
let path = disk_paths[0].join(&bucket).join(object).join(STORAGE_FORMAT_FILE);
|
||||
let cleanup_metadata = fs::read(&path).await.expect("save completed replica");
|
||||
let mut live = FileInfo::new(object, 2, 2);
|
||||
live.volume = bucket.clone();
|
||||
live.erasure.index = 1;
|
||||
live.data_dir = Some(Uuid::new_v4());
|
||||
live.mod_time = Some(OffsetDateTime::now_utc());
|
||||
live.size = 3;
|
||||
live.add_object_part(1, "149603e6c03516362a8da23f624db945".to_string(), 3, live.mod_time, 3, None, None);
|
||||
live.transition_status = TRANSITION_COMPLETE.to_string();
|
||||
live.transition_tier = "WARM".to_string();
|
||||
live.transitioned_objname = owner.transitioned_object.name.clone();
|
||||
live.transition_version = Some(owner.transitioned_object.version_id.clone());
|
||||
live.transition_version_state = rustfs_filemeta::TransitionVersionState::Exact;
|
||||
rustfs_utils::http::insert_str(&mut live.metadata, rustfs_utils::http::SUFFIX_TRANSITION_TIER_DESTINATION_ID, identity);
|
||||
let mut old_metadata = FileMeta::new();
|
||||
old_metadata.add_version(live).expect("prepare minority live source");
|
||||
fs::write(&path, old_metadata.marshal_msg().expect("encode live source"))
|
||||
.await
|
||||
.expect("model one replica retained by an interrupted overwrite");
|
||||
|
||||
let err = super::cleanup_free_version_exact(Arc::clone(&ecstore), &owner, &CancellationToken::new())
|
||||
.await
|
||||
.expect_err("quorum free versions cannot erase a minority live reference");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::WouldBlock);
|
||||
assert_eq!(backend.remove_count().await, 0);
|
||||
assert!(backend.contains(&owner.transitioned_object.name).await);
|
||||
|
||||
fs::write(&path, cleanup_metadata)
|
||||
.await
|
||||
.expect("complete replica convergence");
|
||||
assert!(
|
||||
super::cleanup_free_version_exact(Arc::clone(&ecstore), &owner, &CancellationToken::new())
|
||||
.await
|
||||
.expect("converged cleanup can delete the exact remote owner")
|
||||
);
|
||||
assert_eq!(backend.remove_count().await, 1);
|
||||
assert!(!backend.contains(&owner.transitioned_object.name).await);
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
use crate::bucket::metadata::BUCKET_METADATA_FILE;
|
||||
use crate::bucket::replication::ReplicationMigrationBridge;
|
||||
use crate::disk::{BUCKET_META_PREFIX, MIGRATING_META_BUCKET, RUSTFS_META_BUCKET};
|
||||
use crate::error::Error;
|
||||
use crate::error::{Error, Result, is_err_strict_not_found, is_err_strict_volume_not_found};
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||
use crate::storage_api_contracts::{
|
||||
bucket::{BucketOperations, BucketOptions},
|
||||
@@ -33,7 +33,7 @@ use rustfs_utils::path::SLASH_SEPARATOR;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// IAM config prefix under meta bucket (e.g. config/iam/).
|
||||
const IAM_CONFIG_PREFIX: &str = "config/iam";
|
||||
@@ -53,6 +53,39 @@ type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
|
||||
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, Error>;
|
||||
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
|
||||
|
||||
#[derive(Clone, Debug, thiserror::Error)]
|
||||
enum MigrationMetadataError {
|
||||
#[error("empty legacy metadata: {0}")]
|
||||
Empty(String),
|
||||
#[error("incompatible legacy metadata: {0}")]
|
||||
Incompatible(String),
|
||||
}
|
||||
|
||||
impl From<MigrationMetadataError> for Error {
|
||||
fn from(error: MigrationMetadataError) -> Self {
|
||||
let message = match &error {
|
||||
MigrationMetadataError::Empty(_) => "empty legacy metadata",
|
||||
MigrationMetadataError::Incompatible(_) => "incompatible legacy metadata",
|
||||
};
|
||||
// Keep the record path in the typed source, not in the quorum grouping key.
|
||||
Self::other_with_context(message, error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a migration failure at the startup boundary, rendering the safe
|
||||
/// record path while leaving storage-layer error grouping stable.
|
||||
pub fn migration_startup_error(error: Error) -> std::io::Error {
|
||||
if let Error::Io(io_error) = &error
|
||||
&& let Some(metadata_error) = io_error
|
||||
.get_ref()
|
||||
.and_then(|context| context.source())
|
||||
.and_then(|source| source.downcast_ref::<MigrationMetadataError>())
|
||||
{
|
||||
return std::io::Error::other(metadata_error.clone());
|
||||
}
|
||||
std::io::Error::other(error)
|
||||
}
|
||||
|
||||
/// Callback used to decrypt an at-rest config blob during MinIO -> RustFS migration.
|
||||
///
|
||||
/// MinIO encrypts IAM identity/service-account files and the server config at rest
|
||||
@@ -211,7 +244,7 @@ fn normalize_bucket_meta_blob(path: &str, data: &[u8]) -> std::result::Result<Op
|
||||
/// Uses list_bucket (from disk volumes) to get bucket names, since list_objects_v2 on the legacy
|
||||
/// meta bucket may not work (legacy format differs from object layer expectations).
|
||||
/// Skips buckets that already exist in RustFS (idempotent).
|
||||
pub async fn try_migrate_bucket_metadata<S>(store: Arc<S>)
|
||||
pub async fn try_migrate_bucket_metadata<S>(store: Arc<S>) -> Result<()>
|
||||
where
|
||||
S: BucketOperations<Error = crate::error::Error>
|
||||
+ ObjectIO<
|
||||
@@ -231,25 +264,18 @@ where
|
||||
DeletedObject = DeletedObject,
|
||||
>,
|
||||
{
|
||||
let buckets_list = match store
|
||||
let buckets_list = store
|
||||
.list_bucket(&BucketOptions {
|
||||
no_metadata: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("list buckets failed (skip migration): {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
.await?;
|
||||
|
||||
let buckets: Vec<String> = buckets_list.into_iter().map(|b| b.name).collect();
|
||||
|
||||
if buckets.is_empty() {
|
||||
debug!("No migrating bucket metadata found");
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
debug!("Found {} migrating bucket metadata, migrating...", buckets.len());
|
||||
@@ -263,26 +289,40 @@ where
|
||||
|
||||
for bucket in buckets {
|
||||
let meta_path = format!("{BUCKET_META_PREFIX}{SLASH_SEPARATOR}{bucket}{SLASH_SEPARATOR}{BUCKET_METADATA_FILE}");
|
||||
migrate_one_if_missing(store.clone(), &opts, &h, &meta_path, &format!("bucket metadata: {bucket}")).await;
|
||||
migrate_one_if_missing(store.clone(), &opts, &h, &meta_path, &format!("bucket metadata: {bucket}")).await?;
|
||||
|
||||
let resync_path = format!(
|
||||
"{BUCKET_META_PREFIX}{SLASH_SEPARATOR}{bucket}{SLASH_SEPARATOR}{REPLICATION_META_DIR}{SLASH_SEPARATOR}{RESYNC_META_FILE}"
|
||||
);
|
||||
migrate_one_if_missing(store.clone(), &opts, &h, &resync_path, &format!("bucket replication resync: {bucket}")).await;
|
||||
migrate_one_if_missing(store.clone(), &opts, &h, &resync_path, &format!("bucket replication resync: {bucket}")).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migration_target_exists<S: EcstoreObjectOperations>(store: &S, path: &str) -> Result<bool> {
|
||||
match store
|
||||
.get_object_info(RUSTFS_META_BUCKET, path, &ObjectOptions::default())
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(err) if is_err_strict_not_found(&err) || is_err_strict_volume_not_found(&err) => Ok(false),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn migrate_one_if_missing<S>(store: Arc<S>, opts: &ObjectOptions, headers: &HeaderMap, path: &str, label: &str)
|
||||
async fn migrate_one_if_missing<S>(
|
||||
store: Arc<S>,
|
||||
opts: &ObjectOptions,
|
||||
headers: &HeaderMap,
|
||||
path: &str,
|
||||
label: &str,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: EcstoreObjectIO + EcstoreObjectOperations,
|
||||
{
|
||||
if store
|
||||
.get_object_info(RUSTFS_META_BUCKET, path, &ObjectOptions::default())
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
if migration_target_exists(store.as_ref(), path).await? {
|
||||
debug!("{label} already exists in RustFS, skip");
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut rd = match store
|
||||
@@ -290,43 +330,31 @@ where
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
debug!("read migrating {label}: {e}");
|
||||
return;
|
||||
}
|
||||
// Ordinary RustFS deployments have no legacy bucket, and optional
|
||||
// legacy settings (such as replication resync) may not exist.
|
||||
Err(err) if is_err_strict_not_found(&err) || is_err_strict_volume_not_found(&err) => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let data = match rd.read_all().await {
|
||||
Ok(d) if !d.is_empty() => d,
|
||||
Ok(_) => return,
|
||||
Err(e) => {
|
||||
debug!("read migrating {label} body: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let data = match normalize_bucket_meta_blob(path, &data) {
|
||||
Ok(Some(normalized)) => normalized,
|
||||
Ok(None) => data,
|
||||
Err(e) => {
|
||||
warn!("skip {label} migration due to incompatible format: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let data = rd.read_all().await?;
|
||||
if data.is_empty() {
|
||||
return Err(MigrationMetadataError::Empty(path.to_owned()).into());
|
||||
}
|
||||
let data = normalize_bucket_meta_blob(path, &data)
|
||||
.map_err(|_| MigrationMetadataError::Incompatible(path.to_owned()))?
|
||||
.unwrap_or(data);
|
||||
|
||||
let mut put_data = PutObjReader::from_vec(data);
|
||||
if let Err(e) = store.put_object(RUSTFS_META_BUCKET, path, &mut put_data, opts).await {
|
||||
warn!("write {label}: {e}");
|
||||
} else {
|
||||
info!("Migrated {label}");
|
||||
}
|
||||
store.put_object(RUSTFS_META_BUCKET, path, &mut put_data, opts).await?;
|
||||
info!("Migrated {label}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Migrates IAM config from legacy meta bucket `config/iam/` to RustFS meta bucket.
|
||||
/// Lists all objects under the IAM prefix in the source, copies each to the target if not present.
|
||||
/// Skips objects that already exist in RustFS (idempotent).
|
||||
/// If list_objects_v2 on the legacy bucket fails (e.g. format differs), migration is skipped.
|
||||
pub async fn try_migrate_iam_config<S>(store: Arc<S>, decrypt_fn: Option<LegacyBlobDecryptFn>)
|
||||
/// An absent legacy bucket is a no-op; migration errors prevent startup readiness.
|
||||
pub async fn try_migrate_iam_config<S>(store: Arc<S>, decrypt_fn: Option<LegacyBlobDecryptFn>) -> Result<()>
|
||||
where
|
||||
S: ListOperations<
|
||||
Error = crate::error::Error,
|
||||
@@ -366,47 +394,36 @@ where
|
||||
loop {
|
||||
let list_result = match store
|
||||
.clone()
|
||||
.list_objects_v2(MIGRATING_META_BUCKET, &prefix, continuation, None, 500, false, None, false)
|
||||
.list_objects_v2(MIGRATING_META_BUCKET, &prefix, continuation.clone(), None, 500, false, None, false)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
debug!("list IAM config from legacy bucket failed (skip migration): {e}");
|
||||
return;
|
||||
}
|
||||
Err(err) if is_err_strict_volume_not_found(&err) => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
for obj in list_result.objects {
|
||||
let path = &obj.name;
|
||||
if path.is_empty() || path.ends_with('/') {
|
||||
// Unsupported records must not trigger target lookups, reads, or decryption.
|
||||
if path != IAM_FORMAT_FILE_PATH
|
||||
&& !is_identity_path(path)
|
||||
&& !is_group_path(path)
|
||||
&& !is_policy_doc_path(path)
|
||||
&& !is_policy_mapping_path(path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if store
|
||||
.get_object_info(RUSTFS_META_BUCKET, path, &ObjectOptions::default())
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
if migration_target_exists(store.as_ref(), path).await? {
|
||||
debug!("IAM config already exists in RustFS, skip: {path}");
|
||||
continue;
|
||||
}
|
||||
let mut rd = match store
|
||||
let mut rd = store
|
||||
.get_object_reader(MIGRATING_META_BUCKET, path, None, h.clone(), &opts)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
debug!("read migrating IAM config {path}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let data = match rd.read_all().await {
|
||||
Ok(d) if !d.is_empty() => d,
|
||||
Ok(_) => continue,
|
||||
Err(e) => {
|
||||
debug!("read migrating IAM config {path} body: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
.await?;
|
||||
let data = rd.read_all().await?;
|
||||
if data.is_empty() {
|
||||
return Err(MigrationMetadataError::Empty(path.to_owned()).into());
|
||||
}
|
||||
// MinIO encrypts IAM identity/service-account files at rest. Decrypt
|
||||
// before normalizing; fall back to the raw bytes when no key applies
|
||||
// (plaintext blobs, or nothing to decrypt) so existing behavior holds.
|
||||
@@ -420,22 +437,17 @@ where
|
||||
debug!("skip unsupported IAM config path during migration: {path}");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("skip IAM config migration due to incompatible format, path: {path}, err: {e}");
|
||||
continue;
|
||||
}
|
||||
// Parser errors may contain credential data. Report only the path.
|
||||
Err(_) => return Err(MigrationMetadataError::Incompatible(path.to_owned()).into()),
|
||||
};
|
||||
let mut put_data = PutObjReader::from_vec(data);
|
||||
if let Err(e) = store.put_object(RUSTFS_META_BUCKET, path, &mut put_data, &opts).await {
|
||||
warn!("write IAM config {path}: {e}");
|
||||
} else {
|
||||
info!("Migrated IAM config: {path}");
|
||||
total_migrated += 1;
|
||||
}
|
||||
store.put_object(RUSTFS_META_BUCKET, path, &mut put_data, &opts).await?;
|
||||
info!("Migrated IAM config: {path}");
|
||||
total_migrated += 1;
|
||||
}
|
||||
|
||||
continuation = list_result.next_continuation_token.or(list_result.continuation_token);
|
||||
if !list_result.is_truncated || continuation.is_none() {
|
||||
continuation = next_iam_migration_page(list_result.is_truncated, continuation, list_result.next_continuation_token)?;
|
||||
if continuation.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -443,10 +455,74 @@ where
|
||||
if total_migrated > 0 {
|
||||
info!("IAM migration complete: {} object(s) migrated", total_migrated);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_iam_migration_page(truncated: bool, previous: Option<String>, next: Option<String>) -> Result<Option<String>> {
|
||||
if !truncated {
|
||||
return Ok(None);
|
||||
}
|
||||
let next = next.filter(|token| !token.is_empty());
|
||||
if next.is_none() || next == previous {
|
||||
return Err(Error::other("legacy IAM migration listing did not advance"));
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn migration_errors_group_by_cause_and_retain_typed_record_context() {
|
||||
use super::{Error, MigrationMetadataError};
|
||||
|
||||
for (make_error, message) in [
|
||||
(
|
||||
MigrationMetadataError::Empty as fn(String) -> MigrationMetadataError,
|
||||
"empty legacy metadata",
|
||||
),
|
||||
(MigrationMetadataError::Incompatible, "incompatible legacy metadata"),
|
||||
] {
|
||||
let first: Error = make_error("buckets/first/.metadata.bin".into()).into();
|
||||
let second: Error = make_error("buckets/second/.metadata.bin".into()).into();
|
||||
assert_eq!(first, second, "record paths must not fragment error grouping");
|
||||
assert_eq!(first.clone(), second, "cloning must preserve error grouping");
|
||||
|
||||
let io_error = std::io::Error::from(first);
|
||||
let detail = io_error
|
||||
.get_ref()
|
||||
.and_then(|context| context.source())
|
||||
.expect("record context must remain in the error source");
|
||||
assert!(detail.downcast_ref::<MigrationMetadataError>().is_some());
|
||||
assert!(detail.to_string().contains("buckets/first/.metadata.bin"));
|
||||
|
||||
let startup_error = super::migration_startup_error(make_error("buckets/startup/.metadata.bin".into()).into());
|
||||
assert!(
|
||||
startup_error
|
||||
.get_ref()
|
||||
.is_some_and(|source| source.is::<MigrationMetadataError>())
|
||||
);
|
||||
assert_eq!(startup_error.to_string(), format!("{message}: buckets/startup/.metadata.bin"));
|
||||
}
|
||||
assert_ne!(
|
||||
Error::from(MigrationMetadataError::Empty("record".into())),
|
||||
Error::from(MigrationMetadataError::Incompatible("record".into())),
|
||||
"different migration failures must remain distinguishable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_iam_listing_cannot_report_completed_migration() {
|
||||
use super::next_iam_migration_page;
|
||||
assert_eq!(next_iam_migration_page(false, Some("old".into()), None).expect("final page"), None);
|
||||
assert_eq!(
|
||||
next_iam_migration_page(true, Some("old".into()), Some("next".into())).expect("advancing page"),
|
||||
Some("next".into())
|
||||
);
|
||||
for next in [None, Some(String::new()), Some("old".into())] {
|
||||
assert!(next_iam_migration_page(true, Some("old".into()), next).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
use super::{normalize_bucket_meta_blob, normalize_iam_config_blob};
|
||||
use crate::bucket::replication::{
|
||||
BucketReplicationResyncStatus, ReplicationMigrationBridge, ResyncStatusType, TargetReplicationResyncStatus,
|
||||
@@ -659,6 +735,13 @@ mod tests {
|
||||
.collect();
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(ecstore.clone(), existing).await;
|
||||
|
||||
super::try_migrate_bucket_metadata(ecstore.clone())
|
||||
.await
|
||||
.expect("fresh stores do not require a legacy metadata bucket");
|
||||
super::try_migrate_iam_config(ecstore.clone(), None)
|
||||
.await
|
||||
.expect("fresh stores do not require a legacy IAM bucket");
|
||||
|
||||
let meta_path = format!("{BUCKET_META_PREFIX}{SLASH_SEPARATOR}interop{SLASH_SEPARATOR}{BUCKET_METADATA_FILE}");
|
||||
let put_opts = ObjectOptions::default();
|
||||
|
||||
@@ -680,8 +763,31 @@ mod tests {
|
||||
.await
|
||||
.expect("seed .minio.sys bucket metadata");
|
||||
|
||||
// --- Run the real startup migration. ---
|
||||
super::try_migrate_bucket_metadata(ecstore.clone()).await;
|
||||
// A partial import must report failure, even if the main bucket
|
||||
// metadata copied successfully before an incompatible resync record.
|
||||
let resync_path = format!("{BUCKET_META_PREFIX}/interop/.replication/resync.bin");
|
||||
ecstore
|
||||
.put_object(
|
||||
MIGRATING_META_BUCKET,
|
||||
&resync_path,
|
||||
&mut PutObjReader::from_vec(b"invalid resync metadata".to_vec()),
|
||||
&put_opts,
|
||||
)
|
||||
.await
|
||||
.expect("seed malformed legacy resync metadata");
|
||||
assert!(
|
||||
super::try_migrate_bucket_metadata(ecstore.clone()).await.is_err(),
|
||||
"incompatible native metadata must not be reported as a completed migration"
|
||||
);
|
||||
ecstore
|
||||
.delete_object(MIGRATING_META_BUCKET, &resync_path, ObjectOptions::default())
|
||||
.await
|
||||
.expect("remove invalid optional legacy resync record");
|
||||
|
||||
// Retry the real startup migration after repairing the source.
|
||||
super::try_migrate_bucket_metadata(ecstore.clone())
|
||||
.await
|
||||
.expect("native bucket metadata migration completes");
|
||||
|
||||
// --- The migrated `.rustfs.sys` blob must carry every MinIO config, ---
|
||||
// byte-identical to the source (typed XML/JSON parsing of these fields is
|
||||
|
||||
@@ -577,26 +577,6 @@ fn heal_control_auth_may_need_replay_scope_refresh(err: &Error) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum HealControlRetryAction {
|
||||
Reconnect,
|
||||
RefreshReplayScope,
|
||||
}
|
||||
|
||||
fn heal_control_retry_action(
|
||||
err: &Error,
|
||||
reconnect_attempted: bool,
|
||||
replay_scope_refresh_attempted: bool,
|
||||
) -> Option<HealControlRetryAction> {
|
||||
if !replay_scope_refresh_attempted && heal_control_auth_may_need_replay_scope_refresh(err) {
|
||||
return Some(HealControlRetryAction::RefreshReplayScope);
|
||||
}
|
||||
if !reconnect_attempted && PeerRestClient::is_network_like_error(err) {
|
||||
return Some(HealControlRetryAction::Reconnect);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn decode_remote_version_state_capability(expected_member: &str, result: &[u8]) -> Result<Uuid> {
|
||||
let (topology_member, process_epoch) = rustfs_protos::decode_remote_version_state_capability(result).map_err(Error::other)?;
|
||||
if topology_member != expected_member {
|
||||
@@ -1773,41 +1753,34 @@ impl PeerRestClient {
|
||||
return Err(Error::other("heal control command exceeds size limit"));
|
||||
}
|
||||
let capability_probe = rustfs_protos::is_heal_control_capability_probe(&command);
|
||||
let mut reconnect_attempted = false;
|
||||
let mut replay_scope_refresh_attempted = false;
|
||||
loop {
|
||||
let result = self
|
||||
.heal_control_once(version, &topology_fingerprint, &command, capability_probe)
|
||||
let result = self
|
||||
.heal_control_once(version, &topology_fingerprint, &command, capability_probe)
|
||||
.await;
|
||||
if result
|
||||
.as_ref()
|
||||
.err()
|
||||
.is_some_and(heal_control_auth_may_need_replay_scope_refresh)
|
||||
{
|
||||
self.prepare_heal_control_auth_retry().await;
|
||||
return self
|
||||
.finalize_result(
|
||||
self.heal_control_once(version, &topology_fingerprint, &command, capability_probe)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
let Some(action) = result
|
||||
.as_ref()
|
||||
.err()
|
||||
.and_then(|err| heal_control_retry_action(err, reconnect_attempted, replay_scope_refresh_attempted))
|
||||
else {
|
||||
return self.finalize_result(result).await;
|
||||
};
|
||||
match action {
|
||||
HealControlRetryAction::Reconnect => reconnect_attempted = true,
|
||||
HealControlRetryAction::RefreshReplayScope => replay_scope_refresh_attempted = true,
|
||||
}
|
||||
self.prepare_heal_control_retry(action).await;
|
||||
}
|
||||
self.finalize_result(result).await
|
||||
}
|
||||
|
||||
async fn prepare_heal_control_retry(&self, action: HealControlRetryAction) {
|
||||
if action == HealControlRetryAction::RefreshReplayScope
|
||||
&& let Err(err) = clear_peer_replay_state_for_addr(&self.grid_host)
|
||||
{
|
||||
async fn prepare_heal_control_auth_retry(&self) {
|
||||
if let Err(err) = clear_peer_replay_state_for_addr(&self.grid_host) {
|
||||
debug!(
|
||||
peer = %self.grid_host,
|
||||
error = %err,
|
||||
"could not clear heal control replay state before retry"
|
||||
);
|
||||
}
|
||||
// A restart can leave both the local offline gate and the peer replay
|
||||
// epoch stale. Clear the gate on either recovery step so the next
|
||||
// bounded attempt reaches a fresh channel instead of fast-failing.
|
||||
self.prepare_retry().await;
|
||||
self.evict_connection().await;
|
||||
}
|
||||
|
||||
async fn heal_control_once(
|
||||
@@ -3894,51 +3867,6 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_control_retry_plan_allows_one_reconnect_and_one_epoch_refresh() {
|
||||
let offline = Error::RemoteClientUnavailable("peer http://127.0.0.1:9000 is temporarily offline".to_string());
|
||||
let stale_epoch = Error::from(tonic::Status::unauthenticated("No valid auth token"));
|
||||
|
||||
assert_eq!(heal_control_retry_action(&offline, false, false), Some(HealControlRetryAction::Reconnect));
|
||||
assert_eq!(heal_control_retry_action(&offline, true, false), None);
|
||||
assert_eq!(
|
||||
heal_control_retry_action(&stale_epoch, true, false),
|
||||
Some(HealControlRetryAction::RefreshReplayScope),
|
||||
"a reconnect may expose the restarted peer's stale replay epoch"
|
||||
);
|
||||
assert_eq!(heal_control_retry_action(&stale_epoch, true, true), None);
|
||||
assert_eq!(
|
||||
heal_control_retry_action(&stale_epoch, false, false),
|
||||
Some(HealControlRetryAction::RefreshReplayScope)
|
||||
);
|
||||
assert_eq!(
|
||||
heal_control_retry_action(&offline, false, true),
|
||||
Some(HealControlRetryAction::Reconnect),
|
||||
"an epoch refresh may be followed by one bounded reconnect"
|
||||
);
|
||||
assert_eq!(heal_control_retry_action(&offline, true, true), None);
|
||||
assert_eq!(
|
||||
heal_control_retry_action(&Error::from(tonic::Status::permission_denied("bad signature")), false, false),
|
||||
None,
|
||||
"authorization failures must never be retried"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heal_control_epoch_refresh_clears_offline_gate() {
|
||||
let client = test_peer_client();
|
||||
client.offline.store(true, Ordering::Release);
|
||||
|
||||
client
|
||||
.prepare_heal_control_retry(HealControlRetryAction::RefreshReplayScope)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!client.offline.load(Ordering::Acquire),
|
||||
"epoch refresh must not leave the following attempt behind the offline gate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_rest_client_network_classifier_keeps_slow_peers_online() {
|
||||
// The per-RPC channel deadline (RUSTFS_INTERNODE_RPC_TIMEOUT, 30s)
|
||||
|
||||
@@ -7470,20 +7470,6 @@ impl PoolMeta {
|
||||
.is_some_and(is_decommission_suspended)
|
||||
}
|
||||
|
||||
pub(crate) fn has_active_decommission_capacity_reservation(&self, idx: usize) -> bool {
|
||||
self.pools
|
||||
.get(idx)
|
||||
.and_then(|pool| pool.decommission.as_ref())
|
||||
.is_some_and(|info| {
|
||||
info.has_decommission_state()
|
||||
&& is_decommission_active(info.complete, info.failed, info.canceled)
|
||||
&& info
|
||||
.capacity_reservation
|
||||
.as_ref()
|
||||
.is_some_and(DecommissionCapacityReservation::active)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn scanner_pause_backlog_pool_writable(&self, idx: usize) -> bool {
|
||||
self.pools.get(idx).is_some_and(|pool| {
|
||||
!pool
|
||||
@@ -17609,17 +17595,6 @@ impl ECStore {
|
||||
}
|
||||
self.persist_decommission_durable_ilm_receipt(source_pool_idx, target_pool_idx, &receipt)
|
||||
.await?;
|
||||
self.decommission_durable_ilm_receipt_path_for_test(source_pool_idx, source_path, record)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn decommission_durable_ilm_receipt_path_for_test(
|
||||
&self,
|
||||
source_pool_idx: usize,
|
||||
source_path: &str,
|
||||
record: &ValidatedDurableIlmRecord,
|
||||
) -> Result<String> {
|
||||
let run_token = self.durable_ilm_receipt_run_token(source_pool_idx).await?;
|
||||
Ok(decommission_durable_ilm_receipt_path(&run_token, source_path, record.id_kind, &record.id))
|
||||
}
|
||||
|
||||
@@ -41,14 +41,6 @@ struct DanglingDeleteGraceError {
|
||||
grace_secs: i64,
|
||||
}
|
||||
|
||||
/// Marks a conditional-file write that failed before its publication rename.
|
||||
/// Callers may choose another owner only while this marker is preserved; every
|
||||
/// unmarked error remains commit-ambiguous and must fail closed.
|
||||
#[derive(Debug)]
|
||||
struct ConditionalFileNotCommittedError {
|
||||
source: io::Error,
|
||||
}
|
||||
|
||||
// DiskError == StorageErr
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DiskError {
|
||||
@@ -228,18 +220,6 @@ impl std::fmt::Display for DanglingDeleteGraceError {
|
||||
|
||||
impl StdError for DanglingDeleteGraceError {}
|
||||
|
||||
impl std::fmt::Display for ConditionalFileNotCommittedError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.source.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl StdError for ConditionalFileNotCommittedError {
|
||||
fn source(&self) -> Option<&(dyn StdError + 'static)> {
|
||||
Some(&self.source)
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_internode_missing_error(error: &InternodeHttpError) -> Option<DiskError> {
|
||||
if error.is_remote_file_not_found() {
|
||||
return Some(DiskError::FileNotFound);
|
||||
@@ -313,22 +293,6 @@ impl DiskError {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn conditional_file_not_committed(source: io::Error) -> io::Error {
|
||||
io::Error::new(source.kind(), ConditionalFileNotCommittedError { source })
|
||||
}
|
||||
|
||||
/// Whether a local conditional-file replacement failed before the target
|
||||
/// publication rename and therefore cannot have committed new owner bytes.
|
||||
pub fn is_conditional_file_not_committed(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
DiskError::Io(io_error)
|
||||
if io_error
|
||||
.get_ref()
|
||||
.is_some_and(|source| source.downcast_ref::<ConditionalFileNotCommittedError>().is_some())
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_dangling_delete_grace(&self) -> bool {
|
||||
matches!(self, DiskError::Io(io_error) if Self::io_error_is_dangling_delete_grace(io_error))
|
||||
}
|
||||
@@ -663,9 +627,6 @@ impl From<tokio::task::JoinError> for DiskError {
|
||||
impl Clone for DiskError {
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
DiskError::Io(io_error) if self.is_conditional_file_not_committed() => DiskError::Io(
|
||||
DiskError::conditional_file_not_committed(io::Error::new(io_error.kind(), io_error.to_string())),
|
||||
),
|
||||
DiskError::Io(io_error) => DiskError::Io(
|
||||
rustfs_rio::clone_internode_http_io_error(io_error)
|
||||
.and_then(std::io::Error::into_inner)
|
||||
@@ -859,21 +820,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn conditional_file_not_committed_marker_is_explicit_and_clone_safe() {
|
||||
let marked = DiskError::from(DiskError::conditional_file_not_committed(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"staging rejected",
|
||||
)));
|
||||
assert!(marked.is_conditional_file_not_committed());
|
||||
assert!(marked.is_conditional_file_not_committed());
|
||||
assert!(!DiskError::Timeout.is_conditional_file_not_committed());
|
||||
assert!(
|
||||
!DiskError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "rename rejected"))
|
||||
.is_conditional_file_not_committed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_read_error_preserves_kind_and_disk_classification() {
|
||||
let timeout = terminal_read_error_to_io(DiskError::Timeout);
|
||||
|
||||
@@ -6409,6 +6409,10 @@ impl LocalDisk {
|
||||
// A missing or still-populated directory is benign here; see
|
||||
// is_benign_object_rmdir_error (handles the illumos/Solaris EEXIST
|
||||
// convention, rustfs/rustfs#4978).
|
||||
if is_dir_not_empty_error(&err) {
|
||||
// A populated directory keeps its ancestors populated; no further pruning is needed.
|
||||
return Ok(());
|
||||
}
|
||||
if !is_benign_object_rmdir_error(&err) {
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_DELETE_FAILED,
|
||||
@@ -8957,8 +8961,7 @@ impl DiskAPI for LocalDisk {
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&lock_path)
|
||||
.map_err(DiskError::conditional_file_not_committed)?;
|
||||
.open(&lock_path)?;
|
||||
flock(&lock, FlockOperation::NonBlockingLockExclusive).map_err(std::io::Error::from)?;
|
||||
let result = (|| {
|
||||
let current = match std::fs::read(&file_path) {
|
||||
@@ -9013,15 +9016,10 @@ impl DiskAPI for LocalDisk {
|
||||
.ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "conditional file has no parent"))?;
|
||||
let temporary = parent.join(format!(".{}.{}.tmp", path.replace('/', "_"), Uuid::new_v4()));
|
||||
let write_result = (|| -> std::io::Result<()> {
|
||||
let not_committed = DiskError::conditional_file_not_committed;
|
||||
let mut staged = std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&temporary)
|
||||
.map_err(not_committed)?;
|
||||
staged.write_all(&replacement).map_err(not_committed)?;
|
||||
let mut staged = std::fs::OpenOptions::new().create_new(true).write(true).open(&temporary)?;
|
||||
staged.write_all(&replacement)?;
|
||||
if sync_metadata {
|
||||
staged.sync_all().map_err(not_committed)?;
|
||||
staged.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&temporary, &file_path)?;
|
||||
Ok(())
|
||||
@@ -11228,6 +11226,176 @@ mod test {
|
||||
(disk, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_pruning_stops_at_live_metadata_below_a_guarded_ancestor() {
|
||||
// Tuple fields drop in order, releasing the disk's root handle before the temporary directory.
|
||||
let fixture = new_disk().await;
|
||||
let (disk, _dir) = &fixture;
|
||||
let base = disk.get_bucket_path(RUSTFS_META_BUCKET).expect("resolve metadata volume");
|
||||
let shared = base.join("buckets");
|
||||
let guard = Arc::new(
|
||||
os::mkdir_all_below_existing_base_std(&shared, &base, &disk.publication_root)
|
||||
.expect("retain the shared publication directory"),
|
||||
);
|
||||
|
||||
for owned in [false, true] {
|
||||
for missing_backup in [false, true] {
|
||||
let transaction = Uuid::new_v4();
|
||||
let object = shared.join(".bloomcycle.bin");
|
||||
let rollback = object.join(transaction.to_string());
|
||||
let metadata = object.join(STORAGE_FORMAT_FILE);
|
||||
let backup = rollback.join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
fs::create_dir_all(&rollback).await.expect("create rollback directory");
|
||||
fs::write(&metadata, b"committed metadata")
|
||||
.await
|
||||
.expect("write live metadata");
|
||||
if !missing_backup {
|
||||
fs::write(&backup, b"old metadata").await.expect("write rollback backup");
|
||||
}
|
||||
let owner: Option<Arc<dyn Send + Sync>> = if owned { Some(guard.clone()) } else { None };
|
||||
let result = disk
|
||||
.delete_with_namespace_owner(
|
||||
RUSTFS_META_BUCKET,
|
||||
&format!("buckets/.bloomcycle.bin/{transaction}/{STORAGE_FORMAT_FILE_BACKUP}"),
|
||||
DeleteOptions::default(),
|
||||
owner,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!backup.exists(), "backup must be absent, owned={owned}, missing={missing_backup}");
|
||||
assert!(!rollback.exists(), "empty rollback directory must be pruned");
|
||||
assert_eq!(fs::read(&metadata).await.expect("read committed metadata"), b"committed metadata");
|
||||
result.expect("a nonempty object must stop pruning before the guarded ancestor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_pruning_removes_empty_and_missing_ancestors_but_keeps_the_volume() {
|
||||
let fixture = new_disk().await;
|
||||
let (disk, _dir) = &fixture;
|
||||
ensure_test_volume(disk, "pruning").await;
|
||||
let base = disk.get_bucket_path("pruning").expect("resolve test volume");
|
||||
|
||||
for missing in [false, true] {
|
||||
let parent = base.join("parent");
|
||||
let rollback = parent.join("object/transaction");
|
||||
fs::create_dir_all(&rollback).await.expect("create empty ancestor chain");
|
||||
let path = if missing {
|
||||
"parent/object/transaction/missing/xl.meta.bkp"
|
||||
} else {
|
||||
fs::write(rollback.join(STORAGE_FORMAT_FILE_BACKUP), b"backup")
|
||||
.await
|
||||
.expect("create backup");
|
||||
"parent/object/transaction/xl.meta.bkp"
|
||||
};
|
||||
|
||||
disk.delete("pruning", path, DeleteOptions::default())
|
||||
.await
|
||||
.expect("empty and missing ancestors should be pruned");
|
||||
assert!(!parent.exists(), "the whole empty chain should be removed");
|
||||
assert!(base.is_dir(), "pruning must stop at the volume boundary");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_pruning_does_not_remove_the_base_or_an_outside_path() {
|
||||
let fixture = new_disk().await;
|
||||
let (disk, dir) = &fixture;
|
||||
let base = dir.path().join("base");
|
||||
let outside = dir.path().join("outside");
|
||||
fs::create_dir(&base).await.expect("create base");
|
||||
fs::write(&outside, b"outside data").await.expect("create outside file");
|
||||
|
||||
disk.delete_file(&base, &base, false, false)
|
||||
.await
|
||||
.expect("base path is protected");
|
||||
disk.delete_file(&base, &outside, false, false)
|
||||
.await
|
||||
.expect("outside path is protected");
|
||||
assert!(base.is_dir(), "the base must not be removed even when empty");
|
||||
assert_eq!(fs::read(&outside).await.expect("read outside file"), b"outside data");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn delete_pruning_propagates_a_locked_backup_error() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use windows_sys::Win32::{Foundation::ERROR_SHARING_VIOLATION, Storage::FileSystem::FILE_SHARE_READ};
|
||||
|
||||
let fixture = new_disk().await;
|
||||
let (disk, _dir) = &fixture;
|
||||
ensure_test_volume(disk, "pruning").await;
|
||||
let base = disk.get_bucket_path("pruning").expect("resolve test volume");
|
||||
let backup = base.join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
fs::write(&backup, b"backup").await.expect("write backup");
|
||||
let guard = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(FILE_SHARE_READ)
|
||||
.open(&backup)
|
||||
.expect("hold the backup without delete sharing");
|
||||
|
||||
let err = disk
|
||||
.delete("pruning", STORAGE_FORMAT_FILE_BACKUP, DeleteOptions::default())
|
||||
.await
|
||||
.expect_err("a genuine target-file deletion failure must propagate");
|
||||
let DiskError::Io(err) = err else {
|
||||
panic!("expected contextual I/O error, got {err:?}");
|
||||
};
|
||||
let context = err
|
||||
.get_ref()
|
||||
.and_then(|err| err.downcast_ref::<FileAccessDeniedWithContext>())
|
||||
.expect("preserve the failing path and original OS error");
|
||||
assert_eq!(context.path, backup);
|
||||
assert_eq!(
|
||||
context.source.raw_os_error(),
|
||||
Some(i32::try_from(ERROR_SHARING_VIOLATION).expect("OS code fits"))
|
||||
);
|
||||
assert_eq!(fs::read(&backup).await.expect("backup remains readable"), b"backup");
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn delete_pruning_propagates_a_locked_empty_parent_error() {
|
||||
use windows_sys::Win32::Foundation::ERROR_SHARING_VIOLATION;
|
||||
|
||||
let fixture = new_disk().await;
|
||||
let (disk, _dir) = &fixture;
|
||||
ensure_test_volume(disk, "pruning").await;
|
||||
let base = disk.get_bucket_path("pruning").expect("resolve test volume");
|
||||
let parent = base.join("parent");
|
||||
let guard = os::mkdir_all_below_existing_base_std(&parent, &base, &disk.publication_root)
|
||||
.expect("retain an empty parent without delete sharing");
|
||||
let backup = parent.join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
fs::write(&backup, b"backup").await.expect("write backup");
|
||||
|
||||
let err = disk
|
||||
.delete("pruning", "parent/xl.meta.bkp", DeleteOptions::default())
|
||||
.await
|
||||
.expect_err("a real parent failure without a nonempty boundary must still propagate");
|
||||
let DiskError::Io(err) = err else {
|
||||
panic!("expected contextual I/O error, got {err:?}");
|
||||
};
|
||||
let context = err
|
||||
.get_ref()
|
||||
.and_then(|err| err.downcast_ref::<FileAccessDeniedWithContext>())
|
||||
.expect("preserve parent failure context");
|
||||
assert_eq!(context.path, parent);
|
||||
assert_eq!(
|
||||
context.source.raw_os_error(),
|
||||
Some(i32::try_from(ERROR_SHARING_VIOLATION).expect("OS code fits"))
|
||||
);
|
||||
assert!(!backup.exists(), "the target was removed before the parent error");
|
||||
assert!(parent.is_dir(), "the guarded parent remains");
|
||||
drop(guard);
|
||||
disk.delete("pruning", "parent/xl.meta.bkp", DeleteOptions::default())
|
||||
.await
|
||||
.expect("pruning should succeed once the actual guard is released");
|
||||
assert!(!parent.exists());
|
||||
assert!(base.is_dir());
|
||||
}
|
||||
|
||||
// #948: a genuinely missing source is benign and must still return Ok.
|
||||
#[tokio::test]
|
||||
async fn windows_and_unix_move_to_trash_missing_source_is_ok() {
|
||||
@@ -11711,14 +11879,59 @@ mod test {
|
||||
/// stale deterministically, instead of sleeping and hoping the filesystem
|
||||
/// timestamp granularity (or a backward wall-clock step) cooperates.
|
||||
fn backdate_mtime(path: &Path, age: Duration) {
|
||||
use std::fs::{File, FileTimes};
|
||||
use std::fs::{FileTimes, OpenOptions};
|
||||
let mtime = std::time::SystemTime::now() - age;
|
||||
File::open(path)
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_WRITE_ATTRIBUTES};
|
||||
|
||||
// Directories need backup semantics, and changing mtime needs attribute-write access.
|
||||
options
|
||||
.access_mode(FILE_WRITE_ATTRIBUTES)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS);
|
||||
}
|
||||
options
|
||||
.open(path)
|
||||
.expect("path should open to backdate its mtime")
|
||||
.set_times(FileTimes::new().set_modified(mtime))
|
||||
.expect("mtime should rewind into the past");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_tmp_on_startup_backdate_mtime_preserves_files_and_directory_contents() {
|
||||
use std::time::SystemTime;
|
||||
|
||||
let root = tempfile::tempdir().expect("create timestamp fixture root");
|
||||
let directory = root.path().join("directory");
|
||||
let file = directory.join("payload");
|
||||
std::fs::create_dir(&directory).expect("create timestamp fixture directory");
|
||||
std::fs::write(&file, b"unchanged payload").expect("write timestamp fixture payload");
|
||||
let age = Duration::from_secs(60);
|
||||
// Filesystems may round stored timestamps; do not require subsecond precision or sleep.
|
||||
let rounding = Duration::from_secs(2);
|
||||
|
||||
for path in [&file, &directory] {
|
||||
let earliest = SystemTime::now() - age - rounding;
|
||||
backdate_mtime(path, age);
|
||||
let latest = SystemTime::now() - age + rounding;
|
||||
let modified = std::fs::metadata(path)
|
||||
.expect("read backdated path metadata")
|
||||
.modified()
|
||||
.expect("read backdated modification time");
|
||||
assert!(modified >= earliest && modified <= latest, "mtime must be backdated for {path:?}");
|
||||
}
|
||||
|
||||
let moved = root.path().join("moved");
|
||||
std::fs::rename(&directory, &moved).expect("mtime helper must release its handles before cleanup");
|
||||
assert_eq!(
|
||||
std::fs::read(moved.join("payload")).expect("read preserved payload"),
|
||||
b"unchanged payload"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_cleanup_barrier_and_tmp_trash_cleanup_cover_noop_and_delete_paths() {
|
||||
use tempfile::tempdir;
|
||||
@@ -22656,10 +22869,6 @@ mod test {
|
||||
.await
|
||||
.expect_err("directory fsync failure must fail the CAS update");
|
||||
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::Other));
|
||||
assert!(
|
||||
!err.is_conditional_file_not_committed(),
|
||||
"an error after publication rename must remain commit-ambiguous"
|
||||
);
|
||||
assert_eq!(
|
||||
disk.read_all(RUSTFS_META_BUCKET, HEALING_MARKER_PATH)
|
||||
.await
|
||||
|
||||
@@ -47,6 +47,43 @@ use tokio::fs;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Hold later repair publications after admitting one baseline object. The
|
||||
/// fixture arms this on one replacement disk before rejoining the cluster.
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
async fn wait_for_heal_commit_test_barrier(root: &Path, bucket: &str, object: &str) -> Result<()> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let barrier = root.join(".rustfs.sys/e2e-heal-commit-barrier");
|
||||
let prefix = match fs::read_to_string(&barrier).await {
|
||||
Ok(prefix) => prefix,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let key = format!("{bucket}/{object}");
|
||||
if prefix.is_empty() || !key.starts_with(&prefix) {
|
||||
return Ok(());
|
||||
}
|
||||
let admitted = barrier.with_extension("admitted");
|
||||
match fs::OpenOptions::new().write(true).create_new(true).open(&admitted).await {
|
||||
Ok(mut file) => {
|
||||
file.write_all(key.as_bytes()).await?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
loop {
|
||||
if !fs::try_exists(&barrier).await? || fs::read_to_string(&admitted).await? == key {
|
||||
return Ok(());
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(std::io::Error::new(ErrorKind::TimedOut, "heal commit test barrier was not released").into());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback_committed_rename_std(
|
||||
dst_file_path: &Path,
|
||||
new_data_path: Option<&Path>,
|
||||
@@ -253,6 +290,10 @@ impl LocalDisk {
|
||||
state: &mut RenameDataState,
|
||||
) -> Result<RenameDataResp> {
|
||||
crate::hp_guard!("LocalDisk::rename_data");
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if fi.is_healing() {
|
||||
wait_for_heal_commit_test_barrier(&self.root, dst_volume, dst_path).await?;
|
||||
}
|
||||
let mut fi = fi;
|
||||
// A non-force DeleteBucket must not remove a directory while a local
|
||||
// object commit is publishing into it. The peer's empty scan remains
|
||||
|
||||
@@ -1626,6 +1626,72 @@ mod tests {
|
||||
aborting_full_queue_settles_pending_send().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn delayed_reader_error_keeps_source_and_drops_every_encode_path() {
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("injected request body inactivity")]
|
||||
struct BodyInactivity;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StalledReader {
|
||||
data: Cursor<Vec<u8>>,
|
||||
timer: Option<Pin<Box<tokio::time::Sleep>>>,
|
||||
dropped: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl AsyncRead for StalledReader {
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||
if self.data.position() < self.data.get_ref().len() as u64 {
|
||||
return Pin::new(&mut self.data).poll_read(cx, buf);
|
||||
}
|
||||
let timer = self
|
||||
.timer
|
||||
.get_or_insert_with(|| Box::pin(tokio::time::sleep(Duration::from_secs(300))));
|
||||
std::task::ready!(timer.as_mut().poll(cx));
|
||||
Poll::Ready(Err(std::io::Error::other(BodyInactivity)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StalledReader {
|
||||
fn drop(&mut self) {
|
||||
self.dropped.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit entry points select the paths; environment caches and input
|
||||
// size heuristics cannot silently turn this into repeated Vec coverage.
|
||||
for path in ["direct", "vec", "bytesmut", "batched"] {
|
||||
let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let reader = StalledReader {
|
||||
data: Cursor::new(vec![7; 64]),
|
||||
timer: None,
|
||||
dropped: Arc::clone(&dropped),
|
||||
};
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut writers = (0..4)
|
||||
.map(|_| Some(bitrot_writer(DeferredCommitWriter::new(Arc::clone(&committed)), 32)))
|
||||
.collect::<Vec<_>>();
|
||||
let erasure = Arc::new(Erasure::new(2, 2, 64));
|
||||
let result = match path {
|
||||
"direct" => erasure.encode_single_block_non_inline(reader, &mut writers, 2).await,
|
||||
"vec" => erasure.encode_with_ingest_mode(reader, &mut writers, 2, false).await,
|
||||
"bytesmut" => erasure.encode_with_ingest_mode(reader, &mut writers, 2, true).await,
|
||||
"batched" => erasure.encode_batched(reader, &mut writers, 2).await,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let error = result.expect_err("stalled input must fail before shard commit");
|
||||
assert!(error.get_ref().is_some_and(|source| source.is::<BodyInactivity>()), "{path}: {error:?}");
|
||||
assert!(
|
||||
dropped.load(std::sync::atomic::Ordering::Acquire),
|
||||
"{path} must release its reader/producer before returning"
|
||||
);
|
||||
assert!(
|
||||
committed.lock().expect("committed bytes").is_empty(),
|
||||
"{path} must not commit partial shards"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn helper_writers_cover_flush_and_shutdown_paths() {
|
||||
let mut failing_write = FailingWriteWriter;
|
||||
|
||||
@@ -3076,23 +3076,6 @@ impl SetDisks {
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
) -> Result<Option<rustfs_filemeta::FileInfoVersions>> {
|
||||
self.load_file_info_versions_for_cleanup(bucket, object, false).await
|
||||
}
|
||||
|
||||
pub(crate) async fn load_file_info_versions_for_tier_cleanup(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
) -> Result<Option<rustfs_filemeta::FileInfoVersions>> {
|
||||
self.load_file_info_versions_for_cleanup(bucket, object, true).await
|
||||
}
|
||||
|
||||
async fn load_file_info_versions_for_cleanup(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
retain_unconfirmed_tier_references: bool,
|
||||
) -> Result<Option<rustfs_filemeta::FileInfoVersions>> {
|
||||
let disk_object = rustfs_utils::path::encode_dir_object(object);
|
||||
let disks = self.get_disks_internal().await;
|
||||
@@ -3169,24 +3152,12 @@ impl SetDisks {
|
||||
)));
|
||||
}
|
||||
|
||||
let mut file_info_versions = FileMeta {
|
||||
let file_info_versions = FileMeta {
|
||||
versions,
|
||||
..Default::default()
|
||||
}
|
||||
.get_all_file_info_versions(bucket, object, true)
|
||||
.map_err(decode_error)?;
|
||||
if retain_unconfirmed_tier_references {
|
||||
// A failed overwrite may leave its live source on a minority
|
||||
// of disks. Preserve that reference even if quorum merging
|
||||
// selects only the replacement and its cleanup owner.
|
||||
file_info_versions.versions.extend(
|
||||
transition_copies
|
||||
.into_values()
|
||||
.flatten()
|
||||
.map(|(version, _)| version)
|
||||
.filter(|version| !version.tier_free_version()),
|
||||
);
|
||||
}
|
||||
|
||||
for file_info in file_info_versions
|
||||
.versions
|
||||
@@ -12243,64 +12214,6 @@ mod tests {
|
||||
assert!(result.is_err(), "missing disks must prevent metadata write quorum");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_overwrite_cleanup_rejects_unreadable_disk_despite_metadata_quorum() {
|
||||
let bucket = "tier-unreadable-disk";
|
||||
let object = "object";
|
||||
let mut dirs = Vec::new();
|
||||
let mut disks = Vec::new();
|
||||
let mut fi = metadata_test_fileinfo(object);
|
||||
fi.mod_time = Some(OffsetDateTime::now_utc());
|
||||
for index in 1..=3 {
|
||||
let (dir, disk) = read_multiple_test_disk(bucket, &[]).await;
|
||||
fi.erasure.index = index;
|
||||
disk.write_metadata(bucket, bucket, object, fi.clone())
|
||||
.await
|
||||
.expect("seed metadata quorum");
|
||||
dirs.push(dir);
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
disks.push(None);
|
||||
let set = io_primitives_test_set(disks, 2).await;
|
||||
assert!(
|
||||
set.load_file_info_versions_exact(bucket, object).await.is_err(),
|
||||
"exact reads must preserve release's unreadable-replica fence"
|
||||
);
|
||||
assert!(
|
||||
set.load_file_info_versions_for_tier_cleanup(bucket, object).await.is_err(),
|
||||
"unreadable replica may still reference the old remote object"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_overwrite_cleanup_rejects_minority_metadata_in_an_absent_set() {
|
||||
let bucket = "tier-minority-metadata";
|
||||
let object = "object";
|
||||
let mut dirs = Vec::new();
|
||||
let mut disks = Vec::new();
|
||||
for index in 1..=4 {
|
||||
let (dir, disk) = read_multiple_test_disk(bucket, &[]).await;
|
||||
if index == 1 {
|
||||
let mut fi = metadata_test_fileinfo(object);
|
||||
fi.mod_time = Some(OffsetDateTime::now_utc());
|
||||
disk.write_metadata(bucket, bucket, object, fi)
|
||||
.await
|
||||
.expect("seed minority metadata");
|
||||
}
|
||||
dirs.push(dir);
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
let set = io_primitives_test_set(disks, 2).await;
|
||||
assert!(
|
||||
set.load_file_info_versions_exact(bucket, object).await.is_err(),
|
||||
"exact reads must preserve release's minority-ownership fence"
|
||||
);
|
||||
assert!(
|
||||
set.load_file_info_versions_for_tier_cleanup(bucket, object).await.is_err(),
|
||||
"absence on a majority cannot prove this physical set has no remote reference"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_file_info_versions_exact_returns_versions_from_read_quorum() {
|
||||
let bucket = "exact-versions-bucket";
|
||||
|
||||
@@ -3280,65 +3280,6 @@ mod heal_result_report_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deep_heal_rebuilds_missing_part_when_metadata_remains_current() {
|
||||
let (temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
|
||||
let bucket = "deep-heal-missing-part-current-meta";
|
||||
let object = "object.bin";
|
||||
for disk in &disks {
|
||||
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||
}
|
||||
|
||||
let payload = vec![0x7b; 1024 * 1024];
|
||||
let mut reader = PutObjReader::from_vec(payload);
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("source object should be written before shard loss");
|
||||
let source = disks[2]
|
||||
.read_version("", bucket, object, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("source metadata should be readable");
|
||||
let data_dir = source.data_dir.expect("non-inline source should have a data directory");
|
||||
let missing_part = temp_dirs[1]
|
||||
.path()
|
||||
.join(bucket)
|
||||
.join(object)
|
||||
.join(data_dir.to_string())
|
||||
.join("part.1");
|
||||
tokio::fs::remove_file(&missing_part)
|
||||
.await
|
||||
.expect("target shard should be removed while xl.meta remains current");
|
||||
|
||||
let (result, error) = set
|
||||
.heal_object(
|
||||
bucket,
|
||||
object,
|
||||
"",
|
||||
&HealOpts {
|
||||
no_lock: true,
|
||||
scan_mode: HealScanMode::Deep,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("deep heal should finish after a single shard is removed");
|
||||
|
||||
assert!(error.is_none(), "deep heal should recover the missing shard: {error:?}");
|
||||
assert_eq!(result.after.drives[1].state, DriveState::Ok.to_string());
|
||||
assert!(
|
||||
missing_part.exists(),
|
||||
"deep heal must reconstruct the missing shard on the original disk slot"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacement_target_readback_checks_the_requested_historical_version() {
|
||||
let (temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
|
||||
|
||||
@@ -5767,6 +5767,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capped_staging_queue_does_not_poll_the_part_reader() {
|
||||
use futures::StreamExt;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let (_temp_dirs, disks, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "multipart-staging-body-demand";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disks, bucket).await;
|
||||
let mut options = ObjectOptions::default();
|
||||
insert_str(&mut options.user_defined, "max-total-object-size", "1024".to_owned());
|
||||
let upload = set_disks
|
||||
.new_multipart_upload(bucket, object, &options)
|
||||
.await
|
||||
.expect("capped upload");
|
||||
let upload_path = SetDisks::get_upload_id_dir(bucket, object, &upload.upload_id);
|
||||
let semaphore = capped_multipart_staging_semaphore(&upload_path);
|
||||
let held = Arc::clone(&semaphore).acquire_owned().await.expect("hold staging permit");
|
||||
let owners = Arc::strong_count(&semaphore);
|
||||
let polls = Arc::new(AtomicUsize::new(0));
|
||||
let body_polls = Arc::clone(&polls);
|
||||
let stream = futures::stream::iter([Ok::<Bytes, std::io::Error>(Bytes::from(vec![7; 512]))]).inspect(move |_| {
|
||||
body_polls.fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
let input = tokio_util::io::StreamReader::new(stream);
|
||||
let mut reader = PutObjReader::new(HashReader::from_stream(input, 512, 512, None, None, false).expect("part reader"));
|
||||
let task = tokio::spawn(async move {
|
||||
set_disks
|
||||
.put_object_part(bucket, object, &upload.upload_id, 1, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while Arc::strong_count(&semaphore) == owners {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("part must reach the actual staging semaphore");
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(600)).await;
|
||||
tokio::time::resume();
|
||||
assert_eq!(polls.load(Ordering::Relaxed), 0, "staging admission must not create read demand");
|
||||
assert!(!task.is_finished());
|
||||
drop(held);
|
||||
let part = tokio::time::timeout(Duration::from_secs(10), task)
|
||||
.await
|
||||
.expect("staging permit released")
|
||||
.expect("part task")
|
||||
.expect("queued part");
|
||||
assert_eq!(part.size, 512);
|
||||
assert_eq!(polls.load(Ordering::Relaxed), 1);
|
||||
drop(semaphore);
|
||||
remove_capped_multipart_staging_semaphore(&upload_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_object_part_recovers_transaction_with_one_faulty_disk_at_write_quorum() {
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
|
||||
@@ -3821,13 +3821,6 @@ impl SetDisks {
|
||||
}
|
||||
|
||||
fi.metadata = user_defined;
|
||||
if fi.version_id.is_none_or(|id| id.is_nil()) && !opts.data_movement && expected_restore_operation_id.is_none() {
|
||||
// Every disk must publish the same cleanup owner alongside a
|
||||
// replaced null version. This transient key is not persisted
|
||||
// on the new object; recovery discovers the free-version in
|
||||
// the committed xl.meta even if this request is cancelled.
|
||||
fi.set_tier_free_version_id(&Uuid::new_v4().to_string());
|
||||
}
|
||||
fi.mod_time = mod_time;
|
||||
fi.size = w_size as i64;
|
||||
fi.versioned = opts.versioned || opts.version_suspended;
|
||||
@@ -18181,102 +18174,6 @@ mod put_object_tmp_cleanup_tests {
|
||||
drop(temp_dirs);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(capacity_dirty_scope)]
|
||||
async fn tier_overwrite_failed_quorum_and_cancellation_preserve_live_source() {
|
||||
for cancel_before_rename in [false, true] {
|
||||
let (dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "tier-overwrite-failure";
|
||||
let object = "still-live";
|
||||
make_completion_test_bucket(&disks, bucket).await;
|
||||
let old_body = vec![0x31; TEST_OBJECT_SIZE];
|
||||
let mut metadata = HashMap::from([(
|
||||
"x-amz-restore".to_string(),
|
||||
"ongoing-request=\"false\", expiry-date=\"2099-01-01T00:00:00Z\"".to_string(),
|
||||
)]);
|
||||
for (suffix, value) in [
|
||||
(rustfs_utils::http::SUFFIX_TRANSITION_STATUS, "complete".to_string()),
|
||||
(rustfs_utils::http::SUFFIX_TRANSITION_TIER, "WARM".to_string()),
|
||||
(rustfs_utils::http::SUFFIX_TRANSITIONED_OBJECTNAME, "remote/still-live".to_string()),
|
||||
(rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_ID, "exact-live-version".to_string()),
|
||||
(rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_STATE, "exact".to_string()),
|
||||
(rustfs_utils::http::SUFFIX_TRANSITION_TIER_DESTINATION_ID, "ab".repeat(32)),
|
||||
] {
|
||||
rustfs_utils::http::insert_str(&mut metadata, suffix, value);
|
||||
}
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(old_body.clone()),
|
||||
&ObjectOptions {
|
||||
user_defined: metadata,
|
||||
write_completion: WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed live transitioned source");
|
||||
wait_for_tmp_workspace_to_drain(&dirs, "seed write must drain").await;
|
||||
let before = set
|
||||
.load_file_info_versions_exact(bucket, object)
|
||||
.await
|
||||
.expect("read original metadata")
|
||||
.expect("original exists");
|
||||
|
||||
if cancel_before_rename {
|
||||
let barrier = PutObjectCommitBarrier::install(bucket, object, PutObjectCommitPause::AfterQuotaReservation);
|
||||
let writer = Arc::clone(&set);
|
||||
let put = tokio::spawn(async move {
|
||||
writer
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(vec![0x32; TEST_OBJECT_SIZE]),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
put.abort();
|
||||
assert!(put.await.expect_err("cancel paused replacement").is_cancelled());
|
||||
wait_for_tmp_workspace_to_drain(&dirs, "cancelled replacement must roll back").await;
|
||||
drop(barrier);
|
||||
} else {
|
||||
let _fault = rename_fault_injection::fail_rename_on(object, &[2, 3]);
|
||||
let err = set
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(vec![0x32; TEST_OBJECT_SIZE]),
|
||||
&ObjectOptions {
|
||||
write_completion: WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("two disk commits cannot satisfy write quorum three");
|
||||
assert!(matches!(err, Error::ErasureWriteQuorum | Error::InsufficientWriteQuorum(_, _)), "{err}");
|
||||
}
|
||||
let after = set
|
||||
.load_file_info_versions_exact(bucket, object)
|
||||
.await
|
||||
.expect("read rolled-back metadata")
|
||||
.expect("live source must survive");
|
||||
assert_eq!(after.versions, before.versions, "failed replacement must preserve the live version");
|
||||
assert_eq!(
|
||||
after.free_versions, before.free_versions,
|
||||
"failed replacement must not publish a cleanup owner"
|
||||
);
|
||||
let mut reader = set
|
||||
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("live source remains readable");
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.expect("read original bytes");
|
||||
assert_eq!(actual, old_body);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cooperative_cancellation_while_waiting_for_namespace_lock_cleans_tmp_workspace() {
|
||||
let (temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
@@ -18604,75 +18501,71 @@ mod put_object_tmp_cleanup_tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(capacity_dirty_scope)]
|
||||
async fn cancelled_rename_keeps_namespace_lock_until_publication() {
|
||||
temp_env::async_with_vars([(ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("false"))], async {
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "put-commit-lock-cancelled-rename";
|
||||
let object = "commit-lock-cancelled-rename-object";
|
||||
for disk in &disk_stores {
|
||||
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||
}
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "put-commit-lock-cancelled-rename";
|
||||
let object = "commit-lock-cancelled-rename-object";
|
||||
for disk in &disk_stores {
|
||||
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||
}
|
||||
|
||||
let rename_tasks = rename_fanout_barrier::observe_tasks(object);
|
||||
let rename_barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
||||
let first_store = Arc::clone(&set_disks);
|
||||
let first = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::from_vec(vec![b'1'; TEST_OBJECT_SIZE]);
|
||||
first_store
|
||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(30), rename_barrier.wait_until_paused())
|
||||
let rename_tasks = rename_fanout_barrier::observe_tasks(object);
|
||||
let rename_barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
||||
let first_store = Arc::clone(&set_disks);
|
||||
let first = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::from_vec(vec![b'1'; TEST_OBJECT_SIZE]);
|
||||
first_store
|
||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("first PUT should pause during the authoritative rename");
|
||||
|
||||
let second_namespace_barrier = PutObjectCommitBarrier::install(bucket, object, PutObjectCommitPause::BeforeNamespace);
|
||||
let second_store = Arc::clone(&set_disks);
|
||||
let second = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::from_vec(vec![b'2'; TEST_OBJECT_SIZE]);
|
||||
second_store
|
||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
second_namespace_barrier.release_and_wait_until_namespace_pending().await;
|
||||
|
||||
first.abort();
|
||||
assert!(
|
||||
first
|
||||
.await
|
||||
.expect_err("the first request should be cancelled while rename is parked")
|
||||
.is_cancelled()
|
||||
);
|
||||
tokio::task::yield_now().await;
|
||||
assert!(
|
||||
!second.is_finished(),
|
||||
"the second writer must remain blocked by the cancelled commit owner"
|
||||
);
|
||||
|
||||
rename_barrier.release();
|
||||
drop(rename_barrier);
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while rename_tasks.running() != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(30), rename_barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("the cancelled owner's rename fanout should drain");
|
||||
second
|
||||
.await
|
||||
.expect("second overwrite task should join")
|
||||
.expect("second overwrite should commit after the cancelled owner reaches publication");
|
||||
.expect("first PUT should pause during the authoritative rename");
|
||||
|
||||
let mut reader = set_disks
|
||||
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
let second_namespace_barrier = PutObjectCommitBarrier::install(bucket, object, PutObjectCommitPause::BeforeNamespace);
|
||||
let second_store = Arc::clone(&set_disks);
|
||||
let second = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::from_vec(vec![b'2'; TEST_OBJECT_SIZE]);
|
||||
second_store
|
||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the latest overwrite should be readable");
|
||||
let mut body = Vec::new();
|
||||
reader.stream.read_to_end(&mut body).await.expect("latest body should drain");
|
||||
assert_eq!(body, vec![b'2'; TEST_OBJECT_SIZE]);
|
||||
});
|
||||
second_namespace_barrier.release_and_wait_until_namespace_pending().await;
|
||||
|
||||
first.abort();
|
||||
assert!(
|
||||
first
|
||||
.await
|
||||
.expect_err("the first request should be cancelled while rename is parked")
|
||||
.is_cancelled()
|
||||
);
|
||||
tokio::task::yield_now().await;
|
||||
assert!(
|
||||
!second.is_finished(),
|
||||
"the second writer must remain blocked by the cancelled commit owner"
|
||||
);
|
||||
|
||||
rename_barrier.release();
|
||||
drop(rename_barrier);
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while rename_tasks.running() != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
.await
|
||||
.expect("the cancelled owner's rename fanout should drain");
|
||||
second
|
||||
.await
|
||||
.expect("second overwrite task should join")
|
||||
.expect("second overwrite should commit after the cancelled owner reaches publication");
|
||||
|
||||
let mut reader = set_disks
|
||||
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the latest overwrite should be readable");
|
||||
let mut body = Vec::new();
|
||||
reader.stream.read_to_end(&mut body).await.expect("latest body should drain");
|
||||
assert_eq!(body, vec![b'2'; TEST_OBJECT_SIZE]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -364,6 +364,19 @@ impl ECStore {
|
||||
Ok(pieces.into_guard(bucket, registration.token))
|
||||
}
|
||||
|
||||
/// Hold this guard through recursive-delete authorization and mutation so
|
||||
/// writers cannot introduce an unchecked object into the deletion scope.
|
||||
pub async fn lock_bucket_for_recursive_delete(&self, bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard> {
|
||||
if self.ctx.lock_manager().is_disabled() {
|
||||
return Err(StorageError::InvalidArgument(
|
||||
bucket.to_owned(),
|
||||
String::new(),
|
||||
"Recursive deletion requires namespace locking".to_owned(),
|
||||
));
|
||||
}
|
||||
self.acquire_bucket_lifecycle_write_lock(bucket).await
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_bucket_lifecycle_write_lock(&self, bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard> {
|
||||
let lock = self.new_ns_lock(bucket, BUCKET_LIFECYCLE_LOCK_OBJECT).await?;
|
||||
lock.get_write_lock(get_lock_acquire_timeout())
|
||||
|
||||
@@ -2832,224 +2832,6 @@ mod tests {
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn tier_overwrite_put_and_self_copy_recover_persisted_cleanup_owners() {
|
||||
use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryState;
|
||||
use crate::bucket::lifecycle::tier_free_version_recovery::recover_tier_free_versions;
|
||||
use rustfs_filemeta::TransitionVersionState::{Exact, KnownDisabled, SuspendedNull};
|
||||
use rustfs_s3_client::transition_api::ReaderImpl;
|
||||
use rustfs_utils::http::{
|
||||
SUFFIX_TRANSITION_STATUS, SUFFIX_TRANSITION_TIER, SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
SUFFIX_TRANSITIONED_OBJECTNAME, SUFFIX_TRANSITIONED_VERSION_ID, SUFFIX_TRANSITIONED_VERSION_STATE, insert_str,
|
||||
};
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create tier overwrite store");
|
||||
let (mut ctx, mut store, mut shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-overwrite", &[4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let tier = "OVERWRITE-TIER";
|
||||
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier).await;
|
||||
let lease = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier)
|
||||
.await
|
||||
.expect("tier identity");
|
||||
let identity = rustfs_utils::crypto::hex(lease.backend_identity());
|
||||
drop(lease);
|
||||
|
||||
for state in [Exact, KnownDisabled, SuspendedNull] {
|
||||
for suspended in [false, true] {
|
||||
for self_copy in [false, true] {
|
||||
let bucket = format!("tier-overwrite-{}", Uuid::new_v4());
|
||||
let object = "object";
|
||||
let remote = format!("remote/{bucket}");
|
||||
let version = match state {
|
||||
Exact => "opaque-overwrite-version",
|
||||
SuspendedNull => "null",
|
||||
_ => "",
|
||||
};
|
||||
let payload = vec![0x5b; if suspended { 512 * 1024 } else { 257 }];
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
backend.set_put_remote_version(Some(version.to_string())).await;
|
||||
let lease = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier)
|
||||
.await
|
||||
.expect("seed tier lease");
|
||||
lease
|
||||
.put(
|
||||
&remote,
|
||||
ReaderImpl::Body(bytes::Bytes::from(payload.clone())),
|
||||
payload.len().try_into().expect("payload size"),
|
||||
)
|
||||
.await
|
||||
.expect("seed remote bytes");
|
||||
drop(lease);
|
||||
let mut metadata = HashMap::from([
|
||||
("content-type".to_string(), "application/octet-stream".to_string()),
|
||||
(
|
||||
"x-amz-restore".to_string(),
|
||||
"ongoing-request=\"false\", expiry-date=\"2099-01-01T00:00:00Z\"".to_string(),
|
||||
),
|
||||
]);
|
||||
for (suffix, value) in [
|
||||
(SUFFIX_TRANSITION_STATUS, "complete"),
|
||||
(SUFFIX_TRANSITION_TIER, tier),
|
||||
(SUFFIX_TRANSITION_TIER_DESTINATION_ID, identity.as_str()),
|
||||
(SUFFIX_TRANSITIONED_OBJECTNAME, remote.as_str()),
|
||||
(SUFFIX_TRANSITIONED_VERSION_STATE, state.as_str()),
|
||||
] {
|
||||
insert_str(&mut metadata, suffix, value.to_string());
|
||||
}
|
||||
if !version.is_empty() {
|
||||
insert_str(&mut metadata, SUFFIX_TRANSITIONED_VERSION_ID, version.to_string());
|
||||
}
|
||||
let options = ObjectOptions {
|
||||
version_suspended: suspended,
|
||||
..Default::default()
|
||||
};
|
||||
store
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(payload.clone()),
|
||||
&ObjectOptions {
|
||||
user_defined: metadata,
|
||||
..options.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed transitioned source with locally restored bytes");
|
||||
let expected = if self_copy {
|
||||
payload.clone()
|
||||
} else {
|
||||
vec![0x73; payload.len()]
|
||||
};
|
||||
let new_metadata = HashMap::from([
|
||||
("content-type".to_string(), "text/plain".to_string()),
|
||||
("x-amz-meta-replacement".to_string(), "kept".to_string()),
|
||||
]);
|
||||
if self_copy {
|
||||
let mut source = store
|
||||
.get_object_info(&bucket, object, &options)
|
||||
.await
|
||||
.expect("self-copy source");
|
||||
source.metadata_only = false;
|
||||
source.user_defined = Arc::new(new_metadata);
|
||||
source.put_object_reader = Some(PutObjReader::from_vec(expected.clone()));
|
||||
store
|
||||
.copy_object(&bucket, object, &bucket, object, &mut source, &options, &options)
|
||||
.await
|
||||
.expect("materialized self-copy");
|
||||
} else {
|
||||
store
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(expected.clone()),
|
||||
&ObjectOptions {
|
||||
user_defined: new_metadata,
|
||||
..options.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("overwrite transitioned null version");
|
||||
}
|
||||
|
||||
let set = store.pools[0].get_disks_by_key(object);
|
||||
let versions = set
|
||||
.load_file_info_versions_exact(&bucket, object)
|
||||
.await
|
||||
.expect("read committed disk metadata")
|
||||
.expect("replacement metadata exists");
|
||||
let free: Vec<_> = versions
|
||||
.versions
|
||||
.iter()
|
||||
.chain(versions.free_versions.iter())
|
||||
.filter(|fi| fi.tier_free_version())
|
||||
.collect();
|
||||
assert_eq!(free.len(), 1, "{state:?}, suspended={suspended}, copy={self_copy}");
|
||||
assert_eq!(free[0].transitioned_objname, remote);
|
||||
assert_eq!(free[0].transition_version_state, state);
|
||||
assert!(backend.contains(&remote).await, "commit must not delete remote bytes before cleanup");
|
||||
let removed_before = backend.remove_count().await;
|
||||
|
||||
// Restart before queue delivery. The new runtime must
|
||||
// reconstruct ownership solely from the committed xl.meta.
|
||||
let tier_config = ctx
|
||||
.tier_config_mgr()
|
||||
.read()
|
||||
.await
|
||||
.tiers
|
||||
.get(tier)
|
||||
.expect("tier configuration survives restart")
|
||||
.clone_with_credentials();
|
||||
drop(set);
|
||||
shutdown.cancel();
|
||||
drop(store);
|
||||
drop(ctx);
|
||||
(ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-overwrite-restart", &[4]))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
{
|
||||
let manager = ctx.tier_config_mgr();
|
||||
let mut manager = manager.write().await;
|
||||
manager.tiers.insert(tier.to_string(), tier_config);
|
||||
manager
|
||||
.install_test_driver(tier, Box::new(backend.clone()))
|
||||
.expect("rebind the same remote destination after restart");
|
||||
}
|
||||
let set = store.pools[0].get_disks_by_key(object);
|
||||
ExpiryState::resize_workers(1, Arc::clone(&store)).await;
|
||||
let recovered = recover_tier_free_versions(Arc::clone(&store), 100, None, None)
|
||||
.await
|
||||
.expect("recover persisted cleanup owner");
|
||||
assert!(recovered.enqueued >= 1);
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
loop {
|
||||
let versions = set
|
||||
.load_file_info_versions_exact(&bucket, object)
|
||||
.await
|
||||
.expect("read cleanup progress")
|
||||
.expect("new object must survive cleanup");
|
||||
if versions
|
||||
.versions
|
||||
.iter()
|
||||
.chain(versions.free_versions.iter())
|
||||
.all(|fi| !fi.tier_free_version())
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("cleanup must converge");
|
||||
assert!(!backend.contains(&remote).await);
|
||||
assert_eq!(backend.remove_count().await, removed_before + 1, "one remote DELETE per owner");
|
||||
assert_eq!(backend.remove_versions().await.last(), Some(&(remote.clone(), version.to_string())));
|
||||
let mut reader = store
|
||||
.get_object_reader(&bucket, object, None, HeaderMap::new(), &options)
|
||||
.await
|
||||
.expect("replacement remains readable");
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.expect("read replacement bytes");
|
||||
assert_eq!(actual, expected);
|
||||
let current = store
|
||||
.get_object_info(&bucket, object, &options)
|
||||
.await
|
||||
.expect("replacement metadata");
|
||||
assert_eq!(current.user_defined.get("content-type").map(String::as_str), Some("text/plain"));
|
||||
assert_eq!(current.user_defined.get("x-amz-meta-replacement").map(String::as_str), Some("kept"));
|
||||
assert!(current.transitioned_object.status.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
@@ -9643,13 +9425,7 @@ mod tests {
|
||||
com::save_config(store.pools[1].clone(), &manual_task_path, manual_task_bytes.clone())
|
||||
.await
|
||||
.expect("target task rewrite should invalidate cached metadata before the quorum check");
|
||||
let manual_task_record =
|
||||
validate_durable_ilm_record(&manual_task_path, &manual_task_bytes).expect("manual task should validate");
|
||||
let manual_task_receipt_path = store
|
||||
.decommission_durable_ilm_receipt_path_for_test(0, &manual_task_path, &manual_task_record)
|
||||
.await
|
||||
.expect("manual task receipt path should resolve");
|
||||
let target_task_set = store.pools[1].get_disks_by_key(&manual_task_receipt_path);
|
||||
let target_task_set = store.pools[1].get_disks_by_key(&manual_task_path);
|
||||
let original_target_task_disks = {
|
||||
let mut disks = target_task_set.disks.write().await;
|
||||
let original = disks.clone();
|
||||
@@ -12554,6 +12330,23 @@ mod tests {
|
||||
.expect("tier free-version recovery should complete");
|
||||
}
|
||||
|
||||
/// Unlocked poll for exact metadata absence while asynchronous free-version
|
||||
/// cleanup removes the per-disk copies. Mid-cleanup, fewer than a read
|
||||
/// quorum of disks may still hold the record, so that transient result
|
||||
/// means "not converged yet"; every other error still fails the test.
|
||||
#[cfg(feature = "test-util")]
|
||||
async fn exact_metadata_absent_during_cleanup(store: &crate::store::ECStore, bucket: &str, object: &str) -> bool {
|
||||
match store.pools[0]
|
||||
.get_disks_by_key(object)
|
||||
.load_file_info_versions_exact(bucket, object)
|
||||
.await
|
||||
{
|
||||
Ok(metadata) => metadata.is_none(),
|
||||
Err(StorageError::InsufficientReadQuorum(_, _)) => false,
|
||||
Err(error) => panic!("{object} cleanup metadata should remain readable: {error:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
async fn wait_for_expiry_workers_idle(store: &crate::store::ECStore) {
|
||||
let expiry_state = store.ctx.expiry_state();
|
||||
@@ -15727,20 +15520,7 @@ mod tests {
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
loop {
|
||||
let metadata_absent = {
|
||||
// Synchronize with cleanup so the snapshot cannot span per-disk marker removal.
|
||||
let mut read_opts = ObjectOptions::default();
|
||||
let _guards = store
|
||||
.acquire_all_physical_object_read_locks("batch_transitioned_delete_test", bucket, causal, &mut read_opts)
|
||||
.await
|
||||
.expect("causal batch cleanup observation should acquire object read locks");
|
||||
store.pools[0]
|
||||
.get_disks_by_key(causal)
|
||||
.load_file_info_versions_exact(bucket, causal)
|
||||
.await
|
||||
.expect("causal batch cleanup metadata should remain readable")
|
||||
.is_none()
|
||||
};
|
||||
let metadata_absent = exact_metadata_absent_during_cleanup(&store, bucket, causal).await;
|
||||
if metadata_absent && backend.remove_versions().await.len() >= 2 {
|
||||
return;
|
||||
}
|
||||
@@ -15819,24 +15599,7 @@ mod tests {
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
loop {
|
||||
let metadata_absent = {
|
||||
let mut read_opts = ObjectOptions::default();
|
||||
let _guards = store
|
||||
.acquire_all_physical_object_read_locks(
|
||||
"batch_transitioned_delete_test",
|
||||
bucket,
|
||||
versioned_causal,
|
||||
&mut read_opts,
|
||||
)
|
||||
.await
|
||||
.expect("versioned causal batch cleanup observation should acquire object read locks");
|
||||
store.pools[0]
|
||||
.get_disks_by_key(versioned_causal)
|
||||
.load_file_info_versions_exact(bucket, versioned_causal)
|
||||
.await
|
||||
.expect("versioned causal batch cleanup metadata should remain readable")
|
||||
.is_none()
|
||||
};
|
||||
let metadata_absent = exact_metadata_absent_during_cleanup(&store, bucket, versioned_causal).await;
|
||||
if metadata_absent && backend.remove_versions().await.len() == 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ fn to_filemeta_err(err: Error) -> rustfs_filemeta::Error {
|
||||
err.narrow_to_filemeta().unwrap_or_else(rustfs_filemeta::Error::other)
|
||||
}
|
||||
|
||||
use crate::bucket::lifecycle::bucket_lifecycle_ops::free_version_remote_tuple_matches;
|
||||
use crate::bucket::metadata_sys::{
|
||||
get_versioning_config, has_authoritative_never_versioned_state, has_authoritative_never_versioned_state_in,
|
||||
};
|
||||
@@ -71,7 +70,7 @@ use tokio::io::duplex;
|
||||
use tokio::sync::broadcast::{self};
|
||||
use tokio::sync::mpsc::{self, Receiver, Sender};
|
||||
use tokio::sync::{OnceCell, RwLock};
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{Instrument, debug, error, info, warn};
|
||||
use uuid::Uuid;
|
||||
@@ -4332,7 +4331,6 @@ impl ECStore {
|
||||
"store list_merged started"
|
||||
);
|
||||
|
||||
let rx = rx.child_token();
|
||||
let mut futures = Vec::new();
|
||||
|
||||
let mut inputs = Vec::new();
|
||||
@@ -4348,10 +4346,16 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
|
||||
let merge_task = spawn_listing_merge(rx, inputs, sender);
|
||||
tokio::spawn(
|
||||
async move {
|
||||
if let Err(err) = merge_entry_channels(rx, inputs, sender.clone(), 1).await {
|
||||
error!("merge_entry_channels err {:?}", err)
|
||||
}
|
||||
}
|
||||
.instrument(tracing::Span::current()),
|
||||
);
|
||||
|
||||
let results = join_all(futures).await;
|
||||
merge_task.await.map_err(Error::from)??;
|
||||
|
||||
let mut all_at_eof = true;
|
||||
|
||||
@@ -4418,7 +4422,6 @@ impl ECStore {
|
||||
) -> Result<()> {
|
||||
check_list_objs_args(bucket, prefix, &None)?;
|
||||
|
||||
let rx = rx.child_token();
|
||||
let mut futures = Vec::new();
|
||||
let mut inputs = Vec::new();
|
||||
|
||||
@@ -4780,11 +4783,17 @@ impl ECStore {
|
||||
.instrument(tracing::Span::current()),
|
||||
);
|
||||
|
||||
let merge_task = spawn_listing_merge(rx, inputs, merge_tx);
|
||||
tokio::spawn(
|
||||
async move {
|
||||
if let Err(err) = merge_entry_channels(rx, inputs, merge_tx, 1).await {
|
||||
error!("merge_entry_channels err {:?}", err)
|
||||
}
|
||||
}
|
||||
.instrument(tracing::Span::current()),
|
||||
);
|
||||
|
||||
let walk_started = std::time::Instant::now();
|
||||
let walk_results = join_all(futures).await;
|
||||
merge_task.await.map_err(Error::from)??;
|
||||
let mut errs = Vec::new();
|
||||
for walk_result in walk_results {
|
||||
match walk_result {
|
||||
@@ -5059,130 +5068,6 @@ async fn send_or_cancel(rx: &CancellationToken, out_channel: &Sender<MetaCacheEn
|
||||
}
|
||||
}
|
||||
|
||||
/// Each input has already been resolved inside its own erasure set. This is a
|
||||
/// union of version histories, never a quorum vote between unrelated pools.
|
||||
fn merge_object_entry_versions(first: &mut MetaCacheEntry, others: impl Iterator<Item = MetaCacheEntry>) -> Result<()> {
|
||||
let name = first.name.clone();
|
||||
let mut versions: HashMap<(Option<Uuid>, bool), (FileMetaShallowVersion, ObjectInfo)> = HashMap::new();
|
||||
for mut entry in std::iter::once(std::mem::take(first)).chain(others) {
|
||||
let meta = match entry.cached.take() {
|
||||
Some(meta) => meta,
|
||||
None => FileMeta::load(&entry.metadata).map_err(|_| Error::FileCorrupt)?,
|
||||
};
|
||||
if meta.versions.is_empty() {
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
for version in meta.versions {
|
||||
let parsed = version.parse_version_meta().map_err(|_| Error::FileCorrupt)?;
|
||||
if !parsed.valid() || parsed.version_type != version.header.version_type {
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
let fi = parsed.into_fileinfo("", &name, true).map_err(|_| Error::FileCorrupt)?;
|
||||
let version_id = fi.version_id.filter(|id| !id.is_nil());
|
||||
if version_id != version.header.version_id.filter(|id| !id.is_nil())
|
||||
|| fi.mod_time != version.header.mod_time
|
||||
|| fi.tier_free_version() != version.header.free_version()
|
||||
{
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
let info = ObjectInfo::from_file_info(&fi, "", &name, true);
|
||||
let identity = (version_id, version.header.free_version());
|
||||
match versions.entry(identity) {
|
||||
std::collections::hash_map::Entry::Vacant(slot) => {
|
||||
slot.insert((version, info));
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(mut slot) => {
|
||||
let (previous, previous_info) = slot.get();
|
||||
// Suspended and unversioned writes replace the one null
|
||||
// slot. Distinct UUID versions never supersede each other.
|
||||
if version_id.is_none() && info.mod_time != previous_info.mod_time {
|
||||
if info.mod_time > previous_info.mod_time {
|
||||
slot.insert((version, info));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let equivalent = if info.delete_marker && previous_info.delete_marker {
|
||||
super::object::is_equivalent_data_movement_delete_marker(&info, previous_info)
|
||||
} else {
|
||||
crate::data_movement::is_equivalent_data_movement_object_identity(&info, previous_info, true, true)
|
||||
};
|
||||
if !equivalent {
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
// Equivalent migrated copies can have different coding or
|
||||
// data directories. Choose a stable representation without
|
||||
// making input order part of the S3 version order.
|
||||
if version.meta < previous.meta {
|
||||
slot.insert((version, info));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut live_remote_references = HashMap::<String, HashMap<String, Vec<ObjectInfo>>>::new();
|
||||
for (_, info) in versions.values() {
|
||||
if !info.transitioned_object.free_version && info.transitioned_object.status == rustfs_filemeta::TRANSITION_COMPLETE {
|
||||
live_remote_references
|
||||
.entry(info.transitioned_object.tier.clone())
|
||||
.or_default()
|
||||
.entry(info.transitioned_object.name.clone())
|
||||
.or_default()
|
||||
.push(info.clone());
|
||||
}
|
||||
}
|
||||
// Keep cleanup durable in its source xl.meta, but do not expose it to a
|
||||
// merged recovery walk while another physical pool still owns the tuple.
|
||||
versions.retain(|_, (_, info)| {
|
||||
!info.transitioned_object.free_version
|
||||
|| !live_remote_references
|
||||
.get(info.transitioned_object.tier.as_str())
|
||||
.and_then(|by_name| by_name.get(info.transitioned_object.name.as_str()))
|
||||
.is_some_and(|candidates| {
|
||||
candidates
|
||||
.iter()
|
||||
.any(|live| free_version_remote_tuple_matches(info, live).unwrap_or(false))
|
||||
})
|
||||
});
|
||||
let mut merged = FileMeta::new();
|
||||
merged.versions = versions.into_values().map(|(version, _)| version).collect();
|
||||
merged.versions.sort_by(|a, b| {
|
||||
if a.header.sorts_before(&b.header) {
|
||||
std::cmp::Ordering::Less
|
||||
} else if b.header.sorts_before(&a.header) {
|
||||
std::cmp::Ordering::Greater
|
||||
} else {
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
});
|
||||
let metadata = merged.marshal_msg()?;
|
||||
*first = MetaCacheEntry {
|
||||
name,
|
||||
metadata,
|
||||
cached: Some(merged),
|
||||
reusable: true,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `rx` is private to the producers. Cancelling it on a merge error must not
|
||||
/// cancel the request token, which would suppress that error at the API edge.
|
||||
fn spawn_listing_merge(
|
||||
rx: CancellationToken,
|
||||
inputs: Vec<Receiver<MetaCacheEntry>>,
|
||||
sender: Sender<MetaCacheEntry>,
|
||||
) -> JoinHandle<Result<()>> {
|
||||
tokio::spawn(
|
||||
async move {
|
||||
let result = merge_entry_channels(rx.clone(), inputs, sender, 1).await;
|
||||
if result.is_err() {
|
||||
rx.cancel();
|
||||
}
|
||||
result
|
||||
}
|
||||
.instrument(tracing::Span::current()),
|
||||
)
|
||||
}
|
||||
|
||||
async fn merge_entry_channels(
|
||||
rx: CancellationToken,
|
||||
in_channels: Vec<Receiver<MetaCacheEntry>>,
|
||||
@@ -5248,7 +5133,6 @@ async fn merge_entry_channels(
|
||||
// after anything greater has been emitted).
|
||||
let mut last_emitted = String::new();
|
||||
let mut group: Vec<Box<MergeHead>> = Vec::new();
|
||||
let mut object_entries: Vec<MetaCacheEntry> = Vec::new();
|
||||
let mut refill: Vec<usize> = Vec::with_capacity(in_channels.len());
|
||||
|
||||
while let Some(Reverse(first)) = heap.pop() {
|
||||
@@ -5266,7 +5150,7 @@ async fn merge_entry_channels(
|
||||
// Resolve the same-name group to one winner (heads arrive in ascending
|
||||
// channel order):
|
||||
// - prefix dir vs prefix dir: the first (lowest channel) wins;
|
||||
// - object vs object: merge the independently resolved version stacks;
|
||||
// - object vs object: the later channel wins (legacy authority rule);
|
||||
// - object vs prefix dir: same-name means both end with the separator,
|
||||
// i.e. the object is an explicit "directory marker" for the same S3
|
||||
// key — it shadows the prefix dir so the key does not surface as
|
||||
@@ -5284,27 +5168,11 @@ async fn merge_entry_channels(
|
||||
if dir_winner.is_none() {
|
||||
dir_winner = Some(head);
|
||||
}
|
||||
} else if let Some(winner) = object_winner.as_ref() {
|
||||
// Key-only candidates carry no version metadata and cannot
|
||||
// replace a resolved stack or contribute a quorum vote.
|
||||
if head.entry.is_object() {
|
||||
if winner.entry.is_object() {
|
||||
object_entries.push(head.entry);
|
||||
} else {
|
||||
object_winner = Some(head);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
object_winner = Some(head);
|
||||
}
|
||||
}
|
||||
|
||||
if !object_entries.is_empty()
|
||||
&& let Some(winner) = object_winner.as_mut()
|
||||
{
|
||||
merge_object_entry_versions(&mut winner.entry, object_entries.drain(..))?;
|
||||
}
|
||||
|
||||
if let Some(head) = object_winner.or(dir_winner)
|
||||
&& head.entry.name != last_emitted
|
||||
{
|
||||
@@ -5737,7 +5605,6 @@ impl Sets {
|
||||
"sets list_merged started"
|
||||
);
|
||||
|
||||
let rx = rx.child_token();
|
||||
let mut futures = Vec::new();
|
||||
let mut inputs = Vec::new();
|
||||
|
||||
@@ -5750,10 +5617,16 @@ impl Sets {
|
||||
futures.push(async move { set.list_path(rx_clone, opts, send).await });
|
||||
}
|
||||
|
||||
let merge_task = spawn_listing_merge(rx, inputs, sender);
|
||||
tokio::spawn(
|
||||
async move {
|
||||
if let Err(err) = merge_entry_channels(rx, inputs, sender.clone(), 1).await {
|
||||
error!("merge_entry_channels err {:?}", err);
|
||||
}
|
||||
}
|
||||
.instrument(tracing::Span::current()),
|
||||
);
|
||||
|
||||
let results = join_all(futures).await;
|
||||
merge_task.await.map_err(Error::from)??;
|
||||
let mut all_at_eof = true;
|
||||
let mut errs = Vec::new();
|
||||
for result in results {
|
||||
@@ -5804,7 +5677,6 @@ impl Sets {
|
||||
) -> Result<()> {
|
||||
check_list_objs_args(bucket, prefix, &None)?;
|
||||
|
||||
let rx = rx.child_token();
|
||||
let mut futures = Vec::new();
|
||||
let mut inputs = Vec::new();
|
||||
|
||||
@@ -6135,11 +6007,17 @@ impl Sets {
|
||||
.instrument(tracing::Span::current()),
|
||||
);
|
||||
|
||||
let merge_task = spawn_listing_merge(rx, inputs, merge_tx);
|
||||
tokio::spawn(
|
||||
async move {
|
||||
if let Err(err) = merge_entry_channels(rx, inputs, merge_tx, 1).await {
|
||||
error!("merge_entry_channels err {:?}", err)
|
||||
}
|
||||
}
|
||||
.instrument(tracing::Span::current()),
|
||||
);
|
||||
|
||||
let walk_started = std::time::Instant::now();
|
||||
let walk_results = join_all(futures).await;
|
||||
merge_task.await.map_err(Error::from)??;
|
||||
let mut errs = Vec::new();
|
||||
for walk_result in walk_results {
|
||||
match walk_result {
|
||||
@@ -7138,7 +7016,7 @@ mod test {
|
||||
};
|
||||
use crate::cache_value::metacache_set::{FallbackClaimTracker, TestReaderBehavior, list_path_raw};
|
||||
use crate::disk::{DiskAPI, DiskOption, STORAGE_FORMAT_FILE, endpoint::Endpoint, error::DiskError, new_disk};
|
||||
use crate::error::{Result, StorageError};
|
||||
use crate::error::StorageError;
|
||||
use crate::object_api::ObjectInfo;
|
||||
use rustfs_filemeta::{
|
||||
FileInfo, FileMeta, FileMetaVersion, MetaCacheEntries, MetaCacheEntriesSorted, MetaCacheEntry, MetaDeleteMarker,
|
||||
@@ -7460,47 +7338,6 @@ mod test {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_transitioned_meta_entry(name: &str, remote_object: &str, delete_source: bool) -> MetaCacheEntry {
|
||||
let mut source = FileInfo::new(name, 2, 2);
|
||||
source.volume = "bucket".to_string();
|
||||
source.name = name.to_string();
|
||||
source.version_id = Some(Uuid::from_u128(1));
|
||||
source.versioned = true;
|
||||
source.size = 1;
|
||||
source.mod_time = Some(time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp"));
|
||||
source.transition_status = rustfs_filemeta::TRANSITION_COMPLETE.to_string();
|
||||
source.transition_tier = "WARM".to_string();
|
||||
source.transitioned_objname = remote_object.to_string();
|
||||
source.transition_version = Some("remote-version".to_string());
|
||||
source.transition_version_state = rustfs_filemeta::TransitionVersionState::Exact;
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut source.metadata,
|
||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
"00".repeat(32),
|
||||
);
|
||||
|
||||
let mut meta = FileMeta::new();
|
||||
meta.add_version(source.clone())
|
||||
.expect("test metadata should accept transitioned source");
|
||||
if delete_source {
|
||||
let mut delete = FileInfo {
|
||||
name: name.to_string(),
|
||||
version_id: source.version_id,
|
||||
..Default::default()
|
||||
};
|
||||
delete.set_tier_free_version_id(&Uuid::from_u128(2).to_string());
|
||||
meta.delete_version(&delete)
|
||||
.expect("transitioned delete should create a free-version owner");
|
||||
}
|
||||
let metadata = meta.marshal_msg().expect("test transitioned metadata should marshal");
|
||||
MetaCacheEntry {
|
||||
name: name.to_string(),
|
||||
metadata,
|
||||
cached: Some(meta),
|
||||
reusable: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_object_with_delete_marker_meta_entry(
|
||||
name: &str,
|
||||
object_mod_time: time::OffsetDateTime,
|
||||
@@ -10562,7 +10399,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_entry_channels_preserves_cross_pool_delete_marker_versions() {
|
||||
async fn merge_entry_channels_documents_candidate_metadata_authority_risk() {
|
||||
let (tx_a, rx_a) = mpsc::channel(4);
|
||||
let (tx_b, rx_b) = mpsc::channel(4);
|
||||
let (tx_c, rx_c) = mpsc::channel(4);
|
||||
@@ -10591,13 +10428,9 @@ mod test {
|
||||
.expect("merged entry should be present");
|
||||
assert_eq!(merged.name, "obj-a");
|
||||
assert!(
|
||||
merged.is_latest_delete_marker(),
|
||||
"a newer marker must remain current across independently resolved pools"
|
||||
!merged.is_latest_delete_marker(),
|
||||
"current merge consumes candidate metadata bytes; future index-backed strong modes must live-verify metadata instead"
|
||||
);
|
||||
let versions = merged.file_info_versions("bucket").expect("merged versions should decode");
|
||||
assert_eq!(versions.versions.len(), 2, "retain the historical object and deduplicate the marker");
|
||||
assert!(versions.versions[0].deleted && versions.versions[0].is_latest);
|
||||
assert!(!versions.versions[1].deleted && !versions.versions[1].is_latest);
|
||||
assert!(
|
||||
matches!(timeout(Duration::from_secs(1), out_rx.recv()).await, Ok(None)),
|
||||
"merge should not emit a duplicate entry for the same key"
|
||||
@@ -10609,276 +10442,6 @@ mod test {
|
||||
.expect("merge task should succeed");
|
||||
}
|
||||
|
||||
fn rewrite_test_version(mut entry: MetaCacheEntry, change: impl FnOnce(&mut FileMetaVersion)) -> MetaCacheEntry {
|
||||
let meta = entry.cached.as_mut().expect("test metadata should be decoded");
|
||||
assert_eq!(meta.versions.len(), 1);
|
||||
let mut version = meta.versions[0].parse_version_meta().expect("test version should decode");
|
||||
change(&mut version);
|
||||
meta.versions[0] = version.try_into().expect("test version should encode");
|
||||
entry.metadata = meta.marshal_msg().expect("test metadata should encode");
|
||||
entry
|
||||
}
|
||||
|
||||
async fn merge_test_object_entries(entries: Vec<MetaCacheEntry>) -> Result<MetaCacheEntry> {
|
||||
let mut inputs = Vec::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
let (sender, receiver) = mpsc::channel(1);
|
||||
sender.send(entry).await.expect("fixture entry should queue");
|
||||
inputs.push(receiver);
|
||||
}
|
||||
let (sender, mut receiver) = mpsc::channel(1);
|
||||
let task = tokio::spawn(merge_entry_channels(CancellationToken::new(), inputs, sender, 1));
|
||||
let entry = receiver.recv().await;
|
||||
task.await.expect("merge must not panic")?;
|
||||
Ok(entry.expect("a valid same-key group must produce an entry"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_entry_channels_orders_complete_histories_independently_of_pool_order() {
|
||||
let time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||
let first = test_object_meta_entry_with_erasure_versions("key", &[(time, "first", 4, 2)]);
|
||||
let second = rewrite_test_version(
|
||||
test_object_meta_entry_with_erasure_versions("key", &[(time, "second", 4, 2)]),
|
||||
|version| version.object.as_mut().expect("object version").version_id = Some(Uuid::from_u128(2)),
|
||||
);
|
||||
let marker = test_delete_marker_meta_entry("key", time + time::Duration::seconds(1));
|
||||
let inputs = [first, second, marker];
|
||||
let mut expected = None;
|
||||
for order in [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
|
||||
let entry = merge_test_object_entries(order.map(|index| inputs[index].clone()).to_vec())
|
||||
.await
|
||||
.expect("disjoint version chains should merge");
|
||||
let versions = entry.file_info_versions("bucket").expect("merged versions should decode");
|
||||
assert_eq!(versions.versions.len(), 3);
|
||||
assert!(versions.versions[0].deleted && versions.versions[0].is_latest);
|
||||
assert!(
|
||||
versions.versions[1..]
|
||||
.iter()
|
||||
.all(|version| !version.deleted && !version.is_latest)
|
||||
);
|
||||
assert!(versions.versions.iter().all(|version| version.num_versions == 3));
|
||||
let identities = versions.versions.iter().map(|version| version.version_id).collect::<Vec<_>>();
|
||||
assert!(identities.contains(&Some(Uuid::from_u128(1))));
|
||||
assert!(identities.contains(&Some(Uuid::from_u128(2))));
|
||||
if let Some(expected) = &expected {
|
||||
assert_eq!(&identities, expected, "equal-time versions must have stable pagination order");
|
||||
} else {
|
||||
expected = Some(identities);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_entry_channels_key_only_candidates_do_not_override_version_metadata() {
|
||||
let time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||
let marker = test_delete_marker_meta_entry("key", time);
|
||||
for entries in [
|
||||
vec![test_meta_entry("key"), marker.clone()],
|
||||
vec![marker.clone(), test_meta_entry("key")],
|
||||
] {
|
||||
let mut merged = merge_test_object_entries(entries)
|
||||
.await
|
||||
.expect("merge a name with resolved metadata");
|
||||
assert!(merged.is_latest_delete_marker());
|
||||
assert_eq!(merged.file_info_versions("bucket").expect("decode marker").versions.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_entry_channels_accepts_equivalent_migrated_coding_and_data_dirs() {
|
||||
let time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||
let first = test_object_meta_entry_with_erasure_versions("key", &[(time, "same-etag", 4, 2)]);
|
||||
let second = rewrite_test_version(
|
||||
test_object_meta_entry_with_erasure_versions("key", &[(time, "same-etag", 6, 2)]),
|
||||
|version| version.object.as_mut().expect("object version").data_dir = Some(Uuid::from_u128(42)),
|
||||
);
|
||||
let forward = merge_test_object_entries(vec![first.clone(), second.clone()])
|
||||
.await
|
||||
.expect("valid migration copies");
|
||||
let reverse = merge_test_object_entries(vec![second, first])
|
||||
.await
|
||||
.expect("reversed migration copies");
|
||||
assert_eq!(forward.metadata, reverse.metadata, "representation must not depend on channel order");
|
||||
let versions = forward.file_info_versions("bucket").expect("merged metadata should decode");
|
||||
assert_eq!(versions.versions.len(), 1);
|
||||
assert_eq!(versions.versions[0].metadata.get("etag").map(String::as_str), Some("same-etag"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_entry_channels_defers_free_version_while_same_remote_source_is_live() {
|
||||
let live = test_transitioned_meta_entry("key", "remote/shared", false);
|
||||
let free = test_transitioned_meta_entry("key", "remote/shared", true);
|
||||
for inputs in [vec![live.clone(), free.clone()], vec![free.clone(), live.clone()]] {
|
||||
let merged = merge_test_object_entries(inputs)
|
||||
.await
|
||||
.expect("same remote source and cleanup owner should merge");
|
||||
let versions = merged
|
||||
.file_info_versions_with_free_versions("bucket")
|
||||
.expect("merged transition history should decode");
|
||||
assert_eq!(versions.versions.len(), 1);
|
||||
assert!(versions.free_versions.is_empty(), "a live remote reference must defer cleanup discovery");
|
||||
}
|
||||
|
||||
let unrelated = test_transitioned_meta_entry("key", "remote/other", false);
|
||||
let merged = merge_test_object_entries(vec![free, unrelated])
|
||||
.await
|
||||
.expect("unrelated remote references should merge");
|
||||
let versions = merged
|
||||
.file_info_versions_with_free_versions("bucket")
|
||||
.expect("merged transition history should decode");
|
||||
assert_eq!(versions.versions.len(), 1);
|
||||
assert_eq!(versions.free_versions.len(), 1, "an unrelated source must not suppress cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_entry_channels_rejects_conflicting_version_identity_and_metadata() {
|
||||
let time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||
let original = test_object_meta_entry_with_erasure_versions("key", &[(time, "original", 4, 2)]);
|
||||
for key in [
|
||||
"etag",
|
||||
"x-amz-tagging",
|
||||
"x-amz-object-lock-mode",
|
||||
"x-amz-object-lock-retain-until-date",
|
||||
] {
|
||||
let changed = rewrite_test_version(original.clone(), |version| {
|
||||
version
|
||||
.object
|
||||
.as_mut()
|
||||
.expect("object version")
|
||||
.meta_user
|
||||
.insert(key.to_string(), "changed".to_string());
|
||||
});
|
||||
for pair in [[original.clone(), changed.clone()], [changed, original.clone()]] {
|
||||
let err = merge_test_object_entries(pair.to_vec())
|
||||
.await
|
||||
.expect_err("conflicting copies must fail");
|
||||
assert_eq!(err, StorageError::FileCorrupt, "conflict in {key} must not become arbitrary metadata");
|
||||
}
|
||||
}
|
||||
let marker = rewrite_test_version(test_delete_marker_meta_entry("key", time), |version| {
|
||||
version.delete_marker.as_mut().expect("delete marker").version_id = Some(Uuid::from_u128(1));
|
||||
});
|
||||
assert_eq!(
|
||||
merge_test_object_entries(vec![original, marker])
|
||||
.await
|
||||
.expect_err("UUID type conflict"),
|
||||
StorageError::FileCorrupt
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_entry_channels_reconciles_null_overwrite_without_losing_uuid_history() {
|
||||
let time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||
let history = test_object_meta_entry_with_erasure_versions("key", &[(time, "history", 4, 2)]);
|
||||
let old_null = rewrite_test_version(history.clone(), |version| {
|
||||
version.object.as_mut().expect("null object").version_id = None;
|
||||
});
|
||||
let marker = rewrite_test_version(test_delete_marker_meta_entry("key", time + time::Duration::seconds(1)), |version| {
|
||||
version.delete_marker.as_mut().expect("null marker").version_id = Some(Uuid::nil());
|
||||
});
|
||||
for inputs in [
|
||||
vec![old_null.clone(), marker.clone(), history.clone()],
|
||||
vec![history, marker, old_null],
|
||||
] {
|
||||
let entry = merge_test_object_entries(inputs)
|
||||
.await
|
||||
.expect("new null slot should replace old null slot");
|
||||
let versions = entry.file_info_versions("bucket").expect("null versions should decode");
|
||||
assert_eq!(versions.versions.len(), 2);
|
||||
assert!(versions.versions[0].deleted && versions.versions[0].is_latest);
|
||||
assert!(versions.versions[0].version_id.is_none_or(|id| id.is_nil()));
|
||||
assert_eq!(versions.versions[1].version_id, Some(Uuid::from_u128(1)));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_entry_channels_rejects_corrupt_version_headers_and_empty_stacks() {
|
||||
let time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||
let original = test_object_meta_entry_with_erasure_versions("key", &[(time, "etag", 4, 2)]);
|
||||
for empty in [false, true] {
|
||||
let mut corrupt = original.clone();
|
||||
let meta = corrupt.cached.as_mut().expect("fixture metadata");
|
||||
if empty {
|
||||
meta.versions.clear();
|
||||
} else {
|
||||
meta.versions[0].header.version_id = Some(Uuid::from_u128(99));
|
||||
}
|
||||
corrupt.metadata = meta.marshal_msg().expect("encode corrupt fixture");
|
||||
assert_eq!(
|
||||
merge_test_object_entries(vec![original.clone(), corrupt])
|
||||
.await
|
||||
.expect_err("corrupt candidate must fail"),
|
||||
StorageError::FileCorrupt
|
||||
);
|
||||
}
|
||||
let mut malformed = original.clone();
|
||||
malformed.cached = None;
|
||||
malformed.metadata = vec![0xff];
|
||||
assert_eq!(
|
||||
merge_test_object_entries(vec![original, malformed])
|
||||
.await
|
||||
.expect_err("malformed metadata must fail"),
|
||||
StorageError::FileCorrupt
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_entry_channels_does_not_combine_subquorum_markers_across_erasure_sets() {
|
||||
let time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||
let old = test_object_meta_entry_with_erasure_versions("key", &[(time, "history", 4, 2)]);
|
||||
let marked = test_object_with_delete_marker_meta_entry("key", time, time + time::Duration::seconds(1));
|
||||
let mut inputs = Vec::new();
|
||||
for marker_copies in [1, 2] {
|
||||
let resolver = list_metadata_resolution_params("bucket".to_string(), 3, 3, true, 0);
|
||||
let copies = (0..3)
|
||||
.map(|index| Some(if index < marker_copies { marked.clone() } else { old.clone() }))
|
||||
.collect();
|
||||
let entry = resolve_listing_entries(MetaCacheEntries(copies), resolver, false)
|
||||
.expect("each set independently retains its quorum-backed history");
|
||||
inputs.push(entry);
|
||||
}
|
||||
let merged = merge_test_object_entries(inputs).await.expect("merge resolved histories");
|
||||
let versions = merged.file_info_versions("bucket").expect("decode merged history");
|
||||
assert_eq!(
|
||||
versions.versions.len(),
|
||||
1,
|
||||
"three marker copies across two EC domains do not form a quorum"
|
||||
);
|
||||
assert!(!versions.versions[0].deleted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_merge_preserves_error_after_partial_output_without_cancelling_request() {
|
||||
let time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||
let (first_tx, first_rx) = mpsc::channel(2);
|
||||
let (second_tx, second_rx) = mpsc::channel(1);
|
||||
first_tx.send(test_meta_entry("a/")).await.expect("queue preceding prefix");
|
||||
first_tx
|
||||
.send(test_object_meta_entry_with_erasure_versions("b", &[(time, "one", 4, 2)]))
|
||||
.await
|
||||
.expect("queue first copy");
|
||||
second_tx
|
||||
.send(test_object_meta_entry_with_erasure_versions("b", &[(time, "two", 4, 2)]))
|
||||
.await
|
||||
.expect("queue conflicting copy");
|
||||
drop(first_tx);
|
||||
drop(second_tx);
|
||||
let request = CancellationToken::new();
|
||||
let workers = request.child_token();
|
||||
let (sender, mut receiver) = mpsc::channel(1);
|
||||
let task = super::spawn_listing_merge(workers.clone(), vec![first_rx, second_rx], sender);
|
||||
assert_eq!(receiver.recv().await.expect("preceding result should arrive").name, "a/");
|
||||
assert!(receiver.recv().await.is_none());
|
||||
assert_eq!(
|
||||
task.await
|
||||
.expect("merge task must not panic")
|
||||
.expect_err("conflict must propagate"),
|
||||
StorageError::FileCorrupt
|
||||
);
|
||||
assert!(workers.is_cancelled(), "failed merge must stop the disk producers");
|
||||
assert!(!request.is_cancelled(), "the API must still observe the merge error");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_entry_channels_handles_single_channel() {
|
||||
let (tx, rx) = mpsc::channel(4);
|
||||
|
||||
@@ -859,9 +859,78 @@ async fn delete_recursive_prefix_with_tier_delete_journal(
|
||||
}
|
||||
}
|
||||
}
|
||||
// A trailing slash selects a directory, not the object at its parent key.
|
||||
// Raw filesystem recursion would also remove that object's metadata and
|
||||
// data. Preserve it by purging the selected keys individually when they
|
||||
// share this physical directory. The bucket write lock covers both scans.
|
||||
if object.ends_with('/') && !is_meta_bucketname(bucket) {
|
||||
let parent = object.strip_suffix('/').unwrap_or(object);
|
||||
for pool in &store.pools {
|
||||
for set in &pool.disk_set {
|
||||
let page = set
|
||||
.clone()
|
||||
.inner_list_object_versions_for_recursive_delete(bucket, parent, None, None, 1)
|
||||
.await?;
|
||||
if page.objects.iter().any(|info| info.name == parent) {
|
||||
return delete_directory_keys_with_tier_delete_journal(store, bucket, object, opts, tier_journal_api).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
delete_prefix_with_tier_delete_journal(store, bucket, object, opts, tier_journal_api).await
|
||||
}
|
||||
|
||||
async fn delete_directory_keys_with_tier_delete_journal(
|
||||
store: &ECStore,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
opts: &ObjectOptions,
|
||||
tier_journal_api: Option<&Arc<ECStore>>,
|
||||
) -> Result<()> {
|
||||
for pool in &store.pools {
|
||||
for set in &pool.disk_set {
|
||||
let mut previous_keys = std::collections::BTreeSet::new();
|
||||
loop {
|
||||
// Restart after each bounded batch: its version markers have
|
||||
// been deleted, and the bucket write lock excludes new keys.
|
||||
let page = set
|
||||
.clone()
|
||||
.inner_list_object_versions_for_recursive_delete(
|
||||
bucket,
|
||||
prefix,
|
||||
None,
|
||||
None,
|
||||
RECURSIVE_DELETE_VERSION_SCAN_PAGE_SIZE,
|
||||
)
|
||||
.await?;
|
||||
let keys = page
|
||||
.objects
|
||||
.into_iter()
|
||||
.map(|info| info.name)
|
||||
.filter(|key| key.starts_with(prefix))
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
if keys.is_empty() {
|
||||
break;
|
||||
}
|
||||
if keys == previous_keys {
|
||||
return Err(Error::other("directory deletion did not advance"));
|
||||
}
|
||||
for key in &keys {
|
||||
let encoded_key = encode_dir_object(key);
|
||||
let mut exact_opts = opts.clone();
|
||||
exact_opts.delete_prefix_object = true;
|
||||
let _guard = store
|
||||
.acquire_object_write_lock_if_needed("delete_object", bucket, &encoded_key, &mut exact_opts)
|
||||
.await?;
|
||||
delete_prefix_with_tier_delete_journal(store, bucket, &encoded_key, &exact_opts, tier_journal_api).await?;
|
||||
}
|
||||
previous_keys = keys;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A GET whose object identity has been resolved while its namespace read lock
|
||||
/// remains held, but whose body reader has not been constructed yet.
|
||||
///
|
||||
@@ -2013,7 +2082,7 @@ fn effective_object_actual_size(info: &ObjectInfo) -> Option<i64> {
|
||||
info.get_actual_size().ok()
|
||||
}
|
||||
|
||||
pub(super) fn is_equivalent_data_movement_delete_marker(source: &ObjectInfo, target: &ObjectInfo) -> bool {
|
||||
fn is_equivalent_data_movement_delete_marker(source: &ObjectInfo, target: &ObjectInfo) -> bool {
|
||||
is_data_movement_delete_marker(source)
|
||||
&& is_data_movement_delete_marker(target)
|
||||
&& source.version_id == target.version_id
|
||||
@@ -4682,13 +4751,14 @@ impl ECStore {
|
||||
return Err(Error::other("lifecycle delete-all requires namespace locking"));
|
||||
}
|
||||
|
||||
let _bucket_lifecycle_guard = if is_meta_bucketname(bucket) {
|
||||
None
|
||||
} else if opts.delete_prefix {
|
||||
Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?)
|
||||
} else {
|
||||
Some(self.acquire_bucket_lifecycle_read_lock(bucket).await?)
|
||||
};
|
||||
let _bucket_lifecycle_guard =
|
||||
if is_meta_bucketname(bucket) || (opts.delete_prefix && opts.bucket_lifecycle_lock_fence.is_some()) {
|
||||
None
|
||||
} else if opts.delete_prefix {
|
||||
Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?)
|
||||
} else {
|
||||
Some(self.acquire_bucket_lifecycle_read_lock(bucket).await?)
|
||||
};
|
||||
let object = if opts.delete_prefix && !opts.delete_prefix_object {
|
||||
object.to_owned()
|
||||
} else {
|
||||
@@ -4791,13 +4861,7 @@ impl ECStore {
|
||||
return Ok(ObjectInfo::default());
|
||||
}
|
||||
|
||||
let creates_latest_marker = should_create_delete_marker_for_missing_object(&opts);
|
||||
let mut gopts = delete_pool_lookup_opts(&opts, true);
|
||||
if creates_latest_marker {
|
||||
// An unwritable source still owns its current version. Hiding it
|
||||
// during lookup would turn a rejected write into a new-pool marker.
|
||||
gopts.skip_rebalancing = false;
|
||||
}
|
||||
let gopts = delete_pool_lookup_opts(&opts, true);
|
||||
|
||||
if opts.data_movement {
|
||||
let existing_pool_info = self.get_pool_info_existing_with_opts(bucket, object, &gopts).await;
|
||||
@@ -4923,12 +4987,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
// Determine which pool contains it
|
||||
let existing_pool_info = if creates_latest_marker {
|
||||
self.get_pool_info_for_delete_marker(bucket, object, &gopts).await
|
||||
} else {
|
||||
self.get_pool_info_existing_with_opts(bucket, object, &gopts).await
|
||||
};
|
||||
let (mut pinfo, errs) = match existing_pool_info {
|
||||
let (mut pinfo, errs) = match self.get_pool_info_existing_with_opts(bucket, object, &gopts).await {
|
||||
Ok(res) => res,
|
||||
Err(err) if is_err_read_quorum(&err) => return Err(StorageError::ErasureWriteQuorum),
|
||||
Err(err) if is_err_object_not_found(&err) && should_create_delete_marker_for_missing_object(&opts) => {
|
||||
@@ -4965,18 +5024,7 @@ impl ECStore {
|
||||
}
|
||||
};
|
||||
|
||||
if creates_latest_marker && self.is_suspended(pinfo.index).await {
|
||||
let has_active_reservation = self
|
||||
.pool_meta
|
||||
.read()
|
||||
.await
|
||||
.has_active_decommission_capacity_reservation(pinfo.index);
|
||||
if has_active_reservation {
|
||||
pinfo.index = self.get_pool_idx_no_lock(bucket, object, 0).await?;
|
||||
}
|
||||
}
|
||||
|
||||
if pinfo.object_info.delete_marker && opts.version_id.is_none() && !creates_latest_marker {
|
||||
if pinfo.object_info.delete_marker && opts.version_id.is_none() {
|
||||
pinfo.object_info.name = decode_dir_object(object);
|
||||
return Ok(pinfo.object_info);
|
||||
}
|
||||
@@ -4998,13 +5046,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
for pool in self.pools.iter() {
|
||||
if creates_latest_marker && pool.pool_idx != pinfo.index {
|
||||
continue;
|
||||
}
|
||||
if self.is_suspended(pool.pool_idx).await || self.is_pool_rebalancing(pool.pool_idx).await {
|
||||
if creates_latest_marker {
|
||||
return Err(StorageError::SlowDown);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -5029,7 +5071,7 @@ impl ECStore {
|
||||
return Ok(obj);
|
||||
}
|
||||
Err(err) => {
|
||||
if creates_latest_marker || (!is_err_object_not_found(&err) && !is_err_version_not_found(&err)) {
|
||||
if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
@@ -8319,546 +8361,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn multipool_version_test_store(bucket: &str) -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let (mut dirs, first_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
|
||||
let (second_dirs, second_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
|
||||
dirs.extend(second_dirs);
|
||||
let store = Arc::new(new_prepared_reader_test_store_with_ctx(&[first_set, second_set], ctx).await);
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
store
|
||||
.handle_make_bucket(
|
||||
bucket,
|
||||
&MakeBucketOptions {
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create the versioned bucket in both pools");
|
||||
(dirs, store)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipool_delete_marker_stays_with_existing_versions() {
|
||||
let bucket = "multipool-marker-routing";
|
||||
let object = "history.bin";
|
||||
let (_dirs, store) = multipool_version_test_store(bucket).await;
|
||||
|
||||
let mut expected_versions = Vec::new();
|
||||
for value in 1..=3_u8 {
|
||||
let written = store.pools[1]
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(vec![value; 4097]),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
user_defined: HashMap::from([
|
||||
(rustfs_utils::http::AMZ_OBJECT_TAGGING.to_string(), format!("generation={value}")),
|
||||
("x-amz-meta-generation".to_string(), value.to_string()),
|
||||
]),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write a historical version deterministically to pool 1");
|
||||
expected_versions.push(written.version_id.expect("versioned PUT must acknowledge a UUID"));
|
||||
}
|
||||
let marker = store
|
||||
.delete_object(
|
||||
bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("delete the current version");
|
||||
assert!(marker.delete_marker);
|
||||
let marker_id = marker.version_id.expect("DELETE must acknowledge a marker UUID");
|
||||
assert!(!expected_versions.contains(&marker_id));
|
||||
|
||||
let local = store.pools[1]
|
||||
.clone()
|
||||
.inner_list_object_versions(bucket, object, None, None, None, 10)
|
||||
.await
|
||||
.expect("list the object-owning pool");
|
||||
assert_eq!(local.objects.len(), 4, "the marker must be committed beside the three existing versions");
|
||||
assert_eq!(local.objects[0].version_id, Some(marker_id));
|
||||
assert!(local.objects[0].delete_marker && local.objects[0].is_latest);
|
||||
let versions = store
|
||||
.clone()
|
||||
.inner_list_object_versions(bucket, object, None, None, None, 10)
|
||||
.await
|
||||
.expect("list all pools");
|
||||
assert_eq!(versions.objects.len(), 4);
|
||||
assert_eq!(versions.objects.iter().filter(|version| version.is_latest).count(), 1);
|
||||
for (index, version_id) in expected_versions.iter().copied().enumerate() {
|
||||
assert!(
|
||||
versions
|
||||
.objects
|
||||
.iter()
|
||||
.any(|version| version.version_id == Some(version_id) && !version.is_latest)
|
||||
);
|
||||
let mut reader = store
|
||||
.handle_get_object_reader(
|
||||
bucket,
|
||||
object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
version_id: Some(version_id.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("historical version should remain readable");
|
||||
let mut payload = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut payload)
|
||||
.await
|
||||
.expect("read all historical bytes");
|
||||
let value = u8::try_from(index + 1).expect("fixture generation fits u8");
|
||||
assert_eq!(payload, vec![value; 4097]);
|
||||
let listed = versions
|
||||
.objects
|
||||
.iter()
|
||||
.find(|version| version.version_id == Some(version_id))
|
||||
.expect("listed historical version");
|
||||
assert_eq!(listed.user_tags.as_str(), format!("generation={value}"));
|
||||
assert_eq!(listed.user_defined.get("x-amz-meta-generation"), Some(&value.to_string()));
|
||||
}
|
||||
|
||||
let repeated = store
|
||||
.delete_object(
|
||||
bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("repeat simple DELETE");
|
||||
assert!(repeated.delete_marker);
|
||||
assert_ne!(
|
||||
repeated.version_id,
|
||||
Some(marker_id),
|
||||
"each enabled-versioning DELETE creates a new marker"
|
||||
);
|
||||
store
|
||||
.delete_object(
|
||||
bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: repeated.version_id.map(|id| id.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("remove the newest marker by identity");
|
||||
store
|
||||
.delete_object(
|
||||
bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(marker_id.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("remove the original marker by identity");
|
||||
let current = store
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("previous version becomes current");
|
||||
assert_eq!(current.version_id, expected_versions.last().copied());
|
||||
assert!(!current.delete_marker);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipool_existing_split_history_lists_and_paginates_without_metadata_writes() {
|
||||
for marker_pool in 0..2 {
|
||||
let bucket = format!("multipool-split-history-{marker_pool}");
|
||||
let object = "history.bin";
|
||||
let (dirs, store) = multipool_version_test_store(&bucket).await;
|
||||
let mut expected = Vec::new();
|
||||
for value in 1..=3_u8 {
|
||||
let version = store.pools[1 - marker_pool]
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(vec![value; 4097]),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed history through the owning pool's normal write path");
|
||||
expected.push(version.version_id);
|
||||
}
|
||||
let marker = store.pools[marker_pool]
|
||||
.delete_object(
|
||||
&bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("reproduce the previously committed split marker with a normal pool DELETE");
|
||||
assert!(marker.delete_marker);
|
||||
expected.push(marker.version_id);
|
||||
expected.reverse();
|
||||
let mut before = Vec::new();
|
||||
for dir in &dirs {
|
||||
before.push(
|
||||
tokio::fs::read(dir.path().join(&bucket).join(object).join("xl.meta"))
|
||||
.await
|
||||
.expect("snapshot persisted version metadata"),
|
||||
);
|
||||
}
|
||||
for max_keys in [1, 2, 4, 10] {
|
||||
let mut key_marker = None;
|
||||
let mut version_marker = None;
|
||||
let mut listed = Vec::new();
|
||||
let mut completed = false;
|
||||
for _ in 0..6 {
|
||||
let page = store
|
||||
.clone()
|
||||
.inner_list_object_versions(&bucket, object, key_marker.clone(), version_marker.clone(), None, max_keys)
|
||||
.await
|
||||
.expect("read a complete merged version page");
|
||||
listed.extend(page.objects);
|
||||
if !page.is_truncated {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
assert_ne!(
|
||||
(&page.next_marker, &page.next_version_idmarker),
|
||||
(&key_marker, &version_marker),
|
||||
"version cursor must advance"
|
||||
);
|
||||
key_marker = page.next_marker;
|
||||
version_marker = page.next_version_idmarker;
|
||||
}
|
||||
assert!(completed, "pagination must terminate");
|
||||
assert_eq!(listed.iter().map(|version| version.version_id).collect::<Vec<_>>(), expected);
|
||||
assert!(listed[0].delete_marker && listed[0].is_latest);
|
||||
assert!(listed[1..].iter().all(|version| !version.delete_marker && !version.is_latest));
|
||||
}
|
||||
let visible = store
|
||||
.clone()
|
||||
.list_objects_generic(&bucket, "", None, None, 10, false)
|
||||
.await
|
||||
.expect("list current objects");
|
||||
assert!(visible.objects.is_empty(), "the global current marker hides the object");
|
||||
for (dir, before) in dirs.iter().zip(before) {
|
||||
assert_eq!(
|
||||
tokio::fs::read(dir.path().join(&bucket).join(object).join("xl.meta"))
|
||||
.await
|
||||
.expect("read unchanged metadata"),
|
||||
before
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipool_conflicting_version_metadata_fails_the_listing_request() {
|
||||
let bucket = "multipool-version-conflict";
|
||||
let (_dirs, store) = multipool_version_test_store(bucket).await;
|
||||
store.pools[0]
|
||||
.put_object(
|
||||
bucket,
|
||||
"a.bin",
|
||||
&mut PutObjReader::from_vec(b"preceding result".to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed an entry before the conflict");
|
||||
let version_id = Uuid::new_v4();
|
||||
let mod_time = OffsetDateTime::now_utc();
|
||||
let mut etags = Vec::new();
|
||||
for (pool_idx, value) in [(0, 1), (1, 2)] {
|
||||
let written = store.pools[pool_idx]
|
||||
.put_object(
|
||||
bucket,
|
||||
"z.bin",
|
||||
&mut PutObjReader::from_vec(vec![value; 4097]),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
mod_time: Some(mod_time),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("persist independent conflicting copies");
|
||||
assert_eq!(written.version_id, Some(version_id));
|
||||
etags.push(written.etag);
|
||||
}
|
||||
assert_ne!(etags[0], etags[1], "fixture must contain a semantic conflict");
|
||||
let err = store
|
||||
.clone()
|
||||
.inner_list_object_versions(bucket, "", None, None, None, 10)
|
||||
.await
|
||||
.expect_err("partial output must not hide the merge error");
|
||||
assert_eq!(err, StorageError::FileCorrupt);
|
||||
|
||||
let cancellation = tokio_util::sync::CancellationToken::new();
|
||||
let (sender, mut receiver) = tokio::sync::mpsc::channel(1);
|
||||
let walk = store
|
||||
.clone()
|
||||
.walk(cancellation.clone(), bucket, "", sender, WalkOptions::default());
|
||||
let drain = async { while receiver.recv().await.is_some() {} };
|
||||
let (walk_result, ()) = tokio::time::timeout(Duration::from_secs(5), async { tokio::join!(walk, drain) })
|
||||
.await
|
||||
.expect("bounded walk output must drain and terminate on a merge error");
|
||||
assert_eq!(
|
||||
walk_result.expect_err("walk must report the same metadata conflict"),
|
||||
StorageError::FileCorrupt
|
||||
);
|
||||
assert!(!cancellation.is_cancelled(), "worker failure must not cancel the caller's request");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipool_marker_rejects_unwritable_owner_without_falling_back() {
|
||||
let bucket = "multipool-unwritable-owner";
|
||||
let object = "history.bin";
|
||||
let (_dirs, store) = multipool_version_test_store(bucket).await;
|
||||
store.pools[1]
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(vec![1; 4097]),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed the nonzero owner");
|
||||
*store.pool_meta.write().await = PoolMeta {
|
||||
pools: vec![prepared_pool_test_status(0, false), prepared_pool_test_status(1, true)],
|
||||
..Default::default()
|
||||
};
|
||||
let err = store
|
||||
.delete_object(
|
||||
bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("suspended owner must reject marker creation");
|
||||
assert_eq!(err, StorageError::SlowDown);
|
||||
*store.pool_meta.write().await = PoolMeta::default();
|
||||
|
||||
let mut rebalancing = crate::services::rebalance::RebalanceStats {
|
||||
participating: true,
|
||||
..Default::default()
|
||||
};
|
||||
rebalancing.info.status = crate::services::rebalance::RebalStatus::Started;
|
||||
*store.rebalance_meta.write().await = Some(crate::services::rebalance::RebalanceMeta {
|
||||
pool_stats: vec![crate::services::rebalance::RebalanceStats::default(), rebalancing],
|
||||
..Default::default()
|
||||
});
|
||||
let error = store
|
||||
.delete_object(
|
||||
bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
*store.rebalance_meta.write().await = None;
|
||||
assert_eq!(
|
||||
error.expect_err("rebalance must not hide the owner during marker lookup"),
|
||||
StorageError::SlowDown
|
||||
);
|
||||
|
||||
// Isolate object quorum from the bucket metadata preflight: disabling
|
||||
// a whole pool can otherwise fail before object ownership is looked up.
|
||||
let quorum_bucket = RUSTFS_META_BUCKET;
|
||||
store.pools[1]
|
||||
.put_object(
|
||||
quorum_bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(vec![1; 4097]),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed the object-quorum fixture");
|
||||
for pool in &store.pools {
|
||||
pool.put_object(
|
||||
quorum_bucket,
|
||||
"split.bin",
|
||||
&mut PutObjReader::from_vec(vec![2; 4097]),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed another key with history in both pools");
|
||||
}
|
||||
|
||||
let owner = &store.pools[1].disk_set[0];
|
||||
let healthy = owner.disks.read().await.clone();
|
||||
for disk in owner.disks.write().await.iter_mut().skip(1) {
|
||||
*disk = None;
|
||||
}
|
||||
let error = store
|
||||
.delete_object(
|
||||
quorum_bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let split_error = store
|
||||
.delete_object(
|
||||
quorum_bucket,
|
||||
"split.bin",
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
*owner.disks.write().await = healthy;
|
||||
assert_eq!(
|
||||
error.expect_err("subquorum owner must not become a new-pool marker"),
|
||||
StorageError::ErasureWriteQuorum
|
||||
);
|
||||
assert_eq!(
|
||||
split_error.expect_err("a readable older pool does not prove the global current version"),
|
||||
StorageError::ErasureWriteQuorum
|
||||
);
|
||||
let split = store.pools[0]
|
||||
.clone()
|
||||
.inner_list_object_versions(quorum_bucket, "split.bin", None, None, None, 10)
|
||||
.await
|
||||
.expect("inspect the readable older pool");
|
||||
assert_eq!(split.objects.len(), 1);
|
||||
assert!(!split.objects[0].delete_marker);
|
||||
let other = store.pools[0]
|
||||
.clone()
|
||||
.inner_list_object_versions(quorum_bucket, object, None, None, None, 10)
|
||||
.await
|
||||
.expect("inspect the other pool");
|
||||
assert!(other.objects.is_empty());
|
||||
let history = store.pools[1]
|
||||
.clone()
|
||||
.inner_list_object_versions(quorum_bucket, object, None, None, None, 10)
|
||||
.await
|
||||
.expect("inspect the restored owner");
|
||||
assert_eq!(history.objects.len(), 1);
|
||||
assert!(!history.objects[0].delete_marker);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipool_suspended_and_batch_markers_keep_the_null_slot_on_the_owner() {
|
||||
let bucket = "multipool-suspended-markers";
|
||||
let object = "history.bin";
|
||||
let (_dirs, store) = multipool_version_test_store(bucket).await;
|
||||
let original = store.pools[1]
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(vec![1; 4097]),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed the UUID history in pool 1");
|
||||
store
|
||||
.update_bucket_metadata_config(
|
||||
bucket,
|
||||
crate::bucket::metadata::BUCKET_VERSIONING_CONFIG,
|
||||
b"<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>".to_vec(),
|
||||
)
|
||||
.await
|
||||
.expect("persist suspended bucket versioning");
|
||||
let null = store
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(vec![2; 4097]),
|
||||
&ObjectOptions {
|
||||
version_suspended: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write the null version beside the history");
|
||||
assert!(null.version_id.is_none_or(|id| id.is_nil()));
|
||||
for _ in 0..2 {
|
||||
let marker = store
|
||||
.delete_object(
|
||||
bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
version_suspended: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("replace the null slot with a marker");
|
||||
assert!(marker.delete_marker);
|
||||
assert!(marker.version_id.is_none_or(|id| id.is_nil()));
|
||||
}
|
||||
let (deleted, errors) = store
|
||||
.delete_objects(
|
||||
bucket,
|
||||
vec![ObjectToDelete {
|
||||
object_name: object.to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
ObjectOptions::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(errors.iter().all(Option::is_none), "batch DELETE should succeed: {errors:?}");
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(deleted[0].delete_marker);
|
||||
let versions = store.pools[1]
|
||||
.clone()
|
||||
.inner_list_object_versions(bucket, object, None, None, None, 10)
|
||||
.await
|
||||
.expect("inspect the owner after batch DELETE");
|
||||
assert_eq!(versions.objects.len(), 2, "only one null marker and the UUID history remain");
|
||||
assert!(versions.objects[0].delete_marker && versions.objects[0].is_latest);
|
||||
assert_eq!(versions.objects[1].version_id, original.version_id);
|
||||
let other = store.pools[0]
|
||||
.clone()
|
||||
.inner_list_object_versions(bucket, object, None, None, None, 10)
|
||||
.await
|
||||
.expect("inspect the unused pool");
|
||||
assert!(other.objects.is_empty());
|
||||
}
|
||||
|
||||
async fn assert_prepared_reader_blocks_writer(store: &ECStore, bucket: &str, object: &str) {
|
||||
assert_pool_writer_is_blocked(store, 0, bucket, object).await;
|
||||
}
|
||||
|
||||
@@ -611,18 +611,7 @@ impl ECStore {
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(PoolObjInfo, Vec<PoolErr>)> {
|
||||
self.internal_get_pool_info_existing_with_opts(bucket, object, opts, false)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn get_pool_info_for_delete_marker(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(PoolObjInfo, Vec<PoolErr>)> {
|
||||
self.internal_get_pool_info_existing_with_opts(bucket, object, opts, true)
|
||||
.await
|
||||
self.internal_get_pool_info_existing_with_opts(bucket, object, opts).await
|
||||
}
|
||||
|
||||
async fn internal_get_pool_info_existing_with_opts(
|
||||
@@ -630,7 +619,6 @@ impl ECStore {
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
require_all_pool_reads: bool,
|
||||
) -> Result<(PoolObjInfo, Vec<PoolErr>)> {
|
||||
let mut futures = Vec::new();
|
||||
for pool in self.pools.iter() {
|
||||
@@ -659,15 +647,6 @@ impl ECStore {
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
// A readable older pool cannot prove ownership of the
|
||||
// current version while another pool is unreadable. Check
|
||||
// both raw and object-scoped quorum errors before sorting.
|
||||
if require_all_pool_reads && !is_err_object_not_found(&e) && !is_err_version_not_found(&e) {
|
||||
return Err(match e {
|
||||
Error::ErasureReadQuorum | Error::InsufficientReadQuorum(_, _) => Error::ErasureWriteQuorum,
|
||||
err => err,
|
||||
});
|
||||
}
|
||||
ress.push(PoolObjInfo {
|
||||
index,
|
||||
err: Some(e),
|
||||
@@ -677,34 +656,6 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
|
||||
if require_all_pool_reads {
|
||||
let suspended_pools = {
|
||||
let pool_meta = self.pool_meta.read().await;
|
||||
(0..self.pools.len())
|
||||
.map(|idx| pool_meta.is_suspended(idx))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let candidates = ress
|
||||
.iter()
|
||||
.map(|pinfo| LatestObjectInfoCandidate {
|
||||
info: pinfo.err.is_none().then(|| pinfo.object_info.clone()),
|
||||
idx: pinfo.index,
|
||||
err: pinfo.err.clone(),
|
||||
})
|
||||
.collect();
|
||||
let (object_info, index) =
|
||||
resolve_latest_object_info_candidates_with_pool_state(candidates, &suspended_pools, bucket, object, opts)?;
|
||||
let pools_with_object = self.pools_with_object(&ress, opts).await;
|
||||
return Ok((
|
||||
PoolObjInfo {
|
||||
index,
|
||||
object_info,
|
||||
err: None,
|
||||
},
|
||||
pools_with_object,
|
||||
));
|
||||
}
|
||||
|
||||
ress.sort_by(|a, b| {
|
||||
let at = a.object_info.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH);
|
||||
let bt = b.object_info.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH);
|
||||
|
||||
@@ -480,110 +480,7 @@ impl FileMeta {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_version(&mut self, fi: FileInfo) -> Result<()> {
|
||||
if let Some(free_version) = self.overwritten_tier_free_version(&fi)? {
|
||||
// The replacement and its cleanup owner must share one xl.meta
|
||||
// commit. Keep the original intact if either insertion fails.
|
||||
let mut next = self.clone();
|
||||
next.add_version_inner(fi)?;
|
||||
next.add_version_filemata(free_version)?;
|
||||
*self = next;
|
||||
return Ok(());
|
||||
}
|
||||
self.add_version_inner(fi)
|
||||
}
|
||||
|
||||
fn overwritten_tier_free_version(&self, fi: &FileInfo) -> Result<Option<FileMetaVersion>> {
|
||||
use rustfs_utils::http::{
|
||||
SUFFIX_TIER_FV_ID, SUFFIX_TRANSITION_STATUS, SUFFIX_TRANSITION_TIER, SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
SUFFIX_TRANSITIONED_OBJECTNAME, SUFFIX_TRANSITIONED_VERSION_ID, SUFFIX_TRANSITIONED_VERSION_STATE,
|
||||
get_consistent_bytes, get_consistent_str, has_internal_suffix, strip_internal_prefix_preserving_case,
|
||||
};
|
||||
|
||||
if fi.version_id.is_some_and(|id| !id.is_nil()) || !contains_key_str(&fi.metadata, SUFFIX_TIER_FV_ID) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(existing) = self
|
||||
.versions
|
||||
.iter()
|
||||
.find(|v| v.header.version_id.is_none_or(|id| id.is_nil()))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let old = existing.parse_version_meta()?;
|
||||
let Some(mut object) = old.object else {
|
||||
return Ok(None);
|
||||
};
|
||||
let status = get_consistent_bytes(&object.meta_sys, SUFFIX_TRANSITION_STATUS);
|
||||
if status.is_none()
|
||||
&& object
|
||||
.meta_sys
|
||||
.keys()
|
||||
.any(|key| has_internal_suffix(key, SUFFIX_TRANSITION_STATUS))
|
||||
{
|
||||
// Empty status is a valid local object. The reader distinguishes
|
||||
// it from conflicting aliases before the ordinary overwrite.
|
||||
object.into_fileinfo(&fi.volume, &fi.name, false)?;
|
||||
return Ok(None);
|
||||
}
|
||||
if status != Some(TRANSITION_COMPLETE.as_bytes()) {
|
||||
return Ok(None);
|
||||
}
|
||||
// Reuse the reader's alias/state validation. A legacy empty remote
|
||||
// version is valid and must not be mistaken for conflicting aliases.
|
||||
object.into_fileinfo(&fi.volume, &fi.name, false)?;
|
||||
if object
|
||||
.meta_sys
|
||||
.keys()
|
||||
.any(|key| has_internal_suffix(key, SUFFIX_TRANSITION_TIER_DESTINATION_ID))
|
||||
&& get_consistent_bytes(&object.meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID).is_none()
|
||||
{
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
let transition_suffixes = [
|
||||
SUFFIX_TRANSITION_STATUS,
|
||||
SUFFIX_TRANSITION_TIER,
|
||||
SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
SUFFIX_TRANSITIONED_OBJECTNAME,
|
||||
SUFFIX_TRANSITIONED_VERSION_ID,
|
||||
SUFFIX_TRANSITIONED_VERSION_STATE,
|
||||
];
|
||||
let replacement = MetaObject::from(fi.clone());
|
||||
if transition_suffixes
|
||||
.iter()
|
||||
.all(|suffix| get_consistent_bytes(&object.meta_sys, suffix) == get_consistent_bytes(&replacement.meta_sys, suffix))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let id = get_consistent_str(&fi.metadata, SUFFIX_TIER_FV_ID).ok_or(Error::FileCorrupt)?;
|
||||
let id = Uuid::parse_str(id)?;
|
||||
if id.is_nil() || self.versions.iter().any(|version| version.header.version_id == Some(id)) {
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
// The reader also accepts legacy key casing. Canonicalize only this
|
||||
// cleanup source so init_free_version preserves every accepted field,
|
||||
// including an explicitly empty unversioned remote version.
|
||||
for suffix in transition_suffixes {
|
||||
let value = object
|
||||
.meta_sys
|
||||
.iter()
|
||||
.find(|(key, _)| {
|
||||
strip_internal_prefix_preserving_case(key).is_some_and(|found| found.eq_ignore_ascii_case(suffix))
|
||||
})
|
||||
.map(|(_, value)| value.clone());
|
||||
if let Some(value) = value {
|
||||
rustfs_utils::http::insert_bytes(&mut object.meta_sys, suffix, value);
|
||||
}
|
||||
}
|
||||
let (free_version, created) = object.init_free_version(fi)?;
|
||||
if !created {
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
Ok(Some(free_version))
|
||||
}
|
||||
|
||||
fn add_version_inner(&mut self, mut fi: FileInfo) -> Result<()> {
|
||||
rustfs_utils::http::remove_str(&mut fi.metadata, rustfs_utils::http::SUFFIX_TIER_FV_ID);
|
||||
pub fn add_version(&mut self, mut fi: FileInfo) -> Result<()> {
|
||||
// empty version_id means "null" (versioning disabled/suspended)
|
||||
if fi.version_id.is_none() {
|
||||
fi.version_id = Some(Uuid::nil());
|
||||
@@ -1566,235 +1463,6 @@ mod test {
|
||||
});
|
||||
}
|
||||
|
||||
fn tier_overwrite_fixture(state: crate::TransitionVersionState) -> (FileMeta, FileInfo) {
|
||||
let mut source = FileInfo::new("object", 2, 2);
|
||||
source.erasure.index = 1;
|
||||
source.mod_time = Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixture timestamp"));
|
||||
source.data_dir = Some(Uuid::new_v4());
|
||||
source.transition_status = TRANSITION_COMPLETE.to_string();
|
||||
source.transition_tier = "WARM".to_string();
|
||||
source.transitioned_objname = "remote/old-object".to_string();
|
||||
source.transition_version_state = state;
|
||||
source.transition_version = match state {
|
||||
crate::TransitionVersionState::Exact => Some("opaque-provider-version".to_string()),
|
||||
crate::TransitionVersionState::SuspendedNull => Some("null".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut source.metadata,
|
||||
rustfs_utils::http::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
"ab".repeat(32),
|
||||
);
|
||||
if state == crate::TransitionVersionState::KnownDisabled {
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut source.metadata,
|
||||
rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_ID,
|
||||
String::new(),
|
||||
);
|
||||
}
|
||||
let mut meta = FileMeta::new();
|
||||
meta.add_version(source.clone()).expect("seed transitioned null version");
|
||||
(meta, source)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_overwrite_preserves_exact_cleanup_owner_across_reload() {
|
||||
use crate::TransitionVersionState::{Exact, KnownDisabled, SuspendedNull, Unknown};
|
||||
use rustfs_utils::http::{MINIO_INTERNAL_PREFIX, RUSTFS_INTERNAL_PREFIX, SUFFIX_TIER_FV_ID};
|
||||
|
||||
for state in [Exact, KnownDisabled, SuspendedNull, Unknown] {
|
||||
for inline in [false, true] {
|
||||
let (mut meta, _) = tier_overwrite_fixture(state);
|
||||
let old = meta.versions[0].parse_version_meta().expect("old metadata");
|
||||
let old = old.object.expect("old object");
|
||||
let id = Uuid::new_v4();
|
||||
let mut replacement = FileInfo::new("object", 2, 2);
|
||||
replacement.version_id = inline.then_some(Uuid::nil());
|
||||
replacement.mod_time = Some(OffsetDateTime::from_unix_timestamp(1_700_000_001).expect("fixture timestamp"));
|
||||
replacement.data_dir = Some(Uuid::new_v4());
|
||||
replacement.size = 3;
|
||||
if inline {
|
||||
replacement.data = Some(Bytes::from_static(b"new"));
|
||||
}
|
||||
replacement.set_tier_free_version_id(&id.to_string());
|
||||
|
||||
meta.add_version(replacement.clone())
|
||||
.expect("replace transitioned null version");
|
||||
let bytes = meta.marshal_msg().expect("persist replacement and cleanup owner");
|
||||
let mut reopened = FileMeta::load(&bytes).expect("reopen committed metadata");
|
||||
assert_eq!(reopened.versions.len(), 2);
|
||||
let (_, free) = reopened.find_version(Some(id)).expect("durable cleanup owner");
|
||||
assert!(free.free_version());
|
||||
let marker = free.delete_marker.expect("cleanup marker");
|
||||
for suffix in [
|
||||
rustfs_utils::http::SUFFIX_TRANSITION_TIER,
|
||||
rustfs_utils::http::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
rustfs_utils::http::SUFFIX_TRANSITIONED_OBJECTNAME,
|
||||
rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_ID,
|
||||
rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_STATE,
|
||||
] {
|
||||
for prefix in [RUSTFS_INTERNAL_PREFIX, MINIO_INTERNAL_PREFIX] {
|
||||
let key = format!("{prefix}{suffix}");
|
||||
assert_eq!(marker.meta_sys.get(&key), old.meta_sys.get(&key), "{state:?}: {key}");
|
||||
}
|
||||
}
|
||||
let (_, current) = reopened.find_version(None).expect("replacement survives restart");
|
||||
let current = current.object.expect("replacement object");
|
||||
assert_eq!(current.size, 3);
|
||||
assert!(!rustfs_utils::http::contains_key_bytes(¤t.meta_sys, SUFFIX_TIER_FV_ID));
|
||||
reopened
|
||||
.add_version(replacement)
|
||||
.expect("replaying replacement is idempotent");
|
||||
assert_eq!(reopened.versions.len(), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_overwrite_rejects_cleanup_failure_without_mutating_source() {
|
||||
for id in ["not-a-uuid".to_string(), Uuid::nil().to_string()] {
|
||||
let (mut meta, _) = tier_overwrite_fixture(crate::TransitionVersionState::Exact);
|
||||
let before = meta.clone();
|
||||
let mut replacement = FileInfo::new("object", 2, 2);
|
||||
replacement.mod_time = Some(OffsetDateTime::now_utc());
|
||||
replacement.set_tier_free_version_id(&id);
|
||||
assert!(meta.add_version(replacement).is_err());
|
||||
assert_eq!(meta, before, "invalid cleanup identity must preserve source");
|
||||
}
|
||||
with_object_max_versions_for_test(1, || {
|
||||
let (mut meta, _) = tier_overwrite_fixture(crate::TransitionVersionState::KnownDisabled);
|
||||
let before = meta.clone();
|
||||
let mut replacement = FileInfo::new("object", 2, 2);
|
||||
replacement.mod_time = Some(OffsetDateTime::now_utc());
|
||||
replacement.data = Some(Bytes::from_static(b"new"));
|
||||
replacement.set_tier_free_version_id(&Uuid::new_v4().to_string());
|
||||
assert_eq!(
|
||||
meta.add_version(replacement)
|
||||
.expect_err("cleanup owner exceeds version limit"),
|
||||
Error::MaxVersionsExceeded
|
||||
);
|
||||
assert_eq!(meta, before, "failed cleanup insertion must also preserve inline bytes");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_overwrite_allows_empty_transition_status_on_local_source() {
|
||||
let mut source = FileInfo::new("object", 2, 2);
|
||||
source.mod_time = Some(OffsetDateTime::now_utc());
|
||||
source.data = Some(Bytes::from_static(b"old"));
|
||||
rustfs_utils::http::insert_str(&mut source.metadata, rustfs_utils::http::SUFFIX_TRANSITION_STATUS, String::new());
|
||||
let mut meta = FileMeta::new();
|
||||
meta.add_version(source)
|
||||
.expect("seed readable local metadata with empty status");
|
||||
let mut replacement = FileInfo::new("object", 2, 2);
|
||||
replacement.mod_time = Some(OffsetDateTime::now_utc());
|
||||
replacement.size = 3;
|
||||
replacement.data = Some(Bytes::from_static(b"new"));
|
||||
replacement.set_tier_free_version_id(&Uuid::new_v4().to_string());
|
||||
meta.add_version(replacement)
|
||||
.expect("an ordinary overwrite must still succeed");
|
||||
assert_eq!(meta.versions.len(), 1);
|
||||
let (_, current) = meta.find_version(None).expect("replacement remains visible");
|
||||
assert_eq!(current.object.expect("ordinary object").size, 3);
|
||||
assert!(!meta.versions[0].header.free_version());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_overwrite_preserves_legacy_metadata_casing() {
|
||||
use rustfs_utils::http::{MINIO_INTERNAL_PREFIX, RUSTFS_INTERNAL_PREFIX};
|
||||
|
||||
for state in [
|
||||
crate::TransitionVersionState::Exact,
|
||||
crate::TransitionVersionState::KnownDisabled,
|
||||
] {
|
||||
let (mut meta, _) = tier_overwrite_fixture(state);
|
||||
let mut source = meta.versions[0].parse_version_meta().expect("seeded source");
|
||||
let object = source.object.as_mut().expect("transitioned source");
|
||||
let expected = object.meta_sys.clone();
|
||||
object.meta_sys = object
|
||||
.meta_sys
|
||||
.drain()
|
||||
.map(|(key, value)| (key.to_ascii_uppercase(), value))
|
||||
.collect();
|
||||
meta.versions[0] = FileMetaShallowVersion::try_from(source).expect("legacy key casing");
|
||||
let id = Uuid::new_v4();
|
||||
let mut replacement = FileInfo::new("object", 2, 2);
|
||||
replacement.mod_time = Some(OffsetDateTime::now_utc());
|
||||
replacement.set_tier_free_version_id(&id.to_string());
|
||||
meta.add_version(replacement).expect("overwrite readable legacy source");
|
||||
let reopened = FileMeta::load(&meta.marshal_msg().expect("persist overwrite")).expect("reopen overwrite");
|
||||
let (_, owner) = reopened
|
||||
.find_version(Some(id))
|
||||
.expect("legacy source must retain cleanup ownership");
|
||||
let marker = owner.delete_marker.expect("cleanup marker");
|
||||
for suffix in [
|
||||
rustfs_utils::http::SUFFIX_TRANSITION_TIER,
|
||||
rustfs_utils::http::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
rustfs_utils::http::SUFFIX_TRANSITIONED_OBJECTNAME,
|
||||
rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_ID,
|
||||
rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_STATE,
|
||||
] {
|
||||
for prefix in [RUSTFS_INTERNAL_PREFIX, MINIO_INTERNAL_PREFIX] {
|
||||
let key = format!("{prefix}{suffix}");
|
||||
assert_eq!(marker.meta_sys.get(&key), expected.get(&key), "legacy {state:?}: {key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_overwrite_rejects_conflicting_remote_metadata_aliases() {
|
||||
use rustfs_utils::http::{
|
||||
MINIO_INTERNAL_PREFIX, SUFFIX_TRANSITION_STATUS, SUFFIX_TRANSITION_TIER, SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
SUFFIX_TRANSITIONED_OBJECTNAME, SUFFIX_TRANSITIONED_VERSION_ID, SUFFIX_TRANSITIONED_VERSION_STATE,
|
||||
};
|
||||
for suffix in [
|
||||
SUFFIX_TRANSITION_STATUS,
|
||||
SUFFIX_TRANSITION_TIER,
|
||||
SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
SUFFIX_TRANSITIONED_OBJECTNAME,
|
||||
SUFFIX_TRANSITIONED_VERSION_ID,
|
||||
SUFFIX_TRANSITIONED_VERSION_STATE,
|
||||
] {
|
||||
let (mut meta, _) = tier_overwrite_fixture(crate::TransitionVersionState::Exact);
|
||||
let mut old = meta.versions[0].parse_version_meta().expect("seeded source metadata");
|
||||
old.object
|
||||
.as_mut()
|
||||
.expect("transitioned source")
|
||||
.meta_sys
|
||||
.insert(format!("{MINIO_INTERNAL_PREFIX}{suffix}"), b"conflicting-value".to_vec());
|
||||
meta.versions[0] = FileMetaShallowVersion::try_from(old).expect("encode conflicting aliases");
|
||||
let before = meta.clone();
|
||||
let mut replacement = FileInfo::new("object", 2, 2);
|
||||
replacement.mod_time = Some(OffsetDateTime::now_utc());
|
||||
replacement.set_tier_free_version_id(&Uuid::new_v4().to_string());
|
||||
assert_eq!(
|
||||
meta.add_version(replacement)
|
||||
.expect_err("ambiguous ownership must fail closed"),
|
||||
Error::FileCorrupt
|
||||
);
|
||||
assert_eq!(meta, before, "conflicting {suffix} must not erase the old remote tuple");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_overwrite_keeps_retained_remote_and_versioned_copy_ownership() {
|
||||
let (mut meta, mut source) = tier_overwrite_fixture(crate::TransitionVersionState::Exact);
|
||||
source.set_tier_free_version_id(&Uuid::new_v4().to_string());
|
||||
meta.add_version(source.clone())
|
||||
.expect("restore retains the same remote owner");
|
||||
assert_eq!(meta.versions.len(), 1);
|
||||
source.version_id = Some(Uuid::new_v4());
|
||||
source.transition_status.clear();
|
||||
source.transition_tier.clear();
|
||||
source.transitioned_objname.clear();
|
||||
source.transition_version = None;
|
||||
source.transition_version_state = crate::TransitionVersionState::Unknown;
|
||||
meta.add_version(source).expect("versioned write retains historical source");
|
||||
assert_eq!(meta.versions.len(), 2);
|
||||
assert!(meta.versions.iter().all(|version| !version.header.free_version()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_filemata_uses_canonical_equal_time_order() {
|
||||
let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid test timestamp");
|
||||
|
||||
@@ -21,8 +21,7 @@ use crate::heal::{
|
||||
use crate::{Error, Result};
|
||||
use rustfs_heal_contracts::heal_channel::{
|
||||
HealAdmissionReceipt, HealAdmissionResult, HealChannelCommand, HealChannelPriority, HealChannelReceiver, HealChannelRequest,
|
||||
HealChannelResponse, HealOpts, HealReceiptCommand, HealReceiptReceiver, HealRequestSource, HealScanMode,
|
||||
publish_heal_response,
|
||||
HealChannelResponse, HealReceiptCommand, HealReceiptReceiver, HealRequestSource, HealScanMode, publish_heal_response,
|
||||
};
|
||||
use rustfs_madmin::heal_commands::HealResultItem;
|
||||
use serde::Serialize;
|
||||
@@ -79,8 +78,6 @@ struct HealTaskStatusPayload<'a> {
|
||||
progress: Option<&'a HealProgress>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
outcome: Option<&'a super::outcome::HealTaskOutcome>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
settings: Option<&'a HealOpts>,
|
||||
}
|
||||
|
||||
fn u64_is_zero(value: &u64) -> bool {
|
||||
@@ -94,7 +91,6 @@ fn encode_heal_task_status_payload(
|
||||
mut truncated: bool,
|
||||
sequence: (u64, u64),
|
||||
outcome: Option<&super::outcome::HealTaskOutcome>,
|
||||
settings: Option<&HealOpts>,
|
||||
) -> Result<(Vec<u8>, bool)> {
|
||||
loop {
|
||||
let data = serde_json::to_vec(&HealTaskStatusPayload {
|
||||
@@ -105,7 +101,6 @@ fn encode_heal_task_status_payload(
|
||||
min_seq: sequence.1,
|
||||
progress,
|
||||
outcome,
|
||||
settings,
|
||||
})
|
||||
.map_err(|e| Error::Serialization(format!("failed to serialize heal task status: {e}")))?;
|
||||
if data.len() <= MAX_HEAL_STATUS_PAYLOAD_SIZE {
|
||||
@@ -127,13 +122,12 @@ fn encode_heal_status_response(
|
||||
truncated: bool,
|
||||
sequence: (u64, u64),
|
||||
outcome: Option<&super::outcome::HealTaskOutcome>,
|
||||
settings: Option<&HealOpts>,
|
||||
) -> Result<(Vec<u8>, Option<String>)> {
|
||||
let (summary, detail) = match outcome {
|
||||
Some(outcome) => outcome.legacy_status(summary, detail),
|
||||
None => (summary, detail),
|
||||
};
|
||||
let (data, truncated) = encode_heal_task_status_payload(summary, items, progress, truncated, sequence, outcome, settings)?;
|
||||
let (data, truncated) = encode_heal_task_status_payload(summary, items, progress, truncated, sequence, outcome)?;
|
||||
Ok((data, super::outcome::heal_status_detail(detail, truncated)))
|
||||
}
|
||||
|
||||
@@ -445,10 +439,6 @@ impl HealChannelProcessor {
|
||||
};
|
||||
|
||||
let outcome = report.as_ref().ok().and_then(|report| report.outcome.clone());
|
||||
let settings = report
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|report| report.options.as_ref().map(heal_options_to_wire));
|
||||
let (summary, detail, items, truncated, progress, next_seq, min_seq) = match report {
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Pending | HealTaskStatus::Running,
|
||||
@@ -594,7 +584,6 @@ impl HealChannelProcessor {
|
||||
truncated,
|
||||
(next_seq, min_seq),
|
||||
outcome.as_deref(),
|
||||
settings.as_ref(),
|
||||
)?;
|
||||
|
||||
let response = HealChannelResponse {
|
||||
@@ -789,21 +778,6 @@ impl HealChannelProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
fn heal_options_to_wire(options: &HealOptions) -> HealOpts {
|
||||
HealOpts {
|
||||
recursive: options.recursive,
|
||||
dry_run: options.dry_run,
|
||||
remove: options.remove_corrupted,
|
||||
recreate: options.recreate_missing,
|
||||
scan_mode: options.scan_mode,
|
||||
update_parity: options.update_parity,
|
||||
no_lock: options.no_lock,
|
||||
read_repair: false,
|
||||
pool: options.pool_index,
|
||||
set: options.set_index,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::DiskStore;
|
||||
@@ -899,7 +873,7 @@ mod tests {
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
let (data, detail) = encode_heal_status_response("running", items, None, None, false, (0, 0), None, None).unwrap();
|
||||
let (data, detail) = encode_heal_status_response("running", items, None, None, false, (0, 0), None).unwrap();
|
||||
|
||||
assert!(data.len() <= MAX_HEAL_STATUS_PAYLOAD_SIZE);
|
||||
let payload: serde_json::Value = serde_json::from_slice(&data).unwrap();
|
||||
@@ -964,7 +938,6 @@ mod tests {
|
||||
true,
|
||||
(9, 4),
|
||||
Some(&outcome),
|
||||
None,
|
||||
)
|
||||
.expect("canonical owner encoding");
|
||||
let decoded: serde_json::Value = serde_json::from_slice(&bytes).expect("wire payload");
|
||||
@@ -987,9 +960,8 @@ mod tests {
|
||||
] {
|
||||
let mut outcome = HealTaskOutcome::default();
|
||||
outcome.finish(Some(reason));
|
||||
let (data, detail) =
|
||||
encode_heal_status_response("finished", Vec::new(), None, None, false, (0, 0), Some(&outcome), None)
|
||||
.expect("canonical abort adapter");
|
||||
let (data, detail) = encode_heal_status_response("finished", Vec::new(), None, None, false, (0, 0), Some(&outcome))
|
||||
.expect("canonical abort adapter");
|
||||
let json: serde_json::Value = serde_json::from_slice(&data).expect("public state");
|
||||
assert_eq!(json["summary"], "stopped");
|
||||
assert_eq!(json["outcome"]["execution"]["state"], "aborted");
|
||||
@@ -1025,7 +997,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let (bytes, detail) = encode_heal_status_response("running", items, None, None, false, (9, 4), Some(&outcome), None)
|
||||
let (bytes, detail) = encode_heal_status_response("running", items, None, None, false, (9, 4), Some(&outcome))
|
||||
.expect("bounded status with cumulative outcome");
|
||||
assert!(bytes.len() <= MAX_HEAL_STATUS_PAYLOAD_SIZE);
|
||||
let wire: serde_json::Value = serde_json::from_slice(&bytes).expect("bounded payload");
|
||||
@@ -1909,23 +1881,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_process_query_request_reports_running_for_queued_task() {
|
||||
let heal_manager = create_test_heal_manager();
|
||||
let request = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "bucket".to_string(),
|
||||
},
|
||||
HealOptions {
|
||||
scan_mode: HealScanMode::Deep,
|
||||
remove_corrupted: true,
|
||||
recreate_missing: false,
|
||||
update_parity: false,
|
||||
recursive: true,
|
||||
dry_run: true,
|
||||
pool_index: Some(1),
|
||||
set_index: Some(2),
|
||||
..Default::default()
|
||||
},
|
||||
HealPriority::High,
|
||||
);
|
||||
let request = HealRequest::bucket("bucket".to_string());
|
||||
let task_id = request.id.clone();
|
||||
assert_eq!(
|
||||
heal_manager
|
||||
@@ -1954,14 +1910,6 @@ mod tests {
|
||||
.expect("status payload should be json");
|
||||
assert_eq!(payload["summary"], "running");
|
||||
assert_eq!(payload["items"].as_array().expect("items should be an array").len(), 0);
|
||||
assert_eq!(payload["settings"]["scanMode"], 2);
|
||||
assert_eq!(payload["settings"]["dryRun"], true);
|
||||
assert_eq!(payload["settings"]["remove"], true);
|
||||
assert_eq!(payload["settings"]["recreate"], false);
|
||||
assert_eq!(payload["settings"]["updateParity"], false);
|
||||
assert_eq!(payload["settings"]["recursive"], true);
|
||||
assert_eq!(payload["settings"]["pool"], 1);
|
||||
assert_eq!(payload["settings"]["set"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -197,7 +197,6 @@ fn record_displaced_terminal(
|
||||
progress: None,
|
||||
retained_bytes: std::sync::OnceLock::new(),
|
||||
heal_type: request.heal_type.clone(),
|
||||
options: request.options.clone(),
|
||||
status: HealTaskStatus::Failed {
|
||||
error: format!("heal task displaced by a higher-priority request ({DISPLACED_HEAL_REASON})"),
|
||||
},
|
||||
@@ -278,8 +277,6 @@ async fn publish_completed_heal(
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HealTaskReport {
|
||||
/// Options used by the task, when retained by the state source.
|
||||
pub options: Option<HealOptions>,
|
||||
pub outcome: Option<Arc<HealTaskOutcome>>,
|
||||
pub status: HealTaskStatus,
|
||||
pub result_items: Vec<HealResultItem>,
|
||||
@@ -297,7 +294,6 @@ pub struct HealTaskReport {
|
||||
async fn active_task_report(task: &HealTask, since: Option<u64>) -> HealTaskReport {
|
||||
let window = task.get_result_items_since(since).await;
|
||||
HealTaskReport {
|
||||
options: Some(task.options.clone()),
|
||||
status: task.get_status().await,
|
||||
outcome: Some(Arc::new(task.get_outcome().await)),
|
||||
result_items: window.items,
|
||||
@@ -313,7 +309,6 @@ async fn active_task_report(task: &HealTask, since: Option<u64>) -> HealTaskRepo
|
||||
|
||||
fn empty_task_report(status: HealTaskStatus) -> HealTaskReport {
|
||||
HealTaskReport {
|
||||
options: None,
|
||||
outcome: None,
|
||||
status,
|
||||
result_items: Vec::new(),
|
||||
@@ -324,13 +319,6 @@ fn empty_task_report(status: HealTaskStatus) -> HealTaskReport {
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_task_report_with_options(status: HealTaskStatus, options: HealOptions) -> HealTaskReport {
|
||||
HealTaskReport {
|
||||
options: Some(options),
|
||||
..empty_task_report(status)
|
||||
}
|
||||
}
|
||||
|
||||
fn completed_task_report(completed: &CompletedHealStatus, since: Option<u64>) -> HealTaskReport {
|
||||
let mut lagged = false;
|
||||
let result_items = match since {
|
||||
@@ -348,7 +336,6 @@ fn completed_task_report(completed: &CompletedHealStatus, since: Option<u64>) ->
|
||||
}
|
||||
};
|
||||
HealTaskReport {
|
||||
options: Some(completed.options.clone()),
|
||||
status: completed.status.clone(),
|
||||
outcome: completed.outcome.clone(),
|
||||
result_items,
|
||||
@@ -561,7 +548,10 @@ fn retry_budget_for_result(task: &HealTask, result: &Result<()>, retryable_batch
|
||||
}
|
||||
|
||||
let error = err.to_string();
|
||||
if !err.is_recoverable_heal() {
|
||||
// Batch aggregation preserves the typed classification in its counters,
|
||||
// while the returned task error retains only the first error's display text.
|
||||
let retryable_batch_result = retryable_batch_failure && matches!(err, Error::TaskExecutionFailed { .. });
|
||||
if !retryable_batch_result && !err.is_recoverable_heal() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -879,9 +869,9 @@ pub struct HealManager {
|
||||
/// cascade without re-locking.
|
||||
enum TaskStateLookup {
|
||||
Active(Arc<HealTask>),
|
||||
Retrying(HealTaskStatus, HealOptions),
|
||||
Retrying(HealTaskStatus),
|
||||
Completed(Arc<CompletedHealStatus>),
|
||||
Queued(HealOptions),
|
||||
Queued,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
@@ -2144,7 +2134,7 @@ impl HealManager {
|
||||
.get(canonical_task_id)
|
||||
.filter(|retrying| matches_path(&retrying.request.heal_type))
|
||||
{
|
||||
return Ok(TaskStateLookup::Retrying(retrying.status(), retrying.request.options.clone()));
|
||||
return Ok(TaskStateLookup::Retrying(retrying.status()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2165,8 +2155,12 @@ impl HealManager {
|
||||
|
||||
{
|
||||
let queue = self.heal_queue.lock().await;
|
||||
if let Some(request) = queue.request_matching_id_and_path(canonical_task_id, heal_path) {
|
||||
return Ok(TaskStateLookup::Queued(request.options.clone()));
|
||||
let queued = match heal_path {
|
||||
Some(path) => queue.contains_request_id_matching_path(canonical_task_id, path),
|
||||
None => queue.contains_request_id(canonical_task_id),
|
||||
};
|
||||
if queued {
|
||||
return Ok(TaskStateLookup::Queued);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2209,14 +2203,12 @@ impl HealManager {
|
||||
task_id: &str,
|
||||
heal_type: &HealType,
|
||||
source: HealRequestSource,
|
||||
options: &HealOptions,
|
||||
) -> Result<bool> {
|
||||
let completed = CompletedHealStatus {
|
||||
outcome: None,
|
||||
progress: None,
|
||||
retained_bytes: std::sync::OnceLock::new(),
|
||||
heal_type: heal_type.clone(),
|
||||
options: options.clone(),
|
||||
status: HealTaskStatus::Cancelled,
|
||||
result_items_truncated: false,
|
||||
completed_at: SystemTime::now(),
|
||||
@@ -2231,9 +2223,9 @@ impl HealManager {
|
||||
let canonical_task_id = self.canonical_task_id(task_id).await;
|
||||
match self.lookup_task_state(&canonical_task_id, None).await? {
|
||||
TaskStateLookup::Active(task) => Ok(task.get_status().await),
|
||||
TaskStateLookup::Retrying(status, _) => Ok(status),
|
||||
TaskStateLookup::Retrying(status) => Ok(status),
|
||||
TaskStateLookup::Completed(completed) => Ok(completed.status.clone()),
|
||||
TaskStateLookup::Queued(_) => Ok(HealTaskStatus::Pending),
|
||||
TaskStateLookup::Queued => Ok(HealTaskStatus::Pending),
|
||||
TaskStateLookup::NotFound => Err(Error::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
}),
|
||||
@@ -2251,9 +2243,9 @@ impl HealManager {
|
||||
let canonical_task_id = self.canonical_task_id(task_id).await;
|
||||
match self.lookup_task_state(&canonical_task_id, None).await? {
|
||||
TaskStateLookup::Active(task) => Ok(active_task_report(&task, since).await),
|
||||
TaskStateLookup::Retrying(status, options) => Ok(empty_task_report_with_options(status, options)),
|
||||
TaskStateLookup::Retrying(status) => Ok(empty_task_report(status)),
|
||||
TaskStateLookup::Completed(completed) => Ok(completed_task_report(&completed, since)),
|
||||
TaskStateLookup::Queued(options) => Ok(empty_task_report_with_options(HealTaskStatus::Pending, options)),
|
||||
TaskStateLookup::Queued => Ok(empty_task_report(HealTaskStatus::Pending)),
|
||||
TaskStateLookup::NotFound => Err(Error::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
}),
|
||||
@@ -2274,9 +2266,9 @@ impl HealManager {
|
||||
let canonical_task_id = self.canonical_task_id(task_id).await;
|
||||
match self.lookup_task_state(&canonical_task_id, Some(heal_path)).await? {
|
||||
TaskStateLookup::Active(task) => Ok(active_task_report(&task, since).await),
|
||||
TaskStateLookup::Retrying(status, options) => Ok(empty_task_report_with_options(status, options)),
|
||||
TaskStateLookup::Retrying(status) => Ok(empty_task_report(status)),
|
||||
TaskStateLookup::Completed(completed) => Ok(completed_task_report(&completed, since)),
|
||||
TaskStateLookup::Queued(options) => Ok(empty_task_report_with_options(HealTaskStatus::Pending, options)),
|
||||
TaskStateLookup::Queued => Ok(empty_task_report(HealTaskStatus::Pending)),
|
||||
TaskStateLookup::NotFound => {
|
||||
if self.path_has_task(heal_path).await {
|
||||
return Err(Error::InvalidClientToken);
|
||||
@@ -2297,9 +2289,9 @@ impl HealManager {
|
||||
let canonical_task_id = self.canonical_task_id(task_id).await;
|
||||
match self.lookup_task_state(&canonical_task_id, Some(heal_path)).await? {
|
||||
TaskStateLookup::Active(task) => Ok(task.get_status().await),
|
||||
TaskStateLookup::Retrying(status, _) => Ok(status),
|
||||
TaskStateLookup::Retrying(status) => Ok(status),
|
||||
TaskStateLookup::Completed(completed) => Ok(completed.status.clone()),
|
||||
TaskStateLookup::Queued(_) => Ok(HealTaskStatus::Pending),
|
||||
TaskStateLookup::Queued => Ok(HealTaskStatus::Pending),
|
||||
TaskStateLookup::NotFound => {
|
||||
if self.path_has_task(heal_path).await {
|
||||
return Err(Error::InvalidClientToken);
|
||||
@@ -2415,13 +2407,8 @@ impl HealManager {
|
||||
{
|
||||
let mut retrying_heals = self.retrying_heals.lock().await;
|
||||
if let Some(retrying) = retrying_heals.get(&canonical_task_id) {
|
||||
self.publish_admin_cancelled_terminal(
|
||||
&canonical_task_id,
|
||||
&retrying.request.heal_type,
|
||||
retrying.request.source,
|
||||
&retrying.request.options,
|
||||
)
|
||||
.await?;
|
||||
self.publish_admin_cancelled_terminal(&canonical_task_id, &retrying.request.heal_type, retrying.request.source)
|
||||
.await?;
|
||||
self.root_recovery
|
||||
.remove(&canonical_task_id, &retrying.request.heal_type, retrying.request.source)
|
||||
.await?;
|
||||
@@ -2447,7 +2434,7 @@ impl HealManager {
|
||||
|
||||
let mut queue = self.heal_queue.lock().await;
|
||||
if let Some(request) = queue.requests().find(|request| request.id == canonical_task_id) {
|
||||
self.publish_admin_cancelled_terminal(&canonical_task_id, &request.heal_type, request.source, &request.options)
|
||||
self.publish_admin_cancelled_terminal(&canonical_task_id, &request.heal_type, request.source)
|
||||
.await?;
|
||||
self.root_recovery
|
||||
.remove(&request.id, &request.heal_type, request.source)
|
||||
@@ -2524,13 +2511,8 @@ impl HealManager {
|
||||
|
||||
for task_id in &task_ids {
|
||||
if let Some(retrying) = retrying_heals.get(task_id) {
|
||||
self.publish_admin_cancelled_terminal(
|
||||
task_id,
|
||||
&retrying.request.heal_type,
|
||||
retrying.request.source,
|
||||
&retrying.request.options,
|
||||
)
|
||||
.await?;
|
||||
self.publish_admin_cancelled_terminal(task_id, &retrying.request.heal_type, retrying.request.source)
|
||||
.await?;
|
||||
self.root_recovery
|
||||
.remove(task_id, &retrying.request.heal_type, retrying.request.source)
|
||||
.await?;
|
||||
@@ -2565,7 +2547,7 @@ impl HealManager {
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for request in &queued_matches {
|
||||
self.publish_admin_cancelled_terminal(&request.id, &request.heal_type, request.source, &request.options)
|
||||
self.publish_admin_cancelled_terminal(&request.id, &request.heal_type, request.source)
|
||||
.await?;
|
||||
self.root_recovery
|
||||
.remove(&request.id, &request.heal_type, request.source)
|
||||
|
||||
@@ -81,8 +81,6 @@ pub(super) enum QueuePushOutcome {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct CompletedHealStatus {
|
||||
pub(super) heal_type: HealType,
|
||||
/// Options used to execute the task, retained for token-scoped status.
|
||||
pub(super) options: HealOptions,
|
||||
pub(super) status: HealTaskStatus,
|
||||
pub(super) progress: Option<HealProgress>,
|
||||
pub(super) outcome: Option<Arc<HealTaskOutcome>>,
|
||||
@@ -211,7 +209,6 @@ impl CompletedHealStatus {
|
||||
let (next_seq, min_seq) = task.result_seq_cursors();
|
||||
let mut snapshot = Self {
|
||||
heal_type: task.heal_type.clone(),
|
||||
options: task.options.clone(),
|
||||
status,
|
||||
progress: Some(task.get_progress().await),
|
||||
outcome: Some(Arc::new(task.get_outcome().await)),
|
||||
@@ -494,15 +491,14 @@ impl PriorityHealQueue {
|
||||
self.heap.iter().map(|item| &item.request)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn contains_request_id(&self, request_id: &str) -> bool {
|
||||
self.heap.iter().any(|item| item.request.id == request_id)
|
||||
}
|
||||
|
||||
pub(super) fn request_matching_id_and_path(&self, request_id: &str, heal_path: Option<&str>) -> Option<&HealRequest> {
|
||||
self.heap.iter().map(|item| &item.request).find(|request| {
|
||||
request.id == request_id && heal_path.is_none_or(|path| heal_type_matches_path(&request.heal_type, path))
|
||||
})
|
||||
pub(super) fn contains_request_id_matching_path(&self, request_id: &str, heal_path: &str) -> bool {
|
||||
self.heap
|
||||
.iter()
|
||||
.any(|item| item.request.id == request_id && heal_type_matches_path(&item.request.heal_type, heal_path))
|
||||
}
|
||||
|
||||
pub(super) fn queued_request_id_for_dedup_key(&self, key: &str) -> Option<&str> {
|
||||
|
||||
@@ -208,8 +208,6 @@ struct RootHealTerminal {
|
||||
task_id: String,
|
||||
heal_type: RecoveryHealType,
|
||||
status: HealTaskStatus,
|
||||
#[serde(default, deserialize_with = "decode_options")]
|
||||
options: HealOptions,
|
||||
progress: Option<HealProgress>,
|
||||
completed_at: SystemTime,
|
||||
}
|
||||
@@ -221,19 +219,17 @@ impl RootHealTerminal {
|
||||
task_id: task_id.to_owned(),
|
||||
heal_type: RecoveryHealType::from(&completed.heal_type),
|
||||
status: completed.status.clone(),
|
||||
options: completed.options.clone(),
|
||||
progress: completed.progress.clone(),
|
||||
completed_at: completed.completed_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn cancelled(task_id: &str, heal_type: &HealType, options: HealOptions) -> Self {
|
||||
fn cancelled(task_id: &str, heal_type: &HealType) -> Self {
|
||||
Self {
|
||||
schema: ROOT_TERMINAL_SCHEMA,
|
||||
task_id: task_id.to_owned(),
|
||||
heal_type: RecoveryHealType::from(heal_type),
|
||||
status: HealTaskStatus::Cancelled,
|
||||
options,
|
||||
progress: None,
|
||||
completed_at: SystemTime::now(),
|
||||
}
|
||||
@@ -245,7 +241,6 @@ impl RootHealTerminal {
|
||||
progress: self.progress,
|
||||
retained_bytes: std::sync::OnceLock::new(),
|
||||
heal_type: self.heal_type.into(),
|
||||
options: self.options,
|
||||
status: self.status,
|
||||
result_items_truncated: false,
|
||||
completed_at: self.completed_at,
|
||||
@@ -525,50 +520,32 @@ impl RootHealRecovery {
|
||||
let _guard = self.mutation.lock().await;
|
||||
let disks = self.disks().await?;
|
||||
let existing = Self::find(&disks, &request.id).await?;
|
||||
let (disk, expected) = match existing {
|
||||
Some((disk, bytes)) => (disk, Some(bytes)),
|
||||
None => {
|
||||
let disk = disks
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::Other("No local disk available for root heal shutdown recovery".to_string()))?;
|
||||
(disk, None)
|
||||
}
|
||||
};
|
||||
if request.options.no_lock {
|
||||
return Err(Error::Other("Administrator root heal cannot skip namespace locking".to_string()));
|
||||
}
|
||||
let bytes = serde_json::to_vec(&RootHealIntent::from_request(request))
|
||||
.map_err(|error| Error::Other(format!("Serialize root heal recovery record: {error}")))?;
|
||||
let path = intent_path(&request.id)?;
|
||||
if let Some((disk, expected)) = existing {
|
||||
return match EcstoreDiskAPI::compare_and_update_file(
|
||||
disk.as_ref(),
|
||||
RUSTFS_META_BUCKET,
|
||||
&path,
|
||||
Some(expected),
|
||||
Some(bytes.into()),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
EcstoreConditionalFileUpdate::Updated => Ok(()),
|
||||
_ => Err(Error::Other(format!("Root heal recovery record changed for {}", request.id))),
|
||||
};
|
||||
}
|
||||
|
||||
if disks.is_empty() {
|
||||
return Err(Error::Other("No local disk available for root heal shutdown recovery".to_string()));
|
||||
}
|
||||
let mut last_not_committed = None;
|
||||
for disk in &disks {
|
||||
match EcstoreDiskAPI::compare_and_update_file(
|
||||
disk.as_ref(),
|
||||
RUSTFS_META_BUCKET,
|
||||
&path,
|
||||
None,
|
||||
Some(bytes.clone().into()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(EcstoreConditionalFileUpdate::Updated) => return Ok(()),
|
||||
Ok(_) => return Err(Error::Other(format!("Root heal recovery record changed for {}", request.id))),
|
||||
Err(error) if error.is_conditional_file_not_committed() => last_not_committed = Some(error),
|
||||
Err(error) => return Err(Error::Disk(error)),
|
||||
}
|
||||
}
|
||||
match last_not_committed {
|
||||
Some(error) => Err(Error::Disk(error)),
|
||||
None => Err(Error::Other("No local disk accepted the root heal recovery record".to_string())),
|
||||
match EcstoreDiskAPI::compare_and_update_file(
|
||||
disk.as_ref(),
|
||||
RUSTFS_META_BUCKET,
|
||||
&intent_path(&request.id)?,
|
||||
expected,
|
||||
Some(bytes.into()),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
EcstoreConditionalFileUpdate::Updated => Ok(()),
|
||||
_ => Err(Error::Other(format!("Root heal recovery record changed for {}", request.id))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,7 +658,7 @@ impl RootHealRecovery {
|
||||
};
|
||||
let pending = decode_intent(task_id, &bytes)?;
|
||||
let heal_type = HealType::from(pending.heal_type);
|
||||
let terminal = RootHealTerminal::cancelled(task_id, &heal_type, pending.options);
|
||||
let terminal = RootHealTerminal::cancelled(task_id, &heal_type);
|
||||
let _ = Self::persist_terminal_locked(&disks, task_id, terminal).await?;
|
||||
match EcstoreDiskAPI::compare_and_update_file(
|
||||
disk.as_ref(),
|
||||
|
||||
@@ -112,7 +112,6 @@ fn completed_retention_fixture(completed_at: SystemTime) -> CompletedHealStatus
|
||||
CompletedHealStatus {
|
||||
outcome: None,
|
||||
heal_type: HealType::Cluster,
|
||||
options: HealOptions::default(),
|
||||
status: HealTaskStatus::Completed,
|
||||
progress: Some(HealProgress {
|
||||
objects_scanned: 9,
|
||||
@@ -2615,27 +2614,62 @@ fn test_retry_request_for_recoverable_error_stops_at_limit() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_request_rescans_batch_when_all_exhausted_objects_are_retryable() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
let task = HealTask::from_request(HealRequest::bucket("bucket".to_string()), storage);
|
||||
let result = Err(task
|
||||
.record_batch_failure(BatchHealFailure {
|
||||
scope: "bucket:bucket".to_string(),
|
||||
failed: 1,
|
||||
retryable: 1,
|
||||
permanent: 0,
|
||||
first_object: "object".to_string(),
|
||||
first_error: "Lock acquisition timeout".to_string(),
|
||||
})
|
||||
.await);
|
||||
for source_error in [
|
||||
Error::Disk(DiskError::FaultyDisk),
|
||||
Error::Disk(DiskError::FaultyRemoteDisk),
|
||||
Error::Storage(EcstoreError::SlowDown),
|
||||
Error::TaskExecutionFailed {
|
||||
message: "Lock acquisition timeout".to_string(),
|
||||
},
|
||||
] {
|
||||
assert!(source_error.is_recoverable_heal());
|
||||
let first_error = source_error.to_string();
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
let task = HealTask::from_request(HealRequest::bucket("bucket".to_string()), storage);
|
||||
let result = Err(task
|
||||
.record_batch_failure(BatchHealFailure {
|
||||
scope: "bucket:bucket".to_string(),
|
||||
failed: 1,
|
||||
retryable: 1,
|
||||
permanent: 0,
|
||||
first_object: "object".to_string(),
|
||||
first_error: first_error.clone(),
|
||||
})
|
||||
.await);
|
||||
|
||||
let (retry_request, retry_delay, error) = retry_request_for_result_with_budget(&task, &result)
|
||||
.await
|
||||
.expect("all-retryable batch failure should rescan within the manager retry budget");
|
||||
let (retry_request, retry_delay, error) = retry_request_for_result_with_budget(&task, &result)
|
||||
.await
|
||||
.expect("all-retryable batch failure should rescan within the manager retry budget");
|
||||
|
||||
assert_eq!(retry_request.id, task.id);
|
||||
assert_eq!(retry_request.retry_attempts, 1);
|
||||
assert!(retry_delay > Duration::ZERO);
|
||||
assert!(error.contains("Lock acquisition timeout"));
|
||||
assert_eq!(retry_request.id, task.id);
|
||||
assert_eq!(retry_request.retry_attempts, 1);
|
||||
assert!(retry_delay > Duration::ZERO);
|
||||
assert!(error.contains(&first_error));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_request_does_not_rescan_cancelled_or_timed_out_retryable_batch() {
|
||||
for terminal_error in [Error::TaskCancelled, Error::TaskTimeout] {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
let task = HealTask::from_request(HealRequest::bucket("bucket".to_string()), storage);
|
||||
let _ = task
|
||||
.record_batch_failure(BatchHealFailure {
|
||||
scope: "bucket:bucket".to_string(),
|
||||
failed: 1,
|
||||
retryable: 1,
|
||||
permanent: 0,
|
||||
first_object: "object".to_string(),
|
||||
first_error: Error::Disk(DiskError::FaultyDisk).to_string(),
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
retry_request_for_result_with_budget(&task, &Err(terminal_error))
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2698,7 +2732,6 @@ async fn insert_retrying_request(manager: &HealManager, request: HealRequest) ->
|
||||
outcome: None,
|
||||
retained_bytes: std::sync::OnceLock::new(),
|
||||
heal_type: request.heal_type,
|
||||
options: request.options.clone(),
|
||||
status: HealTaskStatus::Retrying {
|
||||
error: "Lock acquisition timeout".to_string(),
|
||||
retry_attempt: request.retry_attempts,
|
||||
@@ -3470,19 +3503,12 @@ async fn test_get_task_report_queries_queued_task_by_token_without_path() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
let manager = HealManager::new_without_root_recovery_for_test(storage, None);
|
||||
|
||||
let options = HealOptions {
|
||||
scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Deep,
|
||||
dry_run: true,
|
||||
remove_corrupted: true,
|
||||
recreate_missing: false,
|
||||
..Default::default()
|
||||
};
|
||||
let request = HealRequest::new(
|
||||
HealType::ErasureSet {
|
||||
buckets: vec![],
|
||||
set_disk_id: "pool_0_set_1".to_string(),
|
||||
},
|
||||
options.clone(),
|
||||
HealOptions::default(),
|
||||
HealPriority::High,
|
||||
);
|
||||
let request_id = request.id.clone();
|
||||
@@ -3498,37 +3524,9 @@ async fn test_get_task_report_queries_queued_task_by_token_without_path() {
|
||||
.expect("queued task should be queryable by token");
|
||||
|
||||
assert_eq!(report.status, HealTaskStatus::Pending);
|
||||
assert_eq!(report.options, Some(options));
|
||||
assert!(report.result_items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_task_report_preserves_retrying_options() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
let manager = HealManager::new_without_root_recovery_for_test(storage, None);
|
||||
let options = HealOptions {
|
||||
scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Deep,
|
||||
dry_run: true,
|
||||
recreate_missing: false,
|
||||
..Default::default()
|
||||
};
|
||||
let mut request = HealRequest::bucket("bucket-retrying-options".to_string());
|
||||
request.options = options.clone();
|
||||
let task_id = request.id.clone();
|
||||
manager.retrying_heals.lock().await.insert(
|
||||
task_id.clone(),
|
||||
RetryingHeal {
|
||||
request,
|
||||
error: "transient".to_string(),
|
||||
cancel_token: CancellationToken::new(),
|
||||
},
|
||||
);
|
||||
|
||||
let report = manager.get_task_report(&task_id).await.expect("retrying task report");
|
||||
assert!(matches!(report.status, HealTaskStatus::Retrying { .. }));
|
||||
assert_eq!(report.options, Some(options));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retrying_completion_outranks_the_queue_for_the_same_id() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
@@ -3547,7 +3545,6 @@ async fn test_retrying_completion_outranks_the_queue_for_the_same_id() {
|
||||
outcome: None,
|
||||
retained_bytes: std::sync::OnceLock::new(),
|
||||
heal_type: request.heal_type.clone(),
|
||||
options: request.options.clone(),
|
||||
status: HealTaskStatus::Retrying {
|
||||
error: "transient disk failure".to_string(),
|
||||
retry_attempt: 1,
|
||||
@@ -3588,7 +3585,6 @@ async fn test_get_task_status_reads_recent_completed_status() {
|
||||
heal_type: HealType::Bucket {
|
||||
bucket: "bucket".to_string(),
|
||||
},
|
||||
options: HealOptions::default(),
|
||||
status: HealTaskStatus::Completed,
|
||||
result_items_truncated: false,
|
||||
seqed_items: Vec::new(),
|
||||
@@ -3623,7 +3619,6 @@ async fn test_get_task_report_for_path_reads_completed_items() {
|
||||
object: "object".to_string(),
|
||||
version_id: None,
|
||||
},
|
||||
options: HealOptions::default(),
|
||||
status: HealTaskStatus::Completed,
|
||||
result_items_truncated: true,
|
||||
seqed_items: vec![(
|
||||
|
||||
@@ -17,44 +17,6 @@ use super::*;
|
||||
use crate::heal::RUSTFS_META_BUCKET;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[cfg(unix)]
|
||||
struct RestoreDirectoryMode {
|
||||
path: std::path::PathBuf,
|
||||
mode: u32,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl RestoreDirectoryMode {
|
||||
fn read_only(path: std::path::PathBuf) -> Self {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let mode = std::fs::metadata(&path)
|
||||
.expect("metadata directory mode")
|
||||
.permissions()
|
||||
.mode();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o555)).expect("make metadata directory read-only");
|
||||
Self { path, mode }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for RestoreDirectoryMode {
|
||||
fn drop(&mut self) {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let _ = std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(self.mode));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ordered_recovery_disks(first: DiskStore, second: DiskStore) -> (DiskStore, DiskStore) {
|
||||
if first.endpoint().to_string() <= second.endpoint().to_string() {
|
||||
(first, second)
|
||||
} else {
|
||||
(second, first)
|
||||
}
|
||||
}
|
||||
|
||||
async fn recovery_disk() -> (TempDir, DiskStore) {
|
||||
let temp = TempDir::new().expect("temporary root recovery disk");
|
||||
let endpoint = Endpoint::try_from(temp.path().to_string_lossy().as_ref()).expect("disk endpoint");
|
||||
@@ -100,7 +62,6 @@ fn completed_admin_status(heal_type: &HealType, completed_at: SystemTime) -> Com
|
||||
CompletedHealStatus {
|
||||
outcome: None,
|
||||
heal_type: heal_type.clone(),
|
||||
options: HealOptions::default(),
|
||||
status: HealTaskStatus::Completed,
|
||||
progress: Some(HealProgress {
|
||||
objects_scanned: 1,
|
||||
@@ -117,94 +78,6 @@ fn completed_admin_status(heal_type: &HealType, completed_at: SystemTime) -> Com
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn root_recovery_new_intent_skips_prepublication_read_only_owner() {
|
||||
let (first_temp, first_disk) = recovery_disk().await;
|
||||
let (second_temp, second_disk) = recovery_disk().await;
|
||||
let first_endpoint = first_disk.endpoint().to_string();
|
||||
let (read_only_disk, writable_disk) = ordered_recovery_disks(first_disk, second_disk);
|
||||
let read_only_root = if read_only_disk.endpoint().to_string() == first_endpoint {
|
||||
first_temp.path()
|
||||
} else {
|
||||
second_temp.path()
|
||||
};
|
||||
let _restore = RestoreDirectoryMode::read_only(read_only_root.join(RUSTFS_META_BUCKET));
|
||||
let manager = recovery_manager(vec![read_only_disk.clone(), writable_disk.clone()]);
|
||||
let mut request = admin_request(HealType::Object {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
version_id: None,
|
||||
});
|
||||
|
||||
let receipt = manager
|
||||
.submit_heal_request_with_receipt(request.clone())
|
||||
.await
|
||||
.expect("a writable local disk should own the admin heal intent");
|
||||
assert_eq!(receipt.result, HealAdmissionResult::Accepted);
|
||||
let path = format!("root-heal-{}.json", request.id);
|
||||
assert!(matches!(
|
||||
read_only_disk.read_all(RUSTFS_META_BUCKET, &path).await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
assert!(writable_disk.read_all(RUSTFS_META_BUCKET, &path).await.is_ok());
|
||||
|
||||
request.retry_attempts = 1;
|
||||
manager
|
||||
.root_recovery
|
||||
.persist(&request)
|
||||
.await
|
||||
.expect("an existing fallback owner should remain updateable");
|
||||
let pending = manager.root_recovery.pending().await.expect("read the single durable owner");
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, request.id);
|
||||
assert_eq!(pending[0].retry_attempts, 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn root_recovery_existing_owner_never_migrates_after_write_rejection() {
|
||||
let (first_temp, first_disk) = recovery_disk().await;
|
||||
let (second_temp, second_disk) = recovery_disk().await;
|
||||
let first_endpoint = first_disk.endpoint().to_string();
|
||||
let (owner_disk, alternate_disk) = ordered_recovery_disks(first_disk, second_disk);
|
||||
let owner_root = if owner_disk.endpoint().to_string() == first_endpoint {
|
||||
first_temp.path()
|
||||
} else {
|
||||
second_temp.path()
|
||||
};
|
||||
let manager = recovery_manager(vec![owner_disk.clone(), alternate_disk.clone()]);
|
||||
let mut request = root_request();
|
||||
manager
|
||||
.root_recovery
|
||||
.persist(&request)
|
||||
.await
|
||||
.expect("create the canonical owner");
|
||||
let path = format!("root-heal-{}.json", request.id);
|
||||
let committed = owner_disk
|
||||
.read_all(RUSTFS_META_BUCKET, &path)
|
||||
.await
|
||||
.expect("canonical owner bytes");
|
||||
let _restore = RestoreDirectoryMode::read_only(owner_root.join(RUSTFS_META_BUCKET));
|
||||
|
||||
request.retry_attempts = 1;
|
||||
assert!(
|
||||
manager.root_recovery.persist(&request).await.is_err(),
|
||||
"an existing owner write rejection must fail closed"
|
||||
);
|
||||
assert_eq!(
|
||||
owner_disk
|
||||
.read_all(RUSTFS_META_BUCKET, &path)
|
||||
.await
|
||||
.expect("original owner remains"),
|
||||
committed
|
||||
);
|
||||
assert!(matches!(
|
||||
alternate_disk.read_all(RUSTFS_META_BUCKET, &path).await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
async fn active_root(manager: &HealManager, request: HealRequest) -> Arc<HealTask> {
|
||||
let task = Arc::new(HealTask::from_request(request, manager.storage.clone()));
|
||||
*task.status.write().await = HealTaskStatus::Running;
|
||||
@@ -382,12 +255,9 @@ async fn root_recovery_non_admin_request_is_not_persisted() {
|
||||
async fn root_recovery_path_cancel_covers_durable_only_non_root_record() {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = recovery_manager(vec![disk.clone()]);
|
||||
let mut request = admin_request(HealType::Bucket {
|
||||
let request = admin_request(HealType::Bucket {
|
||||
bucket: "bucket".to_string(),
|
||||
});
|
||||
request.options.scan_mode = rustfs_heal_contracts::heal_channel::HealScanMode::Deep;
|
||||
request.options.dry_run = true;
|
||||
request.options.recreate_missing = false;
|
||||
manager
|
||||
.root_recovery
|
||||
.persist(&request)
|
||||
@@ -422,14 +292,6 @@ async fn root_recovery_path_cancel_covers_durable_only_non_root_record() {
|
||||
.expect("durable cancellation remains queryable by id"),
|
||||
HealTaskStatus::Cancelled
|
||||
);
|
||||
assert_eq!(
|
||||
restarted
|
||||
.get_task_report(&request.id)
|
||||
.await
|
||||
.expect("durable cancellation report")
|
||||
.options,
|
||||
Some(request.options)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -492,7 +354,7 @@ async fn root_recovery_terminal_receipt_wins_over_stale_pending_scoped_intent_af
|
||||
.await
|
||||
.expect("durable bucket responsibility");
|
||||
manager
|
||||
.publish_admin_cancelled_terminal(&request.id, &request.heal_type, request.source, &request.options)
|
||||
.publish_admin_cancelled_terminal(&request.id, &request.heal_type, request.source)
|
||||
.await
|
||||
.expect("publish terminal receipt");
|
||||
manager
|
||||
@@ -529,12 +391,9 @@ async fn root_recovery_terminal_receipt_wins_over_stale_pending_scoped_intent_af
|
||||
async fn root_recovery_completed_non_root_admin_is_queryable_after_restart() {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = recovery_manager(vec![disk.clone()]);
|
||||
let mut request = admin_request(HealType::Bucket {
|
||||
let request = admin_request(HealType::Bucket {
|
||||
bucket: "bucket".to_string(),
|
||||
});
|
||||
request.options.scan_mode = rustfs_heal_contracts::heal_channel::HealScanMode::Deep;
|
||||
request.options.dry_run = true;
|
||||
request.options.recreate_missing = false;
|
||||
manager
|
||||
.root_recovery
|
||||
.persist(&request)
|
||||
@@ -543,7 +402,6 @@ async fn root_recovery_completed_non_root_admin_is_queryable_after_restart() {
|
||||
let completed = CompletedHealStatus {
|
||||
outcome: None,
|
||||
heal_type: request.heal_type.clone(),
|
||||
options: request.options.clone(),
|
||||
status: HealTaskStatus::Completed,
|
||||
progress: Some(HealProgress {
|
||||
objects_scanned: 2,
|
||||
@@ -590,49 +448,6 @@ async fn root_recovery_completed_non_root_admin_is_queryable_after_restart() {
|
||||
.expect("completed terminal exposes progress");
|
||||
assert_eq!(progress.objects_scanned, 2);
|
||||
assert_eq!(progress.objects_healed, 2);
|
||||
assert_eq!(
|
||||
restarted
|
||||
.get_task_report(&request.id)
|
||||
.await
|
||||
.expect("completed terminal report")
|
||||
.options,
|
||||
Some(request.options)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_recovery_legacy_terminal_without_options_uses_defaults() {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = recovery_manager(vec![disk.clone()]);
|
||||
let request = admin_request(HealType::Bucket {
|
||||
bucket: "legacy-bucket".to_string(),
|
||||
});
|
||||
let completed = completed_admin_status(&request.heal_type, SystemTime::now());
|
||||
manager
|
||||
.publish_admin_terminal(&request.id, &request.heal_type, request.source, &completed)
|
||||
.await
|
||||
.expect("publish terminal receipt");
|
||||
|
||||
let path = format!("terminal-root-heal-{}.json", request.id);
|
||||
let bytes = disk.read_all(RUSTFS_META_BUCKET, &path).await.expect("read terminal receipt");
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&bytes).expect("decode terminal receipt");
|
||||
value.as_object_mut().expect("terminal object").remove("options");
|
||||
disk.write_all(
|
||||
RUSTFS_META_BUCKET,
|
||||
&path,
|
||||
serde_json::to_vec(&value).expect("encode legacy receipt").into(),
|
||||
)
|
||||
.await
|
||||
.expect("write legacy terminal receipt");
|
||||
drop(manager);
|
||||
|
||||
let restarted = recovery_manager(vec![disk]);
|
||||
let report = restarted
|
||||
.get_task_report(&request.id)
|
||||
.await
|
||||
.expect("legacy terminal remains queryable");
|
||||
assert_eq!(report.status, HealTaskStatus::Completed);
|
||||
assert_eq!(report.options, Some(HealOptions::default()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -100,6 +100,34 @@ async fn minio_permanent_identities_survive_migration_and_repeated_iam_loads() {
|
||||
.await;
|
||||
env.make_bucket(LEGACY_META_BUCKET, false).await;
|
||||
|
||||
for (path, body) in [
|
||||
("config/iam/empty.json", Vec::new()),
|
||||
("config/iam/users/ignored/extra.json", b"not JSON".to_vec()),
|
||||
] {
|
||||
env.put_object_bytes(LEGACY_META_BUCKET, path, body).await;
|
||||
}
|
||||
try_migrate_iam_config(
|
||||
env.ecstore.clone(),
|
||||
Some(std::sync::Arc::new(|_| panic!("unsupported IAM records must not be decrypted"))),
|
||||
)
|
||||
.await
|
||||
.expect("unsupported IAM records, including empty objects, must be skipped");
|
||||
|
||||
let format_path = "config/iam/format.json";
|
||||
for body in [Vec::new(), b"invalid IAM format".to_vec()] {
|
||||
env.put_object_bytes(LEGACY_META_BUCKET, format_path, body).await;
|
||||
let error = try_migrate_iam_config(env.ecstore.clone(), None)
|
||||
.await
|
||||
.expect_err("empty or incompatible supported IAM metadata must prevent startup readiness");
|
||||
let io_error = std::io::Error::from(error);
|
||||
let detail = io_error
|
||||
.get_ref()
|
||||
.and_then(|context| context.source())
|
||||
.expect("failure must retain the supported record in its source");
|
||||
assert!(detail.to_string().contains(format_path), "failure must identify the supported record");
|
||||
}
|
||||
seed_legacy_iam_object(&env, format_path, &json!({"version": 1})).await;
|
||||
|
||||
let regular_source = json!({
|
||||
"version": 1,
|
||||
"credentials": {
|
||||
@@ -155,7 +183,12 @@ async fn minio_permanent_identities_survive_migration_and_repeated_iam_loads() {
|
||||
)
|
||||
.await;
|
||||
|
||||
try_migrate_iam_config(env.ecstore.clone(), None).await;
|
||||
try_migrate_iam_config(env.ecstore.clone(), None)
|
||||
.await
|
||||
.expect("legacy IAM migration completes after source repair");
|
||||
try_migrate_iam_config(env.ecstore.clone(), None)
|
||||
.await
|
||||
.expect("completed legacy IAM migration is idempotent");
|
||||
|
||||
let store = ObjectStore::new(env.ecstore);
|
||||
assert_identity_survives(
|
||||
|
||||
@@ -22,8 +22,9 @@ When changing key-management behavior, verify compatibility with:
|
||||
For local KMS end-to-end tests, keep proxy bypass settings:
|
||||
|
||||
```bash
|
||||
python3 scripts/e2e_binary.py build
|
||||
NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= \
|
||||
cargo test --package e2e_test test_local_kms_end_to_end -- --nocapture --test-threads=1
|
||||
python3 scripts/e2e_binary.py run -- cargo test --package e2e_test test_local_kms_end_to_end -- --nocapture --test-threads=1
|
||||
```
|
||||
|
||||
### Black-box behavior suite and the Vault lane
|
||||
|
||||
@@ -29,6 +29,8 @@ use rustfs_kms::{
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
const LIVE_KEY_DESCRIPTION: &str = "AppRole live metadata";
|
||||
|
||||
fn assert_approle_config(config: &KmsConfig, expected_backend: KmsBackend) {
|
||||
assert_eq!(config.backend, expected_backend);
|
||||
let auth_method = match &config.backend_config {
|
||||
@@ -42,12 +44,13 @@ fn assert_approle_config(config: &KmsConfig, expected_backend: KmsBackend) {
|
||||
);
|
||||
}
|
||||
|
||||
async fn exercise_backend<B: KmsBackendTrait + ?Sized>(backend: &B, key_prefix: &str) -> rustfs_kms::Result<()> {
|
||||
async fn exercise_backend<B: KmsBackendTrait + ?Sized>(backend: &B, key_prefix: &str) -> rustfs_kms::Result<String> {
|
||||
let key_id = format!("{key_prefix}-{}", uuid::Uuid::new_v4());
|
||||
let created = backend
|
||||
.create_key(CreateKeyRequest {
|
||||
key_name: Some(key_id.clone()),
|
||||
key_usage: KeyUsage::EncryptDecrypt,
|
||||
description: Some(LIVE_KEY_DESCRIPTION.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
@@ -57,6 +60,7 @@ async fn exercise_backend<B: KmsBackendTrait + ?Sized>(backend: &B, key_prefix:
|
||||
.describe_key(rustfs_kms::DescribeKeyRequest { key_id: key_id.clone() })
|
||||
.await?;
|
||||
assert_eq!(described.key_metadata.key_id, key_id);
|
||||
assert_eq!(described.key_metadata.description.as_deref(), Some(LIVE_KEY_DESCRIPTION));
|
||||
|
||||
let listed = backend
|
||||
.list_keys(ListKeysRequest {
|
||||
@@ -88,7 +92,7 @@ async fn exercise_backend<B: KmsBackendTrait + ?Sized>(backend: &B, key_prefix:
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(unwrapped.plaintext, generated.plaintext_key);
|
||||
Ok(())
|
||||
Ok(key_id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -97,7 +101,7 @@ async fn vault_kv2_approle_auth_live() -> rustfs_kms::Result<()> {
|
||||
let config = KmsConfig::from_env()?;
|
||||
assert_approle_config(&config, KmsBackend::VaultKv2);
|
||||
let backend = VaultKmsBackend::new(config).await?;
|
||||
exercise_backend(&backend, "rustfs-approle-kv2").await
|
||||
exercise_backend(&backend, "rustfs-approle-kv2").await.map(|_| ())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -109,9 +113,34 @@ async fn vault_transit_approle_auth_live() -> rustfs_kms::Result<()> {
|
||||
BackendConfig::VaultTransit(vault) => vault,
|
||||
_ => panic!("expected Vault Transit configuration"),
|
||||
};
|
||||
assert_eq!(transit.mount_path, "transit");
|
||||
assert_eq!(transit.metadata_kv_mount, DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT);
|
||||
assert_eq!(transit.metadata_key_prefix, DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX);
|
||||
|
||||
let backend = VaultTransitKmsBackend::new(config).await?;
|
||||
exercise_backend(&backend, "rustfs-approle-transit").await
|
||||
exercise_backend(&backend, "rustfs-approle-transit").await.map(|_| ())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a real Vault AppRole; run scripts/test/vault_approle_kms_live.sh"]
|
||||
async fn vault_transit_approle_custom_paths_live() -> rustfs_kms::Result<()> {
|
||||
let config = KmsConfig::from_env()?;
|
||||
assert_approle_config(&config, KmsBackend::VaultTransit);
|
||||
let BackendConfig::VaultTransit(transit) = &config.backend_config else {
|
||||
panic!("expected Vault Transit configuration");
|
||||
};
|
||||
assert_eq!(transit.mount_path, "kms-test/transit");
|
||||
assert_eq!(transit.metadata_kv_mount, "kms-test/metadata");
|
||||
assert_eq!(transit.metadata_key_prefix, "custom/transit-metadata");
|
||||
|
||||
let backend = VaultTransitKmsBackend::new(config.clone()).await?;
|
||||
let key_id = exercise_backend(&backend, "rustfs-approle-custom-transit").await?;
|
||||
drop(backend);
|
||||
|
||||
// A fresh backend must read the stored record, not synthesize Enabled
|
||||
// metadata or reuse a record cached during key creation.
|
||||
let restarted = VaultTransitKmsBackend::new(config).await?;
|
||||
let described = restarted.describe_key(rustfs_kms::DescribeKeyRequest { key_id }).await?;
|
||||
assert_eq!(described.key_metadata.description.as_deref(), Some(LIVE_KEY_DESCRIPTION));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -730,12 +730,16 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
// Highest priority first, like MinIO's `FilterActionableRules`. The
|
||||
// tie-breakers make this a total order: a comparator that only
|
||||
// orders same-destination pairs is not transitive, and the standard
|
||||
// library sort panics on such inputs past its insertion-sort
|
||||
// threshold (backlog#2367 C-1).
|
||||
rules.sort_by(|a, b| {
|
||||
if a.destination == b.destination {
|
||||
b.priority.cmp(&a.priority)
|
||||
} else {
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
b.priority
|
||||
.cmp(&a.priority)
|
||||
.then_with(|| a.destination.bucket.cmp(&b.destination.bucket))
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
|
||||
rules
|
||||
@@ -813,24 +817,19 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
|
||||
return vec![role.to_string()];
|
||||
}
|
||||
|
||||
let mut arns = Vec::new();
|
||||
let mut targets_map: HashSet<String> = HashSet::new();
|
||||
let rules = self.filter_actionable_rules(obj);
|
||||
|
||||
for rule in rules {
|
||||
// Rule order (priority descending) is the ARN order: callers that
|
||||
// iterate targets see the highest-priority destination first.
|
||||
let mut arns: Vec<String> = Vec::new();
|
||||
for rule in self.filter_actionable_rules(obj) {
|
||||
if rule.status == ReplicationRuleStatus::from_static(ReplicationRuleStatus::DISABLED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let arn = rule.destination.bucket.trim();
|
||||
if !arn.is_empty() && !targets_map.contains(arn) {
|
||||
targets_map.insert(arn.to_string());
|
||||
if !arn.is_empty() && !arns.iter().any(|seen| seen == arn) {
|
||||
arns.push(arn.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
for arn in targets_map {
|
||||
arns.push(arn);
|
||||
}
|
||||
arns
|
||||
}
|
||||
|
||||
@@ -1908,6 +1907,84 @@ mod tests {
|
||||
assert_eq!(decisions, vec![(target_a.to_string(), false), (target_b.to_string(), true)]);
|
||||
}
|
||||
|
||||
// backlog#2367 C-1: the actionable-rule sort must be a total order. A
|
||||
// comparator that answers `Equal` for different destinations but orders
|
||||
// same-destination rules by priority is not transitive, and the standard
|
||||
// library sort panics on such inputs once the slice is past the
|
||||
// insertion-sort threshold (> 20 rules).
|
||||
#[test]
|
||||
fn actionable_rule_sort_is_a_total_order_across_destinations() {
|
||||
let targets = ["arn:target:a", "arn:target:b", "arn:target:c"];
|
||||
let mut seed: u64 = 0x2367;
|
||||
for _ in 0..200 {
|
||||
let rule_count = 21 + (seed % 200) as usize;
|
||||
let rules = (0..rule_count)
|
||||
.map(|index| {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
let target = targets[(seed >> 33) as usize % targets.len()];
|
||||
delete_marker_rule(&format!("r{index}"), target, "", index as i32, true)
|
||||
})
|
||||
.collect();
|
||||
let config = ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules,
|
||||
};
|
||||
let ordered = config.filter_actionable_rules(&ObjectOpts {
|
||||
name: "logs/app.log".to_string(),
|
||||
op_type: ReplicationType::Object,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(ordered.len(), rule_count);
|
||||
assert!(
|
||||
ordered.windows(2).all(|pair| pair[0].priority >= pair[1].priority),
|
||||
"actionable rules must be ordered by descending priority"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// backlog#2367 C-2: a V1 rule carries its prefix at the top level (no
|
||||
// <Filter>). Ignoring it made `<Prefix>logs/</Prefix>` match every object.
|
||||
#[test]
|
||||
fn top_level_rule_prefix_scopes_matching_without_a_filter() {
|
||||
let arn = "arn:target:a";
|
||||
let config = ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![delete_marker_rule("v1-prefix", arn, "logs/", 1, true)],
|
||||
};
|
||||
assert_eq!(config.rules[0].prefix(), "logs/");
|
||||
|
||||
let matching = config.filter_actionable_rules(&ObjectOpts {
|
||||
name: "logs/app.log".to_string(),
|
||||
op_type: ReplicationType::Object,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(matching.len(), 1);
|
||||
|
||||
let outside = config.filter_actionable_rules(&ObjectOpts {
|
||||
name: "data/app.log".to_string(),
|
||||
op_type: ReplicationType::Object,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(outside.is_empty(), "an object outside the V1 prefix must not match: {outside:?}");
|
||||
assert!(
|
||||
config
|
||||
.filter_target_arns(&ObjectOpts {
|
||||
name: "data/app.log".to_string(),
|
||||
op_type: ReplicationType::Object,
|
||||
..Default::default()
|
||||
})
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// A <Filter> still wins over the deprecated top-level element.
|
||||
let mut filtered = delete_marker_rule("filtered", arn, "logs/", 1, true);
|
||||
filtered.filter = Some(s3s::dto::ReplicationRuleFilter {
|
||||
prefix: Some("photos/".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(filtered.prefix(), "photos/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_delete_targets_use_overlapping_rules_and_highest_priority_switch() {
|
||||
let target_a = "arn:target:a";
|
||||
|
||||
@@ -22,6 +22,10 @@ pub trait ReplicationRuleExt {
|
||||
}
|
||||
|
||||
impl ReplicationRuleExt for ReplicationRule {
|
||||
/// The rule's key prefix: `Filter.Prefix`, else `Filter.And.Prefix`, else
|
||||
/// the deprecated top-level `Prefix` of a V1 rule written without a
|
||||
/// `<Filter>` (backlog#2367 C-2). A rule that carries both keeps AWS's
|
||||
/// precedence: the `<Filter>` is authoritative.
|
||||
fn prefix(&self) -> &str {
|
||||
if let Some(filter) = &self.filter {
|
||||
if let Some(prefix) = &filter.prefix {
|
||||
@@ -32,7 +36,7 @@ impl ReplicationRuleExt for ReplicationRule {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
self.prefix.as_deref().unwrap_or("")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1691,7 +1691,7 @@ mod serial_tests {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
#[ignore = "FAILING on main: excluded from the serial ILM lane pending a fix, see rustfs/backlog#1148 (ilm-1 partial)"]
|
||||
async fn test_noncurrent_expiry_still_works_after_immediate_compensation_transition() {
|
||||
let (disk_paths, ecstore) = setup_isolated_test_env(true).await;
|
||||
|
||||
@@ -1775,7 +1775,7 @@ mod serial_tests {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
#[ignore = "FAILING on main: excluded from the serial ILM lane pending a fix, see rustfs/backlog#1148 (ilm-1 partial)"]
|
||||
async fn test_noncurrent_transition_still_works_after_immediate_compensation_transition() {
|
||||
let (disk_paths, ecstore) = setup_isolated_test_env(true).await;
|
||||
|
||||
|
||||
@@ -30,23 +30,11 @@ These are approved-target invariants. A protocol's explicitly labeled current ex
|
||||
| Remote PUT is in flight or its response is unknown | Transition transaction | Only cleanup of its own canonical candidate, subject to the transaction recovery predicate | Durable transaction identity plus a known remote-version state; the approved target also requires expiry and durable takeover of the creator fence |
|
||||
| Local transition commit is complete | Exact transitioned version in `xl.meta` | No | Recovery finds the transaction's logical bucket/object/version and requires the complete recorded source identity (version ID, data directory, modification time, size, and ETag), `TRANSITION_COMPLETE`, and the same remote object, tier, and remote version before removing only the transaction record |
|
||||
| An ordinary delete removes that transitioned version | Hidden `xl.meta` free-version | Yes | Metadata quorum atomically removes the visible version and preserves its exact tier tuple in the free-version |
|
||||
| PUT or materialized self-copy replaces a transitioned null version | Hidden `xl.meta` free-version | Yes, after replacement commit and complete physical reference checks | The coordinator supplies one cleanup UUID to every disk; replacement metadata and the old remote tuple are written in the same `xl.meta` commit |
|
||||
| A recursive prefix/delete-all operation cannot preserve per-object markers | v6 journal bound to an immutable single dispatch manifest or a chunk-parent-bound child manifest | Yes, but only after child/manifest completion and all-pool absence proof | `DispatchAuthorized`, exact local destructive mutation, every journal `Committed`, then child/manifest `Completed`; a chunk parent advances only after that child completion |
|
||||
| Tier configuration mutation, manual job, or decommission receipt | Intent/admission/copy proof only | No | These records gate configuration, scheduling, or migration; they never become remote-object cleanup owners |
|
||||
|
||||
An old journal and a free-version can coexist during compatibility recovery. That coexistence is evidence of multiple possible owners, not permission to choose one: the journal path must retain its record until the version-specific recovery rule proves which owner is authoritative.
|
||||
|
||||
Null-version replacement uses the existing free-version format and recovery
|
||||
worker. Failed metadata preparation preserves both the old version and its
|
||||
inline bytes; rename rollback restores the complete previous metadata. Recovery
|
||||
must retain cleanup while any physical replica still references the remote tuple,
|
||||
including a minority version omitted by quorum merging, or any disk cannot be
|
||||
checked. A successful replacement needs no in-memory queue receipt to survive
|
||||
restart: the normal free-version sweep discovers its committed owner. Restores
|
||||
that retain the same remote tuple and ordinary versioned writes retain their
|
||||
existing ownership. Older binaries can read this format, but all writers and
|
||||
cleanup workers need the overwrite fix before these guarantees cover the fleet.
|
||||
|
||||
## Persisted record inventory
|
||||
|
||||
All keys below are objects in the internal metadata bucket. The table gives the canonical target form. Transition-transaction runtime recovery extracts the final 32 hexadecimal characters and UUID while ignoring shard directories and accepting uppercase hex. Manual-job runtime recovery requires exactly two shards matching the filename prefix, but accepts an uppercase UUID when the shards use the same uppercase text; it then loads the lowercase canonical job by UUID. The decommission validator recomputes and rejects a noncanonical manual-job path, but currently inherits the weaker transition parser. Exact runtime canonical-path validation for both protocols is an approved target.
|
||||
|
||||
@@ -13,7 +13,7 @@ ODM configuration is stored in two additional keys in the existing bucket metada
|
||||
|
||||
Upgrade every node before enabling ODM. Before any rollback to rc.5, stop new migration work, retain a secure copy of the original full configuration and credentials, and disable ODM on every bucket and node. The redacted configuration GET and metadata export are not credential backups. Objects still present only at the source cannot be read through RustFS while ODM is disabled or rc.5 is running; finish migration first, redirect those reads to the source, or plan a maintenance window. After all nodes return to a compatible release, reapply and validate the saved configuration; already stored local objects remain local. Turning the global module switch off alone does not make an old metadata writer preserve these keys.
|
||||
|
||||
The ignored `upgrade_compatibility_test::rc5_rollback_requires_restoring_odm_configuration` test pins release commit `40a2470feb567201165a5b809b7598bb4b1f68f5`, restarts against the same data directory, writes bucket tags through rc.5, and verifies configuration recovery after returning to the current binary. Set `RUSTFS_UPGRADE_SOURCE_BINARY` to that release's executable and run `cargo test -p e2e_test rc5_rollback_requires_restoring_odm_configuration -- --ignored --test-threads=1`. The test records a known old-writer limitation; it does not certify mixed-version ODM operation.
|
||||
The ignored `upgrade_compatibility_test::rc5_rollback_requires_restoring_odm_configuration` test pins release commit `40a2470feb567201165a5b809b7598bb4b1f68f5`, restarts against the same data directory, writes bucket tags through rc.5, and verifies configuration recovery after returning to the current binary. Set `RUSTFS_UPGRADE_SOURCE_BINARY` to that release's executable, build the current binary with `python3 scripts/e2e_binary.py build`, and run `python3 scripts/e2e_binary.py run -- cargo test -p e2e_test rc5_rollback_requires_restoring_odm_configuration -- --ignored --test-threads=1`. The test records a known old-writer limitation; it does not certify mixed-version ODM operation.
|
||||
|
||||
## Optional Google dependencies
|
||||
|
||||
|
||||
@@ -102,10 +102,16 @@ Cloudflare's proxy may buffer the entire request body before forwarding and can
|
||||
1. Bypass the proxy. Send the failing request to `http://<host>:9000` directly. Success confirms the fault is in the proxy/CDN path.
|
||||
2. Bypass the CDN, keep the proxy. Point the proxy straight at the origin (Cloudflare grey cloud / direct DNS). If it now works, the CDN was buffering or re-chunking the body.
|
||||
3. Check idle reuse. Intermittent failures that correlate with upload size are almost always the keep-alive mismatch. Lower the proxy keepalive (or disable it) and retry.
|
||||
4. Check for a truncated body. If the upload hangs indefinitely rather than resetting, the proxy is forwarding a partial body and then going silent without closing the connection. RustFS bounds this wait with `RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT` (`DEFAULT_HTTP_REQUEST_BODY_READ_TIMEOUT`, 300; `0` disables) and on timeout logs `put_object_body_read_stalled` with the received/expected byte counts.
|
||||
4. Check for a stalled body. A client or intermediary can stop forwarding data without closing the connection. `RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT` defaults to 300 seconds. `PutObject` logs `put_object_body_read_stalled` when its body-read guard expires. `UploadPart` logs `upload_part_body_read_stalled` and returns `RequestTimeout` (HTTP 400); its log records `raw_bytes_received`, `expected_decoded_bytes`, `timeout_secs`, bucket, key, and request ID. The event identifies missing input progress, without attributing the cause to a particular proxy.
|
||||
5. Compare bytes. Confirm the proxy forwards exactly `Content-Length` body bytes with no compression or transformation.
|
||||
6. Confirm signed headers survive. `Host` and `x-amz-*` must reach RustFS unchanged; a `SignatureDoesNotMatch` (rather than a hang) points here.
|
||||
|
||||
For HTTP `UploadPart`, the inactivity budget counts time waiting for raw request-body bytes while storage is requesting input. Positive raw bytes reset the budget, including fragments of a signed AWS chunk that has not yet finished decoding. Foreground admission, capped-session staging, and storage backpressure do not consume the budget. Finishing the declared payload does not bypass the signed terminator, required trailers, or final body validation. This is an inactivity limit, so an upload making progress can take longer than 300 seconds overall.
|
||||
|
||||
Setting the timeout to `0` disables it for ordinary uploads. Multipart sessions with an explicit total-object-size cap retain a minimum 300-second timeout, including when the configured value is `0`. After a body-stall timeout, HTTP/1 uses the existing raw-body drain and closes the connection; HTTP/2 releases the affected stream and keeps the connection usable.
|
||||
|
||||
`UploadPart` requires a known logical byte length. RustFS uses the length normalized by S3S after authentication and decoding, with an exact logical stream length as a fallback. A bare `x-amz-decoded-content-length` or `Content-Encoding: aws-chunked` declaration cannot supply this length by itself. Requests reaching an ordinary upload session without a known length return `MissingContentLength` (HTTP 411) before body ingestion; capped sessions retain their `UnexpectedContent` rejection. Preserve the client's framing and signed headers through the proxy. The 5 GiB limit applies to each part request, not the combined size of an ordinary multipart upload.
|
||||
|
||||
## Known failure signatures
|
||||
|
||||
| Symptom | Forwarding fault | Issue |
|
||||
|
||||
@@ -126,13 +126,15 @@ promtool test rules storage-rules.test.yml
|
||||
The native pipeline test in
|
||||
`crates/e2e_test/src/storage_metric_ownership_test.rs` requires pinned Collector,
|
||||
Prometheus, previous-release RustFS, and current RustFS executables. Set
|
||||
`RUSTFS_OTELCOL_BINARY`, `RUSTFS_PROMETHEUS_BINARY`,
|
||||
`RUSTFS_METRICS_BASELINE_BINARY`, and `CARGO_BIN_EXE_rustfs` to those files.
|
||||
`RUSTFS_OTELCOL_BINARY`, `RUSTFS_PROMETHEUS_BINARY`, and
|
||||
`RUSTFS_METRICS_BASELINE_BINARY` to those files; `scripts/e2e_binary.py run`
|
||||
supplies the current RustFS as `CARGO_BIN_EXE_rustfs`.
|
||||
Optionally set `RUSTFS_METRICS_E2E_ARTIFACTS` to retain logs and Prometheus data.
|
||||
Run only this external-tool test:
|
||||
|
||||
```bash
|
||||
cargo test --locked -p e2e_test storage_metric_ownership_pipeline -- --ignored --nocapture
|
||||
python3 scripts/e2e_binary.py build
|
||||
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test storage_metric_ownership_pipeline -- --ignored --nocapture
|
||||
```
|
||||
|
||||
The test first reproduces duplicated global details with the previous release,
|
||||
|
||||
@@ -11,8 +11,8 @@ Pick the lowest layer that can prove the change; add a higher-layer test only wh
|
||||
|---|---|---|---|
|
||||
| Unit & crate integration | Per-crate logic and in-process integration tests | `cargo nextest run --all --exclude e2e_test` (or `-p <crate>`); `make test` wraps it | Every PR, required (`Test and Lint`, `ci` profile) |
|
||||
| ecstore black-box | Erasure-coded read/write/recovery validation; profiles `quick` / `full` / `destructive` / `fuzz` | `scripts/run_ecstore_validation_suite.sh --profile quick` | Local and release validation only; not wired into any workflow. Contract: [ecstore-validation-suite-design.md](ecstore-validation-suite-design.md) |
|
||||
| e2e (`e2e_test` crate) | A real `rustfs` binary per test, driven over the S3, admin, and protocol APIs | `cargo nextest run --profile e2e-smoke -p e2e_test` | PR: `e2e-smoke` (report-only); merge queue / main push: `e2e-full`; nightly: `e2e-repl-nightly`, `e2e-nightly`, `e2e-protocols`, `e2e-distributed`. Guide: [`crates/e2e_test/README.md`](../../crates/e2e_test/README.md); 4-node 4-disk map: [distributed-e2e.md](distributed-e2e.md) |
|
||||
| Outbound target matrix | Replication of every object shape (empty, plain, retention, legal hold, multipart) against every remote-target failure mode the fake target models; an explicit expectation table pins known-red cells to an open issue | `cargo nextest run -p e2e_test -E 'test(/^replication_target_matrix_test::/)'` (build `target/debug/rustfs` first) | With `e2e-repl-nightly`; required locally for any change to outbound client defaults (SOP: [`docs/postmortems/2026-09-03-replication-checksum-default-regression.md`](../postmortems/2026-09-03-replication-checksum-default-regression.md)) |
|
||||
| e2e (`e2e_test` crate) | A real `rustfs` binary per test, driven over the S3, admin, and protocol APIs | `python3 scripts/e2e_binary.py build --features e2e-test-hooks`, then `python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test` | PR: `e2e-smoke` (report-only); merge queue / main push: `e2e-full`; nightly: `e2e-repl-nightly`, `e2e-nightly`, `e2e-protocols`, `e2e-distributed`. Guide: [`crates/e2e_test/README.md`](../../crates/e2e_test/README.md); 4-node 4-disk map: [distributed-e2e.md](distributed-e2e.md) |
|
||||
| Outbound target matrix | Replication of every object shape (empty, plain, retention, legal hold, multipart) against every remote-target failure mode the fake target models; an explicit expectation table pins known-red cells to an open issue | `python3 scripts/e2e_binary.py build`, then `python3 scripts/e2e_binary.py run -- cargo nextest run -p e2e_test -E 'test(/^replication_target_matrix_test::/)'` | With `e2e-repl-nightly`; required locally for any change to outbound client defaults (SOP: [`docs/postmortems/2026-09-03-replication-checksum-default-regression.md`](../postmortems/2026-09-03-replication-checksum-default-regression.md)) |
|
||||
| s3s-e2e conformance | External S3 conformance tool against a live server | `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>` | PR, report-only (second half of the `End-to-End Tests` job) |
|
||||
| S3 compatibility | `ceph/s3-tests` (boto3; allow-list `scripts/s3-tests/implemented_tests.txt`) and MinIO `mint` | `scripts/s3-tests/run.sh`; mint via `.github/workflows/mint.yml` | s3-tests: PR report-only plus a weekly full sweep; mint: weekly, report-only |
|
||||
| Chaos / fault-injection | Single-node disk fault injection (`crates/e2e_test/src/chaos.rs`, `crates/e2e_test/src/fault_proxy.rs`) plus the 4-node kill/fresh-drive/blackhole cases in `crates/e2e_test/src/distributed/chaos_test.rs` | Part of the e2e crate (`e2e-reliability` and `e2e-distributed`) | Reliability cases with `e2e-full`; 4-node chaos on storage-sensitive PRs and nightly via `e2e-distributed` |
|
||||
|
||||
@@ -40,9 +40,9 @@ The aggregate requires the validation lanes already selected by `ci.yml`; this c
|
||||
| PR, non-doc change | `ILM Integration (serial)` | `ci.yml` `test-ilm-integration-serial` | Via aggregate | exact command in the job |
|
||||
| PR, non-doc change | `Test and Lint (rio-v2)`, `Test and Lint (swift)`, `Test and Lint (sftp)` | `ci.yml` `test-and-lint-rio-v2`, `test-and-lint-protocols` | Via aggregate | `cargo nextest run` with the job's `--features` |
|
||||
| PR, non-doc change | `Connect Short Credential Boundary` | `ci.yml` `connect-short-credential-boundary` | Via aggregate | `cargo test -p rustfs --test connect_registration --features connect-e2e-short-credentials`; `cargo check -p rustfs --release --features connect-e2e-short-credentials` must fail |
|
||||
| PR, non-doc change | `Build RustFS Debug Binary` | `ci.yml` `build-rustfs-debug-binary` | Via aggregate; prerequisite for black-box jobs | `cargo build -p rustfs --bins --features e2e-test-hooks` |
|
||||
| PR, non-doc change | `Build RustFS Debug Binary` | `ci.yml` `build-rustfs-debug-binary` | Via aggregate; prerequisite for black-box jobs | `python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks` (binary plus its `rustfs.e2e.json` sidecar) |
|
||||
| PR, non-doc change | `io_uring Integration (real)` | `ci.yml` `uring-integration` | Via aggregate | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
|
||||
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Via aggregate | `cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>`; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
|
||||
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Via aggregate | `python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>` under the same wrapper; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
|
||||
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Via aggregate | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
|
||||
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Via aggregate | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
|
||||
| PR touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||
@@ -51,11 +51,11 @@ The aggregate requires the validation lanes already selected by `ci.yml`; this c
|
||||
| PR touching `paths` in `fuzz.yml` | `Build Fuzz Harness`, `Smoke / <target>` | `fuzz.yml` `fuzz-build`, `pr-fuzz-smoke` | Report-only | `MAX_TOTAL_TIME=60 ./scripts/fuzz/run.sh` |
|
||||
| PR touching `paths` in `windows-filesystem.yml` | `Rename Safety` | `windows-filesystem.yml` `rename-safety` | Report-only | the `cargo test -p rustfs-ecstore --lib <filter>` commands in the job, on Windows |
|
||||
| PR touching `paths` in `coverage.yml` | `Workspace line coverage` | `coverage.yml` `coverage` | Report-only | `make coverage`; `python3 scripts/check_security_coverage.py target/llvm-cov/coverage.json` |
|
||||
| PR touching `paths` in `e2e-upgrade.yml` | `Direct upgrade from the previous release`, `Mixed-version rolling upgrade from the previous release`, `Bucket configuration survives the upgrade`, `Rollback reads current bucket metadata` | `e2e-upgrade.yml` `upgrade` matrix | Report-only | the `cargo test --locked -p e2e_test` command in the job with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release (`UPGRADE_SOURCE_VERSION`) |
|
||||
| PR touching `paths` in `e2e-upgrade.yml` | `Direct upgrade from the previous release`, `Mixed-version rolling upgrade from the previous release`, `Bucket configuration survives the upgrade`, `Rollback reads current bucket metadata` | `e2e-upgrade.yml` `upgrade` matrix | Report-only | `python3 scripts/e2e_binary.py build`, then the job's `python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test` command with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release (`UPGRADE_SOURCE_VERSION`) |
|
||||
| PR touching `paths` in `oidc-keycloak.yml` | `OIDC Keycloak live gate` | `oidc-keycloak.yml` `oidc-keycloak-live` | Report-only | `cargo build --locked -p rustfs --bin rustfs`, then `bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs` |
|
||||
| PR touching `paths` in `targets-integration.yml` | `PostgreSQL, MySQL, AMQP, and NATS` | `targets-integration.yml` `targets-live` | Report-only | start the containers as in the job, export the `RUSTFS_TEST_*` DSNs, then the job's `cargo test --locked -p rustfs-targets --test <name> -- --ignored --test-threads=1` commands |
|
||||
| PR, documentation-only selection | `Quick Checks`, `Typos`, `Test and Lint` | `ci.yml` `quick-checks`, `typos`, `required-checks` | Required directly or via aggregate | Quick Checks commands; `python3 scripts/ci_gate.py --self-test` |
|
||||
| `merge_group`; push to `main` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Via aggregate on these events | `cargo nextest run --profile e2e-full -p e2e_test` |
|
||||
| `merge_group`; push to `main` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Via aggregate on these events | `python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test` (CI adds `--binary "$RUSTFS_E2E_STARTUP_CAS_BINARY"` for the downloaded binary and sidecar) |
|
||||
|
||||
e2e filters live in `.config/nextest.toml`; extend a profile instead of adding a second selector. Before a profile runs, `scripts/check_test_wiring.py` compares its listing to the committed digest in `.config/e2e-<profile>-selection.txt`, so a silent test drop fails closed.
|
||||
|
||||
@@ -75,12 +75,12 @@ Scheduled lanes never block a PR. Their workflow-local gate fails the run, sched
|
||||
|---|---|---|---|---|
|
||||
| `ci.yml` (weekly) | full matrix, including the schedule/dispatch-only rio-v2 jobs `build-rustfs-debug-binary-rio-v2` and `e2e-tests-rio-v2` | strict aggregate; the full E2E lane runs on dispatch, merge groups, and main pushes | yes | dispatch `ci.yml` |
|
||||
| `build.yml` (weekly) | `build-rustfs` over the six-target platform matrix in `prepare-platform-matrix` (four Linux, macOS aarch64, Windows x86_64) | build/package integrity | yes | dispatch `build.yml` with an exact platform set |
|
||||
| `e2e-replication-nightly.yml` (nightly) | `repl-nightly`, `cluster-nightly`, `protocols-nightly` | three independent gates; JUnit, membership listing, server logs | yes | `cargo nextest run --profile e2e-repl-nightly -p e2e_test`; `--profile e2e-nightly`; `-j 1 --profile e2e-protocols` |
|
||||
| `e2e-distributed.yml` (storage-sensitive PRs + nightly) | `distributed` | fail-closed 4-node 4-disk S3, durability, replication, movement, fault, and direct/rolling upgrade gate; JUnit, membership listing, per-node server logs | yes, with `never_ran_grace_until` | download the pinned previous release as in the workflow, export `RUSTFS_UPGRADE_SOURCE_BINARY`, then `cargo nextest run --profile e2e-distributed -p e2e_test` |
|
||||
| `e2e-replication-nightly.yml` (nightly) | `repl-nightly`, `cluster-nightly`, `protocols-nightly` | three independent gates; JUnit, membership listing, server logs | yes | `python3 scripts/e2e_binary.py build --bins`, then `python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-repl-nightly -p e2e_test`; `--features e2e-test-hooks` on both steps with `--profile e2e-nightly`; `--features ftps,webdav,sftp` on both steps with `-j 1 --profile e2e-protocols` |
|
||||
| `e2e-distributed.yml` (storage-sensitive PRs + nightly) | `distributed` | fail-closed 4-node 4-disk S3, durability, replication, movement, fault, and direct/rolling upgrade gate; JUnit, membership listing, per-node server logs | yes, with `never_ran_grace_until` | download the pinned previous release as in the workflow, export `RUSTFS_UPGRADE_SOURCE_BINARY`, run `python3 scripts/e2e_binary.py build --bins`, then `python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test` |
|
||||
| `e2e-s3tests.yml` (weekly) | `s3tests` (single and distributed, four shards each), `upstream-head-canary` | compatibility gate; report, JUnit, node IDs, server logs | yes | `scripts/s3-tests/run.sh` against an existing single or distributed target |
|
||||
| `fuzz.yml` (nightly) | `nightly-fuzz-corpus` per target | gate; corpus and crash artifacts | yes | `MAX_TOTAL_TIME=<seconds> ./scripts/fuzz/run.sh` |
|
||||
| `minio-interop.yml` (nightly) | `minio-interop` | EC + SSE read-parity gate | yes, with `never_ran_grace_until` | pinned Docker fixture steps in the workflow |
|
||||
| `on-demand-migration-interop.yml` (nightly) | `minio-source`, `cloud-source` (`aws`, `r2`, `gcs`) | report-only provider interop; one JSON report per provider naming cases, timings and source request counts, plus JUnit and MinIO logs. A cloud provider whose `ODM_INTEROP_*` secrets are absent is skipped with a summary note, not failed | no | start the pinned MinIO container as in the job, export the `RUSTFS_ODM_INTEROP_*` variables, then `cargo nextest run --profile e2e-odm-interop -p e2e_test` |
|
||||
| `on-demand-migration-interop.yml` (nightly) | `minio-source`, `cloud-source` (`aws`, `r2`, `gcs`) | report-only provider interop; one JSON report per provider naming cases, timings and source request counts, plus JUnit and MinIO logs. A cloud provider whose `ODM_INTEROP_*` secrets are absent is skipped with a summary note, not failed | no | start the pinned MinIO container as in the job, export the `RUSTFS_ODM_INTEROP_*` variables, run `python3 scripts/e2e_binary.py build --bins`, then `python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test` |
|
||||
| `performance-ab.yml` (nightly) | `warp-ab` | regression-budget gate; A/B summaries and server logs | yes | `bash scripts/run_hotpath_warp_abba.sh --help` |
|
||||
| `nightly-gnu.yml` (nightly) | `build`, `kms-vault-lane`, `kms-vault-ha-failover` | build, live Vault, and HA failover gates | yes | commands and pinned Vault images in the workflow |
|
||||
| `audit.yml` (nightly) | `cargo-deny`, `workflow-pin-report` | dependency and workflow-pin gates | yes | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# CI timing samples
|
||||
|
||||
Collect a bounded sample of completed PR CI runs created in the last seven days with `python3 scripts/ci_timing_report.py --limit 30 --output /tmp/ci-timing.json`. This requires an authenticated `gh` CLI with Actions read access. The JSON retains run SHA, attempt, job and step timestamps so another reviewer can reproduce the summary with `--input /tmp/ci-timing.json --output /tmp/ci-timing-summary.json` without GitHub access.
|
||||
|
||||
The report separates job creation-to-start wait, job execution, and individual step durations. The successful-code sample excludes documentation-only runs and successful PR-closure cancellation handlers. It uses the selected attempt's start for reruns, includes parallel jobs in the runner-minute sum, and leaves missing timestamps unknown. It does not estimate compiler time from a combined build-and-test step or treat runner minutes as wall time or a bill.
|
||||
|
||||
A small recent sample may contain no complete successful code runs. In that case its median is absent, not zero. Keep cancelled and failed counts visible; do not replace the sample with only green runs when evaluating changes.
|
||||
|
||||
Before reducing a PR lane, compare a proposed path classifier in shadow mode against the existing full selection. Keep that lane required until equivalent nightly evidence is complete and current for the same source policy. Timing data alone does not establish functional coverage, escaped-regression rate or quarantine health. The 30–45 minute PR target in backlog #2483 remains an experiment to measure, not an acceptance result from this tool.
|
||||
@@ -23,7 +23,7 @@ The expansion fixture is an all-current-binary fleet, so it initializes pool met
|
||||
|
||||
## What this lane covers
|
||||
|
||||
`cargo nextest run --profile e2e-distributed -p e2e_test` selects `distributed::*`:
|
||||
`python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test` selects `distributed::*`:
|
||||
|
||||
- S3 put / get / head / list / copy / rename / delete / presign, range and conditional reads, special keys, metadata, tags, pagination, empty objects, multipart complete and abort
|
||||
- Object Lock COMPLIANCE, GOVERNANCE and bypass, legal hold, bucket default retention, and non-lock bucket rejection
|
||||
@@ -56,7 +56,7 @@ Hardware power-loss, physical NIC pull, authenticated inter-node partition, firm
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cargo build -p rustfs --bins
|
||||
python3 scripts/e2e_binary.py build --bins
|
||||
# Expansion/decommission/rebalance cases require four paths on distinct filesystems.
|
||||
# If you do not already have four disks, sized tmpfs is enough:
|
||||
# for p in 0 1 2 3; do
|
||||
@@ -66,13 +66,13 @@ cargo build -p rustfs --bins
|
||||
export RUSTFS_E2E_POOL_ROOTS=/mnt/rustfs-pool-0:/mnt/rustfs-pool-1:/mnt/rustfs-pool-2:/mnt/rustfs-pool-3
|
||||
# Upgrade cases require the pinned previous binary (CI downloads it).
|
||||
export RUSTFS_UPGRADE_SOURCE_BINARY=/path/to/rustfs-1.0.0-rc.2
|
||||
cargo nextest run --profile e2e-distributed -p e2e_test
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test
|
||||
```
|
||||
|
||||
Without `RUSTFS_UPGRADE_SOURCE_BINARY` the two `distributed::upgrade_test::*` cases fail closed. Without four distinct `RUSTFS_E2E_POOL_ROOTS`, the expansion and data-movement cases fail closed. Filter upgrades out for a local run that is not checking upgrade:
|
||||
|
||||
```bash
|
||||
cargo nextest run --profile e2e-distributed -p e2e_test -E 'not test(/^distributed::upgrade_test::/)'
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test -E 'not test(/^distributed::upgrade_test::/)'
|
||||
```
|
||||
|
||||
The upgrade topology is `ClusterTopology::single_pool(4)` (4 nodes × 1 drive). That matches the proven mixed-version fixture in `upgrade_compatibility_test`; 4×4 localhost drives are rejected by the previous release's same-device disk check.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Functional chain evidence
|
||||
|
||||
The functional-chain driver calls ten reusable suite workflows sequentially in one Actions run. A suite failure does not suppress later suites. `complete-chain` requires every suite job and its evidence artifact to succeed. The root uses its own concurrency group so an active chain can finish; the suites retain the shared VM lock used by standalone tests.
|
||||
|
||||
## Candidate identity
|
||||
|
||||
The driver resolves one successful `nightly-gnu.yml` attempt from main. Automatic runs select the triggering scheduled attempt. Manual runs require an explicit build run ID and attempt. Resolution verifies the artifact name, run association, ZIP size and digest, single JSON member, and immutable package URL containing run, attempt and package checksum.
|
||||
|
||||
Schema 2 distinguishes the workflow SHA from the actual build SHA and source ref. A release build triggered by a main workflow remains release evidence. Legacy schema 1 is accepted only when its source SHA equals the producer workflow SHA. The consumer does not resolve the source branch again, so branch movement cannot silently select a different package.
|
||||
|
||||
All suite installers receive the same package URL and checksum (`PACKAGE_SHA256`, or `TO_SHA256` for upgrade). Their existing package checks run before installation. The private test repository is checked out at `.config/functional-script-revision.txt`; change that pin only to a reviewed, merged revision.
|
||||
|
||||
## Completion and reruns
|
||||
|
||||
Every suite records its chain run/attempt, workflow SHA, private pin, candidate identity, report hash and execution counts. A missing/empty report, zero passing executions, failed or unfinished case, failed test/report step, cancelled job, or mismatched private checkout invalidates the evidence. Uploading the suite proof requires successful proof generation.
|
||||
|
||||
The final job checks all ten expected suite results and all ten proof files against the same envelope. It emits `functional-chain-complete-<run>-<attempt>` only after those checks pass. Failed partial reruns cannot combine an old successful lane's proof with a new attempt. Use **Re-run all jobs** for a new complete acceptance attempt.
|
||||
|
||||
## Health publication
|
||||
|
||||
`functional-chain-health.yml` inspects recent main-branch chain runs hourly. It validates complete evidence against the exact producer artifact again and checks the private pin from the chain's workflow commit. Its JSON separates the latest attempt from the last complete success for each source. A later failure preserves historical success without turning the new failure green.
|
||||
|
||||
Evidence expires 36 hours after the producer attempt started. The dashboard also treats collection older than two hours as stale. Workflow enablement, owner, source identities, evidence version and expiry are visible. An invalid legacy chain-driver success is not complete evidence, and release success cannot authorize moving main PR coverage to nightly.
|
||||
|
||||
The dashboard's `src/chain-health.json` must exist before enabling publication. Updates use the read blob SHA and reject unsupported, null or newer existing state. The companion dashboard view is required to display this data. Until a real same-candidate chain completes, these workflow and script checks do not satisfy backlog #2481 or unblock coverage migration in #2483.
|
||||
@@ -25,9 +25,9 @@ Every fixed RustFS GitHub Security Advisory maps to at least one named regressio
|
||||
| Layer | Command | Lane | Guard |
|
||||
| --- | --- | --- | --- |
|
||||
| Unit and crate tests (`ghsa_r5qv_*`, the m77q pins, `ghsa_5354_*`, `ghsa_3ppv_*`, `ghsa_6r96_*`, `ghsa_v9cp_*`, `ghsa_g3vq_*`, `ghsa_g8w9_*`) | `cargo nextest run --profile ci --all --exclude e2e_test` | every PR, `Test and Lint` (required) | none needed; the workspace pass runs every unit and crate test |
|
||||
| S3-API negative-auth e2e (`negative_sigv4_test`, `presigned_negative_test`, `admin_auth_test`) | `cargo nextest run --profile e2e-smoke -p e2e_test` | every PR, `End-to-End Tests` (report-only) | `scripts/check_security_smoke_count.sh` with the floor in `.config/security-smoke-floor.txt`, run in the `e2e-tests` job; fails when a rename drops one of these modules out of the smoke filter |
|
||||
| Other S3 e2e guards (`anonymous_access_test`) | `cargo nextest run --profile e2e-smoke -p e2e_test` | every PR, `End-to-End Tests` (report-only) | `scripts/check_test_wiring.py --check-profile e2e-smoke` digest |
|
||||
| Protocol e2e (`protocols::test_protocol_core_suite`, GHSA-3p3x) | `RUSTFS_BUILD_FEATURES=ftps,webdav,sftp cargo nextest run -j 1 --profile e2e-protocols -p e2e_test` | nightly, `e2e-replication-nightly.yml` job `protocols-nightly`; not PR-gated | `scripts/check_test_wiring.py --check-profile e2e-protocols` digest |
|
||||
| S3-API negative-auth e2e (`negative_sigv4_test`, `presigned_negative_test`, `admin_auth_test`) | `python3 scripts/e2e_binary.py build --features e2e-test-hooks`, then `python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test` | every PR, `End-to-End Tests` (report-only) | `scripts/check_security_smoke_count.sh` with the floor in `.config/security-smoke-floor.txt`, run in the `e2e-tests` job; fails when a rename drops one of these modules out of the smoke filter |
|
||||
| Other S3 e2e guards (`anonymous_access_test`) | same build and run as the row above | every PR, `End-to-End Tests` (report-only) | `scripts/check_test_wiring.py --check-profile e2e-smoke` digest |
|
||||
| Protocol e2e (`protocols::test_protocol_core_suite`, GHSA-3p3x) | `python3 scripts/e2e_binary.py build --features ftps,webdav,sftp`, then `python3 scripts/e2e_binary.py run --features ftps,webdav,sftp -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test` | nightly, `e2e-replication-nightly.yml` job `protocols-nightly`; not PR-gated | `scripts/check_test_wiring.py --check-profile e2e-protocols` digest |
|
||||
|
||||
Notes:
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
rustfs = rustPlatform.buildRustPackage {
|
||||
pname = "rustfs";
|
||||
version = "1.0.0-rc.5";
|
||||
version = "1.0.0-rc.6";
|
||||
|
||||
src = ./.;
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: rustfs
|
||||
description: RustFS helm chart to deploy RustFS on kubernetes cluster.
|
||||
type: application
|
||||
version: "1.0.0-rc.5"
|
||||
appVersion: "1.0.0-rc.5"
|
||||
version: "1.0.0-rc.6"
|
||||
appVersion: "1.0.0-rc.6"
|
||||
home: https://rustfs.com
|
||||
icon: https://media.sys.truenas.net/apps/rustfs/icons/icon.svg
|
||||
maintainers:
|
||||
|
||||
+5
-2
@@ -1,9 +1,9 @@
|
||||
%global _enable_debug_packages 0
|
||||
%global _empty_manifest_terminate_build 0
|
||||
%global prerelease rc.5
|
||||
%global prerelease rc.6
|
||||
Name: rustfs
|
||||
Version: 1.0.0
|
||||
Release: rc.5
|
||||
Release: rc.6
|
||||
Summary: High-performance distributed object storage for MinIO alternative
|
||||
|
||||
License: Apache-2.0
|
||||
@@ -58,6 +58,9 @@ install %_builddir/%{name}-%{version}-%{prerelease}/target/%_arch/%_arch-unknown
|
||||
%_bindir/rustfs
|
||||
|
||||
%changelog
|
||||
* Thu Sep 10 2026 overtrue <anzhengchao@gmail.com>
|
||||
- Update RPM package to RustFS 1.0.0-rc.6
|
||||
|
||||
* Mon Aug 31 2026 overtrue <anzhengchao@gmail.com>
|
||||
- Update RPM package to RustFS 1.0.0-rc.5
|
||||
|
||||
|
||||
+168
-21
@@ -22,9 +22,9 @@ use crate::server::rate_limit::{
|
||||
apply_throttle_headers, client_ip,
|
||||
};
|
||||
use crate::server::{
|
||||
APPLE_TOUCH_ICON_PATH, APPLE_TOUCH_ICON_PRECOMPOSED_PATH, CONSOLE_PREFIX, FAVICON_PATH, HEALTH_PREFIX, HEALTH_READY_PATH,
|
||||
HeaderMapCarrier, HealthProbe, LICENSE, RUSTFS_ADMIN_PREFIX, RequestContextLayer, VERSION, build_health_response_parts,
|
||||
collect_probe_readiness,
|
||||
APPLE_TOUCH_ICON_PATH, APPLE_TOUCH_ICON_PRECOMPOSED_PATH, FAVICON_PATH, HEALTH_PREFIX, HEALTH_READY_PATH, HeaderMapCarrier,
|
||||
HealthProbe, LICENSE, RUSTFS_ADMIN_PREFIX, RequestContextLayer, VERSION, build_health_response_parts,
|
||||
collect_probe_readiness, console_prefix,
|
||||
};
|
||||
use crate::version::{self, build};
|
||||
use axum::{
|
||||
@@ -83,7 +83,7 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type.to_string())
|
||||
.body(Body::from(file.data))
|
||||
.body(Body::from(rewrite_console_asset(path, file.data, crate::server::console_prefix())))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type.to_string())
|
||||
.body(Body::from(file.data))
|
||||
.body(Body::from(rewrite_console_asset(&index_path, file.data, crate::server::console_prefix())))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,11 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type.to_string())
|
||||
.body(Body::from(file.data))
|
||||
.body(Body::from(rewrite_console_asset(
|
||||
"index.html",
|
||||
file.data,
|
||||
crate::server::console_prefix(),
|
||||
)))
|
||||
.unwrap()
|
||||
} else {
|
||||
Response::builder()
|
||||
@@ -116,6 +120,57 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// Next exports bake the base path into HTML, chunk loaders, and RSC text payloads.
|
||||
// Rewrite only path references, preserving external URLs such as the source repository.
|
||||
fn rewrite_console_asset<'a>(path: &str, data: std::borrow::Cow<'a, [u8]>, prefix: &str) -> std::borrow::Cow<'a, [u8]> {
|
||||
use std::borrow::Cow;
|
||||
|
||||
if prefix == crate::server::CONSOLE_PREFIX
|
||||
|| !matches!(
|
||||
path.rsplit('.').next(),
|
||||
Some("html" | "js" | "css" | "json" | "txt" | "webmanifest" | "svg")
|
||||
)
|
||||
{
|
||||
return data;
|
||||
}
|
||||
let Ok(text) = std::str::from_utf8(&data) else {
|
||||
return data;
|
||||
};
|
||||
let mut rewritten = Cow::Borrowed(text);
|
||||
let escaped_default = crate::server::CONSOLE_PREFIX.replace('/', "\\/");
|
||||
let escaped_prefix = prefix.replace('/', "\\/");
|
||||
for (source, target) in [
|
||||
(crate::server::CONSOLE_PREFIX, prefix),
|
||||
(escaped_default.as_str(), escaped_prefix.as_str()),
|
||||
] {
|
||||
let mut output = String::new();
|
||||
let mut copied = 0;
|
||||
for (offset, _) in rewritten.match_indices(source) {
|
||||
let end = offset + source.len();
|
||||
let before = rewritten.as_bytes().get(offset.wrapping_sub(1)).copied();
|
||||
let after = rewritten.as_bytes().get(end).copied();
|
||||
let starts_path = before
|
||||
.is_none_or(|byte| byte.is_ascii_whitespace() || matches!(byte, b'"' | b'\'' | b'`' | b'(' | b'=' | b'}' | b'>'));
|
||||
let ends_prefix = after.is_none_or(|byte| {
|
||||
byte.is_ascii_whitespace() || matches!(byte, b'/' | b'\\' | b'"' | b'\'' | b'`' | b'?' | b'#' | b')' | b'<')
|
||||
});
|
||||
if starts_path && ends_prefix {
|
||||
output.push_str(&rewritten[copied..offset]);
|
||||
output.push_str(target);
|
||||
copied = end;
|
||||
}
|
||||
}
|
||||
if copied != 0 {
|
||||
output.push_str(&rewritten[copied..]);
|
||||
rewritten = Cow::Owned(output);
|
||||
}
|
||||
}
|
||||
match rewritten {
|
||||
Cow::Borrowed(_) => data,
|
||||
Cow::Owned(text) => Cow::Owned(text.into_bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub(crate) struct Config {
|
||||
#[serde(skip)]
|
||||
@@ -468,7 +523,7 @@ fn get_console_config_from_env() -> (bool, u32, u64, String) {
|
||||
/// - `true` if the path is for console access, `false` otherwise.
|
||||
pub fn is_console_path(path: &str) -> bool {
|
||||
matches!(path, FAVICON_PATH | APPLE_TOUCH_ICON_PATH | APPLE_TOUCH_ICON_PRECOMPOSED_PATH)
|
||||
|| has_path_prefix(path, CONSOLE_PREFIX)
|
||||
|| has_path_prefix(path, console_prefix())
|
||||
}
|
||||
|
||||
/// Setup comprehensive middleware stack with tower-http features
|
||||
@@ -487,34 +542,35 @@ fn setup_console_middleware_stack(
|
||||
rate_limit_rpm: u32,
|
||||
auth_timeout: u64,
|
||||
) -> Router {
|
||||
let console_prefix = console_prefix();
|
||||
let mut app = Router::new()
|
||||
.route(FAVICON_PATH, get(static_handler))
|
||||
.route(&format!("{CONSOLE_PREFIX}{LICENSE}"), get(license_handler))
|
||||
.route(&format!("{CONSOLE_PREFIX}{VERSION}"), get(version_handler))
|
||||
.nest(CONSOLE_PREFIX, Router::new().fallback_service(get(static_handler)))
|
||||
.route(&format!("{console_prefix}{LICENSE}"), get(license_handler))
|
||||
.route(&format!("{console_prefix}{VERSION}"), get(version_handler))
|
||||
.nest(console_prefix, Router::new().fallback_service(get(static_handler)))
|
||||
.fallback_service(get(static_handler));
|
||||
|
||||
if rustfs_utils::get_env_bool(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, rustfs_config::DEFAULT_HEALTH_ENDPOINT_ENABLE) {
|
||||
app = app
|
||||
.route(&format!("{CONSOLE_PREFIX}{HEALTH_PREFIX}"), get(health_check).head(health_check))
|
||||
.route(&format!("{console_prefix}{HEALTH_PREFIX}"), get(health_check).head(health_check))
|
||||
.route(
|
||||
&format!("{CONSOLE_PREFIX}{}", crate::server::HEALTH_COMPAT_LIVE_PATH),
|
||||
&format!("{console_prefix}{}", crate::server::HEALTH_COMPAT_LIVE_PATH),
|
||||
get(health_check).head(health_check),
|
||||
)
|
||||
.route(&format!("{CONSOLE_PREFIX}{HEALTH_READY_PATH}"), get(health_check).head(health_check));
|
||||
.route(&format!("{console_prefix}{HEALTH_READY_PATH}"), get(health_check).head(health_check));
|
||||
} else {
|
||||
// Keep disabled health probes from falling through to the SPA fallback.
|
||||
app = app
|
||||
.route(
|
||||
&format!("{CONSOLE_PREFIX}{HEALTH_PREFIX}"),
|
||||
&format!("{console_prefix}{HEALTH_PREFIX}"),
|
||||
get(health_route_disabled).head(health_route_disabled),
|
||||
)
|
||||
.route(
|
||||
&format!("{CONSOLE_PREFIX}{}", crate::server::HEALTH_COMPAT_LIVE_PATH),
|
||||
&format!("{console_prefix}{}", crate::server::HEALTH_COMPAT_LIVE_PATH),
|
||||
get(health_route_disabled).head(health_route_disabled),
|
||||
)
|
||||
.route(
|
||||
&format!("{CONSOLE_PREFIX}{HEALTH_READY_PATH}"),
|
||||
&format!("{console_prefix}{HEALTH_READY_PATH}"),
|
||||
get(health_route_disabled).head(health_route_disabled),
|
||||
);
|
||||
}
|
||||
@@ -624,7 +680,7 @@ async fn health_check(
|
||||
uri: Uri,
|
||||
server_ctx: Option<Extension<Arc<crate::runtime_sources::ServerContextSlot>>>,
|
||||
) -> Response {
|
||||
let probe = if uri.path().strip_prefix(CONSOLE_PREFIX) == Some(HEALTH_READY_PATH) {
|
||||
let probe = if uri.path().strip_prefix(console_prefix()) == Some(HEALTH_READY_PATH) {
|
||||
HealthProbe::Readiness
|
||||
} else {
|
||||
HealthProbe::Liveness
|
||||
@@ -786,6 +842,7 @@ pub(crate) fn make_console_server() -> Router {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::server::CONSOLE_PREFIX;
|
||||
use axum::body::Body;
|
||||
use axum::routing::get;
|
||||
use http::{Request, StatusCode};
|
||||
@@ -867,9 +924,14 @@ mod tests {
|
||||
async fn console_config_handler_serializes_admin_discovery_paths() {
|
||||
init_console_cfg(IpAddr::V4(Ipv4Addr::LOCALHOST), 9001);
|
||||
|
||||
let response = config_handler(Uri::from_static("http://127.0.0.1:9001/rustfs/console/api/v1/config"), HeaderMap::new())
|
||||
.await
|
||||
.into_response();
|
||||
let response = config_handler(
|
||||
format!("http://127.0.0.1:9001{CONSOLE_PREFIX}/api/v1/config")
|
||||
.parse()
|
||||
.expect("console URI"),
|
||||
HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
.into_response();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body();
|
||||
@@ -889,7 +951,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn external_admin_paths_are_not_console_paths() {
|
||||
assert!(is_console_path("/rustfs/console/"));
|
||||
assert!(is_console_path(&format!("{CONSOLE_PREFIX}/")));
|
||||
assert!(!is_console_path(&format!("{CONSOLE_PREFIX}-other/index.html")));
|
||||
assert!(is_console_path("/apple-touch-icon.png"));
|
||||
assert!(is_console_path("/apple-touch-icon-precomposed.png"));
|
||||
assert!(!is_console_path("/minio/admin/v3/info"));
|
||||
@@ -1273,3 +1336,87 @@ mod tests {
|
||||
assert!(value.get("expired").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod console_asset_prefix_tests {
|
||||
use super::rewrite_console_asset;
|
||||
use crate::server::CONSOLE_PREFIX;
|
||||
use std::borrow::Cow;
|
||||
|
||||
#[test]
|
||||
fn rewrites_exported_assets_and_client_routes() {
|
||||
let fixtures = [
|
||||
(
|
||||
"index.html",
|
||||
r#"<script src="/rustfs/console/_next/app.js"></script>"#,
|
||||
r#"<script src="/console/_next/app.js"></script>"#,
|
||||
),
|
||||
(
|
||||
"app.js",
|
||||
r#"let base="/rustfs/console";fetch(`${host}/rustfs/console/version`)"#,
|
||||
r#"let base="/console";fetch(`${host}/console/version`)"#,
|
||||
),
|
||||
("app.css", "url(/rustfs/console/logo.svg)", "url(/console/logo.svg)"),
|
||||
(
|
||||
"route.txt",
|
||||
r#"2:I[1,["/rustfs/console/_next/app.js"],"default"]"#,
|
||||
r#"2:I[1,["/console/_next/app.js"],"default"]"#,
|
||||
),
|
||||
("config.json", r#"{"url":"\/rustfs\/console\/login"}"#, r#"{"url":"\/console\/login"}"#),
|
||||
("site.webmanifest", r#"{"start_url":"/rustfs/console/"}"#, r#"{"start_url":"/console/"}"#),
|
||||
(
|
||||
"logo.svg",
|
||||
r#"<image href="/rustfs/console/logo.png"/>"#,
|
||||
r#"<image href="/console/logo.png"/>"#,
|
||||
),
|
||||
];
|
||||
for (path, input, expected) in fixtures {
|
||||
let input = input
|
||||
.replace("/rustfs/console", CONSOLE_PREFIX)
|
||||
.replace("\\/rustfs\\/console", &CONSOLE_PREFIX.replace('/', "\\/"));
|
||||
assert_eq!(
|
||||
rewrite_console_asset(path, Cow::Borrowed(input.as_bytes()), "/console").as_ref(),
|
||||
expected.as_bytes(),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_the_standard_path_from_an_oem_build() {
|
||||
let input = format!(r#"<script src="{CONSOLE_PREFIX}/_next/app.js"></script>"#);
|
||||
let output = rewrite_console_asset("index.html", Cow::Borrowed(input.as_bytes()), rustfs_config::DEFAULT_CONSOLE_PREFIX);
|
||||
assert_eq!(output.as_ref(), br#"<script src="/rustfs/console/_next/app.js"></script>"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_unrelated_urls_paths_and_default_bytes() {
|
||||
let input = format!(
|
||||
r#"["https://github.com/rustfs/console","/other{CONSOLE_PREFIX}","{CONSOLE_PREFIX}-extra","{CONSOLE_PREFIX}/index.html"]"#
|
||||
);
|
||||
let expected = format!(
|
||||
r#"["https://github.com/rustfs/console","/other{CONSOLE_PREFIX}","{CONSOLE_PREFIX}-extra","/console/index.html"]"#
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_console_asset("app.js", Cow::Borrowed(input.as_bytes()), "/console").as_ref(),
|
||||
expected.as_bytes()
|
||||
);
|
||||
let unchanged = rewrite_console_asset("app.js", Cow::Borrowed(input.as_bytes()), CONSOLE_PREFIX);
|
||||
assert!(matches!(unchanged, Cow::Borrowed(_)));
|
||||
assert_eq!(unchanged.as_ref(), input.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_binary_invalid_utf8_and_text_without_paths() {
|
||||
let invalid_utf8 = [b"\xff".as_slice(), CONSOLE_PREFIX.as_bytes()].concat();
|
||||
for (path, bytes) in [
|
||||
("image.png", CONSOLE_PREFIX.as_bytes()),
|
||||
("app.js", invalid_utf8.as_slice()),
|
||||
("app.js", b"https://github.com/rustfs/console".as_slice()),
|
||||
] {
|
||||
let output = rewrite_console_asset(path, Cow::Borrowed(bytes), "/console");
|
||||
assert!(matches!(output, Cow::Borrowed(_)), "{path}");
|
||||
assert_eq!(output.as_ref(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ use super::supervise_admin_mutation;
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::runtime_sources::{current_action_credentials, current_ready_iam_handle, object_store_from_req};
|
||||
use crate::admin::service::caller_identity::CallerIdentity;
|
||||
use crate::admin::service::caller_identity::{CallerIdentity, oidc_profile_fields};
|
||||
use crate::admin::storage_api::s3::{self, Body, S3ErrorCode, S3Request, S3Response, S3Result};
|
||||
use crate::admin::utils::read_compatible_admin_body;
|
||||
use crate::auth::constant_time_eq;
|
||||
@@ -73,6 +73,16 @@ pub fn register_account_route(r: &mut S3Router<AdminOperation>) -> std::io::Resu
|
||||
/// `GET /rustfs/admin/v3/account/info`
|
||||
pub struct SelfAccountInfoHandler {}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct SelfAccountInfoResponse {
|
||||
#[serde(flatten)]
|
||||
account: SelfAccountInfo,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for SelfAccountInfoHandler {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
@@ -124,17 +134,22 @@ impl Operation for SelfAccountInfoHandler {
|
||||
None => return Err(s3::error(S3ErrorCode::ServiceUnavailable, "the object store is not ready")),
|
||||
};
|
||||
|
||||
let info = SelfAccountInfo {
|
||||
access_key: caller.access_key.clone(),
|
||||
identity_type: caller.identity_type,
|
||||
session_access_key: caller.session_access_key.clone(),
|
||||
is_admin: caller.is_owner,
|
||||
status,
|
||||
member_of,
|
||||
policies,
|
||||
credentials_source: caller.credentials_source,
|
||||
mutable: caller.mutability(),
|
||||
mfa,
|
||||
let (username, email) = oidc_profile_fields(&caller.credentials);
|
||||
let info = SelfAccountInfoResponse {
|
||||
account: SelfAccountInfo {
|
||||
access_key: caller.access_key.clone(),
|
||||
identity_type: caller.identity_type,
|
||||
session_access_key: caller.session_access_key.clone(),
|
||||
is_admin: caller.is_owner,
|
||||
status,
|
||||
member_of,
|
||||
policies,
|
||||
credentials_source: caller.credentials_source,
|
||||
mutable: caller.mutability(),
|
||||
mfa,
|
||||
},
|
||||
username,
|
||||
email,
|
||||
};
|
||||
|
||||
admin_json_response(req.uri.path(), &caller.credentials.secret_key, StatusCode::OK, &info)
|
||||
@@ -548,6 +563,38 @@ fn validate_new_secret_key(request: &ChangePasswordRequest) -> S3Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use rustfs_madmin::account::{AccountMutability, CredentialsSource};
|
||||
|
||||
#[test]
|
||||
fn self_account_info_response_adds_oidc_display_fields_without_changing_base_type() {
|
||||
let mut response = SelfAccountInfoResponse {
|
||||
account: SelfAccountInfo {
|
||||
access_key: "virtual-parent".to_string(),
|
||||
identity_type: IdentityType::Sts,
|
||||
session_access_key: Some("temporary-key".to_string()),
|
||||
is_admin: false,
|
||||
status: "enabled".to_string(),
|
||||
member_of: Vec::new(),
|
||||
policies: Vec::new(),
|
||||
credentials_source: CredentialsSource::Iam,
|
||||
mutable: AccountMutability::default(),
|
||||
mfa: AccountMfaSummary::default(),
|
||||
},
|
||||
username: Some("oidc-user".to_string()),
|
||||
email: Some("oidc-user@example.test".to_string()),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&response).expect("serialize account response");
|
||||
assert_eq!(value["access_key"], "virtual-parent");
|
||||
assert_eq!(value["username"], "oidc-user");
|
||||
assert_eq!(value["email"], "oidc-user@example.test");
|
||||
|
||||
response.username = None;
|
||||
response.email = None;
|
||||
let legacy_shape = serde_json::to_value(&response).expect("serialize account response without OIDC fields");
|
||||
assert!(!legacy_shape.as_object().unwrap().contains_key("username"));
|
||||
assert!(!legacy_shape.as_object().unwrap().contains_key("email"));
|
||||
}
|
||||
|
||||
fn change_request(current: &str, new: &str) -> ChangePasswordRequest {
|
||||
ChangePasswordRequest {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
use crate::admin::auth::authenticate_request;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::runtime_sources::{current_action_credentials, object_store_from_req};
|
||||
use crate::admin::service::caller_identity::oidc_profile_fields;
|
||||
use crate::admin::storage_api::bucket::versioning_sys::BucketVersioningSys;
|
||||
use crate::admin::storage_api::contract::admin::StorageAdminApi;
|
||||
use crate::admin::storage_api::contract::bucket::{BucketOperations, BucketOptions};
|
||||
@@ -52,6 +53,16 @@ pub struct AccountInfo {
|
||||
|
||||
pub struct AccountInfoHandler {}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AccountInfoResponse {
|
||||
#[serde(flatten)]
|
||||
account: rustfs_madmin::AccountInfo,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
pub fn register_account_info_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
|
||||
r.insert(
|
||||
Method::GET,
|
||||
@@ -242,6 +253,7 @@ impl Operation for AccountInfoHandler {
|
||||
let policy_str = serde_json::to_string(&effective_policy)
|
||||
.map_err(|_e| S3Error::with_message(S3ErrorCode::InternalError, "parse policy failed"))?;
|
||||
|
||||
let (username, email) = oidc_profile_fields(&cred);
|
||||
let mut account_info = rustfs_madmin::AccountInfo {
|
||||
account_name,
|
||||
server: StorageAdminApi::backend_info(store.as_ref()).await,
|
||||
@@ -288,8 +300,12 @@ impl Operation for AccountInfoHandler {
|
||||
}
|
||||
}
|
||||
|
||||
let data = serde_json::to_vec(&account_info)
|
||||
.map_err(|_e| S3Error::with_message(S3ErrorCode::InternalError, "parse accountInfo failed"))?;
|
||||
let data = serde_json::to_vec(&AccountInfoResponse {
|
||||
account: account_info,
|
||||
username,
|
||||
email,
|
||||
})
|
||||
.map_err(|_e| S3Error::with_message(S3ErrorCode::InternalError, "parse accountInfo failed"))?;
|
||||
|
||||
let mut header = HeaderMap::new();
|
||||
header.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
@@ -305,6 +321,25 @@ mod tests {
|
||||
use rustfs_policy::policy::BucketPolicy;
|
||||
use s3s::dto::{Destination, ReplicationRule};
|
||||
|
||||
#[test]
|
||||
fn accountinfo_response_adds_optional_oidc_display_fields() {
|
||||
let mut response = AccountInfoResponse {
|
||||
account: rustfs_madmin::AccountInfo::default(),
|
||||
username: Some("oidc-user".to_string()),
|
||||
email: Some("oidc-user@example.test".to_string()),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&response).expect("serialize accountinfo response");
|
||||
assert_eq!(value["username"], "oidc-user");
|
||||
assert_eq!(value["email"], "oidc-user@example.test");
|
||||
|
||||
response.username = None;
|
||||
response.email = None;
|
||||
let legacy_shape = serde_json::to_value(&response).expect("serialize accountinfo response without OIDC fields");
|
||||
assert!(!legacy_shape.as_object().unwrap().contains_key("username"));
|
||||
assert!(!legacy_shape.as_object().unwrap().contains_key("email"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_account_info_structure() {
|
||||
// Test AccountInfo struct creation and serialization
|
||||
|
||||
@@ -17,7 +17,6 @@ use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::runtime_sources::app_context_from_req;
|
||||
use crate::admin::storage_api::bucket::is_reserved_or_invalid_bucket;
|
||||
use crate::admin::storage_api::bucket::utils::is_valid_object_prefix;
|
||||
use crate::error::ApiError;
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use crate::server::RemoteAddr;
|
||||
use crate::storage::rpc::node_service::heal::{
|
||||
@@ -29,7 +28,6 @@ use futures_util::future::join_all;
|
||||
use http::{HeaderMap, HeaderValue, Uri};
|
||||
use hyper::{Method, StatusCode};
|
||||
use matchit::Params;
|
||||
use percent_encoding::percent_decode_str;
|
||||
use rustfs_config::MAX_HEAL_REQUEST_SIZE;
|
||||
use rustfs_heal::heal::utils::format_set_disk_id;
|
||||
use rustfs_heal_contracts::heal_channel::{
|
||||
@@ -37,11 +35,13 @@ use rustfs_heal_contracts::heal_channel::{
|
||||
};
|
||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
use rustfs_scanner::scanner::{BackgroundHealInfo, read_background_heal_info};
|
||||
use rustfs_utils::path::path_join;
|
||||
use s3s::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::time::{Duration, timeout};
|
||||
@@ -71,17 +71,9 @@ struct HealInitParams {
|
||||
}
|
||||
|
||||
fn extract_heal_init_params(body: &Bytes, uri: &Uri, params: Params<'_, '_>) -> S3Result<HealInitParams> {
|
||||
// matchit captures the original URI bytes. Decode once before validation
|
||||
// so literal %2F keys remain distinct from actual path separators.
|
||||
let mut hip = HealInitParams {
|
||||
bucket: percent_decode_str(params.get("bucket").unwrap_or_default())
|
||||
.decode_utf8()
|
||||
.map_err(|_| ApiError::invalid_request("invalid bucket name encoding"))?
|
||||
.into_owned(),
|
||||
obj_prefix: percent_decode_str(params.get("prefix").unwrap_or_default())
|
||||
.decode_utf8()
|
||||
.map_err(|_| ApiError::invalid_request("invalid object name encoding"))?
|
||||
.into_owned(),
|
||||
bucket: params.get("bucket").map(|s| s.to_string()).unwrap_or_default(),
|
||||
obj_prefix: params.get("prefix").map(|s| s.to_string()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
};
|
||||
validate_heal_target(&hip.bucket, &hip.obj_prefix)?;
|
||||
@@ -172,13 +164,13 @@ fn validate_heal_target(bucket: &str, obj_prefix: &str) -> S3Result<()> {
|
||||
}
|
||||
|
||||
fn encode_heal_control_path(bucket: &str, obj_prefix: &str) -> String {
|
||||
if obj_prefix.is_empty() {
|
||||
return bucket.to_owned();
|
||||
if bucket.is_empty() && obj_prefix.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// This identifies an S3 target, not a filesystem path. In particular,
|
||||
// a leading slash in the object must not alias the sibling without it.
|
||||
format!("{bucket}/{obj_prefix}")
|
||||
path_join(&[PathBuf::from(bucket), PathBuf::from(obj_prefix)])
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn heal_control_response_id(heal_path: &str, client_token: &str) -> String {
|
||||
@@ -208,7 +200,7 @@ pub fn register_heal_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<
|
||||
|
||||
r.insert(
|
||||
Method::POST,
|
||||
format!("{}{}", ADMIN_PREFIX, "/v3/heal/{bucket}/{*prefix}").as_str(),
|
||||
format!("{}{}", ADMIN_PREFIX, "/v3/heal/{bucket}/{prefix}").as_str(),
|
||||
AdminOperation(&HealHandler {}),
|
||||
)?;
|
||||
|
||||
@@ -1076,8 +1068,6 @@ async fn submit_cluster_heal_channel_command(
|
||||
struct HealTaskStatusPayload {
|
||||
#[serde(skip)]
|
||||
adapted_detail: Option<String>,
|
||||
#[serde(default, rename = "settings", skip_serializing)]
|
||||
heal_settings: Option<HealOpts>,
|
||||
summary: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
items: Vec<rustfs_madmin::heal_commands::HealResultItem>,
|
||||
@@ -1132,10 +1122,9 @@ fn encode_heal_start_success(client_token: String, client_address: String) -> S3
|
||||
fn encode_heal_task_status(
|
||||
mut payload: HealTaskStatusPayload,
|
||||
failure_detail: String,
|
||||
fallback_heal_settings: HealOpts,
|
||||
heal_settings: HealOpts,
|
||||
) -> S3Result<Vec<u8>> {
|
||||
let failure_detail = payload.adapted_detail.take().unwrap_or(failure_detail);
|
||||
let heal_settings = payload.heal_settings.take().unwrap_or(fallback_heal_settings);
|
||||
encode_json(&HealTaskStatus {
|
||||
payload,
|
||||
failure_detail,
|
||||
@@ -1627,135 +1616,6 @@ mod tests {
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::Duration;
|
||||
|
||||
fn parse_registered_heal_request(uri: &Uri) -> s3s::S3Result<HealInitParams> {
|
||||
let mut registered = super::S3Router::new(false);
|
||||
super::register_heal_route(&mut registered).expect("register production Heal routes");
|
||||
let mut router = Router::new();
|
||||
for route in registered.registered_routes() {
|
||||
router.insert(route.clone(), ()).expect("replay production route");
|
||||
}
|
||||
let path = format!("POST|{}", uri.path());
|
||||
let matched = router.at(&path).expect("request must match a production Heal route");
|
||||
let body = Bytes::from_static(
|
||||
br#"{"recursive":false,"dryRun":true,"remove":false,"recreate":false,"scanMode":2,"updateParity":false,"nolock":false,"readRepair":false,"pool":0,"set":0}"#,
|
||||
);
|
||||
extract_heal_init_params(&body, uri, matched.params)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heal_routes_accept_nested_and_encoded_object_paths() {
|
||||
let mut router = super::S3Router::new(false);
|
||||
super::register_heal_route(&mut router).expect("register production Heal routes");
|
||||
for prefix in ["/rustfs/admin", "/minio/admin"] {
|
||||
for target in [
|
||||
"",
|
||||
"test-bucket",
|
||||
"test-bucket/object.bin",
|
||||
"test-bucket/dir/sub/object.bin",
|
||||
"test-bucket/dir%2Fobject.bin",
|
||||
] {
|
||||
let path = format!("{prefix}/v3/heal/{target}");
|
||||
assert!(router.contains_compatible_route(http::Method::POST, &path), "{path}");
|
||||
assert!(!router.contains_compatible_route(http::Method::GET, &path), "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heal_target_decodes_once_and_keeps_start_status_stop_identity() {
|
||||
for (wire, object) in [
|
||||
("object.bin", "object.bin"),
|
||||
("dir/sub/object.bin", "dir/sub/object.bin"),
|
||||
("dir%2Fsub%2Fobject.bin", "dir/sub/object.bin"),
|
||||
("dir%2fsub/object.bin", "dir/sub/object.bin"),
|
||||
("%2Fobject.bin", "/object.bin"),
|
||||
("dir/", "dir/"),
|
||||
("dir%2F", "dir/"),
|
||||
("literal%252Fslash", "literal%2Fslash"),
|
||||
("space%20key%2Bplus", "space key+plus"),
|
||||
("literal+plus", "literal+plus"),
|
||||
("%E4%B8%AD%E6%96%87%2F%E6%96%87%E4%BB%B6", "中文/文件"),
|
||||
("query%3Fhash%23percent%25", "query?hash#percent%"),
|
||||
] {
|
||||
for query in ["", "?clientToken=task", "?clientToken=task&forceStop=true"] {
|
||||
let uri = format!("/rustfs/admin/v3/heal/test%2Dbucket/{wire}{query}")
|
||||
.parse()
|
||||
.expect("valid encoded URI");
|
||||
let parsed = parse_registered_heal_request(&uri).expect("valid Heal target");
|
||||
assert_eq!(parsed.bucket, "test-bucket");
|
||||
assert_eq!(parsed.obj_prefix, object, "wire target: {wire}");
|
||||
assert_eq!(
|
||||
encode_heal_control_path(&parsed.bucket, &parsed.obj_prefix),
|
||||
format!("test-bucket/{object}")
|
||||
);
|
||||
assert_eq!(parsed.client_token, if query.is_empty() { "" } else { "task" });
|
||||
assert_eq!(parsed.force_stop, query.ends_with("forceStop=true"));
|
||||
if query.is_empty() {
|
||||
let request = build_heal_channel_request(&parsed);
|
||||
assert_eq!(request.bucket, "test-bucket");
|
||||
assert_eq!(request.object_prefix.as_deref(), Some(object));
|
||||
assert_eq!(request.pool_index, Some(0));
|
||||
assert_eq!(request.set_index, Some(0));
|
||||
assert_eq!(request.dry_run, Some(true));
|
||||
assert_eq!(request.scan_mode, Some(HealScanMode::Deep));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heal_target_validates_decoded_paths_before_admission() {
|
||||
for target in [
|
||||
"test%2Fbucket/object",
|
||||
"test%00bucket/object",
|
||||
"test%FFbucket/object",
|
||||
"test-bucket/dir%2F..%2Fobject",
|
||||
"test-bucket/dir/%2e/object",
|
||||
"test-bucket/dir%5C..%5Cobject",
|
||||
"test-bucket/dir%2F%2Fobject",
|
||||
"test-bucket/object%00",
|
||||
"test-bucket/object%FF",
|
||||
] {
|
||||
let uri = format!("/rustfs/admin/v3/heal/{target}").parse().expect("encoded URI");
|
||||
let err = parse_registered_heal_request(&uri).expect_err("decoded invalid target must fail closed");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest, "target: {target}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_nested_heal_routes_still_require_authentication() {
|
||||
use s3s::route::S3Route;
|
||||
|
||||
let mut router = super::S3Router::new(false);
|
||||
super::register_heal_route(&mut router).expect("register production Heal routes");
|
||||
for prefix in ["/rustfs/admin", "/minio/admin"] {
|
||||
for object in ["dir/object.bin", "dir%2Fobject.bin", "literal%252Fslash"] {
|
||||
let mut req = s3s::S3Request {
|
||||
input: s3s::Body::empty(),
|
||||
method: http::Method::POST,
|
||||
uri: format!("{prefix}/v3/heal/test-bucket/{object}").parse().expect("Heal URI"),
|
||||
headers: http::HeaderMap::new(),
|
||||
extensions: http::Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
};
|
||||
let err = router
|
||||
.check_access(&mut req)
|
||||
.await
|
||||
.expect_err("router must require a signature");
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
let err = router
|
||||
.call(req)
|
||||
.await
|
||||
.expect_err("handler must independently require authentication");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
assert!(err.to_string().contains("authentication required"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn replacement_record(task_id: &str) -> rustfs_heal::ReplacementRecoveryRecord {
|
||||
rustfs_heal::ReplacementRecoveryRecord {
|
||||
task_id: task_id.to_string(),
|
||||
@@ -2962,50 +2822,6 @@ mod tests {
|
||||
OffsetDateTime::parse(start_time, &Rfc3339).expect("startTime should be RFC3339");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_heal_task_status_uses_settings_from_channel_payload() {
|
||||
let response = rustfs_heal_contracts::heal_channel::HealChannelResponse {
|
||||
request_id: "token".into(),
|
||||
success: true,
|
||||
data: Some(
|
||||
br#"{"summary":"running","settings":{"recursive":true,"dryRun":true,"remove":true,"recreate":false,"scanMode":2,"updateParity":false,"nolock":false,"readRepair":false,"pool":1,"set":2}}"#
|
||||
.to_vec(),
|
||||
),
|
||||
error: None,
|
||||
};
|
||||
let payload = super::heal_channel_response_status(&response).expect("channel status should decode");
|
||||
let encoded =
|
||||
encode_heal_task_status(payload, String::new(), HealOpts::default()).expect("public status should serialize");
|
||||
let json: serde_json::Value = serde_json::from_slice(&encoded).expect("public status should decode");
|
||||
|
||||
assert_eq!(json["settings"]["scanMode"], 2);
|
||||
assert_eq!(json["settings"]["dryRun"], true);
|
||||
assert_eq!(json["settings"]["remove"], true);
|
||||
assert_eq!(json["settings"]["recreate"], false);
|
||||
assert_eq!(json["settings"]["updateParity"], false);
|
||||
assert_eq!(json["settings"]["recursive"], true);
|
||||
assert_eq!(json["settings"]["pool"], 1);
|
||||
assert_eq!(json["settings"]["set"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_heal_task_status_defaults_settings_for_legacy_channel_payload() {
|
||||
let response = rustfs_heal_contracts::heal_channel::HealChannelResponse {
|
||||
request_id: "token".into(),
|
||||
success: true,
|
||||
data: Some(br#"{"summary":"running"}"#.to_vec()),
|
||||
error: None,
|
||||
};
|
||||
let payload = super::heal_channel_response_status(&response).expect("legacy channel status should decode");
|
||||
let encoded =
|
||||
encode_heal_task_status(payload, String::new(), HealOpts::default()).expect("legacy public status should serialize");
|
||||
let json: serde_json::Value = serde_json::from_slice(&encoded).expect("public status should decode");
|
||||
|
||||
assert_eq!(json["settings"]["scanMode"], 1);
|
||||
assert_eq!(json["settings"]["dryRun"], false);
|
||||
assert_eq!(json["settings"]["remove"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_heal_task_status_reports_truncated_items() {
|
||||
let encoded = encode_heal_task_status(
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::admin::storage_api::config::{
|
||||
read_admin_config_without_migrate, read_admin_server_config_snapshot, save_admin_server_config_snapshot,
|
||||
};
|
||||
use crate::admin::utils::json_response;
|
||||
use crate::server::{ADMIN_PREFIX, CONSOLE_PREFIX, MINIO_ADMIN_PREFIX};
|
||||
use crate::server::{ADMIN_PREFIX, MINIO_ADMIN_PREFIX, console_prefix};
|
||||
use http::StatusCode;
|
||||
use hyper::Method;
|
||||
use matchit::Params;
|
||||
@@ -824,7 +824,8 @@ fn build_console_redirect(
|
||||
let fragment =
|
||||
build_console_callback_fragment(access_key, secret_key, session_token, expiration, redirect_after, logout_token);
|
||||
|
||||
let callback_path = format!("{CONSOLE_PREFIX}{CONSOLE_OIDC_CALLBACK_SUFFIX}");
|
||||
let console_prefix = console_prefix();
|
||||
let callback_path = format!("{console_prefix}{CONSOLE_OIDC_CALLBACK_SUFFIX}");
|
||||
if let Some(base_url) = browser_redirect_url(&callback_path)? {
|
||||
return Ok(format!("{base_url}#{fragment}"));
|
||||
}
|
||||
@@ -836,7 +837,8 @@ fn build_console_redirect(
|
||||
}
|
||||
|
||||
fn build_console_login_redirect(req: &S3Request<Body>) -> S3Result<String> {
|
||||
let login_path = format!("{CONSOLE_PREFIX}{CONSOLE_LOGIN_SUFFIX}");
|
||||
let console_prefix = console_prefix();
|
||||
let login_path = format!("{console_prefix}{CONSOLE_LOGIN_SUFFIX}");
|
||||
if let Some(url) = browser_redirect_url(&login_path)? {
|
||||
return Ok(url);
|
||||
}
|
||||
@@ -1672,6 +1674,26 @@ mod tests {
|
||||
assert_eq!(callback, "https://internal:9000/rustfs/admin/v3/oidc/callback/default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_prefix_process_case_oidc() {
|
||||
if std::env::var_os("RUSTFS_TEST_CONSOLE_PREFIX_PROCESS").is_none() {
|
||||
return;
|
||||
}
|
||||
crate::server::init_console_prefix().expect("initialize console prefix");
|
||||
let prefix = console_prefix();
|
||||
let req = build_oidc_request("http://internal/rustfs/admin/v3/oidc/callback/default", Some("internal:9000"), None);
|
||||
assert_eq!(
|
||||
build_console_login_redirect(&req).expect("login URL"),
|
||||
format!("https://console.example.com{prefix}/auth/login")
|
||||
);
|
||||
let redirect = build_console_redirect(&req, "access", "secret", "token", None, None, None).expect("console callback URL");
|
||||
assert!(redirect.starts_with(&format!("https://console.example.com{prefix}/auth/oidc-callback/#")));
|
||||
assert_eq!(
|
||||
derive_callback_uri(&req, "default").expect("admin callback URL"),
|
||||
"https://console.example.com/rustfs/admin/v3/oidc/callback/default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_console_redirect_uses_browser_redirect_url() {
|
||||
let req = build_oidc_request("http://internal/rustfs/admin/v3/oidc/callback/default", Some("internal:9000"), None);
|
||||
@@ -1681,7 +1703,7 @@ mod tests {
|
||||
.expect("console redirect should use browser redirect URL")
|
||||
});
|
||||
|
||||
assert!(redirect.starts_with("https://console.example.com/rustfs/console/auth/oidc-callback/#"));
|
||||
assert!(redirect.starts_with(&format!("https://console.example.com{}/auth/oidc-callback/#", console_prefix())));
|
||||
assert!(redirect.contains("redirect=%2Fbuckets"));
|
||||
assert!(redirect.contains("logoutToken=logout-token"));
|
||||
}
|
||||
@@ -1694,7 +1716,7 @@ mod tests {
|
||||
build_console_login_redirect(&req).expect("login redirect should use browser redirect URL")
|
||||
});
|
||||
|
||||
assert_eq!(redirect, "https://console.example.com/rustfs/console/auth/login");
|
||||
assert_eq!(redirect, format!("https://console.example.com{}/auth/login", console_prefix()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -44,7 +44,8 @@ use crate::admin::utils::{empty_response, json_response, read_compatible_admin_b
|
||||
use crate::error::ApiError;
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use crate::site_replication::identity::{
|
||||
canonical_endpoint, is_https_endpoint, mark_unknown_peer_sync_enabled, same_identity_endpoint, site_identity_key,
|
||||
canonical_endpoint, deployment_id_for_endpoint, is_https_endpoint, mark_unknown_peer_sync_enabled, same_identity_endpoint,
|
||||
site_identity_key,
|
||||
};
|
||||
use crate::storage::storage_api::{lock_bucket_targets_metadata, with_config_object_write_lock};
|
||||
use base64_simd::URL_SAFE_NO_PAD;
|
||||
@@ -312,6 +313,8 @@ struct SRPeerJoinResponse {
|
||||
peer: PeerInfo,
|
||||
#[serde(rename = "initialSyncErrorMessage", default, skip_serializing_if = "String::is_empty")]
|
||||
initial_sync_error_message: String,
|
||||
#[serde(rename = "initialSyncDeferred", default, skip_serializing_if = "std::ops::Not::not")]
|
||||
initial_sync_deferred: bool,
|
||||
/// Whether the receiving site actually applied this join.
|
||||
///
|
||||
/// Three-valued on purpose. `None` means the peer did not report — MinIO
|
||||
@@ -330,6 +333,8 @@ struct SRPeerJoinEnvelope {
|
||||
request: SRPeerJoinReq,
|
||||
#[serde(rename = "deferSyncStateEnable", default, skip_serializing_if = "std::ops::Not::not")]
|
||||
defer_sync_state_enable: bool,
|
||||
#[serde(rename = "deferInitialSync", default, skip_serializing_if = "std::ops::Not::not")]
|
||||
defer_initial_sync: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -2792,6 +2797,19 @@ fn prune_in_sync_status_details(status: &mut SRStatusInfo, opts: &SRStatusOption
|
||||
}
|
||||
}
|
||||
|
||||
fn peer_states_from_infos(
|
||||
site_infos: BTreeMap<String, SRInfo>,
|
||||
reachable_peers: &HashSet<String>,
|
||||
) -> BTreeMap<String, SRStateInfo> {
|
||||
// Failed metainfo fetches leave default entries in site_infos for comparison;
|
||||
// they must not become fabricated peer state. PeerErrors describes the failure.
|
||||
site_infos
|
||||
.into_iter()
|
||||
.filter(|(deployment_id, _)| reachable_peers.contains(deployment_id))
|
||||
.map(|(deployment_id, info)| (deployment_id, info.state))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn build_status_info(state: &SiteReplicationState, local_peer: &PeerInfo, uri: &Uri) -> S3Result<SRStatusInfo> {
|
||||
let opts = sr_status_options(uri);
|
||||
let mut local_info = Some(filter_sr_info(build_sr_info(state, local_peer).await?, &opts));
|
||||
@@ -2920,17 +2938,7 @@ async fn build_status_info(state: &SiteReplicationState, local_peer: &PeerInfo,
|
||||
}
|
||||
|
||||
if opts.peer_state {
|
||||
for (deployment_id, peer) in &state.peers {
|
||||
status.peer_states.insert(
|
||||
deployment_id.clone(),
|
||||
SRStateInfo {
|
||||
name: peer.name.clone(),
|
||||
peers: state.peers.clone(),
|
||||
updated_at: state.updated_at,
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
status.peer_states = peer_states_from_infos(site_infos, &reachable_peers);
|
||||
}
|
||||
|
||||
Ok(status)
|
||||
@@ -2940,6 +2948,7 @@ fn merge_add_sites(
|
||||
mut state: SiteReplicationState,
|
||||
local_peer: PeerInfo,
|
||||
sites: Vec<PeerSite>,
|
||||
preflight_infos: &[SiteReplicationAddPreflightInfo],
|
||||
service_account_access_key: String,
|
||||
service_account_parent: String,
|
||||
replicate_ilm_expiry: bool,
|
||||
@@ -2949,11 +2958,36 @@ fn merge_add_sites(
|
||||
state.service_account_parent = service_account_parent;
|
||||
state.updated_at = Some(OffsetDateTime::now_utc());
|
||||
state.peers = build_join_peers(&state, &local_peer, sites, replicate_ilm_expiry);
|
||||
// Every join must carry the verified identities, including peers that
|
||||
// have not joined yet. Fixing only the coordinator after each reply
|
||||
// leaves the other sites holding endpoint-derived placeholders.
|
||||
for info in preflight_infos {
|
||||
if let Some(mut peer) = existing_peer_for_endpoint(&state, &info.endpoint) {
|
||||
peer.deployment_id = info.deployment_id.clone();
|
||||
state = reconcile_peer_with_actual_identity(state, peer);
|
||||
}
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn update_peer(mut state: SiteReplicationState, incoming: PeerInfo, ilm_expiry_override: Option<bool>) -> SiteReplicationState {
|
||||
let mut peer = normalize_peer_info(incoming);
|
||||
// An older sender may still hold a placeholder after this site has
|
||||
// learned the real ID. Do not let that delivery downgrade the identity.
|
||||
if peer.deployment_id == deployment_id_for_endpoint(&peer.endpoint)
|
||||
&& let Some(existing) = state.peers.values().find(|existing| {
|
||||
same_identity_endpoint(&existing.endpoint, &peer.endpoint)
|
||||
&& existing.deployment_id != deployment_id_for_endpoint(&existing.endpoint)
|
||||
})
|
||||
{
|
||||
peer.deployment_id = existing.deployment_id.clone();
|
||||
}
|
||||
// Remove the placeholder before persistence normalizes duplicate
|
||||
// endpoints; otherwise map ordering can discard the real identity.
|
||||
state.peers.retain(|_, existing| {
|
||||
!same_identity_endpoint(&existing.endpoint, &peer.endpoint)
|
||||
|| existing.deployment_id != deployment_id_for_endpoint(&existing.endpoint)
|
||||
});
|
||||
if let Some(enabled) = ilm_expiry_override {
|
||||
peer.replicate_ilm_expiry = enabled;
|
||||
}
|
||||
@@ -3539,6 +3573,13 @@ fn align_peer_edit_deployment_id(state: &SiteReplicationState, incoming: &mut Pe
|
||||
return;
|
||||
};
|
||||
if matches.next().is_none() {
|
||||
if same_identity_endpoint(&peer.endpoint, &incoming.endpoint)
|
||||
&& peer.deployment_id == deployment_id_for_endpoint(&peer.endpoint)
|
||||
&& !incoming.deployment_id.is_empty()
|
||||
&& incoming.deployment_id != deployment_id_for_endpoint(&incoming.endpoint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
incoming.deployment_id = peer.deployment_id.clone();
|
||||
}
|
||||
}
|
||||
@@ -3853,6 +3894,30 @@ fn pending_remote_peer_ids(peers: &BTreeMap<String, PeerInfo>, local_peer: &Peer
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The peers a pending remove / rotation still has to notify: every remote
|
||||
/// peer that has not acked, with the local site excluded by the same
|
||||
/// deployment-id-or-endpoint identity [`pending_remote_peer_ids`] finalizes
|
||||
/// on. The tick-driven `local_peer` carries the node's own listen address
|
||||
/// rather than the registered site endpoint (and a handler's carries the
|
||||
/// request `Host`, which behind a load balancer differs too), so an
|
||||
/// endpoint-only check dialed the site itself, timed out against the
|
||||
/// lifecycle lock this very request holds, and reported the operation as
|
||||
/// `Partial` (backlog#2367 A-4).
|
||||
fn pending_peers_awaiting_notification<'a>(
|
||||
peers: &'a BTreeMap<String, PeerInfo>,
|
||||
local_peer: &PeerInfo,
|
||||
acked_deployment_ids: &BTreeSet<String>,
|
||||
) -> Vec<&'a PeerInfo> {
|
||||
peers
|
||||
.values()
|
||||
.filter(|peer| {
|
||||
peer.deployment_id != local_peer.deployment_id
|
||||
&& !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint)
|
||||
&& !acked_deployment_ids.contains(&peer.deployment_id)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pending_all_remote_peers_acked(
|
||||
peers: &BTreeMap<String, PeerInfo>,
|
||||
local_peer: &PeerInfo,
|
||||
@@ -4068,12 +4133,7 @@ async fn drive_pending_rotation(pending: &PendingRotation, local_peer: &PeerInfo
|
||||
};
|
||||
|
||||
let mut peer_errors = Vec::new();
|
||||
for peer in pending.peers.values() {
|
||||
if same_identity_endpoint(&peer.endpoint, &local_peer.endpoint)
|
||||
|| pending.acked_deployment_ids.contains(&peer.deployment_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for peer in pending_peers_awaiting_notification(&pending.peers, local_peer, &pending.acked_deployment_ids) {
|
||||
// A superseded join returns BEFORE `apply_iam`, so a no-op answer
|
||||
// means the peer never installed the new secret. Acking it would
|
||||
// finalize a rotation half the mesh cannot authenticate against
|
||||
@@ -4376,12 +4436,9 @@ async fn drive_pending_remove(pending_remove: &PendingRemove, local_peer: &PeerI
|
||||
if secret_candidates.is_empty() {
|
||||
peer_errors.push("site replication service account secret unavailable".to_string());
|
||||
} else {
|
||||
for peer in pending_remove.original_peers.values() {
|
||||
if same_identity_endpoint(&peer.endpoint, &local_peer.endpoint)
|
||||
|| pending_remove.acked_deployment_ids.contains(&peer.deployment_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for peer in
|
||||
pending_peers_awaiting_notification(&pending_remove.original_peers, local_peer, &pending_remove.acked_deployment_ids)
|
||||
{
|
||||
if let Err(err) = PeerAdminRequest::put(
|
||||
&runtime_peer_connection(peer)?,
|
||||
SITE_REPLICATION_PEER_REMOVE_PATH,
|
||||
@@ -4931,7 +4988,7 @@ async fn ensure_site_replication_bucket_targets(bucket: &str) -> S3Result<()> {
|
||||
return Ok(());
|
||||
};
|
||||
let config = bucket_replication_config_for_target_refresh(bucket).await?;
|
||||
ensure_site_replication_bucket_targets_with_runtime(
|
||||
let written = ensure_site_replication_bucket_targets_with_runtime(
|
||||
bucket,
|
||||
&runtime.state,
|
||||
&runtime.local_peer,
|
||||
@@ -4939,7 +4996,11 @@ async fn ensure_site_replication_bucket_targets(bucket: &str) -> S3Result<()> {
|
||||
&runtime.service_account_secret_key,
|
||||
expected_incarnation_id,
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
if written {
|
||||
reload_bucket_metadata_on_peers(bucket, "site_replication_bucket_targets", false).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_site_replication_bucket_setup(bucket: &str) -> S3Result<bool> {
|
||||
@@ -5009,6 +5070,9 @@ async fn cleanup_removed_site_replication_bucket(bucket: &str, removed_deploymen
|
||||
Err(err) => return Err(ApiError::from(err).into()),
|
||||
}
|
||||
|
||||
if removed > 0 {
|
||||
reload_bucket_metadata_on_peers(bucket, "site_replication_bucket_cleanup", true).await;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
@@ -5325,7 +5389,7 @@ async fn refresh_bucket_targets_after_endpoint_edit(pending_id: &str, service_ac
|
||||
let local_peer = current_local_runtime_peer(&target_state);
|
||||
let _targets_guard = lock_bucket_targets_metadata(&bucket.name).await;
|
||||
let replication_config = bucket_replication_config_for_target_refresh(&bucket.name).await?;
|
||||
ensure_site_replication_bucket_targets_with_runtime(
|
||||
let written = ensure_site_replication_bucket_targets_with_runtime(
|
||||
&bucket.name,
|
||||
&target_state,
|
||||
&local_peer,
|
||||
@@ -5334,6 +5398,9 @@ async fn refresh_bucket_targets_after_endpoint_edit(pending_id: &str, service_ac
|
||||
expected_incarnation_id,
|
||||
)
|
||||
.await?;
|
||||
if written {
|
||||
reload_bucket_metadata_on_peers(&bucket.name, "site_replication_endpoint_refresh", false).await;
|
||||
}
|
||||
|
||||
rewritten.push(bucket.name.clone());
|
||||
|
||||
@@ -5377,16 +5444,12 @@ async fn site_bucket_resync_manifest_entry(bucket: &str, peer: &PeerInfo, now: O
|
||||
..Default::default()
|
||||
};
|
||||
let _targets_guard = lock_bucket_targets_metadata(bucket).await;
|
||||
let (config, _) = match metadata_sys::get_replication_config(bucket).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
entry.status = "failed".to_string();
|
||||
entry.err_detail = summarize_peer_error_detail(&err.to_string());
|
||||
return entry;
|
||||
}
|
||||
};
|
||||
let targets = match metadata_sys::list_bucket_targets(bucket).await {
|
||||
Ok(targets) => targets,
|
||||
// Read what is persisted, not this node's cache: the wiring may have
|
||||
// been written by another node moments ago (`start_site_bucket_resync`
|
||||
// already reads its targets from disk), and an operator resync must see
|
||||
// the same records the drive will use.
|
||||
let (config, targets) = match site_bucket_resync_persisted_wiring(bucket).await {
|
||||
Ok(wiring) => wiring,
|
||||
Err(err) => {
|
||||
entry.status = "failed".to_string();
|
||||
entry.err_detail = summarize_peer_error_detail(&err.to_string());
|
||||
@@ -5417,6 +5480,15 @@ async fn site_bucket_resync_manifest_entry(bucket: &str, peer: &PeerInfo, now: O
|
||||
entry
|
||||
}
|
||||
|
||||
/// The persisted replication configuration and bucket targets, bypassing the
|
||||
/// node-local metadata cache. `ConfigNotFound` surfaces for a bucket without
|
||||
/// a replication configuration, matching the cached read's error.
|
||||
async fn site_bucket_resync_persisted_wiring(bucket: &str) -> Result<(ReplicationConfiguration, BucketTargets), StorageError> {
|
||||
let metadata = metadata_sys::get_config_from_disk(bucket).await?;
|
||||
let config = metadata.replication_config.ok_or(StorageError::ConfigNotFound)?;
|
||||
Ok((config, metadata.bucket_target_config.unwrap_or_default()))
|
||||
}
|
||||
|
||||
async fn start_site_bucket_resync(bucket: &str, target_arn: &str, resync_id: &str) -> ResyncBucketStatus {
|
||||
let mut bucket_status = ResyncBucketStatus {
|
||||
bucket: bucket.to_string(),
|
||||
@@ -5439,17 +5511,8 @@ async fn start_site_bucket_resync(bucket: &str, target_arn: &str, resync_id: &st
|
||||
}
|
||||
};
|
||||
|
||||
let (config, _) = match metadata_sys::get_replication_config(bucket).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
bucket_status.status = "failed".to_string();
|
||||
bucket_status.err_detail = err.to_string();
|
||||
return bucket_status;
|
||||
}
|
||||
};
|
||||
|
||||
let targets = match metadata_sys::list_bucket_targets_from_disk(bucket).await {
|
||||
Ok(targets) => targets,
|
||||
let (config, targets) = match site_bucket_resync_persisted_wiring(bucket).await {
|
||||
Ok(wiring) => wiring,
|
||||
Err(err) => {
|
||||
bucket_status.status = "failed".to_string();
|
||||
bucket_status.err_detail = err.to_string();
|
||||
@@ -6046,6 +6109,10 @@ async fn apply_bucket_meta_item(item: SRBucketMeta) -> S3Result<()> {
|
||||
drop(lifecycle_guard);
|
||||
drop(targets_guard);
|
||||
|
||||
if !skip_config_write {
|
||||
reload_bucket_metadata_on_peers(&item.bucket, "site_replication_bucket_meta", item.r#type == "lc-config").await;
|
||||
}
|
||||
|
||||
if item.r#type == "replication-config" {
|
||||
// Rebuild the local outbound rules too: a site that joined an already-replicated
|
||||
// bucket receives this item before it has any `site-repl-*` rule of its own.
|
||||
@@ -6684,6 +6751,7 @@ fn parse_peer_join_response(body: &[u8], fallback_peer: PeerInfo) -> Result<SRPe
|
||||
return Ok(SRPeerJoinResponse {
|
||||
peer: fallback_peer,
|
||||
initial_sync_error_message: String::new(),
|
||||
initial_sync_deferred: false,
|
||||
applied: None,
|
||||
});
|
||||
}
|
||||
@@ -6775,6 +6843,7 @@ impl Operation for SiteReplicationAddHandler {
|
||||
current_state,
|
||||
local_peer.clone(),
|
||||
sites.clone(),
|
||||
&preflight_infos,
|
||||
service_account_access_key.clone(),
|
||||
admin_access_key,
|
||||
replicate_ilm_expiry,
|
||||
@@ -6789,66 +6858,86 @@ impl Operation for SiteReplicationAddHandler {
|
||||
updated_at: state.updated_at,
|
||||
},
|
||||
defer_sync_state_enable: true,
|
||||
defer_initial_sync: true,
|
||||
};
|
||||
let peer_join_path = with_site_replication_bootstrap_token(
|
||||
SITE_REPLICATION_PEER_JOIN_PATH,
|
||||
&add_in_progress_guard.token.to_string(),
|
||||
);
|
||||
|
||||
let mut joined_endpoints = HashSet::new();
|
||||
// Install every site's service account before any receiver probes or
|
||||
// backfills to a peer that may not have joined yet. Reuse the join
|
||||
// snapshot for the second pass without repeating IAM/topology writes.
|
||||
// Only peers acknowledging deferral get a second request; older
|
||||
// receivers retain their one-pass behavior and reported errors.
|
||||
let initial_sync_path = format!("{peer_join_path}&initial-sync=true");
|
||||
let mut initial_sync_errors = SiteReplicationErrorSummary::default();
|
||||
for (site, preflight) in sites.iter().zip(preflight_infos.iter()) {
|
||||
if same_identity_endpoint(&site.endpoint, &local_peer.endpoint)
|
||||
|| !joined_endpoints.insert(site_identity_key(&site.endpoint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut deferred_endpoints = HashSet::new();
|
||||
for (path, defer_initial_sync) in [(&peer_join_path, true), (&initial_sync_path, false)] {
|
||||
let mut joined_endpoints = HashSet::new();
|
||||
for (site, preflight) in sites.iter().zip(preflight_infos.iter()) {
|
||||
if same_identity_endpoint(&site.endpoint, &local_peer.endpoint)
|
||||
|| (!defer_initial_sync && !deferred_endpoints.contains(&site_identity_key(&site.endpoint)))
|
||||
|| !joined_endpoints.insert(site_identity_key(&site.endpoint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut peer_join_req = join_req.clone();
|
||||
peer_join_req.request.svc_acct_parent = site.access_key.clone();
|
||||
let connection = PeerConnection::try_from(site)?;
|
||||
let body = PeerAdminRequest::put(&connection, &peer_join_path, &site.access_key)
|
||||
.send(&site.secret_key, &peer_join_req)
|
||||
.await?;
|
||||
let mut peer_join_req = join_req.clone();
|
||||
peer_join_req.defer_initial_sync = defer_initial_sync;
|
||||
peer_join_req.request.svc_acct_parent = site.access_key.clone();
|
||||
let connection = PeerConnection::try_from(site)?;
|
||||
let body = PeerAdminRequest::put(&connection, path, &site.access_key)
|
||||
.send(&site.secret_key, &peer_join_req)
|
||||
.await?;
|
||||
|
||||
let mut fallback_peer = existing_peer_for_endpoint(&state, &site.endpoint)
|
||||
.unwrap_or_else(|| normalize_peer_site(site.clone(), replicate_ilm_expiry));
|
||||
fallback_peer.deployment_id = preflight.deployment_id.clone();
|
||||
let join_response = parse_peer_join_response(&body, fallback_peer).map_err(|e| {
|
||||
S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
format!("parse peer join response from {} failed: {e}", site.endpoint),
|
||||
)
|
||||
})?;
|
||||
if !join_response.initial_sync_error_message.is_empty() {
|
||||
initial_sync_errors.push(format!("{}: {}", site.endpoint, join_response.initial_sync_error_message));
|
||||
let mut fallback_peer = existing_peer_for_endpoint(&state, &site.endpoint)
|
||||
.unwrap_or_else(|| normalize_peer_site(site.clone(), replicate_ilm_expiry));
|
||||
fallback_peer.deployment_id = preflight.deployment_id.clone();
|
||||
let join_response = parse_peer_join_response(&body, fallback_peer).map_err(|e| {
|
||||
S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
format!("parse peer join response from {} failed: {e}", site.endpoint),
|
||||
)
|
||||
})?;
|
||||
if join_response.initial_sync_deferred {
|
||||
if defer_initial_sync {
|
||||
deferred_endpoints.insert(site_identity_key(&site.endpoint));
|
||||
} else {
|
||||
initial_sync_errors.push(format!("{}: peer did not complete initial sync", site.endpoint));
|
||||
}
|
||||
}
|
||||
if !join_response.initial_sync_error_message.is_empty() {
|
||||
initial_sync_errors.push(format!("{}: {}", site.endpoint, join_response.initial_sync_error_message));
|
||||
}
|
||||
// An explicit no-op join. The peer answered 200 but wrote nothing —
|
||||
// its persisted state is already newer than the snapshot it was
|
||||
// sent — so the add is only PARTIALLY configured and saying
|
||||
// "configured successfully" would be a lie (rustfs/rustfs#5963).
|
||||
// `None` (a MinIO peer, or one older than the field) is not a
|
||||
// no-op signal and is deliberately not reported.
|
||||
if join_response.applied == Some(false) {
|
||||
let phase = if defer_initial_sync { "join" } else { "initial sync" };
|
||||
initial_sync_errors.push(format!(
|
||||
"{}: peer did not apply the {phase} (its site replication state is newer than the snapshot it was sent); \
|
||||
the site is not configured against this peer",
|
||||
site.endpoint
|
||||
));
|
||||
}
|
||||
state = reconcile_peer_with_actual_identity(state, join_response.peer);
|
||||
let reconciled_peer = existing_peer_for_endpoint(&state, &site.endpoint).ok_or_else(|| {
|
||||
S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
format!("peer join response from {} did not identify the requested site", site.endpoint),
|
||||
)
|
||||
})?;
|
||||
validate_proposed_peer(&reconciled_peer).map_err(|err| {
|
||||
S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
format!("invalid peer join response from {}: {err}", site.endpoint),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
// An explicit no-op join. The peer answered 200 but wrote nothing —
|
||||
// its persisted state is already newer than the snapshot it was
|
||||
// sent — so the add is only PARTIALLY configured and saying
|
||||
// "configured successfully" would be a lie (rustfs/rustfs#5963).
|
||||
// `None` (a MinIO peer, or one older than the field) is not a
|
||||
// no-op signal and is deliberately not reported.
|
||||
if join_response.applied == Some(false) {
|
||||
initial_sync_errors.push(format!(
|
||||
"{}: peer did not apply the join (its site replication state is newer than the snapshot it was sent); \
|
||||
the site is not configured against this peer",
|
||||
site.endpoint
|
||||
));
|
||||
}
|
||||
state = reconcile_peer_with_actual_identity(state, join_response.peer);
|
||||
let reconciled_peer = existing_peer_for_endpoint(&state, &site.endpoint).ok_or_else(|| {
|
||||
S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
format!("peer join response from {} did not identify the requested site", site.endpoint),
|
||||
)
|
||||
})?;
|
||||
validate_proposed_peer(&reconciled_peer).map_err(|err| {
|
||||
S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
format!("invalid peer join response from {}: {err}", site.endpoint),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
mark_unknown_peer_sync_enabled(&mut state.peers);
|
||||
@@ -7122,6 +7211,24 @@ impl Operation for SiteReplicationNetPerfHandler {
|
||||
|
||||
pub struct SRPeerJoinHandler {}
|
||||
|
||||
fn ensure_initial_sync_join_current(state: &SiteReplicationState, join_req: &SRPeerJoinReq) -> S3Result<()> {
|
||||
if !state.enabled()
|
||||
|| join_req.updated_at.is_none()
|
||||
|| state.updated_at != join_req.updated_at
|
||||
|| state.service_account_access_key.is_empty()
|
||||
|| state.service_account_access_key != join_req.svc_acct_access_key
|
||||
|| state.pending_remove.is_some()
|
||||
|| state.pending_rotation.is_some()
|
||||
|| pending_endpoint_refresh(state).is_some()
|
||||
{
|
||||
return Err(s3_error!(
|
||||
InvalidRequest,
|
||||
"site replication changed before initial sync; re-run replicate add"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the join admission decided about an incoming peer join. The verdict —
|
||||
/// and the committed state the back-fill afterwards needs — travel out of
|
||||
/// [`admit_peer_join`] instead of being answered where they are decided.
|
||||
@@ -7284,6 +7391,7 @@ fn superseded_join_response(peer: PeerInfo) -> SRPeerJoinResponse {
|
||||
SRPeerJoinResponse {
|
||||
peer,
|
||||
initial_sync_error_message: String::new(),
|
||||
initial_sync_deferred: false,
|
||||
applied: Some(false),
|
||||
}
|
||||
}
|
||||
@@ -7293,6 +7401,7 @@ fn applied_join_response(peer: PeerInfo, initial_sync_error_message: String) ->
|
||||
SRPeerJoinResponse {
|
||||
peer,
|
||||
initial_sync_error_message,
|
||||
initial_sync_deferred: false,
|
||||
applied: Some(true),
|
||||
}
|
||||
}
|
||||
@@ -7302,17 +7411,30 @@ impl Operation for SRPeerJoinHandler {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let cred = validate_site_replication_admin_request(&req, AdminAction::SiteReplicationAddAction).await?;
|
||||
let bootstrap_token = site_replication_bootstrap_token(&req.uri);
|
||||
let initial_sync_only = query_pairs(&req.uri).get("initial-sync").is_some_and(|value| value == "true");
|
||||
let local_endpoint = site_replication_local_endpoint(&req.uri, &req.headers);
|
||||
// The body is fully read before the admission takes the lifecycle
|
||||
// guard: a sender that stalls mid-body must not block this node's
|
||||
// add/remove/rotate/reconciler.
|
||||
let join_envelope: SRPeerJoinEnvelope = read_site_replication_json(req, &cred.secret_key, true).await?;
|
||||
let defer_sync_state_enable = join_envelope.defer_sync_state_enable;
|
||||
let defer_initial_sync = join_envelope.defer_initial_sync;
|
||||
let join_req = join_envelope.request;
|
||||
validate_join_peer_snapshot(&join_req.peers)?;
|
||||
|
||||
let committed =
|
||||
admit_peer_join(local_endpoint, join_req, defer_sync_state_enable, apply_peer_join_service_account).await?;
|
||||
let _initial_sync_guard = if initial_sync_only {
|
||||
Some(SiteReplicationLifecycleGuard::acquire().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let committed = if initial_sync_only {
|
||||
let state = load_site_replication_state().await?;
|
||||
ensure_initial_sync_join_current(&state, &join_req)?;
|
||||
let local_peer = local_peer_at_endpoint(local_endpoint, &state);
|
||||
PeerJoinOutcome::Applied(Box::new(state), local_peer)
|
||||
} else {
|
||||
admit_peer_join(local_endpoint, join_req, defer_sync_state_enable, apply_peer_join_service_account).await?
|
||||
};
|
||||
// Committed; the reverse-reachability probe and the bucket back-fill
|
||||
// run outside the transaction — their transport helpers' retry-event
|
||||
// bookkeeping re-enters it (P1-15).
|
||||
@@ -7329,6 +7451,12 @@ impl Operation for SRPeerJoinHandler {
|
||||
return json_response(StatusCode::OK, &superseded_join_response(peer));
|
||||
}
|
||||
};
|
||||
if defer_initial_sync && !initial_sync_only {
|
||||
let mut response =
|
||||
applied_join_response(state.peers.get(&local_peer.deployment_id).cloned().unwrap_or(local_peer), String::new());
|
||||
response.initial_sync_deferred = true;
|
||||
return json_response(StatusCode::OK, &response);
|
||||
}
|
||||
// Fix 1 (receiving side): ensure the joining peer also sets up replication for any
|
||||
// buckets it already owns so the reverse direction works from the start. Per-bucket
|
||||
// failures are logged (BUG2) so a reverse-direction back-fill gap is observable.
|
||||
@@ -7432,6 +7560,7 @@ impl Operation for SRPeerBucketOpsHandler {
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
reload_bucket_metadata_on_peers(&bucket, "site_replication_make_bucket", false).await;
|
||||
}
|
||||
"configure-replication" => {
|
||||
store
|
||||
@@ -8455,9 +8584,92 @@ impl Operation for SRRotateServiceAccountHandler {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::site_replication::identity::deployment_id_for_endpoint;
|
||||
use rustfs_madmin::SRSessionPolicy;
|
||||
|
||||
#[test]
|
||||
fn peer_states_preserve_each_sites_actual_membership_and_metadata() {
|
||||
let local = SRStateInfo {
|
||||
name: "local".to_string(),
|
||||
peers: BTreeMap::from([(
|
||||
"actual-remote".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "actual-remote".to_string(),
|
||||
endpoint: "http://remote:9000".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
)]),
|
||||
updated_at: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
api_version: Some("1".to_string()),
|
||||
};
|
||||
let remote = SRStateInfo {
|
||||
name: "remote-reported-name".to_string(),
|
||||
peers: BTreeMap::from([(
|
||||
"legacy-placeholder".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "legacy-placeholder".to_string(),
|
||||
endpoint: "http://local:9000".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
)]),
|
||||
updated_at: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(10)),
|
||||
api_version: None,
|
||||
};
|
||||
let infos = BTreeMap::from([
|
||||
(
|
||||
"local".to_string(),
|
||||
SRInfo {
|
||||
state: local.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"remote".to_string(),
|
||||
SRInfo {
|
||||
state: remote.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
]);
|
||||
let states = peer_states_from_infos(infos, &HashSet::from(["local".to_string(), "remote".to_string()]));
|
||||
assert_eq!(states.len(), 2);
|
||||
assert_eq!(serde_json::to_value(&states["local"]).unwrap(), serde_json::to_value(local).unwrap());
|
||||
assert_eq!(serde_json::to_value(&states["remote"]).unwrap(), serde_json::to_value(remote).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_states_omit_unreachable_peers_instead_of_defaulting_them() {
|
||||
let infos = BTreeMap::from([
|
||||
("local".to_string(), SRInfo::default()),
|
||||
("offline".to_string(), SRInfo::default()),
|
||||
]);
|
||||
let states = peer_states_from_infos(infos, &HashSet::from(["local".to_string()]));
|
||||
assert_eq!(states.len(), 1);
|
||||
assert!(states.contains_key("local"));
|
||||
assert!(!states.contains_key("offline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_states_preserve_a_reachable_peers_empty_membership() {
|
||||
let states = peer_states_from_infos(
|
||||
BTreeMap::from([(
|
||||
"remote".to_string(),
|
||||
SRInfo {
|
||||
enabled: false,
|
||||
state: SRStateInfo {
|
||||
name: "remote".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
)]),
|
||||
&HashSet::from(["remote".to_string()]),
|
||||
);
|
||||
assert_eq!(states["remote"].name, "remote");
|
||||
assert!(states["remote"].peers.is_empty());
|
||||
assert!(states["remote"].updated_at.is_none());
|
||||
assert!(states["remote"].api_version.is_none());
|
||||
}
|
||||
|
||||
/// A peer the status probe could not reach must render as offline.
|
||||
///
|
||||
/// Regression: `build_metrics_summary` used to hardcode `online: true` and
|
||||
@@ -10889,6 +11101,7 @@ mod tests {
|
||||
secret_key: "remote-sk".to_string(),
|
||||
..PeerSite::default()
|
||||
}],
|
||||
&[],
|
||||
"svc-ak".to_string(),
|
||||
"root".to_string(),
|
||||
true,
|
||||
@@ -10922,6 +11135,7 @@ mod tests {
|
||||
..PeerSite::default()
|
||||
},
|
||||
],
|
||||
&[],
|
||||
"svc-ak".to_string(),
|
||||
"root".to_string(),
|
||||
true,
|
||||
@@ -12128,6 +12342,173 @@ mod tests {
|
||||
assert!(normalized.contains_key("hash-remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peer_identity_join_snapshot_uses_verified_ids() {
|
||||
let actual = ["site-a", "site-b", "site-c"].map(|name| PeerInfo {
|
||||
deployment_id: format!("{name}-deployment"),
|
||||
..peer(name, &format!("https://{name}.example.com:9000"))
|
||||
});
|
||||
let preflight = actual
|
||||
.iter()
|
||||
.map(|peer| preflight_site("reported-name", &peer.endpoint, &peer.deployment_id, 0))
|
||||
.collect::<Vec<_>>();
|
||||
let sites = actual
|
||||
.iter()
|
||||
.map(|peer| PeerSite {
|
||||
name: peer.name.clone(),
|
||||
endpoint: peer.endpoint.clone(),
|
||||
skip_tls_verify: true,
|
||||
ca_cert_pem: "requested-ca".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let state = merge_add_sites(
|
||||
SiteReplicationState::default(),
|
||||
actual[0].clone(),
|
||||
sites,
|
||||
&preflight,
|
||||
"svc-ak".to_string(),
|
||||
"root".to_string(),
|
||||
false,
|
||||
);
|
||||
assert_eq!(state.peers.len(), actual.len());
|
||||
for expected in &actual {
|
||||
let stored = state
|
||||
.peers
|
||||
.get(&expected.deployment_id)
|
||||
.expect("verified ID in initial join map");
|
||||
assert_eq!(stored.name, expected.name);
|
||||
assert_eq!(stored.endpoint, expected.endpoint);
|
||||
assert!(stored.skip_tls_verify);
|
||||
assert_eq!(stored.ca_cert_pem, "requested-ca");
|
||||
}
|
||||
for local in &actual[1..] {
|
||||
let mut joined = SiteReplicationState::default();
|
||||
apply_peer_join(
|
||||
&mut joined,
|
||||
local,
|
||||
SRPeerJoinReq {
|
||||
peers: state.peers.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
true,
|
||||
);
|
||||
assert_eq!(joined.peers.keys().collect::<Vec<_>>(), state.peers.keys().collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peer_identity_legacy_edit_does_not_restore_placeholder() {
|
||||
let actual = PeerInfo {
|
||||
deployment_id: "actual-remote".to_string(),
|
||||
..peer("remote", "http://remote.example.com:9000")
|
||||
};
|
||||
for name in ["remote", ""] {
|
||||
let state = SiteReplicationState {
|
||||
peers: BTreeMap::from([(actual.deployment_id.clone(), actual.clone())]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut incoming = PeerInfo {
|
||||
deployment_id: deployment_id_for_endpoint("https://REMOTE.example.com:9000/"),
|
||||
sync_state: SyncStatus::Enable,
|
||||
..peer(name, "https://REMOTE.example.com:9000/")
|
||||
};
|
||||
align_peer_edit_deployment_id(&state, &mut incoming);
|
||||
let state = update_peer(state, incoming, None);
|
||||
assert_eq!(state.peers.len(), 1);
|
||||
assert_eq!(state.peers[&actual.deployment_id].sync_state, SyncStatus::Enable);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peer_identity_finalization_repairs_legacy_three_site_join() {
|
||||
let actual = ["site-a", "site-b", "site-c"].map(|name| PeerInfo {
|
||||
deployment_id: format!("{name}-deployment"),
|
||||
..peer(name, &format!("http://{name}.example.com:9000"))
|
||||
});
|
||||
let sites = actual
|
||||
.iter()
|
||||
.map(|peer| PeerSite {
|
||||
name: peer.name.clone(),
|
||||
endpoint: peer.endpoint.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let mut coordinator = merge_add_sites(
|
||||
SiteReplicationState::default(),
|
||||
actual[0].clone(),
|
||||
sites,
|
||||
&[],
|
||||
"svc-ak".to_string(),
|
||||
"root".to_string(),
|
||||
true,
|
||||
);
|
||||
let join = SRPeerJoinReq {
|
||||
peers: coordinator.peers.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
for remote in &actual[1..] {
|
||||
coordinator = reconcile_peer_with_actual_identity(coordinator, remote.clone());
|
||||
}
|
||||
mark_unknown_peer_sync_enabled(&mut coordinator.peers);
|
||||
|
||||
for local in &actual[1..] {
|
||||
let mut state = SiteReplicationState::default();
|
||||
apply_peer_join(&mut state, local, join.clone(), true);
|
||||
for mut incoming in coordinator.peers.values().cloned() {
|
||||
align_peer_edit_deployment_id(&state, &mut incoming);
|
||||
state = apply_internal_peer_edit(state, local, incoming, None).expect("finalize peer identity");
|
||||
}
|
||||
assert_eq!(state.peers.len(), actual.len(), "finalization must not retain placeholder peers");
|
||||
for expected in &actual {
|
||||
let stored = existing_peer_for_endpoint(&state, &expected.endpoint).expect("peer remains configured");
|
||||
assert_eq!(stored.deployment_id, expected.deployment_id, "observer: {}", local.name);
|
||||
assert_eq!(stored.sync_state, SyncStatus::Enable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peer_identity_edit_replaces_placeholder_for_canonical_endpoint() {
|
||||
let local = PeerInfo {
|
||||
deployment_id: "local-deployment".to_string(),
|
||||
..peer("local", "https://local.example.com:9000")
|
||||
};
|
||||
let endpoint = "http://remote.example.com:9000";
|
||||
let placeholder = PeerInfo {
|
||||
deployment_id: deployment_id_for_endpoint(endpoint),
|
||||
..peer("remote", endpoint)
|
||||
};
|
||||
for deployment_id in ["00000000-0000-4000-8000-000000000001", "ffffffff-ffff-4fff-bfff-ffffffffffff"] {
|
||||
for name in ["remote", ""] {
|
||||
for already_present in [false, true] {
|
||||
let mut incoming = PeerInfo {
|
||||
deployment_id: deployment_id.to_string(),
|
||||
sync_state: SyncStatus::Enable,
|
||||
..peer(name, "https://REMOTE.example.com:9000/")
|
||||
};
|
||||
let mut state = SiteReplicationState {
|
||||
peers: BTreeMap::from([
|
||||
(local.deployment_id.clone(), local.clone()),
|
||||
(placeholder.deployment_id.clone(), placeholder.clone()),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
if already_present {
|
||||
state.peers.insert(incoming.deployment_id.clone(), incoming.clone());
|
||||
}
|
||||
align_peer_edit_deployment_id(&state, &mut incoming);
|
||||
let state = apply_internal_peer_edit(state, &local, incoming, None).expect("repair peer identity");
|
||||
assert_eq!(state.peers.len(), 2, "repair must replace, not duplicate, the placeholder");
|
||||
assert!(!state.peers.contains_key(&placeholder.deployment_id));
|
||||
assert!(state.peers.contains_key(deployment_id));
|
||||
let normalized = normalize_peer_map_by_identity(state.peers);
|
||||
assert!(normalized.contains_key(deployment_id), "normalization must retain the actual ID");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reconcile_peer_with_actual_identity_replaces_endpoint_hash_key() {
|
||||
let mut state = SiteReplicationState::default();
|
||||
@@ -13450,6 +13831,7 @@ mod tests {
|
||||
assert_eq!(response.peer.deployment_id, "remote-deployment");
|
||||
assert_eq!(response.peer.endpoint, "https://remote.example.com");
|
||||
assert!(response.initial_sync_error_message.is_empty());
|
||||
assert!(!response.initial_sync_deferred);
|
||||
assert_eq!(
|
||||
response.applied, None,
|
||||
"a MinIO empty-body success reports nothing; it must not read as a no-op join"
|
||||
@@ -13459,6 +13841,7 @@ mod tests {
|
||||
let json = serde_json::to_vec(&SRPeerJoinResponse {
|
||||
peer: peer("actual", "https://actual.example.com"),
|
||||
initial_sync_error_message: "sync failed".to_string(),
|
||||
initial_sync_deferred: false,
|
||||
applied: Some(true),
|
||||
})
|
||||
.expect("serialize join response");
|
||||
@@ -14311,6 +14694,69 @@ mod tests {
|
||||
assert_eq!(value.get("deferSyncStateEnable"), Some(&Value::Bool(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initial_sync_join_requires_the_committed_snapshot() {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let mut state = SiteReplicationState {
|
||||
updated_at: Some(now),
|
||||
service_account_access_key: "replicator".to_string(),
|
||||
peers: BTreeMap::from([
|
||||
("a".to_string(), peer("a", "https://a.example.com")),
|
||||
("b".to_string(), peer("b", "https://b.example.com")),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut request = SRPeerJoinReq {
|
||||
updated_at: Some(now),
|
||||
svc_acct_access_key: "replicator".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
ensure_initial_sync_join_current(&state, &request).expect("same committed join");
|
||||
for timestamp in [None, Some(now - time::Duration::SECOND), Some(now + time::Duration::SECOND)] {
|
||||
request.updated_at = timestamp;
|
||||
assert!(ensure_initial_sync_join_current(&state, &request).is_err());
|
||||
}
|
||||
request.updated_at = Some(now);
|
||||
request.svc_acct_access_key = "another-replicator".to_string();
|
||||
assert!(ensure_initial_sync_join_current(&state, &request).is_err());
|
||||
request.svc_acct_access_key.clone_from(&state.service_account_access_key);
|
||||
state.pending_remove = Some(PendingRemove::default());
|
||||
assert!(ensure_initial_sync_join_current(&state, &request).is_err());
|
||||
state.pending_remove = None;
|
||||
state.pending_rotation = Some(PendingRotation::default());
|
||||
assert!(ensure_initial_sync_join_current(&state, &request).is_err());
|
||||
state.pending_rotation = None;
|
||||
state.pending_endpoint_refresh = Some(PendingEndpointRefresh::default());
|
||||
assert!(ensure_initial_sync_join_current(&state, &request).is_err());
|
||||
state.pending_endpoint_refresh = None;
|
||||
state.service_account_access_key.clear();
|
||||
request.svc_acct_access_key.clear();
|
||||
assert!(ensure_initial_sync_join_current(&state, &request).is_err());
|
||||
state.service_account_access_key = "replicator".to_string();
|
||||
request.svc_acct_access_key.clone_from(&state.service_account_access_key);
|
||||
state.peers.clear();
|
||||
assert!(ensure_initial_sync_join_current(&state, &request).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_join_initial_sync_deferral_preserves_legacy_requests() {
|
||||
let legacy: SRPeerJoinEnvelope = serde_json::from_str("{}").expect("legacy join");
|
||||
assert!(!legacy.defer_initial_sync);
|
||||
assert!(serde_json::to_value(&legacy).unwrap().get("deferInitialSync").is_none());
|
||||
let deferred: SRPeerJoinEnvelope = serde_json::from_str(r#"{"deferInitialSync":true}"#).expect("deferred join");
|
||||
assert!(deferred.defer_initial_sync);
|
||||
assert_eq!(serde_json::to_value(deferred).unwrap()["deferInitialSync"], true);
|
||||
let mut response = applied_join_response(peer("b", "https://b.example.com"), String::new());
|
||||
assert!(serde_json::to_value(&response).unwrap().get("initialSyncDeferred").is_none());
|
||||
response.initial_sync_deferred = true;
|
||||
let wire = serde_json::to_vec(&response).unwrap();
|
||||
assert!(
|
||||
parse_peer_join_response(&wire, PeerInfo::default())
|
||||
.unwrap()
|
||||
.initial_sync_deferred
|
||||
);
|
||||
}
|
||||
|
||||
// BUG2: pre-existing-bucket back-fill failures must be surfaced in the add response's
|
||||
// initial_sync_error_message, not swallowed behind an unqualified success.
|
||||
#[test]
|
||||
@@ -14363,6 +14809,7 @@ mod tests {
|
||||
let value = serde_json::to_value(SRPeerJoinResponse {
|
||||
peer: peer("remote", "https://remote.example.com"),
|
||||
initial_sync_error_message: "bucket setup failed".to_string(),
|
||||
initial_sync_deferred: false,
|
||||
applied: Some(true),
|
||||
})
|
||||
.expect("serialize peer join response");
|
||||
@@ -14374,6 +14821,7 @@ mod tests {
|
||||
let value = serde_json::to_value(SRPeerJoinResponse {
|
||||
peer: peer("remote", "https://remote.example.com"),
|
||||
initial_sync_error_message: String::new(),
|
||||
initial_sync_deferred: false,
|
||||
applied: None,
|
||||
})
|
||||
.expect("serialize peer join response");
|
||||
@@ -15434,4 +15882,54 @@ mod tests {
|
||||
"no replicated config write may bypass the source stamp"
|
||||
);
|
||||
}
|
||||
|
||||
/// backlog#2367 A-4: `remove --all` notified "the peer" at the site's own
|
||||
/// registered endpoint. The tick-driven local peer carries the node's
|
||||
/// listen address, so an endpoint-only self check let the loop dial the
|
||||
/// site itself and report `Partial: failed to notify 1 peer(s)`.
|
||||
#[test]
|
||||
fn pending_notifications_skip_the_local_site_by_deployment_id() {
|
||||
let local_registered = PeerInfo {
|
||||
deployment_id: "site-b".to_string(),
|
||||
..peer("site-b", "http://site-b.example.com:9000")
|
||||
};
|
||||
let remote = PeerInfo {
|
||||
deployment_id: "site-a".to_string(),
|
||||
..peer("site-a", "http://site-a.example.com:9000")
|
||||
};
|
||||
let acked = PeerInfo {
|
||||
deployment_id: "site-c".to_string(),
|
||||
..peer("site-c", "http://site-c.example.com:9000")
|
||||
};
|
||||
let peers = BTreeMap::from([
|
||||
(local_registered.deployment_id.clone(), local_registered.clone()),
|
||||
(remote.deployment_id.clone(), remote),
|
||||
(acked.deployment_id.clone(), acked.clone()),
|
||||
]);
|
||||
let acked_ids = BTreeSet::from([acked.deployment_id]);
|
||||
|
||||
// The tick resolves the local peer from its own listen address.
|
||||
let local_from_tick = PeerInfo {
|
||||
deployment_id: "site-b".to_string(),
|
||||
..peer("site-b", "http://127.0.0.1:9000")
|
||||
};
|
||||
let to_notify: Vec<&str> = pending_peers_awaiting_notification(&peers, &local_from_tick, &acked_ids)
|
||||
.iter()
|
||||
.map(|peer| peer.deployment_id.as_str())
|
||||
.collect();
|
||||
assert_eq!(to_notify, vec!["site-a"], "the local site and the acked peer are never dialed");
|
||||
|
||||
// Identity stays consistent with what finalization waits for.
|
||||
assert_eq!(
|
||||
pending_remote_peer_ids(&peers, &local_from_tick),
|
||||
BTreeSet::from(["site-a".to_string(), "site-c".to_string()])
|
||||
);
|
||||
|
||||
// A handler-resolved local peer (registered endpoint) agrees.
|
||||
let to_notify: Vec<&str> = pending_peers_awaiting_notification(&peers, &local_registered, &acked_ids)
|
||||
.iter()
|
||||
.map(|peer| peer.deployment_id.as_str())
|
||||
.collect();
|
||||
assert_eq!(to_notify, vec!["site-a"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ const SITE_REPLICATION_RESYNC_ROUTE: &str = "/rustfs/admin/v3/site-replication/r
|
||||
const SITE_REPLICATION_REPAIR_ROUTE: &str = "/rustfs/admin/v3/site-replication/repair";
|
||||
const SITE_REPLICATION_REPAIR_STATUS_ROUTE: &str = "/rustfs/admin/v3/site-replication/repair/status";
|
||||
const IAM_POLICY_ATTACH_ROUTE: &str = "/rustfs/admin/v3/idp/builtin/policy/attach";
|
||||
const DATA_USAGE_INFO_ROUTE: &str = "/rustfs/admin/v3/datausageinfo";
|
||||
const IAM_POLICY_DETACH_ROUTE: &str = "/rustfs/admin/v3/idp/builtin/policy/detach";
|
||||
const IAM_POLICY_ENTITIES_ROUTE: &str = "/rustfs/admin/v3/idp/builtin/policy-entities";
|
||||
const IAM_ACCESS_KEYS_BULK_ROUTE: &str = "/rustfs/admin/v3/list-access-keys-bulk";
|
||||
@@ -1077,12 +1078,25 @@ fn advertised_admin_capabilities() -> Vec<AdvertisedAdminCapability> {
|
||||
("admin.account.mfa", HttpMethod::Get, ACCOUNT_MFA_ROUTE),
|
||||
("admin.mfa.challenge", HttpMethod::Get, MFA_CHALLENGE_ROUTE),
|
||||
("admin.user.mfa", HttpMethod::Get, USER_MFA_ROUTE),
|
||||
// `rc du` is gated on this name. Before it was advertised the client
|
||||
// inferred it from a `1.0.0-rc.` version prefix, which no longer
|
||||
// matches once the server reports `1.0.0` (backlog#2367 E-2).
|
||||
("admin.data-usage", HttpMethod::Get, DATA_USAGE_INFO_ROUTE),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, method, route)| AdvertisedAdminCapability {
|
||||
name,
|
||||
status: admin_route_capability(method, route),
|
||||
})
|
||||
.chain(std::iter::once(AdvertisedAdminCapability {
|
||||
// `rc watch` streams `GET /{bucket}?events=`, a misc extension route
|
||||
// dispatched by `admin::router` rather than an admin policy route,
|
||||
// so its status is not an inventory lookup (same version-prefix
|
||||
// inference on the client as `admin.data-usage`).
|
||||
name: "listen_notification",
|
||||
status: CapabilityStatus::supported()
|
||||
.with_reason("bucket listen notification (?events=) is dispatched by the admin router"),
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1258,6 +1272,9 @@ mod tests {
|
||||
"admin.iam.access-keys-bulk",
|
||||
"admin.iam.access-keys-bulk.ldap",
|
||||
"admin.iam.access-keys-bulk.openid",
|
||||
// rc pinned these two by version prefix until 1.0.0 (backlog#2367 E-2).
|
||||
"admin.data-usage",
|
||||
"listen_notification",
|
||||
];
|
||||
for name in expected_supported {
|
||||
let entry = response
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user