mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-05 19:55:37 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 043842d571 | |||
| e1608fbd9c | |||
| eb1b17802c | |||
| 112f70914d | |||
| ea9aa53fd8 | |||
| dd368f0f5b | |||
| 6d8606412e | |||
| 037354cec0 | |||
| 30ab919bb3 | |||
| 8fc1c9281e | |||
| 14cef91423 | |||
| c9acc33720 | |||
| 955d491174 | |||
| 1c4e9f1b65 | |||
| a9f01dbbdb | |||
| 35aefbb2a5 | |||
| 8fb335cf19 | |||
| 8f763fb1a2 | |||
| a6b5da64f2 | |||
| 1210428b6d | |||
| d5426f59ec | |||
| 4da48f888e | |||
| f54323b062 | |||
| cc5060ac20 | |||
| d8580ec970 | |||
| 034efa8dac | |||
| 188f380b3b | |||
| e2a921bc16 | |||
| 447f3c704b | |||
| d915f9565e | |||
| 55ad7508b9 | |||
| 33fd056000 | |||
| 1277c11f04 | |||
| bdb069c0fd | |||
| b65ab747d3 | |||
| 8c48f184b5 |
@@ -0,0 +1,2 @@
|
||||
sha256-linux=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
||||
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
||||
@@ -41,6 +41,7 @@ script-tests: ## Run shell script tests
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_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/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
|
||||
|
||||
@@ -183,6 +183,13 @@ test-group = 'e2e-reliability'
|
||||
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
# 4-node 4-drive distributed Actions suite: each case starts four rustfs
|
||||
# processes and up to sixteen data directories. Serialize across nextest's
|
||||
# process boundary so several 4x4 clusters never overlap.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^distributed::/)'
|
||||
test-group = 'e2e-cluster-nightly'
|
||||
|
||||
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
|
||||
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
||||
# one group.
|
||||
@@ -526,6 +533,27 @@ path = "junit.xml"
|
||||
filter = 'package(e2e_test)'
|
||||
test-group = 'e2e-cluster-nightly'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-distributed profile — 4-node 4-disk Actions suite
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage-sensitive PR / nightly / dispatch lane owned by
|
||||
# .github/workflows/e2e-distributed.yml.
|
||||
# Each case starts four rustfs processes (and for site replication, two
|
||||
# clusters). Upgrade cases also require RUSTFS_UPGRADE_SOURCE_BINARY.
|
||||
# Serialized via e2e-cluster-nightly with no retries.
|
||||
[profile.e2e-distributed]
|
||||
default-filter = 'package(e2e_test) & test(/^distributed::/)'
|
||||
fail-fast = false
|
||||
# Decommission / rebalance cases poll for up to 180s with little stdout.
|
||||
slow-timeout = { period = "120s", terminate-after = 6 }
|
||||
|
||||
[profile.e2e-distributed.junit]
|
||||
path = "junit.xml"
|
||||
|
||||
[[profile.e2e-distributed.overrides]]
|
||||
filter = 'package(e2e_test)'
|
||||
test-group = 'e2e-cluster-nightly'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -586,6 +614,10 @@ path = "junit.xml"
|
||||
# cluster-fault lane. heal_erasure_disk_rebuild is intentionally not
|
||||
# excluded here because backlog#2213 promotes core heal rebuild coverage to
|
||||
# this merge/main lane while retaining nightly coverage.
|
||||
# * distributed:: — 4-node 4-disk Actions suite (S3, lock, versioning,
|
||||
# replication, quota, observability, expand/decommission/rebalance, site
|
||||
# replication, chaos, upgrade history/IAM). Owns [profile.e2e-distributed] and
|
||||
# .github/workflows/e2e-distributed.yml.
|
||||
# * on_demand_migration::interop_test — the ODM-20 provider interoperability
|
||||
# cases, which are meaningless without a source: they run in the dedicated
|
||||
# [profile.e2e-odm-interop] lane below, where the workflow points them at a
|
||||
@@ -607,6 +639,7 @@ default-filter = """
|
||||
package(e2e_test)
|
||||
& !test(/^protocols::/)
|
||||
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& !test(/^distributed::/)
|
||||
& !test(/^replication_extension_test::/)
|
||||
& !test(/^replication_target_matrix_test::/)
|
||||
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
|
||||
|
||||
@@ -94,12 +94,17 @@ runs:
|
||||
shell: bash
|
||||
run: ./scripts/check_embedded_secrets.sh
|
||||
|
||||
- name: Run script contract tests
|
||||
shell: bash
|
||||
run: make script-tests
|
||||
|
||||
- name: Check test wiring
|
||||
shell: bash
|
||||
run: |
|
||||
python3 ./scripts/check_test_wiring.py --self-test
|
||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
python3 ./scripts/test_security_workflow.py
|
||||
python3 ./scripts/test_nightly_candidate.py
|
||||
python3 ./scripts/check_test_wiring.py
|
||||
|
||||
- name: Check no planning docs committed
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
|
||||
{
|
||||
"workflow": ".github/workflows/e2e-distributed.yml",
|
||||
"max_age_hours": 36,
|
||||
"never_ran_grace_until": "2026-09-18T00:00:00Z"
|
||||
},
|
||||
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
|
||||
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# 4-node 4-disk distributed e2e lane.
|
||||
#
|
||||
# Each selected test starts a real localhost cluster via
|
||||
# `RustFSTestClusterEnvironment` (4 processes; 4 drives per node unless the
|
||||
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
|
||||
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
|
||||
# nightly runs, and manual dispatches all execute the same fail-closed suite.
|
||||
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
|
||||
|
||||
name: e2e-distributed
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "Cargo.lock"
|
||||
- "Cargo.toml"
|
||||
- ".config/nextest.toml"
|
||||
- ".github/workflows/e2e-distributed.yml"
|
||||
- "crates/audit/**"
|
||||
- "crates/common/**"
|
||||
- "crates/config/**"
|
||||
- "crates/e2e_test/**"
|
||||
- "crates/ecstore/**"
|
||||
- "crates/filemeta/**"
|
||||
- "crates/heal/**"
|
||||
- "crates/iam/**"
|
||||
- "crates/lock/**"
|
||||
- "crates/madmin/**"
|
||||
- "crates/notify/**"
|
||||
- "crates/replication/**"
|
||||
- "crates/s3-client/**"
|
||||
- "crates/s3-ops/**"
|
||||
- "crates/s3-types/**"
|
||||
- "crates/scanner/**"
|
||||
- "crates/storage-api/**"
|
||||
- "crates/utils/**"
|
||||
- "rustfs/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
filter:
|
||||
description: "Optional nextest -E filter (default: the whole e2e-distributed profile)"
|
||||
required: false
|
||||
default: ""
|
||||
schedule:
|
||||
# 05:53 UTC nightly — clear of e2e-nightly (04:29) and ODM interop (05:23).
|
||||
- cron: "53 5 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||
|
||||
jobs:
|
||||
distributed:
|
||||
name: Distributed 4-node 4-disk e2e
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NO_PROXY: 127.0.0.1,localhost
|
||||
HTTP_PROXY: ""
|
||||
HTTPS_PROXY: ""
|
||||
# Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
|
||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-e2e-distributed
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Prepare isolated filesystems for pool movement
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
|
||||
mkdir -p "${mount_base}"
|
||||
roots=()
|
||||
for pool in 0 1 2 3; do
|
||||
image="${mount_base}/pool-${pool}.img"
|
||||
mountpoint="${mount_base}/pool-${pool}"
|
||||
truncate -s 1G "${image}"
|
||||
mkfs.ext4 -q -F "${image}"
|
||||
mkdir -p "${mountpoint}"
|
||||
sudo mount -o loop,nosuid,nodev "${image}" "${mountpoint}"
|
||||
sudo chmod 1777 "${mountpoint}"
|
||||
roots+=("${mountpoint}")
|
||||
done
|
||||
printf -v joined_roots '%s:' "${roots[@]}"
|
||||
echo "RUSTFS_E2E_POOL_ROOTS=${joined_roots%:}" >> "${GITHUB_ENV}"
|
||||
findmnt --noheadings --output TARGET,SOURCE,FSTYPE --target "${roots[0]}"
|
||||
findmnt --noheadings --output TARGET,SOURCE,FSTYPE --target "${roots[1]}"
|
||||
findmnt --noheadings --output TARGET,SOURCE,FSTYPE --target "${roots[2]}"
|
||||
findmnt --noheadings --output TARGET,SOURCE,FSTYPE --target "${roots[3]}"
|
||||
|
||||
- name: Download pinned previous release
|
||||
env:
|
||||
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$SOURCE_DIR"
|
||||
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
|
||||
curl --fail --location --retry 3 --output "$archive" \
|
||||
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
|
||||
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
|
||||
unzip -q "$archive" -d "$SOURCE_DIR"
|
||||
chmod +x "$SOURCE_DIR/rustfs"
|
||||
test -x "$SOURCE_DIR/rustfs"
|
||||
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build rustfs binary
|
||||
run: |
|
||||
cargo build -p rustfs --bins
|
||||
: > target/debug/rustfs.features
|
||||
|
||||
- name: Verify distributed e2e membership
|
||||
env:
|
||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
||||
run: |
|
||||
cargo nextest list --profile e2e-distributed -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-distributed "${NEXTEST_LISTING}"
|
||||
|
||||
- name: Run distributed 4-node e2e suite
|
||||
env:
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-distributed-logs
|
||||
FILTER: ${{ inputs.filter }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${FILTER}" ]; then
|
||||
cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||
else
|
||||
cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||
fi
|
||||
|
||||
- name: Upload distributed e2e diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: e2e-distributed-${{ github.run_number }}
|
||||
path: |
|
||||
target/nextest/e2e-distributed/junit.xml
|
||||
${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
||||
${{ runner.temp }}/rustfs-e2e-distributed-logs/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Unmount isolated pool filesystems
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
|
||||
for pool in 0 1 2 3; do
|
||||
mountpoint="${mount_base}/pool-${pool}"
|
||||
if mountpoint --quiet "${mountpoint}"; then
|
||||
sudo umount "${mountpoint}"
|
||||
fi
|
||||
done
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [distributed]
|
||||
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -19,7 +19,9 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/e2e-upgrade.yml"
|
||||
- "crates/e2e_test/src/common.rs"
|
||||
- "crates/e2e_test/src/fake_s3_target/**"
|
||||
- "crates/e2e_test/src/lib.rs"
|
||||
- "crates/e2e_test/src/replication_extension_test.rs"
|
||||
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||
- "crates/ecstore/**"
|
||||
- "crates/filemeta/**"
|
||||
@@ -44,9 +46,9 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.5
|
||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
|
||||
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
|
||||
|
||||
jobs:
|
||||
upgrade:
|
||||
@@ -55,14 +57,31 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Direct upgrade from rc.2
|
||||
# The two `_from_rc2_` tests keep their names: they assert
|
||||
# release-independent object contracts and pass unchanged against the
|
||||
# newer pinned source, so renaming them would only churn history and
|
||||
# the CI required-check names. UPGRADE_SOURCE_VERSION above is the
|
||||
# single source of truth for which release they actually run against.
|
||||
- name: Direct upgrade from the previous release
|
||||
cache_key: e2e-direct-upgrade
|
||||
test: direct_upgrade_from_rc2_preserves_object_contracts
|
||||
artifact: direct-upgrade
|
||||
- name: Mixed-version rolling upgrade from rc.2
|
||||
- name: Mixed-version rolling upgrade from the previous release
|
||||
cache_key: e2e-mixed-version-upgrade
|
||||
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
|
||||
artifact: mixed-version-upgrade
|
||||
- name: Bucket configuration survives the upgrade
|
||||
cache_key: e2e-bucket-config-upgrade
|
||||
test: direct_upgrade_from_previous_release_preserves_bucket_configuration
|
||||
artifact: bucket-config-upgrade
|
||||
- name: Rollback reads current bucket metadata
|
||||
cache_key: e2e-bucket-config-rollback
|
||||
test: rollback_to_previous_release_reads_current_bucket_metadata
|
||||
artifact: bucket-config-rollback
|
||||
- name: ODM configuration recovery after rc.5 rollback
|
||||
cache_key: e2e-odm-config-rollback
|
||||
test: rc5_rollback_requires_restoring_odm_configuration
|
||||
artifact: odm-config-rollback
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
|
||||
@@ -166,8 +166,9 @@ jobs:
|
||||
# e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... .
|
||||
# Skipped when the R2 secrets are not configured (artifact-only mode).
|
||||
- name: Upload DEB to Cloudflare R2
|
||||
if: env.R2_ACCESS_KEY_ID != ''
|
||||
id: publish
|
||||
env:
|
||||
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
@@ -182,28 +183,70 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y -qq awscli
|
||||
fi
|
||||
|
||||
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||
export AWS_DEFAULT_REGION="auto"
|
||||
|
||||
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
|
||||
SOURCE_SHA="$(git rev-parse HEAD)"
|
||||
if [[ "${SOURCE_SHA}" != "${GITHUB_SHA}" ]]; then
|
||||
echo "Checkout SHA does not match the nightly build run" >&2
|
||||
exit 1
|
||||
fi
|
||||
DEB_SHA256="$(sha256sum "${DEB_FILE}" | cut -d ' ' -f 1)"
|
||||
CANDIDATE_KEY="artifacts/rustfs/packages/nightly/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${DEB_SHA256}/rustfs.deb"
|
||||
CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}"
|
||||
|
||||
# Old AWS CLI models lack conditional PutObject support. Never fall
|
||||
# back to an overwriting upload for a candidate.
|
||||
AWS_CLI=aws
|
||||
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y -qq python3-venv
|
||||
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/nightly-awscli.XXXXXX")"
|
||||
trap 'rm -rf "${AWS_CLI_DIR}"' EXIT
|
||||
python3 -m venv "${AWS_CLI_DIR}"
|
||||
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
|
||||
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
|
||||
fi
|
||||
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
|
||||
"${AWS_CLI}" --version
|
||||
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${CANDIDATE_KEY}" \
|
||||
--body "${DEB_FILE}" --if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
|
||||
PUBLISHED_SHA256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${CANDIDATE_URL}" | sha256sum | cut -d ' ' -f 1)"
|
||||
if [[ "${PUBLISHED_SHA256}" != "${DEB_SHA256}" ]]; then
|
||||
echo "Published candidate checksum does not match the built package" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/"
|
||||
|
||||
echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}"
|
||||
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
"${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
|
||||
# Stable "latest" alias so tests can fetch the newest nightly
|
||||
# without knowing today's date.
|
||||
echo "📤 Uploading latest alias"
|
||||
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
||||
"${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
||||
--endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
|
||||
echo "✅ R2 upload complete"
|
||||
|
||||
CANDIDATE_FILE="${RUNNER_TEMP}/nightly-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
|
||||
jq -n --arg source_sha "${SOURCE_SHA}" \
|
||||
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--arg package_url "${CANDIDATE_URL}" --arg package_sha256 "${DEB_SHA256}" \
|
||||
'{schema: 1, source_sha: $source_sha, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' \
|
||||
> "${CANDIDATE_FILE}"
|
||||
echo "candidate_file=${CANDIDATE_FILE}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Upload nightly candidate manifest
|
||||
if: ${{ steps.publish.outputs.candidate_file != '' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: nightly-candidate-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.publish.outputs.candidate_file }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
||||
#
|
||||
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
# Functional chain driver: runs the ten functional suites in a fixed order
|
||||
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
|
||||
# replication, with performance on its own runner in parallel) and guarantees
|
||||
# the chain keeps moving even when individual suites fail.
|
||||
# replication -> 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,
|
||||
@@ -59,16 +59,3 @@ jobs:
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-upgrade' \
|
||||
-F 'client_payload[from_suite]=nightly-build'
|
||||
|
||||
- name: Dispatch performance suite (parallel, own runner)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch performance" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-performance' \
|
||||
-F 'client_payload[from_suite]=nightly-build'
|
||||
|
||||
@@ -54,14 +54,26 @@ env:
|
||||
jobs:
|
||||
heal-test:
|
||||
runs-on: smoke-testing
|
||||
# Requirement: a failing suite must not fail the workflow; failures
|
||||
# are filed to rustfs/backlog and the chain continues.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 480
|
||||
# Standalone manual run, or one link of the nightly functional chain
|
||||
# (storage -> heal -> pool). Pool expansion no longer re-runs heal.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-heal-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'RUSTFS_WARP_LOG_FILE=%s/warp.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -117,7 +129,7 @@ jobs:
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||
|
||||
- name: Preflight checks
|
||||
run: |
|
||||
@@ -127,7 +139,7 @@ jobs:
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||
|
||||
- name: Run heal test (write -> outage -> heal -> verify)
|
||||
id: test
|
||||
@@ -137,13 +149,10 @@ jobs:
|
||||
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
|
||||
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
|
||||
--log-file /tmp/rustfs-heal-test.log
|
||||
--log-file "${LOG_FILE}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-heal-test.log
|
||||
REPORT_FILE: /tmp/rustfs-heal-report.md
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
@@ -152,8 +161,9 @@ jobs:
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
STEPS_TABLE="/tmp/rustfs-heal-steps.md"
|
||||
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY'
|
||||
STEPS_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/steps.md"
|
||||
CASE_RESULT=success
|
||||
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY' || CASE_RESULT=failure
|
||||
import re
|
||||
import sys
|
||||
|
||||
@@ -165,6 +175,7 @@ jobs:
|
||||
|
||||
steps = {}
|
||||
order = []
|
||||
status_rank = {'SKIP': 0, 'PASS': 1, 'FAIL': 2}
|
||||
version = None
|
||||
version_node = None
|
||||
verdict = None
|
||||
@@ -178,14 +189,15 @@ jobs:
|
||||
n, desc, status = m.group(1), m.group(2), m.group(3)
|
||||
if n not in steps:
|
||||
order.append(n)
|
||||
steps[n] = (desc, status) # later lines win (fail after pass)
|
||||
if n not in steps or status_rank[status] > status_rank[steps[n][1]]:
|
||||
steps[n] = (desc, status)
|
||||
continue
|
||||
m = ver_re.match(line)
|
||||
if m:
|
||||
version, version_node = m.group(1), m.group(2)
|
||||
continue
|
||||
m = result_re.match(line)
|
||||
if m:
|
||||
if m and verdict != 'FAIL':
|
||||
verdict, verdict_detail = m.group(1), m.group(2)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
@@ -205,30 +217,43 @@ jobs:
|
||||
out.write(f'| {n} | {desc} | {status} |\n')
|
||||
if not order:
|
||||
out.write('| - | - | NOT RUN (no step result lines found) |\n')
|
||||
complete = set(steps) == {str(n) for n in range(1, 8)}
|
||||
sys.exit(0 if complete and verdict != 'FAIL' and all(status == 'PASS' for _, status in steps.values()) else 1)
|
||||
PY
|
||||
RESULT=failure
|
||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
{
|
||||
echo "# RustFS heal test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${STEPS_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${STEPS_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}"
|
||||
echo '```'
|
||||
else
|
||||
echo "The suite or evidence validation failed. See this run's artifact for partial step results and suite.log."
|
||||
fi
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-heal-report.md
|
||||
SUITE: heal
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -238,28 +263,32 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- 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 }}
|
||||
REPORT_FILE: '/tmp/rustfs-heal-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-heal-test.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -287,14 +316,16 @@ jobs:
|
||||
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 [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -310,14 +341,16 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload test logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-heal-test-${{ github.run_id }}
|
||||
name: rustfs-heal-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-heal-test*.log
|
||||
/tmp/rustfs-warp.*.log
|
||||
if-no-files-found: warn
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/warp.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/steps.md
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||
|
||||
@@ -49,10 +49,28 @@ env:
|
||||
jobs:
|
||||
kms-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 420
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-kms-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -109,9 +127,6 @@ jobs:
|
||||
|
||||
- name: Run KMS suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-kms.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-kms-test.sh
|
||||
@@ -141,10 +156,7 @@ jobs:
|
||||
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-kms.log
|
||||
REPORT_FILE: /tmp/rustfs-kms-report.md
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
@@ -156,79 +168,43 @@ jobs:
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-kms-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
except FileNotFoundError:
|
||||
rows = []
|
||||
|
||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||
for _, _, status in rows:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
PY
|
||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
CASE_RESULT=success
|
||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||
RESULT=failure
|
||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
{
|
||||
echo "# RustFS KMS test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${CASE_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}"
|
||||
echo '```'
|
||||
else
|
||||
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||
fi
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-kms-report.md
|
||||
SUITE: kms
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -238,28 +214,32 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- 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 }}
|
||||
REPORT_FILE: '/tmp/rustfs-kms-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-kms.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -287,14 +267,16 @@ jobs:
|
||||
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 [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -310,14 +292,15 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-kms-test-${{ github.run_id }}
|
||||
name: rustfs-kms-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-kms.log
|
||||
/tmp/rustfs-kms-report.md
|
||||
if-no-files-found: warn
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
|
||||
@@ -49,17 +49,16 @@ on:
|
||||
type: boolean
|
||||
default: true
|
||||
repository_dispatch:
|
||||
# Chain entry: dispatched by rustfs-functional-chain.yml (runs on its own
|
||||
# pf-testing runner, in parallel with the shared-VM chain).
|
||||
# Chain handoff: dispatched when the replication suite finishes.
|
||||
types: [rustfs-chain-performance]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Dedicated pf-testing runner/environment: own concurrency group so perf runs
|
||||
# never block (or are blocked by) the pool-expansion / heal tests.
|
||||
# The default performance nodes overlap the other suites' remote VMs, even
|
||||
# though the runner differs. Hold the shared lock through cleanup as well.
|
||||
concurrency:
|
||||
group: rustfs-performance-test
|
||||
group: rustfs-shared-functional-tests
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
@@ -76,22 +75,33 @@ env:
|
||||
# Package used by the nightly run (workflow_dispatch inputs are empty for
|
||||
# workflow_run events), i.e. the latest nightly deb published by nightly-gnu.yml.
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
# Fixed benchmark result directory so later steps can read summary.md
|
||||
RUSTFS_RESULT_DIR: /tmp/rustfs-perf-results
|
||||
# Cross-repo token for uploading reports to rustfs/dashboard (set in repo settings)
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
jobs:
|
||||
performance-test:
|
||||
runs-on: pf-testing
|
||||
# Requirement: a failing benchmark must not fail the workflow;
|
||||
# failures are filed to rustfs/backlog.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 900
|
||||
# Run on manual dispatch, or when the nightly build completed successfully.
|
||||
# Skipped when nightly failed.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-performance-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'RUSTFS_RESULT_DIR=%s/results\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'VERSION_FILE=%s/version.txt\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -123,7 +133,7 @@ jobs:
|
||||
if: ${{ inputs.cleanup_before != 'false' }}
|
||||
run: |
|
||||
chmod +x auto-testing/rustfs_performance_test.sh
|
||||
./auto-testing/rustfs_performance_test.sh --step 1 -y
|
||||
./auto-testing/rustfs_performance_test.sh --step 1 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||
|
||||
- name: Install RustFS package & start cluster (4x4)
|
||||
run: |
|
||||
@@ -133,7 +143,7 @@ jobs:
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}"
|
||||
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||
|
||||
- name: Preflight checks
|
||||
run: |
|
||||
@@ -143,7 +153,7 @@ jobs:
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}"
|
||||
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||
|
||||
- name: Run benchmark (GET/PUT/MIXED)
|
||||
id: benchmark
|
||||
@@ -156,17 +166,15 @@ jobs:
|
||||
--step 5 -y \
|
||||
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
|
||||
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
|
||||
--log-file /tmp/rustfs-perf-test.log
|
||||
--log-file "${LOG_FILE}"
|
||||
|
||||
- name: Analyze results
|
||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||
run: |
|
||||
./auto-testing/rustfs_performance_test.sh --step 6 -y
|
||||
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||
|
||||
- name: Collect RustFS version info
|
||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||
env:
|
||||
VERSION_FILE: /tmp/rustfs-version.txt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES}"
|
||||
@@ -186,7 +194,6 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
RESULT_DIR: ${{ env.RUSTFS_RESULT_DIR }}
|
||||
VERSION_FILE: /tmp/rustfs-version.txt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -194,7 +201,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
SUMMARY="${RESULT_DIR}/summary.md"
|
||||
[ -f "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
|
||||
[ -s "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="reports/${DATE}.md"
|
||||
{
|
||||
@@ -202,6 +209,8 @@ jobs:
|
||||
echo ""
|
||||
echo "- **Date**: ${DATE}"
|
||||
echo "- **Run**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- **Attempt**: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- **Workflow Commit**: ${GITHUB_SHA}"
|
||||
echo "- **Trigger**: ${{ github.event_name }}"
|
||||
echo "- **Package**: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||
echo ""
|
||||
@@ -211,8 +220,8 @@ jobs:
|
||||
echo '```text'
|
||||
cat "${VERSION_FILE}"
|
||||
echo '```'
|
||||
} > /tmp/rustfs-perf-report.md
|
||||
CONTENT="$(python3 -c 'import base64; print(base64.b64encode(open("/tmp/rustfs-perf-report.md","rb").read()).decode())')"
|
||||
} > "${REPORT_FILE}"
|
||||
CONTENT="$(python3 -c 'import base64,sys; print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report: ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
@@ -231,11 +240,10 @@ jobs:
|
||||
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 }}
|
||||
REPORT_FILE: '/tmp/rustfs-perf-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-perf-test.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -263,14 +271,16 @@ jobs:
|
||||
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 [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -286,20 +296,26 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload test logs & results
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-perf-test-${{ github.run_id }}
|
||||
name: rustfs-perf-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-perf-test*.log
|
||||
/tmp/rustfs-perf-results/**
|
||||
/tmp/rustfs-version.txt
|
||||
if-no-files-found: warn
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/version.txt
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/master.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/summary.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/summary.tsv
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/get_*.txt
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/put_*.txt
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/mixed_*.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Reset test environment (after)
|
||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||
run: |
|
||||
./auto-testing/rustfs_performance_test.sh --step 7 -y
|
||||
./auto-testing/rustfs_performance_test.sh --step 7 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
|
||||
@@ -76,9 +76,6 @@ jobs:
|
||||
pool-expansion-test:
|
||||
name: Pool expansion / decommission test
|
||||
runs-on: smoke-testing
|
||||
# Requirement: a failing suite must not fail the workflow; failures
|
||||
# are filed to rustfs/backlog and the chain continues.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
env:
|
||||
@@ -542,17 +539,22 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.pool_test.outcome == 'failure' || steps.pool_test.outcome == 'cancelled') }}
|
||||
|
||||
@@ -34,8 +34,7 @@ on:
|
||||
- site
|
||||
default: all
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the security suite finishes. This is the
|
||||
# last link of the functional chain.
|
||||
# Chain handoff: dispatched when the security suite finishes.
|
||||
types: [rustfs-chain-replication]
|
||||
|
||||
permissions:
|
||||
@@ -62,12 +61,28 @@ env:
|
||||
jobs:
|
||||
replication-test:
|
||||
runs-on: smoke-testing
|
||||
# A failed replication run must not break the chain or the workflow: the
|
||||
# failure is reported to rustfs/backlog instead (see the issue step).
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-replication-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -116,9 +131,6 @@ jobs:
|
||||
|
||||
- name: Run replication suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-replication.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-replication-test.sh
|
||||
@@ -141,10 +153,7 @@ jobs:
|
||||
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-replication.log
|
||||
REPORT_FILE: /tmp/rustfs-replication-report.md
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
@@ -166,80 +175,44 @@ jobs:
|
||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||
fi
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-replication-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
except FileNotFoundError:
|
||||
rows = []
|
||||
|
||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||
for _, _, status in rows:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
PY
|
||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
CASE_RESULT=success
|
||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||
RESULT=failure
|
||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
{
|
||||
echo "# RustFS replication test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${CASE_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}"
|
||||
echo '```'
|
||||
else
|
||||
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||
fi
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-replication-report.md
|
||||
SUITE: replication
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -249,28 +222,32 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- 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 }}
|
||||
REPORT_FILE: '/tmp/rustfs-replication-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-replication.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -298,14 +275,16 @@ jobs:
|
||||
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 [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -321,14 +300,15 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-replication-${{ github.run_id }}
|
||||
name: rustfs-replication-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-replication.log
|
||||
/tmp/rustfs-replication-report.md
|
||||
if-no-files-found: warn
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
@@ -349,13 +329,50 @@ jobs:
|
||||
'
|
||||
done
|
||||
|
||||
- name: Chain complete
|
||||
# Replication is the last link of the functional chain: nothing to
|
||||
# dispatch after it. This step just records that the chain finished.
|
||||
- name: "Continue functional chain (next: Performance)"
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
echo "Functional chain complete: replication (final suite) finished."
|
||||
echo "from_suite=security trigger=${{ github.event_name }} outcome=${{ steps.test.outcome }}"
|
||||
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]=replication'; then
|
||||
echo "dispatched next suite Performance (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Performance after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after replication (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
trap 'rm -f "${BODY_FILE}"' EXIT
|
||||
{
|
||||
echo "The functional chain could not hand off from **replication** to **Performance** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-performance'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-performance'"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
|
||||
@@ -37,10 +37,28 @@ env:
|
||||
jobs:
|
||||
s3-compat-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-s3-compat-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -88,9 +106,6 @@ jobs:
|
||||
|
||||
- name: Run S3 compatibility suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-s3-compat.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-s3-compat-test.sh
|
||||
@@ -107,10 +122,7 @@ jobs:
|
||||
./auto-testing/rustfs-s3-compat-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-s3-compat.log
|
||||
REPORT_FILE: /tmp/rustfs-s3-compat-report.md
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
@@ -132,83 +144,44 @@ jobs:
|
||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||
fi
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-s3-compat-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
current = None
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
current = case_id
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
current = None
|
||||
except FileNotFoundError:
|
||||
rows = []
|
||||
|
||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||
for _, _, status in rows:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
PY
|
||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
CASE_RESULT=success
|
||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||
RESULT=failure
|
||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
{
|
||||
echo "# RustFS S3 compatibility test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${CASE_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}"
|
||||
echo '```'
|
||||
else
|
||||
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||
fi
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-s3-compat-report.md
|
||||
SUITE: s3
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -218,28 +191,32 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- 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 }}
|
||||
REPORT_FILE: '/tmp/rustfs-s3-compat-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-s3-compat.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -267,14 +244,16 @@ jobs:
|
||||
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 [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -290,14 +269,15 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-s3-compat-${{ github.run_id }}
|
||||
name: rustfs-s3-compat-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-s3-compat.log
|
||||
/tmp/rustfs-s3-compat-report.md
|
||||
if-no-files-found: warn
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
|
||||
@@ -77,10 +77,14 @@ jobs:
|
||||
timeout-minutes: 360
|
||||
if: ${{ 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
|
||||
# exactly how run 33934141181 lost rustfs-security-test.sh).
|
||||
- name: Checkout repository (for the OIDC live gate script)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: rustfs-repo
|
||||
|
||||
- name: Initialize security evidence
|
||||
id: evidence
|
||||
@@ -88,7 +92,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
SECURITY_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-security-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${SECURITY_ARTIFACTS_DIR}"
|
||||
mkdir -- "${SECURITY_ARTIFACTS_DIR}" "${SECURITY_ARTIFACTS_DIR}-scratch"
|
||||
printf 'SECURITY_ARTIFACTS_DIR=%s\n' "${SECURITY_ARTIFACTS_DIR}" >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
@@ -144,8 +148,8 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
||||
TMPDIR: ${{ env.SECURITY_ARTIFACTS_DIR }}
|
||||
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/scripts/test/oidc_keycloak_live.sh
|
||||
TMPDIR: ${{ env.SECURITY_ARTIFACTS_DIR }}-scratch
|
||||
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/rustfs-repo/scripts/test/oidc_keycloak_live.sh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-security-test.sh
|
||||
@@ -168,7 +172,7 @@ jobs:
|
||||
else
|
||||
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||
fi
|
||||
GITHUB_STEP_SUMMARY=/dev/null ./auto-testing/rustfs-security-test.sh "${ARGS[@]}"
|
||||
GITHUB_STEP_SUMMARY=/dev/null ./auto-testing/rustfs-security-test.sh "${ARGS[@]}" 2>&1 | tee "${SECURITY_ARTIFACTS_DIR}/suite.log"
|
||||
|
||||
- name: Generate report
|
||||
id: report
|
||||
@@ -219,17 +223,22 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
@@ -296,7 +305,10 @@ jobs:
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-security-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ env.SECURITY_ARTIFACTS_DIR }}/
|
||||
path: |
|
||||
${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.SECURITY_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
|
||||
@@ -46,10 +46,28 @@ env:
|
||||
jobs:
|
||||
storage-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-storage-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -97,9 +115,6 @@ jobs:
|
||||
|
||||
- name: Run storage engine suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-storage.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-storage-test.sh
|
||||
@@ -122,10 +137,7 @@ jobs:
|
||||
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-storage.log
|
||||
REPORT_FILE: /tmp/rustfs-storage-report.md
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
@@ -147,83 +159,44 @@ jobs:
|
||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||
fi
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-storage-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
current = None
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
current = case_id
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
current = None
|
||||
except FileNotFoundError:
|
||||
rows = []
|
||||
|
||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||
for _, _, status in rows:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
PY
|
||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
CASE_RESULT=success
|
||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||
RESULT=failure
|
||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
{
|
||||
echo "# RustFS storage engine test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${CASE_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}"
|
||||
echo '```'
|
||||
else
|
||||
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||
fi
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-storage-report.md
|
||||
SUITE: storage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -233,28 +206,32 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- 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 }}
|
||||
REPORT_FILE: '/tmp/rustfs-storage-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-storage.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -282,14 +259,16 @@ jobs:
|
||||
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 [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -305,14 +284,15 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-storage-${{ github.run_id }}
|
||||
name: rustfs-storage-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-storage.log
|
||||
/tmp/rustfs-storage-report.md
|
||||
if-no-files-found: warn
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
|
||||
@@ -61,9 +61,6 @@ env:
|
||||
jobs:
|
||||
tier-test:
|
||||
runs-on: smoke-testing
|
||||
# Requirement: a failing suite must not fail the workflow; failures
|
||||
# are filed to rustfs/backlog and the chain continues.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 420
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
@@ -380,17 +377,22 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: Verify required tier evidence
|
||||
id: evidence_verify
|
||||
|
||||
@@ -79,10 +79,28 @@ env:
|
||||
jobs:
|
||||
upgrade-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 420
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-upgrade-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -142,9 +160,7 @@ jobs:
|
||||
|
||||
- name: Run upgrade compatibility suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-upgrade.log
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -202,10 +218,7 @@ jobs:
|
||||
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-upgrade.log
|
||||
REPORT_FILE: /tmp/rustfs-upgrade-report.md
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FROM_URL='${{ inputs.from_url }}'
|
||||
@@ -226,103 +239,47 @@ jobs:
|
||||
else
|
||||
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-upgrade-cases.md"
|
||||
MATRIX_TABLE="/tmp/rustfs-upgrade-matrix.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file, matrix_file = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
|
||||
topo_re = re.compile(
|
||||
r'^\[UPG-TOPO\]\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+PASS=(\d+)\s+FAIL=(\d+)\s*$')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
topo_rows = []
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = topo_re.match(line)
|
||||
if m:
|
||||
topo_rows.append(m.groups())
|
||||
continue
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
except FileNotFoundError:
|
||||
rows = []
|
||||
|
||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||
for _, _, status in rows:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
|
||||
# Upgrade matrix: one row per topology/backend with the versions
|
||||
# captured on the nodes (rustfs --version) and the aggregated
|
||||
# result. The dashboard renders this table directly.
|
||||
with open(matrix_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Upgrade Matrix\n\n')
|
||||
out.write('| Topology | KMS Backend | From Version | To Version | Result |\n')
|
||||
out.write('| --- | --- | --- | --- | --- |\n')
|
||||
for topo, backend, old_v, new_v, npass, nfail in topo_rows:
|
||||
result = 'PASS' if nfail == '0' else 'FAIL'
|
||||
out.write(f'| {topo} | {backend} | {old_v} | {new_v} | {result} (PASS={npass} FAIL={nfail}) |\n')
|
||||
if not topo_rows:
|
||||
out.write('| - | - | - | - | NOT RUN (suite failed before upgrade) |\n')
|
||||
PY
|
||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
MATRIX_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/matrix.md"
|
||||
CASE_RESULT=success
|
||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" || CASE_RESULT=failure
|
||||
RESULT=failure
|
||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
{
|
||||
echo "# RustFS upgrade compatibility report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- From: ${FROM_SOURCE}"
|
||||
echo "- To: ${TO_SOURCE}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${MATRIX_TABLE}" || true
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${MATRIX_TABLE}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}"
|
||||
echo '```'
|
||||
else
|
||||
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||
fi
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-upgrade-report.md
|
||||
SUITE: upgrade
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -332,28 +289,32 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- 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 }}
|
||||
REPORT_FILE: '/tmp/rustfs-upgrade-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-upgrade.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -381,14 +342,16 @@ jobs:
|
||||
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 [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -404,14 +367,16 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-upgrade-test-${{ github.run_id }}
|
||||
name: rustfs-upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-upgrade-report.md
|
||||
/tmp/rustfs-upgrade.*/*
|
||||
if-no-files-found: ignore
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/matrix.md
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
|
||||
@@ -22,6 +22,7 @@ on:
|
||||
- "Continuous Integration"
|
||||
- "coverage"
|
||||
- "e2e-nightly"
|
||||
- "e2e-distributed"
|
||||
- "e2e-s3tests"
|
||||
- "Fuzz"
|
||||
- "mint"
|
||||
|
||||
+4
-1
@@ -18,7 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Read paths: an object at or below `policy.inline_max_bytes` (16 MiB by default) is teed to the client and to the local store in a single source read; a larger object or a Range read streams through and a background pull stores the whole object. A HEAD miss is proxied to the source and stores nothing (`policy.head = local_only` disables it). Every source-backed response carries `x-rustfs-on-demand-migration: source`
|
||||
- Protections: a per-source circuit breaker, a per-key negative cache, singleflight per key, a concurrency limit and a bounded pull queue shared by the inline and background paths, an optional bandwidth limit, an anti-loop request marker, and the shared outbound-endpoint (SSRF) policy
|
||||
- Metrics under `rustfs_on_demand_migration_*` (`requests_total`, `pulled_bytes_total`, `pulled_objects_total`, `pull_failures_total`, `inflight_pulls`, `queue_depth`, `source_latency_seconds_*`, `breaker_state`), mirrored per node by the admin status route
|
||||
- Limitations: listings show only local objects (the source is not merged into `ListObjectsV2`); PUT and DELETE never reach the source; a source object updated after it was pulled is not re-fetched; SSE-C source objects are unsupported and answer 424; `Last-Modified` on a pulled object is the local write time, with the source timestamp kept in metadata
|
||||
- Listings: `ListObjects` v1 remains local with ordinary key markers. `ListObjectsV2` can merge source objects when `policy.list_through = true`; this is off by default
|
||||
- Upgrade and rollback: finish upgrading every node before enabling ODM. An rc.5 node that writes bucket configuration drops the ODM fields from metadata; neither a later restart nor moving the service out of ECStore recovers them. Before rollback, disable ODM and securely retain the original full configuration and credentials. After every node returns to a compatible version, restore and validate that configuration. Redacted exports cannot replace the credential backup; source-only objects are unavailable through RustFS while ODM is disabled. See the upgrade and rollback section of `docs/operations/on-demand-migration.md`
|
||||
- Optional Google dependencies: default and `full` server builds retain native GCS support. `cargo build -p rustfs --no-default-features --features ftps,webdav` excludes Google SDKs while preserving configuration decoding and redaction; native GCS ODM and tier operations require the `gcs` feature. Do not use that build with existing GCS-tiered data
|
||||
- Limitations: PUT and DELETE never reach the source; a source object updated after it was pulled is not re-fetched; SSE-C source objects are unsupported and answer 424; `Last-Modified` on a pulled object is the local write time, with the source timestamp kept in metadata
|
||||
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
|
||||
- Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes
|
||||
- Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window
|
||||
|
||||
Generated
+36
-21
@@ -1679,7 +1679,7 @@ version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1698,7 +1698,7 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2110,7 +2110,7 @@ version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"crypto-common 0.1.6",
|
||||
"inout 0.1.4",
|
||||
]
|
||||
|
||||
@@ -2580,7 +2580,7 @@ version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
@@ -2605,11 +2605,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -3901,7 +3901,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.7",
|
||||
"crypto-common 0.1.6",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -4166,7 +4166,7 @@ dependencies = [
|
||||
"crypto-bigint 0.5.5",
|
||||
"digest 0.10.7",
|
||||
"ff 0.13.1",
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
"group 0.13.0",
|
||||
"hkdf 0.12.4",
|
||||
"pem-rfc7468 0.7.0",
|
||||
@@ -4499,7 +4499,7 @@ checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
|
||||
dependencies = [
|
||||
"io-lifetimes 2.0.4",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4622,9 +4622,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
version = "0.14.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
@@ -4637,7 +4637,7 @@ version = "1.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
"rustversion",
|
||||
"typenum",
|
||||
]
|
||||
@@ -5319,9 +5319,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hotpath-macros"
|
||||
version = "0.25.0"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "929b2285d2cd21b2733a7fb6ebc843bb4f83dbd1db0122f5f9ebb9567b1e2613"
|
||||
checksum = "846bde0d9600d98434e1aac376977d7718bfe3d2f5312a041b7c59a6a466c51a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5660,7 +5660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding 0.3.3",
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5693,7 +5693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
|
||||
dependencies = [
|
||||
"io-lifetimes 3.0.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7115,7 +7115,7 @@ version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"getrandom 0.2.17",
|
||||
"http 1.5.0",
|
||||
@@ -9493,6 +9493,8 @@ dependencies = [
|
||||
"atomic_enum",
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"axum",
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -9501,11 +9503,13 @@ dependencies = [
|
||||
"clap",
|
||||
"const-str",
|
||||
"datafusion",
|
||||
"faster-hex",
|
||||
"flatbuffers",
|
||||
"flate2",
|
||||
"futures",
|
||||
"futures-lite",
|
||||
"futures-util",
|
||||
"google-cloud-auth",
|
||||
"hashbrown 0.17.1",
|
||||
"hex-simd",
|
||||
"hmac 0.13.0",
|
||||
@@ -9524,6 +9528,7 @@ dependencies = [
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
"mime_guess",
|
||||
"moka",
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"p256 0.14.0",
|
||||
@@ -9616,6 +9621,7 @@ dependencies = [
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"x509-parser",
|
||||
"xxhash-rust",
|
||||
"zeroize",
|
||||
"zip",
|
||||
"zstd 0.14.0",
|
||||
@@ -11399,7 +11405,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct 0.2.0",
|
||||
"der 0.7.10",
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
"pkcs8 0.10.2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
@@ -12399,7 +12405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -13651,6 +13657,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
@@ -13823,7 +13838,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+2
-1
@@ -371,7 +371,8 @@ dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.5.3" }
|
||||
hotpath = { version = "0.25.0", default-features = false }
|
||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||
hotpath = { version = "=0.25.0", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.48" }
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
||||
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
|
||||
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||
| **distributed 4×4** | [`src/distributed/`](src/distributed) | Storage-sensitive PR and nightly `e2e-distributed` lane: S3, object lock/WORM, versioning, bucket/site replication, quota, expand/decommission/rebalance, concurrency, chaos, 4-node upgrade of historical data and IAM AK/SK. Map: [`docs/testing/distributed-e2e.md`](../../docs/testing/distributed-e2e.md) |
|
||||
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
||||
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||
|
||||
@@ -171,6 +172,7 @@ the same profile for membership and execution with one nightly worker.
|
||||
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
||||
| Direct and mixed-version rolling upgrades from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** |
|
||||
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
||||
| Distributed 4-node 4-disk (`e2e-distributed` profile) | `.github/workflows/e2e-distributed.yml` | **Active** (storage-sensitive PR / nightly / dispatch) |
|
||||
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
||||
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
||||
| Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) |
|
||||
@@ -191,6 +193,9 @@ cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
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
|
||||
|
||||
@@ -1700,6 +1700,69 @@ impl RustFSTestClusterEnvironment {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append a new single-node erasure pool to a stopped multi-pool cluster.
|
||||
///
|
||||
/// Used to simulate pool expansion on localhost: every pool already owns
|
||||
/// exactly one node with `drives_per_node >= 2` (the only multi-pool layout
|
||||
/// the single-host `RUSTFS_VOLUMES` syntax can express). The new node is
|
||||
/// allocated a fresh port and empty drive directories; callers must
|
||||
/// [`Self::start`] afterwards so every process picks up the extended
|
||||
/// volumes argument. Existing data directories are left untouched.
|
||||
pub async fn append_single_node_pool(&mut self) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if self.nodes.iter().any(|node| node.process.is_some()) {
|
||||
return Err("stop the cluster before appending a pool".into());
|
||||
}
|
||||
if self.topology.drives_per_node < 2 {
|
||||
return Err(
|
||||
"append_single_node_pool requires drives_per_node >= 2 (the server parser rejects a single-drive ellipses pool)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut pools = self.topology.normalized_pools();
|
||||
for (pool_idx, nodes) in pools.iter().enumerate() {
|
||||
if nodes.len() != 1 {
|
||||
return Err(format!(
|
||||
"pool {pool_idx} spans {} nodes; append_single_node_pool requires one node per pool",
|
||||
nodes.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
let new_idx = self.nodes.len();
|
||||
let port = RustFSTestEnvironment::find_available_port().await?;
|
||||
let address = format!("127.0.0.1:{port}");
|
||||
let data_dirs: Vec<String> = (0..self.topology.drives_per_node)
|
||||
.map(|drive| format!("{}/node{}/drive{}", self.temp_dir, new_idx, drive))
|
||||
.collect();
|
||||
for dir in &data_dirs {
|
||||
fs::create_dir_all(dir).await?;
|
||||
}
|
||||
|
||||
self.nodes.push(ClusterNode {
|
||||
url: format!("http://{address}"),
|
||||
address,
|
||||
data_dir: data_dirs[0].clone(),
|
||||
data_dirs,
|
||||
pool_idx: pools.len(),
|
||||
process: None,
|
||||
});
|
||||
pools.push(vec![new_idx]);
|
||||
self.topology.node_count = self.nodes.len();
|
||||
self.topology.pools = pools;
|
||||
self.node_extra_env.push(Vec::new());
|
||||
self.node_capture_log_paths.push(None);
|
||||
self.volume_proxy_addresses.push(None);
|
||||
|
||||
if !self.extra_env.iter().any(|(key, _)| key == "RUSTFS_UNSAFE_BYPASS_DISK_CHECK") {
|
||||
self.extra_env
|
||||
.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
|
||||
}
|
||||
|
||||
Ok(new_idx)
|
||||
}
|
||||
|
||||
/// Gracefully stop one cluster node and wait for its process to exit.
|
||||
///
|
||||
/// This is intentionally separate from [`Self::stop_node`]: the latter is
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
|
||||
wait_for_ready, wait_until,
|
||||
};
|
||||
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
|
||||
use crate::common::{build_test_s3_config, init_logging};
|
||||
use crate::fault_proxy::FaultMode;
|
||||
use aws_sdk_s3::Client;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Barrier, mpsc};
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[tokio::test]
|
||||
async fn kill_and_restart_node_preserves_objects() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("killnode");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let body = vec![0x11u8; 128 * 1024];
|
||||
put_object(&dist.client(0)?, &bucket, "keep.bin", body.clone()).await?;
|
||||
|
||||
dist.cluster.stop_node(3)?;
|
||||
retrying_get_equals(&dist.client(0)?, &bucket, "keep.bin", &body, Duration::from_secs(20)).await?;
|
||||
|
||||
dist.cluster.start_node(3).await?;
|
||||
wait_for_ready(&dist.cluster).await?;
|
||||
assert_object_bytes(&dist.client(3)?, &bucket, "keep.bin", &body).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_cluster_restart_preserves_objects() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("pwr");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let body = vec![0x44u8; 64 * 1024];
|
||||
put_object(&dist.client(1)?, &bucket, "survive.bin", body.clone()).await?;
|
||||
|
||||
dist.cluster.stop();
|
||||
dist.cluster.start().await?;
|
||||
wait_for_ready(&dist.cluster).await?;
|
||||
for node_idx in 0..dist.cluster.nodes.len() {
|
||||
assert_object_bytes(&dist.client(node_idx)?, &bucket, "survive.bin", &body).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_drive_replacement_is_physically_healed_without_data_change() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start_with_env(DistLayout::FourByFour, &[("RUSTFS_HEAL_ENABLED", "true")]).await?;
|
||||
let bucket = unique_bucket("baddrive");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let body = payload_for("fresh-drive/durable.bin", 8 * 1024 * 1024);
|
||||
put_object(&dist.client(1)?, &bucket, "durable.bin", body.clone()).await?;
|
||||
|
||||
let replaced_drive = PathBuf::from(&dist.cluster.nodes[0].data_dirs[0]);
|
||||
let baseline = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
|
||||
assert!(
|
||||
baseline.is_complete(),
|
||||
"replacement target did not hold a complete baseline shard: {baseline:?}"
|
||||
);
|
||||
assert!(
|
||||
!baseline.expected_part_numbers.is_empty(),
|
||||
"replacement witness must use physical part shards: {baseline:?}"
|
||||
);
|
||||
|
||||
dist.cluster.stop_node(0)?;
|
||||
let format_path = replaced_drive.join(".rustfs.sys/format.json");
|
||||
let format = std::fs::read(&format_path)?;
|
||||
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
|
||||
std::fs::rename(&replaced_drive, &retired_drive)?;
|
||||
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path omitted parent")?)?;
|
||||
std::fs::write(&format_path, format)?;
|
||||
let empty = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
|
||||
assert!(!empty.has_xl_meta, "fresh replacement unexpectedly retained object metadata: {empty:?}");
|
||||
|
||||
dist.cluster.start_node(0).await?;
|
||||
wait_for_ready(&dist.cluster).await?;
|
||||
let heal_body =
|
||||
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[1].url);
|
||||
signed_admin_post(&heal_url, Some(heal_body), &dist.cluster.access_key, &dist.cluster.secret_key).await?;
|
||||
wait_until(
|
||||
Duration::from_secs(90),
|
||||
|| async {
|
||||
let healed = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
|
||||
Ok(healed.matches_manifest(&baseline))
|
||||
},
|
||||
"fresh replacement contains the original complete shard manifest",
|
||||
)
|
||||
.await?;
|
||||
|
||||
for node_idx in 0..dist.cluster.nodes.len() {
|
||||
assert_object_bytes(&dist.client(node_idx)?, &bucket, "durable.bin", &body).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_gets_survive_peer_node_kill() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("getkill");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let body = payload_for("inflight/steady.bin", 8 * 1024 * 1024);
|
||||
put_object(&dist.client(0)?, &bucket, "steady.bin", body.clone()).await?;
|
||||
|
||||
let live: Vec<_> = (0..3).map(|idx| dist.client(idx)).collect::<Result<Vec<_>, _>>()?;
|
||||
let worker_count = 12;
|
||||
let release = Arc::new(Barrier::new(worker_count + 1));
|
||||
let (started_tx, mut started_rx) = mpsc::unbounded_channel();
|
||||
let mut handles = Vec::new();
|
||||
for idx in 0..worker_count {
|
||||
let client = live[idx % live.len()].clone();
|
||||
let bucket = bucket.clone();
|
||||
let body = body.clone();
|
||||
let release = release.clone();
|
||||
let started_tx = started_tx.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let response = client.get_object().bucket(&bucket).key("steady.bin").send().await?;
|
||||
if response.content_length() != Some(body.len() as i64) {
|
||||
return Err::<(), Box<dyn std::error::Error + Send + Sync>>(
|
||||
format!("worker {idx} received a wrong content length").into(),
|
||||
);
|
||||
}
|
||||
started_tx.send(idx)?;
|
||||
release.wait().await;
|
||||
let actual = response.body.collect().await?.into_bytes();
|
||||
if actual.as_ref() != body.as_slice() {
|
||||
return Err(format!("worker {idx} received corrupted bytes after peer kill").into());
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
drop(started_tx);
|
||||
for _ in 0..worker_count {
|
||||
timeout(Duration::from_secs(30), started_rx.recv())
|
||||
.await?
|
||||
.ok_or("a streaming GET exited before reaching the kill barrier")?;
|
||||
}
|
||||
|
||||
dist.cluster.stop_node(3)?;
|
||||
release.wait().await;
|
||||
for handle in handles {
|
||||
handle.await??;
|
||||
}
|
||||
|
||||
dist.cluster.start_node(3).await?;
|
||||
wait_for_ready(&dist.cluster).await?;
|
||||
assert_object_bytes(&dist.client(3)?, &bucket, "steady.bin", &body).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blackholed_node_client_network_preserves_cluster_availability_and_recovers() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let proxy = crate::fault_proxy::FaultProxy::start(dist.cluster.nodes[3].address.parse()?).await?;
|
||||
let proxied_url = format!("http://{}", proxy.local_addr());
|
||||
let proxied_client = Client::from_conf(build_test_s3_config(
|
||||
&proxied_url,
|
||||
&dist.cluster.access_key,
|
||||
&dist.cluster.secret_key,
|
||||
None,
|
||||
"distributed-network-chaos",
|
||||
));
|
||||
|
||||
let result: TestResult = async {
|
||||
let bucket = unique_bucket("netfault");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let baseline = payload_for("network/baseline.bin", 1024 * 1024);
|
||||
put_object(&dist.client(0)?, &bucket, "baseline.bin", baseline.clone()).await?;
|
||||
assert_object_bytes(&proxied_client, &bucket, "baseline.bin", &baseline).await?;
|
||||
|
||||
proxy.set_mode(FaultMode::Blackhole);
|
||||
assert_eq!(proxy.mode(), FaultMode::Blackhole);
|
||||
if let Ok(Ok(_)) = timeout(
|
||||
Duration::from_secs(5),
|
||||
proxied_client.get_object().bucket(&bucket).key("baseline.bin").send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err("blackholed node endpoint unexpectedly completed a GET".into());
|
||||
}
|
||||
|
||||
let during = payload_for("network/during.bin", 1024 * 1024);
|
||||
timeout(Duration::from_secs(30), async {
|
||||
put_object(&dist.client(1)?, &bucket, "during-blackhole.bin", during.clone()).await?;
|
||||
assert_object_bytes(&dist.client(2)?, &bucket, "baseline.bin", &baseline).await?;
|
||||
assert_object_bytes(&dist.client(0)?, &bucket, "during-blackhole.bin", &during).await?;
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
|
||||
})
|
||||
.await??;
|
||||
|
||||
proxy.set_mode(FaultMode::Pass);
|
||||
retrying_get_equals(&proxied_client, &bucket, "during-blackhole.bin", &during, Duration::from_secs(30)).await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
proxy.set_mode(FaultMode::Pass);
|
||||
proxy.shutdown().await;
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, unique_bucket};
|
||||
use crate::common::init_logging;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_high_concurrency_mixed_workload_is_consistent_on_every_node() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("conc");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let clients = Arc::new(dist.clients()?);
|
||||
let worker_count = 24;
|
||||
let rounds = 4;
|
||||
let barrier = Arc::new(Barrier::new(worker_count));
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for idx in 0..worker_count {
|
||||
let clients = clients.clone();
|
||||
let barrier = barrier.clone();
|
||||
let bucket = bucket.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
let writer = &clients[idx % clients.len()];
|
||||
let reader = &clients[(idx + 1) % clients.len()];
|
||||
let copier = &clients[(idx + 2) % clients.len()];
|
||||
let mut retained = Vec::with_capacity(rounds);
|
||||
for round in 0..rounds {
|
||||
let key = format!("source/worker-{idx:02}-round-{round}.bin");
|
||||
let copy_key = format!("retained/worker-{idx:02}-round-{round}.bin");
|
||||
let body = payload_for(&key, 64 * 1024);
|
||||
put_object(writer, &bucket, &key, body.clone()).await?;
|
||||
|
||||
let head = reader.head_object().bucket(&bucket).key(&key).send().await?;
|
||||
if head.content_length() != Some(body.len() as i64) {
|
||||
return Err(format!("HEAD returned the wrong size for {key}: {head:?}").into());
|
||||
}
|
||||
assert_object_bytes(reader, &bucket, &key, &body).await?;
|
||||
|
||||
copier
|
||||
.copy_object()
|
||||
.bucket(&bucket)
|
||||
.key(©_key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_object_bytes(writer, &bucket, ©_key, &body).await?;
|
||||
|
||||
writer.delete_object().bucket(&bucket).key(&key).send().await?;
|
||||
let missing = reader
|
||||
.head_object()
|
||||
.bucket(&bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("deleted source key must not remain visible");
|
||||
if missing.raw_response().map(|response| response.status().as_u16()) != Some(404) {
|
||||
return Err(format!("deleted source {key} returned an unexpected result: {missing:?}").into());
|
||||
}
|
||||
retained.push((copy_key, body));
|
||||
}
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(retained)
|
||||
}));
|
||||
}
|
||||
|
||||
let mut inventory = Vec::new();
|
||||
for handle in handles {
|
||||
inventory.extend(handle.await??);
|
||||
}
|
||||
|
||||
let expected_keys: BTreeSet<_> = inventory.iter().map(|(key, _)| key.as_str()).collect();
|
||||
for (node_idx, client) in clients.iter().enumerate() {
|
||||
let listed = client.list_objects_v2().bucket(&bucket).prefix("retained/").send().await?;
|
||||
let listed_keys: BTreeSet<_> = listed.contents().iter().filter_map(|object| object.key()).collect();
|
||||
assert_eq!(listed_keys, expected_keys, "node {node_idx} returned a divergent retained-key listing");
|
||||
for (key, body) in &inventory {
|
||||
assert_object_bytes(client, &bucket, key, body)
|
||||
.await
|
||||
.map_err(|error| format!("node {node_idx} failed to read {key}: {error}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
|
||||
decommission_status_json, payload_for, put_inventory_retrying, retrying_get_equals, retrying_put, start_decommission,
|
||||
unique_bucket, wait_for_decommission_complete, wait_for_decommission_running_with_progress,
|
||||
};
|
||||
use crate::common::init_logging;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_puts_during_decommission_do_not_lose_baseline_or_new_objects() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||
let bucket = unique_bucket("concdecom");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let baseline_client = dist.client(0)?;
|
||||
let inventory = put_inventory_retrying(&baseline_client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
|
||||
dist.expand_to_four_pools().await?;
|
||||
|
||||
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||
|
||||
let clients = Arc::new(dist.clients()?);
|
||||
let barrier = Arc::new(Barrier::new(17));
|
||||
let mut handles = Vec::new();
|
||||
for idx in 0..16 {
|
||||
let clients = clients.clone();
|
||||
let barrier = barrier.clone();
|
||||
let bucket = bucket.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
let client = &clients[idx % clients.len()];
|
||||
let key = format!("live/{idx:02}.bin");
|
||||
let body = payload_for(&key, 8 * 1024);
|
||||
retrying_put(client, &bucket, &key, body.clone(), Duration::from_secs(45)).await?;
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((key, body))
|
||||
}));
|
||||
}
|
||||
|
||||
wait_for_decommission_running_with_progress(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||
barrier.wait().await;
|
||||
|
||||
let mut live_objects = Vec::new();
|
||||
for handle in handles {
|
||||
live_objects.push(handle.await??);
|
||||
}
|
||||
let status = decommission_status_json(&dist.cluster).await?;
|
||||
if !decommission_running_with_progress(&status, DECOMMISSION_POOL_ID)? {
|
||||
return Err(format!("decommission did not remain active across concurrent PUTs: {status}").into());
|
||||
}
|
||||
|
||||
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||
|
||||
let checker = dist.client(2)?;
|
||||
assert_inventory(&checker, &bucket, &inventory).await?;
|
||||
for (key, body) in live_objects {
|
||||
retrying_get_equals(&checker, &bucket, &key, &body, Duration::from_secs(30)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, enable_versioning, put_inventory_retrying,
|
||||
sha256_hex, start_decommission, unique_bucket, wait_for_decommission_active, wait_for_decommission_complete,
|
||||
};
|
||||
use crate::common::init_logging;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn decommission_does_not_alter_object_sha256_across_pools() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||
let bucket = unique_bucket("integrity");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let client = dist.client(0)?;
|
||||
enable_versioning(&client, &bucket).await?;
|
||||
let inventory = put_inventory_retrying(&client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
|
||||
let before: Vec<(String, String)> = inventory.iter().map(|(key, body)| (key.clone(), sha256_hex(body))).collect();
|
||||
|
||||
let versioned_key = "history/versioned.bin";
|
||||
let version_one = b"historical bytes before data movement".to_vec();
|
||||
let version_two = b"current bytes before data movement".to_vec();
|
||||
let version_one_id = client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(versioned_key)
|
||||
.body(ByteStream::from(version_one.clone()))
|
||||
.send()
|
||||
.await?
|
||||
.version_id()
|
||||
.ok_or("historical PUT omitted version ID")?
|
||||
.to_string();
|
||||
let version_two_id = client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(versioned_key)
|
||||
.body(ByteStream::from(version_two.clone()))
|
||||
.send()
|
||||
.await?
|
||||
.version_id()
|
||||
.ok_or("current PUT omitted version ID")?
|
||||
.to_string();
|
||||
|
||||
let multipart_key = "multipart/moved.bin";
|
||||
let first_part = vec![0x31; 5 * 1024 * 1024];
|
||||
let second_part = vec![0x72; 1024 * 1024];
|
||||
let upload = client
|
||||
.create_multipart_upload()
|
||||
.bucket(&bucket)
|
||||
.key(multipart_key)
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = upload.upload_id().ok_or("movement multipart upload omitted upload ID")?;
|
||||
let uploaded_one = client
|
||||
.upload_part()
|
||||
.bucket(&bucket)
|
||||
.key(multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(first_part.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
let uploaded_two = client
|
||||
.upload_part()
|
||||
.bucket(&bucket)
|
||||
.key(multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(2)
|
||||
.body(ByteStream::from(second_part.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(&bucket)
|
||||
.key(multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(uploaded_one.e_tag().ok_or("movement part 1 omitted ETag")?)
|
||||
.build(),
|
||||
)
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(2)
|
||||
.e_tag(uploaded_two.e_tag().ok_or("movement part 2 omitted ETag")?)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
dist.expand_to_four_pools().await?;
|
||||
|
||||
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||
wait_for_decommission_active(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||
|
||||
let after_client = dist.client(2)?;
|
||||
assert_inventory(&after_client, &bucket, &inventory).await?;
|
||||
for (key, expected_hash) in before {
|
||||
let got = after_client.get_object().bucket(&bucket).key(&key).send().await?;
|
||||
let body = got.body.collect().await?.into_bytes();
|
||||
assert_eq!(sha256_hex(body.as_ref()), expected_hash, "checksum changed for {key} after decommission");
|
||||
}
|
||||
for (version_id, expected) in [(&version_one_id, &version_one), (&version_two_id, &version_two)] {
|
||||
let got = after_client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(versioned_key)
|
||||
.version_id(version_id)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(got.as_ref(), expected.as_slice(), "version {version_id} changed after decommission");
|
||||
}
|
||||
let mut expected_multipart = first_part;
|
||||
expected_multipart.extend_from_slice(&second_part);
|
||||
let got_multipart = after_client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(multipart_key)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(
|
||||
sha256_hex(got_multipart.as_ref()),
|
||||
sha256_hex(&expected_multipart),
|
||||
"multipart checksum changed after decommission"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, list_pools_json, put_inventory,
|
||||
put_inventory_retrying, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_active,
|
||||
wait_for_decommission_complete, wait_for_rebalance_active, wait_for_rebalance_complete,
|
||||
};
|
||||
use crate::common::init_logging;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_pool_expand_preserves_objects_then_rebalance() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||
let bucket = unique_bucket("expand");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let client = dist.client(0)?;
|
||||
let inventory = put_inventory(&client, &bucket, 64, 256 * 1024).await?;
|
||||
assert_inventory(&client, &bucket, &inventory).await?;
|
||||
|
||||
for expected_nodes in 2..=4 {
|
||||
let new_node = dist.append_pool_and_restart().await?;
|
||||
assert_eq!(new_node + 1, expected_nodes);
|
||||
assert_inventory(&dist.client(new_node)?, &bucket, &inventory).await?;
|
||||
}
|
||||
assert_eq!(dist.cluster.nodes.len(), 4);
|
||||
|
||||
// Prove that the expanded pool map is durable, and clear any recovery
|
||||
// latch raised while the newly-added pool replicas converged.
|
||||
dist.restart_current_binary_gracefully().await?;
|
||||
|
||||
let after_expand = dist.client(0)?;
|
||||
assert_inventory(&after_expand, &bucket, &inventory).await?;
|
||||
let peer = dist.client(3)?;
|
||||
assert_inventory(&peer, &bucket, &inventory).await?;
|
||||
|
||||
let rebalance_id = start_rebalance(&dist.cluster).await?;
|
||||
wait_for_rebalance_active(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
||||
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
|
||||
assert_inventory(&peer, &bucket, &inventory).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_pool_decommission_moves_objects_without_loss() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||
let bucket = unique_bucket("decom");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let client = dist.client(0)?;
|
||||
let inventory = put_inventory_retrying(&client, &bucket, 96, 128 * 1024, Duration::from_secs(30)).await?;
|
||||
dist.expand_to_four_pools().await?;
|
||||
|
||||
let pools_before = list_pools_json(&dist.cluster).await?;
|
||||
let pool_count = pools_before
|
||||
.as_array()
|
||||
.map(Vec::len)
|
||||
.or_else(|| pools_before.get("pools").and_then(serde_json::Value::as_array).map(Vec::len))
|
||||
.ok_or_else(|| format!("pool list omitted an array: {pools_before}"))?;
|
||||
assert_eq!(pool_count, 4, "expected exactly four pools before decommission: {pools_before}");
|
||||
|
||||
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||
wait_for_decommission_active(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||
|
||||
let after = dist.client(2)?;
|
||||
assert_inventory(&after, &bucket, &inventory).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket, wait_until,
|
||||
};
|
||||
use crate::common::init_logging;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_four_drive_multipart_and_cross_node_listing_agree() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("extra");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let client = dist.client(0)?;
|
||||
|
||||
let key = "multipart.bin";
|
||||
let part1 = vec![0x41u8; 5 * 1024 * 1024];
|
||||
let part2 = vec![0x42u8; 5 * 1024 * 1024];
|
||||
let upload = client.create_multipart_upload().bucket(&bucket).key(key).send().await?;
|
||||
let upload_id = upload.upload_id().ok_or("missing upload id")?.to_string();
|
||||
|
||||
let uploaded1 = client
|
||||
.upload_part()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(part1.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
let uploaded2 = client
|
||||
.upload_part()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(2)
|
||||
.body(ByteStream::from(part2.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(uploaded1.e_tag().unwrap_or_default())
|
||||
.build(),
|
||||
)
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(2)
|
||||
.e_tag(uploaded2.e_tag().unwrap_or_default())
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let mut expected = part1;
|
||||
expected.extend_from_slice(&part2);
|
||||
for node_idx in 0..dist.cluster.nodes.len() {
|
||||
assert_object_bytes(&dist.client(node_idx)?, &bucket, key, &expected).await?;
|
||||
}
|
||||
|
||||
put_object(&client, &bucket, "list/a", b"a".to_vec()).await?;
|
||||
put_object(&dist.client(2)?, &bucket, "list/b", b"b".to_vec()).await?;
|
||||
let mut seen = Vec::new();
|
||||
for node_idx in 0..dist.cluster.nodes.len() {
|
||||
let listed = dist
|
||||
.client(node_idx)?
|
||||
.list_objects_v2()
|
||||
.bucket(&bucket)
|
||||
.prefix("list/")
|
||||
.send()
|
||||
.await?;
|
||||
let keys: Vec<String> = listed
|
||||
.contents()
|
||||
.iter()
|
||||
.filter_map(|object| object.key().map(str::to_string))
|
||||
.collect();
|
||||
seen.push(keys);
|
||||
}
|
||||
for keys in &seen[1..] {
|
||||
assert_eq!(&seen[0], keys, "list results diverged across nodes: {seen:?}");
|
||||
}
|
||||
|
||||
let got = get_object_bytes(&dist.client(3)?, &bucket, "list/a").await?;
|
||||
assert_eq!(got, b"a");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_list_buckets_agree_across_all_nodes() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("listed");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
put_object(&dist.client(0)?, &bucket, "seed.bin", b"seed".to_vec()).await?;
|
||||
|
||||
for node_idx in 0..dist.cluster.nodes.len() {
|
||||
let client = dist.client(node_idx)?;
|
||||
let name = bucket.clone();
|
||||
wait_until(
|
||||
Duration::from_secs(20),
|
||||
|| {
|
||||
let client = client.clone();
|
||||
let name = name.clone();
|
||||
async move {
|
||||
let listed = client.list_buckets().send().await?;
|
||||
Ok(listed.buckets().iter().any(|entry| entry.name() == Some(name.as_str())))
|
||||
}
|
||||
},
|
||||
&format!("node {node_idx} lists {bucket}"),
|
||||
)
|
||||
.await?;
|
||||
wait_until(
|
||||
Duration::from_secs(20),
|
||||
|| {
|
||||
let client = dist.client(node_idx).expect("client");
|
||||
let name = bucket.clone();
|
||||
async move { Ok(get_object_bytes(&client, &name, "seed.bin").await.ok() == Some(b"seed".to_vec())) }
|
||||
},
|
||||
&format!("node {node_idx} reads seed.bin"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! 4-node 4-drive distributed e2e coverage.
|
||||
//!
|
||||
//! Selected by `[profile.e2e-distributed]` and run from
|
||||
//! `.github/workflows/e2e-distributed.yml`. Excluded from `e2e-full` because
|
||||
//! each case starts four real `rustfs` processes.
|
||||
|
||||
mod chaos_test;
|
||||
mod concurrency_stability_test;
|
||||
mod concurrent_data_movement_test;
|
||||
mod data_integrity_movement_test;
|
||||
mod expand_decommission_rebalance_test;
|
||||
mod extra_test;
|
||||
mod harness;
|
||||
mod object_lock_test;
|
||||
mod observability_test;
|
||||
mod replication_quota_test;
|
||||
mod s3_basic_test;
|
||||
mod s3_during_data_movement_test;
|
||||
mod site_replication_test;
|
||||
mod upgrade_test;
|
||||
mod versioning_test;
|
||||
@@ -0,0 +1,219 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, unique_bucket};
|
||||
use crate::common::init_logging;
|
||||
use crate::object_lock::common::{
|
||||
delete_object_with_bypass, put_object_lock_configuration, put_object_with_legal_hold, put_object_with_retention,
|
||||
};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use aws_sdk_s3::operation::delete_object::DeleteObjectError;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
|
||||
ObjectLockRule,
|
||||
};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
|
||||
fn delete_denied(error: &SdkError<DeleteObjectError>, context: &str) -> TestResult {
|
||||
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||
if code == Some("AccessDenied") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{context}: expected AccessDenied, got {error:?}").into())
|
||||
}
|
||||
}
|
||||
|
||||
async fn expect_versioned_delete_denied(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: &str,
|
||||
bypass: bool,
|
||||
context: &str,
|
||||
) -> TestResult {
|
||||
match delete_object_with_bypass(client, bucket, key, Some(version_id), bypass).await {
|
||||
Ok(_) => Err(format!("{context}: DeleteObject of retained version must be denied").into()),
|
||||
Err(error) => delete_denied(error.as_ref(), context),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_four_drive_object_lock_worm_blocks_delete() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let client = dist.client(0)?;
|
||||
let peer = dist.client(2)?;
|
||||
let bucket = unique_bucket("objlock");
|
||||
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(&bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let retain_until = Utc::now() + ChronoDuration::days(1);
|
||||
|
||||
let compliance_key = "compliance.bin";
|
||||
let compliance_version = put_object_with_retention(
|
||||
&client,
|
||||
&bucket,
|
||||
compliance_key,
|
||||
b"locked-compliance",
|
||||
ObjectLockRetentionMode::Compliance,
|
||||
retain_until,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Unversioned DELETE is allowed: it only creates a delete marker. WORM
|
||||
// applies to a specific version id.
|
||||
let marker = peer.delete_object().bucket(&bucket).key(compliance_key).send().await?;
|
||||
assert_eq!(
|
||||
marker.delete_marker(),
|
||||
Some(true),
|
||||
"unversioned DELETE on a locked object must create a delete marker"
|
||||
);
|
||||
|
||||
expect_versioned_delete_denied(&peer, &bucket, compliance_key, &compliance_version, false, "COMPLIANCE without bypass")
|
||||
.await?;
|
||||
expect_versioned_delete_denied(&peer, &bucket, compliance_key, &compliance_version, true, "COMPLIANCE with bypass").await?;
|
||||
|
||||
let governance_key = "governance.bin";
|
||||
let governance_version = put_object_with_retention(
|
||||
&client,
|
||||
&bucket,
|
||||
governance_key,
|
||||
b"locked-governance",
|
||||
ObjectLockRetentionMode::Governance,
|
||||
retain_until,
|
||||
)
|
||||
.await?;
|
||||
|
||||
expect_versioned_delete_denied(&peer, &bucket, governance_key, &governance_version, false, "GOVERNANCE without bypass")
|
||||
.await?;
|
||||
delete_object_with_bypass(&peer, &bucket, governance_key, Some(&governance_version), true).await?;
|
||||
let deleted_governance = peer
|
||||
.head_object()
|
||||
.bucket(&bucket)
|
||||
.key(governance_key)
|
||||
.version_id(&governance_version)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("GOVERNANCE bypass must remove the retained version");
|
||||
assert_eq!(
|
||||
deleted_governance.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"deleted GOVERNANCE version returned an unexpected HEAD result: {deleted_governance:?}"
|
||||
);
|
||||
|
||||
let hold_key = "legal-hold.bin";
|
||||
let hold_version =
|
||||
put_object_with_legal_hold(&client, &bucket, hold_key, b"legal-hold", ObjectLockLegalHoldStatus::On).await?;
|
||||
expect_versioned_delete_denied(&peer, &bucket, hold_key, &hold_version, false, "legal hold without bypass").await?;
|
||||
expect_versioned_delete_denied(&peer, &bucket, hold_key, &hold_version, true, "legal hold with bypass").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_default_retention_is_visible_and_non_lock_bucket_rejects_configuration() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let writer = dist.client(0)?;
|
||||
let reader = dist.client(3)?;
|
||||
let bucket = unique_bucket("default-lock");
|
||||
|
||||
writer
|
||||
.create_bucket()
|
||||
.bucket(&bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
put_object_lock_configuration(&writer, &bucket, ObjectLockRetentionMode::Governance, Some(1), None).await?;
|
||||
|
||||
let key = "default-governance.bin";
|
||||
let put = writer
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"default retention payload"))
|
||||
.send()
|
||||
.await?;
|
||||
let version_id = put.version_id().ok_or("default-retained PUT omitted version ID")?;
|
||||
|
||||
let config = reader.get_object_lock_configuration().bucket(&bucket).send().await?;
|
||||
let default_retention = config
|
||||
.object_lock_configuration()
|
||||
.and_then(|configuration| configuration.rule())
|
||||
.and_then(|rule| rule.default_retention())
|
||||
.ok_or("GetObjectLockConfiguration omitted default retention")?;
|
||||
assert_eq!(default_retention.mode().map(|mode| mode.as_str()), Some("GOVERNANCE"));
|
||||
assert_eq!(default_retention.days(), Some(1));
|
||||
|
||||
let retention = reader
|
||||
.get_object_retention()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.version_id(version_id)
|
||||
.send()
|
||||
.await?;
|
||||
let retention = retention.retention().ok_or("GetObjectRetention omitted applied retention")?;
|
||||
assert_eq!(retention.mode().map(|mode| mode.as_str()), Some("GOVERNANCE"));
|
||||
let retain_until = retention
|
||||
.retain_until_date()
|
||||
.ok_or("default retention omitted retain-until date")?;
|
||||
assert!(retain_until.secs() > Utc::now().timestamp(), "default retention is not in the future");
|
||||
|
||||
let versioning = reader.get_bucket_versioning().bucket(&bucket).send().await?;
|
||||
assert_eq!(versioning.status().map(|status| status.as_str()), Some("Enabled"));
|
||||
expect_versioned_delete_denied(&reader, &bucket, key, version_id, false, "default GOVERNANCE retention without bypass")
|
||||
.await?;
|
||||
|
||||
let plain_bucket = unique_bucket("no-lock");
|
||||
dist.create_bucket(&plain_bucket).await?;
|
||||
let configuration = ObjectLockConfiguration::builder()
|
||||
.object_lock_enabled(ObjectLockEnabled::Enabled)
|
||||
.rule(
|
||||
ObjectLockRule::builder()
|
||||
.default_retention(
|
||||
DefaultRetention::builder()
|
||||
.mode(ObjectLockRetentionMode::Governance)
|
||||
.days(1)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
let error = writer
|
||||
.put_object_lock_configuration()
|
||||
.bucket(&plain_bucket)
|
||||
.object_lock_configuration(configuration)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an unversioned bucket must reject Object Lock enablement");
|
||||
let service_error = error
|
||||
.as_service_error()
|
||||
.ok_or("non-lock bucket rejection was not an S3 service error")?;
|
||||
assert_eq!(service_error.code(), Some("InvalidBucketState"), "unexpected error: {error:?}");
|
||||
assert_eq!(
|
||||
service_error.message(),
|
||||
Some("Object Lock configuration cannot be enabled on existing buckets"),
|
||||
"unexpected error: {error:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, cluster_admin_ok, unique_bucket, wait_for_ready};
|
||||
use crate::common::{admin_request, init_logging, local_http_client};
|
||||
use aws_sdk_s3::operation::RequestId;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use bytes::Bytes;
|
||||
use http::Method;
|
||||
use http_body_util::{BodyExt, Empty};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Request, Response};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use local_ip_address::local_ip;
|
||||
use rustfs_madmin::metrics::RealtimeMetrics;
|
||||
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||
use serde_json::Value;
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{Instant, timeout};
|
||||
|
||||
async fn spawn_audit_collector() -> TestResult<(String, mpsc::UnboundedReceiver<Value>, JoinHandle<()>)> {
|
||||
let listener = TcpListener::bind("0.0.0.0:0").await?;
|
||||
let endpoint = format!("http://{}/audit", std::net::SocketAddr::new(local_ip()?, listener.local_addr()?.port()));
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let service = service_fn(move |request: Request<Incoming>| {
|
||||
let tx = tx.clone();
|
||||
async move {
|
||||
let method = request.method().clone();
|
||||
if let Ok(body) = request.into_body().collect().await
|
||||
&& method == Method::POST
|
||||
&& let Ok(payload) = serde_json::from_slice::<Value>(&body.to_bytes())
|
||||
{
|
||||
if let Some(records) = payload["Records"].as_array() {
|
||||
for entry in records {
|
||||
let _ = tx.send(entry.clone());
|
||||
}
|
||||
} else {
|
||||
let _ = tx.send(payload);
|
||||
}
|
||||
}
|
||||
Ok::<_, Infallible>(Response::new(Empty::<Bytes>::new()))
|
||||
}
|
||||
});
|
||||
let _ = hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok((endpoint, rx, handle))
|
||||
}
|
||||
|
||||
async fn wait_for_audit_entry(
|
||||
rx: &mut mpsc::UnboundedReceiver<Value>,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
request_id: &str,
|
||||
) -> TestResult<Value> {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let mut seen = Vec::new();
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(format!(
|
||||
"audit webhook did not receive PutObject for {bucket}/{key}; received {} other records: {seen:?}",
|
||||
seen.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let entry = match timeout(remaining, rx.recv()).await {
|
||||
Ok(Some(entry)) => entry,
|
||||
Ok(None) => return Err("audit collector stopped before the expected entry arrived".into()),
|
||||
Err(_) => {
|
||||
return Err(format!(
|
||||
"audit webhook did not receive PutObject for {bucket}/{key}; received {} other records: {seen:?}",
|
||||
seen.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
};
|
||||
if entry["api"]["name"].as_str() == Some("s3:PutObject")
|
||||
&& entry["api"]["bucket"].as_str() == Some(bucket)
|
||||
&& entry["api"]["object"].as_str() == Some(key)
|
||||
&& entry["requestID"].as_str() == Some(request_id)
|
||||
{
|
||||
return Ok(entry);
|
||||
}
|
||||
if seen.len() < 8 {
|
||||
seen.push(format!(
|
||||
"api={:?} bucket={:?} object={:?} requestID={:?}",
|
||||
entry["api"]["name"].as_str(),
|
||||
entry["api"]["bucket"].as_str(),
|
||||
entry["api"]["object"].as_str(),
|
||||
entry["requestID"].as_str()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_health_inventory_metrics_and_audit_delivery_are_consistent() -> TestResult {
|
||||
init_logging();
|
||||
let (audit_endpoint, mut audit_entries, collector) = spawn_audit_collector().await?;
|
||||
let audit_origin = reqwest::Url::parse(&audit_endpoint)?.origin().ascii_serialization();
|
||||
let audit_env = [
|
||||
("RUSTFS_AUDIT_ENABLE", "true"),
|
||||
("RUSTFS_AUDIT_WEBHOOK_ENABLE_DISTRIBUTED", "on"),
|
||||
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_DISTRIBUTED", audit_endpoint.as_str()),
|
||||
(ENV_OUTBOUND_ALLOW_ORIGINS, audit_origin.as_str()),
|
||||
];
|
||||
let mut dist = DistCluster::new_stopped_with_env(DistLayout::FourByFour, &audit_env).await?;
|
||||
for node_idx in 0..dist.cluster.nodes.len() {
|
||||
let queue_dir = format!("{}/audit-queue-node-{node_idx}", dist.cluster.temp_dir);
|
||||
tokio::fs::create_dir_all(&queue_dir).await?;
|
||||
dist.cluster
|
||||
.set_node_env(node_idx, "RUSTFS_AUDIT_WEBHOOK_QUEUE_DIR_DISTRIBUTED", queue_dir)?;
|
||||
}
|
||||
dist.cluster.start().await?;
|
||||
wait_for_ready(&dist.cluster).await?;
|
||||
|
||||
let http = local_http_client();
|
||||
for node in &dist.cluster.nodes {
|
||||
for probe in ["ready", "live"] {
|
||||
let response = http.get(format!("{}/health/{probe}", node.url)).send().await?;
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"node {} {probe} probe failed: {}",
|
||||
node.address,
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let info_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/info", None).await?;
|
||||
let info: Value = serde_json::from_str(&info_body)?;
|
||||
let servers = info["info"]["servers"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("admin info omitted servers: {info}"))?;
|
||||
assert_eq!(servers.len(), 4, "admin info did not report all four nodes: {info}");
|
||||
|
||||
let storage_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
|
||||
let storage: Value = serde_json::from_str(&storage_body)?;
|
||||
let disks = storage["info"]["disks"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("storageinfo omitted disks: {storage}"))?;
|
||||
assert_eq!(disks.len(), 16, "storageinfo did not report all sixteen drives: {storage}");
|
||||
assert!(
|
||||
disks.iter().all(|disk| {
|
||||
disk["state"].as_str().is_some_and(|state| state.eq_ignore_ascii_case("ok"))
|
||||
&& disk["runtimeState"]
|
||||
.as_str()
|
||||
.is_some_and(|state| state.eq_ignore_ascii_case("online"))
|
||||
}),
|
||||
"storageinfo reported a drive that was not healthy and online: {storage}"
|
||||
);
|
||||
|
||||
for (node_idx, node) in dist.cluster.nodes.iter().enumerate() {
|
||||
let (status, metrics_body) = admin_request(
|
||||
&node.url,
|
||||
Method::GET,
|
||||
"/rustfs/admin/v3/metrics?n=1&by-host=true&by-disk=true",
|
||||
None,
|
||||
&dist.cluster.access_key,
|
||||
&dist.cluster.secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert!(status.is_success(), "node {node_idx} metrics failed: {status} {metrics_body}");
|
||||
let sample: RealtimeMetrics = serde_json::from_str(
|
||||
metrics_body
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or_else(|| format!("node {node_idx} returned empty metrics"))?,
|
||||
)?;
|
||||
assert!(sample.finally, "node {node_idx} metrics sample was not terminal");
|
||||
assert!(sample.errors.is_empty(), "node {node_idx} metrics reported errors: {:?}", sample.errors);
|
||||
assert!(!sample.hosts.is_empty(), "node {node_idx} metrics omitted hosts");
|
||||
}
|
||||
|
||||
let targets_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/audit/target/list", None).await?;
|
||||
let targets: Value = serde_json::from_str(&targets_body)?;
|
||||
let configured = targets["audit_endpoints"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("audit target list omitted audit_endpoints: {targets}"))?
|
||||
.iter()
|
||||
.any(|target| target["account_id"].as_str() == Some("distributed") && target["service"].as_str() == Some("webhook"));
|
||||
assert!(configured, "configured audit webhook was missing: {targets}");
|
||||
|
||||
let bucket = unique_bucket("audit");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let key = "correlated/audit-object.bin";
|
||||
let put = dist
|
||||
.client(2)?
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"distributed audit payload"))
|
||||
.send()
|
||||
.await?;
|
||||
let request_id = put.request_id().ok_or("PutObject response omitted request ID")?;
|
||||
let audit = wait_for_audit_entry(&mut audit_entries, &bucket, key, request_id).await?;
|
||||
assert_eq!(
|
||||
audit["api"]["status_code"].as_i64(),
|
||||
Some(200),
|
||||
"audit entry did not report success: {audit}"
|
||||
);
|
||||
assert!(
|
||||
!audit.to_string().contains(&dist.cluster.secret_key),
|
||||
"audit entry leaked the root secret key"
|
||||
);
|
||||
|
||||
collector.abort();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DistCluster, DistLayout, TestResult, enable_versioning, put_bucket_replication, put_object, retrying_put, set_bucket_quota,
|
||||
set_remote_target, unique_bucket, wait_for_ready, wait_for_replicated_bytes, wait_until,
|
||||
};
|
||||
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, init_logging};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use http::Method;
|
||||
use std::time::Duration;
|
||||
|
||||
async fn wait_for_replication_status(
|
||||
client: &aws_sdk_s3::Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
expected: &[&str],
|
||||
timeout: Duration,
|
||||
) -> TestResult {
|
||||
wait_until(
|
||||
timeout,
|
||||
|| async {
|
||||
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||
Ok(head
|
||||
.replication_status()
|
||||
.is_some_and(|status| expected.contains(&status.as_str())))
|
||||
},
|
||||
&format!("replication status for {bucket}/{key} in {expected:?}"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_bucket_replication_converges_to_peer_cluster() -> TestResult {
|
||||
init_logging();
|
||||
let (source, mut target) = DistCluster::start_replication_pair().await?;
|
||||
let source_bucket = unique_bucket("replsrc");
|
||||
let target_bucket = unique_bucket("repldst");
|
||||
source.create_bucket(&source_bucket).await?;
|
||||
target.create_bucket(&target_bucket).await?;
|
||||
|
||||
let source_client = source.client(0)?;
|
||||
let target_client = target.client(0)?;
|
||||
enable_versioning(&source_client, &source_bucket).await?;
|
||||
enable_versioning(&target_client, &target_bucket).await?;
|
||||
|
||||
let arn = set_remote_target(&source.cluster, &source_bucket, &target.cluster, &target_bucket).await?;
|
||||
put_bucket_replication(&source.cluster, &source_bucket, &arn).await?;
|
||||
|
||||
let key = "replicated/metadata-and-tags.bin";
|
||||
let body = b"distributed-bucket-replication".to_vec();
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(&source_bucket)
|
||||
.key(key)
|
||||
.metadata("origin", "four-node-source")
|
||||
.tagging("suite=distributed&shape=metadata")
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
wait_for_replicated_bytes(&target_client, &target_bucket, key, &body, Duration::from_secs(45)).await?;
|
||||
wait_for_replication_status(&source_client, &source_bucket, key, &["COMPLETED"], Duration::from_secs(30)).await?;
|
||||
|
||||
let peer_read = target.client(3)?;
|
||||
wait_for_replicated_bytes(&peer_read, &target_bucket, key, &body, Duration::from_secs(15)).await?;
|
||||
let replica_head = peer_read.head_object().bucket(&target_bucket).key(key).send().await?;
|
||||
assert_eq!(
|
||||
replica_head
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.get("origin"))
|
||||
.map(String::as_str),
|
||||
Some("four-node-source")
|
||||
);
|
||||
assert_eq!(replica_head.replication_status().map(|status| status.as_str()), Some("REPLICA"));
|
||||
let replica_tags = peer_read.get_object_tagging().bucket(&target_bucket).key(key).send().await?;
|
||||
let tags: std::collections::BTreeMap<_, _> = replica_tags.tag_set().iter().map(|tag| (tag.key(), tag.value())).collect();
|
||||
assert_eq!(tags.get("suite"), Some(&"distributed"));
|
||||
assert_eq!(tags.get("shape"), Some(&"metadata"));
|
||||
|
||||
target.cluster.stop();
|
||||
let outage_key = "replicated/queued-during-target-outage.bin";
|
||||
let outage_body = b"retry-after-target-restart".to_vec();
|
||||
put_object(&source_client, &source_bucket, outage_key, outage_body.clone()).await?;
|
||||
wait_for_replication_status(
|
||||
&source_client,
|
||||
&source_bucket,
|
||||
outage_key,
|
||||
&["PENDING", "FAILED"],
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await?;
|
||||
|
||||
target.cluster.start().await?;
|
||||
wait_for_ready(&target.cluster).await?;
|
||||
wait_for_replicated_bytes(&target.client(2)?, &target_bucket, outage_key, &outage_body, Duration::from_secs(90)).await?;
|
||||
wait_for_replication_status(&source_client, &source_bucket, outage_key, &["COMPLETED"], Duration::from_secs(45)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_four_drive_hard_quota_rejects_over_limit_put() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start_with_env(DistLayout::FourByFour, FAST_DATA_USAGE_SCANNER_ENV).await?;
|
||||
let bucket = unique_bucket("quota");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
set_bucket_quota(&dist.cluster, &bucket, 8 * 1024).await?;
|
||||
|
||||
let client = dist.client(1)?;
|
||||
retrying_put(&client, &bucket, "small.bin", vec![0u8; 1024], Duration::from_secs(30)).await?;
|
||||
wait_until(
|
||||
Duration::from_secs(30),
|
||||
|| async {
|
||||
let (status, body) = super::harness::cluster_admin(
|
||||
&dist.cluster,
|
||||
Method::GET,
|
||||
&format!("/rustfs/admin/v3/quota-stats/{bucket}"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if !status.is_success() {
|
||||
return Ok(false);
|
||||
}
|
||||
let stats: serde_json::Value =
|
||||
serde_json::from_str(&body).map_err(|error| format!("quota stats returned invalid JSON: {error}: {body}"))?;
|
||||
let usage = stats
|
||||
.get("current_usage")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.ok_or_else(|| format!("quota stats omitted current_usage: {stats}"))?;
|
||||
Ok(usage >= 1024)
|
||||
},
|
||||
"quota stats observe small object",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let oversized_key = "too-big.bin";
|
||||
let error = client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(oversized_key)
|
||||
.body(vec![0u8; 16 * 1024].into())
|
||||
.send()
|
||||
.await
|
||||
.expect_err("hard quota must reject the oversized PUT");
|
||||
let service_error = error
|
||||
.as_service_error()
|
||||
.ok_or("quota rejection was not an S3 service error")?;
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(400),
|
||||
"quota rejection must be HTTP 400: {error:?}"
|
||||
);
|
||||
assert_eq!(service_error.code(), Some("InvalidRequest"), "unexpected quota error: {error:?}");
|
||||
assert!(
|
||||
service_error
|
||||
.message()
|
||||
.is_some_and(|message| message.starts_with("Bucket quota exceeded")),
|
||||
"PUT must fail specifically at quota admission: {error:?}"
|
||||
);
|
||||
|
||||
let missing = client
|
||||
.head_object()
|
||||
.bucket(&bucket)
|
||||
.key(oversized_key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an object rejected by quota must not become visible");
|
||||
assert_eq!(
|
||||
missing.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"quota-rejected object returned an unexpected HEAD result: {missing:?}"
|
||||
);
|
||||
|
||||
let listed = client.list_objects_v2().bucket(&bucket).send().await?;
|
||||
assert!(
|
||||
listed.contents().iter().all(|object| object.key() != Some(oversized_key)),
|
||||
"quota-rejected key leaked into ListObjectsV2"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket};
|
||||
use crate::common::{init_logging, local_http_client};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::presigning::PresigningConfig;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{Delete, MetadataDirective, ObjectIdentifier};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_four_drive_s3_put_get_head_list_copy_rename_delete_and_presign() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("s3basic");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
|
||||
let writer = dist.client(0)?;
|
||||
let reader = dist.client(3)?;
|
||||
let key = "dir/object.bin";
|
||||
let body = vec![0xA5u8; 256 * 1024];
|
||||
put_object(&writer, &bucket, key, body.clone()).await?;
|
||||
|
||||
let head = reader.head_object().bucket(&bucket).key(key).send().await?;
|
||||
assert_eq!(head.content_length(), Some(body.len() as i64));
|
||||
assert_object_bytes(&reader, &bucket, key, &body).await?;
|
||||
|
||||
let ranged = reader
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.range("bytes=0-15")
|
||||
.send()
|
||||
.await?;
|
||||
let ranged_body = ranged.body.collect().await?.into_bytes();
|
||||
assert_eq!(ranged_body.as_ref(), &body[..16]);
|
||||
|
||||
let listed = reader.list_objects_v2().bucket(&bucket).prefix("dir/").send().await?;
|
||||
let keys: Vec<_> = listed.contents().iter().filter_map(|object| object.key()).collect();
|
||||
assert_eq!(keys, vec![key]);
|
||||
|
||||
let copy_key = "dir/object-copy.bin";
|
||||
reader
|
||||
.copy_object()
|
||||
.bucket(&bucket)
|
||||
.key(copy_key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Copy)
|
||||
.send()
|
||||
.await?;
|
||||
assert_object_bytes(&writer, &bucket, copy_key, &body).await?;
|
||||
|
||||
let moved_key = "dir/object-moved.bin";
|
||||
writer
|
||||
.copy_object()
|
||||
.bucket(&bucket)
|
||||
.key(moved_key)
|
||||
.copy_source(format!("{bucket}/{copy_key}"))
|
||||
.send()
|
||||
.await?;
|
||||
writer.delete_object().bucket(&bucket).key(copy_key).send().await?;
|
||||
match writer.head_object().bucket(&bucket).key(copy_key).send().await {
|
||||
Ok(_) => return Err("copied source still present after rename delete".into()),
|
||||
Err(error) if error.as_service_error().is_some_and(|err| err.is_not_found()) => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
assert_object_bytes(&reader, &bucket, moved_key, &body).await?;
|
||||
|
||||
let presigned = writer
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.presigned(PresigningConfig::expires_in(Duration::from_secs(120))?)
|
||||
.await?;
|
||||
let response = local_http_client().get(presigned.uri().to_string()).send().await?;
|
||||
assert!(response.status().is_success(), "presigned GET failed: {}", response.status());
|
||||
let presigned_body = response.bytes().await?;
|
||||
assert_eq!(presigned_body.as_ref(), body.as_slice());
|
||||
|
||||
let empty_key = "empty";
|
||||
put_object(&writer, &bucket, empty_key, Vec::new()).await?;
|
||||
let empty = get_object_bytes(&reader, &bucket, empty_key).await?;
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let deleted = writer
|
||||
.delete_objects()
|
||||
.bucket(&bucket)
|
||||
.delete(
|
||||
Delete::builder()
|
||||
.objects(ObjectIdentifier::builder().key(key).build()?)
|
||||
.objects(ObjectIdentifier::builder().key(moved_key).build()?)
|
||||
.objects(ObjectIdentifier::builder().key(empty_key).build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(deleted.errors().is_empty(), "DeleteObjects reported failures: {deleted:?}");
|
||||
assert_eq!(deleted.deleted().len(), 3, "DeleteObjects did not acknowledge every key");
|
||||
|
||||
let remaining = reader.list_objects_v2().bucket(&bucket).send().await?;
|
||||
assert!(remaining.contents().is_empty(), "bucket still has objects after delete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_s3_metadata_tags_special_keys_pagination_and_multipart_abort() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("s3matrix");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let writer = dist.client(0)?;
|
||||
let reader = dist.client(3)?;
|
||||
|
||||
let special_key = "unicode/测试 space+percent%25.txt";
|
||||
let special_body = b"metadata and tagging survive distributed routing".to_vec();
|
||||
let put = writer
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(special_key)
|
||||
.metadata("test-meta", "distributed")
|
||||
.tagging("purpose=compatibility&scope=four-by-four")
|
||||
.body(ByteStream::from(special_body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
let etag = put.e_tag().ok_or("PutObject omitted ETag")?.to_string();
|
||||
|
||||
let head = reader.head_object().bucket(&bucket).key(special_key).send().await?;
|
||||
assert_eq!(
|
||||
head.metadata()
|
||||
.and_then(|metadata| metadata.get("test-meta"))
|
||||
.map(String::as_str),
|
||||
Some("distributed")
|
||||
);
|
||||
assert_eq!(head.e_tag(), Some(etag.as_str()));
|
||||
let tags = reader.get_object_tagging().bucket(&bucket).key(special_key).send().await?;
|
||||
let actual_tags: std::collections::BTreeMap<_, _> = tags
|
||||
.tag_set()
|
||||
.iter()
|
||||
.map(|tag| (tag.key().to_string(), tag.value().to_string()))
|
||||
.collect();
|
||||
assert_eq!(actual_tags.get("purpose").map(String::as_str), Some("compatibility"));
|
||||
assert_eq!(actual_tags.get("scope").map(String::as_str), Some("four-by-four"));
|
||||
|
||||
let conditional = reader
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(special_key)
|
||||
.if_match(&etag)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(conditional.body.collect().await?.into_bytes().as_ref(), special_body.as_slice());
|
||||
let invalid_range = reader
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(special_key)
|
||||
.range("bytes=999999-1000000")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an unsatisfiable range must fail");
|
||||
assert_eq!(
|
||||
invalid_range.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidRange"),
|
||||
"unexpected invalid-range error: {invalid_range:?}"
|
||||
);
|
||||
|
||||
let upload_key = "multipart/aborted.bin";
|
||||
let upload = writer
|
||||
.create_multipart_upload()
|
||||
.bucket(&bucket)
|
||||
.key(upload_key)
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = upload.upload_id().ok_or("CreateMultipartUpload omitted upload ID")?;
|
||||
writer
|
||||
.upload_part()
|
||||
.bucket(&bucket)
|
||||
.key(upload_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(vec![0x5Au8; 5 * 1024 * 1024]))
|
||||
.send()
|
||||
.await?;
|
||||
let pending = reader
|
||||
.list_multipart_uploads()
|
||||
.bucket(&bucket)
|
||||
.prefix("multipart/")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(pending.uploads().iter().any(|entry| entry.upload_id() == Some(upload_id)));
|
||||
writer
|
||||
.abort_multipart_upload()
|
||||
.bucket(&bucket)
|
||||
.key(upload_key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
let after_abort = reader
|
||||
.list_multipart_uploads()
|
||||
.bucket(&bucket)
|
||||
.prefix("multipart/")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(after_abort.uploads().iter().all(|entry| entry.upload_id() != Some(upload_id)));
|
||||
let aborted_head = reader
|
||||
.head_object()
|
||||
.bucket(&bucket)
|
||||
.key(upload_key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("aborted multipart upload must not create an object");
|
||||
assert_eq!(
|
||||
aborted_head.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"aborted multipart object returned an unexpected HEAD result: {aborted_head:?}"
|
||||
);
|
||||
|
||||
for index in 0..113 {
|
||||
let key = format!("page/{index:04}.txt");
|
||||
put_object(&writer, &bucket, &key, format!("page-{index}").into_bytes()).await?;
|
||||
}
|
||||
let mut token = None;
|
||||
let mut paged_keys = Vec::new();
|
||||
loop {
|
||||
let page = reader
|
||||
.list_objects_v2()
|
||||
.bucket(&bucket)
|
||||
.prefix("page/")
|
||||
.max_keys(37)
|
||||
.set_continuation_token(token.take())
|
||||
.send()
|
||||
.await?;
|
||||
paged_keys.extend(page.contents().iter().filter_map(|object| object.key().map(str::to_string)));
|
||||
if page.is_truncated() != Some(true) {
|
||||
break;
|
||||
}
|
||||
token = Some(
|
||||
page.next_continuation_token()
|
||||
.ok_or("truncated ListObjectsV2 page omitted next continuation token")?
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
assert_eq!(paged_keys.len(), 113);
|
||||
let expected: Vec<_> = (0..113).map(|index| format!("page/{index:04}.txt")).collect();
|
||||
assert_eq!(paged_keys, expected, "pagination lost, duplicated, or reordered keys");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
|
||||
decommission_status_json, put_inventory_retrying, rebalance_running_with_progress, rebalance_status_json,
|
||||
retrying_get_equals, retrying_put, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
|
||||
wait_for_decommission_running_with_progress, wait_for_rebalance_complete, wait_for_rebalance_running_with_progress,
|
||||
};
|
||||
use crate::common::init_logging;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||
let bucket = unique_bucket("s3move");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let client = dist.client(0)?;
|
||||
let inventory = put_inventory_retrying(&client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
|
||||
dist.expand_to_four_pools().await?;
|
||||
|
||||
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||
wait_for_decommission_running_with_progress(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||
let live = dist.client(2)?;
|
||||
retrying_put(
|
||||
&live,
|
||||
&bucket,
|
||||
"during-decommission.bin",
|
||||
b"written-while-decommissioning".to_vec(),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await?;
|
||||
retrying_get_equals(
|
||||
&live,
|
||||
&bucket,
|
||||
"during-decommission.bin",
|
||||
b"written-while-decommissioning",
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await?;
|
||||
let listed = live.list_objects_v2().bucket(&bucket).send().await?;
|
||||
assert!(
|
||||
listed
|
||||
.contents()
|
||||
.iter()
|
||||
.any(|object| object.key() == Some("during-decommission.bin")),
|
||||
"list during decommission missed the newly written key"
|
||||
);
|
||||
let status = decommission_status_json(&dist.cluster).await?;
|
||||
if !decommission_running_with_progress(&status, DECOMMISSION_POOL_ID)? {
|
||||
return Err(format!("decommission did not remain active across the S3 operations: {status}").into());
|
||||
}
|
||||
|
||||
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||
assert_inventory(&live, &bucket, &inventory).await?;
|
||||
|
||||
let rebalance_id = start_rebalance(&dist.cluster).await?;
|
||||
wait_for_rebalance_running_with_progress(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
||||
retrying_put(
|
||||
&live,
|
||||
&bucket,
|
||||
"during-rebalance.bin",
|
||||
b"written-while-rebalancing".to_vec(),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await?;
|
||||
retrying_get_equals(
|
||||
&live,
|
||||
&bucket,
|
||||
"during-rebalance.bin",
|
||||
b"written-while-rebalancing",
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await?;
|
||||
let status = rebalance_status_json(&dist.cluster).await?;
|
||||
if !rebalance_running_with_progress(&status, &rebalance_id)? {
|
||||
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
|
||||
}
|
||||
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
|
||||
assert_inventory(&dist.client(1)?, &bucket, &inventory).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DistCluster, TestResult, cluster_admin_ok, enable_versioning, put_object, unique_bucket, wait_for_replicated_bytes,
|
||||
wait_until,
|
||||
};
|
||||
use crate::common::{init_logging, signed_request};
|
||||
use http::{Method, StatusCode};
|
||||
use rustfs_madmin::{PeerSite, ReplicateAddStatus, SiteReplicationInfo, SyncStatus};
|
||||
use std::time::Duration;
|
||||
|
||||
async fn site_replication_add(
|
||||
cluster: &crate::common::RustFSTestClusterEnvironment,
|
||||
sites: &[PeerSite],
|
||||
) -> TestResult<ReplicateAddStatus> {
|
||||
let url = format!("{}/rustfs/admin/v3/site-replication/add?replicateILMExpiry=false", cluster.nodes[0].url);
|
||||
let response = signed_request(
|
||||
Method::PUT,
|
||||
&url,
|
||||
&cluster.access_key,
|
||||
&cluster.secret_key,
|
||||
Some(serde_json::to_vec(sites)?),
|
||||
Some("application/json"),
|
||||
)
|
||||
.await?;
|
||||
if response.status() != StatusCode::OK {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("site replication add failed: {status} {body}").into());
|
||||
}
|
||||
Ok(serde_json::from_slice(&response.bytes().await?)?)
|
||||
}
|
||||
|
||||
async fn site_replication_info(cluster: &crate::common::RustFSTestClusterEnvironment) -> TestResult<SiteReplicationInfo> {
|
||||
let body = cluster_admin_ok(cluster, Method::GET, "/rustfs/admin/v3/site-replication/info", None).await?;
|
||||
Ok(serde_json::from_str(&body)?)
|
||||
}
|
||||
|
||||
async fn wait_for_site_replication_enabled(cluster: &crate::common::RustFSTestClusterEnvironment) -> TestResult {
|
||||
wait_until(
|
||||
Duration::from_secs(30),
|
||||
|| async {
|
||||
let info = site_replication_info(cluster).await?;
|
||||
Ok(info.enabled && info.sites.len() == 2 && info.sites.iter().all(|site| site.sync_state == SyncStatus::Enable))
|
||||
},
|
||||
"site replication enabled with two synchronized sites",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_site_replication_replicates_object_to_peer_site() -> TestResult {
|
||||
init_logging();
|
||||
let (site_a, site_b) = DistCluster::start_replication_pair().await?;
|
||||
let bucket = unique_bucket("siterepl");
|
||||
site_a.create_bucket(&bucket).await?;
|
||||
site_b.create_bucket(&bucket).await?;
|
||||
|
||||
let client_a = site_a.client(0)?;
|
||||
let client_b = site_b.client(0)?;
|
||||
enable_versioning(&client_a, &bucket).await?;
|
||||
enable_versioning(&client_b, &bucket).await?;
|
||||
|
||||
let sites = vec![
|
||||
PeerSite {
|
||||
name: "site-a".to_string(),
|
||||
endpoint: site_a.cluster.nodes[0].url.clone(),
|
||||
access_key: site_a.cluster.access_key.clone(),
|
||||
secret_key: site_a.cluster.secret_key.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
PeerSite {
|
||||
name: "site-b".to_string(),
|
||||
endpoint: site_b.cluster.nodes[0].url.clone(),
|
||||
access_key: site_b.cluster.access_key.clone(),
|
||||
secret_key: site_b.cluster.secret_key.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let add_status = site_replication_add(&site_a.cluster, &sites).await?;
|
||||
assert!(
|
||||
add_status.success && add_status.err_detail.is_empty() && add_status.initial_sync_error_message.is_empty(),
|
||||
"site replication add reported failure: {add_status:?}"
|
||||
);
|
||||
wait_for_site_replication_enabled(&site_a.cluster).await?;
|
||||
wait_for_site_replication_enabled(&site_b.cluster).await?;
|
||||
|
||||
let info_a = site_replication_info(&site_a.cluster).await?;
|
||||
let remote = info_a
|
||||
.sites
|
||||
.iter()
|
||||
.find(|site| site.name == "site-b")
|
||||
.ok_or_else(|| format!("site A info omitted the configured site-b peer: {info_a:?}"))?;
|
||||
assert_eq!(remote.endpoint, site_b.cluster.nodes[0].url);
|
||||
let deployment_ids: std::collections::BTreeSet<_> = info_a.sites.iter().map(|site| site.deployment_id.as_str()).collect();
|
||||
assert!(
|
||||
deployment_ids.iter().all(|deployment_id| !deployment_id.is_empty()) && deployment_ids.len() == 2,
|
||||
"site peers must have two distinct non-empty deployment IDs: {info_a:?}"
|
||||
);
|
||||
assert!(info_a.retry_stats.is_none(), "site A has pending replication retries: {info_a:?}");
|
||||
assert!(info_a.pending_operation.is_none(), "site A has a pending operation: {info_a:?}");
|
||||
|
||||
let key = "site-object.bin";
|
||||
let body = b"four-node-site-replication".to_vec();
|
||||
put_object(&client_a, &bucket, key, body.clone()).await?;
|
||||
wait_for_replicated_bytes(&client_b, &bucket, key, &body, Duration::from_secs(60)).await?;
|
||||
|
||||
let peer_b = site_b.client(3)?;
|
||||
wait_for_replicated_bytes(&peer_b, &bucket, key, &body, Duration::from_secs(20)).await?;
|
||||
|
||||
let reverse_key = "reverse/site-object.bin";
|
||||
let reverse_body = b"site-b-to-site-a".to_vec();
|
||||
put_object(&site_b.client(2)?, &bucket, reverse_key, reverse_body.clone()).await?;
|
||||
wait_for_replicated_bytes(&site_a.client(3)?, &bucket, reverse_key, &reverse_body, Duration::from_secs(60)).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! 4-node upgrade coverage for historical objects and IAM AK/SK.
|
||||
//!
|
||||
//! Complements `upgrade_compatibility_test` (single-node SSE/multipart and
|
||||
//! mixed-version listing). This module pins the distributed contract the
|
||||
//! hardware upgrade chain is meant to catch: after a 4-node upgrade, objects
|
||||
//! written on the previous release still read back, and IAM user credentials
|
||||
//! created before the upgrade still authenticate.
|
||||
//!
|
||||
//! Requires `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous
|
||||
//! release. The `e2e-distributed` workflow downloads that binary; a local run
|
||||
//! without it fails closed rather than skipping.
|
||||
|
||||
use super::harness::{
|
||||
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, enable_versioning, get_object_bytes, put_object,
|
||||
unique_bucket, wait_until,
|
||||
};
|
||||
use crate::common::{
|
||||
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
|
||||
};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
||||
const IAM_SECRET: &str = "UpgradeTestSecretKey1";
|
||||
const WRONG_SECRET: &str = "WrongSecretKey000000";
|
||||
const CREDENTIAL_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
struct UpgradeSeed {
|
||||
history_bucket: String,
|
||||
history_key: &'static str,
|
||||
history_body: Vec<u8>,
|
||||
versioned_bucket: String,
|
||||
versioned_key: &'static str,
|
||||
version1: String,
|
||||
version1_body: Vec<u8>,
|
||||
version2: String,
|
||||
version2_body: Vec<u8>,
|
||||
iam_bucket: String,
|
||||
iam_key: &'static str,
|
||||
iam_body: Vec<u8>,
|
||||
iam_user: String,
|
||||
iam_secret: &'static str,
|
||||
}
|
||||
|
||||
fn source_binary() -> TestResult<PathBuf> {
|
||||
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
|
||||
format!(
|
||||
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
|
||||
)
|
||||
})?;
|
||||
if !path.is_file() {
|
||||
return Err(format!("upgrade source binary does not exist: {}", path.display()).into());
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn capture_upgrade_logs(cluster: &mut DistCluster, label: &str) -> TestResult {
|
||||
let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else {
|
||||
return Ok(());
|
||||
};
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
for node_idx in 0..cluster.cluster.nodes.len() {
|
||||
let path = Path::new(&log_dir).join(format!("{label}-node-{node_idx}.log"));
|
||||
cluster
|
||||
.cluster
|
||||
.set_node_capture_log_path(node_idx, path.to_string_lossy().into_owned())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn iam_rw_policy(bucket: &str) -> String {
|
||||
serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": [
|
||||
format!("arn:aws:s3:::{bucket}"),
|
||||
format!("arn:aws:s3:::{bucket}/*")
|
||||
]
|
||||
}]
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn create_iam_user(dist: &DistCluster, user: &str, secret: &str, policy_name: &str, bucket: &str) -> TestResult {
|
||||
let url = &dist.cluster.nodes[0].url;
|
||||
let access = &dist.cluster.access_key;
|
||||
let admin_secret = &dist.cluster.secret_key;
|
||||
admin_create_user_via(AdminTransport::Signed, url, access, admin_secret, user, secret).await?;
|
||||
admin_add_canned_policy_via(AdminTransport::Signed, url, access, admin_secret, policy_name, &iam_rw_policy(bucket)).await?;
|
||||
admin_attach_user_policy_via(AdminTransport::Signed, url, access, admin_secret, policy_name, user).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, label: &str) -> TestResult {
|
||||
wait_until(
|
||||
CREDENTIAL_TIMEOUT,
|
||||
|| {
|
||||
let client = client.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let key = key.to_string();
|
||||
let body = body.clone();
|
||||
async move {
|
||||
put_object(&client, &bucket, &key, body).await?;
|
||||
Ok(true)
|
||||
}
|
||||
},
|
||||
label,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn wait_for_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8], label: &str) -> TestResult {
|
||||
wait_until(
|
||||
CREDENTIAL_TIMEOUT,
|
||||
|| {
|
||||
let client = client.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let key = key.to_string();
|
||||
let expected = expected.to_vec();
|
||||
async move {
|
||||
let got = get_object_bytes(&client, &bucket, &key).await?;
|
||||
Ok(got == expected)
|
||||
}
|
||||
},
|
||||
label,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn seed_history_and_iam(dist: &DistCluster) -> TestResult<UpgradeSeed> {
|
||||
let history_bucket = unique_bucket("upg-hist");
|
||||
let versioned_bucket = unique_bucket("upg-ver");
|
||||
let iam_bucket = unique_bucket("upg-iam");
|
||||
dist.create_bucket(&history_bucket).await?;
|
||||
dist.create_bucket(&versioned_bucket).await?;
|
||||
dist.create_bucket(&iam_bucket).await?;
|
||||
|
||||
let root = dist.client(0)?;
|
||||
enable_versioning(&root, &versioned_bucket).await?;
|
||||
|
||||
let history_key = "plain-history.bin";
|
||||
let history_body = b"written by the previous 4-node release".to_vec();
|
||||
put_object(&root, &history_bucket, history_key, history_body.clone()).await?;
|
||||
|
||||
let versioned_key = "versioned-history.txt";
|
||||
let version1_body = b"version-one-before-upgrade".to_vec();
|
||||
let version1 = root
|
||||
.put_object()
|
||||
.bucket(&versioned_bucket)
|
||||
.key(versioned_key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(version1_body.clone()))
|
||||
.send()
|
||||
.await?
|
||||
.version_id()
|
||||
.ok_or("first versioned PUT omitted version ID")?
|
||||
.to_string();
|
||||
let version2_body = b"version-two-before-upgrade".to_vec();
|
||||
let version2 = root
|
||||
.put_object()
|
||||
.bucket(&versioned_bucket)
|
||||
.key(versioned_key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(version2_body.clone()))
|
||||
.send()
|
||||
.await?
|
||||
.version_id()
|
||||
.ok_or("second versioned PUT omitted version ID")?
|
||||
.to_string();
|
||||
|
||||
let iam_user = format!("upg{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let policy_name = format!("upgpol{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_iam_user(dist, &iam_user, IAM_SECRET, &policy_name, &iam_bucket).await?;
|
||||
|
||||
let iam_key = "iam-history.bin";
|
||||
let iam_body = b"written with pre-upgrade IAM AK/SK".to_vec();
|
||||
let iam_client = dist.client_with_credentials(1, &iam_user, IAM_SECRET)?;
|
||||
wait_for_put(&iam_client, &iam_bucket, iam_key, iam_body.clone(), "IAM user PUT before upgrade").await?;
|
||||
|
||||
Ok(UpgradeSeed {
|
||||
history_bucket,
|
||||
history_key,
|
||||
history_body,
|
||||
versioned_bucket,
|
||||
versioned_key,
|
||||
version1,
|
||||
version1_body,
|
||||
version2,
|
||||
version2_body,
|
||||
iam_bucket,
|
||||
iam_key,
|
||||
iam_body,
|
||||
iam_user,
|
||||
iam_secret: IAM_SECRET,
|
||||
})
|
||||
}
|
||||
|
||||
async fn assert_history_and_iam(dist: &DistCluster, seed: &UpgradeSeed, context: &str) -> TestResult {
|
||||
let root_a = dist.client(0)?;
|
||||
let root_b = dist.client(3)?;
|
||||
wait_for_bytes(
|
||||
&root_b,
|
||||
&seed.history_bucket,
|
||||
seed.history_key,
|
||||
&seed.history_body,
|
||||
&format!("{context}: root GET historical object"),
|
||||
)
|
||||
.await?;
|
||||
assert_object_bytes(&root_a, &seed.history_bucket, seed.history_key, &seed.history_body).await?;
|
||||
|
||||
let v1 = root_b
|
||||
.get_object()
|
||||
.bucket(&seed.versioned_bucket)
|
||||
.key(seed.versioned_key)
|
||||
.version_id(&seed.version1)
|
||||
.send()
|
||||
.await?;
|
||||
let v1_body = v1.body.collect().await?.into_bytes();
|
||||
if v1_body.as_ref() != seed.version1_body.as_slice() {
|
||||
return Err(format!("{context}: version 1 bytes changed after upgrade").into());
|
||||
}
|
||||
let v2 = root_a
|
||||
.get_object()
|
||||
.bucket(&seed.versioned_bucket)
|
||||
.key(seed.versioned_key)
|
||||
.version_id(&seed.version2)
|
||||
.send()
|
||||
.await?;
|
||||
let v2_body = v2.body.collect().await?.into_bytes();
|
||||
if v2_body.as_ref() != seed.version2_body.as_slice() {
|
||||
return Err(format!("{context}: version 2 bytes changed after upgrade").into());
|
||||
}
|
||||
|
||||
let users = cluster_admin_ok(&dist.cluster, http::Method::GET, "/rustfs/admin/v3/list-users", None).await?;
|
||||
if !users.contains(&seed.iam_user) {
|
||||
return Err(format!("{context}: list-users lost IAM user {}: {users}", seed.iam_user).into());
|
||||
}
|
||||
|
||||
let iam_on_upgraded = dist.client_with_credentials(0, &seed.iam_user, seed.iam_secret)?;
|
||||
let iam_on_peer = dist.client_with_credentials(3, &seed.iam_user, seed.iam_secret)?;
|
||||
wait_for_bytes(
|
||||
&iam_on_upgraded,
|
||||
&seed.iam_bucket,
|
||||
seed.iam_key,
|
||||
&seed.iam_body,
|
||||
&format!("{context}: IAM GET historical object on node 0"),
|
||||
)
|
||||
.await?;
|
||||
wait_for_bytes(
|
||||
&iam_on_peer,
|
||||
&seed.iam_bucket,
|
||||
seed.iam_key,
|
||||
&seed.iam_body,
|
||||
&format!("{context}: IAM GET historical object on node 3"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let post_key = format!("after-upgrade-{context}.txt");
|
||||
let post_body = format!("{context}: written with the same IAM AK/SK after upgrade").into_bytes();
|
||||
wait_for_put(
|
||||
&iam_on_peer,
|
||||
&seed.iam_bucket,
|
||||
&post_key,
|
||||
post_body.clone(),
|
||||
&format!("{context}: IAM PUT after upgrade"),
|
||||
)
|
||||
.await?;
|
||||
assert_object_bytes(&iam_on_upgraded, &seed.iam_bucket, &post_key, &post_body).await?;
|
||||
|
||||
let bad = dist.client_with_credentials(1, &seed.iam_user, WRONG_SECRET)?;
|
||||
match bad.get_object().bucket(&seed.iam_bucket).key(seed.iam_key).send().await {
|
||||
Ok(_) => return Err(format!("{context}: wrong secret must not read the IAM object").into()),
|
||||
Err(error) => {
|
||||
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||
let rejected = code == Some("SignatureDoesNotMatch")
|
||||
|| code == Some("InvalidAccessKeyId")
|
||||
|| code == Some("AccessDenied")
|
||||
|| code == Some("InvalidArgument")
|
||||
|| error.raw_response().is_some_and(|response| response.status().as_u16() == 403);
|
||||
if !rejected {
|
||||
return Err(format!("{context}: wrong secret failed with unexpected error {error:?}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let post_root_key = format!("root-after-{context}.bin");
|
||||
let post_root_body = format!("{context}: root write after upgrade").into_bytes();
|
||||
put_object(&root_a, &seed.history_bucket, &post_root_key, post_root_body.clone()).await?;
|
||||
assert_object_bytes(&root_b, &seed.history_bucket, &post_root_key, &post_root_body).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_direct_upgrade_preserves_history_and_iam_credentials() -> TestResult {
|
||||
init_logging();
|
||||
let previous = source_binary()?;
|
||||
let mut dist = DistCluster::new_stopped(DistLayout::FourNodeFourDisk).await?;
|
||||
capture_upgrade_logs(&mut dist, "direct-upgrade")?;
|
||||
dist.start_from_binary(&previous).await?;
|
||||
|
||||
let seed = seed_history_and_iam(&dist).await?;
|
||||
dist.restart_with_current_binary().await?;
|
||||
assert_history_and_iam(&dist, &seed, "direct").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> TestResult {
|
||||
init_logging();
|
||||
let previous = source_binary()?;
|
||||
let mut dist = DistCluster::new_stopped(DistLayout::FourNodeFourDisk).await?;
|
||||
capture_upgrade_logs(&mut dist, "rolling-upgrade")?;
|
||||
dist.start_from_binary(&previous).await?;
|
||||
|
||||
let seed = seed_history_and_iam(&dist).await?;
|
||||
|
||||
dist.replace_node_with_current_binary(0).await?;
|
||||
assert_history_and_iam(&dist, &seed, "one-current-node").await?;
|
||||
|
||||
for node_idx in [1, 2] {
|
||||
dist.replace_node_with_current_binary(node_idx).await?;
|
||||
}
|
||||
assert_history_and_iam(&dist, &seed, "one-previous-node").await?;
|
||||
|
||||
dist.replace_node_with_current_binary(3).await?;
|
||||
assert_history_and_iam(&dist, &seed, "homogeneous-current").await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, enable_versioning, get_object_bytes, put_object, unique_bucket};
|
||||
use crate::common::init_logging;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_four_drive_versioning_put_list_get_delete_marker() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("version");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let writer = dist.client(0)?;
|
||||
let reader = dist.client(3)?;
|
||||
enable_versioning(&writer, &bucket).await?;
|
||||
|
||||
let key = "versioned.txt";
|
||||
let v1_id = writer
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.body(b"v1".to_vec().into())
|
||||
.send()
|
||||
.await?
|
||||
.version_id()
|
||||
.ok_or("v1 PUT omitted version ID")?
|
||||
.to_string();
|
||||
let v2_id = writer
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.body(b"v2".to_vec().into())
|
||||
.send()
|
||||
.await?
|
||||
.version_id()
|
||||
.ok_or("v2 PUT omitted version ID")?
|
||||
.to_string();
|
||||
|
||||
let versions = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
|
||||
let matching_versions: Vec<_> = versions
|
||||
.versions()
|
||||
.iter()
|
||||
.filter(|version| version.key() == Some(key))
|
||||
.collect();
|
||||
assert_eq!(matching_versions.len(), 2, "fresh key must have exactly two versions: {versions:?}");
|
||||
assert!(versions.delete_markers().is_empty(), "fresh key unexpectedly has a delete marker");
|
||||
assert!(
|
||||
matching_versions
|
||||
.iter()
|
||||
.any(|version| version.version_id() == Some(v1_id.as_str()) && version.is_latest() != Some(true)),
|
||||
"v1 was not the historical version: {versions:?}"
|
||||
);
|
||||
assert!(
|
||||
matching_versions
|
||||
.iter()
|
||||
.any(|version| version.version_id() == Some(v2_id.as_str()) && version.is_latest() == Some(true)),
|
||||
"v2 was not the latest version: {versions:?}"
|
||||
);
|
||||
|
||||
let latest = get_object_bytes(&reader, &bucket, key).await?;
|
||||
assert_eq!(latest, b"v2");
|
||||
|
||||
let older = reader.get_object().bucket(&bucket).key(key).version_id(&v1_id).send().await?;
|
||||
let older_body = older.body.collect().await?.into_bytes();
|
||||
assert_eq!(older_body.as_ref(), b"v1");
|
||||
|
||||
let deleted = writer.delete_object().bucket(&bucket).key(key).send().await?;
|
||||
assert_eq!(deleted.delete_marker(), Some(true));
|
||||
let marker_id = deleted.version_id().ok_or("DeleteObject omitted delete-marker version ID")?;
|
||||
let after_delete = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
|
||||
let matching_markers: Vec<_> = after_delete
|
||||
.delete_markers()
|
||||
.iter()
|
||||
.filter(|marker| marker.key() == Some(key))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
matching_markers.len(),
|
||||
1,
|
||||
"delete marker missing or duplicated after current-version delete: {after_delete:?}"
|
||||
);
|
||||
assert!(
|
||||
matching_markers[0].version_id() == Some(marker_id) && matching_markers[0].is_latest() == Some(true),
|
||||
"DeleteObject response and ListObjectVersions disagree about the marker: {after_delete:?}"
|
||||
);
|
||||
|
||||
let latest_after_delete = reader.get_object().bucket(&bucket).key(key).send().await;
|
||||
match latest_after_delete {
|
||||
Ok(_) => return Err("current version should be a delete marker".into()),
|
||||
Err(error)
|
||||
if error
|
||||
.as_service_error()
|
||||
.and_then(ProvideErrorMetadata::code)
|
||||
.is_some_and(|code| code == "NoSuchKey" || code == "NotFound") => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
|
||||
let restored = reader.get_object().bucket(&bucket).key(key).version_id(&v1_id).send().await?;
|
||||
let restored_body = restored.body.collect().await?.into_bytes();
|
||||
assert_eq!(restored_body.as_ref(), b"v1");
|
||||
|
||||
writer
|
||||
.delete_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.version_id(marker_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(get_object_bytes(&reader, &bucket, key).await?, b"v2");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_versioning_suspension_keeps_one_null_version_and_history() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("suspend");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let writer = dist.client(0)?;
|
||||
let reader = dist.client(3)?;
|
||||
enable_versioning(&writer, &bucket).await?;
|
||||
|
||||
let key = "suspended.txt";
|
||||
let original = writer
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.body(b"enabled-history".to_vec().into())
|
||||
.send()
|
||||
.await?
|
||||
.version_id()
|
||||
.ok_or("enabled PUT omitted version ID")?
|
||||
.to_string();
|
||||
writer
|
||||
.put_bucket_versioning()
|
||||
.bucket(&bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Suspended)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
put_object(&writer, &bucket, key, b"null-one".to_vec()).await?;
|
||||
put_object(&writer, &bucket, key, b"null-two".to_vec()).await?;
|
||||
assert_eq!(get_object_bytes(&reader, &bucket, key).await?, b"null-two");
|
||||
|
||||
let versions = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
|
||||
let matching: Vec<_> = versions
|
||||
.versions()
|
||||
.iter()
|
||||
.filter(|version| version.key() == Some(key))
|
||||
.collect();
|
||||
assert!(matching.iter().any(|version| version.version_id() == Some(original.as_str())));
|
||||
let null_version_count = matching
|
||||
.iter()
|
||||
.filter(|version| {
|
||||
matches!(
|
||||
version.version_id(),
|
||||
None | Some("") | Some("null") | Some("00000000-0000-0000-0000-000000000000")
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(null_version_count, 1, "suspended overwrites must keep one null version: {versions:?}");
|
||||
|
||||
let historical = reader
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.version_id(&original)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(historical.body.collect().await?.into_bytes().as_ref(), b"enabled-history");
|
||||
Ok(())
|
||||
}
|
||||
@@ -378,6 +378,11 @@ mod bucket_stats_regression_test;
|
||||
#[cfg(test)]
|
||||
mod distributed_startup_regression_test;
|
||||
|
||||
// 4-node / 4-disk distributed Actions suite (S3, lock, versioning, replication,
|
||||
// quota, observability, expand/decommission/rebalance, site replication, chaos).
|
||||
#[cfg(test)]
|
||||
mod distributed;
|
||||
|
||||
// P1 regression: tier/ILM transition (rustfs#5218, #5130, #5011, #4826, #5024)
|
||||
#[cfg(test)]
|
||||
mod tier_transition_regression_test;
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
//! journal (`count_requests`) carries the assertion in every one of them.
|
||||
|
||||
use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env};
|
||||
use crate::fake_s3_target::Operation;
|
||||
use crate::fake_s3_target::{FaultAction, Operation};
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use bytes::Bytes;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use std::time::Duration;
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
@@ -145,14 +146,38 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
|
||||
.await?;
|
||||
|
||||
let body = payload(128 * 1024);
|
||||
let blocker = "queue/blocker.bin";
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(blocker, body.clone())]);
|
||||
// The one-chunk range completes immediately; its full background pull
|
||||
// occupies the only slot while the remaining requests fill the queue.
|
||||
env.source.inject_for_key(
|
||||
Operation::GetObject,
|
||||
blocker,
|
||||
FaultAction::SlowSendBody {
|
||||
chunk_bytes: 1024,
|
||||
delay: Duration::from_millis(100),
|
||||
},
|
||||
2,
|
||||
);
|
||||
let response = env
|
||||
.raw_object_request(http::Method::GET, bucket, blocker, &[("range", "bytes=0-1023")])
|
||||
.await?;
|
||||
assert_eq!(response.status, 206);
|
||||
assert_eq!(response.body, body.slice(0..1024));
|
||||
env.wait_for_status_counter(bucket, "/inflight_pulls", 1, SETTLE).await?;
|
||||
|
||||
let keys: Vec<String> = (0..REQUESTS).map(|index| format!("queue/object-{index:03}.bin")).collect();
|
||||
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
|
||||
env.seed_source(SOURCE_BUCKET, &seeds);
|
||||
|
||||
let responses: Vec<RawResponse> = futures::future::try_join_all(
|
||||
// Bound source connections below the fixture's limit while still
|
||||
// submitting all 100 requests to the eight-slot background queue.
|
||||
let responses: Vec<RawResponse> = futures::stream::iter(
|
||||
keys.iter()
|
||||
.map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])),
|
||||
)
|
||||
.buffered(16)
|
||||
.try_collect()
|
||||
.await?;
|
||||
for (key, response) in keys.iter().zip(&responses) {
|
||||
assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body));
|
||||
@@ -168,6 +193,15 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
|
||||
.wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE)
|
||||
.await?;
|
||||
assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue");
|
||||
let queue_full = usize::try_from(queue_full)?;
|
||||
assert!(queue_full <= REQUESTS);
|
||||
env.wait_for_status_counter(
|
||||
bucket,
|
||||
"/counters/pulled_objects_total/background",
|
||||
u64::try_from(REQUESTS + 1 - queue_full)?,
|
||||
SETTLE,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum();
|
||||
assert!(
|
||||
@@ -175,9 +209,6 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
|
||||
"every reader is served from the source: {ranged_reads} GETs for {REQUESTS} readers"
|
||||
);
|
||||
let dropped = keys.iter().filter(|key| source_get_count(&env, key) == 1).count();
|
||||
assert!(
|
||||
dropped > 0,
|
||||
"the overflowed keys are the ones with no backfill GET, but every key got one"
|
||||
);
|
||||
assert_eq!(dropped, queue_full, "only overflowed keys remain without a background GET");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -265,16 +265,13 @@ async fn list_through_rejects_a_tampered_continuation_token() -> TestResult {
|
||||
let decoded = String::from_utf8(base64_simd::STANDARD.decode_to_vec(token.as_bytes())?)?;
|
||||
assert!(decoded.contains("\"t\":\"odm-list\""), "the merged token is an envelope: {decoded}");
|
||||
|
||||
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":2").as_bytes());
|
||||
let rejected = env
|
||||
.raw_list_objects_v2(bucket, &format!("continuation-token={tampered}"))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
rejected.status,
|
||||
400,
|
||||
"a bumped token version is a client error: {}",
|
||||
String::from_utf8_lossy(&rejected.body)
|
||||
);
|
||||
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":3").as_bytes());
|
||||
assert_ne!(tampered, token, "the test must change the token version");
|
||||
let query = serde_urlencoded::to_string([("continuation-token", tampered.as_str())])?;
|
||||
let rejected = env.raw_list_objects_v2(bucket, &query).await?;
|
||||
let error_body = String::from_utf8_lossy(&rejected.body);
|
||||
assert_eq!(rejected.status, 400, "a bumped token version is a client error: {}", error_body);
|
||||
assert!(error_body.contains("<Code>InvalidArgument</Code>"), "{error_body}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -12,21 +12,34 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||
use crate::common::{
|
||||
RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging, replication_fast_env, rustfs_binary_path,
|
||||
};
|
||||
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target};
|
||||
use crate::on_demand_migration::common::{ODM_SERVER_ENV, OdmTestEnv, SeedObject};
|
||||
use crate::replication_extension_test::{
|
||||
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, put_bucket_replication, set_replication_target_with_options,
|
||||
};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ServerSideEncryption, VersioningConfiguration,
|
||||
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, DefaultRetention,
|
||||
ExpirationStatus, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter, ObjectLockConfiguration, ObjectLockEnabled,
|
||||
ObjectLockRetentionMode, ObjectLockRule, PublicAccessBlockConfiguration, ServerSideEncryption, ServerSideEncryptionByDefault,
|
||||
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, Tag, Tagging, VersioningConfiguration,
|
||||
};
|
||||
use http::{Method, StatusCode};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::{Instant, sleep};
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
||||
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
|
||||
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||
const PLAIN_BUCKET: &str = "upgrade-plain-data";
|
||||
@@ -40,6 +53,32 @@ const MULTIPART_UPLOADS_PER_WORKER: usize = 16;
|
||||
// comfortably covers that window plus CI scheduling jitter.
|
||||
const LISTING_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
// Bucket-configuration upgrade/rollback scenarios (rustfs#7172, #7183, #7089).
|
||||
const CONFIG_PLAIN_BUCKET: &str = "upgrade-config-plain";
|
||||
const CONFIG_ENCRYPTED_BUCKET: &str = "upgrade-config-encrypted";
|
||||
const CONFIG_REPLICATED_BUCKET: &str = "upgrade-config-replicated";
|
||||
const CONFIG_LOCKED_BUCKET: &str = "upgrade-config-locked";
|
||||
const CONFIG_REPLICA_BUCKET: &str = "upgrade-config-replica";
|
||||
const ROLLBACK_BUCKET: &str = "rollback-config-data";
|
||||
const ROLLBACK_REPLICA_BUCKET: &str = "rollback-config-replica";
|
||||
const BUCKET_QUOTA_BYTES: u64 = 64 * 1024 * 1024;
|
||||
const LIFECYCLE_RULE_ID: &str = "upgrade-expire-logs";
|
||||
const LIFECYCLE_PREFIX: &str = "logs/";
|
||||
const LIFECYCLE_DAYS: i32 = 30;
|
||||
const BUCKET_TAG_KEY: &str = "owner";
|
||||
const BUCKET_TAG_VALUE: &str = "upgrade-compatibility";
|
||||
const OBJECT_LOCK_DAYS: i32 = 1;
|
||||
// `set-bucket-quota` answers 503 until the scanner has made the bucket's usage
|
||||
// authoritative; the quota test uses the same 30s budget.
|
||||
const QUOTA_READINESS_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
// Quota admission fails closed while a freshly started server has neither
|
||||
// authoritative usage nor a persisted degraded baseline for the bucket
|
||||
// (rustfs#5716), so a write to a quota-enabled bucket is retryable-503 for that
|
||||
// window. It is a restart property, not an upgrade property — the same window
|
||||
// opens on the very first start — so the write assertions ride it out instead
|
||||
// of treating it as an upgrade failure.
|
||||
const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
|
||||
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
||||
.map(PathBuf::from)
|
||||
@@ -240,6 +279,93 @@ async fn exercise_mixed_cluster(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pins the published old writer's limitation and the supported recovery
|
||||
/// procedure. This is not a promise that mixed-version ODM is supported.
|
||||
/// Replace the loss assertion when ODM gains independent persistence;
|
||||
/// preserving configuration across rc.5 writes is then an improvement.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires the pinned 1.0.0-rc.5 release binary"]
|
||||
async fn rc5_rollback_requires_restoring_odm_configuration() -> TestResult {
|
||||
init_logging();
|
||||
let previous_binary = source_binary()?;
|
||||
let version = tokio::process::Command::new(&previous_binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await?;
|
||||
assert!(version.status.success(), "previous binary must report its version");
|
||||
assert!(
|
||||
String::from_utf8(version.stdout)?.contains(RC5_COMMIT),
|
||||
"this compatibility scenario requires the published rc.5 writer"
|
||||
);
|
||||
let mut env = OdmTestEnv::start().await?;
|
||||
let bucket = "odm-rc5-rollback";
|
||||
let source_bucket = "odm-rc5-source";
|
||||
env.source.create_bucket_with_mode(source_bucket, BucketMode::Unversioned);
|
||||
env.seed_source(
|
||||
source_bucket,
|
||||
&[SeedObject::new(
|
||||
"source-only",
|
||||
bytes::Bytes::from_static(b"source read after recovery"),
|
||||
)],
|
||||
);
|
||||
env.rustfs.create_test_bucket(bucket).await?;
|
||||
let saved_config = env.fake_source_spec(source_bucket);
|
||||
assert_eq!(env.configure_source(bucket, &saved_config).await?.status, 200);
|
||||
let before = env.get_config(bucket).await?;
|
||||
assert_eq!(before.status, 200);
|
||||
let expected_config = before
|
||||
.json()?
|
||||
.get("config")
|
||||
.cloned()
|
||||
.ok_or("configuration response omitted config")?;
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("local")
|
||||
.body(ByteStream::from_static(b"local data survives rollback"))
|
||||
.send()
|
||||
.await?;
|
||||
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
||||
let restarted = env.get_config(bucket).await?;
|
||||
assert_eq!(restarted.status, 200, "a current writer preserves ODM across restart");
|
||||
assert_eq!(restarted.json()?.get("config"), Some(&expected_config));
|
||||
|
||||
restart_from_binary(&mut env.rustfs, &previous_binary, &[]).await?;
|
||||
env.client
|
||||
.put_bucket_tagging()
|
||||
.bucket(bucket)
|
||||
.tagging(
|
||||
Tagging::builder()
|
||||
.tag_set(Tag::builder().key("writer").value("rc5").build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
||||
let missing = env.get_config(bucket).await?;
|
||||
assert_eq!(missing.status, 404, "rc.5 rewrites metadata without ODM keys");
|
||||
assert!(missing.body.contains("NoSuchConfiguration"));
|
||||
assert_eq!(read_object(&env.client, bucket, "local", None).await?.1, b"local data survives rollback");
|
||||
let tags = env.client.get_bucket_tagging().bucket(bucket).send().await?;
|
||||
assert!(tags.tag_set().iter().any(|tag| tag.key() == "writer" && tag.value() == "rc5"));
|
||||
|
||||
assert_eq!(
|
||||
env.configure_source(bucket, &saved_config).await?.status,
|
||||
200,
|
||||
"restore from saved full configuration"
|
||||
);
|
||||
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
||||
let restored = env.get_config(bucket).await?;
|
||||
assert_eq!(restored.status, 200, "restored ODM configuration persists");
|
||||
assert_eq!(restored.json()?.get("config"), Some(&expected_config));
|
||||
env.wait_until_source_consulted(bucket).await?;
|
||||
assert_eq!(
|
||||
read_object(&env.client, bucket, "source-only", None).await?.1,
|
||||
b"source read after recovery"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||
async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
|
||||
@@ -429,3 +555,653 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Child-process environment shared by both bucket-configuration scenarios.
|
||||
///
|
||||
/// The replication target is an in-process fake bound to `127.0.0.1`, which
|
||||
/// `set-remote-target` rejects as an SSRF risk without the loopback opt-in, and
|
||||
/// the proxy bypass keeps a developer's `HTTP_PROXY` from intercepting the
|
||||
/// server's outbound health check.
|
||||
fn bucket_config_server_env() -> Vec<(&'static str, &'static str)> {
|
||||
let mut env = vec![
|
||||
(SSE_MASTER_KEY_ENV, SSE_MASTER_KEY),
|
||||
("NO_PROXY", "127.0.0.1,localhost"),
|
||||
("HTTP_PROXY", ""),
|
||||
("HTTPS_PROXY", ""),
|
||||
// Shorten the scanner cycle so the bucket's usage becomes authoritative
|
||||
// in seconds; both `set-bucket-quota` and quota admission block on it.
|
||||
("RUSTFS_SCANNER_CYCLE", "1"),
|
||||
("RUSTFS_SCANNER_START_DELAY_SECS", "0"),
|
||||
];
|
||||
env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
env.extend(replication_fast_env());
|
||||
env
|
||||
}
|
||||
|
||||
/// Restart `env` in place on the same data directory using an explicit binary.
|
||||
///
|
||||
/// [`RustFSTestEnvironment::restart_server_preserving_data`] always relaunches
|
||||
/// the workspace build, which is the upgrade direction only. The rollback
|
||||
/// scenario needs the reverse: stop the current build and bring the pinned
|
||||
/// previous release up on the metadata that build just wrote.
|
||||
async fn restart_from_binary(env: &mut RustFSTestEnvironment, binary: &Path, server_env: &[(&str, &str)]) -> TestResult {
|
||||
env.stop_server();
|
||||
env.start_rustfs_server_from_binary(binary, vec![], server_env).await
|
||||
}
|
||||
|
||||
async fn set_bucket_quota(env: &RustFSTestEnvironment, bucket: &str, quota_bytes: u64) -> TestResult {
|
||||
let path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||
let body = serde_json::json!({ "quota": quota_bytes, "quota_type": "HARD" }).to_string();
|
||||
let deadline = Instant::now() + QUOTA_READINESS_TIMEOUT;
|
||||
loop {
|
||||
let (status, response) =
|
||||
admin_request(&env.url, Method::PUT, &path, Some(body.clone()), &env.access_key, &env.secret_key).await?;
|
||||
if status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
if status != StatusCode::SERVICE_UNAVAILABLE || Instant::now() >= deadline {
|
||||
return Err(format!("setting the quota of {bucket} failed: {status} {response}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT into a quota-enabled bucket, riding out the post-start quota-admission
|
||||
/// warm-up described on [`QUOTA_ADMISSION_WARMUP_TIMEOUT`].
|
||||
///
|
||||
/// Only `ServiceUnavailable` is retried: any other failure, and a warm-up that
|
||||
/// never ends, is a genuine regression and surfaces as an error.
|
||||
async fn put_object_through_quota_warmup(client: &Client, bucket: &str, key: &str, body: &'static [u8]) -> TestResult {
|
||||
let deadline = Instant::now() + QUOTA_ADMISSION_WARMUP_TIMEOUT;
|
||||
loop {
|
||||
let result = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(body))
|
||||
.send()
|
||||
.await;
|
||||
let error = match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) => error,
|
||||
};
|
||||
let retryable = error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("ServiceUnavailable");
|
||||
if !retryable || Instant::now() >= deadline {
|
||||
return Err(format!("PUT {bucket}/{key} failed after the quota warm-up window: {error}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_bucket_quota(env: &RustFSTestEnvironment, bucket: &str) -> Result<Option<u64>, BoxError> {
|
||||
let path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
|
||||
if status != StatusCode::OK {
|
||||
return Err(format!("reading the quota of {bucket} failed: {status} {response}").into());
|
||||
}
|
||||
let quota: serde_json::Value = serde_json::from_str(&response)?;
|
||||
Ok(quota.get("quota").and_then(serde_json::Value::as_u64))
|
||||
}
|
||||
|
||||
/// `GET /rustfs/admin/v3/list-remote-targets?bucket=...`.
|
||||
///
|
||||
/// Returns an error for any non-200, because rustfs#7172 made this endpoint
|
||||
/// fail closed on a `bucket-targets.json` blob the running build cannot parse.
|
||||
/// An upgrade that misreads a blob written by the previous release therefore
|
||||
/// shows up here as an error, and a silently dropped target shows up as an
|
||||
/// empty list — the caller must distinguish the two.
|
||||
async fn list_remote_targets(env: &RustFSTestEnvironment, bucket: &str) -> Result<Vec<serde_json::Value>, BoxError> {
|
||||
let path = format!("/rustfs/admin/v3/list-remote-targets?bucket={}", urlencoding::encode(bucket));
|
||||
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
|
||||
if status != StatusCode::OK {
|
||||
return Err(format!("list-remote-targets for {bucket} failed: {status} {response}").into());
|
||||
}
|
||||
Ok(serde_json::from_str(&response)?)
|
||||
}
|
||||
|
||||
/// Assert that `bucket` still carries exactly the replication target `arn`.
|
||||
async fn assert_remote_target_preserved(env: &RustFSTestEnvironment, bucket: &str, arn: &str, context: &str) -> TestResult {
|
||||
let targets = list_remote_targets(env, bucket).await?;
|
||||
assert_eq!(
|
||||
targets.len(),
|
||||
1,
|
||||
"{context}: list-remote-targets must still report the single configured target, got {targets:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
targets[0].get("arn").and_then(serde_json::Value::as_str),
|
||||
Some(arn),
|
||||
"{context}: the target ARN changed across the restart: {targets:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure a replication target on `bucket` pointing at the in-process fake,
|
||||
/// then attach an enabled replication rule for it. Returns the target ARN.
|
||||
async fn configure_replication(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
target: &FakeS3Target,
|
||||
target_bucket: &str,
|
||||
) -> Result<String, BoxError> {
|
||||
let arn = set_replication_target_with_options(
|
||||
env,
|
||||
bucket,
|
||||
ReplicationTargetOptions {
|
||||
endpoint: &target.address(),
|
||||
access_key: FAKE_ACCESS_KEY,
|
||||
secret_key: FAKE_SECRET_KEY,
|
||||
target_bucket,
|
||||
secure: false,
|
||||
skip_tls_verify: false,
|
||||
ca_cert_pem: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
put_bucket_replication(env, bucket, &arn).await?;
|
||||
Ok(arn)
|
||||
}
|
||||
|
||||
async fn put_default_sse_s3_encryption(client: &Client, bucket: &str) -> TestResult {
|
||||
let configuration = ServerSideEncryptionConfiguration::builder()
|
||||
.rules(
|
||||
ServerSideEncryptionRule::builder()
|
||||
.apply_server_side_encryption_by_default(
|
||||
ServerSideEncryptionByDefault::builder()
|
||||
.sse_algorithm(ServerSideEncryption::Aes256)
|
||||
.build()?,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build()?;
|
||||
client
|
||||
.put_bucket_encryption()
|
||||
.bucket(bucket)
|
||||
.server_side_encryption_configuration(configuration)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_default_sse_s3_encryption(client: &Client, bucket: &str, context: &str) -> TestResult {
|
||||
let response = client.get_bucket_encryption().bucket(bucket).send().await?;
|
||||
let rules = response
|
||||
.server_side_encryption_configuration()
|
||||
.ok_or("GetBucketEncryption omitted the configuration")?
|
||||
.rules();
|
||||
assert_eq!(rules.len(), 1, "{context}: expected exactly one encryption rule, got {rules:?}");
|
||||
assert_eq!(
|
||||
rules[0]
|
||||
.apply_server_side_encryption_by_default()
|
||||
.map(ServerSideEncryptionByDefault::sse_algorithm),
|
||||
Some(&ServerSideEncryption::Aes256),
|
||||
"{context}: the default encryption algorithm changed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn put_bucket_tag(client: &Client, bucket: &str) -> TestResult {
|
||||
let tagging = Tagging::builder()
|
||||
.tag_set(Tag::builder().key(BUCKET_TAG_KEY).value(BUCKET_TAG_VALUE).build()?)
|
||||
.build()?;
|
||||
client.put_bucket_tagging().bucket(bucket).tagging(tagging).send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_bucket_tag(client: &Client, bucket: &str, context: &str) -> TestResult {
|
||||
let tags = client.get_bucket_tagging().bucket(bucket).send().await?;
|
||||
let tag_set = tags.tag_set();
|
||||
assert_eq!(tag_set.len(), 1, "{context}: expected exactly one bucket tag, got {tag_set:?}");
|
||||
assert_eq!(tag_set[0].key(), BUCKET_TAG_KEY, "{context}: bucket tag key changed");
|
||||
assert_eq!(tag_set[0].value(), BUCKET_TAG_VALUE, "{context}: bucket tag value changed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_versioning_enabled(client: &Client, bucket: &str, context: &str) -> TestResult {
|
||||
let versioning = client.get_bucket_versioning().bucket(bucket).send().await?;
|
||||
assert_eq!(
|
||||
versioning.status(),
|
||||
Some(&BucketVersioningStatus::Enabled),
|
||||
"{context}: versioning is no longer Enabled on {bucket}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bucket_policy_document(bucket: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Sid": "UpgradePublicRead",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": ["*"] },
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{bucket}/public/*")]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
/// `GET .../on-demand-migration/{bucket}/status`.
|
||||
///
|
||||
/// The migration module defaults on from rustfs#7089, so a bucket that never
|
||||
/// configured a source must still answer `configured: false` rather than
|
||||
/// engaging the migration path.
|
||||
async fn assert_migration_not_configured(env: &RustFSTestEnvironment, bucket: &str) -> TestResult {
|
||||
let path = format!("/rustfs/admin/v3/on-demand-migration/{bucket}/status");
|
||||
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"the migration status endpoint must answer for an unconfigured bucket: {status} {response}"
|
||||
);
|
||||
let body: serde_json::Value = serde_json::from_str(&response)?;
|
||||
assert_eq!(
|
||||
body.get("configured"),
|
||||
Some(&serde_json::Value::Bool(false)),
|
||||
"a bucket upgraded from the previous release must not look migration-configured: {body}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A GET for a key that was never written must be a plain `NoSuchKey`.
|
||||
///
|
||||
/// With the migration module on by default this is the cheap proof that an
|
||||
/// unconfigured bucket never consults a source: any migration engagement would
|
||||
/// surface as a different status or error code here.
|
||||
async fn assert_missing_key_is_no_such_key(client: &Client, bucket: &str, key: &str) -> TestResult {
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a key that was never written must not be readable");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"a missing key must stay a 404 on a bucket with no migration configuration"
|
||||
);
|
||||
assert_eq!(
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("NoSuchKey"),
|
||||
"a missing key must stay NoSuchKey on a bucket with no migration configuration"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bucket configuration written by the pinned previous release must survive an
|
||||
/// upgrade to the current build unchanged, and must keep working.
|
||||
///
|
||||
/// This pins the three on-disk surfaces the on-demand-migration series moved:
|
||||
///
|
||||
/// * `BucketMetadata` grew two msgpack keys (encoded map length 44 -> 46), so
|
||||
/// every configuration read below decodes a 44-key blob on 46-key code.
|
||||
/// * rustfs#7172 made an unreadable `bucket-targets.json` / encryption /
|
||||
/// public-access-block / quota blob "present but unreadable" instead of
|
||||
/// silently defaulting, and made `list-remote-targets` fail closed on it. A
|
||||
/// replication target configured by the old release must therefore still be
|
||||
/// *listed*, not dropped and not an error.
|
||||
/// * rustfs#7183 made the object write path refuse a PUT when the bucket's
|
||||
/// encryption configuration cannot be read, so a misparsed SSE config would
|
||||
/// turn every PUT to that bucket into a 500.
|
||||
///
|
||||
/// Not covered on purpose: on-demand-migration configuration itself, which the
|
||||
/// previous release has no public API for — the reverse direction is asserted
|
||||
/// instead (an upgraded bucket reports `configured: false`).
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||
async fn direct_upgrade_from_previous_release_preserves_bucket_configuration() -> TestResult {
|
||||
init_logging();
|
||||
let previous_binary = source_binary()?;
|
||||
|
||||
// In-process: the fake target outlives both server processes, so the
|
||||
// replication target stays reachable across the upgrade.
|
||||
let replication_target = FakeS3Target::start().await?;
|
||||
replication_target.create_bucket(CONFIG_REPLICA_BUCKET);
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let server_env = bucket_config_server_env();
|
||||
env.start_rustfs_server_from_binary(&previous_binary, vec![], &server_env)
|
||||
.await?;
|
||||
let old_client = env.create_s3_client();
|
||||
|
||||
env.create_test_bucket(CONFIG_PLAIN_BUCKET).await?;
|
||||
env.create_test_bucket(CONFIG_ENCRYPTED_BUCKET).await?;
|
||||
env.create_test_bucket(CONFIG_REPLICATED_BUCKET).await?;
|
||||
old_client
|
||||
.create_bucket()
|
||||
.bucket(CONFIG_LOCKED_BUCKET)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Plain bucket: policy, tags, lifecycle, quota.
|
||||
let policy = bucket_policy_document(CONFIG_PLAIN_BUCKET);
|
||||
old_client
|
||||
.put_bucket_policy()
|
||||
.bucket(CONFIG_PLAIN_BUCKET)
|
||||
.policy(policy.to_string())
|
||||
.send()
|
||||
.await?;
|
||||
put_bucket_tag(&old_client, CONFIG_PLAIN_BUCKET).await?;
|
||||
old_client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(CONFIG_PLAIN_BUCKET)
|
||||
.lifecycle_configuration(
|
||||
BucketLifecycleConfiguration::builder()
|
||||
.rules(
|
||||
LifecycleRule::builder()
|
||||
.id(LIFECYCLE_RULE_ID)
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.filter(LifecycleRuleFilter::builder().prefix(LIFECYCLE_PREFIX).build())
|
||||
.expiration(LifecycleExpiration::builder().days(LIFECYCLE_DAYS).build())
|
||||
.build()?,
|
||||
)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
set_bucket_quota(&env, CONFIG_PLAIN_BUCKET, BUCKET_QUOTA_BYTES).await?;
|
||||
|
||||
// Encrypted bucket: SSE-S3 default encryption plus a fully restrictive
|
||||
// public access block, both of which rustfs#7172 now fails closed on.
|
||||
put_default_sse_s3_encryption(&old_client, CONFIG_ENCRYPTED_BUCKET).await?;
|
||||
old_client
|
||||
.put_public_access_block()
|
||||
.bucket(CONFIG_ENCRYPTED_BUCKET)
|
||||
.public_access_block_configuration(
|
||||
PublicAccessBlockConfiguration::builder()
|
||||
.block_public_acls(true)
|
||||
.ignore_public_acls(true)
|
||||
.block_public_policy(true)
|
||||
.restrict_public_buckets(true)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Replicated bucket: versioning, a validated remote target, a rule.
|
||||
enable_versioning(&old_client, CONFIG_REPLICATED_BUCKET).await?;
|
||||
let target_arn = configure_replication(&env, CONFIG_REPLICATED_BUCKET, &replication_target, CONFIG_REPLICA_BUCKET).await?;
|
||||
assert_remote_target_preserved(&env, CONFIG_REPLICATED_BUCKET, &target_arn, "before the upgrade").await?;
|
||||
|
||||
// Object-lock bucket: a default GOVERNANCE retention on a fresh bucket.
|
||||
old_client
|
||||
.put_object_lock_configuration()
|
||||
.bucket(CONFIG_LOCKED_BUCKET)
|
||||
.object_lock_configuration(
|
||||
ObjectLockConfiguration::builder()
|
||||
.object_lock_enabled(ObjectLockEnabled::Enabled)
|
||||
.rule(
|
||||
ObjectLockRule::builder()
|
||||
.default_retention(
|
||||
DefaultRetention::builder()
|
||||
.mode(ObjectLockRetentionMode::Governance)
|
||||
.days(OBJECT_LOCK_DAYS)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let plain_key = "plain/written-by-previous";
|
||||
let plain_bytes = b"plain object written by the previous RustFS release";
|
||||
put_object_through_quota_warmup(&old_client, CONFIG_PLAIN_BUCKET, plain_key, plain_bytes).await?;
|
||||
|
||||
let encrypted_key = "encrypted/written-by-previous";
|
||||
let encrypted_bytes = b"default-encrypted object written by the previous RustFS release";
|
||||
old_client
|
||||
.put_object()
|
||||
.bucket(CONFIG_ENCRYPTED_BUCKET)
|
||||
.key(encrypted_key)
|
||||
.body(ByteStream::from_static(encrypted_bytes))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
read_object(&old_client, CONFIG_ENCRYPTED_BUCKET, encrypted_key, None)
|
||||
.await?
|
||||
.0,
|
||||
Some(ServerSideEncryption::Aes256),
|
||||
"the previous release must apply the bucket default encryption it just accepted"
|
||||
);
|
||||
|
||||
// The multipart object lives in the default-encrypted bucket so the
|
||||
// upgraded build has to reassemble parts *and* re-derive the object key.
|
||||
let multipart_key = "encrypted/multipart-written-by-previous";
|
||||
let multipart_parts = vec![vec![b'm'; 5 * 1024 * 1024], b"final multipart bytes".to_vec()];
|
||||
let multipart_bytes = multipart_parts.concat();
|
||||
write_multipart(&old_client, CONFIG_ENCRYPTED_BUCKET, multipart_key, &multipart_parts).await?;
|
||||
|
||||
let versioned_key = "versioned/written-by-previous";
|
||||
let versioned_bytes = b"versioned object written by the previous RustFS release";
|
||||
let versioned_id = old_client
|
||||
.put_object()
|
||||
.bucket(CONFIG_REPLICATED_BUCKET)
|
||||
.key(versioned_key)
|
||||
.body(ByteStream::from_static(versioned_bytes))
|
||||
.send()
|
||||
.await?
|
||||
.version_id()
|
||||
.ok_or("versioned PUT omitted version ID")?
|
||||
.to_string();
|
||||
|
||||
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||
let new_client = env.create_s3_client();
|
||||
|
||||
// Every configuration must read back unchanged on the upgraded build.
|
||||
let upgraded_policy = new_client.get_bucket_policy().bucket(CONFIG_PLAIN_BUCKET).send().await?;
|
||||
let upgraded_policy: serde_json::Value =
|
||||
serde_json::from_str(upgraded_policy.policy().ok_or("GetBucketPolicy omitted the document")?)?;
|
||||
assert_eq!(upgraded_policy, policy, "the bucket policy changed across the upgrade");
|
||||
assert_bucket_tag(&new_client, CONFIG_PLAIN_BUCKET, "after the upgrade").await?;
|
||||
|
||||
let lifecycle = new_client
|
||||
.get_bucket_lifecycle_configuration()
|
||||
.bucket(CONFIG_PLAIN_BUCKET)
|
||||
.send()
|
||||
.await?;
|
||||
let rules = lifecycle.rules();
|
||||
assert_eq!(rules.len(), 1, "the lifecycle rule count changed across the upgrade: {rules:?}");
|
||||
assert_eq!(rules[0].id(), Some(LIFECYCLE_RULE_ID));
|
||||
assert_eq!(rules[0].status(), &ExpirationStatus::Enabled);
|
||||
assert_eq!(
|
||||
rules[0].expiration().and_then(LifecycleExpiration::days),
|
||||
Some(LIFECYCLE_DAYS),
|
||||
"the lifecycle expiration changed across the upgrade"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
get_bucket_quota(&env, CONFIG_PLAIN_BUCKET).await?,
|
||||
Some(BUCKET_QUOTA_BYTES),
|
||||
"the bucket quota changed across the upgrade"
|
||||
);
|
||||
|
||||
assert_default_sse_s3_encryption(&new_client, CONFIG_ENCRYPTED_BUCKET, "after the upgrade").await?;
|
||||
let public_access_block = new_client
|
||||
.get_public_access_block()
|
||||
.bucket(CONFIG_ENCRYPTED_BUCKET)
|
||||
.send()
|
||||
.await?;
|
||||
let public_access_block = public_access_block
|
||||
.public_access_block_configuration()
|
||||
.ok_or("GetPublicAccessBlock omitted the configuration")?;
|
||||
assert_eq!(public_access_block.block_public_acls(), Some(true));
|
||||
assert_eq!(public_access_block.ignore_public_acls(), Some(true));
|
||||
assert_eq!(public_access_block.block_public_policy(), Some(true));
|
||||
assert_eq!(public_access_block.restrict_public_buckets(), Some(true));
|
||||
|
||||
assert_versioning_enabled(&new_client, CONFIG_REPLICATED_BUCKET, "after the upgrade").await?;
|
||||
// rustfs#7172: neither an empty list nor an error is acceptable here.
|
||||
assert_remote_target_preserved(&env, CONFIG_REPLICATED_BUCKET, &target_arn, "after the upgrade").await?;
|
||||
let replication = new_client
|
||||
.get_bucket_replication()
|
||||
.bucket(CONFIG_REPLICATED_BUCKET)
|
||||
.send()
|
||||
.await?;
|
||||
let replication_rules = replication
|
||||
.replication_configuration()
|
||||
.ok_or("GetBucketReplication omitted the configuration")?
|
||||
.rules();
|
||||
assert_eq!(
|
||||
replication_rules.len(),
|
||||
1,
|
||||
"the replication rule count changed across the upgrade: {replication_rules:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
replication_rules[0].destination().map(|destination| destination.bucket()),
|
||||
Some(target_arn.as_str()),
|
||||
"the replication rule no longer points at the configured target"
|
||||
);
|
||||
|
||||
let object_lock = new_client
|
||||
.get_object_lock_configuration()
|
||||
.bucket(CONFIG_LOCKED_BUCKET)
|
||||
.send()
|
||||
.await?;
|
||||
let object_lock = object_lock
|
||||
.object_lock_configuration()
|
||||
.ok_or("GetObjectLockConfiguration omitted the configuration")?;
|
||||
assert_eq!(object_lock.object_lock_enabled(), Some(&ObjectLockEnabled::Enabled));
|
||||
let retention = object_lock
|
||||
.rule()
|
||||
.and_then(ObjectLockRule::default_retention)
|
||||
.ok_or("the object lock configuration lost its default retention")?;
|
||||
assert_eq!(retention.mode(), Some(&ObjectLockRetentionMode::Governance));
|
||||
assert_eq!(retention.days(), Some(OBJECT_LOCK_DAYS));
|
||||
|
||||
// rustfs#7183: a PUT into the default-encrypted bucket must still succeed
|
||||
// and still come back encrypted.
|
||||
let post_upgrade_encrypted_key = "encrypted/written-after-upgrade";
|
||||
let post_upgrade_encrypted_bytes = b"default-encrypted object written by the current RustFS build";
|
||||
new_client
|
||||
.put_object()
|
||||
.bucket(CONFIG_ENCRYPTED_BUCKET)
|
||||
.key(post_upgrade_encrypted_key)
|
||||
.body(ByteStream::from_static(post_upgrade_encrypted_bytes))
|
||||
.send()
|
||||
.await?;
|
||||
let (encryption, body) = read_object(&new_client, CONFIG_ENCRYPTED_BUCKET, post_upgrade_encrypted_key, None).await?;
|
||||
assert_eq!(
|
||||
encryption,
|
||||
Some(ServerSideEncryption::Aes256),
|
||||
"a PUT after the upgrade lost the bucket default encryption"
|
||||
);
|
||||
assert_eq!(body, post_upgrade_encrypted_bytes);
|
||||
|
||||
let post_upgrade_plain_key = "plain/written-after-upgrade";
|
||||
let post_upgrade_plain_bytes = b"plain object written by the current RustFS build";
|
||||
put_object_through_quota_warmup(&new_client, CONFIG_PLAIN_BUCKET, post_upgrade_plain_key, post_upgrade_plain_bytes).await?;
|
||||
let (encryption, body) = read_object(&new_client, CONFIG_PLAIN_BUCKET, post_upgrade_plain_key, None).await?;
|
||||
assert_eq!(encryption, None, "a bucket without default encryption must not encrypt a PUT");
|
||||
assert_eq!(body, post_upgrade_plain_bytes);
|
||||
|
||||
// Every object written by the previous release reads back byte-identical.
|
||||
assert_eq!(read_object(&new_client, CONFIG_PLAIN_BUCKET, plain_key, None).await?.1, plain_bytes);
|
||||
let (encryption, body) = read_object(&new_client, CONFIG_ENCRYPTED_BUCKET, encrypted_key, None).await?;
|
||||
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||
assert_eq!(body, encrypted_bytes);
|
||||
let (encryption, body) = read_object(&new_client, CONFIG_ENCRYPTED_BUCKET, multipart_key, None).await?;
|
||||
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||
assert_eq!(body, multipart_bytes, "the multipart object did not survive the upgrade");
|
||||
assert_eq!(
|
||||
read_object(&new_client, CONFIG_REPLICATED_BUCKET, versioned_key, Some(&versioned_id))
|
||||
.await?
|
||||
.1,
|
||||
versioned_bytes
|
||||
);
|
||||
|
||||
// rustfs#7089: the migration module is on by default, but a bucket that
|
||||
// never configured a source behaves exactly as before.
|
||||
assert_migration_not_configured(&env, CONFIG_PLAIN_BUCKET).await?;
|
||||
assert_missing_key_is_no_such_key(&new_client, CONFIG_PLAIN_BUCKET, "plain/never-written").await?;
|
||||
|
||||
replication_target.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rolling back to the pinned previous release must still read the bucket
|
||||
/// metadata the current build wrote.
|
||||
///
|
||||
/// This is the other half of the `BucketMetadata` 44 -> 46 key change: the
|
||||
/// current build writes a 46-key msgpack map with `OnDemandMigrationConfigJSON`
|
||||
/// and `OnDemandMigrationConfigUpdatedAt`, and the previous release's decoder
|
||||
/// has to skip those two unknown keys instead of failing the whole blob. If it
|
||||
/// did not, every configuration read below would come back empty or error and
|
||||
/// the rollback would silently discard the bucket's configuration.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||
async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult {
|
||||
init_logging();
|
||||
let previous_binary = source_binary()?;
|
||||
|
||||
let replication_target = FakeS3Target::start().await?;
|
||||
replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET);
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let server_env = bucket_config_server_env();
|
||||
env.start_rustfs_server_with_env(vec![], &server_env).await?;
|
||||
let new_client = env.create_s3_client();
|
||||
|
||||
env.create_test_bucket(ROLLBACK_BUCKET).await?;
|
||||
enable_versioning(&new_client, ROLLBACK_BUCKET).await?;
|
||||
put_default_sse_s3_encryption(&new_client, ROLLBACK_BUCKET).await?;
|
||||
put_bucket_tag(&new_client, ROLLBACK_BUCKET).await?;
|
||||
let target_arn = configure_replication(&env, ROLLBACK_BUCKET, &replication_target, ROLLBACK_REPLICA_BUCKET).await?;
|
||||
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "before the rollback").await?;
|
||||
|
||||
let single_key = "rollback/single";
|
||||
let single_bytes = b"single-part object written by the current RustFS build";
|
||||
let single_version = new_client
|
||||
.put_object()
|
||||
.bucket(ROLLBACK_BUCKET)
|
||||
.key(single_key)
|
||||
.body(ByteStream::from_static(single_bytes))
|
||||
.send()
|
||||
.await?
|
||||
.version_id()
|
||||
.ok_or("versioned PUT omitted version ID")?
|
||||
.to_string();
|
||||
|
||||
let multipart_key = "rollback/multipart";
|
||||
let multipart_parts = vec![vec![b'r'; 5 * 1024 * 1024], b"final rollback bytes".to_vec()];
|
||||
let multipart_bytes = multipart_parts.concat();
|
||||
write_multipart(&new_client, ROLLBACK_BUCKET, multipart_key, &multipart_parts).await?;
|
||||
|
||||
restart_from_binary(&mut env, &previous_binary, &server_env).await?;
|
||||
let old_client = env.create_s3_client();
|
||||
|
||||
assert_versioning_enabled(&old_client, ROLLBACK_BUCKET, "after the rollback").await?;
|
||||
assert_default_sse_s3_encryption(&old_client, ROLLBACK_BUCKET, "after the rollback").await?;
|
||||
assert_bucket_tag(&old_client, ROLLBACK_BUCKET, "after the rollback").await?;
|
||||
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "after the rollback").await?;
|
||||
|
||||
let (encryption, body) = read_object(&old_client, ROLLBACK_BUCKET, single_key, Some(&single_version)).await?;
|
||||
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||
assert_eq!(body, single_bytes);
|
||||
let (encryption, body) = read_object(&old_client, ROLLBACK_BUCKET, multipart_key, None).await?;
|
||||
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||
assert_eq!(body, multipart_bytes, "the multipart object did not survive the rollback");
|
||||
|
||||
// A PUT on the rolled-back release must still honour the encryption
|
||||
// configuration it decoded out of the current build's metadata blob.
|
||||
let post_rollback_key = "rollback/written-after-rollback";
|
||||
let post_rollback_bytes = b"object written by the previous RustFS release after the rollback";
|
||||
old_client
|
||||
.put_object()
|
||||
.bucket(ROLLBACK_BUCKET)
|
||||
.key(post_rollback_key)
|
||||
.body(ByteStream::from_static(post_rollback_bytes))
|
||||
.send()
|
||||
.await?;
|
||||
let (encryption, body) = read_object(&old_client, ROLLBACK_BUCKET, post_rollback_key, None).await?;
|
||||
assert_eq!(
|
||||
encryption,
|
||||
Some(ServerSideEncryption::Aes256),
|
||||
"the rolled-back release lost the bucket default encryption"
|
||||
);
|
||||
assert_eq!(body, post_rollback_bytes);
|
||||
|
||||
replication_target.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
|
||||
# Compiles the controlled list-objects namespace-journal chaos injector into a
|
||||
# production binary (it is always available to tests). Off by default so the
|
||||
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
|
||||
@@ -212,8 +213,8 @@ aws-smithy-runtime-api = { workspace = true, features = ["http-1x"] }
|
||||
parking_lot = { workspace = true }
|
||||
base64-simd.workspace = true
|
||||
serde_urlencoded.workspace = true
|
||||
google-cloud-storage = { workspace = true }
|
||||
google-cloud-auth = { workspace = true }
|
||||
google-cloud-storage = { workspace = true, optional = true }
|
||||
google-cloud-auth = { workspace = true, optional = true }
|
||||
faster-hex = { workspace = true }
|
||||
ratelimit = { workspace = true }
|
||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
|
||||
|
||||
@@ -146,68 +146,23 @@ pub mod bucket {
|
||||
};
|
||||
}
|
||||
|
||||
pub mod on_demand_migration {
|
||||
pub use crate::bucket::on_demand_migration::{
|
||||
ApplyOutcome, BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION,
|
||||
Breaker, BreakerState, BreakerTransition, BreakerVerdict, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, GaugeGuard,
|
||||
LastSourceError, LatencyBucketSnapshot, NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache, OdmBucketSnapshot, OdmLookup,
|
||||
OdmOp, OdmOutcome, OdmStateError, OdmStats, OdmStatsSnapshot, OnDemandMigrationSys, PullError, PullFailureReason,
|
||||
PullFollower, PullLeader, PullOutcome, PullPath, PullResult, PullSlot, SOURCE_LATENCY_BUCKET_BOUNDS_MS,
|
||||
SourceLatencySnapshot, source_client_spec,
|
||||
};
|
||||
pub use crate::bucket::on_demand_migration::{
|
||||
ConfigPublishHook, FilterConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_HOOK, ON_DEMAND_MIGRATION_CONFIG_VERSION,
|
||||
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy,
|
||||
SourceConfig, SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
|
||||
};
|
||||
pub use crate::bucket::on_demand_migration::{
|
||||
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS,
|
||||
PullCompletion, PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody,
|
||||
WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
|
||||
idle_guarded_body,
|
||||
};
|
||||
pub use crate::bucket::on_demand_migration::{
|
||||
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListPageError, ListThroughCursor, ListThroughMerger,
|
||||
ListThroughToken, ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MAX_LIST_NO_PROGRESS_PAGES, MergeOutcome,
|
||||
MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT, SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter,
|
||||
decode_continuation_token, source_list_plan,
|
||||
};
|
||||
pub mod backfill {
|
||||
pub use crate::bucket::on_demand_migration::backfill::{
|
||||
BACKFILL_CHECKPOINT_FILE, BACKFILL_CHECKPOINT_FORMAT_VERSION, BACKFILL_FAILED_KEYS_CAPACITY, BACKFILL_LEASE,
|
||||
BACKFILL_LEASE_LOCK_PREFIX, BACKFILL_LIST_PAGE_SIZE, BACKFILL_RECOVERY_INTERVAL, BACKFILL_SAVE_EVERY_KEYS,
|
||||
BACKFILL_SAVE_INTERVAL, BackfillCheckpoint, BackfillContext, BackfillContextFactory, BackfillError,
|
||||
BackfillLastError, BackfillOwner, BackfillRecoveryStats, BackfillRequest, BackfillRunner, BackfillState,
|
||||
BucketBackfillContext, LocalBackfillObject, PriorityPullPermits, PullPermit, PullPriority, SkipExisting,
|
||||
StoredCheckpoint, SysBackfillContexts, global_backfill_runner, install_global_backfill_runner, key_hash,
|
||||
read_checkpoint, run_backfill_recovery_loop, spawn_backfill_recovery_loop,
|
||||
};
|
||||
}
|
||||
pub mod source_client {
|
||||
pub use crate::bucket::on_demand_migration::source_client::{
|
||||
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceListRequest, SourceObject, SourcePage,
|
||||
SourceProbe, SourceProvider, SourceSse, SourceTimeouts, USER_AGENT_SUFFIX, is_multipart_etag, range_header_value,
|
||||
resolve_path_style,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub mod metadata_sys {
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
|
||||
pub use crate::bucket::metadata_sys::{
|
||||
BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
||||
BUCKET_CONFIG_PUBLISH_HOOK, BucketConfigPublishHook, BucketMetadataMutationGuard, BucketMetadataSys,
|
||||
ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
||||
acquire_bucket_metadata_transaction_lock_for_incarnation, acquire_scanner_bucket_incarnation_fence,
|
||||
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, delete_under_transaction_lock, get,
|
||||
get_accelerate_config, get_bucket_policy, get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk,
|
||||
get_cors_config, get_durability_config, get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config,
|
||||
get_notification_config, get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config,
|
||||
get_public_access_block_config, get_quota_config, get_replication_config, get_request_payment_config, get_sse_config,
|
||||
get_tagging_config, get_versioning_config, get_website_config, init_bucket_metadata_sys, list_bucket_targets,
|
||||
reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata, update,
|
||||
update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation, update_quota_if_incarnation,
|
||||
update_under_transaction_lock,
|
||||
get_on_demand_migration_config_in, get_public_access_block_config, get_quota_config, get_replication_config,
|
||||
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
|
||||
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
|
||||
update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
||||
update_quota_if_incarnation, update_under_transaction_lock,
|
||||
};
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::bucket::metadata_sys::{ConfigWriteLockProbe, test_support};
|
||||
}
|
||||
|
||||
pub mod migration {
|
||||
@@ -250,7 +205,7 @@ pub mod bucket {
|
||||
pub mod remote_s3_client {
|
||||
pub use crate::bucket::remote_s3_client::{
|
||||
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_client,
|
||||
validate_remote_endpoint,
|
||||
build_remote_s3_config, validate_remote_endpoint, validate_target_ca_pem,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -496,9 +451,9 @@ pub mod object {
|
||||
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission,
|
||||
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
||||
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitStartError,
|
||||
ScannerPublicationCommitState, StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook,
|
||||
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook,
|
||||
unregister_object_mutation_hook,
|
||||
ScannerPublicationCommitState, StreamConsumer, WriteCompletion, get_object_body_cache_plaintext_len,
|
||||
lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
|
||||
unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||
};
|
||||
pub use crate::store::{
|
||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||
@@ -562,6 +517,12 @@ pub mod set_disk {
|
||||
pub mod test_util {
|
||||
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
|
||||
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
|
||||
/// Keep a namespace commit pending until the returned owner is dropped.
|
||||
#[must_use]
|
||||
pub fn hold_namespace_commit(store: &crate::store::ECStore) -> impl Send + Sync {
|
||||
store.ctx.begin_namespace_commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -489,28 +489,17 @@ impl BucketMetadata {
|
||||
!self.bucket_targets_config_json.is_empty() && self.bucket_target_config.is_none()
|
||||
}
|
||||
|
||||
/// Parsed per-bucket durability override, if a valid one is stored.
|
||||
///
|
||||
/// Absent/empty/unparsable payloads all mean "no override" (the bucket
|
||||
/// follows the global durability mode); a parse failure is logged so a
|
||||
/// corrupted entry cannot silently change fsync behavior.
|
||||
/// Parsed on-demand migration config, if one is stored.
|
||||
///
|
||||
/// `Ok(None)` means no config (absent or cleared). A stored payload that
|
||||
/// does not parse is an error, never a default: the runtime must not
|
||||
/// pull from a source it cannot describe.
|
||||
pub fn on_demand_migration_config(
|
||||
&self,
|
||||
) -> std::result::Result<
|
||||
Option<super::on_demand_migration::OnDemandMigrationConfig>,
|
||||
super::on_demand_migration::OnDemandMigrationConfigError,
|
||||
> {
|
||||
if self.on_demand_migration_config_json.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
super::on_demand_migration::OnDemandMigrationConfig::from_json(&self.on_demand_migration_config_json).map(Some)
|
||||
/// Opaque application-owned configuration with its persisted update time.
|
||||
/// Empty bytes mean absent or cleared; decoding belongs to the consumer.
|
||||
pub fn on_demand_migration_config(&self) -> Option<(&[u8], OffsetDateTime)> {
|
||||
(!self.on_demand_migration_config_json.is_empty()).then_some((
|
||||
self.on_demand_migration_config_json.as_slice(),
|
||||
self.on_demand_migration_config_updated_at,
|
||||
))
|
||||
}
|
||||
|
||||
/// Parsed per-bucket durability override, if a valid one is stored.
|
||||
/// Invalid payloads follow the global mode after logging a parse failure.
|
||||
pub fn durability_config(&self) -> Option<super::durability::BucketDurabilityConfig> {
|
||||
if self.durability_config_json.is_empty() {
|
||||
return None;
|
||||
@@ -916,13 +905,6 @@ impl BucketMetadata {
|
||||
self.durability_config_updated_at = updated;
|
||||
}
|
||||
BUCKET_ON_DEMAND_MIGRATION_CONFIG => {
|
||||
// Structural check only (shape, unknown fields); the
|
||||
// deployment-relative rules run in the admin handler with a
|
||||
// `ValidationContext`. A blob this build cannot read must not
|
||||
// be persisted for every later reader to trip over.
|
||||
if !data.is_empty() {
|
||||
super::on_demand_migration::OnDemandMigrationConfig::from_json(&data).map_err(Error::other)?;
|
||||
}
|
||||
self.on_demand_migration_config_json = data;
|
||||
self.on_demand_migration_config_updated_at = updated;
|
||||
}
|
||||
@@ -1978,51 +1960,30 @@ mod test {
|
||||
|
||||
const ODM_JSON: &[u8] = br#"{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
|
||||
|
||||
/// rustfs/backlog#2148: the on-demand migration config is a RustFS
|
||||
/// extension entry that round-trips through `update_config` and the
|
||||
/// msgpack codec, clears on delete, and never parses corruption into a
|
||||
/// default.
|
||||
/// The metadata codec preserves application-owned bytes and timestamps.
|
||||
#[test]
|
||||
fn on_demand_migration_config_round_trips_and_tracks_updates() {
|
||||
use crate::bucket::on_demand_migration::{OnDemandMigrationConfig, OnDemandMigrationConfigError};
|
||||
|
||||
let mut bm = BucketMetadata::new("odm-bucket");
|
||||
assert_eq!(bm.on_demand_migration_config(), Ok(None), "fresh metadata carries no config");
|
||||
|
||||
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
|
||||
assert_eq!(bm.on_demand_migration_config(), None, "fresh metadata carries no config");
|
||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||
.expect("valid config is accepted");
|
||||
assert_ne!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
||||
assert_eq!(bm.on_demand_migration_config(), Ok(Some(expected.clone())));
|
||||
|
||||
.expect("opaque config is accepted");
|
||||
let stamped = bm.on_demand_migration_config_updated_at;
|
||||
assert_ne!(stamped, OffsetDateTime::UNIX_EPOCH);
|
||||
assert_eq!(bm.on_demand_migration_config(), Some((ODM_JSON, stamped)));
|
||||
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
|
||||
assert_eq!(back.on_demand_migration_config_json, bm.on_demand_migration_config_json);
|
||||
assert_eq!(
|
||||
back.on_demand_migration_config_updated_at.unix_timestamp(),
|
||||
bm.on_demand_migration_config_updated_at.unix_timestamp()
|
||||
);
|
||||
assert_eq!(back.on_demand_migration_config(), Ok(Some(expected)));
|
||||
|
||||
// A blob this build cannot read is rejected at the write boundary
|
||||
// rather than persisted for every reader to trip over.
|
||||
let before = bm.on_demand_migration_config_json.clone();
|
||||
assert!(
|
||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec())
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(bm.on_demand_migration_config_json, before, "a rejected update leaves the blob untouched");
|
||||
|
||||
// Delete clears the entry.
|
||||
let stamped = bm.on_demand_migration_config_updated_at;
|
||||
assert_eq!(back.on_demand_migration_config_updated_at.unix_timestamp(), stamped.unix_timestamp());
|
||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, Vec::new()).unwrap();
|
||||
assert!(bm.on_demand_migration_config_json.is_empty());
|
||||
assert_eq!(bm.on_demand_migration_config(), Ok(None));
|
||||
assert_eq!(bm.on_demand_migration_config(), None);
|
||||
assert!(bm.on_demand_migration_config_updated_at >= stamped);
|
||||
|
||||
// Corruption that bypassed `update_config` (disk, another writer)
|
||||
// is a typed error, never a default.
|
||||
bm.on_demand_migration_config_json = b"not-json".to_vec();
|
||||
assert!(matches!(bm.on_demand_migration_config(), Err(OnDemandMigrationConfigError::Malformed(_))));
|
||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, b"not-json".to_vec())
|
||||
.unwrap();
|
||||
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
back.on_demand_migration_config_json, b"not-json",
|
||||
"metadata must not reinterpret application bytes"
|
||||
);
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2148: a `.metadata.bin` written before the on-demand
|
||||
@@ -2034,7 +1995,7 @@ mod test {
|
||||
let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata");
|
||||
assert!(bm.on_demand_migration_config_json.is_empty());
|
||||
assert_eq!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
||||
assert_eq!(bm.on_demand_migration_config(), Ok(None));
|
||||
assert_eq!(bm.on_demand_migration_config(), None);
|
||||
|
||||
bm.default_timestamps();
|
||||
assert_ne!(bm.created, OffsetDateTime::UNIX_EPOCH, "fixture must carry a real creation time");
|
||||
|
||||
@@ -19,7 +19,6 @@ use super::quota::BucketQuota;
|
||||
use super::target::BucketTargets;
|
||||
use crate::bucket::bucket_target_sys::BucketTargetSys;
|
||||
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
|
||||
use crate::bucket::on_demand_migration::{ON_DEMAND_MIGRATION_CONFIG_HOOK, OnDemandMigrationConfig};
|
||||
use crate::bucket::utils::is_meta_bucketname;
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result, is_err_bucket_not_found, is_err_strict_volume_not_found};
|
||||
@@ -49,6 +48,11 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Opaque bucket configuration notifications for application-owned services.
|
||||
/// `None` withdraws a configuration; consumers validate nonempty bytes.
|
||||
pub type BucketConfigPublishHook = Box<dyn Fn(&str, &str, Option<(&[u8], OffsetDateTime, Uuid)>) + Send + Sync>;
|
||||
pub static BUCKET_CONFIG_PUBLISH_HOOK: std::sync::OnceLock<BucketConfigPublishHook> = std::sync::OnceLock::new();
|
||||
|
||||
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
@@ -395,39 +399,21 @@ fn clear_bucket_durability(bucket: &str) {
|
||||
crate::disk::local::bucket_durability::set(bucket, None);
|
||||
}
|
||||
|
||||
/// Publish the bucket's on-demand migration config (or its absence) to the
|
||||
/// runtime registered in `ON_DEMAND_MIGRATION_CONFIG_HOOK`.
|
||||
///
|
||||
/// Called from the same five cache-install paths as
|
||||
/// [`sync_bucket_durability`]. A stored payload this build cannot parse is
|
||||
/// published as `None`: the runtime must stop pulling for that bucket rather
|
||||
/// than keep an older config or guess.
|
||||
/// Publish application-owned bytes on every cache install path.
|
||||
fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) {
|
||||
let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() else {
|
||||
return;
|
||||
};
|
||||
match bm.on_demand_migration_config() {
|
||||
Ok(config) => hook(bucket, config.as_ref()),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event = "bucket_metadata_parse_failed",
|
||||
component = "ecstore",
|
||||
subsystem = "bucket_metadata",
|
||||
bucket = %bucket,
|
||||
config = "on_demand_migration",
|
||||
error = %err,
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
hook(bucket, None);
|
||||
}
|
||||
if let Some(hook) = BUCKET_CONFIG_PUBLISH_HOOK.get() {
|
||||
hook(
|
||||
bucket,
|
||||
super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG,
|
||||
bm.on_demand_migration_config()
|
||||
.map(|(bytes, stamp)| (bytes, stamp, bm.bucket_incarnation_id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Withdraw a bucket's on-demand migration config when its metadata leaves
|
||||
/// the cache.
|
||||
fn clear_on_demand_migration(bucket: &str) {
|
||||
if let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() {
|
||||
hook(bucket, None);
|
||||
if let Some(hook) = BUCKET_CONFIG_PUBLISH_HOOK.get() {
|
||||
hook(bucket, super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1049,15 +1035,21 @@ pub async fn get_durability_config(
|
||||
}
|
||||
|
||||
/// The bucket's on-demand migration config with its update time, or
|
||||
/// `Ok(None)` when the bucket has none. A stored payload that does not parse
|
||||
/// is a typed error (`OnDemandMigrationConfigError` inside `Error::Io`).
|
||||
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
|
||||
/// `Ok(None)` when the bucket has none. Bytes are opaque to the metadata owner.
|
||||
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
|
||||
bucket_meta_sys.get_on_demand_migration_config(bucket).await
|
||||
}
|
||||
|
||||
/// Resolve opaque configuration from the store's own metadata system.
|
||||
pub async fn get_on_demand_migration_config_in(api: &ECStore, bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
|
||||
let sys = bucket_metadata_sys_of(&api.ctx)?;
|
||||
let lock = sys.read().await;
|
||||
lock.get_on_demand_migration_config(bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
@@ -2579,29 +2571,27 @@ impl BucketMetadataSys {
|
||||
}
|
||||
|
||||
/// See [`get_on_demand_migration_config`].
|
||||
pub async fn get_on_demand_migration_config(
|
||||
&self,
|
||||
bucket: &str,
|
||||
) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
|
||||
pub async fn get_on_demand_migration_config(&self, bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
|
||||
let (bm, _) = self.get_config(bucket).await?;
|
||||
|
||||
let config = bm.on_demand_migration_config().map_err(Error::other)?;
|
||||
Ok(config.map(|config| (config, bm.on_demand_migration_config_updated_at)))
|
||||
Ok(bm
|
||||
.on_demand_migration_config()
|
||||
.map(|(bytes, updated_at)| (bytes.to_vec(), updated_at)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only fixture shared with sibling modules (e.g. the quota checker
|
||||
/// tests): a 4-disk `ECStore` on an isolated instance context, so tests
|
||||
/// exercising the metadata system never touch ambient process state.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub mod test_support {
|
||||
use super::*;
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::store::init_local_disks_with_instance_ctx;
|
||||
|
||||
pub(crate) async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||
pub async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||
let mut dirs = Vec::with_capacity(4);
|
||||
let mut endpoints = Vec::with_capacity(4);
|
||||
for disk_idx in 0..4 {
|
||||
@@ -4385,19 +4375,26 @@ mod tests {
|
||||
|
||||
const ODM_JSON: &[u8] = br#"{"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
|
||||
|
||||
type RecordedOdmConfig = Option<(Vec<u8>, OffsetDateTime, Uuid)>;
|
||||
type RecordedOdmHookCall = (String, RecordedOdmConfig);
|
||||
|
||||
/// Every `(bucket, config)` the recording hook has seen. Tests filter by
|
||||
/// their own bucket name; the hook is process-wide and set once.
|
||||
static ODM_HOOK_CALLS: std::sync::Mutex<Vec<(String, Option<OnDemandMigrationConfig>)>> = std::sync::Mutex::new(Vec::new());
|
||||
static ODM_HOOK_CALLS: std::sync::Mutex<Vec<RecordedOdmHookCall>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn install_recording_odm_hook() {
|
||||
ON_DEMAND_MIGRATION_CONFIG_HOOK.get_or_init(|| {
|
||||
Box::new(|bucket, config| {
|
||||
ODM_HOOK_CALLS.lock().unwrap().push((bucket.to_string(), config.cloned()));
|
||||
BUCKET_CONFIG_PUBLISH_HOOK.get_or_init(|| {
|
||||
Box::new(|bucket, config_file, config| {
|
||||
assert_eq!(config_file, super::super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG);
|
||||
ODM_HOOK_CALLS.lock().unwrap().push((
|
||||
bucket.to_string(),
|
||||
config.map(|(bytes, stamp, incarnation)| (bytes.to_vec(), stamp, incarnation)),
|
||||
));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn odm_hook_calls(bucket: &str) -> Vec<Option<OnDemandMigrationConfig>> {
|
||||
fn odm_hook_calls(bucket: &str) -> Vec<RecordedOdmConfig> {
|
||||
ODM_HOOK_CALLS
|
||||
.lock()
|
||||
.unwrap()
|
||||
@@ -4407,54 +4404,6 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2148: the accessor reports absence as `Ok(None)` and a
|
||||
/// stored payload it cannot parse as a typed error, never as a default
|
||||
/// and never as `ConfigNotFound`.
|
||||
#[tokio::test]
|
||||
async fn get_on_demand_migration_config_distinguishes_absent_from_corrupt() {
|
||||
use crate::bucket::on_demand_migration::OnDemandMigrationConfigError;
|
||||
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = BucketMetadataSys::new(ecstore);
|
||||
let bucket = "odm-accessor";
|
||||
|
||||
sys.set(bucket.to_string(), Arc::new(BucketMetadata::new(bucket))).await;
|
||||
assert_eq!(sys.get_on_demand_migration_config(bucket).await.unwrap(), None);
|
||||
|
||||
let mut corrupt = BucketMetadata::new(bucket);
|
||||
corrupt.on_demand_migration_config_json = br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec();
|
||||
sys.set(bucket.to_string(), Arc::new(corrupt)).await;
|
||||
let err = sys
|
||||
.get_on_demand_migration_config(bucket)
|
||||
.await
|
||||
.expect_err("corrupt config must not read as a default");
|
||||
assert_ne!(err, Error::ConfigNotFound, "corruption must not be reported as absence");
|
||||
let typed = match &err {
|
||||
Error::Io(io) => io
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<OnDemandMigrationConfigError>()),
|
||||
_ => None,
|
||||
};
|
||||
assert!(
|
||||
matches!(typed, Some(OnDemandMigrationConfigError::Malformed(_))),
|
||||
"typed parse error must survive the Result boundary, got: {err:?}"
|
||||
);
|
||||
|
||||
let mut valid = BucketMetadata::new(bucket);
|
||||
valid
|
||||
.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||
.unwrap();
|
||||
let stamped = valid.on_demand_migration_config_updated_at;
|
||||
sys.set(bucket.to_string(), Arc::new(valid)).await;
|
||||
let (config, updated_at) = sys
|
||||
.get_on_demand_migration_config(bucket)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("stored config is returned");
|
||||
assert_eq!(config, OnDemandMigrationConfig::from_json(ODM_JSON).unwrap());
|
||||
assert_eq!(updated_at, stamped);
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2148: the publish hook fires on every path that
|
||||
/// installs bucket metadata into the cache (set, initial load, peer
|
||||
/// reload, refresh loop, lazy load) and withdraws on removal, mirroring
|
||||
@@ -4468,15 +4417,22 @@ mod tests {
|
||||
for dir in &dirs {
|
||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist");
|
||||
}
|
||||
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
|
||||
|
||||
let incarnation = Uuid::new_v4();
|
||||
let expect_publish = |before: usize, label: &str| {
|
||||
let calls = odm_hook_calls(bucket);
|
||||
assert_eq!(calls.len(), before + 1, "{label} must publish exactly once");
|
||||
assert_eq!(calls.last().unwrap().as_ref(), Some(&expected), "{label} must publish the stored config");
|
||||
assert_eq!(
|
||||
calls.last().unwrap().as_ref().map(|(bytes, _, _)| bytes.as_slice()),
|
||||
Some(ODM_JSON),
|
||||
"{label} must publish the stored bytes"
|
||||
);
|
||||
assert_eq!(calls.last().unwrap().as_ref().map(|(_, _, id)| *id), Some(incarnation));
|
||||
};
|
||||
|
||||
// set (via persist_new_and_set, which installs through `set`).
|
||||
let mut bm = BucketMetadata::new(bucket);
|
||||
bm.bucket_incarnation_id = incarnation;
|
||||
bm.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||
.unwrap();
|
||||
let writer = BucketMetadataSys::new(ecstore.clone());
|
||||
@@ -4518,14 +4474,18 @@ mod tests {
|
||||
assert_eq!(calls.len(), before + 1, "remove must withdraw exactly once");
|
||||
assert_eq!(calls.last().unwrap(), &None);
|
||||
|
||||
// A corrupt payload is withdrawn, never published as a config.
|
||||
// Opaque bytes reach the application even if they are not valid JSON.
|
||||
let mut corrupt = BucketMetadata::new(bucket);
|
||||
corrupt.on_demand_migration_config_json = b"not-json".to_vec();
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
lazy.set(bucket.to_string(), Arc::new(corrupt)).await;
|
||||
let calls = odm_hook_calls(bucket);
|
||||
assert_eq!(calls.len(), before + 1);
|
||||
assert_eq!(calls.last().unwrap(), &None, "unreadable config must publish absence");
|
||||
assert_eq!(
|
||||
calls.last().unwrap().as_ref().map(|(bytes, _, _)| bytes.as_slice()),
|
||||
Some(b"not-json".as_slice()),
|
||||
"the application validates opaque config bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -26,7 +26,6 @@ mod metadata_test;
|
||||
pub mod migration;
|
||||
mod msgp_decode;
|
||||
pub mod object_lock;
|
||||
pub mod on_demand_migration;
|
||||
pub mod policy_sys;
|
||||
pub mod quota;
|
||||
pub mod remote_s3_client;
|
||||
|
||||
@@ -180,6 +180,8 @@ impl RemoteS3EndpointSpec {
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemoteS3ClientError {
|
||||
#[error("the {0} backend is not included in this build")]
|
||||
BackendNotCompiled(&'static str),
|
||||
#[error("remote endpoint requires credentials")]
|
||||
MissingCredentials,
|
||||
#[error("{0}")]
|
||||
@@ -281,9 +283,7 @@ impl Intercept for UserAgentSuffixInterceptor {
|
||||
|
||||
/// Builds the SDK config for `spec` without finalizing it, so callers can add
|
||||
/// interceptors or (in tests) swap the HTTP client before `build()`.
|
||||
pub(crate) async fn build_remote_s3_config(
|
||||
spec: &RemoteS3EndpointSpec,
|
||||
) -> Result<aws_sdk_s3::config::Builder, RemoteS3ClientError> {
|
||||
pub async fn build_remote_s3_config(spec: &RemoteS3EndpointSpec) -> Result<aws_sdk_s3::config::Builder, RemoteS3ClientError> {
|
||||
let Some(credentials) = &spec.credentials else {
|
||||
return Err(RemoteS3ClientError::MissingCredentials);
|
||||
};
|
||||
@@ -523,7 +523,7 @@ fn validate_ca_pem_bundle(ca_cert_pem: &[u8]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_target_ca_pem(ca_cert_pem: &str) -> Result<(), RemoteS3ClientError> {
|
||||
pub fn validate_target_ca_pem(ca_cert_pem: &str) -> Result<(), RemoteS3ClientError> {
|
||||
validate_ca_pem_bundle(ca_cert_pem.as_bytes()).map_err(RemoteS3ClientError::InvalidCaPem)
|
||||
}
|
||||
|
||||
|
||||
@@ -956,6 +956,9 @@ pub struct ObjectOptions {
|
||||
pub preserve_etag: Option<String>,
|
||||
pub metadata_chg: bool,
|
||||
pub http_preconditions: Option<HTTPPreconditions>,
|
||||
/// Internal create-only writes may also preserve an acknowledged deletion.
|
||||
/// Evaluated with `http_preconditions` under the namespace commit lock.
|
||||
pub preserve_delete_marker: bool,
|
||||
|
||||
pub delete_replication: Option<ReplicationState>,
|
||||
pub delete_replication_config_snapshot: Option<Arc<DeleteReplicationConfigSnapshot>>,
|
||||
|
||||
@@ -78,6 +78,21 @@ pub(crate) struct ScannerPublicationLeaseEntry {
|
||||
pub(crate) _operation_guard: OwnedRwLockReadGuard<()>,
|
||||
}
|
||||
|
||||
pub(crate) struct NamespaceCommitGuard {
|
||||
ctx: Arc<InstanceContext>,
|
||||
counted: bool,
|
||||
}
|
||||
|
||||
impl Drop for NamespaceCommitGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.counted {
|
||||
// Publish the new generation before a zero-pending publication probe.
|
||||
self.ctx.advance_namespace_commit_generation();
|
||||
self.ctx.namespace_commits.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime state owned by a single `ECStore` instance.
|
||||
///
|
||||
/// This is intentionally minimal in the first migration slice; subsequent
|
||||
@@ -209,9 +224,13 @@ pub struct InstanceContext {
|
||||
/// Last storage-owned movement snapshot observed under the operation
|
||||
/// gate. SetDisks cache writers fail closed until ECStore refreshes it.
|
||||
scanner_publication_state: AtomicU8,
|
||||
namespace_commits: AtomicU64,
|
||||
namespace_commit_generation: AtomicU64,
|
||||
/// Resolves object-encryption material at the application boundary.
|
||||
object_encryption_resolver: OnceLock<Arc<dyn ObjectEncryptionResolver>>,
|
||||
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||
#[cfg(test)]
|
||||
suppress_tier_delete_journal_recovery: bool,
|
||||
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||
tier_delete_journal_recovery_wakeup: tokio::sync::Notify,
|
||||
}
|
||||
@@ -256,8 +275,12 @@ impl InstanceContext {
|
||||
data_movement_generation_exhausted: AtomicBool::new(false),
|
||||
data_movement_generation_notify: Arc::new(Notify::new()),
|
||||
scanner_publication_state: AtomicU8::new(SCANNER_PUBLICATION_STATE_UNKNOWN),
|
||||
namespace_commits: AtomicU64::new(0),
|
||||
namespace_commit_generation: AtomicU64::new(0),
|
||||
object_encryption_resolver: OnceLock::new(),
|
||||
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||
#[cfg(test)]
|
||||
suppress_tier_delete_journal_recovery: false,
|
||||
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||
tier_delete_journal_recovery_wakeup: tokio::sync::Notify::new(),
|
||||
}
|
||||
@@ -385,6 +408,36 @@ impl InstanceContext {
|
||||
&& self.scanner_publication_state.load(Ordering::Acquire) == SCANNER_PUBLICATION_STATE_ALLOWED
|
||||
}
|
||||
|
||||
pub(crate) fn begin_namespace_commit(self: &Arc<Self>) -> Arc<NamespaceCommitGuard> {
|
||||
let counted = self
|
||||
.namespace_commits
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| count.checked_add(1))
|
||||
.is_ok();
|
||||
if counted {
|
||||
self.advance_namespace_commit_generation();
|
||||
} else {
|
||||
self.namespace_commit_generation.store(u64::MAX, Ordering::Release);
|
||||
}
|
||||
Arc::new(NamespaceCommitGuard {
|
||||
ctx: Arc::clone(self),
|
||||
counted,
|
||||
})
|
||||
}
|
||||
|
||||
fn advance_namespace_commit_generation(&self) {
|
||||
let _ = self
|
||||
.namespace_commit_generation
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |generation| Some(generation.saturating_add(1)));
|
||||
}
|
||||
|
||||
pub(crate) fn namespace_commit_generation(&self) -> u64 {
|
||||
self.namespace_commit_generation.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn namespace_commits_pending(&self) -> bool {
|
||||
self.namespace_commits.load(Ordering::Acquire) != 0 || self.namespace_commit_generation() == u64::MAX
|
||||
}
|
||||
|
||||
pub(crate) fn set_scanner_publication_state(&self, blocked: bool) {
|
||||
self.scanner_publication_state.store(
|
||||
if blocked {
|
||||
@@ -640,12 +693,21 @@ impl InstanceContext {
|
||||
}
|
||||
|
||||
pub(crate) fn mark_tier_delete_journal_recovery_started(&self, store_id: Uuid) -> bool {
|
||||
#[cfg(test)]
|
||||
if self.suppress_tier_delete_journal_recovery {
|
||||
return false;
|
||||
}
|
||||
self.tier_delete_journal_recovery_stores
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.insert(store_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn suppress_tier_delete_journal_recovery_for_test(&mut self) {
|
||||
self.suppress_tier_delete_journal_recovery = true;
|
||||
}
|
||||
|
||||
pub(crate) fn mark_transition_transaction_recovery_started(&self, store_id: Uuid) -> bool {
|
||||
self.transition_transaction_recovery_stores
|
||||
.lock()
|
||||
@@ -756,6 +818,50 @@ pub fn bootstrap_ctx() -> Arc<InstanceContext> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn namespace_commit_guards_are_instance_local_and_count_until_last_owner() {
|
||||
let first = Arc::new(InstanceContext::new());
|
||||
let other = Arc::new(InstanceContext::new());
|
||||
first.set_scanner_publication_state(false);
|
||||
other.set_scanner_publication_state(false);
|
||||
assert!(first.scanner_publication_state_allowed());
|
||||
let one = first.begin_namespace_commit();
|
||||
let shared_owner = Arc::clone(&one);
|
||||
let two = first.begin_namespace_commit();
|
||||
assert!(first.namespace_commits_pending());
|
||||
assert!(first.scanner_publication_state_allowed(), "pending writes must not block scan admission");
|
||||
assert_eq!(first.namespace_commit_generation(), 2);
|
||||
assert!(!other.namespace_commits_pending());
|
||||
assert_eq!(other.namespace_commit_generation(), 0);
|
||||
assert!(other.scanner_publication_state_allowed());
|
||||
drop(one);
|
||||
assert_eq!(first.namespace_commit_generation(), 2);
|
||||
drop(shared_owner);
|
||||
assert!(first.namespace_commits_pending());
|
||||
assert_eq!(first.namespace_commit_generation(), 3);
|
||||
drop(two);
|
||||
assert!(!first.namespace_commits_pending());
|
||||
assert_eq!(first.namespace_commit_generation(), 4);
|
||||
assert!(first.scanner_publication_state_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_commit_counter_exhaustion_keeps_publication_blocked() {
|
||||
for (count, generation) in [(0, u64::MAX - 1), (u64::MAX, 0)] {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
ctx.set_scanner_publication_state(false);
|
||||
ctx.namespace_commits.store(count, Ordering::Release);
|
||||
ctx.namespace_commit_generation.store(generation, Ordering::Release);
|
||||
let guard = ctx.begin_namespace_commit();
|
||||
assert!(ctx.namespace_commits_pending());
|
||||
assert_eq!(ctx.namespace_commit_generation(), u64::MAX);
|
||||
drop(guard);
|
||||
assert!(ctx.namespace_commits_pending());
|
||||
assert_eq!(ctx.namespace_commit_generation(), u64::MAX);
|
||||
assert_eq!(ctx.namespace_commits.load(Ordering::Acquire), count);
|
||||
}
|
||||
}
|
||||
|
||||
// The SetupType inputs must derive the exact (is_erasure,
|
||||
// is_dist_erasure, is_erasure_sd) triples that the original three
|
||||
// process-global erasure bools produced via update_erasure_type().
|
||||
@@ -1073,6 +1179,12 @@ mod tests {
|
||||
assert!(!ctx_a.mark_tier_delete_journal_recovery_started(store_a));
|
||||
assert!(ctx_a.mark_tier_delete_journal_recovery_started(store_b));
|
||||
assert!(ctx_b.mark_tier_delete_journal_recovery_started(store_a));
|
||||
|
||||
let mut manual_ctx = InstanceContext::new();
|
||||
manual_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||
assert!(!manual_ctx.mark_tier_delete_journal_recovery_started(store_a));
|
||||
assert!(!manual_ctx.mark_tier_delete_journal_recovery_started(store_b));
|
||||
assert!(ctx_b.mark_tier_delete_journal_recovery_started(store_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(crate) mod tier_probe_intent;
|
||||
pub mod warm_backend;
|
||||
pub mod warm_backend_aliyun;
|
||||
pub mod warm_backend_azure;
|
||||
#[cfg(feature = "gcs")]
|
||||
pub mod warm_backend_gcs;
|
||||
pub mod warm_backend_huaweicloud;
|
||||
pub mod warm_backend_minio;
|
||||
|
||||
@@ -3541,7 +3541,7 @@ impl TierConfigMgr {
|
||||
// Get tier configuration and create new driver
|
||||
let tier_config = self.tiers.get(tier_name).ok_or_else(|| ERR_TIER_NOT_FOUND.clone())?;
|
||||
|
||||
let driver = new_warm_backend(tier_config, false).await?;
|
||||
let driver = construct_warm_backend(tier_config).await?;
|
||||
|
||||
self.replace_driver(tier_name, driver)?;
|
||||
Ok(self
|
||||
@@ -4486,6 +4486,11 @@ impl TierConfigMgr {
|
||||
let committed_coordinator_intent =
|
||||
committed_tier_mutation_intent(coordinator_intent.as_ref(), &committed_config_etag)
|
||||
.map_err(TierConfigUpdateError::Save)?;
|
||||
// Persist Committed before notifying refresh; a Prepared disk record
|
||||
// would restore the prepared block and invalidate our publish allowance.
|
||||
let coordinator_commit =
|
||||
commit_coordinator_tier_mutation_intent(api.clone(), coordinator_intent.as_ref(), &committed_config_etag)
|
||||
.await;
|
||||
if let Some(intent) = committed_coordinator_intent.as_ref() {
|
||||
TierConfigMgr::apply_committed_mutation_intent_block(&handle, intent)
|
||||
.await
|
||||
@@ -4496,9 +4501,9 @@ impl TierConfigMgr {
|
||||
.map_err(TierConfigUpdateError::Publish)?,
|
||||
);
|
||||
}
|
||||
commit_coordinator_tier_mutation_intent(api.clone(), coordinator_intent.as_ref(), &committed_config_etag)
|
||||
.await
|
||||
.map_err(TierConfigUpdateError::Save)?;
|
||||
// Config is already saved: retain the committed fence and wake recovery
|
||||
// even when the coordinator commit failed or its outcome is unknown.
|
||||
coordinator_commit.map_err(TierConfigUpdateError::Save)?;
|
||||
if coordinated_config_update {
|
||||
drop(update.take());
|
||||
drop(config_lock.take());
|
||||
@@ -10603,6 +10608,11 @@ mod tests {
|
||||
.expect_err("coordinator committed-state CAS failure must be observable");
|
||||
assert!(matches!(err, TierConfigUpdateError::Save(_)));
|
||||
assert!(manager.read().await.tiers.contains_key("COLD-A"));
|
||||
assert!(TierConfigMgr::has_committed_mutation_block(&manager).await);
|
||||
let refresh = TierConfigMgr::mutation_refresh_notifier(&manager).await;
|
||||
tokio::time::timeout(Duration::from_secs(1), refresh.notified())
|
||||
.await
|
||||
.expect("failed coordinator commit must notify recovery after saving config");
|
||||
let blocked = match TierConfigMgr::acquire_operation_lease(&manager, "COLD-A").await {
|
||||
Ok(_) => panic!("failed coordinator commit CAS must retain the local committed fence"),
|
||||
Err(err) => err,
|
||||
@@ -14329,6 +14339,12 @@ mod tests {
|
||||
after_commit: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct CasCoordinatorCommitBarrier {
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CasConfigStore {
|
||||
objects: tokio::sync::Mutex<HashMap<String, (Vec<u8>, String)>>,
|
||||
@@ -14341,6 +14357,7 @@ mod tests {
|
||||
fail_delete_prefix: tokio::sync::Mutex<Option<(String, usize)>>,
|
||||
delete_log: tokio::sync::Mutex<Vec<String>>,
|
||||
list_barrier: tokio::sync::Mutex<Option<Arc<CasListBarrier>>>,
|
||||
coordinator_commit_barrier: tokio::sync::Mutex<Option<Arc<CasCoordinatorCommitBarrier>>>,
|
||||
intent_list_calls: AtomicUsize,
|
||||
fail_reference_walk: AtomicBool,
|
||||
reference_walk_send_count: AtomicUsize,
|
||||
@@ -14363,6 +14380,7 @@ mod tests {
|
||||
fail_delete_prefix: tokio::sync::Mutex::new(None),
|
||||
delete_log: tokio::sync::Mutex::new(Vec::new()),
|
||||
list_barrier: tokio::sync::Mutex::new(None),
|
||||
coordinator_commit_barrier: tokio::sync::Mutex::new(None),
|
||||
intent_list_calls: AtomicUsize::new(0),
|
||||
fail_reference_walk: AtomicBool::new(false),
|
||||
reference_walk_send_count: AtomicUsize::new(0),
|
||||
@@ -14554,6 +14572,19 @@ mod tests {
|
||||
}
|
||||
let mut payload = Vec::new();
|
||||
tokio::io::AsyncReadExt::read_to_end(&mut data.stream, &mut payload).await?;
|
||||
if object.starts_with(crate::services::tier::tier_mutation_intent::TIER_COORDINATOR_MUTATION_INTENT_RECORD_PREFIX)
|
||||
&& opts
|
||||
.http_preconditions
|
||||
.as_ref()
|
||||
.and_then(HTTPPreconditions::if_match_value)
|
||||
.is_some()
|
||||
{
|
||||
let barrier = self.coordinator_commit_barrier.lock().await.take();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.arrived.notify_one();
|
||||
barrier.release.notified().await;
|
||||
}
|
||||
}
|
||||
let race_rewrite = if opts
|
||||
.http_preconditions
|
||||
.as_ref()
|
||||
@@ -15651,14 +15682,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_remove_and_save_bypasses_lifecycle_only_reference() {
|
||||
// rustfs/rustfs#6832: reproduces the admin RemoveTier path (not just the lower-level
|
||||
// reference-proof function) for a tier with zero transitioned objects but a lifecycle
|
||||
// rule still pointing at it — the exact shape of
|
||||
// `test_manual_transition_async_tier_failure_reports_terminal_partial` in e2e_test,
|
||||
// which force-removes a tier a lifecycle rule still references to simulate a
|
||||
// decommissioned backend.
|
||||
async fn assert_lifecycle_only_reference_obeys_force(clear: bool, force: bool) {
|
||||
let store = Arc::new(CasConfigStore::default());
|
||||
let tier = build_rustfs_tier("COLD-A");
|
||||
let mut persisted = empty_mgr();
|
||||
@@ -15699,22 +15723,55 @@ mod tests {
|
||||
|
||||
let manager = TierConfigMgr::new();
|
||||
manager.write().await.tiers.insert("COLD-A".to_string(), tier);
|
||||
TierConfigMgr::remove_and_save_with(&manager, store.clone(), "COLD-A", true)
|
||||
.await
|
||||
.expect("force remove must bypass a lifecycle-config-only reference");
|
||||
let mutation = if clear {
|
||||
TierCandidateMutation::Clear(force)
|
||||
} else {
|
||||
TierCandidateMutation::Remove("COLD-A".to_string(), force)
|
||||
};
|
||||
let result = TIER_DRIVER_TEST_FACTORY
|
||||
.scope(
|
||||
healthy_driver_factory(),
|
||||
TierConfigMgr::update_candidate_with_config_lock(&manager, store.clone(), mutation),
|
||||
)
|
||||
.await;
|
||||
if force {
|
||||
result.expect("force mutation must bypass a lifecycle-config-only reference");
|
||||
} else {
|
||||
let err = result.expect_err("non-force mutation must reject a lifecycle-only reference");
|
||||
let TierConfigUpdateError::Publish(err) = err else {
|
||||
panic!("non-force mutation must fail during reference proof: {err:?}");
|
||||
};
|
||||
assert_eq!(err.code, ERR_TIER_BACKEND_IN_USE.code);
|
||||
assert!(err.message.contains("move-current"), "{err}");
|
||||
}
|
||||
|
||||
assert!(!manager.read().await.tiers.contains_key("COLD-A"));
|
||||
assert!(
|
||||
!load_tier_config_for_update(store)
|
||||
assert_eq!(manager.read().await.tiers.contains_key("COLD-A"), !force);
|
||||
assert_eq!(
|
||||
load_tier_config_for_update(store)
|
||||
.await
|
||||
.expect("config should still reload")
|
||||
.0
|
||||
.tiers
|
||||
.contains_key("COLD-A"),
|
||||
"force removal must persist the empty candidate"
|
||||
!force,
|
||||
"persisted state must match the force mutation result"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_with_config_lock_obeys_force_for_lifecycle_only_reference() {
|
||||
for force in [false, true] {
|
||||
assert_lifecycle_only_reference_obeys_force(false, force).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_with_config_lock_obeys_force_for_lifecycle_only_reference() {
|
||||
for force in [false, true] {
|
||||
assert_lifecycle_only_reference_obeys_force(true, force).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_reference_proof_blocks_clear_before_config_save() {
|
||||
let store = Arc::new(CasConfigStore::default());
|
||||
@@ -17255,6 +17312,98 @@ mod tests {
|
||||
assert_ne!(manager_a.read().await.empty(), manager_b.read().await.empty());
|
||||
}
|
||||
|
||||
async fn assert_coordinator_commit_refresh_succeeds(mutation: TierCandidateMutation) {
|
||||
let adding = matches!(mutation, TierCandidateMutation::Add(..));
|
||||
let manager = TierConfigMgr::new();
|
||||
let store = Arc::new(CasConfigStore::default());
|
||||
if !adding {
|
||||
let mut persisted = empty_mgr();
|
||||
persisted.tiers.insert("COLD-A".to_string(), build_rustfs_tier("COLD-A"));
|
||||
persisted
|
||||
.save_tiering_config_if_current(store.clone(), None)
|
||||
.await
|
||||
.expect("existing tier fixture should persist");
|
||||
let mut guard = manager.write().await;
|
||||
install_lease_backend(&mut guard, "COLD-A", LeaseTestBackend::ready("old"));
|
||||
}
|
||||
let barrier = Arc::new(CasCoordinatorCommitBarrier::default());
|
||||
*store.coordinator_commit_barrier.lock().await = Some(barrier.clone());
|
||||
let update_manager = manager.clone();
|
||||
let update_store = store.clone();
|
||||
let update = tokio::spawn(async move {
|
||||
TIER_DRIVER_TEST_FACTORY
|
||||
.scope(
|
||||
healthy_driver_factory(),
|
||||
TIER_MUTATION_TEST_PEERS.scope(
|
||||
Vec::new(),
|
||||
TierConfigMgr::update_candidate_with_config_lock(&update_manager, update_store, mutation),
|
||||
),
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(5), barrier.arrived.notified())
|
||||
.await
|
||||
.expect("mutation should reach coordinator commit after saving config");
|
||||
assert_eq!(
|
||||
load_tier_config_for_update(store.clone())
|
||||
.await
|
||||
.expect("saved config should be readable before coordinator commit")
|
||||
.0
|
||||
.tiers
|
||||
.contains_key("COLD-A"),
|
||||
adding
|
||||
);
|
||||
assert_eq!(
|
||||
TierConfigMgr::load_coordinator_mutation_intents(store.clone())
|
||||
.await
|
||||
.expect("coordinator intent should remain readable")[0]
|
||||
.state,
|
||||
TierMutationIntentState::Prepared
|
||||
);
|
||||
|
||||
let lock_requests = lock_unpoisoned(&store.lock_requests).len();
|
||||
// Also exercise an independently scheduled refresh while the durable
|
||||
// coordinator record is still Prepared, before its commit notification.
|
||||
TierConfigMgr::request_committed_mutation_refresh(&manager).await;
|
||||
TIER_MUTATION_TEST_PEERS
|
||||
.scope(Vec::new(), async {
|
||||
let worker = TierConfigMgr::refresh_tier_config_handle_with(manager.clone(), store.clone());
|
||||
tokio::pin!(worker);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while lock_unpoisoned(&store.lock_requests).len() == lock_requests {
|
||||
tokio::select! {
|
||||
_ = &mut worker => panic!("refresh worker must remain available"),
|
||||
_ = tokio::task::yield_now() => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("refresh should reconcile the Prepared record before waiting for the config lock");
|
||||
barrier.release.notify_one();
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
tokio::select! {
|
||||
_ = &mut worker => panic!("refresh worker must remain available"),
|
||||
result = update => result.expect("tier mutation task should join"),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("tier mutation should finish with refresh running");
|
||||
result.expect("saved tier mutation must publish successfully on the first attempt");
|
||||
})
|
||||
.await;
|
||||
assert_eq!(manager.read().await.tiers.contains_key("COLD-A"), adding);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_add_succeeds_with_refresh_during_coordinator_commit() {
|
||||
assert_coordinator_commit_refresh_succeeds(TierCandidateMutation::Add(build_rustfs_tier("COLD-A"), true)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_remove_succeeds_with_refresh_during_coordinator_commit() {
|
||||
assert_coordinator_commit_refresh_succeeds(TierCandidateMutation::Remove("COLD-A".to_string(), true)).await;
|
||||
}
|
||||
|
||||
async fn committed_refresh_fixture(fail_cleanup: bool) -> (Arc<RwLock<TierConfigMgr>>, Arc<CasConfigStore>, uuid::Uuid) {
|
||||
let manager = TierConfigMgr::new();
|
||||
{
|
||||
|
||||
@@ -19,13 +19,14 @@
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use crate::error::is_err_bucket_not_found;
|
||||
#[cfg(feature = "gcs")]
|
||||
use crate::services::tier::warm_backend_gcs::WarmBackendGCS;
|
||||
use crate::services::tier::{
|
||||
tier::{ERR_TIER_BACKEND_IN_USE, ERR_TIER_INVALID_CONFIG, ERR_TIER_TYPE_UNSUPPORTED},
|
||||
tier_config::{TierConfig, TierType},
|
||||
tier_handlers::{ERR_TIER_BUCKET_NOT_FOUND, ERR_TIER_NOT_FOUND, ERR_TIER_PERM_ERR},
|
||||
warm_backend_aliyun::WarmBackendAliyun,
|
||||
warm_backend_azure::WarmBackendAzure,
|
||||
warm_backend_gcs::WarmBackendGCS,
|
||||
warm_backend_huaweicloud::WarmBackendHuaweicloud,
|
||||
warm_backend_minio::WarmBackendMinIO,
|
||||
warm_backend_r2::WarmBackendR2,
|
||||
@@ -912,6 +913,15 @@ pub async fn new_warm_backend(tier: &TierConfig, probe: bool) -> Result<WarmBack
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "gcs"))]
|
||||
TierType::GCS => {
|
||||
return Err(AdminError {
|
||||
code: ERR_TIER_TYPE_UNSUPPORTED.code.clone(),
|
||||
message: "This build does not include the GCS backend; rebuild with the gcs feature".to_string(),
|
||||
status_code: StatusCode::NOT_IMPLEMENTED,
|
||||
});
|
||||
}
|
||||
#[cfg(feature = "gcs")]
|
||||
TierType::GCS => {
|
||||
if let Some(gcs_config) = tier.gcs.as_ref() {
|
||||
let dd = WarmBackendGCS::new(gcs_config, &tier.name).await;
|
||||
@@ -1028,6 +1038,27 @@ mod tests {
|
||||
|
||||
const PROBE_VERSION: &str = "remote-v2";
|
||||
|
||||
#[cfg(not(feature = "gcs"))]
|
||||
#[tokio::test]
|
||||
async fn gcs_backend_not_compiled_preserves_config() {
|
||||
let json = r#"{"name":"ARCHIVE","type":"gcs","gcs":{"bucket":"archive","creds":"secret"}}"#;
|
||||
let tier: TierConfig = serde_json::from_str(json).expect("GCS config remains readable without the backend");
|
||||
assert_eq!(tier.tier_type, TierType::GCS);
|
||||
let encoded = serde_json::to_vec(&tier).expect("GCS config remains writable");
|
||||
let restored: TierConfig = serde_json::from_slice(&encoded).expect("GCS config round trips");
|
||||
assert_eq!(restored.tier_type, TierType::GCS);
|
||||
let restored_gcs = restored.gcs.as_ref().expect("GCS settings preserved");
|
||||
assert_eq!(restored_gcs.bucket, "archive");
|
||||
assert_eq!(restored_gcs.creds, "secret");
|
||||
assert_eq!(tier.redacted().gcs.expect("redacted GCS settings").creds, "REDACTED");
|
||||
let error = match new_warm_backend(&tier, false).await {
|
||||
Ok(_) => panic!("an excluded GCS backend cannot be constructed"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.code, ERR_TIER_TYPE_UNSUPPORTED.code);
|
||||
assert_eq!(error.status_code, StatusCode::NOT_IMPLEMENTED);
|
||||
}
|
||||
|
||||
struct CountingBackend {
|
||||
put_result: fn() -> Result<String, std::io::Error>,
|
||||
removes: Arc<AtomicUsize>,
|
||||
|
||||
@@ -3558,6 +3558,11 @@ impl RenameRollbackReceipt {
|
||||
}
|
||||
}
|
||||
|
||||
struct RenameRollbackOwnership {
|
||||
receipt: Option<RenameRollbackReceipt>,
|
||||
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
|
||||
}
|
||||
|
||||
async fn inspect_incomplete_rename_rollback(
|
||||
disks: &[Option<DiskStore>],
|
||||
bucket: &str,
|
||||
@@ -3604,8 +3609,12 @@ async fn rollback_failed_rename(
|
||||
dispatch_states: &[RenameDispatchState],
|
||||
rollback_dirs: &[Option<Uuid>],
|
||||
dst: (&str, &str),
|
||||
receipt: Option<RenameRollbackReceipt>,
|
||||
ownership: RenameRollbackOwnership,
|
||||
) {
|
||||
let RenameRollbackOwnership {
|
||||
receipt,
|
||||
namespace_commit_guard,
|
||||
} = ownership;
|
||||
let owned_disks = disks.to_vec();
|
||||
let owned_errs = errs.to_vec();
|
||||
let owned_dispatch_states = dispatch_states.to_vec();
|
||||
@@ -3651,7 +3660,9 @@ async fn rollback_failed_rename(
|
||||
let fi = std::mem::take(&mut file_infos[disk_index]);
|
||||
let bucket = bucket.to_string();
|
||||
let object = object.to_string();
|
||||
let disk_namespace_commit_guard = namespace_commit_guard.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let _namespace_commit_guard = disk_namespace_commit_guard;
|
||||
#[allow(clippy::let_unit_value)]
|
||||
let _task_guard = SetDisks::rename_fanout_task_guard(&object);
|
||||
SetDisks::rename_fanout_barrier(&object, disk_index, rename_fanout_barrier_phase::ROLLBACK).await;
|
||||
@@ -3672,6 +3683,9 @@ async fn rollback_failed_rename(
|
||||
});
|
||||
tasks.push(async move { (disk_index, task.await) });
|
||||
}
|
||||
#[cfg(test)]
|
||||
rollback_fault_injection::after_undo_dispatch(object);
|
||||
let _namespace_commit_guard = namespace_commit_guard;
|
||||
for (disk_index, result) in join_all(tasks).await {
|
||||
outcomes[disk_index].outcome = rename_rollback_task_outcome(result);
|
||||
}
|
||||
@@ -3778,6 +3792,7 @@ pub(in crate::set_disk) struct RenameDataFenceOptions<'a> {
|
||||
write_quorum: usize,
|
||||
scanner_publication_lease_tokens: Option<&'a HashMap<String, Uuid>>,
|
||||
scanner_publication_commit_scope: Option<crate::object_api::ScannerPublicationCommitScope>,
|
||||
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
|
||||
rollback_receipt: Option<RenameRollbackReceipt>,
|
||||
}
|
||||
|
||||
@@ -3790,6 +3805,7 @@ impl<'a> RenameDataFenceOptions<'a> {
|
||||
write_quorum,
|
||||
scanner_publication_lease_tokens,
|
||||
scanner_publication_commit_scope: None,
|
||||
namespace_commit_guard: None,
|
||||
rollback_receipt: None,
|
||||
}
|
||||
}
|
||||
@@ -3806,6 +3822,14 @@ impl<'a> RenameDataFenceOptions<'a> {
|
||||
self.scanner_publication_commit_scope = scanner_publication_commit_scope;
|
||||
self
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) fn with_namespace_commit_guard(
|
||||
mut self,
|
||||
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
|
||||
) -> Self {
|
||||
self.namespace_commit_guard = namespace_commit_guard;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
||||
@@ -4164,6 +4188,7 @@ impl SetDisks {
|
||||
write_quorum,
|
||||
scanner_publication_lease_tokens,
|
||||
scanner_publication_commit_scope: _scanner_publication_commit_scope,
|
||||
namespace_commit_guard,
|
||||
rollback_receipt,
|
||||
} = fence_options;
|
||||
if let Some(file_info) = disks
|
||||
@@ -4210,7 +4235,9 @@ impl SetDisks {
|
||||
let dst_object = fanout_dst_object.clone();
|
||||
let file_info = file_info.clone();
|
||||
let successful_rename_completion_rank = successful_rename_completion_rank.clone();
|
||||
let namespace_commit_guard = namespace_commit_guard.clone();
|
||||
tasks.spawn(async move {
|
||||
let _namespace_commit_guard = namespace_commit_guard;
|
||||
let mut dispatch_state = RenameDispatchState::NotDispatched;
|
||||
let result = std::panic::AssertUnwindSafe(async {
|
||||
#[allow(clippy::let_unit_value)]
|
||||
@@ -4372,7 +4399,10 @@ impl SetDisks {
|
||||
&dispatch_states,
|
||||
&data_dirs,
|
||||
(&fanout_dst_bucket, &fanout_dst_object),
|
||||
rollback_receipt,
|
||||
RenameRollbackOwnership {
|
||||
receipt: rollback_receipt,
|
||||
namespace_commit_guard,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Some(commit_tx) = commit_tx.take() {
|
||||
@@ -4528,6 +4558,7 @@ impl SetDisks {
|
||||
write_quorum,
|
||||
scanner_publication_lease_tokens,
|
||||
scanner_publication_commit_scope,
|
||||
namespace_commit_guard,
|
||||
rollback_receipt,
|
||||
} = fence_options;
|
||||
if let Some(file_info) = disks
|
||||
@@ -4561,6 +4592,7 @@ impl SetDisks {
|
||||
let fanout_dst_bucket = dst_bucket.clone();
|
||||
let fanout_dst_object = dst_object.clone();
|
||||
let fanout_publication_scope = scanner_publication_commit_scope.clone();
|
||||
let fanout_namespace_commit_guard = namespace_commit_guard.clone();
|
||||
// Keep one coordinator task so a cancelled caller cannot drop partially
|
||||
// completed disk mutations. Per-disk futures stay ordered in `join_all`,
|
||||
// preserving slot-indexed quorum and convergence accounting without a
|
||||
@@ -4569,6 +4601,7 @@ impl SetDisks {
|
||||
// Keep the storage-owned movement permit attached to the actual
|
||||
// fan-out owner, even if the caller future is cancelled.
|
||||
let _fanout_publication_scope = fanout_publication_scope;
|
||||
let _namespace_commit_guard = fanout_namespace_commit_guard;
|
||||
let successful_rename_completion_rank =
|
||||
rustfs_io_metrics::put_stage_metrics_enabled().then(|| Arc::new(AtomicUsize::new(0)));
|
||||
let futures = fanout_disks
|
||||
@@ -4790,7 +4823,10 @@ impl SetDisks {
|
||||
&dispatch_states,
|
||||
&data_dirs,
|
||||
(&dst_bucket, &dst_object),
|
||||
rollback_receipt,
|
||||
RenameRollbackOwnership {
|
||||
receipt: rollback_receipt,
|
||||
namespace_commit_guard,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Err(ret_err);
|
||||
@@ -6503,9 +6539,9 @@ impl SetDisks {
|
||||
match oi {
|
||||
Ok(oi) => {
|
||||
// Ordinary writes may proceed past a top-level delete marker;
|
||||
// data movement must not replace an acknowledged deletion.
|
||||
// data movement and guarded internal writes must preserve it.
|
||||
if oi.delete_marker {
|
||||
return opts.data_movement.then_some(StorageError::PreconditionFailed);
|
||||
return (opts.data_movement || opts.preserve_delete_marker).then_some(StorageError::PreconditionFailed);
|
||||
}
|
||||
let if_none_match = http_preconditions.if_none_match_value().map(str::to_owned);
|
||||
let if_match = http_preconditions.if_match_value().map(str::to_owned);
|
||||
@@ -6754,6 +6790,7 @@ pub(in crate::set_disk) mod rollback_fault_injection {
|
||||
VolumeNotFoundAfterRename,
|
||||
PanicAfterRename,
|
||||
CoordinatorPanic,
|
||||
RollbackCoordinatorPanic,
|
||||
}
|
||||
|
||||
fn registry() -> &'static Mutex<HashMap<String, (usize, Fault)>> {
|
||||
@@ -6816,6 +6853,17 @@ pub(in crate::set_disk) mod rollback_fault_injection {
|
||||
panic!("injected rename coordinator panic");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn after_undo_dispatch(object: &str) {
|
||||
let fault = registry()
|
||||
.lock()
|
||||
.expect("rollback registry should not poison")
|
||||
.get(object)
|
||||
.copied();
|
||||
if matches!(fault, Some((_, Fault::RollbackCoordinatorPanic))) {
|
||||
panic!("injected rollback coordinator panic");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only per-disk call counters for the metadata fan-out (backlog#1325,
|
||||
@@ -6977,7 +7025,7 @@ pub(crate) mod rename_fanout_barrier {
|
||||
use tokio::sync::Notify;
|
||||
|
||||
pub use super::rename_fanout_barrier_phase::{
|
||||
CLEANUP as PHASE_CLEANUP, READ_VERSION as PHASE_READ_VERSION, RENAME as PHASE_RENAME,
|
||||
CLEANUP as PHASE_CLEANUP, READ_VERSION as PHASE_READ_VERSION, RENAME as PHASE_RENAME, ROLLBACK as PHASE_ROLLBACK,
|
||||
};
|
||||
|
||||
/// One armed barrier: the fan-out task matching `(disk_index, phase)` pauses.
|
||||
@@ -10814,79 +10862,177 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(capacity_dirty_scope)]
|
||||
async fn rename_rollback_incomplete_receipt_waits_for_undo_barrier() {
|
||||
for cancel_caller in [false, true] {
|
||||
let bucket = "rename-rollback-barrier";
|
||||
let object = if cancel_caller {
|
||||
"rollback-barrier-cancelled"
|
||||
} else {
|
||||
"rollback-barrier-object"
|
||||
};
|
||||
let (dirs, disks) = call_counter_local_disks(bucket, 4).await;
|
||||
prepare_rename_source_dirs(&dirs, &disks, "source").await;
|
||||
let mut old = metadata_test_fileinfo(object);
|
||||
old.mod_time = Some(OffsetDateTime::now_utc());
|
||||
old.data = Some(Bytes::from_static(b"old-inline-body"));
|
||||
old.set_inline_data();
|
||||
old.metadata.insert("etag".to_string(), "old-etag".to_string());
|
||||
for disk in disks.iter().flatten() {
|
||||
disk.write_metadata(bucket, bucket, object, old.clone())
|
||||
.await
|
||||
.expect("old metadata should be staged");
|
||||
}
|
||||
let _rename_fault = rename_fault_injection::fail_rename_on(object, &[2, 3]);
|
||||
let _undo_fault = rollback_fault_injection::arm(object, 0, rollback_fault_injection::Fault::Io);
|
||||
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier_phase::ROLLBACK);
|
||||
let receipt = RenameRollbackReceipt::default();
|
||||
let mut rename = Box::pin(SetDisks::rename_data_owned_with_fence(
|
||||
&disks,
|
||||
(RUSTFS_META_TMP_BUCKET, "source"),
|
||||
rename_commit_fileinfos(object, 4, "new-etag"),
|
||||
(bucket, object),
|
||||
false,
|
||||
RenameDataFenceOptions::new(3, None).with_rollback_receipt(receipt.clone()),
|
||||
));
|
||||
tokio::time::timeout(BARRIER_PAUSE_GUARD, async {
|
||||
tokio::select! {
|
||||
() = barrier.wait_until_paused() => {}
|
||||
_ = rename.as_mut() => panic!("rename returned before the armed rollback barrier"),
|
||||
temp_env::async_with_vars([(ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||
for (allow_early_ack, cancel_caller, object) in [
|
||||
(false, false, "rollback-barrier-object"),
|
||||
(false, true, "rollback-barrier-cancelled"),
|
||||
(true, false, "rollback-barrier-early-object"),
|
||||
(true, true, "rollback-barrier-early-cancelled"),
|
||||
] {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let bucket = "rename-rollback-barrier";
|
||||
let (dirs, disks) = call_counter_local_disks(bucket, 4).await;
|
||||
prepare_rename_source_dirs(&dirs, &disks, "source").await;
|
||||
let mut old = metadata_test_fileinfo(object);
|
||||
old.mod_time = Some(OffsetDateTime::now_utc());
|
||||
old.data = Some(Bytes::from_static(b"old-inline-body"));
|
||||
old.set_inline_data();
|
||||
old.metadata.insert("etag".to_string(), "old-etag".to_string());
|
||||
for disk in disks.iter().flatten() {
|
||||
disk.write_metadata(bucket, bucket, object, old.clone())
|
||||
.await
|
||||
.expect("old metadata should be staged");
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("undo must reach its disk barrier");
|
||||
assert!(receipt.0.get().is_none(), "pending undo must not be recorded as success");
|
||||
if cancel_caller {
|
||||
drop(rename);
|
||||
let _rename_fault = rename_fault_injection::fail_rename_on(object, &[2, 3]);
|
||||
let _undo_fault = rollback_fault_injection::arm(object, 0, rollback_fault_injection::Fault::Io);
|
||||
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier_phase::ROLLBACK);
|
||||
let receipt = RenameRollbackReceipt::default();
|
||||
let mut rename = Box::pin(SetDisks::rename_data_owned_with_fence(
|
||||
&disks,
|
||||
(RUSTFS_META_TMP_BUCKET, "source"),
|
||||
rename_commit_fileinfos(object, 4, "new-etag"),
|
||||
(bucket, object),
|
||||
allow_early_ack,
|
||||
RenameDataFenceOptions::new(3, None)
|
||||
.with_rollback_receipt(receipt.clone())
|
||||
.with_namespace_commit_guard(Some(ctx.begin_namespace_commit())),
|
||||
));
|
||||
tokio::time::timeout(BARRIER_PAUSE_GUARD, async {
|
||||
tokio::select! {
|
||||
() = barrier.wait_until_paused() => {}
|
||||
_ = rename.as_mut() => panic!("rename returned before the armed rollback barrier"),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("undo must reach its disk barrier");
|
||||
assert!(receipt.0.get().is_none(), "pending undo must not be recorded as success");
|
||||
assert!(ctx.namespace_commits_pending());
|
||||
assert_eq!(ctx.namespace_commit_generation(), 1);
|
||||
if cancel_caller {
|
||||
drop(rename);
|
||||
assert!(ctx.namespace_commits_pending(), "caller cancellation must not retire pending undo work");
|
||||
assert_eq!(ctx.namespace_commit_generation(), 1);
|
||||
barrier.release();
|
||||
tokio::time::timeout(BARRIER_PAUSE_GUARD, async {
|
||||
while receipt.0.get().is_none() || ctx.namespace_commits_pending() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("cancelled caller must not cancel rollback accounting");
|
||||
} else {
|
||||
barrier.release();
|
||||
assert!(rename.await.is_err());
|
||||
}
|
||||
assert!(
|
||||
!ctx.namespace_commits_pending(),
|
||||
"the completed rollback must release its namespace ownership"
|
||||
);
|
||||
assert_eq!(ctx.namespace_commit_generation(), 2);
|
||||
assert!(receipt.is_incomplete(), "drained undo failure must survive in the receipt");
|
||||
for dir in dirs.iter().skip(1) {
|
||||
let reopened = reopen_local_disk(dir).await;
|
||||
let restored = reopened
|
||||
.read_version(
|
||||
"",
|
||||
bucket,
|
||||
object,
|
||||
"",
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("old version must remain readable after caller cancellation");
|
||||
assert_eq!(restored.data.as_deref(), Some(b"old-inline-body".as_slice()));
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(capacity_dirty_scope)]
|
||||
async fn rename_rollback_children_keep_namespace_ownership_after_coordinator_panic() {
|
||||
temp_env::async_with_vars([(ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||
for (allow_early_ack, object) in [
|
||||
(false, "rollback-coordinator-panic"),
|
||||
(true, "rollback-coordinator-panic-early"),
|
||||
] {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let bucket = "rename-rollback-coordinator-panic";
|
||||
let (dirs, disks) = call_counter_local_disks(bucket, 4).await;
|
||||
prepare_rename_source_dirs(&dirs, &disks, "source").await;
|
||||
let mut old = metadata_test_fileinfo(object);
|
||||
old.mod_time = Some(OffsetDateTime::now_utc());
|
||||
old.data = Some(Bytes::from_static(b"old-inline-body"));
|
||||
old.set_inline_data();
|
||||
old.metadata.insert("etag".to_string(), "old-etag".to_string());
|
||||
for disk in disks.iter().flatten() {
|
||||
disk.write_metadata(bucket, bucket, object, old.clone())
|
||||
.await
|
||||
.expect("old metadata should be staged");
|
||||
}
|
||||
let _rename_fault = rename_fault_injection::fail_rename_on(object, &[2, 3]);
|
||||
let _rollback_fault =
|
||||
rollback_fault_injection::arm(object, 0, rollback_fault_injection::Fault::RollbackCoordinatorPanic);
|
||||
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier_phase::ROLLBACK);
|
||||
let receipt = RenameRollbackReceipt::default();
|
||||
let result = tokio::time::timeout(
|
||||
BARRIER_PAUSE_GUARD,
|
||||
SetDisks::rename_data_owned_with_fence(
|
||||
&disks,
|
||||
(RUSTFS_META_TMP_BUCKET, "source"),
|
||||
rename_commit_fileinfos(object, 4, "new-etag"),
|
||||
(bucket, object),
|
||||
allow_early_ack,
|
||||
RenameDataFenceOptions::new(3, None)
|
||||
.with_rollback_receipt(receipt.clone())
|
||||
.with_namespace_commit_guard(Some(ctx.begin_namespace_commit())),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("coordinator failure must return without waiting for detached undo tasks");
|
||||
assert!(result.is_err());
|
||||
tokio::time::timeout(BARRIER_PAUSE_GUARD, barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("detached undo must reach its disk barrier");
|
||||
assert!(
|
||||
receipt.is_incomplete(),
|
||||
"coordinator failure must preserve indeterminate recovery evidence"
|
||||
);
|
||||
assert!(ctx.namespace_commits_pending(), "the paused child must retain namespace ownership");
|
||||
assert_eq!(ctx.namespace_commit_generation(), 1);
|
||||
barrier.release();
|
||||
tokio::time::timeout(BARRIER_PAUSE_GUARD, async {
|
||||
while receipt.0.get().is_none() {
|
||||
while ctx.namespace_commits_pending() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("cancelled caller must not cancel rollback accounting");
|
||||
} else {
|
||||
barrier.release();
|
||||
assert!(rename.await.is_err());
|
||||
.expect("completed undo children must release their namespace ownership");
|
||||
assert_eq!(ctx.namespace_commit_generation(), 2);
|
||||
for dir in &dirs {
|
||||
let reopened = reopen_local_disk(dir).await;
|
||||
let restored = reopened
|
||||
.read_version(
|
||||
"",
|
||||
bucket,
|
||||
object,
|
||||
"",
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("old version must remain readable after rollback coordinator failure");
|
||||
assert_eq!(restored.data.as_deref(), Some(b"old-inline-body".as_slice()));
|
||||
}
|
||||
}
|
||||
assert!(receipt.is_incomplete(), "drained undo failure must survive in the receipt");
|
||||
for dir in dirs.iter().skip(1) {
|
||||
let reopened = reopen_local_disk(dir).await;
|
||||
let restored = reopened
|
||||
.read_version(
|
||||
"",
|
||||
bucket,
|
||||
object,
|
||||
"",
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("old version must remain readable after caller cancellation");
|
||||
assert_eq!(restored.data.as_deref(), Some(b"old-inline-body".as_slice()));
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -11001,9 +11147,35 @@ mod tests {
|
||||
let mut file_infos = rename_commit_fileinfos(object, DISKS, "fresh-rollback-etag");
|
||||
file_infos[3] = FileInfo::default();
|
||||
|
||||
SetDisks::rename_data(&disks, RUSTFS_META_TMP_BUCKET, "source", &file_infos, bucket, object, 4)
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
ctx.set_scanner_publication_state(false);
|
||||
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_ROLLBACK);
|
||||
let rename = SetDisks::rename_data_owned_with_fence(
|
||||
&disks,
|
||||
(RUSTFS_META_TMP_BUCKET, "source"),
|
||||
file_infos,
|
||||
(bucket, object),
|
||||
false,
|
||||
RenameDataFenceOptions::new(4, None).with_namespace_commit_guard(Some(ctx.begin_namespace_commit())),
|
||||
);
|
||||
let control = async {
|
||||
barrier.wait_until_paused().await;
|
||||
assert!(ctx.namespace_commits_pending(), "rollback must retain namespace publication ownership");
|
||||
assert!(ctx.scanner_publication_state_allowed(), "rollback must not disable namespace walks");
|
||||
assert_eq!(ctx.namespace_commit_generation(), 1);
|
||||
barrier.release();
|
||||
};
|
||||
let (result, ()) = tokio::time::timeout(BARRIER_PAUSE_GUARD, async { tokio::join!(rename, control) })
|
||||
.await
|
||||
.expect_err("three successful disks must fail a strict write quorum of four");
|
||||
.expect("rename rollback must reach its barrier and finish after release");
|
||||
assert_eq!(
|
||||
result.err(),
|
||||
Some(DiskError::ErasureWriteQuorum),
|
||||
"three successful disks must fail a strict write quorum of four"
|
||||
);
|
||||
assert!(!ctx.namespace_commits_pending());
|
||||
assert!(ctx.scanner_publication_state_allowed());
|
||||
assert_eq!(ctx.namespace_commit_generation(), 2);
|
||||
|
||||
for (idx, dir) in dirs.iter().enumerate() {
|
||||
let reopened = reopen_local_disk(dir).await;
|
||||
|
||||
@@ -2490,9 +2490,9 @@ impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
|
||||
return Ok((result, err.map(|e| e.into())));
|
||||
}
|
||||
|
||||
let disks = self.disks.read().await;
|
||||
|
||||
let disks = disks.clone();
|
||||
// The inner heal and missing-object report read the registry again;
|
||||
// release this snapshot guard before a topology writer can queue between reads.
|
||||
let disks = self.get_disks_internal().await;
|
||||
let (_, errs) = Self::read_all_fileinfo(&disks, "", bucket, object, version_id, false, false, false)
|
||||
.await
|
||||
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
|
||||
@@ -3419,6 +3419,366 @@ mod heal_result_report_tests {
|
||||
assert_eq!(unformatted, DiskError::UnformattedDisk);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum InventoryWriterHealCase {
|
||||
Existing,
|
||||
Missing,
|
||||
MissingVersion,
|
||||
}
|
||||
|
||||
async fn assert_heal_object_inventory_writer(case: InventoryWriterHealCase) {
|
||||
use crate::set_disk::core::io_primitives::disk_call_counters;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let (_temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
|
||||
let bucket = "heal-inventory-writer-bucket";
|
||||
let object = match case {
|
||||
InventoryWriterHealCase::Existing => "heal-inventory-writer-existing",
|
||||
InventoryWriterHealCase::Missing => "heal-inventory-writer-missing",
|
||||
InventoryWriterHealCase::MissingVersion => "heal-inventory-writer-missing-version",
|
||||
};
|
||||
set.make_bucket(
|
||||
bucket,
|
||||
&MakeBucketOptions {
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("heal fixture bucket should be created");
|
||||
let body = vec![0x67; 64 * 1024];
|
||||
let stored_version = Uuid::new_v4();
|
||||
let stored_version_string = stored_version.to_string();
|
||||
let published = if matches!(case, InventoryWriterHealCase::Missing) {
|
||||
None
|
||||
} else {
|
||||
let mut reader = PutObjReader::from_vec(body.clone());
|
||||
let info = set
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
versioned: true,
|
||||
version_id: Some(stored_version_string.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("full-fanout PUT should seed the heal fixture");
|
||||
for disk in &disks {
|
||||
let metadata = disk
|
||||
.read_version("", bucket, object, &stored_version_string, &ReadOptions::default())
|
||||
.await
|
||||
.expect("the seeded version must be present on every disk");
|
||||
assert_eq!(metadata.version_id, Some(stored_version));
|
||||
assert_eq!(metadata.size, i64::try_from(body.len()).expect("fixture size should fit i64"));
|
||||
}
|
||||
Some(info)
|
||||
};
|
||||
let requested_version = match case {
|
||||
InventoryWriterHealCase::Existing => stored_version_string.clone(),
|
||||
InventoryWriterHealCase::Missing => String::new(),
|
||||
InventoryWriterHealCase::MissingVersion => Uuid::new_v4().to_string(),
|
||||
};
|
||||
let opts = HealOpts {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
let calls = disk_call_counters::observe(object);
|
||||
let read_gate = set.disks.read().await;
|
||||
// UFCS selects the trait's outer precheck, not the same-named inherent heal.
|
||||
let heal = <SetDisks as crate::storage_api_contracts::heal::HealOperations>::heal_object(
|
||||
set.as_ref(),
|
||||
bucket,
|
||||
object,
|
||||
&requested_version,
|
||||
&opts,
|
||||
);
|
||||
tokio::pin!(heal);
|
||||
assert!(matches!(
|
||||
futures::poll!(tokio::task::unconstrained(heal.as_mut())),
|
||||
std::task::Poll::Pending
|
||||
));
|
||||
// These tests use the current-thread runtime: full-wait metadata tasks
|
||||
// have been spawned, but cannot run during the single unconstrained poll.
|
||||
assert_eq!(calls.total(disk_call_counters::KIND_READ_VERSION), 0);
|
||||
let writer = set.disks.write();
|
||||
tokio::pin!(writer);
|
||||
assert!(matches!(
|
||||
futures::poll!(tokio::task::unconstrained(writer.as_mut())),
|
||||
std::task::Poll::Pending
|
||||
));
|
||||
assert!(set.disks.try_read().is_err(), "the writer must already block new inventory readers");
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while calls.total(disk_call_counters::KIND_READ_VERSION) < 4 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the suspended trait heal must have started the real metadata fanout");
|
||||
for disk_index in 0..4 {
|
||||
assert_eq!(calls.for_disk(disk_call_counters::KIND_READ_VERSION, disk_index), 1);
|
||||
}
|
||||
drop(read_gate);
|
||||
|
||||
let (_, outcome) =
|
||||
tokio::time::timeout(Duration::from_secs(5), async { tokio::join!(async { drop(writer.await) }, heal) })
|
||||
.await
|
||||
.expect("trait heal must not deadlock its nested inventory read with the queued writer");
|
||||
let (result, error) = outcome.expect("heal should report the object's outcome");
|
||||
match case {
|
||||
InventoryWriterHealCase::Existing => assert!(error.is_none(), "existing object heal failed: {error:?}"),
|
||||
InventoryWriterHealCase::Missing => assert!(matches!(error, Some(Error::FileNotFound))),
|
||||
InventoryWriterHealCase::MissingVersion => assert!(matches!(error, Some(Error::FileVersionNotFound))),
|
||||
}
|
||||
assert_eq!(result.bucket, bucket);
|
||||
assert_eq!(result.object, object);
|
||||
assert_eq!(result.version_id, requested_version);
|
||||
assert_eq!(result.disk_count, 4);
|
||||
assert_eq!(result.before.drives.len(), 4);
|
||||
assert_eq!(result.after.drives.len(), 4);
|
||||
for disk_index in 0..4 {
|
||||
let endpoint = set.set_endpoints[disk_index].to_string();
|
||||
assert_eq!(result.before.drives[disk_index].endpoint, endpoint);
|
||||
assert_eq!(result.after.drives[disk_index].endpoint, endpoint);
|
||||
}
|
||||
if let Some(published) = published {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let mut reader = set
|
||||
.get_object_reader(
|
||||
bucket,
|
||||
object,
|
||||
None,
|
||||
Default::default(),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(stored_version_string),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the stored version must remain readable after heal");
|
||||
assert_eq!(reader.object_info.etag, published.etag);
|
||||
assert_eq!(reader.object_info.version_id, Some(stored_version));
|
||||
let mut observed_body = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut observed_body)
|
||||
.await
|
||||
.expect("stored body should stream");
|
||||
assert_eq!(observed_body, body);
|
||||
})
|
||||
.await
|
||||
.expect("GET must finish after the inventory writer and heal");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heal_object_inventory_writer_existing() {
|
||||
assert_heal_object_inventory_writer(InventoryWriterHealCase::Existing).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heal_object_inventory_writer_missing() {
|
||||
assert_heal_object_inventory_writer(InventoryWriterHealCase::Missing).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heal_object_inventory_writer_missing_version() {
|
||||
assert_heal_object_inventory_writer(InventoryWriterHealCase::MissingVersion).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn heal_object_with_queued_disk_renewal() {
|
||||
use crate::layout::endpoints::SetupType;
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::set_disk::core::io_primitives::disk_call_counters;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::task::Poll;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
// renew_disk still registers local disks on the ambient context. Match
|
||||
// the default serial group used by its other setup/registry fixtures,
|
||||
// and restore only this temporary endpoint, including on a failed join.
|
||||
struct RenewDiskTestState {
|
||||
ctx: Arc<InstanceContext>,
|
||||
was_dist_erasure: bool,
|
||||
map: Arc<RwLock<HashMap<String, Option<DiskStore>>>>,
|
||||
endpoint: String,
|
||||
previous_disk: Option<Option<DiskStore>>,
|
||||
}
|
||||
|
||||
impl Drop for RenewDiskTestState {
|
||||
fn drop(&mut self) {
|
||||
let ctx = self.ctx.clone();
|
||||
let was_dist_erasure = self.was_dist_erasure;
|
||||
let map = self.map.clone();
|
||||
let endpoint = self.endpoint.clone();
|
||||
let previous_disk = self.previous_disk.take();
|
||||
let handle = tokio::runtime::Handle::current();
|
||||
std::thread::spawn(move || {
|
||||
handle.block_on(async move {
|
||||
let mut map = map.write().await;
|
||||
match previous_disk {
|
||||
Some(disk) => {
|
||||
map.insert(endpoint, disk);
|
||||
}
|
||||
None => {
|
||||
map.remove(&endpoint);
|
||||
}
|
||||
}
|
||||
drop(map);
|
||||
if was_dist_erasure {
|
||||
ctx.update_erasure_type(SetupType::DistErasure).await;
|
||||
}
|
||||
});
|
||||
})
|
||||
.join()
|
||||
.expect("renew fixture state restoration should finish");
|
||||
}
|
||||
}
|
||||
|
||||
let (_temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
|
||||
let endpoint = set.set_endpoints[0].clone();
|
||||
let ctx = crate::runtime::global::current_ctx();
|
||||
let map = ctx.local_disk_map();
|
||||
let restore = RenewDiskTestState {
|
||||
ctx: ctx.clone(),
|
||||
was_dist_erasure: ctx.is_dist_erasure().await,
|
||||
map: map.clone(),
|
||||
endpoint: endpoint.to_string(),
|
||||
previous_disk: map.read().await.get(&endpoint.to_string()).cloned(),
|
||||
};
|
||||
// Only distributed erasure needs an override to avoid the ambient slot array.
|
||||
if restore.was_dist_erasure {
|
||||
ctx.update_erasure_type(SetupType::Erasure).await;
|
||||
}
|
||||
|
||||
let bucket = "heal-disk-renewal-bucket";
|
||||
let object = "heal-disk-renewal-object";
|
||||
set.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("renew fixture bucket should be created");
|
||||
let body = vec![0x73; 64 * 1024];
|
||||
let mut reader = PutObjReader::from_vec(body.clone());
|
||||
let published = set
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("full-fanout PUT should seed the renewal fixture");
|
||||
for disk in &disks {
|
||||
let metadata = disk
|
||||
.read_version("", bucket, object, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("the seeded object must be present on every disk");
|
||||
assert_eq!(metadata.size, i64::try_from(body.len()).expect("fixture size should fit i64"));
|
||||
}
|
||||
|
||||
let opts = HealOpts {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
let calls = disk_call_counters::observe(object);
|
||||
let read_gate = set.disks.read().await;
|
||||
let heal = <SetDisks as crate::storage_api_contracts::heal::HealOperations>::heal_object(
|
||||
set.as_ref(),
|
||||
bucket,
|
||||
object,
|
||||
"",
|
||||
&opts,
|
||||
);
|
||||
tokio::pin!(heal);
|
||||
assert!(matches!(futures::poll!(tokio::task::unconstrained(heal.as_mut())), Poll::Pending));
|
||||
assert_eq!(calls.total(disk_call_counters::KIND_READ_VERSION), 0);
|
||||
|
||||
let renew = set.renew_disk(&endpoint);
|
||||
tokio::pin!(renew);
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
futures::future::poll_fn(|cx| {
|
||||
assert!(
|
||||
std::pin::pin!(tokio::task::unconstrained(renew.as_mut()))
|
||||
.poll(cx)
|
||||
.is_pending(),
|
||||
"renewal must reach its inventory write before returning"
|
||||
);
|
||||
if set.disks.try_read().is_err() {
|
||||
Poll::Ready(())
|
||||
} else {
|
||||
Poll::Pending
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("real renewal must queue its topology writer behind the read gate");
|
||||
let registered = map
|
||||
.read()
|
||||
.await
|
||||
.get(&endpoint.to_string())
|
||||
.cloned()
|
||||
.flatten()
|
||||
.expect("renewal must register the connected disk before its inventory write");
|
||||
assert!(!Arc::ptr_eq(®istered, &disks[0]), "renewal must construct a new disk handle");
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while calls.total(disk_call_counters::KIND_READ_VERSION) < 4 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the suspended trait heal must have started the real metadata fanout");
|
||||
for disk_index in 0..4 {
|
||||
assert_eq!(calls.for_disk(disk_call_counters::KIND_READ_VERSION, disk_index), 1);
|
||||
}
|
||||
drop(read_gate);
|
||||
|
||||
let (_, outcome) = tokio::time::timeout(Duration::from_secs(5), async { tokio::join!(renew, heal) })
|
||||
.await
|
||||
.expect("trait heal and real disk renewal must finish without a nested inventory read deadlock");
|
||||
let (report, error) = outcome.expect("heal should report the existing object");
|
||||
assert!(error.is_none(), "existing object heal failed after renewal: {error:?}");
|
||||
assert_eq!(report.bucket, bucket);
|
||||
assert_eq!(report.object, object);
|
||||
assert_eq!(report.disk_count, 4);
|
||||
let renewed = set.get_disks_internal().await[0]
|
||||
.clone()
|
||||
.expect("the renewed slot must remain online");
|
||||
assert!(Arc::ptr_eq(&renewed, ®istered), "the set must publish the newly connected handle");
|
||||
assert_eq!(renewed.endpoint(), endpoint);
|
||||
let format = load_format_erasure(&renewed, false)
|
||||
.await
|
||||
.expect("renewed disk format should remain readable");
|
||||
assert_eq!(format.erasure.this, set.format.erasure.sets[0][0]);
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let mut reader = set
|
||||
.get_object_reader(bucket, object, None, Default::default(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the object must remain readable after renewal and heal");
|
||||
assert_eq!(reader.object_info.etag, published.etag);
|
||||
let mut observed_body = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut observed_body)
|
||||
.await
|
||||
.expect("stored body should stream");
|
||||
assert_eq!(observed_body, body);
|
||||
})
|
||||
.await
|
||||
.expect("GET must finish after renewal and heal");
|
||||
}
|
||||
|
||||
// Regression for #955: an offline disk must contribute exactly one drive
|
||||
// record. Before the fix the offline branch fell through and pushed a second
|
||||
// (Corrupt) record for the same disk, so `before/after.drives` grew to
|
||||
|
||||
@@ -4050,6 +4050,7 @@ mod tests {
|
||||
let _ = drain_global_dirty_scopes();
|
||||
|
||||
let rename_barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
||||
let rename_tasks = rename_fanout_barrier::observe_tasks(object);
|
||||
let complete_store = Arc::clone(&set_disks);
|
||||
let mut complete = tokio::spawn(async move {
|
||||
let mut opts = ObjectOptions::default();
|
||||
@@ -4061,16 +4062,6 @@ mod tests {
|
||||
tokio::time::timeout(Duration::from_secs(30), rename_barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("multipart completion should pause one tail disk during rename");
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), &mut complete).await.is_err(),
|
||||
"multipart completion must not publish success while a tail rename is still paused"
|
||||
);
|
||||
|
||||
let initial = drain_global_dirty_scopes().into_iter().collect::<HashSet<_>>();
|
||||
assert!(
|
||||
initial.is_empty(),
|
||||
"capacity must not be marked as committed before the full multipart rename finishes"
|
||||
);
|
||||
|
||||
let abort_store = Arc::clone(&set_disks);
|
||||
let abort = tokio::spawn(async move {
|
||||
@@ -4079,21 +4070,46 @@ mod tests {
|
||||
.await
|
||||
});
|
||||
signaling.wait_for_attempts(2).await;
|
||||
assert!(!abort.is_finished(), "the in-flight completion must retain the multipart upload guard");
|
||||
|
||||
let retained_staging = futures::future::join_all(
|
||||
disk_stores
|
||||
.iter()
|
||||
.map(|disk| disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &staged_part)),
|
||||
)
|
||||
// A paused rename does not establish that the other disks reached quorum.
|
||||
let retained_staging = tokio::time::timeout(Duration::from_secs(30), async {
|
||||
loop {
|
||||
let mut retained = 0;
|
||||
for result in futures::future::join_all(
|
||||
disk_stores
|
||||
.iter()
|
||||
.map(|disk| disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &staged_part)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
match result {
|
||||
Ok(_) => retained += 1,
|
||||
Err(DiskError::FileNotFound) => {}
|
||||
Err(error) => panic!("staged rename source lookup failed: {error}"),
|
||||
}
|
||||
}
|
||||
if retained <= 1 && rename_tasks.running() == 1 {
|
||||
break retained;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|result| result.is_ok())
|
||||
.count();
|
||||
.expect("unpaused multipart renames should finish before the tail is released");
|
||||
assert_eq!(
|
||||
retained_staging, 1,
|
||||
"only the paused tail disk should still retain the multipart rename source"
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), &mut complete).await.is_err(),
|
||||
"multipart completion must not publish success while a tail rename is still paused"
|
||||
);
|
||||
let initial = drain_global_dirty_scopes().into_iter().collect::<HashSet<_>>();
|
||||
assert!(
|
||||
initial.is_empty(),
|
||||
"capacity must not be marked as committed before the full multipart rename finishes"
|
||||
);
|
||||
assert!(!abort.is_finished(), "the in-flight completion must retain the multipart upload guard");
|
||||
|
||||
signaling.set_target(rustfs_lock::ObjectKey::new(bucket, object));
|
||||
let object_attempt = signaling.attempts.load(Ordering::Acquire) + 1;
|
||||
|
||||
@@ -4459,7 +4459,10 @@ impl SetDisks {
|
||||
commit_scanner_publication_lease_tokens.as_ref(),
|
||||
)
|
||||
.with_publication_scope(commit_scanner_publication_scope.clone())
|
||||
.with_rollback_receipt(commit_rollback_receipt.clone()),
|
||||
.with_rollback_receipt(commit_rollback_receipt.clone())
|
||||
.with_namespace_commit_guard(
|
||||
(!is_meta_bucketname(&commit_bucket)).then(|| commit_set.ctx.begin_namespace_commit()),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
if let Some(scope) = commit_scanner_publication_scope.as_ref() {
|
||||
|
||||
@@ -329,11 +329,11 @@ impl ECStore {
|
||||
/// reuse its result, which is sound because bucket deletion/recreation
|
||||
/// requires the lifecycle WRITE lock and therefore cannot have run while
|
||||
/// any read guard was continuously held.
|
||||
pub(crate) async fn acquire_bucket_incarnation_fence(
|
||||
pub async fn acquire_bucket_incarnation_fence(
|
||||
&self,
|
||||
bucket: &str,
|
||||
expected: uuid::Uuid,
|
||||
) -> Result<super::bucket_fence::BucketIncarnationFenceGuard> {
|
||||
) -> Result<super::BucketIncarnationFenceGuard> {
|
||||
let inner = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
||||
let pieces = super::bucket_fence::FencePieces {
|
||||
registry: self.bucket_fence_registry.clone(),
|
||||
@@ -1059,6 +1059,7 @@ mod tests {
|
||||
use crate::storage_api_contracts::{
|
||||
bucket::{BucketOperations as _, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp},
|
||||
list::ListOperations as _,
|
||||
namespace::NamespaceLocking as _,
|
||||
object::{ObjectIO as _, ObjectOperations as _},
|
||||
};
|
||||
use crate::store::{ECStore, init_local_disks_with_instance_ctx};
|
||||
@@ -1486,10 +1487,19 @@ mod tests {
|
||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("object should be written");
|
||||
let lock = ecstore.pools[0].disk_set[0]
|
||||
.new_ns_lock(bucket, object)
|
||||
.await
|
||||
.expect("fixture namespace lock should be created");
|
||||
drop(
|
||||
lock.get_write_lock(Duration::from_secs(30))
|
||||
.await
|
||||
.expect("fixture rename tail should finish before checking its generation"),
|
||||
);
|
||||
assert_eq!(
|
||||
ecstore.scanner_namespace_mutation_generation(),
|
||||
generation_before_put.saturating_add(1),
|
||||
"successful object creation should advance scanner namespace activity"
|
||||
generation_before_put.saturating_add(3),
|
||||
"successful object creation must observe the logical mutation and both fanout boundaries"
|
||||
);
|
||||
ecstore
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
|
||||
@@ -150,7 +150,7 @@ impl BucketFenceRegistry {
|
||||
/// A held bucket lifecycle read lock plus its registration in the fence
|
||||
/// registry. Dropping the guard deregisters it; the memo is cleared when the
|
||||
/// last guard for the bucket drops (or a lost lock is observed).
|
||||
pub(crate) struct BucketIncarnationFenceGuard {
|
||||
pub struct BucketIncarnationFenceGuard {
|
||||
inner: Option<NamespaceLockGuard>,
|
||||
registry: Arc<BucketFenceRegistry>,
|
||||
bucket: String,
|
||||
@@ -158,6 +158,14 @@ pub(crate) struct BucketIncarnationFenceGuard {
|
||||
}
|
||||
|
||||
impl BucketIncarnationFenceGuard {
|
||||
/// Propagate lifecycle lock loss into the storage commit checks.
|
||||
/// The caller still owns this guard until the complete write tail drains.
|
||||
pub fn attach_to_object_options(&self, opts: &mut crate::object_api::ObjectOptions) {
|
||||
if let Some(guard) = self.namespace_lock_guard() {
|
||||
opts.add_bucket_lifecycle_lock_guard(guard);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||
self.inner.as_ref().is_some_and(NamespaceLockGuard::is_lock_lost)
|
||||
}
|
||||
@@ -346,6 +354,36 @@ mod tests {
|
||||
first_pieces.abandon("b", first.token);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkpoint_options_inherit_bucket_fence_lock_loss() {
|
||||
let lock = NamespaceLock::new("bucket-fence-options".to_string(), Arc::new(LocalClient::new()));
|
||||
let inner = lock
|
||||
.acquire_guard(&lock_request("options"))
|
||||
.await
|
||||
.expect("acquire")
|
||||
.expect("quorum");
|
||||
let pieces = FencePieces {
|
||||
registry: Arc::default(),
|
||||
inner,
|
||||
};
|
||||
let registration = pieces.enter("b");
|
||||
let fence = pieces.into_guard("b", registration.token);
|
||||
let mut opts = crate::object_api::ObjectOptions::default();
|
||||
fence.attach_to_object_options(&mut opts);
|
||||
let inherited = opts
|
||||
.bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.expect("checkpoint inherits lifecycle guard");
|
||||
assert!(!inherited.is_lock_lost());
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
fence.namespace_lock_guard().expect("held guard").lock_lost_notified(),
|
||||
)
|
||||
.await
|
||||
.expect("distributed guard expires");
|
||||
assert!(inherited.is_lock_lost(), "the actual pre-rename options must observe lifecycle lock loss");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buckets_are_isolated() {
|
||||
let reg = BucketFenceRegistry::default();
|
||||
|
||||
@@ -353,6 +353,11 @@ async fn resume_rebalance_after_init(store: Arc<ECStore>, rx: CancellationToken)
|
||||
}
|
||||
|
||||
impl ECStore {
|
||||
/// Shutdown token owned by this store instance.
|
||||
pub fn background_cancel_token(&self) -> Option<CancellationToken> {
|
||||
self.ctx.background_cancel_token()
|
||||
}
|
||||
|
||||
/// Validate topology and process storage-class overrides before any disk is opened.
|
||||
pub fn validate_startup_storage_class(endpoint_pools: &EndpointServerPools) -> Result<()> {
|
||||
let drive_counts = startup_pool_drive_counts(endpoint_pools);
|
||||
@@ -787,6 +792,12 @@ impl ECStore {
|
||||
pub fn single_pool(&self) -> bool {
|
||||
self.pools.len() == 1
|
||||
}
|
||||
|
||||
/// The set-local create-only check is atomic only when every object
|
||||
/// mutation uses that same, enabled namespace lock domain.
|
||||
pub fn supports_atomic_create_only_write_back(&self) -> bool {
|
||||
!self.ctx.lock_manager().is_disabled() && self.pools.len() == 1 && self.pools[0].disk_set.len() == 1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2127,7 +2138,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|&drives_per_set| (1, drives_per_set))
|
||||
.collect::<Vec<_>>();
|
||||
build_isolated_test_store_with_layout(temp_dir, cmd_line, &pool_layouts, shutdown).await
|
||||
build_isolated_test_store_with_layout(temp_dir, cmd_line, &pool_layouts, shutdown, None).await
|
||||
}
|
||||
|
||||
async fn build_isolated_test_store_with_layout(
|
||||
@@ -2135,6 +2146,7 @@ mod tests {
|
||||
cmd_line: &str,
|
||||
pool_layouts: &[(usize, usize)],
|
||||
shutdown: CancellationToken,
|
||||
instance_ctx: Option<Arc<crate::runtime::instance::InstanceContext>>,
|
||||
) -> (
|
||||
Arc<crate::runtime::instance::InstanceContext>,
|
||||
Arc<crate::store::ECStore>,
|
||||
@@ -2167,7 +2179,7 @@ mod tests {
|
||||
let endpoint_pools = EndpointServerPools(pools);
|
||||
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
|
||||
|
||||
let instance_ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let instance_ctx = instance_ctx.unwrap_or_else(|| Arc::new(crate::runtime::instance::InstanceContext::new()));
|
||||
crate::store::init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
|
||||
.await
|
||||
.expect("register local disks into the fresh context");
|
||||
@@ -2535,6 +2547,348 @@ mod tests {
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn early_ack_put_tails_block_scanner_publication_until_all_renames_finish() {
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create scanner PUT tail store dir");
|
||||
let (ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "scanner-put-tails", &[4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = format!("scanner-put-tails-{}", Uuid::new_v4());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create scanner PUT tail bucket");
|
||||
let set = &store.pools[0].disk_set[0];
|
||||
let objects = [("scanner-tail-a", vec![0xA1; 273]), ("scanner-tail-b", vec![0xB2; 379])];
|
||||
|
||||
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||
let (active, blocked, movement_generation) = store.scanner_data_movement_activity().await;
|
||||
assert!(!active && !blocked);
|
||||
assert!(ctx.scanner_publication_state_allowed(), "the set admission cache should start allowed");
|
||||
let (old_lease, _) = store
|
||||
.acquire_scanner_publication_lease(movement_generation, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||
.await
|
||||
.expect("publication lease should be admitted before either PUT starts");
|
||||
|
||||
let barriers: Vec<_> = objects
|
||||
.iter()
|
||||
.map(|(object, _)| {
|
||||
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME)
|
||||
})
|
||||
.collect();
|
||||
let trackers: Vec<_> = objects
|
||||
.iter()
|
||||
.map(|(object, _)| crate::set_disk::rename_fanout_barrier::observe_tasks(object))
|
||||
.collect();
|
||||
let puts: Vec<_> = objects
|
||||
.iter()
|
||||
.map(|(object, body)| {
|
||||
let put_store = Arc::clone(&store);
|
||||
let put_bucket = bucket.clone();
|
||||
let object = *object;
|
||||
let body = body.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::from_vec(body);
|
||||
put_store
|
||||
.put_object(&put_bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let committed = tokio::time::timeout(Duration::from_secs(30), async {
|
||||
for barrier in &barriers {
|
||||
barrier.wait_until_paused().await;
|
||||
}
|
||||
let mut committed = Vec::with_capacity(puts.len());
|
||||
for put in puts {
|
||||
committed.push(
|
||||
put.await
|
||||
.expect("early-ACK PUT task should join while its tail is paused")
|
||||
.expect("root PUT should return after quorum without waiting for its tail"),
|
||||
);
|
||||
}
|
||||
committed
|
||||
})
|
||||
.await
|
||||
.expect("both root PUTs must quorum-ACK while their tail disks remain paused");
|
||||
|
||||
assert!(trackers.iter().all(|tracker| tracker.running() >= 1));
|
||||
assert!(ctx.namespace_commits_pending());
|
||||
assert!(
|
||||
ctx.scanner_publication_state_allowed(),
|
||||
"pending PUT tails must not disable scanner namespace walks"
|
||||
);
|
||||
let (active, blocked, observed_movement_generation) = store.scanner_data_movement_activity().await;
|
||||
assert!(!active, "ordinary PUT tails are not decommission or rebalance work");
|
||||
assert!(!blocked, "ordinary PUT tails must not block the movement-only scan baseline");
|
||||
assert_eq!(observed_movement_generation, movement_generation);
|
||||
assert!(store.scanner_data_usage_publication_blocked().await);
|
||||
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||
assert!(set.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||
for error in [
|
||||
store
|
||||
.acquire_scanner_publication_lease(
|
||||
movement_generation,
|
||||
crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL,
|
||||
)
|
||||
.await
|
||||
.expect_err("a new remote publication lease must reject pending PUT tails"),
|
||||
store
|
||||
.validate_scanner_publication_lease(old_lease, movement_generation)
|
||||
.await
|
||||
.expect_err("an existing remote lease must not bypass pending PUT tails"),
|
||||
store
|
||||
.acquire_scanner_publication_lease_guard(old_lease)
|
||||
.await
|
||||
.expect_err("target-side publication admission must reject pending PUT tails"),
|
||||
] {
|
||||
assert!(
|
||||
error.to_string().contains("blocked"),
|
||||
"publication must fail because of active tails: {error}"
|
||||
);
|
||||
}
|
||||
store.release_scanner_publication_lease(old_lease).await;
|
||||
|
||||
for (index, barrier) in barriers.iter().enumerate() {
|
||||
let commit_generation = ctx.namespace_commit_generation();
|
||||
let namespace_generation = store.scanner_namespace_mutation_generation();
|
||||
barrier.release();
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while trackers[index].running() != 0 || ctx.namespace_commit_generation() <= commit_generation {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
if index + 1 == barriers.len() {
|
||||
while ctx.namespace_commits_pending() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("released tail must drain and publish its terminal namespace generation");
|
||||
assert!(store.scanner_namespace_mutation_generation() > namespace_generation);
|
||||
let pending = index + 1 < barriers.len();
|
||||
assert_eq!(ctx.namespace_commits_pending(), pending);
|
||||
assert_eq!(store.scanner_data_usage_publication_blocked().await, pending);
|
||||
assert!(!store.scanner_data_movement_activity().await.1);
|
||||
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||
assert!(set.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||
}
|
||||
|
||||
let (lease, generation) = store
|
||||
.acquire_scanner_publication_lease(movement_generation, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||
.await
|
||||
.expect("remote publication lease should resume after both tails drain");
|
||||
store
|
||||
.validate_scanner_publication_lease(lease, generation)
|
||||
.await
|
||||
.expect("a resumed remote publication lease should validate");
|
||||
drop(
|
||||
store
|
||||
.acquire_scanner_publication_lease_guard(lease)
|
||||
.await
|
||||
.expect("target-side publication admission should resume after both tails drain"),
|
||||
);
|
||||
assert!(store.release_scanner_publication_lease(lease).await);
|
||||
|
||||
let disks = set.disk_inventory().await;
|
||||
assert_eq!(disks.len(), 4);
|
||||
for ((object, body), committed) in objects.iter().zip(&committed) {
|
||||
let logical_size = i64::try_from(body.len()).expect("fixture payload size should fit i64");
|
||||
let etag = committed.etag.as_ref().expect("root PUT should return a committed ETag");
|
||||
for (disk_index, disk) in disks.iter().enumerate() {
|
||||
let file_info = disk
|
||||
.as_ref()
|
||||
.expect("every fixture disk should remain online")
|
||||
.read_version(
|
||||
"",
|
||||
&bucket,
|
||||
object,
|
||||
"",
|
||||
&crate::disk::ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("disk {disk_index} should publish {object} after its tail finishes: {err}"));
|
||||
assert_eq!(file_info.size, logical_size);
|
||||
assert_eq!(file_info.metadata.get(http::header::ETAG.as_str()), Some(etag));
|
||||
assert!(
|
||||
file_info.inline_data(),
|
||||
"small fixture payloads should have an inline shard on every disk"
|
||||
);
|
||||
let inline_data = file_info.data.as_ref().expect("every disk should retain its inline shard");
|
||||
let erasure = crate::erasure::coding::Erasure::try_new_with_options(
|
||||
file_info.erasure.data_blocks,
|
||||
file_info.erasure.parity_blocks,
|
||||
file_info.erasure.block_size,
|
||||
file_info.uses_legacy_checksum,
|
||||
)
|
||||
.expect("persisted erasure geometry should be valid");
|
||||
let shard_size =
|
||||
usize::try_from(erasure.shard_file_size(logical_size)).expect("fixture shard size should fit usize");
|
||||
crate::erasure::coding::bitrot_verify(
|
||||
Cursor::new(inline_data.clone()),
|
||||
inline_data.len(),
|
||||
shard_size,
|
||||
rustfs_utils::HashAlgorithm::HighwayHash256S,
|
||||
erasure.shard_size(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("disk {disk_index} should retain a complete valid shard for {object}: {err}"));
|
||||
}
|
||||
let mut reader = store
|
||||
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("fully drained PUT should be readable");
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.expect("PUT body should drain");
|
||||
assert_eq!(&actual, body);
|
||||
}
|
||||
|
||||
let generation_before_internal_put = ctx.namespace_commit_generation();
|
||||
let internal_object = "scanner-tail-regression/internal-metadata";
|
||||
let internal_body = b"scanner metadata must not invalidate its own publication";
|
||||
let mut internal_reader = PutObjReader::from_vec(internal_body.to_vec());
|
||||
store
|
||||
.put_object(RUSTFS_META_BUCKET, internal_object, &mut internal_reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("internal metadata PUT should commit without scanner self-invalidation");
|
||||
let internal_lock = set
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, internal_object)
|
||||
.await
|
||||
.expect("internal metadata tail lock should be available");
|
||||
drop(
|
||||
internal_lock
|
||||
.get_write_lock(Duration::from_secs(30))
|
||||
.await
|
||||
.expect("internal metadata tail should drain"),
|
||||
);
|
||||
assert_eq!(ctx.namespace_commit_generation(), generation_before_internal_put);
|
||||
assert!(!ctx.namespace_commits_pending());
|
||||
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||
assert!(set.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||
let mut internal_reader = store
|
||||
.get_object_reader(RUSTFS_META_BUCKET, internal_object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("internal metadata should remain readable");
|
||||
let mut actual = Vec::new();
|
||||
internal_reader
|
||||
.stream
|
||||
.read_to_end(&mut actual)
|
||||
.await
|
||||
.expect("internal metadata body should drain");
|
||||
assert_eq!(actual, internal_body);
|
||||
})
|
||||
.await;
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn cancelled_early_ack_put_keeps_scanner_publication_blocked_until_tail_finishes() {
|
||||
let temp_dir = tempfile::tempdir().expect("create cancelled scanner PUT tail store dir");
|
||||
let (ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "scanner-cancelled-put-tail", &[4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = format!("scanner-cancelled-put-tail-{}", Uuid::new_v4());
|
||||
let object = "scanner-cancelled-tail";
|
||||
let body = vec![0xC3; 273];
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create cancelled scanner PUT tail bucket");
|
||||
|
||||
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||
let tracker = crate::set_disk::rename_fanout_barrier::observe_tasks(object);
|
||||
let tail =
|
||||
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME);
|
||||
let quorum = crate::set_disk::PutObjectCommitBarrier::install(
|
||||
&bucket,
|
||||
object,
|
||||
crate::set_disk::PutObjectCommitPause::AfterRenameQuorum,
|
||||
);
|
||||
let handoff = crate::set_disk::PutObjectCommitBarrier::install(
|
||||
&bucket,
|
||||
object,
|
||||
crate::set_disk::PutObjectCommitPause::AfterRenameHandoff,
|
||||
);
|
||||
let put_store = Arc::clone(&store);
|
||||
let put_bucket = bucket.clone();
|
||||
let put_body = body.clone();
|
||||
let put = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::from_vec(put_body);
|
||||
put_store
|
||||
.put_object(&put_bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(30), tail.wait_until_paused())
|
||||
.await
|
||||
.expect("cancelled PUT should pause one disk before rename");
|
||||
quorum.wait_until_paused().await;
|
||||
put.abort();
|
||||
assert!(
|
||||
put.await
|
||||
.expect_err("caller should be cancelled after rename quorum")
|
||||
.is_cancelled()
|
||||
);
|
||||
quorum.release();
|
||||
handoff.wait_until_paused().await;
|
||||
assert!(tracker.running() >= 1);
|
||||
assert!(ctx.namespace_commits_pending());
|
||||
assert!(!store.scanner_data_movement_activity().await.1);
|
||||
assert!(store.scanner_data_usage_publication_blocked().await);
|
||||
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||
assert!(
|
||||
store.pools[0].disk_set[0]
|
||||
.scanner_data_usage_publication_admission_guard()
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
let generation = store.scanner_namespace_mutation_generation();
|
||||
|
||||
handoff.release();
|
||||
tail.release();
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while tracker.running() != 0 || ctx.namespace_commits_pending() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("cancelled request's detached fanout must release scanner admission after finishing");
|
||||
assert!(store.scanner_namespace_mutation_generation() > generation);
|
||||
assert!(!store.scanner_data_usage_publication_blocked().await);
|
||||
assert!(store.scanner_data_usage_publication_admission_guard().await.is_some());
|
||||
for (disk_index, disk) in store.pools[0].disk_set[0].disk_inventory().await.iter().enumerate() {
|
||||
let file_info = disk
|
||||
.as_ref()
|
||||
.expect("cancelled PUT fixture disk should remain online")
|
||||
.read_version("", &bucket, object, "", &crate::disk::ReadOptions::default())
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("cancelled PUT must still publish on disk {disk_index}: {err}"));
|
||||
assert_eq!(file_info.size, i64::try_from(body.len()).expect("fixture body size should fit i64"));
|
||||
}
|
||||
let mut reader = store
|
||||
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("a cancelled caller must not discard its quorum-committed object");
|
||||
let mut actual = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut actual)
|
||||
.await
|
||||
.expect("cancelled PUT body should drain");
|
||||
assert_eq!(actual, body);
|
||||
})
|
||||
.await;
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
@@ -2986,8 +3340,9 @@ mod tests {
|
||||
) -> crate::core::pools::DecommissionTestFaultDecision {
|
||||
let target_bucket = bucket.to_string();
|
||||
let target_object = object.to_string();
|
||||
Arc::new(move |stage, bucket, object, _attempt, succeeded| {
|
||||
Arc::new(move |stage, bucket, object, attempt, succeeded| {
|
||||
if !succeeded
|
||||
|| attempt >= crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS
|
||||
|| stage != DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT
|
||||
|| bucket != target_bucket
|
||||
|| object != target_object
|
||||
@@ -2997,6 +3352,7 @@ mod tests {
|
||||
|
||||
// Entry retries reset the local attempt; real copy errors can skip
|
||||
// successful attempts. Only injected faults spend this global budget.
|
||||
// A real failure may consume an attempt, so preserve the final chance.
|
||||
faults
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |faults| {
|
||||
(faults < crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS.saturating_sub(1))
|
||||
@@ -5018,6 +5374,7 @@ mod tests {
|
||||
"decommission-delete-fence",
|
||||
&[(2, 4), (1, 4)],
|
||||
CancellationToken::new(),
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
@@ -5149,7 +5506,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn decommission_retry_fault_budget_counts_successes_across_attempt_changes() {
|
||||
for attempts in [[1, 2, 3], [1, 1, 2], [1, 3, 3]] {
|
||||
let cases: &[&[(usize, bool, bool)]] = &[
|
||||
&[(1, true, true), (2, true, true), (3, true, false)],
|
||||
&[(1, true, true), (1, true, true), (2, true, false)],
|
||||
&[(1, true, true), (3, true, false), (3, true, false)],
|
||||
&[(1, true, true), (2, false, false), (1, true, true), (2, true, false)],
|
||||
&[(1, true, true), (2, false, false), (3, true, false)],
|
||||
&[(3, true, false), (4, true, false)],
|
||||
];
|
||||
for case in cases {
|
||||
let faults = Arc::new(AtomicUsize::new(0));
|
||||
let hook = decommission_retry_fault_hook("bucket", "object", Arc::clone(&faults));
|
||||
|
||||
@@ -5163,14 +5528,16 @@ mod tests {
|
||||
}
|
||||
assert_eq!(faults.load(Ordering::SeqCst), 0, "unrelated or failed copies must not consume faults");
|
||||
|
||||
for (index, attempt) in attempts.into_iter().enumerate() {
|
||||
let mut expected_faults = 0;
|
||||
for &(attempt, succeeded, expected) in *case {
|
||||
assert_eq!(
|
||||
hook(DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT, "bucket", "object", attempt, true),
|
||||
index < 2,
|
||||
"attempts={attempts:?}, index={index}"
|
||||
hook(DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT, "bucket", "object", attempt, succeeded),
|
||||
expected,
|
||||
"fault plan {case:?} at attempt {attempt}"
|
||||
);
|
||||
expected_faults += usize::from(expected);
|
||||
assert_eq!(faults.load(Ordering::SeqCst), expected_faults);
|
||||
}
|
||||
assert_eq!(faults.load(Ordering::SeqCst), 2, "attempts={attempts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5306,6 +5673,15 @@ mod tests {
|
||||
changed_result.expect("SourceChanged entry retry must converge");
|
||||
other_result.expect("other bucket entry must continue through ordinary copy retries");
|
||||
|
||||
assert_eq!(
|
||||
store.pool_meta.read().await.pools[0]
|
||||
.decommission
|
||||
.as_ref()
|
||||
.expect("decommission progress should remain available")
|
||||
.items_decommission_failed,
|
||||
0,
|
||||
"entry completion must not hide an exhausted copy failure"
|
||||
);
|
||||
assert!(!rx.is_cancelled(), "entry-level SourceChanged must not cancel the shared worker token");
|
||||
assert_eq!(mutation_calls.load(Ordering::SeqCst), 2, "entry must be re-listed after SourceChanged");
|
||||
assert_eq!(ordinary_faults.load(Ordering::SeqCst), 2, "ordinary copy must consume the retry budget");
|
||||
@@ -5934,6 +6310,7 @@ mod tests {
|
||||
"reverse-decommission-fixed-target",
|
||||
&[(1, 4), (1, 4)],
|
||||
CancellationToken::new(),
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
@@ -6355,6 +6732,7 @@ mod tests {
|
||||
"multi-set-decommission-source-cleanup",
|
||||
&[(2, 4)],
|
||||
CancellationToken::new(),
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
@@ -8870,18 +9248,17 @@ mod tests {
|
||||
const MANIFEST_COUNT: usize = 10;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create fast manifest pass recovery store dir");
|
||||
let (ctx, store, _shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-delete-fast-manifest-pass", &[4])).await;
|
||||
let mut instance_ctx = crate::runtime::instance::InstanceContext::new();
|
||||
instance_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
|
||||
temp_dir.path(),
|
||||
"tier-delete-fast-manifest-pass",
|
||||
&[(1, 4)],
|
||||
CancellationToken::new(),
|
||||
Some(Arc::new(instance_ctx)),
|
||||
))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = "tier-delete-fast-manifest-pass-bucket";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("fast manifest pass bucket should be created");
|
||||
let incarnation = store
|
||||
.bucket_incarnation_id(bucket)
|
||||
.await
|
||||
.expect("fast manifest pass bucket incarnation should resolve");
|
||||
let tier_name = "FAST-MANIFEST-PASS";
|
||||
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
|
||||
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
|
||||
@@ -8889,9 +9266,19 @@ mod tests {
|
||||
.expect("fast manifest pass tier lease should resolve")
|
||||
.backend_identity();
|
||||
for index in 0..MANIFEST_COUNT {
|
||||
// Pagination must not depend on same-bucket lock wait deadlines.
|
||||
let bucket = format!("tier-delete-fast-manifest-pass-{index}");
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("fast manifest pass bucket should be created");
|
||||
let incarnation = store
|
||||
.bucket_incarnation_id(&bucket)
|
||||
.await
|
||||
.expect("fast manifest pass bucket incarnation should resolve");
|
||||
install_aborting_dispatch_fixture(
|
||||
store.clone(),
|
||||
bucket,
|
||||
&bucket,
|
||||
incarnation,
|
||||
&format!("manifest-page-{index:06}/"),
|
||||
tier_name,
|
||||
@@ -8922,12 +9309,78 @@ mod tests {
|
||||
"one production pass must cross the default eight-manifest page limit"
|
||||
);
|
||||
assert_eq!(stats.manifests.scanned, MANIFEST_COUNT);
|
||||
assert_eq!(stats.manifests.deleted, MANIFEST_COUNT);
|
||||
assert_eq!(stats.manifests.failed, 0);
|
||||
assert_eq!(stats.manifests.deleted, MANIFEST_COUNT, "full recovery result: {stats:?}");
|
||||
assert_eq!(stats.manifests.failed, 0, "full recovery result: {stats:?}");
|
||||
assert_eq!(manifest_marker, None);
|
||||
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
|
||||
assert_eq!(tier_delete_journal_count(store).await, 0);
|
||||
assert_eq!(backend.remove_count().await, 0, "rollback recovery must not call the remote tier");
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn tier_delete_manual_pass_retains_manifest_owned_by_startup_recovery() {
|
||||
let temp_dir = tempfile::tempdir().expect("create automatic recovery ownership store dir");
|
||||
let (ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-delete-auto-owner", &[4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = "tier-delete-auto-owner-bucket";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("automatic recovery bucket should be created");
|
||||
let incarnation = store.bucket_incarnation_id(bucket).await.expect("bucket incarnation");
|
||||
let tier_name = "AUTO-OWNER";
|
||||
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
|
||||
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
|
||||
.await
|
||||
.expect("automatic recovery tier lease")
|
||||
.backend_identity();
|
||||
|
||||
// The automatic worker must not observe a partially installed fixture.
|
||||
let lifecycle_guard = store
|
||||
.acquire_bucket_lifecycle_write_lock(bucket)
|
||||
.await
|
||||
.expect("fixture lifecycle lock");
|
||||
let (manifest_name, entries) =
|
||||
install_aborting_dispatch_fixture(store.clone(), bucket, incarnation, "auto-owner/", tier_name, identity, 1).await;
|
||||
let journal_name = tier_delete_journal_object_name(&entries[0]);
|
||||
let hook = TierDeleteDispatchRollbackTestHook::install_slow_delete(&journal_name, &journal_name);
|
||||
drop(lifecycle_guard);
|
||||
ctx.wake_tier_delete_journal_recovery();
|
||||
tokio::time::timeout(Duration::from_secs(30), hook.wait_until_delete_paused())
|
||||
.await
|
||||
.expect("startup recovery should own the manifest before a manual pass");
|
||||
assert!(tier_delete_dispatch_manifest_recovery_inflight_for_test(&store, &manifest_name));
|
||||
|
||||
let stats = recover_tier_delete_dispatch_manifests(store.clone(), 8, None)
|
||||
.await
|
||||
.expect("manual recovery scan");
|
||||
assert_eq!(stats.scanned, 1, "{stats:?}");
|
||||
assert_eq!(stats.retained, 1, "{stats:?}");
|
||||
assert_eq!(stats.deleted, 0, "{stats:?}");
|
||||
assert_eq!(stats.failed, 0, "{stats:?}");
|
||||
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 1);
|
||||
assert_eq!(tier_delete_journal_count(store.clone()).await, 1);
|
||||
|
||||
hook.release_delete();
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
loop {
|
||||
let manifest_gone = matches!(com::read_config(store.clone(), &manifest_name).await, Err(Error::ConfigNotFound));
|
||||
if manifest_gone && !tier_delete_dispatch_manifest_recovery_inflight_for_test(&store, &manifest_name) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("automatic recovery should converge without a manual retry");
|
||||
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
|
||||
assert_eq!(tier_delete_journal_count(store).await, 0);
|
||||
assert_eq!(backend.remove_count().await, 0, "rollback must not delete from the remote tier");
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
@@ -10302,8 +10755,17 @@ mod tests {
|
||||
const JOURNAL_COUNT: usize = 40;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create rollback retry store dir");
|
||||
let (ctx, store, _shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-rollback-retry", &[4])).await;
|
||||
// Manual retries must own progress between fault removal and the next attempt.
|
||||
let mut instance_ctx = crate::runtime::instance::InstanceContext::new();
|
||||
instance_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
|
||||
temp_dir.path(),
|
||||
"dispatch-rollback-retry",
|
||||
&[(1, 4)],
|
||||
CancellationToken::new(),
|
||||
Some(Arc::new(instance_ctx)),
|
||||
))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = "dispatch-rollback-retry-bucket";
|
||||
store
|
||||
@@ -10377,6 +10839,7 @@ mod tests {
|
||||
|
||||
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
|
||||
assert_eq!(backend.remove_count().await, 0, "rollback retries must never call the remote tier");
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
@@ -13204,6 +13667,7 @@ mod tests {
|
||||
"partial-set-prefix-delete",
|
||||
&[(2, 4)],
|
||||
CancellationToken::new(),
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
@@ -16576,6 +17040,7 @@ mod tests {
|
||||
"prepared-directory-recovery",
|
||||
&[(2, 4)],
|
||||
shutdown,
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
@@ -17228,6 +17693,38 @@ mod tests {
|
||||
.expect("test thread should complete");
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn odm_write_back_requires_one_set_and_enabled_namespace_locking() {
|
||||
for (layout, locking, supported) in [
|
||||
(&[(1, 4)][..], true, true),
|
||||
(&[(1, 4), (1, 4)][..], true, false),
|
||||
(&[(2, 4)][..], true, false),
|
||||
(&[(1, 4)][..], false, false),
|
||||
] {
|
||||
temp_env::async_with_vars([("RUSTFS_LOCK_ENABLED", Some(if locking { "true" } else { "false" }))], async {
|
||||
let dir = tempfile::tempdir().expect("isolated topology");
|
||||
let shutdown = CancellationToken::new();
|
||||
let (_ctx, store, _) = without_storage_class_env(build_isolated_test_store_with_layout(
|
||||
dir.path(),
|
||||
"odm-topology",
|
||||
layout,
|
||||
shutdown.clone(),
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
assert_eq!(
|
||||
store.supports_atomic_create_only_write_back(),
|
||||
supported,
|
||||
"layout={layout:?}, locking={locking}"
|
||||
);
|
||||
shutdown.cancel();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
|
||||
@@ -417,6 +417,7 @@ const MAX_UPLOADS_LIST: usize = 10000;
|
||||
mod bucket;
|
||||
mod bucket_fence;
|
||||
pub(crate) use bucket::await_bucket_namespace_operation;
|
||||
pub use bucket_fence::BucketIncarnationFenceGuard;
|
||||
mod heal;
|
||||
mod heal_walk;
|
||||
pub use heal_walk::HealWalkVersion;
|
||||
@@ -848,7 +849,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
pub fn scanner_namespace_mutation_generation(&self) -> u64 {
|
||||
list_objects::scanner_namespace_mutation_generation()
|
||||
list_objects::scanner_namespace_mutation_generation().saturating_add(self.ctx.namespace_commit_generation())
|
||||
}
|
||||
|
||||
pub async fn scanner_data_movement_active(&self) -> bool {
|
||||
@@ -857,7 +858,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
/// Return the storage-owned movement state and generation as one
|
||||
/// authenticated activity snapshot. The read lock is acquired before
|
||||
/// authenticated activity snapshot. The read lock is acquired before
|
||||
/// the state locks (cancelers, pool metadata, then rebalance metadata),
|
||||
/// matching the transition writer order and preventing a terminal state
|
||||
/// from being reported with the preceding generation.
|
||||
@@ -886,11 +887,12 @@ impl ECStore {
|
||||
/// Returns whether scanner metadata may still be hidden by a local
|
||||
/// data-movement state. Terminal failed/canceled decommission entries
|
||||
/// remain suspended until an operator clears or retries them, so they are
|
||||
/// a publication barrier even after the worker has stopped.
|
||||
/// a publication barrier even after the worker has stopped. Active PUT
|
||||
/// rename fanouts also defer publication, including post-ACK tails.
|
||||
pub async fn scanner_data_usage_publication_blocked(&self) -> bool {
|
||||
let operation_gate = self.ctx.data_movement_operation_gate();
|
||||
let _operation_guard = operation_gate.read_owned().await;
|
||||
self.scanner_data_usage_publication_snapshot_blocked().await
|
||||
self.scanner_data_usage_publication_snapshot_blocked().await || self.ctx.namespace_commits_pending()
|
||||
}
|
||||
|
||||
pub async fn scanner_data_movement_pause_status(&self) -> ScannerDataMovementPauseStatus {
|
||||
@@ -1070,7 +1072,7 @@ impl ECStore {
|
||||
{
|
||||
return Err(Error::other("scanner publication lease generation is stale"));
|
||||
}
|
||||
if self.scanner_data_movement_snapshot_locked().await.1 {
|
||||
if self.scanner_data_movement_snapshot_locked().await.1 || self.ctx.namespace_commits_pending() {
|
||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||
}
|
||||
|
||||
@@ -1109,7 +1111,7 @@ impl ECStore {
|
||||
{
|
||||
return Err(Error::other("scanner publication lease generation is stale"));
|
||||
}
|
||||
if self.scanner_data_movement_snapshot_locked().await.1 {
|
||||
if self.scanner_data_movement_snapshot_locked().await.1 || self.ctx.namespace_commits_pending() {
|
||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||
}
|
||||
if !self.ctx.scanner_publication_lease_is_active(token).await {
|
||||
@@ -1129,7 +1131,7 @@ impl ECStore {
|
||||
if self.ctx.data_movement_generation_exhausted() || self.ctx.data_movement_operation_epoch_exhausted() {
|
||||
return Err(Error::other("scanner publication lease generation is exhausted"));
|
||||
}
|
||||
if self.scanner_data_movement_snapshot_locked().await.1 {
|
||||
if self.scanner_data_movement_snapshot_locked().await.1 || self.ctx.namespace_commits_pending() {
|
||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||
}
|
||||
let Some(lease_generation) = self.ctx.scanner_publication_lease_generation(token).await else {
|
||||
|
||||
@@ -45,6 +45,10 @@ use uuid::Uuid;
|
||||
|
||||
use crate::heal::task::{HealOptions, HealPriority, HealRequest, HealType};
|
||||
|
||||
/// Read-only inspection of committed MRF checkpoints. The legacy consumer
|
||||
/// remains unchanged until ownership-aware replay is deployed.
|
||||
pub mod snapshot;
|
||||
|
||||
/// Journal location inside the metadata bucket, following the resume-state
|
||||
/// layout.
|
||||
pub(crate) const MRF_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal.bin";
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Reader-first support for owner-local MRF checkpoints.
|
||||
//!
|
||||
//! Each of two slots has a payload and a commit manifest. The manifest binds
|
||||
//! the writer identity, persistent sequence, length and whole-payload digest.
|
||||
//! Replacing the inactive slot must leave the previous committed slot intact.
|
||||
//! Production publication and reclamation are deliberately not enabled here.
|
||||
//! An unreadable commit path cannot prove that only legacy data exists. This
|
||||
//! explicit inspection API fails closed and never mutates recovery anchors.
|
||||
//! It is not wired into the legacy consumer: that transition requires the
|
||||
//! ownership-aware replay and producer handoff before writer activation.
|
||||
//! One surviving committed replica supports process restart recovery only;
|
||||
//! this reader does not establish a replication quorum or a power-loss policy.
|
||||
|
||||
use super::{MRF_JOURNAL_PATH, MRF_SCOPED_JOURNAL_PATH, decode_journal};
|
||||
use crate::heal::RUSTFS_META_BUCKET;
|
||||
use crate::heal::storage_api::owner::{EcstoreDiskAPI, EcstoreDiskError, EcstoreDiskStore};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Root-level control files avoid requiring a new directory before the first
|
||||
// atomic commit. They remain inside the storage owner's metadata volume.
|
||||
const PAYLOAD_PATHS: [&str; 2] = [".heal-mrf-snapshot.0.bin", ".heal-mrf-snapshot.1.bin"];
|
||||
const MANIFEST_PATHS: [&str; 2] = [".heal-mrf-commit.0.bin", ".heal-mrf-commit.1.bin"];
|
||||
const MAGIC: &[u8; 8] = b"RFMRFC01";
|
||||
const MANIFEST_LEN: usize = 8 + 1 + 16 + 8 + 8 + 32 + 32;
|
||||
const VERSION: u8 = 1;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SnapshotError {
|
||||
#[error("MRF checkpoint has an invalid or incomplete commit record")]
|
||||
Corrupt,
|
||||
#[error("MRF checkpoint format is unsupported")]
|
||||
Unsupported,
|
||||
#[error("MRF checkpoint exceeds the configured byte limit")]
|
||||
TooLarge,
|
||||
#[error("MRF checkpoint replicas disagree at the same sequence")]
|
||||
Conflict,
|
||||
#[error("MRF checkpoint storage is unavailable")]
|
||||
Disk(#[source] EcstoreDiskError),
|
||||
#[error("MRF checkpoint body could not be read")]
|
||||
Read(#[source] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Manifest {
|
||||
owner: Uuid,
|
||||
sequence: u64,
|
||||
payload_len: usize,
|
||||
payload_digest: [u8; 32],
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
fn decode(bytes: &[u8], limit: usize) -> Result<Self, SnapshotError> {
|
||||
if bytes.len() != MANIFEST_LEN || &bytes[..8] != MAGIC {
|
||||
return Err(SnapshotError::Corrupt);
|
||||
}
|
||||
if bytes[8] != VERSION {
|
||||
return Err(SnapshotError::Unsupported);
|
||||
}
|
||||
let signed = MANIFEST_LEN - 32;
|
||||
let checksum: [u8; 32] = Sha256::digest(&bytes[..signed]).into();
|
||||
if checksum != bytes[signed..] {
|
||||
return Err(SnapshotError::Corrupt);
|
||||
}
|
||||
let owner = Uuid::from_slice(&bytes[9..25]).map_err(|_| SnapshotError::Corrupt)?;
|
||||
let sequence = u64::from_le_bytes(bytes[25..33].try_into().map_err(|_| SnapshotError::Corrupt)?);
|
||||
let payload_len = u64::from_le_bytes(bytes[33..41].try_into().map_err(|_| SnapshotError::Corrupt)?);
|
||||
let payload_len = usize::try_from(payload_len).map_err(|_| SnapshotError::TooLarge)?;
|
||||
if owner.is_nil() || sequence == 0 || sequence == u64::MAX {
|
||||
return Err(SnapshotError::Corrupt);
|
||||
}
|
||||
if payload_len > limit {
|
||||
return Err(SnapshotError::TooLarge);
|
||||
}
|
||||
Ok(Self {
|
||||
owner,
|
||||
sequence,
|
||||
payload_len,
|
||||
payload_digest: bytes[41..73].try_into().map_err(|_| SnapshotError::Corrupt)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CommittedSnapshot {
|
||||
manifest: Manifest,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl CommittedSnapshot {
|
||||
/// Persistent single-writer sequence, not a process UUID ordering.
|
||||
pub fn sequence(&self) -> u64 {
|
||||
self.manifest.sequence
|
||||
}
|
||||
|
||||
/// Identity recorded by the committed checkpoint's writer.
|
||||
pub fn owner(&self) -> Uuid {
|
||||
self.manifest.owner
|
||||
}
|
||||
|
||||
/// Complete, checksum-validated record bytes. Inspection does not consume
|
||||
/// these records or acknowledge completion to any producer.
|
||||
pub fn payload(&self) -> &[u8] {
|
||||
&self.payload
|
||||
}
|
||||
|
||||
fn decode(manifest: &[u8], payload: Vec<u8>, limit: usize) -> Result<Self, SnapshotError> {
|
||||
let manifest = Manifest::decode(manifest, limit)?;
|
||||
let checksum: [u8; 32] = Sha256::digest(&payload).into();
|
||||
if payload.len() != manifest.payload_len || checksum != manifest.payload_digest {
|
||||
return Err(SnapshotError::Corrupt);
|
||||
}
|
||||
if decode_journal(&payload).1 != 0 {
|
||||
return Err(SnapshotError::Corrupt);
|
||||
}
|
||||
Ok(Self { manifest, payload })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RecoverySnapshot {
|
||||
/// An intact legacy snapshot, without a comparable commit sequence.
|
||||
Legacy(Vec<u8>),
|
||||
/// A committed checkpoint requiring ownership-aware replay before use.
|
||||
Committed(CommittedSnapshot),
|
||||
}
|
||||
|
||||
async fn read_bounded(disk: &EcstoreDiskStore, path: &str, limit: usize) -> Result<Option<Vec<u8>>, SnapshotError> {
|
||||
let reader = match EcstoreDiskAPI::read_file(disk.as_ref(), RUSTFS_META_BUCKET, path).await {
|
||||
Ok(reader) => reader,
|
||||
Err(EcstoreDiskError::FileNotFound | EcstoreDiskError::VolumeNotFound) => return Ok(None),
|
||||
Err(error) => return Err(SnapshotError::Disk(error)),
|
||||
};
|
||||
let maximum = limit.checked_add(1).ok_or(SnapshotError::TooLarge)?;
|
||||
let maximum = u64::try_from(maximum).map_err(|_| SnapshotError::TooLarge)?;
|
||||
let mut bytes = Vec::new();
|
||||
reader
|
||||
.take(maximum)
|
||||
.read_to_end(&mut bytes)
|
||||
.await
|
||||
.map_err(SnapshotError::Read)?;
|
||||
if bytes.len() > limit {
|
||||
return Err(SnapshotError::TooLarge);
|
||||
}
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
fn select_snapshot(selected: &mut Option<CommittedSnapshot>, candidate: CommittedSnapshot) -> Result<(), SnapshotError> {
|
||||
if let Some(current) = selected {
|
||||
if current.manifest.sequence == candidate.manifest.sequence
|
||||
&& (current.manifest != candidate.manifest || current.payload != candidate.payload)
|
||||
{
|
||||
return Err(SnapshotError::Conflict);
|
||||
}
|
||||
if current.manifest.sequence >= candidate.manifest.sequence {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
*selected = Some(candidate);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_committed(disks: &[EcstoreDiskStore], limit: usize) -> Result<Option<CommittedSnapshot>, SnapshotError> {
|
||||
let mut selected = None;
|
||||
let mut damaged = None;
|
||||
let mut identities = HashMap::new();
|
||||
for disk in disks {
|
||||
for (manifest_path, payload_path) in MANIFEST_PATHS.into_iter().zip(PAYLOAD_PATHS) {
|
||||
let candidate = async {
|
||||
let Some(manifest) = read_bounded(disk, manifest_path, MANIFEST_LEN).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let header = Manifest::decode(&manifest, limit)?;
|
||||
let payload = read_bounded(disk, payload_path, header.payload_len)
|
||||
.await?
|
||||
.ok_or(SnapshotError::Corrupt)?;
|
||||
CommittedSnapshot::decode(&manifest, payload, limit).map(Some)
|
||||
}
|
||||
.await;
|
||||
match candidate {
|
||||
Ok(Some(candidate)) => {
|
||||
let identity = (
|
||||
candidate.manifest.owner,
|
||||
candidate.manifest.payload_len,
|
||||
candidate.manifest.payload_digest,
|
||||
);
|
||||
if identities
|
||||
.insert(candidate.manifest.sequence, identity)
|
||||
.is_some_and(|previous| previous != identity)
|
||||
{
|
||||
return Err(SnapshotError::Conflict);
|
||||
}
|
||||
select_snapshot(&mut selected, candidate)?;
|
||||
}
|
||||
Ok(None) => {}
|
||||
// A future committed format may supersede all readable slots.
|
||||
Err(SnapshotError::Unsupported) => return Err(SnapshotError::Unsupported),
|
||||
Err(error) => damaged = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
match (selected, damaged) {
|
||||
(Some(snapshot), _) => Ok(Some(snapshot)),
|
||||
(None, Some(error)) => Err(error),
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_legacy(disks: &[EcstoreDiskStore], path: &str, limit: usize) -> Result<Option<Vec<u8>>, SnapshotError> {
|
||||
let mut selected = None;
|
||||
let mut incomplete: Option<Vec<u8>> = None;
|
||||
for disk in disks {
|
||||
match read_bounded(disk, path, limit).await {
|
||||
Ok(Some(payload)) if decode_journal(&payload).1 == 0 => {
|
||||
if selected.as_ref().is_some_and(|current| *current != payload) {
|
||||
// Legacy snapshots have no sequence. There is no evidence
|
||||
// that the first, longest or nonempty replica is newest.
|
||||
return Err(SnapshotError::Conflict);
|
||||
}
|
||||
selected = Some(payload);
|
||||
}
|
||||
Ok(Some(payload)) => {
|
||||
if let Some(previous) = &incomplete {
|
||||
if previous.starts_with(&payload) {
|
||||
continue;
|
||||
}
|
||||
if !payload.starts_with(previous) {
|
||||
return Err(SnapshotError::Corrupt);
|
||||
}
|
||||
}
|
||||
incomplete = Some(payload);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
if let Some(prefix) = incomplete
|
||||
&& !selected.as_ref().is_some_and(|payload| payload.starts_with(&prefix))
|
||||
{
|
||||
// In particular, an empty O_TRUNC replica cannot supersede another
|
||||
// replica containing intact records followed by a torn tail.
|
||||
return Err(SnapshotError::Corrupt);
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
/// Inspect local MRF checkpoints without replaying, acknowledging or deleting.
|
||||
///
|
||||
/// `max_bytes` bounds each payload read. Every local replica is examined and
|
||||
/// ambiguous identities, unavailable proof or unsupported formats return a
|
||||
/// typed error. This API must not authorize a writer without the separate
|
||||
/// ownership and mixed-version activation checks.
|
||||
pub async fn inspect_local_recovery_snapshot(max_bytes: usize) -> Result<Option<RecoverySnapshot>, SnapshotError> {
|
||||
read_recovery_snapshot(&super::journal_disks().await, max_bytes).await
|
||||
}
|
||||
|
||||
async fn read_recovery_snapshot(disks: &[EcstoreDiskStore], limit: usize) -> Result<Option<RecoverySnapshot>, SnapshotError> {
|
||||
if let Some(snapshot) = read_committed(disks, limit).await? {
|
||||
return Ok(Some(RecoverySnapshot::Committed(snapshot)));
|
||||
}
|
||||
// RUSTFS_COMPAT_TODO(backlog-2263): inspect retained legacy MRF journals. Remove after all supported upgrade and rollback readers understand committed snapshots and retained journals have migrated.
|
||||
if let Some(payload) = read_legacy(disks, MRF_SCOPED_JOURNAL_PATH, limit).await? {
|
||||
return Ok(Some(RecoverySnapshot::Legacy(payload)));
|
||||
}
|
||||
Ok(read_legacy(disks, MRF_JOURNAL_PATH, limit)
|
||||
.await?
|
||||
.map(RecoverySnapshot::Legacy))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::heal::mrf_queue::encode_intent;
|
||||
use crate::heal::storage_api::owner::{EcstoreConditionalFileUpdate, EcstoreDiskBytes};
|
||||
use crate::heal::{DiskOption, Endpoint, new_disk};
|
||||
use rustfs_common::mrf_channel::{MrfIntent, MrfKind, MrfScope};
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn payload(object: &str) -> Vec<u8> {
|
||||
let intent = MrfIntent {
|
||||
bucket: Arc::from("bucket"),
|
||||
object: Arc::from(object),
|
||||
version_id: None,
|
||||
kind: MrfKind::PartialWrite,
|
||||
scope: None,
|
||||
lease: None,
|
||||
enqueued_at_ms: 1234,
|
||||
attempts: 0,
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
assert!(encode_intent(&intent, &mut bytes), "fixture must encode a full record");
|
||||
bytes
|
||||
}
|
||||
|
||||
fn manifest(owner: Uuid, sequence: u64, payload: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(MANIFEST_LEN);
|
||||
bytes.extend_from_slice(MAGIC);
|
||||
bytes.push(VERSION);
|
||||
bytes.extend_from_slice(owner.as_bytes());
|
||||
bytes.extend_from_slice(&sequence.to_le_bytes());
|
||||
bytes.extend_from_slice(&u64::try_from(payload.len()).expect("fixture length fits").to_le_bytes());
|
||||
bytes.extend_from_slice(&Sha256::digest(payload));
|
||||
bytes.extend_from_slice(&Sha256::digest(&bytes));
|
||||
bytes
|
||||
}
|
||||
|
||||
async fn disk(root: &TempDir, name: &str) -> EcstoreDiskStore {
|
||||
let path = root.path().join(name);
|
||||
std::fs::create_dir_all(&path).expect("create disk directory");
|
||||
let endpoint = Endpoint::try_from(path.to_string_lossy().as_ref()).expect("valid disk endpoint");
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("open disk");
|
||||
let result = EcstoreDiskAPI::make_volume(disk.as_ref(), RUSTFS_META_BUCKET).await;
|
||||
assert!(
|
||||
matches!(result, Ok(()) | Err(EcstoreDiskError::VolumeExists)),
|
||||
"metadata volume: {result:?}"
|
||||
);
|
||||
disk
|
||||
}
|
||||
|
||||
// Exercise the existing storage owner's atomic CAS primitive. No production
|
||||
// caller publishes this format until ownership-aware replay is available.
|
||||
async fn install(disk: &EcstoreDiskStore, path: &str, bytes: &[u8]) {
|
||||
let expected = EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, path).await.ok();
|
||||
let result = EcstoreDiskAPI::compare_and_update_file(
|
||||
disk.as_ref(),
|
||||
RUSTFS_META_BUCKET,
|
||||
path,
|
||||
expected,
|
||||
Some(EcstoreDiskBytes::copy_from_slice(bytes)),
|
||||
)
|
||||
.await
|
||||
.expect("atomic snapshot slot write");
|
||||
assert_eq!(result, EcstoreConditionalFileUpdate::Updated);
|
||||
}
|
||||
|
||||
async fn commit(disk: &EcstoreDiskStore, slot: usize, owner: Uuid, sequence: u64, bytes: &[u8]) {
|
||||
install(disk, PAYLOAD_PATHS[slot], bytes).await;
|
||||
install(disk, MANIFEST_PATHS[slot], &manifest(owner, sequence, bytes)).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_validates_identity_sequence_length_and_digest() {
|
||||
let bytes = payload("object");
|
||||
let owner = Uuid::new_v4();
|
||||
assert!(CommittedSnapshot::decode(&manifest(owner, 1, &bytes), bytes.clone(), bytes.len()).is_ok());
|
||||
for (owner, sequence) in [(Uuid::nil(), 1), (owner, 0), (owner, u64::MAX)] {
|
||||
assert!(matches!(
|
||||
Manifest::decode(&manifest(owner, sequence, &bytes), bytes.len()),
|
||||
Err(SnapshotError::Corrupt)
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
Manifest::decode(&manifest(owner, 1, &bytes), bytes.len() - 1),
|
||||
Err(SnapshotError::TooLarge)
|
||||
));
|
||||
let mut corrupt = manifest(owner, 1, &bytes);
|
||||
corrupt[25] ^= 1;
|
||||
assert!(matches!(Manifest::decode(&corrupt, bytes.len()), Err(SnapshotError::Corrupt)));
|
||||
let mut unsupported = manifest(owner, 1, &bytes);
|
||||
unsupported[8] = 2;
|
||||
assert!(matches!(Manifest::decode(&unsupported, bytes.len()), Err(SnapshotError::Unsupported)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_payload_integrity_is_required_even_with_a_valid_manifest() {
|
||||
let bytes = payload("object");
|
||||
let owner = Uuid::new_v4();
|
||||
let header = manifest(owner, 1, &bytes);
|
||||
assert!(matches!(
|
||||
CommittedSnapshot::decode(&header, bytes[..bytes.len() - 1].to_vec(), bytes.len()),
|
||||
Err(SnapshotError::Corrupt)
|
||||
));
|
||||
let invalid = b"not an MRF record".to_vec();
|
||||
assert!(matches!(
|
||||
CommittedSnapshot::decode(&manifest(owner, 2, &invalid), invalid, bytes.len()),
|
||||
Err(SnapshotError::Corrupt)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn newest_complete_replica_wins_in_both_disk_orders() {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
let first = disk(&root, "first").await;
|
||||
let second = disk(&root, "second").await;
|
||||
let owner = Uuid::new_v4();
|
||||
commit(&first, 0, owner, 1, &payload("old")).await;
|
||||
commit(&second, 1, owner, 2, &payload("new")).await;
|
||||
for disks in [vec![first.clone(), second.clone()], vec![second.clone(), first.clone()]] {
|
||||
let recovered = read_committed(&disks, 4096)
|
||||
.await
|
||||
.expect("read replicas")
|
||||
.expect("committed snapshot");
|
||||
assert_eq!(recovered.manifest.sequence, 2);
|
||||
assert_eq!(recovered.payload, payload("new"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn divergent_commits_at_same_sequence_fail_closed() {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
let first = disk(&root, "first").await;
|
||||
let second = disk(&root, "second").await;
|
||||
let owner = Uuid::new_v4();
|
||||
commit(&first, 0, owner, 7, &payload("a")).await;
|
||||
commit(&second, 1, owner, 7, &payload("b")).await;
|
||||
assert!(matches!(read_committed(&[first, second], 4096).await, Err(SnapshotError::Conflict)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn newer_slot_does_not_hide_a_conflicting_commit_history() {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
let first = disk(&root, "first").await;
|
||||
let second = disk(&root, "second").await;
|
||||
let owner = Uuid::new_v4();
|
||||
commit(&first, 0, owner, 8, &payload("newest")).await;
|
||||
commit(&first, 1, owner, 7, &payload("a")).await;
|
||||
commit(&second, 1, owner, 7, &payload("b")).await;
|
||||
assert!(matches!(read_committed(&[first, second], 4096).await, Err(SnapshotError::Conflict)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uncommitted_or_torn_successor_preserves_previous_slot() {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
let disk = disk(&root, "disk").await;
|
||||
let owner = Uuid::new_v4();
|
||||
let old = payload("old");
|
||||
let next = payload("next");
|
||||
commit(&disk, 0, owner, 1, &old).await;
|
||||
install(&disk, PAYLOAD_PATHS[1], &next).await;
|
||||
let recovered = read_committed(std::slice::from_ref(&disk), 4096)
|
||||
.await
|
||||
.expect("staged payload is not a commit")
|
||||
.expect("old snapshot");
|
||||
assert_eq!(recovered.payload, old);
|
||||
install(&disk, MANIFEST_PATHS[1], &manifest(owner, 2, &next)[..20]).await;
|
||||
let recovered = read_committed(std::slice::from_ref(&disk), 4096)
|
||||
.await
|
||||
.expect("torn manifest preserves old slot")
|
||||
.expect("old snapshot");
|
||||
assert_eq!(recovered.manifest.sequence, 1);
|
||||
install(&disk, MANIFEST_PATHS[1], &manifest(owner, 2, &next)).await;
|
||||
install(&disk, PAYLOAD_PATHS[1], b"torn").await;
|
||||
let recovered = read_committed(&[disk], 4096)
|
||||
.await
|
||||
.expect("torn payload preserves old slot")
|
||||
.expect("old snapshot");
|
||||
assert_eq!(recovered.manifest.sequence, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_manifest_cas_cannot_replace_committed_anchor() {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
let disk = disk(&root, "disk").await;
|
||||
let owner = Uuid::new_v4();
|
||||
let bytes = payload("object");
|
||||
commit(&disk, 0, owner, 1, &bytes).await;
|
||||
let result = EcstoreDiskAPI::compare_and_update_file(
|
||||
disk.as_ref(),
|
||||
RUSTFS_META_BUCKET,
|
||||
MANIFEST_PATHS[0],
|
||||
None,
|
||||
Some(manifest(owner, 2, &bytes).into()),
|
||||
)
|
||||
.await
|
||||
.expect("CAS call");
|
||||
assert_eq!(result, EcstoreConditionalFileUpdate::Mismatch);
|
||||
let recovered = read_committed(&[disk], 4096)
|
||||
.await
|
||||
.expect("read old anchor")
|
||||
.expect("snapshot");
|
||||
assert_eq!(recovered.manifest.sequence, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_import_requires_complete_consistent_replicas() {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
let first = disk(&root, "first").await;
|
||||
let second = disk(&root, "second").await;
|
||||
let bytes = payload("object");
|
||||
for (disk, data) in [(&first, &bytes[..bytes.len() - 1]), (&second, bytes.as_slice())] {
|
||||
EcstoreDiskAPI::write_all(
|
||||
disk.as_ref(),
|
||||
RUSTFS_META_BUCKET,
|
||||
MRF_SCOPED_JOURNAL_PATH,
|
||||
EcstoreDiskBytes::copy_from_slice(data),
|
||||
)
|
||||
.await
|
||||
.expect("legacy fixture");
|
||||
}
|
||||
let disks = [first.clone(), second];
|
||||
assert!(
|
||||
matches!(read_recovery_snapshot(&disks, 4096).await.expect("intact legacy replica"), Some(RecoverySnapshot::Legacy(data)) if data == bytes)
|
||||
);
|
||||
EcstoreDiskAPI::write_all(first.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, payload("different").into())
|
||||
.await
|
||||
.expect("divergent fixture");
|
||||
assert!(matches!(read_recovery_snapshot(&disks, 4096).await, Err(SnapshotError::Conflict)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_inspection_leaves_payload_and_manifest_unchanged() {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
let disk = disk(&root, "disk").await;
|
||||
let owner = Uuid::new_v4();
|
||||
let bytes = payload("object");
|
||||
commit(&disk, 0, owner, 3, &bytes).await;
|
||||
assert!(matches!(
|
||||
read_recovery_snapshot(std::slice::from_ref(&disk), 4096)
|
||||
.await
|
||||
.expect("new snapshot"),
|
||||
Some(RecoverySnapshot::Committed(_))
|
||||
));
|
||||
assert_eq!(
|
||||
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[0])
|
||||
.await
|
||||
.expect("manifest retained")
|
||||
.as_ref(),
|
||||
manifest(owner, 3, &bytes)
|
||||
);
|
||||
assert_eq!(
|
||||
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[0])
|
||||
.await
|
||||
.expect("payload retained")
|
||||
.as_ref(),
|
||||
bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_inspection_rejects_complete_subsets_and_scope_ambiguity() {
|
||||
let scoped = |set_index| {
|
||||
let intent = MrfIntent {
|
||||
bucket: Arc::from("bucket"),
|
||||
object: Arc::from("a"),
|
||||
version_id: None,
|
||||
kind: MrfKind::PartialWrite,
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 0,
|
||||
set_index,
|
||||
}),
|
||||
lease: None,
|
||||
enqueued_at_ms: 1234,
|
||||
attempts: 0,
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
assert!(encode_intent(&intent, &mut bytes), "scoped fixture must encode");
|
||||
bytes
|
||||
};
|
||||
let mut superset = payload("a");
|
||||
superset.extend_from_slice(&payload("b"));
|
||||
for (case, first_bytes, second_bytes) in [
|
||||
("complete-subset", payload("a"), superset),
|
||||
("different-set", scoped(1), scoped(2)),
|
||||
("unknown-scope", payload("a"), scoped(1)),
|
||||
] {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
let first = disk(&root, "first").await;
|
||||
let second = disk(&root, "second").await;
|
||||
for (disk, bytes) in [(&first, &first_bytes), (&second, &second_bytes)] {
|
||||
assert_eq!(decode_journal(bytes).1, 0, "{case}: complete fixture");
|
||||
EcstoreDiskAPI::write_all(
|
||||
disk.as_ref(),
|
||||
RUSTFS_META_BUCKET,
|
||||
MRF_SCOPED_JOURNAL_PATH,
|
||||
EcstoreDiskBytes::copy_from_slice(bytes),
|
||||
)
|
||||
.await
|
||||
.expect("write legacy replica");
|
||||
}
|
||||
for disks in [vec![first.clone(), second.clone()], vec![second.clone(), first.clone()]] {
|
||||
assert!(
|
||||
matches!(read_recovery_snapshot(&disks, 4096).await, Err(SnapshotError::Conflict)),
|
||||
"{case}: neither replica order proves a latest snapshot"
|
||||
);
|
||||
}
|
||||
for (disk, bytes) in [(&first, &first_bytes), (&second, &second_bytes)] {
|
||||
assert_eq!(
|
||||
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
|
||||
.await
|
||||
.expect("legacy evidence retained")
|
||||
.as_ref(),
|
||||
bytes.as_slice(),
|
||||
"{case}: inspection must preserve both source replicas"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_or_corrupt_scoped_snapshot_never_falls_back_to_legacy() {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
let disk = disk(&root, "disk").await;
|
||||
EcstoreDiskAPI::write_all(disk.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, vec![0; 1025].into())
|
||||
.await
|
||||
.expect("oversized fixture");
|
||||
EcstoreDiskAPI::write_all(disk.as_ref(), RUSTFS_META_BUCKET, MRF_JOURNAL_PATH, payload("old").into())
|
||||
.await
|
||||
.expect("legacy fixture");
|
||||
assert!(matches!(
|
||||
read_recovery_snapshot(std::slice::from_ref(&disk), 1024).await,
|
||||
Err(SnapshotError::TooLarge)
|
||||
));
|
||||
assert!(matches!(read_recovery_snapshot(&[disk], 2048).await, Err(SnapshotError::Corrupt)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_legacy_replica_cannot_erase_records_in_a_torn_replica() {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
let first = disk(&root, "first").await;
|
||||
let second = disk(&root, "second").await;
|
||||
let mut incomplete = payload("durable-object");
|
||||
incomplete.extend_from_slice(b"torn");
|
||||
EcstoreDiskAPI::write_all(first.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, Vec::new().into())
|
||||
.await
|
||||
.expect("empty truncated replica");
|
||||
EcstoreDiskAPI::write_all(second.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, incomplete.clone().into())
|
||||
.await
|
||||
.expect("records and torn tail");
|
||||
for disks in [vec![first.clone(), second.clone()], vec![second.clone(), first.clone()]] {
|
||||
assert!(matches!(read_recovery_snapshot(&disks, 4096).await, Err(SnapshotError::Corrupt)));
|
||||
}
|
||||
assert_eq!(
|
||||
EcstoreDiskAPI::read_all(second.as_ref(), RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
|
||||
.await
|
||||
.expect("recovery anchor preserved")
|
||||
.as_ref(),
|
||||
incomplete
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unreadable_commit_record_never_implies_legacy_only() {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
let disk = disk(&root, "disk").await;
|
||||
let legacy = payload("old");
|
||||
EcstoreDiskAPI::write_all(disk.as_ref(), RUSTFS_META_BUCKET, MRF_JOURNAL_PATH, legacy.clone().into())
|
||||
.await
|
||||
.expect("legacy fixture");
|
||||
// Opening a directory as a record either fails at open or at read,
|
||||
// depending on the platform. Neither outcome proves absence.
|
||||
std::fs::create_dir(root.path().join("disk").join(RUSTFS_META_BUCKET).join(MANIFEST_PATHS[0]))
|
||||
.expect("unreadable manifest fixture");
|
||||
let recovered = read_recovery_snapshot(std::slice::from_ref(&disk), 4096).await;
|
||||
assert!(
|
||||
matches!(recovered, Err(SnapshotError::Disk(_) | SnapshotError::Read(_))),
|
||||
"must preserve unavailable proof: {recovered:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, MRF_JOURNAL_PATH)
|
||||
.await
|
||||
.expect("legacy remains")
|
||||
.as_ref(),
|
||||
legacy
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,9 @@ use walkdir::WalkDir;
|
||||
|
||||
mod storage_api;
|
||||
|
||||
use storage_api::integration::{BucketOperations, ECStore, MakeBucketOptions, ObjectIO as _, ObjectOperations as _};
|
||||
use storage_api::integration::{
|
||||
BucketOperations, ECStore, MakeBucketOptions, NamespaceLocking as _, ObjectIO as _, ObjectOperations as _,
|
||||
};
|
||||
|
||||
/// 256 KiB + change: large enough to be stored as non-inline erasure shards
|
||||
/// (so each data version materializes as an on-disk `part.*` file we can assert
|
||||
@@ -106,6 +108,7 @@ async fn put_versioned(ecstore: &Arc<ECStore>, bucket: &str, object: &str, data:
|
||||
.put_object(bucket, object, &mut reader, &opts)
|
||||
.await
|
||||
.expect("versioned put_object failed");
|
||||
wait_for_put_tail(ecstore, bucket, object).await;
|
||||
info.version_id
|
||||
.map(|u| u.to_string())
|
||||
.expect("versioned put must return a version id")
|
||||
@@ -117,6 +120,7 @@ async fn put_unversioned(ecstore: &Arc<ECStore>, bucket: &str, object: &str, dat
|
||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("unversioned put_object failed");
|
||||
wait_for_put_tail(ecstore, bucket, object).await;
|
||||
}
|
||||
|
||||
/// Create a delete-marker as the latest version (versioned:true, no version_id)
|
||||
@@ -160,20 +164,16 @@ fn xl_meta_path(obj_dir: &Path) -> PathBuf {
|
||||
obj_dir.join("xl.meta")
|
||||
}
|
||||
|
||||
async fn wait_for_two_version_copies(disks: &[PathBuf], bucket: &str, object: &str) {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if disks.iter().all(|disk| {
|
||||
let object_dir = object_dir(disk, bucket, object);
|
||||
xl_meta_path(&object_dir).exists() && count_part_files(&object_dir) >= 2
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("PUT rename tails must converge before wiping the versioned fixture");
|
||||
async fn wait_for_put_tail(ecstore: &Arc<ECStore>, bucket: &str, object: &str) {
|
||||
// Shards and xl.meta can exist before the detached PUT owner finishes.
|
||||
let lock = ecstore
|
||||
.new_ns_lock(bucket, object)
|
||||
.await
|
||||
.expect("fixture namespace lock should be created");
|
||||
let _settled = lock
|
||||
.get_write_lock(Duration::from_secs(30))
|
||||
.await
|
||||
.expect("PUT rename tail must finish before inspecting or wiping the fixture");
|
||||
}
|
||||
|
||||
fn recreate_heal_opts() -> HealOpts {
|
||||
@@ -305,7 +305,13 @@ mod serial_tests {
|
||||
let data_v2 = versioned_test_data(20);
|
||||
let v1 = put_versioned(&ecstore, bucket, object, &data_v1).await; // OLD, non-latest
|
||||
let v2 = put_versioned(&ecstore, bucket, object, &data_v2).await; // latest
|
||||
wait_for_two_version_copies(&disk_paths, bucket, object).await;
|
||||
assert!(
|
||||
disk_paths.iter().all(|disk| {
|
||||
let dir = object_dir(disk, bucket, object);
|
||||
xl_meta_path(&dir).exists() && count_part_files(&dir) >= 2
|
||||
}),
|
||||
"both versions must exist on every disk before wiping the fixture"
|
||||
);
|
||||
|
||||
// ── Pre-wipe: prove the fixture actually has 2 versions on disk[0] ──
|
||||
let obj_dir0 = object_dir(&disk_paths[0], bucket, object);
|
||||
|
||||
@@ -23,6 +23,7 @@ pub(crate) mod integration {
|
||||
pub(crate) use rustfs_ecstore::api::storage::ECStore;
|
||||
pub(crate) use rustfs_storage_api::BucketOperations;
|
||||
pub(crate) use rustfs_storage_api::MakeBucketOptions;
|
||||
pub(crate) use rustfs_storage_api::NamespaceLocking;
|
||||
pub(crate) use rustfs_storage_api::ObjectIO;
|
||||
pub(crate) use rustfs_storage_api::ObjectOperations;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"bucket":"photos","config":{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"REDACTED","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null}},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}},"updated_at":"2026-09-02T10:00:00Z"}
|
||||
{"bucket":"photos","config":{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"REDACTED","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":null,"gcs":null},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}},"updated_at":"2026-09-02T10:00:00Z"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"sourceSecretKey123","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null}},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}}
|
||||
{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"sourceSecretKey123","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":null,"gcs":null},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"bucket":"photos","dry_run":false,"config":{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"REDACTED","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null}},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}},"updated_at":"2026-09-02T10:00:00Z","probe":{"reachable":true,"listable":true,"sample_key":"photos/2024/01.jpg"}}
|
||||
{"bucket":"photos","dry_run":false,"config":{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://source.example.com:9000","region":"us-east-1","bucket":"legacy-photos","path_style":"auto","credentials":{"access_key":"AKIASOURCE","secret_key":"REDACTED","session_token":null},"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":null,"gcs":null},"filter":{"prefix":null,"source_prefix":"photos/"},"policy":{"head":"proxy","range_get":"serve_and_backfill","source_error":"propagate","list_through":false,"respect_local_delete_marker":true,"preserve_etag":true,"copy_tags":false,"emit_events":true,"negative_cache_ttl_secs":30,"inline_max_bytes":16777216,"multipart_part_size_bytes":67108864,"max_concurrent_pulls":8,"pull_queue_capacity":1024,"source_timeout":{"connect_ms":5000,"first_byte_ms":15000,"idle_ms":30000},"bandwidth_limit_bytes_per_sec":null}},"updated_at":"2026-09-02T10:00:00Z","probe":{"reachable":true,"listable":true,"sample_key":"photos/2024/01.jpg"}}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//! Wire types for `PUT`/`GET`/`DELETE /v3/on-demand-migration/{bucket}`,
|
||||
//! `GET .../status`, `POST .../backfill?op=start|cancel` and
|
||||
//! `GET .../backfill` (ODM-12), mirroring the server's config model
|
||||
//! (`crates/ecstore/src/bucket/on_demand_migration/config.rs`) and handler
|
||||
//! (`rustfs/src/on_demand_migration/config.rs`) and handler
|
||||
//! responses (`rustfs/src/admin/handlers/on_demand_migration.rs`). The SDK
|
||||
//! owns its own copies, madmin-go style; the fixtures under
|
||||
//! `fixtures/on_demand_migration/` are the contract both sides pin
|
||||
@@ -78,10 +78,18 @@ pub struct OnDemandMigrationSource {
|
||||
#[serde(default)]
|
||||
pub path_style: OnDemandMigrationPathStyle,
|
||||
/// `None` means anonymous access to a public source bucket.
|
||||
/// `None` means anonymous access to a public source bucket. The native
|
||||
/// providers carry their credentials in `azure` / `gcs` instead.
|
||||
#[serde(default)]
|
||||
pub credentials: Option<OnDemandMigrationCredentials>,
|
||||
#[serde(default)]
|
||||
pub tls: OnDemandMigrationTls,
|
||||
/// Required for `azure` and rejected for every other provider.
|
||||
#[serde(default)]
|
||||
pub azure: Option<OnDemandMigrationAzure>,
|
||||
/// Required for `gcs_native` and rejected for every other provider.
|
||||
#[serde(default)]
|
||||
pub gcs: Option<OnDemandMigrationGcs>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -92,7 +100,49 @@ pub enum OnDemandMigrationProvider {
|
||||
Minio,
|
||||
Rustfs,
|
||||
R2,
|
||||
/// GCS XML interoperability API with HMAC keys.
|
||||
Gcs,
|
||||
/// Native Azure Blob service.
|
||||
Azure,
|
||||
/// Native GCS JSON API with a service-account key.
|
||||
#[serde(rename = "gcs_native")]
|
||||
GcsNative,
|
||||
}
|
||||
|
||||
/// Native Azure Blob parameters. The container is `source.bucket`; exactly one
|
||||
/// of `account_key` and `sas_token` is set. Responses carry both as `REDACTED`.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OnDemandMigrationAzure {
|
||||
pub account: String,
|
||||
#[serde(default)]
|
||||
pub account_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sas_token: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for OnDemandMigrationAzure {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("OnDemandMigrationAzure")
|
||||
.field("account", &self.account)
|
||||
.field("account_key", &self.account_key.as_ref().map(|_| "REDACTED"))
|
||||
.field("sas_token", &self.sas_token.as_ref().map(|_| "REDACTED"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Native GCS parameters. The bucket is `source.bucket`; the key JSON embeds a
|
||||
/// private key, so responses carry it as `REDACTED`.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OnDemandMigrationGcs {
|
||||
pub service_account_json: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for OnDemandMigrationGcs {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("OnDemandMigrationGcs")
|
||||
.field("service_account_json", &"REDACTED")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
@@ -806,6 +856,8 @@ mod tests {
|
||||
session_token: None,
|
||||
}),
|
||||
tls: OnDemandMigrationTls::default(),
|
||||
azure: None,
|
||||
gcs: None,
|
||||
});
|
||||
let mut expected: OnDemandMigrationConfig = serde_json::from_str(SET_REQUEST_FIXTURE.trim()).expect("fixture");
|
||||
expected.filter.source_prefix = None;
|
||||
@@ -821,6 +873,42 @@ mod tests {
|
||||
assert!(minimal.source.credentials.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_provider_documents_round_trip_and_hide_their_secrets() {
|
||||
for (label, json) in [
|
||||
(
|
||||
"azure",
|
||||
r#"{"provider":"azure","endpoint":null,"region":"auto","bucket":"legacy-photos","path_style":"auto","credentials":null,"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":{"account":"legacyaccount","account_key":null,"sas_token":"sv=2021-08-06&sig=topsecret"},"gcs":null}"#,
|
||||
),
|
||||
(
|
||||
"gcs_native",
|
||||
r#"{"provider":"gcs_native","endpoint":null,"region":"auto","bucket":"legacy-photos","path_style":"auto","credentials":null,"tls":{"skip_verify":false,"ca_cert_pem":null},"azure":null,"gcs":{"service_account_json":"{\"type\":\"service_account\"}"}}"#,
|
||||
),
|
||||
] {
|
||||
let source: OnDemandMigrationSource = serde_json::from_str(json).unwrap_or_else(|err| panic!("{label}: {err}"));
|
||||
assert_eq!(
|
||||
serde_json::to_string(&source).expect("re-encodes"),
|
||||
json,
|
||||
"{label} must reproduce the server wire shape byte for byte"
|
||||
);
|
||||
}
|
||||
|
||||
let azure = OnDemandMigrationAzure {
|
||||
account: "legacyaccount".to_string(),
|
||||
account_key: Some("c2VjcmV0".to_string()),
|
||||
sas_token: Some("sig=topsecret".to_string()),
|
||||
};
|
||||
let rendered = format!("{azure:?}");
|
||||
assert!(rendered.contains("legacyaccount"));
|
||||
assert!(!rendered.contains("c2VjcmV0"), "{rendered}");
|
||||
assert!(!rendered.contains("topsecret"), "{rendered}");
|
||||
|
||||
let gcs = OnDemandMigrationGcs {
|
||||
service_account_json: r#"{"private_key":"-----BEGIN PRIVATE KEY-----"}"#.to_string(),
|
||||
};
|
||||
assert!(!format!("{gcs:?}").contains("PRIVATE KEY"), "{gcs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_debug_never_prints_secrets() {
|
||||
let credentials = OnDemandMigrationCredentials {
|
||||
|
||||
@@ -38,3 +38,4 @@ pub(crate) use storage_api::metrics::{
|
||||
obs_on_demand_migration_snapshot, obs_replication_site_stats_snapshot, obs_resolve_object_store_handle,
|
||||
obs_transition_state_handle,
|
||||
};
|
||||
pub use storage_api::register_on_demand_migration_metrics_source;
|
||||
|
||||
@@ -17,13 +17,6 @@ use std::time::Duration;
|
||||
|
||||
pub(crate) use rustfs_ecstore::api::bucket::bandwidth::monitor::Monitor as ObsBucketBandwidthMonitor;
|
||||
pub(crate) use rustfs_ecstore::api::bucket::metadata_sys::get_quota_config as obs_get_quota_config;
|
||||
use rustfs_ecstore::api::bucket::on_demand_migration::backfill::{
|
||||
BackfillCheckpoint as SourceBackfillCheckpoint, global_backfill_runner as source_global_backfill_runner,
|
||||
};
|
||||
use rustfs_ecstore::api::bucket::on_demand_migration::{
|
||||
BreakerState as SourceOdmBreakerState, OdmBucketSnapshot as SourceOdmBucketSnapshot,
|
||||
OnDemandMigrationSys as SourceOnDemandMigrationSys,
|
||||
};
|
||||
use rustfs_ecstore::api::bucket::replication::{
|
||||
BucketReplicationStats as SourceBucketReplicationStats, DurableMrfBucketBacklog, DurableMrfTargetBacklog,
|
||||
MrfBucketBacklogObservability, RuntimeReplicationTargetBacklog, durable_mrf_backlog_summary_snapshot,
|
||||
@@ -44,9 +37,7 @@ pub(crate) use rustfs_ecstore::api::runtime::{
|
||||
pub(crate) use rustfs_ecstore::api::storage::ECStore as ObsStore;
|
||||
use rustfs_storage_api as storage_contracts;
|
||||
|
||||
use crate::metrics::collectors::{
|
||||
OdmBackfillBucketStats, OdmBackfillRuntimeStats, OnDemandMigrationBreakerState, OnDemandMigrationBucketStats,
|
||||
};
|
||||
use crate::metrics::collectors::{OdmBackfillBucketStats, OdmBackfillRuntimeStats, OnDemandMigrationBucketStats};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct ObsBucketReplicationTargetStatsSnapshot {
|
||||
@@ -465,70 +456,37 @@ pub(crate) async fn obs_bucket_replication_stats_snapshot() -> Vec<ObsBucketRepl
|
||||
buckets
|
||||
}
|
||||
|
||||
fn on_demand_migration_stats_from_snapshot(snapshot: SourceOdmBucketSnapshot) -> OnDemandMigrationBucketStats {
|
||||
let stats = snapshot.stats;
|
||||
OnDemandMigrationBucketStats {
|
||||
bucket: snapshot.bucket,
|
||||
requests_total: stats.requests_total,
|
||||
pulled_bytes_total: stats.pulled_bytes_total,
|
||||
pulled_objects_total: stats.pulled_objects_total,
|
||||
pull_failures_total: stats.pull_failures_total,
|
||||
inflight_pulls: stats.inflight_pulls,
|
||||
queue_depth: stats.queue_depth,
|
||||
source_latency_buckets: stats
|
||||
.source_latency
|
||||
.buckets
|
||||
.into_iter()
|
||||
.map(|bucket| (bucket.le_ms, bucket.count))
|
||||
.collect(),
|
||||
source_latency_count: stats.source_latency.count,
|
||||
source_latency_sum_ms: stats.source_latency.sum_ms,
|
||||
breaker_state: match stats.breaker_state {
|
||||
SourceOdmBreakerState::Closed => OnDemandMigrationBreakerState::Closed,
|
||||
SourceOdmBreakerState::HalfOpen => OnDemandMigrationBreakerState::HalfOpen,
|
||||
SourceOdmBreakerState::Open => OnDemandMigrationBreakerState::Open,
|
||||
},
|
||||
}
|
||||
struct OnDemandMigrationMetricsSource {
|
||||
snapshot: fn() -> Vec<OnDemandMigrationBucketStats>,
|
||||
backfill_snapshot: fn() -> Vec<OdmBackfillBucketStats>,
|
||||
}
|
||||
|
||||
/// Every bucket with live on-demand migration state on this node, sorted by
|
||||
/// name. Empty while the module switch is off.
|
||||
pub(crate) fn obs_on_demand_migration_snapshot() -> Vec<OnDemandMigrationBucketStats> {
|
||||
SourceOnDemandMigrationSys::get()
|
||||
.snapshot()
|
||||
.into_iter()
|
||||
.map(on_demand_migration_stats_from_snapshot)
|
||||
.collect()
|
||||
}
|
||||
static ON_DEMAND_MIGRATION_METRICS_SOURCE: std::sync::OnceLock<OnDemandMigrationMetricsSource> = std::sync::OnceLock::new();
|
||||
|
||||
fn on_demand_migration_backfill_stats_from_checkpoint(
|
||||
bucket: String,
|
||||
checkpoint: SourceBackfillCheckpoint,
|
||||
) -> OdmBackfillBucketStats {
|
||||
OdmBackfillBucketStats {
|
||||
bucket,
|
||||
state: checkpoint.state.as_str().to_string(),
|
||||
listed: checkpoint.listed,
|
||||
enqueued: checkpoint.enqueued,
|
||||
pulled: checkpoint.pulled,
|
||||
skipped_existing: checkpoint.skipped_existing,
|
||||
failed: checkpoint.failed,
|
||||
bytes: checkpoint.bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Backfill jobs running on this node, sorted by bucket. Empty until the
|
||||
/// runner is installed, and empty again once a job finishes: the series are
|
||||
/// per-node job progress, not a cluster-wide history.
|
||||
pub(crate) fn obs_on_demand_migration_backfill_snapshot(server: String) -> OdmBackfillRuntimeStats {
|
||||
let buckets = source_global_backfill_runner()
|
||||
.map(|runner| {
|
||||
runner
|
||||
.local_job_snapshots()
|
||||
.into_iter()
|
||||
.map(|(bucket, checkpoint)| on_demand_migration_backfill_stats_from_checkpoint(bucket, checkpoint))
|
||||
.collect()
|
||||
/// Register the application-owned ODM snapshots before starting the collector.
|
||||
pub fn register_on_demand_migration_metrics_source(
|
||||
snapshot: fn() -> Vec<OnDemandMigrationBucketStats>,
|
||||
backfill_snapshot: fn() -> Vec<OdmBackfillBucketStats>,
|
||||
) -> bool {
|
||||
ON_DEMAND_MIGRATION_METRICS_SOURCE
|
||||
.set(OnDemandMigrationMetricsSource {
|
||||
snapshot,
|
||||
backfill_snapshot,
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub(crate) fn obs_on_demand_migration_snapshot() -> Vec<OnDemandMigrationBucketStats> {
|
||||
ON_DEMAND_MIGRATION_METRICS_SOURCE
|
||||
.get()
|
||||
.map(|source| (source.snapshot)())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn obs_on_demand_migration_backfill_snapshot(server: String) -> OdmBackfillRuntimeStats {
|
||||
let buckets = ON_DEMAND_MIGRATION_METRICS_SOURCE
|
||||
.get()
|
||||
.map(|source| (source.backfill_snapshot)())
|
||||
.unwrap_or_default();
|
||||
OdmBackfillRuntimeStats { server, buckets }
|
||||
}
|
||||
@@ -580,6 +538,31 @@ pub(crate) async fn obs_replication_site_stats_snapshot(current_data_transfer_ra
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn on_demand_migration_callbacks_supply_runtime_snapshots() {
|
||||
assert!(register_on_demand_migration_metrics_source(
|
||||
|| vec![OnDemandMigrationBucketStats {
|
||||
bucket: "configured".into(),
|
||||
pulled_bytes_total: 4096,
|
||||
..Default::default()
|
||||
}],
|
||||
|| vec![OdmBackfillBucketStats {
|
||||
bucket: "backfill".into(),
|
||||
pulled: 3,
|
||||
..Default::default()
|
||||
}],
|
||||
));
|
||||
let snapshot = obs_on_demand_migration_snapshot();
|
||||
assert_eq!(snapshot.len(), 1);
|
||||
assert_eq!(snapshot[0].bucket, "configured");
|
||||
assert_eq!(snapshot[0].pulled_bytes_total, 4096);
|
||||
let backfill = obs_on_demand_migration_backfill_snapshot("node-a".into());
|
||||
assert_eq!(backfill.server, "node-a");
|
||||
assert_eq!(backfill.buckets.len(), 1);
|
||||
assert_eq!(backfill.buckets[0].bucket, "backfill");
|
||||
assert_eq!(backfill.buckets[0].pulled, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obs_replication_numeric_conversions_floor_negative_values() {
|
||||
assert_eq!(i64_to_u64_floor_zero(-1), 0);
|
||||
@@ -772,51 +755,6 @@ mod tests {
|
||||
assert_eq!(snapshot.mrf_last_flush_duration_millis, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_demand_migration_snapshot_projects_counters_and_breaker_state() {
|
||||
// Built from JSON: the snapshot's timestamps use `time`, which obs does not depend on.
|
||||
let snapshot: SourceOdmBucketSnapshot = serde_json::from_value(serde_json::json!({
|
||||
"bucket": "photos",
|
||||
"provider": "minio",
|
||||
"endpoint_host": "source.example.com",
|
||||
"applied_at": "2026-09-02T10:00:00Z",
|
||||
"client_error": null,
|
||||
"negative_cache_entries": 0,
|
||||
"inflight_keys": 1,
|
||||
"max_concurrent_pulls": 8,
|
||||
"stats": {
|
||||
"requests_total": {"get": {"source_hit": 2}},
|
||||
"pulled_bytes_total": 4096,
|
||||
"pulled_objects_total": {"inline": 1},
|
||||
"pull_failures_total": {"source_timeout": 1},
|
||||
"inflight_pulls": 1,
|
||||
"queue_depth": 2,
|
||||
"source_latency": {
|
||||
"buckets": [{"le_ms": 5, "count": 1}, {"le_ms": 10, "count": 2}],
|
||||
"count": 3,
|
||||
"sum_ms": 90753
|
||||
},
|
||||
"last_source_error": {"class": "server_error", "at": "2026-09-02T10:00:00Z"},
|
||||
"breaker_state": "open"
|
||||
}
|
||||
}))
|
||||
.expect("runtime snapshot decodes");
|
||||
|
||||
let stats = on_demand_migration_stats_from_snapshot(snapshot);
|
||||
|
||||
assert_eq!(stats.bucket, "photos");
|
||||
assert_eq!(stats.requests_total["get"]["source_hit"], 2);
|
||||
assert_eq!(stats.pulled_bytes_total, 4096);
|
||||
assert_eq!(stats.pulled_objects_total["inline"], 1);
|
||||
assert_eq!(stats.pull_failures_total["source_timeout"], 1);
|
||||
assert_eq!(stats.inflight_pulls, 1);
|
||||
assert_eq!(stats.queue_depth, 2);
|
||||
assert_eq!(stats.source_latency_buckets, vec![(5, 1), (10, 2)]);
|
||||
assert_eq!(stats.source_latency_count, 3);
|
||||
assert_eq!(stats.source_latency_sum_ms, 90_753);
|
||||
assert_eq!(stats.breaker_state, OnDemandMigrationBreakerState::Open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_replication_snapshot_preserves_durable_mrf_unavailable_state() {
|
||||
let snapshot = bucket_replication_stats_snapshot_from_parts(
|
||||
|
||||
@@ -196,7 +196,7 @@ pub(crate) async fn read_config_revision<S: ScannerObjectIO>(store: Arc<S>, path
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct DataUsageCacheRevisions {
|
||||
main: DataUsageCacheRevision,
|
||||
backup: Option<DataUsageCacheRevision>,
|
||||
@@ -503,6 +503,10 @@ pub struct DataUsageCacheInfo {
|
||||
pub lkg_leader_epoch: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub lkg_scan_plan_digest: Option<DataUsageScanPlanDigest>,
|
||||
/// Activity-sensitive identity for same-cycle set snapshot reuse. The
|
||||
/// structural plan remains reusable across ordinary bucket writes.
|
||||
#[serde(default)]
|
||||
pub scan_execution_digest: Option<DataUsageScanPlanDigest>,
|
||||
}
|
||||
|
||||
impl Serialize for DataUsageCacheInfo {
|
||||
@@ -519,7 +523,8 @@ impl Serialize for DataUsageCacheInfo {
|
||||
+ usize::from(self.lkg_next_cycle.is_some())
|
||||
+ usize::from(self.lkg_last_update.is_some())
|
||||
+ usize::from(self.lkg_leader_epoch.is_some())
|
||||
+ usize::from(self.lkg_scan_plan_digest.is_some());
|
||||
+ usize::from(self.lkg_scan_plan_digest.is_some())
|
||||
+ usize::from(self.scan_execution_digest.is_some());
|
||||
let mut state = serializer.serialize_map(Some(field_count))?;
|
||||
state.serialize_entry("name", &self.name)?;
|
||||
state.serialize_entry("next_cycle", &self.next_cycle)?;
|
||||
@@ -558,6 +563,9 @@ impl Serialize for DataUsageCacheInfo {
|
||||
if let Some(scan_plan_digest) = self.lkg_scan_plan_digest {
|
||||
state.serialize_entry("lkg_scan_plan_digest", &scan_plan_digest)?;
|
||||
}
|
||||
if let Some(scan_execution_digest) = self.scan_execution_digest {
|
||||
state.serialize_entry("scan_execution_digest", &scan_execution_digest)?;
|
||||
}
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,6 +1067,7 @@ fn test_data_usage_cache_info_deserialize_defaults_scan_resume_after() {
|
||||
assert!(decoded.source.is_none());
|
||||
assert!(!decoded.snapshot_complete);
|
||||
assert!(decoded.scan_plan_digest.is_none());
|
||||
assert!(decoded.scan_execution_digest.is_none());
|
||||
assert_eq!(decoded.cache_key_format, 0);
|
||||
}
|
||||
|
||||
@@ -1109,6 +1110,7 @@ fn test_data_usage_cache_info_unmarshal_old_msgpack_defaults_scan_resume_after()
|
||||
assert!(decoded.source.is_none());
|
||||
assert!(!decoded.snapshot_complete);
|
||||
assert!(decoded.scan_plan_digest.is_none());
|
||||
assert!(decoded.scan_execution_digest.is_none());
|
||||
assert_eq!(decoded.cache_key_format, 0);
|
||||
}
|
||||
|
||||
@@ -1145,6 +1147,7 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
|
||||
source: Some(DataUsageCacheSource::new(1, 2)),
|
||||
snapshot_complete: true,
|
||||
scan_plan_digest: Some(TEST_PLAN_DIGEST),
|
||||
scan_execution_digest: Some(DataUsageScanPlanDigest([42; 32])),
|
||||
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -1164,6 +1167,7 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
|
||||
assert_eq!(current.info.source, Some(DataUsageCacheSource::new(1, 2)));
|
||||
assert!(current.info.snapshot_complete);
|
||||
assert_eq!(current.info.scan_plan_digest, Some(TEST_PLAN_DIGEST));
|
||||
assert_eq!(current.info.scan_execution_digest, Some(DataUsageScanPlanDigest([42; 32])));
|
||||
assert_eq!(current.info.cache_key_format, DATA_USAGE_CACHE_KEY_FORMAT);
|
||||
assert_eq!(current.find("bucket").map(|entry| entry.objects), Some(3));
|
||||
|
||||
|
||||
@@ -1616,7 +1616,7 @@ where
|
||||
// Refresh the storage-owned movement snapshot before reading background
|
||||
// heal state. A missing heal object yields an in-memory default; do not
|
||||
// let that default influence a cycle while publication is blocked.
|
||||
if storeapi.scanner_data_usage_publication_blocked().await {
|
||||
if storeapi.scanner_data_movement_pause_status().await.paused {
|
||||
mark_scan_cycle_idle(cycle_info, &mut cycle_metrics_guard).await;
|
||||
return ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::DataMovement);
|
||||
}
|
||||
@@ -1816,6 +1816,19 @@ where
|
||||
let publication_defer_reason = publication_defer_reason
|
||||
.or(remote_lease_defer_reason)
|
||||
.or(remote_lease_fence_defer_reason);
|
||||
// A PUT tail can finish between the walk and lease acquisition without
|
||||
// changing the movement epoch accepted by those leases. Re-prove the
|
||||
// namespace baseline only after every peer has granted publication.
|
||||
let post_lease_activity_defer_reason = if publication_defer_reason.is_none()
|
||||
&& remote_publication_leases.is_some()
|
||||
&& let Ok(result) = &scan_result
|
||||
&& result.status == ScannerCycleStatus::Complete
|
||||
{
|
||||
scanner_post_lease_activity_defer_reason(result.activity_digest(), probe_scanner_activity(storeapi.as_ref(), true).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let publication_defer_reason = publication_defer_reason.or(post_lease_activity_defer_reason);
|
||||
// Include reasons discovered while acquiring or validating remote leases.
|
||||
let publication_deferred = publication_defer_reason.is_some();
|
||||
let budget_elapsed = cycle_budget.budget_elapsed() && !ctx.is_cancelled();
|
||||
@@ -3240,6 +3253,21 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn scanner_post_lease_activity_defer_reason(
|
||||
expected_digest: Option<[u8; 32]>,
|
||||
activity: Result<ScannerActivitySnapshot, String>,
|
||||
) -> Option<ScannerCycleDeferReason> {
|
||||
match activity {
|
||||
Ok(snapshot)
|
||||
if scanner_activity_allows_usage_publication(&snapshot)
|
||||
&& expected_digest == Some(scanner_activity_snapshot_digest(&snapshot)) =>
|
||||
{
|
||||
None
|
||||
}
|
||||
Ok(_) | Err(_) => Some(ScannerCycleDeferReason::ActivityBaselineUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ScannerCyclePreCommitOutcome {
|
||||
RecoverCacheCycle(u64),
|
||||
@@ -3428,13 +3456,11 @@ use cycle_state::*;
|
||||
use leadership::*;
|
||||
use usage_store::*;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use activity::scanner_activity_snapshot_digest;
|
||||
pub use activity::scanner_topology_digest;
|
||||
pub(crate) use activity::{
|
||||
ScannerActivitySnapshot, ScannerDirtyUsageAcknowledgement, probe_scanner_activity, scanner_activity_allows_usage_publication,
|
||||
scanner_activity_dirty_usage_state_for_host, scanner_activity_publication_lease_targets, scanner_activity_structural_digest,
|
||||
scanner_dirty_usage_acknowledgements,
|
||||
scanner_activity_dirty_usage_state_for_host, scanner_activity_publication_lease_targets, scanner_activity_snapshot_digest,
|
||||
scanner_activity_structural_digest, scanner_dirty_usage_acknowledgements,
|
||||
};
|
||||
pub(crate) use activity::{ScannerCycleOutcome, scanner_cycle_outcome_with_pending_maintenance};
|
||||
pub use backlog::{
|
||||
|
||||
@@ -902,7 +902,6 @@ where
|
||||
observation
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scanner_activity_snapshot_digest(snapshot: &ScannerActivitySnapshot) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(u64::try_from(snapshot.len()).unwrap_or(u64::MAX).to_be_bytes());
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
use super::heal_info::{classify_background_heal_read_error, decode_background_heal_info};
|
||||
use super::*;
|
||||
use crate::EcstoreResult;
|
||||
use crate::storage_api::scan::BucketOperations as _;
|
||||
use crate::storage_api::owner::ecstore_hold_namespace_commit;
|
||||
use crate::storage_api::scan::{BucketOperations as _, ObjectIO as _};
|
||||
use crate::{
|
||||
DATA_USAGE_BLOOM_RECOVERY_PATH, DATA_USAGE_CACHE_KEY_FORMAT, DATA_USAGE_CACHE_NAME, DATA_USAGE_ROOT,
|
||||
DataUsageCachePrepareOutcome, DataUsageCacheSource, DataUsageEntry, DataUsageScanPlanDigest, Endpoint, EndpointServerPools,
|
||||
@@ -1165,6 +1166,116 @@ async fn run_data_scanner_cycle_publishes_activity_for_owner_lifetime() {
|
||||
global_metrics().set_cycle(None).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn coordinator_walks_during_pending_put_without_persisting_or_acknowledging_usage() {
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||
let bucket = format!("scanner-coordinator-pending-{}", Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &crate::storage_api::scan::MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("fixture bucket should be created");
|
||||
let mut reader = PutObjReader::from_vec(b"first".to_vec());
|
||||
store.pools[0].disk_set[0]
|
||||
.put_object(
|
||||
&bucket,
|
||||
"object",
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("fixture object should finish its rename fanout");
|
||||
crate::scanner_io::record_dirty_usage_bucket(&bucket);
|
||||
let dirty_before = crate::scanner_io::dirty_usage_buckets_for_tests();
|
||||
let baseline = read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("fixture usage baseline should be readable");
|
||||
let pending = ecstore_hold_namespace_commit(store.as_ref());
|
||||
let ctx = CancellationToken::new();
|
||||
let budget = ScannerCycleBudget::new_with_progress_tracking(&ctx, ScannerCycleBudgetConfig::default());
|
||||
let mut cycle_info = CurrentCycle {
|
||||
next: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let mut revision = DataUsageCacheRevision::Missing;
|
||||
let outcome = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
run_data_scanner_cycle_with_budget(&ctx, &store, &mut cycle_info, &mut revision, 1, Arc::clone(&budget)),
|
||||
)
|
||||
.await
|
||||
.expect("the coordinator must finish its namespace walk while a PUT is pending");
|
||||
assert_eq!(budget.progress().0, 1, "the coordinator must reach actual object traversal");
|
||||
assert_eq!(outcome, ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::DataMovement));
|
||||
assert_eq!(cycle_info.next, 1, "a rejected publication must not advance the cycle");
|
||||
assert_eq!(revision, DataUsageCacheRevision::Missing);
|
||||
assert_eq!(crate::scanner_io::dirty_usage_buckets_for_tests(), dirty_before);
|
||||
assert_eq!(
|
||||
read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("the prior authoritative usage must remain readable"),
|
||||
baseline,
|
||||
"the pending candidate must not replace the authoritative baseline"
|
||||
);
|
||||
|
||||
let committed_body = b"committed-after-walk";
|
||||
let mut reader = PutObjReader::from_vec(committed_body.to_vec());
|
||||
store.pools[0].disk_set[0]
|
||||
.put_object(
|
||||
&bucket,
|
||||
"object",
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the pending tail must change the physical object before it drains");
|
||||
assert_eq!(crate::scanner_io::dirty_usage_buckets_for_tests(), dirty_before);
|
||||
drop(pending);
|
||||
let retry_budget = ScannerCycleBudget::new_with_progress_tracking(&ctx, ScannerCycleBudgetConfig::default());
|
||||
let outcome = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
run_data_scanner_cycle_with_budget(&ctx, &store, &mut cycle_info, &mut revision, 1, Arc::clone(&retry_budget)),
|
||||
)
|
||||
.await
|
||||
.expect("the same cycle must converge after the pending PUT drains");
|
||||
assert_eq!(
|
||||
retry_budget.progress().0,
|
||||
1,
|
||||
"the same-cycle retry must not reuse the pre-tail bucket cache"
|
||||
);
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
ScannerCycleOutcome::Completed | ScannerCycleOutcome::CompletedWithPendingMaintenance
|
||||
));
|
||||
assert_eq!(cycle_info.next, 2);
|
||||
assert!(!crate::scanner_io::dirty_usage_buckets_for_tests().contains_key(&bucket));
|
||||
let usage = read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("the converged usage should be persisted");
|
||||
let usage: DataUsageInfo = serde_json::from_slice(&usage).expect("the persisted usage should decode");
|
||||
assert_eq!(usage.usage_snapshot_converged, Some(true));
|
||||
assert_eq!(usage.scanner_cycle, Some(1));
|
||||
assert_eq!(usage.objects_total_count, 1);
|
||||
assert_eq!(
|
||||
usage.objects_total_size,
|
||||
u64::try_from(committed_body.len()).expect("fixture body length")
|
||||
);
|
||||
let bucket_usage = usage
|
||||
.buckets_usage
|
||||
.get(&bucket)
|
||||
.expect("the scanned bucket should be published");
|
||||
assert_eq!(bucket_usage.objects_count, 1);
|
||||
assert_eq!(bucket_usage.size, u64::try_from(committed_body.len()).expect("fixture body length"));
|
||||
global_metrics().set_cycle(None).await;
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_finalize_partial_scan_cycle_advances_and_persists_counter() {
|
||||
@@ -8485,6 +8596,66 @@ fn scanner_node_activity(epoch: &str, namespace_generation: u64, maintenance_gen
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_lease_activity_proof_rejects_a_put_tail_that_finished_before_lease_acquisition() {
|
||||
let before = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3))]);
|
||||
let expected_digest = Some(scanner_activity_snapshot_digest(&before));
|
||||
assert_eq!(scanner_post_lease_activity_defer_reason(expected_digest, Ok(before.clone())), None);
|
||||
|
||||
let mut after = before.clone();
|
||||
after
|
||||
.get_mut("node-2")
|
||||
.expect("writer should be present")
|
||||
.namespace_generation += 1;
|
||||
assert_eq!(
|
||||
before["node-2"].movement_generation, after["node-2"].movement_generation,
|
||||
"the existing movement-only lease remains valid after a PUT tail drains"
|
||||
);
|
||||
assert!(scanner_activity_allows_usage_publication(&after));
|
||||
let reason = scanner_post_lease_activity_defer_reason(expected_digest, Ok(after));
|
||||
assert_eq!(reason, Some(ScannerCycleDeferReason::ActivityBaselineUnavailable));
|
||||
|
||||
let result = ScannerCycleResult::new(ScannerCycleStatus::Complete, None).with_remote_dirty_usage_acknowledgements(vec![
|
||||
ScannerDirtyUsageAcknowledgement {
|
||||
host: "node-2".to_string(),
|
||||
instance_id: "epoch-a".to_string(),
|
||||
generation: 5,
|
||||
},
|
||||
]);
|
||||
let (outcome, _, acknowledgements) = finalize_scanner_cycle_result(
|
||||
result,
|
||||
DataUsagePersistOutcome::Deferred(reason.expect("changed namespace should defer publication")),
|
||||
);
|
||||
assert_eq!(
|
||||
outcome,
|
||||
ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||
);
|
||||
assert!(
|
||||
acknowledgements.is_empty(),
|
||||
"a rejected publication must not acknowledge the peer's dirty usage"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_lease_activity_proof_requires_a_complete_matching_baseline() {
|
||||
let before = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3))]);
|
||||
let digest = scanner_activity_snapshot_digest(&before);
|
||||
let mut blocked = before.clone();
|
||||
blocked.get_mut("node-2").expect("peer should be present").publication_blocked = true;
|
||||
let blocked_digest = scanner_activity_snapshot_digest(&blocked);
|
||||
for (expected, observed) in [
|
||||
(None, Ok(before)),
|
||||
(Some(digest), Err("peer is unavailable".to_string())),
|
||||
(Some(digest), Ok(BTreeMap::new())),
|
||||
(Some(blocked_digest), Ok(blocked)),
|
||||
] {
|
||||
assert_eq!(
|
||||
scanner_post_lease_activity_defer_reason(expected, observed),
|
||||
Some(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_activity_snapshot_digest_fences_storage_topology() {
|
||||
let first = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3))]);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::data_usage_define::DATA_USAGE_CACHE_KEY_FORMAT;
|
||||
use crate::data_usage_define::{DATA_USAGE_CACHE_KEY_FORMAT, DataUsageCacheRevisions};
|
||||
use crate::scanner_budget::ScannerCycleBudget;
|
||||
use crate::scanner_folder::{ScannerItem, scan_data_folder};
|
||||
use crate::sleeper::SCANNER_SLEEPER;
|
||||
@@ -271,6 +271,8 @@ pub struct ScannerBucketScanPlan {
|
||||
all_buckets: Arc<Vec<BucketInfo>>,
|
||||
scope: ScannerBucketScanScope,
|
||||
digest: DataUsageScanPlanDigest,
|
||||
// Cache work must invalidate on namespace completion even when its scoped baseline remains reusable.
|
||||
execution_digest: DataUsageScanPlanDigest,
|
||||
leader_epoch: u64,
|
||||
tier_registry_generation: u64,
|
||||
/// Epoch captured once for the whole scanner cycle. `None` is retained
|
||||
@@ -456,9 +458,12 @@ async fn scanner_cycle_activity_status<S>(
|
||||
where
|
||||
S: ScannerStorage,
|
||||
{
|
||||
// Read the pending-commit barrier before sampling its completion generation.
|
||||
// A tail that drains during this await must invalidate the earlier baseline.
|
||||
let publication_blocked = store.scanner_data_usage_publication_blocked().await;
|
||||
match crate::scanner::probe_scanner_activity(store, distributed).await {
|
||||
Ok(after) => {
|
||||
let status = if after == *before {
|
||||
let status = if !publication_blocked && after == *before {
|
||||
ScannerCycleActivityStatus::Unchanged
|
||||
} else {
|
||||
ScannerCycleActivityStatus::Changed
|
||||
@@ -760,6 +765,7 @@ fn scanner_activity_preflight(
|
||||
pub(crate) struct ScannerCycleResult {
|
||||
pub(crate) status: ScannerCycleStatus,
|
||||
publication_epoch: Option<u64>,
|
||||
activity_digest: Option<[u8; 32]>,
|
||||
observational_snapshot_published: bool,
|
||||
dirty_usage_clear: Option<DirtyUsageBuckets>,
|
||||
remote_dirty_usage_acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
|
||||
@@ -774,6 +780,7 @@ impl ScannerCycleResult {
|
||||
Self {
|
||||
status,
|
||||
publication_epoch: None,
|
||||
activity_digest: None,
|
||||
observational_snapshot_published: false,
|
||||
dirty_usage_clear,
|
||||
remote_dirty_usage_acknowledgements: Vec::new(),
|
||||
@@ -793,6 +800,15 @@ impl ScannerCycleResult {
|
||||
self.publication_epoch
|
||||
}
|
||||
|
||||
fn with_activity_digest(mut self, activity_digest: [u8; 32]) -> Self {
|
||||
self.activity_digest = Some(activity_digest);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn activity_digest(&self) -> Option<[u8; 32]> {
|
||||
self.activity_digest
|
||||
}
|
||||
|
||||
pub(crate) fn with_observational_snapshot_published(mut self, published: bool) -> Self {
|
||||
self.observational_snapshot_published = published;
|
||||
self
|
||||
|
||||
@@ -604,10 +604,12 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
store: Arc<SetDisks>,
|
||||
updates: &mpsc::Sender<DataUsageCache>,
|
||||
mut cache_snapshot: DataUsageCache,
|
||||
initial_revisions: Option<&DataUsageCacheRevisions>,
|
||||
cache_cycle_floor: &AtomicU64,
|
||||
expected_publication_epoch: u64,
|
||||
) -> Option<SystemTime> {
|
||||
let source = cache_snapshot.info.source?;
|
||||
let execution_digest = cache_snapshot.info.scan_execution_digest?;
|
||||
let guard = match acquire_scanner_cache_locks(store.as_ref(), DATA_USAGE_CACHE_NAME, source).await {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => {
|
||||
@@ -672,20 +674,36 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
current_cache_root_entry_with_generation(
|
||||
&persisted,
|
||||
DATA_USAGE_ROOT,
|
||||
source,
|
||||
cache_snapshot.info.next_cycle,
|
||||
cache_snapshot.info.leader_epoch,
|
||||
scan_plan_digest,
|
||||
cache_snapshot.info.tier_registry_generation,
|
||||
),
|
||||
Ok(Some(_))
|
||||
) {
|
||||
if persisted.info.scan_execution_digest == Some(execution_digest)
|
||||
&& matches!(
|
||||
current_cache_root_entry_with_generation(
|
||||
&persisted,
|
||||
DATA_USAGE_ROOT,
|
||||
source,
|
||||
cache_snapshot.info.next_cycle,
|
||||
cache_snapshot.info.leader_epoch,
|
||||
scan_plan_digest,
|
||||
cache_snapshot.info.tier_registry_generation,
|
||||
),
|
||||
Ok(Some(_))
|
||||
)
|
||||
{
|
||||
cache_snapshot = persisted;
|
||||
} else {
|
||||
// A later execution may have completed while this scan was walking.
|
||||
// Only replace the cache revision from which this scan started.
|
||||
if initial_revisions != Some(&revisions) {
|
||||
warn!(
|
||||
target: "rustfs::scanner::io",
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "scan_baseline_revision_changed",
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
"Scanner skipped set snapshot without an unchanged baseline revision"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if guard.is_lock_lost() {
|
||||
error!(
|
||||
target: "rustfs::scanner::io",
|
||||
|
||||
@@ -118,6 +118,7 @@ impl ScannerIOCache for SetDisks {
|
||||
all_buckets,
|
||||
scope,
|
||||
digest: scan_plan_digest,
|
||||
execution_digest,
|
||||
leader_epoch,
|
||||
tier_registry_generation,
|
||||
publication_epoch,
|
||||
@@ -137,20 +138,24 @@ impl ScannerIOCache for SetDisks {
|
||||
.ok_or_else(|| StorageError::other("scanner cache publication is blocked by data movement"))?,
|
||||
};
|
||||
let mut old_cache = DataUsageCache::default();
|
||||
if let Err(e) = old_cache.load(self.clone(), DATA_USAGE_CACHE_NAME).await {
|
||||
warn!(
|
||||
target: "rustfs::scanner::io",
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
pool = self.pool_index,
|
||||
set = self.set_index,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "old_cache_load_failed",
|
||||
error = %e,
|
||||
"Scanner old data usage cache load failed; rebuilding from bucket caches"
|
||||
);
|
||||
}
|
||||
let initial_revisions = match old_cache.load_with_revisions(self.clone(), DATA_USAGE_CACHE_NAME).await {
|
||||
Ok(revisions) => Some(revisions),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "rustfs::scanner::io",
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
pool = self.pool_index,
|
||||
set = self.set_index,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "old_cache_load_failed",
|
||||
error = %e,
|
||||
"Scanner old data usage cache load failed; rebuilding from bucket caches"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let scoped_scan = prepare_scoped_set_scan(
|
||||
&old_cache,
|
||||
&buckets,
|
||||
@@ -195,6 +200,7 @@ impl ScannerIOCache for SetDisks {
|
||||
};
|
||||
cache.info.last_update = Some(now);
|
||||
cache.info.snapshot_complete = true;
|
||||
cache.info.scan_execution_digest = Some(execution_digest);
|
||||
cache.info.lkg_snapshot_complete = false;
|
||||
cache.info.lkg_next_cycle = None;
|
||||
cache.info.lkg_last_update = None;
|
||||
@@ -208,6 +214,7 @@ impl ScannerIOCache for SetDisks {
|
||||
self,
|
||||
&updates,
|
||||
cache,
|
||||
initial_revisions.as_ref(),
|
||||
cache_cycle_floor.as_ref(),
|
||||
expected_publication_epoch,
|
||||
)
|
||||
@@ -637,7 +644,7 @@ impl ScannerIOCache for SetDisks {
|
||||
|
||||
let cache_name = path_join_buf(&[&bucket.name, DATA_USAGE_CACHE_NAME]);
|
||||
let bucket_scan_plan_digest =
|
||||
scanner_bucket_cache_digest(scan_plan_digest, dirty_usage_buckets_clone.get(&bucket.name).copied());
|
||||
scanner_bucket_cache_digest(execution_digest, dirty_usage_buckets_clone.get(&bucket.name).copied());
|
||||
|
||||
if let Some(server_epoch) = remote_server_epoch {
|
||||
let request_sequence = remote_session_sequence;
|
||||
@@ -1360,6 +1367,7 @@ impl ScannerIOCache for SetDisks {
|
||||
cache.info.next_cycle = want_cycle;
|
||||
cache.info.last_update.get_or_insert_with(SystemTime::now);
|
||||
cache.info.snapshot_complete = true;
|
||||
cache.info.scan_execution_digest = Some(execution_digest);
|
||||
cache.info.lkg_snapshot_complete = false;
|
||||
cache.info.lkg_next_cycle = None;
|
||||
cache.info.lkg_last_update = None;
|
||||
@@ -1371,6 +1379,7 @@ impl ScannerIOCache for SetDisks {
|
||||
self.clone(),
|
||||
&updates,
|
||||
cache_snapshot,
|
||||
initial_revisions.as_ref(),
|
||||
cache_cycle_floor.as_ref(),
|
||||
expected_publication_epoch,
|
||||
)
|
||||
|
||||
@@ -180,7 +180,7 @@ where
|
||||
// canceled decommission remains suspended after its worker exits, so
|
||||
// starting a scan in that state could build a snapshot that cannot be
|
||||
// routed to the authoritative metadata object.
|
||||
if store.scanner_data_usage_publication_blocked().await {
|
||||
if store.scanner_data_movement_pause_status().await.paused {
|
||||
debug!(
|
||||
target: "rustfs::scanner::io",
|
||||
event = EVENT_SCANNER_SET_STATE,
|
||||
@@ -260,8 +260,13 @@ where
|
||||
}
|
||||
}
|
||||
bucket_plan_complete &= buckets_by_source.keys().copied().collect::<HashSet<_>>() == *expected_sources;
|
||||
let activity_digest = crate::scanner::scanner_activity_snapshot_digest(&activity_before);
|
||||
let scan_plan_digest =
|
||||
scanner_bucket_plan_digest(&all_buckets, crate::scanner::scanner_activity_structural_digest(&activity_before));
|
||||
let mut execution_hasher = Sha256::new();
|
||||
execution_hasher.update(scan_plan_digest.0);
|
||||
execution_hasher.update(activity_digest);
|
||||
let execution_digest = DataUsageScanPlanDigest(execution_hasher.finalize().into());
|
||||
let dirty_usage_snapshot = Arc::new(snapshot_dirty_usage_buckets(&all_buckets, dirty_generation_before_bucket_list));
|
||||
let scan_scope = resolve_scanner_bucket_scan_scope(
|
||||
store,
|
||||
@@ -326,6 +331,7 @@ where
|
||||
};
|
||||
return Ok(ScannerCycleResult::new(status, dirty_usage_clear)
|
||||
.with_publication_epoch(publication_epoch)
|
||||
.with_activity_digest(activity_digest)
|
||||
.with_observational_snapshot_published(observational_snapshot_published)
|
||||
.with_remote_publication_lease_targets(remote_publication_lease_targets)
|
||||
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements));
|
||||
@@ -410,6 +416,7 @@ where
|
||||
all_buckets: Arc::clone(&all_buckets),
|
||||
scope: scan_scope.clone(),
|
||||
digest: scan_plan_digest,
|
||||
execution_digest,
|
||||
leader_epoch,
|
||||
tier_registry_generation,
|
||||
publication_epoch,
|
||||
@@ -598,6 +605,7 @@ where
|
||||
};
|
||||
Ok(ScannerCycleResult::new(cycle_status, dirty_usage_clear)
|
||||
.with_publication_epoch(publication_epoch)
|
||||
.with_activity_digest(activity_digest)
|
||||
.with_observational_snapshot_published(observational_snapshot_published)
|
||||
.with_remote_publication_lease_targets(remote_publication_lease_targets)
|
||||
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements)
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::scanner_folder::ScannerItem;
|
||||
use crate::storage_api::EcstoreScannerPeerDirtyUsageSnapshot;
|
||||
use crate::storage_api::owner::{
|
||||
EcstorePoolDecommissionInfo, EcstoreRebalStatus, EcstoreRebalanceInfo, EcstoreRebalanceMeta, EcstoreRebalanceStats,
|
||||
ecstore_hold_namespace_commit,
|
||||
};
|
||||
use crate::storage_api::scan::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions, ObjectIO as _};
|
||||
use crate::{
|
||||
@@ -343,6 +344,16 @@ async fn multi_pool_scanner_cycle_publishes_combined_usage() {
|
||||
.put_object(&bucket, object, &mut reader, &ScannerObjectOptions::default())
|
||||
.await
|
||||
.expect("object should be written to its selected pool");
|
||||
|
||||
// Quorum ACK can precede tail publication on the disk chosen to scan.
|
||||
let lock = store.pools[pool_index].disk_set[0]
|
||||
.new_ns_lock(&bucket, object)
|
||||
.await
|
||||
.expect("fixture namespace lock should be created");
|
||||
let _settled = lock
|
||||
.get_write_lock(Duration::from_secs(30))
|
||||
.await
|
||||
.expect("fixture rename tail should finish before the usage scan");
|
||||
}
|
||||
|
||||
let ctx = CancellationToken::new();
|
||||
@@ -362,7 +373,7 @@ async fn multi_pool_scanner_cycle_publishes_combined_usage() {
|
||||
.buckets_usage
|
||||
.get(&bucket)
|
||||
.expect("combined bucket usage should be present");
|
||||
assert_eq!(bucket_usage.objects_count, 2);
|
||||
assert_eq!(bucket_usage.objects_count, 2, "{usage:?}");
|
||||
assert_eq!(bucket_usage.size, 11);
|
||||
assert_eq!(usage.objects_total_count, 2);
|
||||
assert_eq!(usage.objects_total_size, 11);
|
||||
@@ -372,6 +383,102 @@ async fn multi_pool_scanner_cycle_publishes_combined_usage() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn pending_put_commit_keeps_scanner_walk_live_without_authoritative_usage() {
|
||||
let (_temp_dir, store) = setup_two_pool_scanner_store().await;
|
||||
let bucket = format!("scanner-pending-put-{}", Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created across both pools");
|
||||
for (pool_index, (object, body)) in [("pool-a", b"first".as_slice()), ("pool-b", b"second".as_slice())]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut reader = ScannerPutObjReader::from_vec(body.to_vec());
|
||||
store.pools[pool_index].disk_set[0]
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ScannerObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("fixture objects must finish their rename fanouts before scanning");
|
||||
}
|
||||
|
||||
let mut pending = Some(ecstore_hold_namespace_commit(store.as_ref()));
|
||||
let mut previous_activity_digest = None;
|
||||
let mut structural_plan_digest = None;
|
||||
for (cycle, converged) in [(1, false), (2, true)] {
|
||||
if converged {
|
||||
drop(pending.take());
|
||||
}
|
||||
assert_eq!(store.scanner_data_usage_publication_blocked().await, !converged);
|
||||
assert!(!store.scanner_data_movement_pause_status().await.paused);
|
||||
let activity = crate::scanner::probe_scanner_activity(store.as_ref(), false)
|
||||
.await
|
||||
.expect("the fixture activity should be observable");
|
||||
let activity_digest = crate::scanner::scanner_activity_snapshot_digest(&activity);
|
||||
if let Some(previous) = previous_activity_digest.replace(activity_digest) {
|
||||
assert_ne!(previous, activity_digest, "draining a namespace commit must change the publication proof");
|
||||
}
|
||||
let ctx = CancellationToken::new();
|
||||
let budget = ScannerCycleBudget::new_with_progress_tracking(&ctx, ScannerCycleBudgetConfig::default());
|
||||
let (updates, mut receiver) = mpsc::channel(1);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
ScannerIOCycle::nsscanner_with_status(
|
||||
store.as_ref(),
|
||||
ctx,
|
||||
Arc::clone(&budget),
|
||||
updates,
|
||||
cycle,
|
||||
1,
|
||||
HealScanMode::Normal,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("namespace scanning must finish while a PUT commit is pending")
|
||||
.expect("namespace scanning must remain available during a pending PUT commit");
|
||||
assert_eq!(result.activity_digest(), Some(activity_digest));
|
||||
if !converged {
|
||||
assert_eq!(budget.progress().0, 2, "the pending commit must not suppress actual object traversal");
|
||||
}
|
||||
assert_eq!(
|
||||
result.status,
|
||||
if converged {
|
||||
ScannerCycleStatus::Complete
|
||||
} else {
|
||||
ScannerCycleStatus::Superseded
|
||||
}
|
||||
);
|
||||
let usage = receiver
|
||||
.recv()
|
||||
.await
|
||||
.expect("the completed walk should produce a usage candidate");
|
||||
assert_eq!(usage.usage_snapshot_converged, Some(converged));
|
||||
assert_eq!(usage.scanner_cycle, Some(cycle));
|
||||
assert_eq!(usage.objects_total_count, 2);
|
||||
assert_eq!(usage.objects_total_size, 11);
|
||||
assert_eq!(usage.usage_snapshot_set_states.len(), 2);
|
||||
for state in &usage.usage_snapshot_set_states {
|
||||
let digest = state
|
||||
.scan_plan_digest
|
||||
.expect("each set must retain its structural cache identity");
|
||||
assert_eq!(*structural_plan_digest.get_or_insert(digest), digest);
|
||||
}
|
||||
let bucket_usage = usage.buckets_usage.get(&bucket).expect("the walked bucket must be present");
|
||||
assert_eq!(bucket_usage.objects_count, 2);
|
||||
assert_eq!(bucket_usage.size, 11);
|
||||
assert!(receiver.recv().await.is_none(), "each walk must emit exactly one terminal candidate");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn multi_pool_scanner_cycle_zero_fills_bucket_absent_from_first_pool() {
|
||||
@@ -387,6 +494,16 @@ async fn multi_pool_scanner_cycle_zero_fills_bucket_absent_from_first_pool() {
|
||||
.put_object(&bucket, "pool-b", &mut reader, &ScannerObjectOptions::default())
|
||||
.await
|
||||
.expect("object should be written only to the second pool");
|
||||
{
|
||||
let lock = store.pools[1].disk_set[0]
|
||||
.new_ns_lock(&bucket, "pool-b")
|
||||
.await
|
||||
.expect("fixture namespace lock should be created");
|
||||
let _settled = lock
|
||||
.get_write_lock(Duration::from_secs(30))
|
||||
.await
|
||||
.expect("fixture rename tail should finish before the usage scan");
|
||||
}
|
||||
store.pools[0]
|
||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
@@ -797,6 +914,124 @@ fn complete_set_usage_cache(buckets: &[(&str, usize)], scan_plan_digest: DataUsa
|
||||
cache
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers() {
|
||||
let (_temp_dir, store) = setup_two_pool_scanner_store().await;
|
||||
let set = Arc::clone(&store.pools[0].disk_set[0]);
|
||||
let epoch = scanner_publication_epoch(Arc::clone(&set)).await.expect("idle set admission");
|
||||
let mut legacy = complete_set_usage_cache(&[("photos", 5)], DataUsageScanPlanDigest([1; 32]));
|
||||
legacy.info.source = Some(DataUsageCacheSource::new(0, 0));
|
||||
legacy
|
||||
.save(Arc::clone(&set), DATA_USAGE_CACHE_NAME)
|
||||
.await
|
||||
.expect("seed legacy set cache");
|
||||
let mut persisted = DataUsageCache::default();
|
||||
let initial = persisted
|
||||
.load_with_revisions(Arc::clone(&set), DATA_USAGE_CACHE_NAME)
|
||||
.await
|
||||
.expect("capture the shared starting revision");
|
||||
let mut fresh = legacy.clone();
|
||||
fresh.info.scan_execution_digest = Some(DataUsageScanPlanDigest([2; 32]));
|
||||
fresh.replace(
|
||||
"photos",
|
||||
DATA_USAGE_ROOT,
|
||||
DataUsageEntry {
|
||||
size: 20,
|
||||
objects: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let cycle_floor = AtomicU64::new(fresh.info.next_cycle);
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
assert!(
|
||||
persist_and_publish_cache_snapshot(Arc::clone(&set), &tx, fresh.clone(), Some(&initial), &cycle_floor, epoch)
|
||||
.await
|
||||
.is_some(),
|
||||
"a legacy cache without execution identity must be refreshed"
|
||||
);
|
||||
let published = rx.try_recv().expect("fresh snapshot should be forwarded");
|
||||
assert_eq!(published.find("photos").expect("published bucket").size, 20);
|
||||
assert_eq!(published.info.scan_execution_digest, fresh.info.scan_execution_digest);
|
||||
let current = persisted
|
||||
.load_with_revisions(Arc::clone(&set), DATA_USAGE_CACHE_NAME)
|
||||
.await
|
||||
.expect("capture the current revision for the unidentified execution");
|
||||
|
||||
let mut stale = legacy.clone();
|
||||
stale.info.scan_execution_digest = Some(DataUsageScanPlanDigest([3; 32]));
|
||||
for (candidate, revisions) in [(stale, &initial), (legacy, ¤t)] {
|
||||
assert!(
|
||||
persist_and_publish_cache_snapshot(Arc::clone(&set), &tx, candidate, Some(revisions), &cycle_floor, epoch)
|
||||
.await
|
||||
.is_none(),
|
||||
"a stale or unidentified execution must not replace the newer snapshot"
|
||||
);
|
||||
assert!(matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)));
|
||||
}
|
||||
fresh.info.scan_execution_digest = Some(DataUsageScanPlanDigest([4; 32]));
|
||||
assert!(
|
||||
persist_and_publish_cache_snapshot(Arc::clone(&set), &tx, fresh.clone(), None, &cycle_floor, epoch)
|
||||
.await
|
||||
.is_none(),
|
||||
"an unreadable starting revision must not authorize an overwrite"
|
||||
);
|
||||
|
||||
fresh.info.scan_execution_digest = published.info.scan_execution_digest;
|
||||
fresh.replace("photos", DATA_USAGE_ROOT, DataUsageEntry::default());
|
||||
assert!(
|
||||
persist_and_publish_cache_snapshot(Arc::clone(&set), &tx, fresh, Some(&initial), &cycle_floor, epoch)
|
||||
.await
|
||||
.is_some(),
|
||||
"an overlapping identical execution must reuse the completed snapshot"
|
||||
);
|
||||
assert_eq!(
|
||||
rx.try_recv()
|
||||
.expect("reused snapshot")
|
||||
.find("photos")
|
||||
.expect("reused bucket")
|
||||
.size,
|
||||
20
|
||||
);
|
||||
persisted
|
||||
.load(Arc::clone(&set), DATA_USAGE_CACHE_NAME)
|
||||
.await
|
||||
.expect("read the final durable set cache");
|
||||
assert_eq!(persisted.find("photos").expect("durable bucket").size, 20);
|
||||
assert_eq!(persisted.info.scan_execution_digest, published.info.scan_execution_digest);
|
||||
|
||||
let ctx = CancellationToken::new();
|
||||
let empty_execution = DataUsageScanPlanDigest([5; 32]);
|
||||
set.nsscanner_cache(
|
||||
ctx.clone(),
|
||||
ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default()),
|
||||
ScannerBucketScanPlan {
|
||||
buckets: Vec::new(),
|
||||
all_buckets: Arc::new(Vec::new()),
|
||||
scope: ScannerBucketScanScope::default(),
|
||||
digest: DataUsageScanPlanDigest([6; 32]),
|
||||
execution_digest: empty_execution,
|
||||
leader_epoch: 11,
|
||||
tier_registry_generation: 13,
|
||||
publication_epoch: Some(epoch),
|
||||
dirty_usage_buckets: Arc::new(HashMap::new()),
|
||||
bucket_failures: ScannerBucketFailureState::default(),
|
||||
pending_maintenance_work: Arc::new(AtomicBool::new(false)),
|
||||
cache_cycle_floor: Arc::new(AtomicU64::new(8)),
|
||||
},
|
||||
tx,
|
||||
8,
|
||||
HealScanMode::Normal,
|
||||
)
|
||||
.await
|
||||
.expect("empty set scope should replace its prior nonempty cache");
|
||||
let empty = rx.try_recv().expect("empty set snapshot should be published");
|
||||
assert_eq!(empty.info.scan_execution_digest, Some(empty_execution));
|
||||
assert!(empty.info.snapshot_complete);
|
||||
let root = empty.checked_flatten(DATA_USAGE_ROOT).expect("complete empty root");
|
||||
assert_eq!((root.size, root.objects), (0, 0));
|
||||
}
|
||||
|
||||
fn complete_usage_baseline(
|
||||
source: DataUsageCacheSource,
|
||||
scan_plan_digest: DataUsageScanPlanDigest,
|
||||
|
||||
@@ -127,6 +127,9 @@ pub(crate) use rustfs_lifecycle::{
|
||||
use rustfs_storage_api as storage_contracts;
|
||||
|
||||
pub(crate) mod owner {
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::set_disk::test_util::hold_namespace_commit as ecstore_hold_namespace_commit;
|
||||
|
||||
pub(crate) use super::storage_contracts::{
|
||||
HTTPPreconditions, HTTPRangeSpec, NS_SCANNER_PROTOCOL_VERSION, ObjectIO, ObjectOperations, ObjectToDelete,
|
||||
};
|
||||
|
||||
@@ -13,8 +13,8 @@ Operator-facing behaviour, configuration, and troubleshooting for these services
|
||||
|
||||
| Service | Desired source | Current-status inputs | Status surface | Side effects |
|
||||
|---|---|---|---|---|
|
||||
| Write-back pull pipeline (`crates/ecstore/src/bucket/on_demand_migration/pull.rs`; the local write is delegated to the app layer in `rustfs/src/app/object/on_demand_migration_put.rs`) | The bucket's `on-demand-migration.json` (`enabled`, `policy.max_concurrent_pulls`, `pull_queue_capacity`, `multipart_part_size_bytes`, `bandwidth_limit_bytes_per_sec`) together with the process switch `RUSTFS_ON_DEMAND_MIGRATION_ENABLED` | Per-bucket runtime state in `crates/ecstore/src/bucket/on_demand_migration/sys.rs`: whether a state is installed, whether its source client built, its cancellation token, queue depth, in-flight pull permits | `GET /rustfs/admin/v3/on-demand-migration/{bucket}/status` (`inflight_pulls`, `queue_depth`, `counters.pulled_*`, `counters.pull_failures_total`) and the `rustfs_on_demand_migration_*` series | Source GET/HEAD/GetObjectTagging traffic; local object writes through the internal put path, hence quota consumption, bucket default SSE, versioning, Object Lock defaults, `ObjectCreated` notifications, and outbound replication scheduling |
|
||||
| Backfill job (module under `crates/ecstore/src/bucket/on_demand_migration/`, rustfs/backlog#2159 — not yet in the tree) | An admin `start` request plus the bucket config; invalidated when the config's `updated_at` changes or the config is deleted | The persisted checkpoint under the bucket's metadata prefix, its `state` field, and the owner lease | The backfill section of the bucket status endpoint and the `rustfs_on_demand_migration_backfill_*` series | Source `ListObjectsV2` paging; queue admission into the write-back pipeline (and therefore all of its side effects); checkpoint writes |
|
||||
| Backfill recovery loop (registered from `rustfs/src/startup_background.rs`, rustfs/backlog#2159 — not yet in the tree) | The set of persisted checkpoints in `state = running`; runs on every node | Checkpoint owner lease expiry | Takeover is reported through the same backfill status; a takeover emits a warn-level lease event | Claims the lease and resumes the backfill job, inheriting its side effects. Scanning checkpoints is read-only |
|
||||
| Write-back pull pipeline (`rustfs/src/on_demand_migration/pull.rs`; the local write is delegated to the app layer in `rustfs/src/app/object/on_demand_migration_put.rs`) | The bucket's `on-demand-migration.json` (`enabled`, `policy.max_concurrent_pulls`, `pull_queue_capacity`, `multipart_part_size_bytes`, `bandwidth_limit_bytes_per_sec`) together with the process switch `RUSTFS_ON_DEMAND_MIGRATION_ENABLED` | Per-bucket runtime state in `rustfs/src/on_demand_migration/sys.rs`: whether a state is installed, whether its source client built, its cancellation token, queue depth, in-flight pull permits | `GET /rustfs/admin/v3/on-demand-migration/{bucket}/status` (`inflight_pulls`, `queue_depth`, `counters.pulled_*`, `counters.pull_failures_total`) and the `rustfs_on_demand_migration_*` series | Source GET/HEAD/GetObjectTagging traffic; local object writes through the internal put path, hence quota consumption, bucket default SSE, versioning, Object Lock defaults, `ObjectCreated` notifications, and outbound replication scheduling |
|
||||
| Backfill job (module under `rustfs/src/on_demand_migration/`, rustfs/backlog#2159) | An admin `start` request plus the bucket config; invalidated when the config's `updated_at` changes or the config is deleted | The persisted checkpoint under the bucket's metadata prefix, its `state` field, and the owner lease | The backfill section of the bucket status endpoint and the `rustfs_on_demand_migration_backfill_*` series | Source `ListObjectsV2` paging; queue admission into the write-back pipeline (and therefore all of its side effects); checkpoint writes |
|
||||
| Backfill recovery loop (registered from `rustfs/src/startup_background.rs`, rustfs/backlog#2159) | The set of persisted checkpoints in `state = running`; runs on every node | Checkpoint owner lease expiry | Takeover is reported through the same backfill status; a takeover emits a warn-level lease event | Claims the lease and resumes the backfill job, inheriting its side effects. Scanning checkpoints is read-only |
|
||||
|
||||
The pull pipeline has no separate loop of its own: a bucket's queue dispatcher starts lazily on the first background pull and is cancelled when the bucket's state is rebuilt or removed, and each inline pull commits in a task that outlives its request so a client disconnect cannot truncate the stored object. Neither the switch nor the config is re-read by the workers: the bucket-metadata publish hook rebuilds the state, which is the only desired-state path.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
## Open Items
|
||||
|
||||
- `backlog-2263` legacy heal MRF inspection: retained per-record journals remain readable while committed-snapshot ownership and writer activation are staged. Remove legacy import only after all supported direct-upgrade and rollback readers understand committed snapshots and migration tooling confirms that no retained or restorable legacy journal requires it. This does not enable a new writer or change the automatic legacy consumer.
|
||||
- `backlog-1337` legacy restore orphan recovery: releases that predate the restore worker-lock marker can leave a valid operation-id and `ongoing-request="true"` after cancellation or process failure, with no durable liveness proof. New servers allow an exact, non-nil legacy generation to be superseded only when its consistently parsed request date is at least 24 hours old. Remove the clock-based legacy fallback after the minimum supported direct-upgrade release writes the v1 worker-lock marker on every restore and operators have resolved every retained pre-v1 ongoing generation.
|
||||
- `backlog-2133-tier-delete-chunk-parent` bounded tier-delete dispatch compatibility: prefixes at or below the legacy manifest limit keep the byte-compatible v1 single-manifest protocol, while larger prefixes place a chunk-parent sentinel at the original deterministic root path and use operation-scoped child manifests. Older binaries reject the sentinel and child paths, preserving the v6 sole-owner downgrade fence instead of starting a competing local delete. Remove the v1 reader and fail-closed mixed-version sentinel only after every supported rollback release validates the parent/child protocol and migration tooling confirms that no retained v1 dispatch manifest remains.
|
||||
- `tokio-tar-extension-limits` bounded archive parser hardening: Snowball extraction depends on precedence-resolved MinIO PAX metadata; per-entry and cumulative extension limits; a physical-entry limit; cancellation-safe parsing and ownership of large streamed members; fused streams after errors; and compatibility with minio-go streams that omit the two-block terminator. Swift bulk extraction also uses the same fork. Keep the reviewed pin while the Snowball path is prototyped against tar-codec/tar-framing. Remove it only after a released API exposes the effective allowed vendor records, RustFS provides a cancellation-safe handoff for borrowed member payloads, footerless input is accepted solely when authenticated request framing proves EOF immediately after a complete member, the existing resource-limit, cancellation, error-fuse, and real minio-go fixtures pass against the replacement, and Swift no longer depends on the fork.
|
||||
|
||||
@@ -84,3 +84,41 @@ The server-config model (`Config`, `KV`, `KVS`) and the global server-config sna
|
||||
## Required Architecture Documents
|
||||
|
||||
The guard requires the documents and section headings listed in its `require_source_contains` entries (`scripts/check_architecture_migration_rules.sh`); the directory index is [README.md](README.md).
|
||||
|
||||
## On-Demand Migration Service
|
||||
|
||||
Read-through, backfill and external pull orchestration belong in an application
|
||||
service under `rustfs/src/<service>/`. ECStore owns the storage primitives they
|
||||
need, including atomic commits, lifecycle locks and on-disk metadata. A service
|
||||
may use these primitives without moving its provider clients or scheduling
|
||||
policy into the engine.
|
||||
|
||||
`rustfs/src/on_demand_migration/` owns source clients, pull scheduling, list
|
||||
merging, runtime state and backfill orchestration. Its `storage_api.rs` is the
|
||||
only ECStore facade boundary. Object write-back still enters the application's
|
||||
internal PUT and multipart use cases, including the atomic create-only commit,
|
||||
delete-marker protection, encryption, quota and notification rules.
|
||||
|
||||
ECStore stores the existing ODM bytes and update timestamp without interpreting
|
||||
the JSON. Every metadata cache install or removal publishes those bytes through
|
||||
`BUCKET_CONFIG_PUBLISH_HOOK`; the application decodes them and synchronously
|
||||
withdraws corrupt configurations. Configuration writes validate structure and
|
||||
deployment constraints in the admin use case before the incarnation-fenced
|
||||
metadata update. Backfill reads metadata from its store's instance context and
|
||||
preserves the checkpoint ETag compare-and-set, lease and tail-drained writes.
|
||||
|
||||
An ODM runtime is bound to the bucket incarnation published with its metadata,
|
||||
not just its name. Source reads and write-back reject a different incarnation.
|
||||
Checkpoint writes hold the user bucket's lifecycle fence through their complete
|
||||
commit and read-back, even if their caller stops waiting; the storage commit
|
||||
also observes lock loss. Deleting and recreating a bucket must not let work for
|
||||
its previous incarnation repopulate objects or checkpoints.
|
||||
|
||||
Observability owns its metric DTOs and accepts application snapshot callbacks;
|
||||
it does not depend on the ODM runtime. The application registers both bucket
|
||||
and backfill snapshots during startup, before metadata and metric collection.
|
||||
|
||||
This boundary does not change `.metadata.bin`, the ODM wire format or the
|
||||
backfill checkpoint format. An older binary may still discard unknown metadata
|
||||
fields when it rewrites a bucket; service relocation does not make mixed-version
|
||||
configuration writes or rollback preserve ODM configuration.
|
||||
|
||||
@@ -63,3 +63,10 @@ Lifecycle, replication, and `SetDisks` split blockers, extracted contracts, and
|
||||
4. Do not replace `SetDisks` with multiple runtime structs in one change; move one operation family only after contracts and focused tests exist.
|
||||
5. Remove or narrow one facade group per change so rollback preserves object IO, quorum, lifecycle/replication queues, scanner repair, notification/audit events, and metadata compatibility.
|
||||
6. Keep `api::bucket`, `api::config`, `api::disk`, and `api::tier` on explicit submodules and symbol lists; do not restore `pub use crate::<owner>::{...}` whole-module passthroughs for those groups.
|
||||
|
||||
### On-Demand Migration
|
||||
|
||||
`rustfs/src/on_demand_migration/storage_api.rs` owns the service's storage facade
|
||||
imports: opaque bucket configuration, shared remote S3 client construction,
|
||||
namespace locking, object options and metadata-object persistence. ODM types
|
||||
are owned by the application and are no longer exported through ECStore.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Remote Credential Sealing ADR
|
||||
|
||||
**Use this when:** you add, read, or persist a stored remote credential — a replication target, a remote tier, or an on-demand migration source — or you need the sealed-envelope format, the mixed-version rules, or the reason this is worth doing in one deployment and not in another.
|
||||
**Source of truth:** the three stores that hold remote credentials — `BUCKET_TARGETS_FILE` and `BUCKET_ON_DEMAND_MIGRATION_CONFIG` in `crates/ecstore/src/bucket/metadata.rs`, `TIER_CONFIG_FILE` in `crates/ecstore/src/services/tier/tier.rs` — the shared envelope in `crates/ecstore/src/bucket/sealed_credentials.rs`, the consumers `crates/ecstore/src/bucket/bucket_target_sys.rs`, `crates/ecstore/src/services/tier/tier_config.rs` and `crates/ecstore/src/bucket/on_demand_migration/config.rs`, and the backend properties in [../operations/kms-backend-security.md](../operations/kms-backend-security.md).
|
||||
**Source of truth:** the three stores that hold remote credentials — `BUCKET_TARGETS_FILE` and `BUCKET_ON_DEMAND_MIGRATION_CONFIG` in `crates/ecstore/src/bucket/metadata.rs`, `TIER_CONFIG_FILE` in `crates/ecstore/src/services/tier/tier.rs` — the shared envelope in `crates/ecstore/src/bucket/sealed_credentials.rs`, the consumers `crates/ecstore/src/bucket/bucket_target_sys.rs`, `crates/ecstore/src/services/tier/tier_config.rs` and `rustfs/src/on_demand_migration/config.rs`, and the backend properties in [../operations/kms-backend-security.md](../operations/kms-backend-security.md).
|
||||
|
||||
## Recommendation
|
||||
|
||||
@@ -37,7 +37,7 @@ Two of the three are not files at all. `bucket-targets.json` and `on-demand-migr
|
||||
| Store | Reached as | Actually persisted at | Written by | Container |
|
||||
|---|---|---|---|---|
|
||||
| Replication and ILM targets | `BUCKET_TARGETS_FILE` | `BucketMetadata::bucket_targets_config_json`, msgpack field `BucketTargetsConfigJSON` | `BucketMetadata::update_config`, then `BucketMetadata::save_with_store`; `crates/ecstore/src/bucket/metadata_sys.rs` serializes the update under a transaction lock | `{BUCKET_META_PREFIX}/{bucket}/{BUCKET_METADATA_FILE}` in `RUSTFS_META_BUCKET` (`crates/ecstore/src/disk/mod.rs`) |
|
||||
| On-demand migration source | `BUCKET_ON_DEMAND_MIGRATION_CONFIG` | `BucketMetadata::on_demand_migration_config_json`, msgpack field `OnDemandMigrationConfigJSON` | same path; `update_config` additionally refuses a blob this build cannot parse | same blob as above |
|
||||
| On-demand migration source | `BUCKET_ON_DEMAND_MIGRATION_CONFIG` | `BucketMetadata::on_demand_migration_config_json`, msgpack field `OnDemandMigrationConfigJSON` | same path; the application validates structure and deployment constraints before persistence | same blob as above |
|
||||
| Remote tiers | `TIER_CONFIG_FILE` | its own object, a four-byte `TIER_CONFIG_FORMAT` / `TIER_CONFIG_VERSION` header followed by an `rmp_serde` payload of `ExternalTierConfigMgr` | `TierConfigMgr` through `encode_external_tiering_config_blob`, under `tier_config_lock_path` | `tier_config_path` under `CONFIG_PREFIX` in `RUSTFS_META_BUCKET` |
|
||||
|
||||
The consequence of the first two sharing a blob is that any change to how that blob parses has a blast radius covering policy, lifecycle, versioning, object lock and everything else in `BucketMetadata` — not just credentials.
|
||||
@@ -48,7 +48,7 @@ Three things hold the line today, and all three keep working whether or not seal
|
||||
|
||||
- **The reserved bucket.** `RUSTFS_META_BUCKET` is `.rustfs.sys`; `is_reserved_or_invalid_bucket` keeps it off the S3 surface, and the admin inspect archive in `rustfs/src/admin/handlers/inspect_archive.rs` runs its request through a strict bucket-name check that a dot-prefixed reserved name does not pass.
|
||||
- **Admin authorization** on every route that can read or write one of the three configurations.
|
||||
- **Redaction on every read path.** `BucketTarget::redacted_credentials` and the `Debug` for `Credentials` in `crates/ecstore/src/bucket/target/bucket_target.rs`, used by the remote-target listing in `rustfs/src/admin/handlers/replication.rs` and by the bucket-metadata export in `rustfs/src/admin/handlers/bucket_meta.rs`; `TierConfig::redacted` in `crates/ecstore/src/services/tier/tier_config.rs`, which is also what that type's `Clone` and `Debug` do; and `SourceCredentials::redacted` in `crates/ecstore/src/bucket/on_demand_migration/config.rs`, used by `rustfs/src/admin/handlers/on_demand_migration.rs`.
|
||||
- **Redaction on every read path.** `BucketTarget::redacted_credentials` and the `Debug` for `Credentials` in `crates/ecstore/src/bucket/target/bucket_target.rs`, used by the remote-target listing in `rustfs/src/admin/handlers/replication.rs` and by the bucket-metadata export in `rustfs/src/admin/handlers/bucket_meta.rs`; `TierConfig::redacted` in `crates/ecstore/src/services/tier/tier_config.rs`, which is also what that type's `Clone` and `Debug` do; and `SourceCredentials::redacted` in `rustfs/src/on_demand_migration/config.rs`, used by `rustfs/src/admin/handlers/on_demand_migration.rs`.
|
||||
|
||||
So no API returns a stored secret. The bytes are reachable by reading the drives, and that is the boundary sealing is proposed to move.
|
||||
|
||||
@@ -74,7 +74,7 @@ The envelope deliberately does **not** carry its own scope. A scope read out of
|
||||
|
||||
## Why a hook instead of a dependency
|
||||
|
||||
`crates/ecstore/Cargo.toml` has no `rustfs-kms` dependency, and adding one would invert the crate layering described in [crate-boundaries.md](crate-boundaries.md). The established shape is an `OnceLock` hook that ECStore defines and the binary installs at startup, as `EVENT_DISPATCH_HOOK` in `crates/ecstore/src/services/event_notification.rs` and `ON_DEMAND_MIGRATION_CONFIG_HOOK` in `crates/ecstore/src/bucket/on_demand_migration/config.rs` already do. `install_credential_sealer` follows it, and the binary supplies an implementation backed by `crates/kms/src/service_manager.rs`.
|
||||
`crates/ecstore/Cargo.toml` has no `rustfs-kms` dependency, and adding one would invert the crate layering described in [crate-boundaries.md](crate-boundaries.md). The established shape is an `OnceLock` hook that ECStore defines and the binary installs at startup, as `EVENT_DISPATCH_HOOK` in `crates/ecstore/src/services/event_notification.rs` and `BUCKET_CONFIG_PUBLISH_HOOK` in `crates/ecstore/src/bucket/metadata_sys.rs` already do. `install_credential_sealer` follows it, and the binary supplies an implementation backed by `crates/kms/src/service_manager.rs`.
|
||||
|
||||
## Compatibility, per store, because the three differ
|
||||
|
||||
|
||||
@@ -23,6 +23,30 @@ therefore has three identities:
|
||||
If any identity changes before commit, the result is a candidate for retry or
|
||||
observation, not an authoritative baseline.
|
||||
|
||||
Ordinary PUT rename fanouts also track instance-scoped in-flight work. A quorum
|
||||
ACK does not release it: the actual disk tasks retain ownership until their
|
||||
rename work ends, including when the request caller is cancelled. Scan admission
|
||||
remains movement-only so sustained PUTs do not stop namespace walks and
|
||||
scanner-driven lifecycle discovery. The post-walk local publication check and
|
||||
remote publication leases reject pending fanouts. Begin/end namespace generations
|
||||
invalidate scans and cached plans across the fanout; after acquiring remote
|
||||
leases, the coordinator rechecks the full activity digest before publishing an
|
||||
authoritative aggregate. This catches a tail that finishes between the scan's
|
||||
last probe and lease acquisition.
|
||||
|
||||
This adds no namespace or movement lock. An already-verified older snapshot may
|
||||
still precede a newly started write. Sustained or stalled PUT tails can delay
|
||||
authoritative usage publication, which resumes through the existing retry
|
||||
schedule rather than a new immediate-wakeup protocol. Intermediate per-set and
|
||||
prefix cache readers retain their existing approximate-cache semantics. A
|
||||
prolonged pending tail with no generation changes can also delay cycle advancement
|
||||
and fresh rescans of already-current caches; this is not a guarantee of lifecycle
|
||||
progress under indefinitely stalled storage I/O.
|
||||
|
||||
This PUT-tail protection requires every writer node to be upgraded. It does not
|
||||
prove that a failed tail replica has healed, and it does not extend the same
|
||||
in-flight tracking to multipart or other namespace mutation paths.
|
||||
|
||||
## Fences
|
||||
|
||||
The protocol uses separate fences because they exclude different stale inputs.
|
||||
@@ -33,7 +57,7 @@ They must not be collapsed unless the replacement proves the same exclusions.
|
||||
| Scanner leadership claim | scanner | competing scanner leaders and stale cycle writers |
|
||||
| Storage publication epoch | ECStore | usage computed across rebalance, decommission, or other data-movement generations |
|
||||
| Publication lease | scanner peers through ECStore-facing activity probes | remote dirty-usage or maintenance state that has not acknowledged the candidate |
|
||||
| CAS revision | backing config object store | lost updates to `.usage.v2.json`, `.usage.json`, or cycle-state objects |
|
||||
| CAS revision | backing config object store | lost updates to usage snapshots, scanner caches, or cycle-state objects |
|
||||
| Per-set freshness | scanner aggregation | a merged usage snapshot that combines stale and current set results |
|
||||
| Tier registry generation | scanner tier accounting | bytes classified against a different warm-tier registry |
|
||||
| Usage floor identity | scanner publication and ECStore quota fallback | empty or legacy values becoming plausible authoritative quota input |
|
||||
@@ -42,6 +66,28 @@ A reader that cannot prove the required fence for its surface must fail closed
|
||||
or use the documented observed path below. It must not synthesize an empty usage
|
||||
snapshot for a missing or corrupt authoritative object.
|
||||
|
||||
## Cache Execution Identity
|
||||
|
||||
The structural scan-plan digest can remain stable across ordinary bucket writes
|
||||
so a scoped scan can retain unaffected baseline buckets. It is not sufficient
|
||||
proof for reusing a completed result within the same cycle. Bucket work uses an
|
||||
execution digest combining the structural plan and the full activity snapshot,
|
||||
with the bucket's dirty generation included in its cache identity. Completed set
|
||||
caches carry the same execution digest separately from their structural plan.
|
||||
The persisted set-root fast path requires equal execution identities as well as
|
||||
the existing source, cycle, leader, tier, and cache-structure checks.
|
||||
|
||||
A set scan also captures its starting cache revisions. When the persisted
|
||||
execution differs, replacement requires those revisions to remain unchanged;
|
||||
otherwise a slow scan could overwrite a newer completed result. The existing
|
||||
cache lock, conditional save, and movement admission still fence the commit.
|
||||
|
||||
The optional `scan_execution_digest` field is appended to the map-encoded cache
|
||||
metadata. Legacy caches remain readable but cannot satisfy same-cycle set-root
|
||||
reuse without this identity. Older readers can ignore the added map key, but
|
||||
older writers do not enforce its fence; readability is not a mixed-version
|
||||
publication-safety guarantee.
|
||||
|
||||
## Persisted Objects
|
||||
|
||||
The persisted objects are part of the compatibility contract. Removing one
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Bucket metadata diagnostics and recovery
|
||||
|
||||
`GET /rustfs/admin/v3/export-bucket-metadata` keeps its strict behavior: an unreadable configuration fails the export. The optional `bucket` query selects one bucket; omitting it selects all buckets.
|
||||
|
||||
To inspect readable configurations while identifying failures, use the same authenticated endpoint with `?diagnostic=true`. This requires the existing `ExportBucketMetadataAction` permission. A successful response has:
|
||||
|
||||
- Filename `bucket-meta-diagnostic.zip` and header `x-rustfs-bucket-metadata-export: diagnostic`.
|
||||
- Readable entries under `_diagnostic/<bucket>/<config>`; target credentials remain redacted.
|
||||
- `_diagnostic-manifest.json`, containing `version: 1`, `mode: "diagnostic"`, `complete`, and an `errors` array. Each error identifies `bucket`, `config`, and the fixed code `configuration_unavailable`. The archive excludes unreadable payloads and parser error details.
|
||||
|
||||
`complete` reports whether all supported configuration reads succeeded. A diagnostic archive is never a restorable backup, including when `complete` is true. Import rejects the manifest or reserved directory before any bucket creation or configuration write. The reserved directory is not a valid bucket name, so older importers cannot restore diagnostic entries as ordinary bucket configurations.
|
||||
|
||||
## Recover unreadable replication targets
|
||||
|
||||
RustFS currently accepts the documented `{"targets": [...]}` object format. It cannot decrypt MinIO KMS-encrypted target metadata. Unreadable target payloads remain failures instead of being interpreted as an empty target set; diagnostic export and replacement import do not add MinIO KMS decryption support.
|
||||
|
||||
1. Inspect the diagnostic manifest to identify affected buckets. Preserve a separate backup of the original source configuration and any credentials needed for recovery.
|
||||
2. Prepare a ZIP containing `<bucket>/bucket-targets.json` with a valid RustFS replacement, whose top-level shape is `{"targets": [...]}`. Supply the intended target settings and credentials; exported credentials are redacted. Use `{"targets": []}` only when intentionally clearing all targets, and reconcile any replication rules that reference removed targets.
|
||||
3. Submit the ZIP to the existing authenticated `PUT /rustfs/admin/v3/import-bucket-metadata` endpoint with `ImportBucketMetadataAction` permission. Import validates the replacement and persists it against the bucket incarnation; it does not need to parse the old target payload successfully.
|
||||
4. Verify target listing and the intended replication configuration. Retry the ordinary strict metadata export to confirm the unreadable configuration no longer blocks it.
|
||||
|
||||
Do not submit the diagnostic archive itself to the import endpoint. Copy only reviewed replacement entries into an ordinary import archive.
|
||||
@@ -1,12 +1,26 @@
|
||||
# On-Demand Migration
|
||||
|
||||
**Use this when:** you are moving an existing S3-compatible bucket into RustFS without a stop-the-world copy, or you are debugging a bucket that serves reads from an external source (424 `SourceUnavailable`, an open circuit breaker, missing pulled objects, a source 403).
|
||||
**Source of truth:** `crates/ecstore/src/bucket/on_demand_migration/` (`config.rs` for the wire model and its bounds, `sys.rs` for the per-node runtime, `source_client.rs` for the outbound client, `pull.rs` for the write-back pipeline, `breaker.rs` and `negative_cache.rs` for the protections), `rustfs/src/app/object/get.rs` and `head.rs` for the read paths, `rustfs/src/app/object/on_demand_migration_put.rs` for the local write, `rustfs/src/admin/handlers/on_demand_migration.rs` for the admin API, and `crates/obs/src/metrics/schema/on_demand_migration.rs` for the metric contract.
|
||||
**Source of truth:** `rustfs/src/on_demand_migration/` (`config.rs` for the wire model and its bounds, `sys.rs` for the per-node runtime, `source_client.rs` for the outbound client, `pull.rs` for the write-back pipeline, `breaker.rs` and `negative_cache.rs` for the protections), `rustfs/src/app/object/get.rs` and `head.rs` for the read paths, `rustfs/src/app/object/on_demand_migration_put.rs` for the local write, `rustfs/src/admin/handlers/on_demand_migration.rs` for the admin API, and `crates/obs/src/metrics/schema/on_demand_migration.rs` for the metric contract.
|
||||
|
||||
On-Demand Migration (ODM) attaches an external S3-compatible **source bucket** to a local RustFS bucket. When a client GETs a key that does not exist locally, RustFS fetches it from the source, streams it to the client, and stores it locally in the same pass; every later read is served locally. It is a pull-style, lazy migration path — the RustFS equivalent of Cloudflare R2 Sippy, Tigris shadow buckets, and Alibaba Cloud OSS / Tencent COS mirror-back-to-origin.
|
||||
|
||||
The module is on by default (rustfs/backlog#2163); set `RUSTFS_ON_DEMAND_MIGRATION_ENABLED=false` on every node to turn it off (`rustfs/src/module_switches.rs`). With the switch off, the runtime never intervenes on a read and the admin `PUT` route refuses with `OnDemandMigrationDisabled`. Reads of the configuration and of the status endpoint keep working while the switch is off, so a disabled deployment can still be inspected. The switch only decides whether the module may act at all: a bucket with no `on-demand-migration.json` is never resolved by the runtime and makes no source call, so turning the module on changes nothing for buckets you have not configured.
|
||||
|
||||
## Upgrade and rollback compatibility
|
||||
|
||||
ODM configuration is stored in two additional keys in the existing bucket metadata map. The metadata format version remains `1` for MinIO compatibility. RustFS `1.0.0-rc.5` only re-encodes its 44 known keys: a bucket configuration write through an rc.5 node discards the ODM configuration and timestamp, even if another node originally wrote them. Restarting a newer binary cannot recover the discarded values. This also means ODM is not supported during a rolling upgrade that still allows rc.5 nodes to write bucket metadata.
|
||||
|
||||
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.
|
||||
|
||||
## Optional Google dependencies
|
||||
|
||||
The default and `full` server builds include the `gcs` Cargo feature to preserve native GCS migration and existing GCS tier support. For a server without Google SDK dependencies, build with `cargo build -p rustfs --no-default-features --features ftps,webdav`. Add `gcs` to that feature list to restore native GCS support. The ECStore library has no default Google dependency; library users that need GCS tiers must enable its `gcs` feature.
|
||||
|
||||
Both builds can read, redact and preserve GCS configuration. A build without `gcs` rejects native ODM client construction with `OnDemandMigrationBackendNotCompiled` (HTTP 501); persisted native sources report an unavailable client. GCS tier initialization returns `XRustFSAdminTierTypeUnsupported` (HTTP 501). Do not deploy that build to a cluster with GCS tiers containing transitioned objects: the configuration remains intact, but reading their remote data requires a GCS-capable binary. The `gcs` provider using HMAC credentials and the S3 interoperability API remains available in every build; only `gcs_native` and native GCS tier clients need the feature.
|
||||
|
||||
## List continuation token rollout
|
||||
|
||||
`RUSTFS_ON_DEMAND_MIGRATION_LIST_V2_TOKENS` defaults to `false`; unset or invalid boolean values also keep it off. It controls only whether a v1 listing may first issue a v2 continuation token after an empty truncated merged page. Every node with this reader support accepts existing v2 tokens and continues their budget even with the switch off. Ordinary pages that consume an object or common prefix retain the original v1 token shape.
|
||||
@@ -89,26 +103,34 @@ Setting `"enabled": false` in the config has the same read-path effect as deleti
|
||||
|
||||
The status endpoint reports **the node that answered the request**. Counters, queue depth and breaker state are per-node runtime state, so in a distributed deployment query every node; the saved configuration and `updated_at` are cluster-wide.
|
||||
|
||||
### Backfill (ships with ODM-12)
|
||||
### Backfill
|
||||
|
||||
Read-through only migrates what clients touch. The background backfill job walks the source listing and pulls the remainder, with a persisted checkpoint (`.rustfs.sys/buckets/<bucket>/on-demand-migration-backfill.json`), a single-owner lease, resume after restart, and `POST .../{bucket}/backfill?op=start|cancel` plus `GET .../{bucket}/backfill` admin routes. That slice (rustfs/backlog#2159) is not part of the build this page was written against: the shape above is the agreed design, and the exact request/response bodies must be re-checked against `docs/architecture/admin-route-action-snapshot.md` once it lands.
|
||||
Backfill waits for the result of every pull, including a pull already queued by an online request. A failed or cancelled shared pull is counted as a failure, never as successful migration. The persisted continuation cursor stays at the first failed page; a takeover replays from there and skips objects already present locally. `completed_with_failures` is not a cutover-ready state.
|
||||
|
||||
Read-through only migrates what clients touch. The background backfill job walks the source listing and pulls the remainder, with a persisted checkpoint (`.rustfs.sys/buckets/<bucket>/on-demand-migration-backfill.json`), a single-owner lease, resume after restart, and `POST .../{bucket}/backfill?op=start|cancel` plus `GET .../{bucket}/backfill` admin routes. See `docs/architecture/admin-route-action-snapshot.md` for the route contract.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
The persisted blob is `on-demand-migration.json` in the bucket's metadata. Unknown fields are rejected rather than dropped, so a config written by a newer build fails loudly on an older one. Every default and bound below comes from `crates/ecstore/src/bucket/on_demand_migration/config.rs`.
|
||||
The persisted blob is `on-demand-migration.json` in the bucket's metadata. Unknown fields are rejected rather than dropped, so a config written by a newer build fails loudly on an older one. Every default and bound below comes from `rustfs/src/on_demand_migration/config.rs`.
|
||||
|
||||
| Field | Type | Default | Bounds / rules |
|
||||
|---|---|---|---|
|
||||
| `version` | integer | `1` | Must be `1` |
|
||||
| `enabled` | bool | `true` | `false` keeps the config but stops all source traffic |
|
||||
| `source.provider` | `s3` \| `aws` \| `minio` \| `rustfs` \| `r2` \| `gcs` | — (required) | Drives endpoint and addressing defaults |
|
||||
| `source.endpoint` | string \| null | — | `http(s)://host[:port]`, no path, query, fragment or userinfo. Required for every provider except `aws`, where it is derived from `region` |
|
||||
| `source.region` | string | — (required) | Non-empty. `auto` is accepted only for `r2`, `minio`, `rustfs` and is signed as `us-east-1` |
|
||||
| `source.bucket` | string | — (required) | Non-empty, no `/` and no whitespace |
|
||||
| `source.provider` | `s3` \| `aws` \| `minio` \| `rustfs` \| `r2` \| `gcs` \| `azure` \| `gcs_native` | — (required) | Drives endpoint and addressing defaults, and which backend the client builds: every value but `azure` and `gcs_native` speaks S3 |
|
||||
| `source.endpoint` | string \| null | — | `http(s)://host[:port]`, no path, query, fragment or userinfo. Required except for `aws` (derived from `region`), `azure` (derived as `https://<account>.blob.core.windows.net`) and `gcs_native` (`https://storage.googleapis.com`). Set it explicitly to point at Azurite or fake-gcs-server, subject to the same outbound policy as any other source endpoint |
|
||||
| `source.region` | string | — (required) | Non-empty. `auto` is accepted for `r2`, `minio`, `rustfs` and for the native providers, and is signed as `us-east-1`. `azure` and `gcs_native` never sign with a region, so `auto` is the honest value there |
|
||||
| `source.bucket` | string | — (required) | Non-empty, no `/` and no whitespace. For `azure` this is the container name, for `gcs_native` the bucket name; the provider block never repeats it |
|
||||
| `source.path_style` | `auto` \| `path` \| `virtual` | `auto` | `auto` resolves to path-style for IP-literal or `localhost` endpoints and for `s3`/`minio`/`rustfs`; virtual-host for `aws`/`gcs`/`r2` |
|
||||
| `source.credentials` | object \| null | `null` | `null` means anonymous, which the client builder does not support yet: the admin `PUT` refuses it with `InvalidArgument`, and a config that reached the metadata another way resolves as unavailable. `access_key` and `secret_key` must be non-empty; `session_token` is optional but must be non-empty when present |
|
||||
| `source.credentials` | object \| null | `null` | Read only by the S3 providers; `azure` and `gcs_native` must leave it `null` and carry their credentials in their own block. `null` means anonymous, which the client builder does not support yet: the admin `PUT` refuses it with `InvalidArgument`, and a config that reached the metadata another way resolves as unavailable. `access_key` and `secret_key` must be non-empty; `session_token` is optional but must be non-empty when present |
|
||||
| `source.tls.skip_verify` | bool | `false` | Disables certificate verification for the source connection |
|
||||
| `source.tls.ca_cert_pem` | string \| null | `null` | Must contain `-----BEGIN CERTIFICATE-----` |
|
||||
| `source.azure` | object \| null | `null` | Required for `provider = "azure"` and rejected for every other provider |
|
||||
| `source.azure.account` | string | — (required) | Storage account name; `[A-Za-z0-9-]` only, because it becomes the first label of the derived host |
|
||||
| `source.azure.account_key` | string \| null | `null` | Base64 storage-account key, signed per request with Shared Key. Mutually exclusive with `sas_token`; exactly one of the two is required |
|
||||
| `source.azure.sas_token` | string \| null | `null` | SAS query string without the leading `?` and without whitespace, appended to every request URL |
|
||||
| `source.gcs` | object \| null | `null` | Required for `provider = "gcs_native"` and rejected for every other provider |
|
||||
| `source.gcs.service_account_json` | string | — (required) | Service-account key JSON; must parse and carry `type: service_account`, `client_email` and `private_key`. Tokens are minted read-only (`devstorage.read_only`) |
|
||||
| `filter.prefix` | string \| null | `null` | Null or non-empty. Only local keys with this prefix consult the source |
|
||||
| `filter.source_prefix` | string \| null | `null` | Null or non-empty. Prepended to the local key to form the source key |
|
||||
| `policy.head` | `proxy` \| `local_only` | `proxy` | `local_only` answers a HEAD miss with 404 and no source traffic |
|
||||
@@ -117,7 +139,7 @@ The persisted blob is `on-demand-migration.json` in the bucket's metadata. Unkno
|
||||
| `policy.list_through` | bool | `false` | Merges the source listing into `ListObjectsV2` so clients see the whole namespace during the migration. Off by default: it puts the source in the path of every listing |
|
||||
| `policy.respect_local_delete_marker` | bool | `true` | A local delete marker is the final answer; only a versioned bucket can produce one |
|
||||
| `policy.preserve_etag` | bool | `true` | Keeps the source ETag on the stored object unless the bucket encrypts by default |
|
||||
| `policy.copy_tags` | bool | `false` | Copies source object tags; needs `s3:GetObjectTagging` and costs one extra source call per inline pull |
|
||||
| `policy.copy_tags` | bool | `false` | Copies source object tags; needs `s3:GetObjectTagging` and costs one extra source call per inline pull. `azure` reads blob tags instead; `gcs_native` has no tags and always finds none |
|
||||
| `policy.emit_events` | bool | `true` | Whether a write-back emits `ObjectCreated` notifications |
|
||||
| `policy.negative_cache_ttl_secs` | integer | `30` | `0..=3600`; `0` disables the negative cache |
|
||||
| `policy.inline_max_bytes` | integer | `16777216` (16 MiB) | `0..=268435456` (256 MiB). At or below this size a GET miss is teed inline; above it the response streams through and a background pull stores the object |
|
||||
@@ -129,7 +151,7 @@ The persisted blob is `on-demand-migration.json` in the bucket's metadata. Unkno
|
||||
| `policy.source_timeout.idle_ms` | integer | `30000` | `100..=600000`; enforced per body chunk on both the background pump and the inline tee |
|
||||
| `policy.bandwidth_limit_bytes_per_sec` | integer \| null | `null` | When set, at least `65536` |
|
||||
|
||||
Values that are **not** configurable: the breaker opens after 5 consecutive counted failures inside a 30 s window, stays open for 30 s and then admits one probe (`breaker.rs`); the negative cache holds at most 100 000 keys per bucket with LRU eviction (`negative_cache.rs`); a background pull retries a retryable source failure at most 3 times with 1 s / 4 s / 16 s base delays plus up to 25 % jitter (`pull.rs`). The SDK's own retry policy is disabled on the source client, so one logical source call is exactly one wire request and the retry budget above is the only one.
|
||||
Values that are **not** configurable: the breaker opens after 5 consecutive counted failures inside a 30 s window, stays open for 30 s and then admits one probe (`breaker.rs`); the negative cache holds at most 100 000 keys per bucket with LRU eviction (`negative_cache.rs`); a background pull retries a retryable source failure at most 3 times with 1 s / 4 s / 16 s base delays plus up to 25 % jitter (`pull.rs`). The SDK's own retry policy is disabled on the source client. Each SDK operation makes one wire request; an ambiguous HEAD 404 additionally probes the bucket, within the same configured first-byte budget.
|
||||
|
||||
Validation also rejects two shapes outright: a source whose endpoint and bucket name **this** bucket on this deployment (`SelfReference`), and a source that matches one of the bucket's own replication targets (`ReplicationLoop`) — that pairing would amplify a write-back into a loop.
|
||||
|
||||
@@ -143,8 +165,14 @@ Validation also rejects two shapes outright: a source whose endpoint and bucket
|
||||
| `rustfs` | Required | Path-style | `auto` allowed | A RustFS source answers the migration request locally thanks to the anti-loop marker | `real_source_test.rs` in the `e2e-nightly` lane |
|
||||
| `r2` | `https://<account-id>.r2.cloudflarestorage.com` | Virtual-host | `auto` allowed (signed as `us-east-1`) | | `cloud-source (r2)`, only while `ODM_INTEROP_R2_*` are configured; no difference recorded yet |
|
||||
| `gcs` | `https://storage.googleapis.com` | Virtual-host | Real region required | Uses the GCS XML interoperability API with an HMAC key pair, not a service-account JSON key | `cloud-source (gcs)`, only while `ODM_INTEROP_GCS_HMAC_*` are configured; no difference recorded yet |
|
||||
| `azure` | Optional; derived as `https://<account>.blob.core.windows.net` | Native Blob REST, not S3 | Unused; write `auto` | Needs `source.azure`; the container is `source.bucket`. Reads need `Read` on the blob and `List` on the container, plus `Tags` when `policy.copy_tags` is on | None yet: no interop job covers Azure |
|
||||
| `gcs_native` | Optional; derived as `https://storage.googleapis.com` | Native GCS API, not S3 | Unused; write `auto` | Needs `source.gcs`. Reads use the XML API for objects and `objects.list` for listings, both with an OAuth token minted from the service-account key; the key needs `storage.objects.get` and `storage.objects.list` | None yet: no interop job covers native GCS |
|
||||
|
||||
Azure Blob has no preset; a native provider is deferred (rustfs/backlog#2166).
|
||||
Every backend answers the same trait contract, pinned by `backend_contract.rs` in `rustfs/src/on_demand_migration/`, and the three differences that contract allows are the ones documented here.
|
||||
|
||||
`azure` differs in two of them. Its ETag is a concurrency token rather than a digest of the bytes, so it is stored as `odm-source-etag` provenance and never used as the expected MD5 of a pulled object — the write-back integrity check falls back to the local digest. And its listing paginates only with an opaque marker: there is no "start after this key" form, so a caller that asks for one gets `Unsupported` instead of a listing that silently starts over.
|
||||
|
||||
`gcs_native` differs in the other two. Its listing also has no exclusive "start after" form (`startOffset` is inclusive), so it refuses one the same way. And GCS has no object tagging at all: `policy.copy_tags` finds no tags rather than failing the pull, because GCS custom metadata is already carried by the head mapping. Its ETag is normally usable: the `x-goog-hash` MD5 is converted to hex and checked against the pulled bytes, except on a composite object, which has no MD5 and whose ETag is then treated as opaque.
|
||||
|
||||
The "Interop evidence" column names the job in `.github/workflows/on-demand-migration-interop.yml` (rustfs/backlog#2167) that last exercised the preset against a real implementation, and is where a provider difference belongs once the lane finds one. That lane is report-only and scheduled: it runs `crates/e2e_test/src/on_demand_migration/interop_test.rs` — the same case bodies as the merge-gate suite, with the source injected through `RUSTFS_ODM_INTEROP_*` — against a pinned MinIO container, and against each cloud provider whose repository secrets are configured. A provider without secrets is skipped with a note in the run summary rather than failing, so "no difference recorded yet" means exactly that and not "verified clean"; see [ci-gates.md](../testing/ci-gates.md) for the row.
|
||||
|
||||
@@ -160,6 +188,14 @@ No write, delete, ACL or versioning permission is required or used. Scope the po
|
||||
|
||||
Behaviour a client can observe. The "Test" column names the case that pins it: `*_test.rs` files live under `crates/e2e_test/src/on_demand_migration/`, and the unit tests live next to the code in `rustfs/src/app/object/get.rs`, `head.rs` and `shared.rs`.
|
||||
|
||||
ODM merged continuation tokens use a NUL-prefixed JSON envelope inside the existing base64 encoding. NUL is not valid in a local object key, so a legitimate JSON-shaped key can never be mistaken for a merged cursor. Upgrade every node before using list-through, and restart any in-progress ODM listing issued by an older build: its unframed JSON tokens cannot be distinguished from legitimate local keys. Ordinary local listing tokens remain unchanged. Tokens issued by this build can still resume the local side after list-through is disabled.
|
||||
|
||||
Source `HEAD` responses with status 404 require a successful bucket probe before being negative-cached. The source credential therefore needs permission for `HeadBucket` (S3 `ListBucket`); a prefix-restricted ListBucket policy can deny that probe, in which case the response is a source failure rather than a cached miss. A missing/inaccessible source bucket, a missing source version, or an ambiguous GET 404 is not proof that the requested key is absent. Conditional GET validators are checked against the actual source GET metadata as well as the advisory HEAD; a missing required validator fails with 424. Source LIST entries without a key or a non-negative size fail the page rather than fabricating an empty object.
|
||||
|
||||
Write-back currently requires namespace locking enabled and exactly one pool with one erasure set. Other topologies fail write-back explicitly as `unsupported`: source reads remain available, but backfill cannot complete successfully or certify cutover. This restriction avoids relying on a set-local condition across distinct pool or lock domains; it does not restrict ordinary S3 writes. Full cross-pool migration requires a globally fenced commit protocol.
|
||||
|
||||
On the supported topology, write-back uses a create-only check under the local storage commit lock for both single-part PUT and multipart completion. A client write that commits while ODM is reading the source is preserved. With `respect_local_delete_marker=true`, a concurrent versioned deletion is preserved too. An explicit `respect_local_delete_marker=false` still permits revival; an unversioned deletion has no tombstone and therefore cannot be distinguished from a key that has never existed locally.
|
||||
|
||||
| Situation | Behaviour | Test |
|
||||
|---|---|---|
|
||||
| GET miss, object at or below `inline_max_bytes` | One source GET, teed: the client streams while the same bytes are written locally. Later reads are local and carry no source marker | `get_basic_test.rs::get_miss_pulls_inline_and_serves_locally_afterwards`, `get.rs::odm_get_inline_streams_to_client_and_commits_the_same_bytes` |
|
||||
@@ -229,7 +265,7 @@ Five provenance keys are written on every pulled object under both internal pref
|
||||
| Concurrency limit | Local write amplification | `max_concurrent_pulls` permits shared by inline and background pulls |
|
||||
| Bounded queue | Unbounded memory on a burst | `pull_queue_capacity` waiting jobs; overflow is counted as `queue_full` and never fails a client response |
|
||||
| Bandwidth limit | Source and network saturation | `bandwidth_limit_bytes_per_sec` (minimum 64 KiB/s) on the source client |
|
||||
| Retry budget | Transient source blips | Background pulls retry a retryable failure up to 3 times (1 s / 4 s / 16 s plus jitter). Inline pulls never retry: the bytes are already on their way to the client. The SDK retry policy on the source client is disabled (`RemoteS3RetryPolicy::Disabled`), so this is the only retry budget and one logical source call is exactly one wire request — replication targets keep the SDK's three attempts, declared on their own spec |
|
||||
| Retry budget | Transient source blips | Background pulls retry a retryable failure up to 3 times (1 s / 4 s / 16 s plus jitter). Inline pulls never retry: the bytes are already on their way to the client. The SDK retry policy is disabled (`RemoteS3RetryPolicy::Disabled`); HEAD 404 also requires one bucket probe. Replication targets keep their separately declared three SDK attempts |
|
||||
| Idle timeout | A source that answers and then goes quiet mid-body | `source_timeout.idle_ms` per body chunk on both paths. The budget measures the source read, upstream of the inline tee, so a slow client is never mistaken for an idle source; when it fires the client stream ends in an error and the write-back is discarded |
|
||||
| Anti-loop marker | Migration chains between RustFS/MinIO deployments | Every source request carries `x-rustfs-source-proxy-request` and `x-minio-source-proxy-request`; a request carrying it is always answered locally |
|
||||
| Outbound endpoint policy | SSRF | See [outbound-connection-policy.md](outbound-connection-policy.md) |
|
||||
|
||||
@@ -12,10 +12,10 @@ 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`. Guide: [`crates/e2e_test/README.md`](../../crates/e2e_test/README.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) |
|
||||
| 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`) used by the reliability and heal e2e modules | Part of the e2e crate (`e2e-reliability` test-group) | With the `e2e-full` and nightly e2e lanes. A multi-node power-loss harness is not in tree |
|
||||
| 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` |
|
||||
| Fuzz | `cargo-fuzz` targets over untrusted parsing surfaces; isolated sub-workspace under `fuzz/` | `./scripts/fuzz/run.sh` (see [`fuzz/README.md`](../../fuzz/README.md)) | PR smoke on the paths listed in `.github/workflows/fuzz.yml`, plus nightly corpus |
|
||||
| Benchmarks | Criterion benches under each crate's `benches/` | `cargo bench -p <crate>` | On demand; never a gate |
|
||||
|
||||
@@ -63,6 +63,7 @@ All profiles are defined in `.config/nextest.toml`; its block comments hold the
|
||||
| `e2e-full` | Merge-queue / main-push single-node e2e lane |
|
||||
| `e2e-repl-nightly` | Nightly slow / cross-process replication lane |
|
||||
| `e2e-nightly` | Nightly serial multi-process cluster fault lane |
|
||||
| `e2e-distributed` | Storage-sensitive PR and nightly 4-node 4-disk S3 / lock / versioning / replication / quota / expand / decommission / rebalance / site-replication / chaos / upgrade (history + IAM AK/SK) lane |
|
||||
| `e2e-protocols` | Nightly fixed-port FTPS/SFTP/WebDAV lane, run with `-j 1` |
|
||||
|
||||
Membership of each e2e profile is pinned by a digest in `.config/e2e-<profile>-selection.txt` and checked by `scripts/check_test_wiring.py --check-profile <profile>` before the lane runs. To list what a profile selects on your platform (the result is platform-dependent because some modules are linux-only):
|
||||
|
||||
@@ -49,7 +49,7 @@ Promotion rule: never promote a report-only lane to required from one green run.
|
||||
| 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 rc.2` | `e2e-upgrade.yml` `direct-upgrade` | Report-only | the `cargo test --locked -p e2e_test` command in the job with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release |
|
||||
| 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 `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 limited to main-CI-excluded paths | `Quick Checks`, `Test and Lint` | `ci-docs-only.yml` `quick-checks`, `test-and-lint` | Required | `git diff --check`; `make doc-paths-check`; `scripts/check_no_planning_docs.sh` |
|
||||
@@ -74,6 +74,7 @@ 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` | per-job | 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-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 |
|
||||
@@ -84,13 +85,19 @@ Scheduled lanes never block a PR. Their workflow-local gate fails the run, sched
|
||||
| `mint.yml` (weekly) | `mint` | report-only by design; per-suite PASS/FAIL/NA and raw `log.json` | yes | pinned Docker sequence in the workflow |
|
||||
| `coverage.yml` (weekly) | `coverage` | report-only trend; lcov and JSON artifact | yes | `make coverage` |
|
||||
| `runner-hygiene.yml` (monthly) | `check-ephemerality` | runner ephemerality | yes | dispatch |
|
||||
| `e2e-upgrade.yml` (weekly) | `direct-upgrade` | upgrade gate; server logs | no | see the PR row |
|
||||
| `e2e-upgrade.yml` (weekly) | `upgrade` (4-case matrix) | upgrade and rollback gate; server logs | no | see the PR row |
|
||||
| `oidc-keycloak.yml` (weekly) | `oidc-keycloak-live` | live OIDC gate | no | see the PR row |
|
||||
| `targets-integration.yml` (nightly) | `targets-live` | live target gate; container logs | no | see the PR row |
|
||||
| `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on a never-created or stale schedule | n/a | dispatch |
|
||||
|
||||
Manual `workflow_dispatch` runs are debugging evidence and do not open scheduled-failure issues. A manual performance run may explicitly allow a known regression; that override is not a passing baseline.
|
||||
|
||||
## Packaged functional acceptance
|
||||
|
||||
`rustfs-functional-chain.yml` dispatches the packaged-build suites in `rustfs-*-test.yml` on the shared lab runners. A failing suite step or job must fail its workflow. Report collection, cleanup, and dispatch of the next suite can still run with `always()`; continuing diagnostics does not make the failed suite successful.
|
||||
|
||||
Workflow status preserves errors that the test scripts report. It does not establish complete execution or a common package identity across the chain: inspect the current run's case results, package identity, and test-script revision as well. A script that returns zero after a failed tool invocation needs its own result check.
|
||||
|
||||
## Release validation
|
||||
|
||||
Post-merge and tag-driven; not a substitute for a PR gate.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Distributed 4-node 4-disk e2e
|
||||
|
||||
**Use this when:** adding or diagnosing GitHub Actions coverage for a 4-node cluster, or deciding whether a behaviour belongs in `e2e-distributed` versus the single-node `e2e-full` lane, the nightly cluster-fault lane, or the hardware functional chain.
|
||||
**Source of truth:** `crates/e2e_test/src/distributed/`, `[profile.e2e-distributed]` in `.config/nextest.toml`, `.github/workflows/e2e-distributed.yml`.
|
||||
|
||||
## Topology
|
||||
|
||||
The in-tree harness runs every node on `127.0.0.1` with a distinct port. That matches `RustFSTestClusterEnvironment` in `crates/e2e_test/src/common.rs`:
|
||||
|
||||
| Layout | Constructor | Use |
|
||||
|---|---|---|
|
||||
| 4 nodes × 4 drives, one pool | `ClusterTopology::single_pool_multidrive(4, 4)` | S3, object lock, versioning, quota, observability, concurrency, chaos |
|
||||
| 4 nodes × 1 drive, one pool | `ClusterTopology::single_pool(4)` | Two-site replication (8 processes total); direct/rolling upgrade from the pinned previous release |
|
||||
| 1 single-node pool × 4 drives, then `append_single_node_pool` three times | expansion seed | Pool expand, then decommission / rebalance / integrity |
|
||||
|
||||
A multi-pool layout in which any pool spans several localhost ports is not expressible (`RUSTFS_VOLUMES` host ellipses would collide on disk paths). Multi-host striped expansion pools remain the hardware functional-chain / backlog #1313 / #1314 lane.
|
||||
|
||||
Data-movement cases fail closed. A decommission or rebalance test must observe a successful start response, an active state, a clean terminal state, non-zero movement counters, and post-operation object integrity. An unsupported response, HTTP 5xx, missing status fields, cleanup warning, or zero-progress terminal response fails the case; pre/post S3 availability alone is not evidence that movement ran.
|
||||
|
||||
The four expansion pools must report independent capacity. Four directories on one runner filesystem all return the same `statfs` totals, so RustFS correctly concludes that no pool is less free than the cluster average and performs no rebalance. The Actions job mounts four isolated ext4 loopback filesystems and exports their absolute paths through `RUSTFS_E2E_POOL_ROOTS`. The harness rejects missing, duplicate, relative, nonexistent, or same-device roots instead of allowing a vacuous movement pass. Planned pool additions stop every process with SIGTERM; hard process termination remains a chaos-only fault. After the fourth pool joins, the harness performs one full graceful persistent restart: this proves the expanded pool map survives restart and ensures movement begins only after every replica can load the converged metadata.
|
||||
|
||||
The expansion fixture is an all-current-binary fleet, so it initializes pool metadata with the documented V3 write and fleet-confirmation gates. Decommission cases write their baseline objects, version history, and multipart data into pool 0 before adding pools 1–3, then retire pool 0. This makes a passing result evidence of user-data movement rather than merely an internal-metadata counter changing.
|
||||
|
||||
## What this lane covers
|
||||
|
||||
`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
|
||||
- Versioning, exact historical reads, delete-marker removal, and suspended null-version overwrite semantics
|
||||
- Bucket replication between two 4-node clusters, including metadata/tags and target-outage retry; hard quota admission and absence of rejected keys
|
||||
- Ready/live probes on every node, exact 4-server/16-disk inventory, realtime metrics on every node, and correlated audit-webhook delivery
|
||||
- Pool expand, decommission, rebalance, checksum integrity, versioned and multipart data, and S3 during active movement
|
||||
- Bidirectional site-replication convergence plus enabled/synchronized peer state on both sites
|
||||
- A 24-worker mixed PUT/HEAD/GET/COPY/DELETE workload; concurrent PUT during active decommission
|
||||
- Node kill/restart, full process restart, node-facing TCP blackhole/recovery, in-flight streaming GETs across a peer kill, and fresh-drive replacement verified by physical `xl.meta`/part-shard census
|
||||
- Multipart, cross-node listing, list-buckets agreement
|
||||
- Direct and rolling upgrade from the pinned previous release: historical objects, versioned history, and IAM user AK/SK still work afterwards
|
||||
|
||||
## Existing Actions gaps this lane does not replace
|
||||
|
||||
Those suites stay in place; this lane fills the in-tree 4×4 hole they leave.
|
||||
|
||||
| Existing lane | Gap |
|
||||
|---|---|
|
||||
| `rustfs-*-test.yml` functional chain | Clones private `rustfs/auto-testing`, runs on three shared VMs (`vm000`–`vm002`), `continue-on-error: true`, not a merge signal, not 4 nodes. Hardware `rustfs-upgrade-test.yml` stays there |
|
||||
| `e2e-upgrade.yml` | Single-node SSE/multipart/delete-marker contracts plus mixed-version listing; does not pin IAM user AK/SK on a 4-node cluster |
|
||||
| `e2e-smoke` / `e2e-full` | Most selected cases are single-node; distributed modules are intentionally owned by this serialized lane |
|
||||
| `e2e-nightly` | 4-node cluster faults and heal, not S3/lock/versioning/quota/decommission matrix |
|
||||
| `e2e-repl-nightly` | Site and bucket replication on 1–3 *single-node* processes |
|
||||
| `e2e-s3tests.yml` `multi` | Weekly ceph/s3-tests against Docker 4-node; not lock/WORM, decommission, chaos, or checksum integrity |
|
||||
| `crates/e2e_test/src/chaos.rs` | Single-node disk faults only |
|
||||
|
||||
Hardware power-loss, physical NIC pull, authenticated inter-node partition, firmware/media errors, and replacement-server provisioning still belong on the hardware validation VMs. This lane provides deterministic process kill, fresh local-volume replacement, and node-facing TCP blackhole analogues; it does not claim physical fault certification.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cargo build -p rustfs --bins
|
||||
# Expansion/decommission/rebalance cases require four paths on distinct filesystems.
|
||||
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
|
||||
```
|
||||
|
||||
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::/)'
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Membership is pinned by `.config/e2e-distributed-selection.txt`. Update the Linux and Darwin entries with `python3 ./scripts/check_test_wiring.py --update-profile e2e-distributed <listing.json> <platform>` after adding or renaming a case.
|
||||
+9
-2
@@ -57,7 +57,8 @@ name = "swift_object_integration_test"
|
||||
required-features = ["swift"]
|
||||
|
||||
[features]
|
||||
default = ["ftps", "webdav"]
|
||||
default = ["ftps", "webdav", "gcs"]
|
||||
gcs = ["rustfs-ecstore/gcs", "dep:google-cloud-auth"]
|
||||
metrics-gpu = ["rustfs-obs/gpu"]
|
||||
ftps = ["rustfs-protocols/ftps"]
|
||||
swift = ["rustfs-protocols/swift"]
|
||||
@@ -66,7 +67,7 @@ sftp = ["rustfs-protocols/sftp"]
|
||||
license = []
|
||||
io-scheduler-debug = [] # Enable debug information in I/O scheduler
|
||||
tracing-chunk-debug = [] # Enable per-chunk tracing in data plane (high noise, for debugging only)
|
||||
full = ["metrics-gpu", "ftps", "swift", "webdav", "sftp", "pyroscope"]
|
||||
full = ["metrics-gpu", "ftps", "swift", "webdav", "sftp", "pyroscope", "gcs"]
|
||||
e2e-test-hooks = []
|
||||
# Shortens Connect credentials only in debug E2E builds.
|
||||
connect-e2e-short-credentials = []
|
||||
@@ -275,6 +276,7 @@ rcgen = { workspace = true }
|
||||
# Async Runtime and Networking
|
||||
async-trait = { workspace = true }
|
||||
axum.workspace = true
|
||||
faster-hex.workspace = true
|
||||
futures.workspace = true
|
||||
futures-lite.workspace = true
|
||||
futures-util.workspace = true
|
||||
@@ -287,6 +289,11 @@ reqwest = { workspace = true, features = ["json", "stream"] }
|
||||
socket2 = { workspace = true, features = ["all"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal", "process", "io-util", "fs"] }
|
||||
tokio-rustls = { workspace = true, default-features = false, features = ["logging", "tls12", "aws-lc-rs"] }
|
||||
aws-smithy-runtime-api = { workspace = true, features = ["http-1x"] }
|
||||
aws-smithy-types = { workspace = true }
|
||||
google-cloud-auth = { workspace = true, optional = true }
|
||||
moka = { workspace = true, features = ["sync"] }
|
||||
xxhash-rust = { workspace = true, features = ["xxh3"] }
|
||||
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
||||
tokio-stream.workspace = true
|
||||
tokio-util = { workspace = true, features = ["io", "compat", "time"] }
|
||||
|
||||
@@ -64,6 +64,9 @@ use time::OffsetDateTime;
|
||||
use tracing::warn;
|
||||
use zip::{ZipArchive, ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
const DIAGNOSTIC_EXPORT_PREFIX: &str = "_diagnostic";
|
||||
const DIAGNOSTIC_EXPORT_MANIFEST: &str = "_diagnostic-manifest.json";
|
||||
|
||||
const LOG_COMPONENT_ADMIN: &str = "admin";
|
||||
const LOG_SUBSYSTEM_BUCKET_META: &str = "bucket_meta";
|
||||
const EVENT_ADMIN_BUCKET_META_STATE: &str = "admin_bucket_meta_state";
|
||||
@@ -97,9 +100,198 @@ fn checked_versioning_xml(validated: &VersioningConfiguration, raw: Vec<u8>) ->
|
||||
checked_raw_xml(validated, raw, deserialize::<VersioningConfiguration>)
|
||||
}
|
||||
|
||||
async fn exported_bucket_config(bucket: &str, conf: &str) -> S3Result<Option<Vec<u8>>> {
|
||||
match conf {
|
||||
BUCKET_POLICY_CONFIG => {
|
||||
let config: BucketPolicy = match metadata_sys::get_bucket_policy(bucket).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(s3_error!(InternalError, "failed to load bucket metadata: {e}"));
|
||||
}
|
||||
};
|
||||
let config_json =
|
||||
serde_json::to_vec(&config).map_err(|e| s3_error!(InternalError, "failed to serialize config: {e}"))?;
|
||||
Ok(Some(config_json))
|
||||
}
|
||||
BUCKET_NOTIFICATION_CONFIG => {
|
||||
let config: s3s::dto::NotificationConfiguration = match metadata_sys::get_notification_config(bucket).await {
|
||||
Ok(Some(res)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
Ok(None) => return Ok(None),
|
||||
};
|
||||
|
||||
let raw_config = metadata_sys::get(bucket)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.notification_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<s3s::dto::NotificationConfiguration>)?;
|
||||
|
||||
Ok(Some(config_xml))
|
||||
}
|
||||
BUCKET_LIFECYCLE_CONFIG => {
|
||||
let config: BucketLifecycleConfiguration = match metadata_sys::get_lifecycle_config(bucket).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(s3_error!(InternalError, "failed to load bucket metadata: {e}"));
|
||||
}
|
||||
};
|
||||
let raw_config = metadata_sys::get(bucket)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("failed to load bucket metadata: {e}")))?
|
||||
.lifecycle_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<BucketLifecycleConfiguration>)?;
|
||||
|
||||
Ok(Some(config_xml))
|
||||
}
|
||||
BUCKET_TAGGING_CONFIG => {
|
||||
let config: Tagging = match metadata_sys::get_tagging_config(bucket).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(s3_error!(InternalError, "failed to load bucket metadata: {e}"));
|
||||
}
|
||||
};
|
||||
let raw_config = metadata_sys::get(bucket)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("failed to load bucket metadata: {e}")))?
|
||||
.tagging_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<Tagging>)?;
|
||||
|
||||
Ok(Some(config_xml))
|
||||
}
|
||||
BUCKET_QUOTA_CONFIG_FILE => {
|
||||
let config: BucketQuota = match metadata_sys::get_quota_config(bucket).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let config_json =
|
||||
serde_json::to_vec(&config).map_err(|e| s3_error!(InternalError, "serialize config failed: {e}"))?;
|
||||
|
||||
Ok(Some(config_json))
|
||||
}
|
||||
OBJECT_LOCK_CONFIG => {
|
||||
let config = match metadata_sys::get_object_lock_config(bucket).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let raw_config = metadata_sys::get(bucket)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.object_lock_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<ObjectLockConfiguration>)?;
|
||||
|
||||
Ok(Some(config_xml))
|
||||
}
|
||||
BUCKET_SSECONFIG => {
|
||||
let config = match metadata_sys::get_sse_config(bucket).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let raw_config = metadata_sys::get(bucket)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.encryption_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<ServerSideEncryptionConfiguration>)?;
|
||||
|
||||
Ok(Some(config_xml))
|
||||
}
|
||||
BUCKET_VERSIONING_CONFIG => {
|
||||
let config = match metadata_sys::get_versioning_config(bucket).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let raw_config = metadata_sys::get(bucket)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.versioning_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_versioning_xml(&config, raw_config)?;
|
||||
|
||||
Ok(Some(config_xml))
|
||||
}
|
||||
BUCKET_REPLICATION_CONFIG => {
|
||||
let config = match metadata_sys::get_replication_config(bucket).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let raw_config = metadata_sys::get(bucket)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.replication_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<ReplicationConfiguration>)?;
|
||||
|
||||
Ok(Some(config_xml))
|
||||
}
|
||||
BUCKET_TARGETS_FILE => {
|
||||
let config: BucketTargets = match metadata_sys::get_bucket_targets_config(bucket).await {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let config_json = serde_json::to_vec(&config.redacted_credentials())
|
||||
.map_err(|e| s3_error!(InternalError, "serialize config failed: {e}"))?;
|
||||
|
||||
Ok(Some(config_json))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
pub struct ExportBucketMetadataQuery {
|
||||
#[serde(default)]
|
||||
pub bucket: String,
|
||||
#[serde(default)]
|
||||
pub diagnostic: bool,
|
||||
}
|
||||
|
||||
pub struct ExportBucketMetadata {}
|
||||
@@ -169,6 +361,7 @@ impl Operation for ExportBucketMetadata {
|
||||
};
|
||||
|
||||
let mut zip_writer = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let confs = [
|
||||
BUCKET_POLICY_CONFIG,
|
||||
@@ -186,244 +379,49 @@ impl Operation for ExportBucketMetadata {
|
||||
for bucket in buckets {
|
||||
for &conf in confs.iter() {
|
||||
let conf_path = path_join_buf(&[bucket.name.as_str(), conf]);
|
||||
match conf {
|
||||
BUCKET_POLICY_CONFIG => {
|
||||
let config: BucketPolicy = match metadata_sys::get_bucket_policy(&bucket.name).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
continue;
|
||||
}
|
||||
return Err(s3_error!(InternalError, "failed to load bucket metadata: {e}"));
|
||||
}
|
||||
};
|
||||
let config_json = serde_json::to_vec(&config)
|
||||
.map_err(|e| s3_error!(InternalError, "failed to serialize config: {e}"))?;
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
.map_err(|e| s3_error!(InternalError, "failed to start archive entry: {e}"))?;
|
||||
zip_writer
|
||||
.write_all(&config_json)
|
||||
.map_err(|e| s3_error!(InternalError, "failed to write archive entry: {e}"))?;
|
||||
let config = match exported_bucket_config(&bucket.name, conf).await {
|
||||
Ok(Some(config)) => config,
|
||||
Ok(None) => continue,
|
||||
Err(error) if !query.diagnostic => return Err(error),
|
||||
Err(_) => {
|
||||
errors.push(serde_json::json!({
|
||||
"bucket": bucket.name,
|
||||
"config": conf,
|
||||
"code": "configuration_unavailable",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
BUCKET_NOTIFICATION_CONFIG => {
|
||||
let config: s3s::dto::NotificationConfiguration =
|
||||
match metadata_sys::get_notification_config(&bucket.name).await {
|
||||
Ok(Some(res)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
continue;
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
Ok(None) => continue,
|
||||
};
|
||||
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.notification_config_xml
|
||||
.clone();
|
||||
let config_xml =
|
||||
checked_raw_xml(&config, raw_config, deserialize::<s3s::dto::NotificationConfiguration>)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
.map_err(|e| s3_error!(InternalError, "start file failed: {e}"))?;
|
||||
zip_writer
|
||||
.write_all(&config_xml)
|
||||
.map_err(|e| s3_error!(InternalError, "write file failed: {e}"))?;
|
||||
}
|
||||
BUCKET_LIFECYCLE_CONFIG => {
|
||||
let config: BucketLifecycleConfiguration = match metadata_sys::get_lifecycle_config(&bucket.name).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
continue;
|
||||
}
|
||||
return Err(s3_error!(InternalError, "failed to load bucket metadata: {e}"));
|
||||
}
|
||||
};
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("failed to load bucket metadata: {e}")))?
|
||||
.lifecycle_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<BucketLifecycleConfiguration>)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
.map_err(|e| s3_error!(InternalError, "failed to start archive entry: {e}"))?;
|
||||
zip_writer
|
||||
.write_all(&config_xml)
|
||||
.map_err(|e| s3_error!(InternalError, "failed to write archive entry: {e}"))?;
|
||||
}
|
||||
BUCKET_TAGGING_CONFIG => {
|
||||
let config: Tagging = match metadata_sys::get_tagging_config(&bucket.name).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
continue;
|
||||
}
|
||||
return Err(s3_error!(InternalError, "failed to load bucket metadata: {e}"));
|
||||
}
|
||||
};
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("failed to load bucket metadata: {e}")))?
|
||||
.tagging_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<Tagging>)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
.map_err(|e| s3_error!(InternalError, "failed to start archive entry: {e}"))?;
|
||||
zip_writer
|
||||
.write_all(&config_xml)
|
||||
.map_err(|e| s3_error!(InternalError, "failed to write archive entry: {e}"))?;
|
||||
}
|
||||
BUCKET_QUOTA_CONFIG_FILE => {
|
||||
let config: BucketQuota = match metadata_sys::get_quota_config(&bucket.name).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
continue;
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let config_json =
|
||||
serde_json::to_vec(&config).map_err(|e| s3_error!(InternalError, "serialize config failed: {e}"))?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
.map_err(|e| s3_error!(InternalError, "start file failed: {e}"))?;
|
||||
zip_writer
|
||||
.write_all(&config_json)
|
||||
.map_err(|e| s3_error!(InternalError, "write file failed: {e}"))?;
|
||||
}
|
||||
OBJECT_LOCK_CONFIG => {
|
||||
let config = match metadata_sys::get_object_lock_config(&bucket.name).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
continue;
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.object_lock_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<ObjectLockConfiguration>)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
.map_err(|e| s3_error!(InternalError, "start file failed: {e}"))?;
|
||||
zip_writer
|
||||
.write_all(&config_xml)
|
||||
.map_err(|e| s3_error!(InternalError, "write file failed: {e}"))?;
|
||||
}
|
||||
BUCKET_SSECONFIG => {
|
||||
let config = match metadata_sys::get_sse_config(&bucket.name).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
continue;
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.encryption_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<ServerSideEncryptionConfiguration>)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
.map_err(|e| s3_error!(InternalError, "start file failed: {e}"))?;
|
||||
zip_writer
|
||||
.write_all(&config_xml)
|
||||
.map_err(|e| s3_error!(InternalError, "write file failed: {e}"))?;
|
||||
}
|
||||
BUCKET_VERSIONING_CONFIG => {
|
||||
let config = match metadata_sys::get_versioning_config(&bucket.name).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
continue;
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.versioning_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_versioning_xml(&config, raw_config)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
.map_err(|e| s3_error!(InternalError, "start file failed: {e}"))?;
|
||||
zip_writer
|
||||
.write_all(&config_xml)
|
||||
.map_err(|e| s3_error!(InternalError, "write file failed: {e}"))?;
|
||||
}
|
||||
BUCKET_REPLICATION_CONFIG => {
|
||||
let config = match metadata_sys::get_replication_config(&bucket.name).await {
|
||||
Ok((res, _)) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
continue;
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
let raw_config = metadata_sys::get(&bucket.name)
|
||||
.await
|
||||
.map_err(|e| export_internal_error(format!("get bucket metadata failed: {e}")))?
|
||||
.replication_config_xml
|
||||
.clone();
|
||||
let config_xml = checked_raw_xml(&config, raw_config, deserialize::<ReplicationConfiguration>)?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
.map_err(|e| s3_error!(InternalError, "start file failed: {e}"))?;
|
||||
zip_writer
|
||||
.write_all(&config_xml)
|
||||
.map_err(|e| s3_error!(InternalError, "write file failed: {e}"))?;
|
||||
}
|
||||
BUCKET_TARGETS_FILE => {
|
||||
let config: BucketTargets = match metadata_sys::get_bucket_targets_config(&bucket.name).await {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
if e == StorageError::ConfigNotFound {
|
||||
continue;
|
||||
}
|
||||
return Err(s3_error!(InternalError, "get bucket metadata failed: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let config_json = serde_json::to_vec(&config.redacted_credentials())
|
||||
.map_err(|e| s3_error!(InternalError, "serialize config failed: {e}"))?;
|
||||
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
.map_err(|e| s3_error!(InternalError, "start file failed: {e}"))?;
|
||||
zip_writer
|
||||
.write_all(&config_json)
|
||||
.map_err(|e| s3_error!(InternalError, "write file failed: {e}"))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
let conf_path = if query.diagnostic {
|
||||
path_join_buf(&[DIAGNOSTIC_EXPORT_PREFIX, &conf_path])
|
||||
} else {
|
||||
conf_path
|
||||
};
|
||||
zip_writer
|
||||
.start_file(conf_path, SimpleFileOptions::default())
|
||||
.map_err(|e| s3_error!(InternalError, "failed to start archive entry: {e}"))?;
|
||||
zip_writer
|
||||
.write_all(&config)
|
||||
.map_err(|e| s3_error!(InternalError, "failed to write archive entry: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
if query.diagnostic {
|
||||
let manifest = serde_json::to_vec(&serde_json::json!({
|
||||
"version": 1,
|
||||
"mode": "diagnostic",
|
||||
"complete": errors.is_empty(),
|
||||
"errors": errors,
|
||||
}))
|
||||
.map_err(|e| s3_error!(InternalError, "failed to serialize diagnostic manifest: {e}"))?;
|
||||
zip_writer
|
||||
.start_file(DIAGNOSTIC_EXPORT_MANIFEST, SimpleFileOptions::default())
|
||||
.map_err(|e| s3_error!(InternalError, "failed to start diagnostic manifest: {e}"))?;
|
||||
zip_writer
|
||||
.write_all(&manifest)
|
||||
.map_err(|e| s3_error!(InternalError, "failed to write diagnostic manifest: {e}"))?;
|
||||
}
|
||||
|
||||
let zip_bytes = zip_writer
|
||||
.finish()
|
||||
.map_err(|e| s3_error!(InternalError, "failed to finalize export archive: {e}"))?;
|
||||
@@ -431,8 +429,17 @@ impl Operation for ExportBucketMetadata {
|
||||
header.insert(CONTENT_TYPE, "application/zip".parse().expect("valid header value"));
|
||||
header.insert(
|
||||
CONTENT_DISPOSITION,
|
||||
"attachment; filename=bucket-meta.zip".parse().expect("valid header value"),
|
||||
if query.diagnostic {
|
||||
"attachment; filename=bucket-meta-diagnostic.zip"
|
||||
} else {
|
||||
"attachment; filename=bucket-meta.zip"
|
||||
}
|
||||
.parse()
|
||||
.expect("valid header value"),
|
||||
);
|
||||
if query.diagnostic {
|
||||
header.insert("x-rustfs-bucket-metadata-export", "diagnostic".parse().expect("valid header value"));
|
||||
}
|
||||
header.insert(CONTENT_LENGTH, zip_bytes.get_ref().len().to_string().parse().expect("valid header value"));
|
||||
Ok(S3Response::with_headers((StatusCode::OK, Body::from(zip_bytes.into_inner())), header))
|
||||
}
|
||||
@@ -499,6 +506,18 @@ impl Operation for ImportBucketMetadata {
|
||||
file_contents.push((file_path, content));
|
||||
}
|
||||
|
||||
// Reject the whole archive before creating buckets or writing configs,
|
||||
// even when the marker is malformed or follows ordinary config entries.
|
||||
if file_contents.iter().any(|(path, _)| {
|
||||
path == DIAGNOSTIC_EXPORT_MANIFEST
|
||||
|| path == DIAGNOSTIC_EXPORT_PREFIX
|
||||
|| path
|
||||
.strip_prefix(DIAGNOSTIC_EXPORT_PREFIX)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
}) {
|
||||
return Err(s3_error!(InvalidRequest, "diagnostic bucket metadata archives cannot be imported"));
|
||||
}
|
||||
|
||||
let durable_quota_import = imported_quota_requires_fleet_proof(&file_contents)?;
|
||||
let quota_fleet_proof =
|
||||
if durable_quota_import {
|
||||
@@ -1419,6 +1438,246 @@ mod backup_zip_compatibility_tests {
|
||||
assert_eq!(response.output.0, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn diagnostic_export_isolated_errors_and_import_recovers_unreadable_targets() {
|
||||
const HEALTHY: &str = "diagnostic-healthy";
|
||||
const UNREADABLE: &str = "diagnostic-unreadable";
|
||||
const REPLACEMENT_TARGETS: &[u8] = br#"{"targets":[]}"#;
|
||||
const SECRET: &str = "diagnostic-must-not-expose-this-secret";
|
||||
|
||||
let _ = rustfs_credentials::init_global_action_credentials(
|
||||
Some(ROOT_ACCESS_KEY.to_string()),
|
||||
Some(ROOT_SECRET_KEY.to_string()),
|
||||
);
|
||||
let temp = tempfile::tempdir().expect("create diagnostic export test root");
|
||||
let env = rustfs_test_utils::TestECStoreEnv::builder()
|
||||
.base_dir(temp.path())
|
||||
.disk_count(1)
|
||||
.build()
|
||||
.await;
|
||||
env.make_bucket(HEALTHY, false).await;
|
||||
env.make_bucket(UNREADABLE, false).await;
|
||||
rustfs_iam::store::object::ObjectStore::new(Arc::clone(&env.ecstore))
|
||||
.save_iam_config(serde_json::json!({"version": 1}), format!("{}/format.json", *IAM_CONFIG_PREFIX))
|
||||
.await
|
||||
.expect("seed IAM format");
|
||||
let iam = rustfs_iam::build_iam_sys(Arc::clone(&env.ecstore))
|
||||
.await
|
||||
.expect("build test IAM");
|
||||
publish_test_app_context(Arc::new(AppContext::with_default_interfaces(
|
||||
Arc::clone(&env.ecstore),
|
||||
iam,
|
||||
Arc::new(rustfs_kms::KmsServiceManager::new()),
|
||||
)));
|
||||
metadata_sys::update(HEALTHY, BUCKET_VERSIONING_CONFIG, VERSIONING_XML.to_vec())
|
||||
.await
|
||||
.expect("seed healthy bucket config");
|
||||
|
||||
let minio_blob = hex_simd::decode_to_vec(
|
||||
include_str!("../../../../crates/ecstore/tests/fixtures/minio/bucket_metadata.blob.hex").trim(),
|
||||
)
|
||||
.expect("decode real MinIO metadata fixture");
|
||||
let minio_metadata = BucketMetadata::unmarshal(&minio_blob[4..]).expect("read MinIO metadata fixture");
|
||||
let targets_array = format!(r#"[{{"credentials":{{"secretKey":"{SECRET}"}}}}]"#).into_bytes();
|
||||
for unreadable_targets in [targets_array, minio_metadata.bucket_targets_config_json] {
|
||||
let mut metadata = metadata_sys::get_config_from_disk(UNREADABLE)
|
||||
.await
|
||||
.expect("load bucket before simulating MinIO targets");
|
||||
let incarnation = metadata.bucket_incarnation_id;
|
||||
metadata.bucket_targets_config_json = unreadable_targets.clone();
|
||||
metadata
|
||||
.save_with_store(Arc::clone(&env.ecstore))
|
||||
.await
|
||||
.expect("persist unreadable targets fixture");
|
||||
crate::storage::storage_api::set_bucket_metadata(UNREADABLE.to_string(), metadata)
|
||||
.await
|
||||
.expect("publish unreadable targets fixture");
|
||||
assert!(metadata_sys::get_bucket_targets_config(UNREADABLE).await.is_err());
|
||||
|
||||
let strict_error = ExportBucketMetadata {}
|
||||
.call(
|
||||
admin_request(Method::GET, Uri::from_static("/rustfs/admin/v3/export-bucket-metadata"), Vec::new()),
|
||||
Params::new(),
|
||||
)
|
||||
.await
|
||||
.expect_err("a complete export must fail closed on unreadable targets");
|
||||
assert_eq!(*strict_error.code(), s3s::S3ErrorCode::InternalError);
|
||||
|
||||
let response = ExportBucketMetadata {}
|
||||
.call(
|
||||
admin_request(
|
||||
Method::GET,
|
||||
Uri::from_static("/rustfs/admin/v3/export-bucket-metadata?diagnostic=true"),
|
||||
Vec::new(),
|
||||
),
|
||||
Params::new(),
|
||||
)
|
||||
.await
|
||||
.expect("one unreadable bucket must not abort diagnostic export");
|
||||
assert_eq!(response.output.0, StatusCode::OK);
|
||||
assert_eq!(response.headers["x-rustfs-bucket-metadata-export"], "diagnostic");
|
||||
assert_eq!(response.headers[CONTENT_DISPOSITION], "attachment; filename=bucket-meta-diagnostic.zip");
|
||||
let bytes = response.output.1.collect().await.expect("read diagnostic archive").to_bytes();
|
||||
let mut archive = ZipArchive::new(Cursor::new(&bytes)).expect("open diagnostic archive");
|
||||
let mut files = HashMap::new();
|
||||
for index in 0..archive.len() {
|
||||
let mut file = archive.by_index(index).expect("read diagnostic entry");
|
||||
let mut content = Vec::new();
|
||||
file.read_to_end(&mut content).expect("read diagnostic config");
|
||||
assert!(!content.windows(SECRET.len()).any(|window| window == SECRET.as_bytes()));
|
||||
assert!(file.name() == DIAGNOSTIC_EXPORT_MANIFEST || file.name().starts_with("_diagnostic/"));
|
||||
files.insert(file.name().to_string(), content);
|
||||
}
|
||||
assert_eq!(files[&format!("_diagnostic/{HEALTHY}/{BUCKET_VERSIONING_CONFIG}")], VERSIONING_XML);
|
||||
assert!(files.contains_key(&format!("_diagnostic/{UNREADABLE}/{BUCKET_VERSIONING_CONFIG}")));
|
||||
assert!(!files.contains_key(&format!("_diagnostic/{UNREADABLE}/{BUCKET_TARGETS_FILE}")));
|
||||
let manifest: serde_json::Value =
|
||||
serde_json::from_slice(&files[DIAGNOSTIC_EXPORT_MANIFEST]).expect("decode diagnostic manifest");
|
||||
assert_eq!(
|
||||
manifest,
|
||||
serde_json::json!({
|
||||
"version": 1,
|
||||
"mode": "diagnostic",
|
||||
"complete": false,
|
||||
"errors": [{ "bucket": UNREADABLE, "config": BUCKET_TARGETS_FILE, "code": "configuration_unavailable" }],
|
||||
})
|
||||
);
|
||||
|
||||
let error = ImportBucketMetadata {}
|
||||
.call(
|
||||
admin_request(Method::PUT, Uri::from_static("/rustfs/admin/v3/import-bucket-metadata"), bytes.to_vec()),
|
||||
Params::new(),
|
||||
)
|
||||
.await
|
||||
.expect_err("diagnostic exports are never backups");
|
||||
assert_eq!(*error.code(), s3s::S3ErrorCode::InvalidRequest);
|
||||
assert_eq!(
|
||||
metadata_sys::get_config_from_disk(UNREADABLE)
|
||||
.await
|
||||
.expect("load rejected import state")
|
||||
.bucket_targets_config_json,
|
||||
unreadable_targets
|
||||
);
|
||||
|
||||
import_archive(zip_with_entries(UNREADABLE, &[(BUCKET_TARGETS_FILE, REPLACEMENT_TARGETS)])).await;
|
||||
let recovered = metadata_sys::get_config_from_disk(UNREADABLE)
|
||||
.await
|
||||
.expect("read recovered targets from disk");
|
||||
assert_eq!(recovered.bucket_incarnation_id, incarnation);
|
||||
assert_eq!(recovered.bucket_targets_config_json, REPLACEMENT_TARGETS);
|
||||
assert!(
|
||||
metadata_sys::get_bucket_targets_config(UNREADABLE)
|
||||
.await
|
||||
.expect("existing import API must recover targets readers")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
// The marker may be malformed, come last, or be removed while the
|
||||
// reserved directory remains. None may allow an earlier config write.
|
||||
for marker in [
|
||||
DIAGNOSTIC_EXPORT_MANIFEST.to_string(),
|
||||
DIAGNOSTIC_EXPORT_PREFIX.to_string(),
|
||||
format!("{DIAGNOSTIC_EXPORT_PREFIX}/bucket/config"),
|
||||
] {
|
||||
let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
writer
|
||||
.start_file(format!("{HEALTHY}/{BUCKET_VERSIONING_CONFIG}"), SimpleFileOptions::default())
|
||||
.expect("start ordinary config before diagnostic marker");
|
||||
writer
|
||||
.write_all(b"<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>")
|
||||
.expect("write ordinary config before diagnostic marker");
|
||||
writer
|
||||
.start_file(
|
||||
format!("diagnostic-never-created/{BUCKET_VERSIONING_CONFIG}"),
|
||||
SimpleFileOptions::default(),
|
||||
)
|
||||
.expect("start a nonexistent bucket config before diagnostic marker");
|
||||
writer.write_all(VERSIONING_XML).expect("write nonexistent bucket config");
|
||||
writer
|
||||
.start_file(marker, SimpleFileOptions::default())
|
||||
.expect("start diagnostic marker");
|
||||
writer.write_all(b"not json").expect("write malformed diagnostic marker");
|
||||
let error = ImportBucketMetadata {}
|
||||
.call(
|
||||
admin_request(
|
||||
Method::PUT,
|
||||
Uri::from_static("/rustfs/admin/v3/import-bucket-metadata"),
|
||||
writer.finish().expect("finish marked archive").into_inner(),
|
||||
),
|
||||
Params::new(),
|
||||
)
|
||||
.await
|
||||
.expect_err("diagnostic preflight must reject before any config write");
|
||||
assert_eq!(*error.code(), s3s::S3ErrorCode::InvalidRequest);
|
||||
assert_eq!(
|
||||
metadata_sys::get_config_from_disk(HEALTHY)
|
||||
.await
|
||||
.expect("read healthy config after rejected import")
|
||||
.versioning_config_xml,
|
||||
VERSIONING_XML
|
||||
);
|
||||
assert!(
|
||||
env.ecstore
|
||||
.get_bucket_info("diagnostic-never-created", &BucketOptions::default())
|
||||
.await
|
||||
.is_err(),
|
||||
"diagnostic preflight must reject before bucket creation"
|
||||
);
|
||||
}
|
||||
|
||||
let response = ExportBucketMetadata {}
|
||||
.call(
|
||||
admin_request(
|
||||
Method::GET,
|
||||
Uri::from_static("/rustfs/admin/v3/export-bucket-metadata?diagnostic=true"),
|
||||
Vec::new(),
|
||||
),
|
||||
Params::new(),
|
||||
)
|
||||
.await
|
||||
.expect("diagnostic export after recovery");
|
||||
let bytes = response
|
||||
.output
|
||||
.1
|
||||
.collect()
|
||||
.await
|
||||
.expect("read complete diagnostic archive")
|
||||
.to_bytes();
|
||||
let mut archive = ZipArchive::new(Cursor::new(&bytes)).expect("open complete diagnostic archive");
|
||||
let manifest: serde_json::Value = serde_json::from_reader(
|
||||
archive
|
||||
.by_name(DIAGNOSTIC_EXPORT_MANIFEST)
|
||||
.expect("complete diagnostic manifest"),
|
||||
)
|
||||
.expect("parse complete diagnostic manifest");
|
||||
assert_eq!(manifest["complete"], true);
|
||||
let error = ImportBucketMetadata {}
|
||||
.call(
|
||||
admin_request(Method::PUT, Uri::from_static("/rustfs/admin/v3/import-bucket-metadata"), bytes.to_vec()),
|
||||
Params::new(),
|
||||
)
|
||||
.await
|
||||
.expect_err("complete diagnostics must still reject import");
|
||||
assert_eq!(*error.code(), s3s::S3ErrorCode::InvalidRequest);
|
||||
|
||||
let response = ExportBucketMetadata {}
|
||||
.call(
|
||||
admin_request(Method::GET, Uri::from_static("/rustfs/admin/v3/export-bucket-metadata"), Vec::new()),
|
||||
Params::new(),
|
||||
)
|
||||
.await
|
||||
.expect("ordinary cluster export must work after API recovery");
|
||||
assert!(!response.headers.contains_key("x-rustfs-bucket-metadata-export"));
|
||||
assert_eq!(response.headers[CONTENT_DISPOSITION], "attachment; filename=bucket-meta.zip");
|
||||
let bytes = response.output.1.collect().await.expect("read ordinary archive").to_bytes();
|
||||
let mut archive = ZipArchive::new(Cursor::new(bytes)).expect("open ordinary archive");
|
||||
assert!(archive.by_name(DIAGNOSTIC_EXPORT_MANIFEST).is_err());
|
||||
assert!(archive.by_name(&format!("{HEALTHY}/{BUCKET_VERSIONING_CONFIG}")).is_ok());
|
||||
assert!(archive.by_name(&format!("{UNREADABLE}/{BUCKET_TARGETS_FILE}")).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn g_zip_001_002_003_use_real_admin_archive_and_persistence_paths() {
|
||||
|
||||
@@ -38,15 +38,6 @@ use crate::admin::runtime_sources::{
|
||||
};
|
||||
use crate::admin::storage_api::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG;
|
||||
use crate::admin::storage_api::bucket::metadata_sys;
|
||||
use crate::admin::storage_api::bucket::on_demand_migration::backfill::{
|
||||
BackfillCheckpoint, BackfillError, BackfillRequest, BackfillState, SkipExisting, global_backfill_runner,
|
||||
};
|
||||
use crate::admin::storage_api::bucket::on_demand_migration::source_client::{
|
||||
SourceClient, SourceClientSpec, SourceError, SourceProbe, SourceProvider, SourceTimeouts,
|
||||
};
|
||||
use crate::admin::storage_api::bucket::on_demand_migration::{
|
||||
OdmBucketSnapshot, OnDemandMigrationConfig, OnDemandMigrationConfigError, OnDemandMigrationSys, PathStyle, ValidationContext,
|
||||
};
|
||||
use crate::admin::storage_api::bucket::remote_s3_client::{
|
||||
PathStyle as RemotePathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3RetryPolicy,
|
||||
};
|
||||
@@ -57,6 +48,16 @@ use crate::admin::utils::{extract_query_params, read_compatible_admin_body};
|
||||
use crate::error::ApiError;
|
||||
use crate::license::license_check;
|
||||
use crate::module_switches::{ENV_ON_DEMAND_MIGRATION_ENABLED, on_demand_migration_enabled_from_env};
|
||||
use crate::on_demand_migration::backfill::{
|
||||
BackfillCheckpoint, BackfillError, BackfillRequest, BackfillState, SkipExisting, global_backfill_runner,
|
||||
};
|
||||
use crate::on_demand_migration::source_client::{
|
||||
SourceClient, SourceClientSpec, SourceError, SourceProbe, SourceProvider, SourceTimeouts,
|
||||
};
|
||||
use crate::on_demand_migration::{
|
||||
OdmBucketSnapshot, OnDemandMigrationConfig, OnDemandMigrationConfigError, OnDemandMigrationSys, PathStyle, ValidationContext,
|
||||
source_backend_spec,
|
||||
};
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use hyper::{Method, StatusCode};
|
||||
use matchit::Params;
|
||||
@@ -89,6 +90,8 @@ const BACKFILL_OP_CANCEL: &str = "cancel";
|
||||
pub(crate) const ERR_CODE_MODULE_DISABLED: &str = "OnDemandMigrationDisabled";
|
||||
/// Error code returned when the source bucket did not answer the probe.
|
||||
pub(crate) const ERR_CODE_SOURCE_UNREACHABLE: &str = "OnDemandMigrationSourceUnreachable";
|
||||
/// Error code returned when the configured provider was excluded at build time.
|
||||
pub(crate) const ERR_CODE_BACKEND_NOT_COMPILED: &str = "OnDemandMigrationBackendNotCompiled";
|
||||
/// Error code returned by `GET` when the bucket has no configuration.
|
||||
pub(crate) const ERR_CODE_NO_SUCH_CONFIGURATION: &str = "NoSuchConfiguration";
|
||||
/// Error code (409) returned by `start` while a backfill job holds the lease.
|
||||
@@ -577,7 +580,7 @@ async fn validate_config(bucket: &str, config: &OnDemandMigrationConfig) -> S3Re
|
||||
}
|
||||
|
||||
fn source_provider(config: &OnDemandMigrationConfig) -> SourceProvider {
|
||||
use crate::admin::storage_api::bucket::on_demand_migration::Provider;
|
||||
use crate::on_demand_migration::Provider;
|
||||
match config.source.provider {
|
||||
Provider::S3 => SourceProvider::S3,
|
||||
Provider::Aws => SourceProvider::Aws,
|
||||
@@ -585,6 +588,8 @@ fn source_provider(config: &OnDemandMigrationConfig) -> SourceProvider {
|
||||
Provider::Rustfs => SourceProvider::Rustfs,
|
||||
Provider::R2 => SourceProvider::R2,
|
||||
Provider::Gcs => SourceProvider::Gcs,
|
||||
Provider::Azure => SourceProvider::Azure,
|
||||
Provider::GcsNative => SourceProvider::GcsNative,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,15 +626,23 @@ pub(crate) fn source_client_spec(config: &OnDemandMigrationConfig) -> SourceClie
|
||||
// a flapping source behind a success and triple the probe's cost.
|
||||
retry: RemoteS3RetryPolicy::Disabled,
|
||||
bandwidth_limit: config.policy.bandwidth_limit_bytes_per_sec.and_then(NonZeroU64::new),
|
||||
// One mapping serves the probe and the runtime, so an admin probe
|
||||
// always exercises the backend the runtime will build.
|
||||
backend: source_backend_spec(source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder failures are input errors: the endpoint policy, the CA PEM or the
|
||||
/// credentials the operator supplied. Anonymous sources are not wired yet
|
||||
/// Distinguishes excluded backends from invalid endpoint, CA or credentials.
|
||||
/// Anonymous S3 sources are not wired yet
|
||||
/// (ODM-05 adds the credential-less path), so `MissingCredentials` is a 400
|
||||
/// naming the field instead of an opaque internal error.
|
||||
fn client_build_error(err: RemoteS3ClientError) -> S3Error {
|
||||
match err {
|
||||
RemoteS3ClientError::BackendNotCompiled(provider) => custom_error(
|
||||
ERR_CODE_BACKEND_NOT_COMPILED,
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
format!("the {provider} backend is not included in this build; rebuild with the gcs feature"),
|
||||
),
|
||||
RemoteS3ClientError::MissingCredentials => admin_s3_error(
|
||||
S3ErrorCode::InvalidArgument,
|
||||
"source.credentials is required: anonymous sources are not supported yet",
|
||||
@@ -774,7 +787,7 @@ impl Operation for GetBucketOnDemandMigrationHandler {
|
||||
let bucket = bucket_from_params(¶ms)?;
|
||||
let cred = authorize_for_bucket(&req, AdminAction::GetBucketOnDemandMigrationAction, &bucket).await?;
|
||||
|
||||
let Some((config, updated_at)) = metadata_sys::get_on_demand_migration_config(&bucket).await.map_err(|err| {
|
||||
let Some((config, updated_at)) = crate::on_demand_migration::config::get_config(&bucket).await.map_err(|err| {
|
||||
admin_s3_error(S3ErrorCode::InternalError, format!("failed to read on-demand migration config: {err}"))
|
||||
})?
|
||||
else {
|
||||
@@ -833,7 +846,7 @@ impl Operation for GetBucketOnDemandMigrationStatusHandler {
|
||||
let bucket = bucket_from_params(¶ms)?;
|
||||
let cred = authorize_for_bucket(&req, AdminAction::GetBucketOnDemandMigrationAction, &bucket).await?;
|
||||
|
||||
let config = metadata_sys::get_on_demand_migration_config(&bucket).await.map_err(|err| {
|
||||
let config = crate::on_demand_migration::config::get_config(&bucket).await.map_err(|err| {
|
||||
admin_s3_error(S3ErrorCode::InternalError, format!("failed to read on-demand migration config: {err}"))
|
||||
})?;
|
||||
let runtime = OnDemandMigrationSys::get().bucket_snapshot(&bucket);
|
||||
@@ -1282,6 +1295,14 @@ mod tests {
|
||||
assert!(err.message().unwrap_or_default().contains("source.credentials"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_not_compiled_is_distinct_from_invalid_credentials() {
|
||||
let err = client_build_error(RemoteS3ClientError::BackendNotCompiled("gcs_native"));
|
||||
assert_eq!(err.code(), &S3ErrorCode::Custom(ERR_CODE_BACKEND_NOT_COMPILED.into()));
|
||||
assert_eq!(err.status_code(), Some(StatusCode::NOT_IMPLEMENTED));
|
||||
assert!(err.message().unwrap_or_default().contains("gcs feature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_switch_defaults_on_and_reads_the_env() {
|
||||
temp_env::with_var(ENV_ON_DEMAND_MIGRATION_ENABLED, None::<&str>, || assert!(module_enabled()));
|
||||
@@ -1742,6 +1763,19 @@ mod store_tests {
|
||||
assert_eq!(body["inflight_pulls"], Value::from(0));
|
||||
assert_eq!(body["queue_depth"], Value::from(0));
|
||||
|
||||
// A malformed replacement cannot overwrite the saved source.
|
||||
let before = metadata_sys::get(BUCKET).await.expect("saved metadata");
|
||||
for invalid in [b"not-json".to_vec(), br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec()] {
|
||||
let err = SetBucketOnDemandMigrationHandler {}
|
||||
.call(root_request(Method::PUT, config_uri(""), invalid), bucket_params(&router))
|
||||
.await
|
||||
.expect_err("malformed replacement must be rejected");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
let after = metadata_sys::get(BUCKET).await.expect("saved metadata remains readable");
|
||||
assert_eq!(after.on_demand_migration_config_json, before.on_demand_migration_config_json);
|
||||
assert_eq!(after.on_demand_migration_config_updated_at, before.on_demand_migration_config_updated_at);
|
||||
}
|
||||
|
||||
// The peer fan-out ran: the single unreachable peer is reported.
|
||||
let context = crate::admin::runtime_sources::current_app_context();
|
||||
let err = reload_peers(context.as_deref(), BUCKET)
|
||||
|
||||
@@ -20,8 +20,8 @@ use time::OffsetDateTime;
|
||||
|
||||
mod ecstore_bucket {
|
||||
pub(crate) use crate::storage::storage_api::ecstore_bucket::{
|
||||
bandwidth, bucket_target_sys, durability, lifecycle, metadata, metadata_sys, object_lock, on_demand_migration, quota,
|
||||
remote_s3_client, replication, target, utils, versioning, versioning_sys,
|
||||
bandwidth, bucket_target_sys, durability, lifecycle, metadata, metadata_sys, object_lock, quota, remote_s3_client,
|
||||
replication, target, utils, versioning, versioning_sys,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -284,34 +284,6 @@ pub(crate) mod durability {
|
||||
pub(crate) type BucketDurabilityConfig = super::ecstore_bucket::durability::BucketDurabilityConfig;
|
||||
}
|
||||
|
||||
pub(crate) mod on_demand_migration {
|
||||
pub(crate) type OdmBucketSnapshot = super::ecstore_bucket::on_demand_migration::OdmBucketSnapshot;
|
||||
pub(crate) type OnDemandMigrationConfig = super::ecstore_bucket::on_demand_migration::OnDemandMigrationConfig;
|
||||
pub(crate) type OnDemandMigrationConfigError = super::ecstore_bucket::on_demand_migration::OnDemandMigrationConfigError;
|
||||
pub(crate) type OnDemandMigrationSys = super::ecstore_bucket::on_demand_migration::OnDemandMigrationSys;
|
||||
pub(crate) type PathStyle = super::ecstore_bucket::on_demand_migration::PathStyle;
|
||||
pub(crate) type Provider = super::ecstore_bucket::on_demand_migration::Provider;
|
||||
pub(crate) type ValidationContext<'a> = super::ecstore_bucket::on_demand_migration::ValidationContext<'a>;
|
||||
|
||||
pub(crate) mod backfill {
|
||||
pub(crate) type BackfillCheckpoint = super::super::ecstore_bucket::on_demand_migration::backfill::BackfillCheckpoint;
|
||||
pub(crate) type BackfillError = super::super::ecstore_bucket::on_demand_migration::backfill::BackfillError;
|
||||
pub(crate) type BackfillRequest = super::super::ecstore_bucket::on_demand_migration::backfill::BackfillRequest;
|
||||
pub(crate) type BackfillState = super::super::ecstore_bucket::on_demand_migration::backfill::BackfillState;
|
||||
pub(crate) type SkipExisting = super::super::ecstore_bucket::on_demand_migration::backfill::SkipExisting;
|
||||
pub(crate) use super::super::ecstore_bucket::on_demand_migration::backfill::global_backfill_runner;
|
||||
}
|
||||
|
||||
pub(crate) mod source_client {
|
||||
pub(crate) type SourceClient = super::super::ecstore_bucket::on_demand_migration::source_client::SourceClient;
|
||||
pub(crate) type SourceClientSpec = super::super::ecstore_bucket::on_demand_migration::source_client::SourceClientSpec;
|
||||
pub(crate) type SourceError = super::super::ecstore_bucket::on_demand_migration::source_client::SourceError;
|
||||
pub(crate) type SourceProbe = super::super::ecstore_bucket::on_demand_migration::source_client::SourceProbe;
|
||||
pub(crate) type SourceProvider = super::super::ecstore_bucket::on_demand_migration::source_client::SourceProvider;
|
||||
pub(crate) type SourceTimeouts = super::super::ecstore_bucket::on_demand_migration::source_client::SourceTimeouts;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod remote_s3_client {
|
||||
pub(crate) type PathStyle = super::ecstore_bucket::remote_s3_client::PathStyle;
|
||||
pub(crate) type RemoteCredentials = super::ecstore_bucket::remote_s3_client::RemoteCredentials;
|
||||
@@ -454,12 +426,6 @@ pub(crate) mod metadata_sys {
|
||||
super::ecstore_bucket::metadata_sys::get_durability_config(bucket).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_on_demand_migration_config(
|
||||
bucket: &str,
|
||||
) -> Result<Option<(super::on_demand_migration::OnDemandMigrationConfig, OffsetDateTime)>> {
|
||||
super::ecstore_bucket::metadata_sys::get_on_demand_migration_config(bucket).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
|
||||
super::ecstore_bucket::metadata_sys::get_quota_config(bucket).await
|
||||
}
|
||||
@@ -912,7 +878,6 @@ pub(crate) mod bucket {
|
||||
pub(crate) use super::lifecycle;
|
||||
pub(crate) use super::metadata;
|
||||
pub(crate) use super::metadata_sys;
|
||||
pub(crate) use super::on_demand_migration;
|
||||
pub(crate) use super::quota;
|
||||
pub(crate) use super::remote_s3_client;
|
||||
pub(crate) use super::replication;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user