mirror of
https://github.com/rustfs/rustfs.git
synced 2026-11-10 12:19:07 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1cdaed49e |
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=9901a4950099b94d3d2618524d540f1ed4bf6023044903886f98b6c4bf5f30fb
|
||||
sha256-linux=09fd5f48f5556c9fc576357222dccd7ebb0b90b790798f1834db2ec5d67b4952
|
||||
sha256-darwin=219ac11e720b3f54dfee4d5d5e95171785ba159b2fe01db3037a8f5061c1ad32
|
||||
sha256-linux=2d34698cc8ec52d5b6c37ac612919a1d21f36cd56a1af539a7522a4eea518e26
|
||||
|
||||
@@ -54,7 +54,6 @@ script-tests: ## Run shell script tests
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_download_release_asset.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_btf_prototypes.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"plan_id": "rustfs-release-validation",
|
||||
"description": "Versioned certification plan for every public RustFS release exit (rustfs/backlog#2662, R01). preview, prerelease and stable all require this explicit plan. Scripts/release/release_gate.py fail-closes publication on any missing, duplicate, stale, substituted or non-PASS required item.",
|
||||
"release_types": ["preview", "prerelease", "stable"],
|
||||
"targets": [
|
||||
{"os": "linux", "arch": "x86_64", "abi": "musl"},
|
||||
{"os": "linux", "arch": "aarch64", "abi": "musl"},
|
||||
{"os": "linux", "arch": "x86_64", "abi": "gnu"},
|
||||
{"os": "linux", "arch": "aarch64", "abi": "gnu"},
|
||||
{"os": "macos", "arch": "aarch64", "abi": "none"},
|
||||
{"os": "windows", "arch": "x86_64", "abi": "msvc"}
|
||||
],
|
||||
"targets_basis": "Platform matrix of .github/workflows/build.yml (prepare-platform-matrix). gnu and macOS targets build with the pyroscope feature; musl and Windows targets build with no extra features.",
|
||||
"features": ["pyroscope"],
|
||||
"trusted_workflows": [
|
||||
{"repository": "rustfs/s3chaos", "workflow_id": "release-gate.yml"}
|
||||
],
|
||||
"trusted_workflows_basis": "The shared evidence envelope producer lives in rustfs/s3chaos (G01, fault/release_gate.rs) and its plan/live split workflow is .github/workflows/release-gate.yml there. Only results from these identities may enter a release certification; PR-uploaded JSON never gains release authority. R02 must extend this list when additional verifier workflows land, never widen it to untrusted sources.",
|
||||
"lanes": [
|
||||
{
|
||||
"lane": "identity",
|
||||
"harness_repository": "rustfs/s3chaos",
|
||||
"harness_sha": "9a48f55c1d6d58ce63a8fb1ea7317e024f32f11d",
|
||||
"harness_basis": "Fixed G01 evidence-envelope producer revision (rustfs/s3chaos PR #102 head recorded in rustfs/backlog#2658). R02 re-pins this when the live verifier ships.",
|
||||
"timeout_seconds": 1800,
|
||||
"environment_capabilities": ["process-exec"],
|
||||
"cases": ["artifact-identity", "startup"],
|
||||
"applies_to": {
|
||||
"release_types": ["preview", "prerelease", "stable"],
|
||||
"platforms": "all"
|
||||
},
|
||||
"applies_basis": "Artifact identity and startup checks are mandatory for every release type, preview included, on every shipped target (rustfs/backlog#2662 frozen design item 5)."
|
||||
},
|
||||
{
|
||||
"lane": "upgrade",
|
||||
"harness_repository": "rustfs/s3chaos",
|
||||
"harness_sha": "9a48f55c1d6d58ce63a8fb1ea7317e024f32f11d",
|
||||
"harness_basis": "Same fixed envelope producer as the identity lane; the workflow SHA and the product build SHA are bound separately and need not be equal.",
|
||||
"timeout_seconds": 5400,
|
||||
"environment_capabilities": ["process-exec", "previous-release-artifact"],
|
||||
"cases": [
|
||||
"direct-upgrade",
|
||||
"mixed-version-upgrade",
|
||||
"bucket-config-upgrade",
|
||||
"bucket-config-rollback",
|
||||
"multipart-layout-upgrade",
|
||||
"multipart-layout-baseline"
|
||||
],
|
||||
"cases_basis": "Case IDs of the pinned previous-release matrix in .github/workflows/e2e-upgrade.yml (source.version/asset/sha256 and the case list mapped there).",
|
||||
"applies_to": {
|
||||
"release_types": ["prerelease", "stable"],
|
||||
"platforms": [{"os": "linux", "arch": "x86_64", "abi": "gnu"}]
|
||||
},
|
||||
"applies_basis": "preview releases use the pre-declared smaller set (identity lane only). The upgrade lane is only exercised on linux-x86_64-gnu, the platform of the pinned source assets."
|
||||
}
|
||||
],
|
||||
"upgrade_sources": {
|
||||
"basis": "Exactly the two previous-release sources pinned in .github/workflows/e2e-upgrade.yml are exercised today (source.version / source.asset / source.sha256). No other N-1/N-2 compatibility is claimed, and these digests are not inferred from tags.",
|
||||
"sources": [
|
||||
{
|
||||
"version": "1.0.1",
|
||||
"role": "N-1",
|
||||
"artifact_name": "rustfs-linux-x86_64-gnu-v1.0.1.zip",
|
||||
"artifact_sha256": "9742e183cb0dfb0d48fa909797af572fe47b7f827569036baa4156e0d3958ee5"
|
||||
},
|
||||
{
|
||||
"version": "1.0.0-rc.5",
|
||||
"role": "N-2",
|
||||
"artifact_name": "rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip",
|
||||
"artifact_sha256": "3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d"
|
||||
}
|
||||
],
|
||||
"rollback_supported": false,
|
||||
"rollback_basis": "Only the bucket-config-rollback case (rollback_to_previous_release_reads_current_bucket_metadata) is exercised. Full data-plane rollback is not certified and is never a default claim."
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,11 @@ runs:
|
||||
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ASSET_DOWNLOADER: ${{ github.action_path }}/../setup/download_release_asset.py
|
||||
run: |
|
||||
actionlint_dir="$(mktemp -d "${RUNNER_TEMP}/actionlint.XXXXXX")"
|
||||
python3 "$ASSET_DOWNLOADER" --repository rhysd/actionlint --tag v1.7.12 --asset actionlint_1.7.12_linux_amd64.tar.gz --output "$actionlint_dir/actionlint.tar.gz" --sha256 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "$actionlint_dir/actionlint.tar.gz" \
|
||||
https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz
|
||||
echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $actionlint_dir/actionlint.tar.gz" | sha256sum --check --status
|
||||
tar -xzf "$actionlint_dir/actionlint.tar.gz" -C "$actionlint_dir" actionlint
|
||||
rm "$actionlint_dir/actionlint.tar.gz"
|
||||
|
||||
@@ -66,7 +66,10 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
# Use the pinned code generators rather than Ubuntu's older packages.
|
||||
# protobuf-compiler is deliberately absent: the setup-protoc step below
|
||||
# installs 35.1 into the tool cache and prepends it to PATH, so the apt
|
||||
# build (older, and never version-matched) was shadowed on every run and
|
||||
# simply never used.
|
||||
- name: Install system dependencies (Ubuntu)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
@@ -76,9 +79,7 @@ runs:
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
ripgrep \
|
||||
python3 \
|
||||
gh
|
||||
ripgrep
|
||||
|
||||
# musl-gcc is needed by the native musl release leg, and zip/unzip by the
|
||||
# release packaging steps. No CI test lane touches any of them.
|
||||
@@ -87,32 +88,13 @@ runs:
|
||||
shell: bash
|
||||
run: sudo apt-get install -y musl-tools zip unzip
|
||||
|
||||
- name: Install pinned code generators through GitHub API (Linux x64)
|
||||
if: runner.os == 'Linux' && runner.arch == 'X64'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ASSET_DOWNLOADER: ${{ github.action_path }}/download_release_asset.py
|
||||
run: |
|
||||
python3 "$ASSET_DOWNLOADER" --repository protocolbuffers/protobuf --tag v35.1 --asset protoc-35.1-linux-x86_64.zip --output "$RUNNER_TEMP/protoc.zip"
|
||||
python3 "$ASSET_DOWNLOADER" --repository google/flatbuffers --tag v25.12.19 --asset 'Linux.flatc.binary.g++-13.zip' --output "$RUNNER_TEMP/flatc.zip"
|
||||
python3 -m zipfile -e "$RUNNER_TEMP/protoc.zip" "$RUNNER_TEMP/protoc"
|
||||
python3 -m zipfile -e "$RUNNER_TEMP/flatc.zip" "$RUNNER_TEMP/flatc"
|
||||
chmod +x "$RUNNER_TEMP/protoc/bin/protoc" "$RUNNER_TEMP/flatc/flatc"
|
||||
"$RUNNER_TEMP/protoc/bin/protoc" --version
|
||||
"$RUNNER_TEMP/flatc/flatc" --version
|
||||
echo "$RUNNER_TEMP/protoc/bin" >> "$GITHUB_PATH"
|
||||
echo "$RUNNER_TEMP/flatc" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install protoc
|
||||
if: runner.os != 'Linux' || runner.arch != 'X64'
|
||||
uses: rustfs/setup-protoc@a3705324d8f9bf5b6c3573fb6cf8ae421db55dd6 # v3.0.1
|
||||
with:
|
||||
version: "35.1"
|
||||
repo-token: ${{ github.token }}
|
||||
|
||||
- name: Install flatc
|
||||
if: runner.os != 'Linux' || runner.arch != 'X64'
|
||||
uses: Nugine/setup-flatc@698800de72a96bfb22cf60431dc21a2ff9a7e07b # v1
|
||||
with:
|
||||
version: "25.12.19"
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download a named GitHub release asset through its API and verify its digest."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def download(repository, tag, name, output, expected_sha256=None):
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*", repository):
|
||||
raise ValueError("invalid release repository")
|
||||
if not tag or not name or "/" in name or "\\" in name:
|
||||
raise ValueError("invalid release tag or asset name")
|
||||
if expected_sha256 is not None and not re.fullmatch(r"[0-9a-f]{64}", expected_sha256):
|
||||
raise ValueError("invalid pinned asset digest")
|
||||
result = subprocess.run(
|
||||
["gh", "api", "--hostname", "github.com", f"repos/{repository}/releases/tags/{quote(tag, safe='')}"],
|
||||
check=True, capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
release = json.loads(result.stdout)
|
||||
if not isinstance(release, dict) or release.get("tag_name") != tag:
|
||||
raise ValueError("release metadata does not match the requested tag")
|
||||
assets = release.get("assets")
|
||||
if not isinstance(assets, list) or any(not isinstance(asset, dict) for asset in assets):
|
||||
raise ValueError("invalid release asset inventory")
|
||||
matches = [asset for asset in assets if asset.get("name") == name]
|
||||
if len(matches) != 1:
|
||||
raise ValueError("release must contain exactly one named asset")
|
||||
asset = matches[0]
|
||||
asset_id, size, digest = asset.get("id"), asset.get("size"), asset.get("digest")
|
||||
if type(asset_id) is not int or asset_id <= 0 or type(size) is not int or not 0 < size <= 512 * 1024 * 1024:
|
||||
raise ValueError("invalid release asset identity or size")
|
||||
if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
|
||||
raise ValueError("release asset has no valid SHA-256 digest")
|
||||
sha256 = digest.removeprefix("sha256:")
|
||||
if expected_sha256 is not None and expected_sha256 != sha256:
|
||||
raise ValueError("release digest differs from the pinned digest")
|
||||
output = Path(output)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(dir=output.parent, prefix=".release-asset-", delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
# gh authenticates to the API; its HTTP client strips credentials on
|
||||
# cross-host redirects. No browser URL or token is supplied in argv.
|
||||
subprocess.run(
|
||||
["gh", "api", "--hostname", "github.com", "--header", "Accept: application/octet-stream", f"repos/{repository}/releases/assets/{asset_id}"],
|
||||
check=True, stdout=stream, timeout=300,
|
||||
)
|
||||
with temporary.open("rb") as stream:
|
||||
actual = hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
if temporary.stat().st_size != size or actual != sha256:
|
||||
raise ValueError("downloaded release asset failed size or SHA-256 verification")
|
||||
temporary.replace(output)
|
||||
print(f"Verified release asset: {repository} {tag} {name} sha256={actual}")
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", required=True)
|
||||
parser.add_argument("--tag", required=True)
|
||||
parser.add_argument("--asset", required=True)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--sha256")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
download(args.repository, args.tag, args.asset, args.output, args.sha256)
|
||||
except (ValueError, KeyError, TypeError, json.JSONDecodeError, subprocess.SubprocessError, OSError) as error:
|
||||
print(f"Release asset download failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+311
-5
@@ -814,13 +814,18 @@ jobs:
|
||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||
export AWS_DEFAULT_REGION="auto"
|
||||
|
||||
# Determine upload path based on build type
|
||||
# Determine upload path based on build type. Release and prerelease
|
||||
# builds only stage their bytes here: the official
|
||||
# artifacts/rustfs/release/ path is written solely by the
|
||||
# certification-gated promote-release-artifacts job, from these same
|
||||
# staged objects. Development builds keep the uncertified dev channel
|
||||
# and never touch official or latest channels.
|
||||
if [[ "$BUILD_TYPE" == "development" ]]; then
|
||||
R2_PATH="s3://${R2_BUCKET}/artifacts/rustfs/dev/"
|
||||
echo "📤 Uploading development build to R2 dev directory"
|
||||
else
|
||||
R2_PATH="s3://${R2_BUCKET}/artifacts/rustfs/release/"
|
||||
echo "📤 Uploading release build to R2 release directory"
|
||||
R2_PATH="s3://${R2_BUCKET}/artifacts/rustfs/staging/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/"
|
||||
echo "📤 Staging release build for certification-gated promotion"
|
||||
fi
|
||||
|
||||
# Upload all rustfs zip files to R2 using S3-compatible API
|
||||
@@ -869,6 +874,221 @@ jobs:
|
||||
echo "This run does not trigger Docker publishing."
|
||||
fi
|
||||
|
||||
# Aggregate the parent candidate batch. This job always collects results and
|
||||
# retains its evidence, including on failure, and every publication job
|
||||
# below requires its success. Missing, stale, substituted or non-PASS
|
||||
# evidence fails closed here: only the trusted verification sources
|
||||
# (rustfs/s3chaos release-gate envelopes, rustfs/backlog#2658) can satisfy
|
||||
# it, so a PR-uploaded JSON set never gains release authority.
|
||||
release-certification:
|
||||
name: Release Certification
|
||||
needs: [ build-check, build-rustfs ]
|
||||
if: ${{ !cancelled() && needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') }}
|
||||
runs-on: ubuntu-latest # gh CLI required; the sm-standard-2 fleet does not ship it
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: ./artifacts
|
||||
pattern: rustfs-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Write candidate and collect trusted validation results
|
||||
env:
|
||||
# Cross-repo read of the trusted verification run. Falls back to the
|
||||
# workflow token, which cannot read rustfs/s3chaos; a missing token
|
||||
# therefore fail-closes instead of certifying.
|
||||
GH_TOKEN: ${{ secrets.RELEASE_VALIDATION_TOKEN || github.token }}
|
||||
PRODUCT_COMMIT: ${{ github.sha }}
|
||||
VERSION: ${{ needs.build-check.outputs.version }}
|
||||
BUILD_TYPE: ${{ needs.build-check.outputs.build_type }}
|
||||
FEATURES: ${{ needs.build-check.outputs.build_type == 'development' && '' || 'pyroscope' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p results release-gate-output
|
||||
PLAN_SHA256=$(python3 -c "import hashlib,json,sys; sys.path.insert(0,'scripts/release'); import release_gate; print(release_gate.canonical_sha256(json.load(open('.config/release-validation-plan.json'))))")
|
||||
|
||||
python3 - "$PLAN_SHA256" > candidate.json <<'PY'
|
||||
import hashlib, json, os, sys, zipfile
|
||||
|
||||
plan_sha256 = sys.argv[1]
|
||||
profile = {"release": "stable", "prerelease": "prerelease", "preview": "preview"}.get(os.environ["BUILD_TYPE"])
|
||||
if profile is None:
|
||||
raise SystemExit("release certification only covers preview, prerelease and stable builds")
|
||||
|
||||
def sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
deliverables = []
|
||||
for name in sorted(os.listdir("artifacts")):
|
||||
if not name.startswith("rustfs-") or not name.endswith(".zip"):
|
||||
continue
|
||||
path = os.path.join("artifacts", name)
|
||||
members = zipfile.ZipFile(path).namelist()
|
||||
binary_name = "rustfs.exe" if "rustfs.exe" in members else "rustfs"
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
binary_bytes = archive.read(binary_name)
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile() as handle:
|
||||
handle.write(binary_bytes)
|
||||
handle.flush()
|
||||
binary_sha = sha256(handle.name)
|
||||
deliverables.append({
|
||||
"kind": "zip",
|
||||
"name": name,
|
||||
"location": "https://github.com/rustfs/rustfs/releases/download/{}/{}".format(os.environ["VERSION"], name),
|
||||
"sha256": sha256(path),
|
||||
"size": os.path.getsize(path),
|
||||
"binary_sha256": binary_sha,
|
||||
})
|
||||
if not deliverables:
|
||||
raise SystemExit("no built deliverables found to certify")
|
||||
candidate = {
|
||||
"schema_version": 1,
|
||||
"product_commit": os.environ["PRODUCT_COMMIT"],
|
||||
"version": os.environ["VERSION"],
|
||||
"release_profile": profile,
|
||||
"features": [f for f in os.environ.get("FEATURES", "").split(",") if f],
|
||||
"plan_sha256": plan_sha256,
|
||||
"validation_batch": {"run_id": os.environ["GITHUB_RUN_ID"], "run_attempt": int(os.environ["GITHUB_RUN_ATTEMPT"])},
|
||||
"deliverables": deliverables,
|
||||
}
|
||||
json.dump(candidate, sys.stdout, indent=2, sort_keys=True)
|
||||
print()
|
||||
PY
|
||||
|
||||
# Trusted verification evidence only: the release-gate envelopes of
|
||||
# rustfs/s3chaos for this exact product commit. Absence blocks below.
|
||||
TRUSTED_REPO="rustfs/s3chaos"
|
||||
TRUSTED_WORKFLOW="release-gate.yml"
|
||||
TRUSTED_RUN=$(gh api "repos/${TRUSTED_REPO}/actions/workflows/${TRUSTED_WORKFLOW}/runs?head_sha=${PRODUCT_COMMIT}&status=success&per_page=1" --jq '.workflow_runs[0].id // empty' || true)
|
||||
if [[ -z "$TRUSTED_RUN" ]]; then
|
||||
echo "❌ No trusted verification run of ${TRUSTED_REPO}/${TRUSTED_WORKFLOW} for ${PRODUCT_COMMIT}; refusing to certify" >&2
|
||||
else
|
||||
gh run download "$TRUSTED_RUN" -R "$TRUSTED_REPO" -n release-validation-evidence -D results || \
|
||||
echo "❌ Trusted verification run ${TRUSTED_RUN} published no release-validation-evidence artifact" >&2
|
||||
fi
|
||||
|
||||
python3 scripts/release/release_gate.py validate \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate candidate.json \
|
||||
--results results \
|
||||
--output release-gate-output
|
||||
|
||||
- name: Upload certification evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-certification
|
||||
path: |
|
||||
candidate.json
|
||||
results/
|
||||
release-gate-output/
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# The only writer of the official R2 release path. Promotion re-checks the
|
||||
# exact bytes about to be published against the passed certification list
|
||||
# and copies the staged objects byte-for-byte: no rebuild-after-verify.
|
||||
promote-release-artifacts:
|
||||
name: Promote Release Artifacts
|
||||
needs: [ build-check, build-rustfs, release-certification ]
|
||||
# Target-channel lock for the official R2 release path.
|
||||
concurrency:
|
||||
group: release-channel-r2-release
|
||||
cancel-in-progress: false
|
||||
if: needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
env:
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download build and certification artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: ./artifacts
|
||||
pattern: rustfs-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download certification evidence
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-certification
|
||||
path: ./certification
|
||||
|
||||
- name: Verify and promote staged release artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "$R2_ACCESS_KEY_ID" || -z "$R2_SECRET_ACCESS_KEY" || -z "$R2_ENDPOINT" || -z "$R2_BUCKET" ]]; then
|
||||
echo "❌ R2 credentials missing; refusing to promote to the official channel" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for zip_file in artifacts/rustfs-*.zip; do
|
||||
[[ -f "$zip_file" ]] || continue
|
||||
name="$(basename "$zip_file")"
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact "$zip_file" \
|
||||
--artifact-name "$name"
|
||||
done
|
||||
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y 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"
|
||||
|
||||
STAGING_PATH="s3://${R2_BUCKET}/artifacts/rustfs/staging/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/"
|
||||
RELEASE_PATH="s3://${R2_BUCKET}/artifacts/rustfs/release/"
|
||||
for zip_file in artifacts/rustfs-*.zip; do
|
||||
[[ -f "$zip_file" ]] || continue
|
||||
name="$(basename "$zip_file")"
|
||||
staged="$(mktemp)"
|
||||
aws s3 cp "${STAGING_PATH}${name}" "$staged" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
staged_sha="$(sha256sum "$staged" | awk '{print $1}')"
|
||||
built_sha="$(sha256sum "$zip_file" | awk '{print $1}')"
|
||||
if [[ "$staged_sha" != "$built_sha" ]]; then
|
||||
echo "❌ Staged object ${name} does not match the verified bytes" >&2
|
||||
exit 1
|
||||
fi
|
||||
aws s3 cp "$staged" "${RELEASE_PATH}${name}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
rm -f "$staged"
|
||||
echo "✅ Promoted ${name} (${built_sha})"
|
||||
done
|
||||
|
||||
# Create GitHub Release for every valid release tag, including previews
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
@@ -1028,6 +1248,10 @@ jobs:
|
||||
update-latest-version:
|
||||
name: Update Latest Version
|
||||
needs: [ build-check, publish-release ]
|
||||
# Target-channel lock for the latest.json version pointer.
|
||||
concurrency:
|
||||
group: release-channel-oss-latest
|
||||
cancel-in-progress: false
|
||||
if: needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
@@ -1068,6 +1292,45 @@ jobs:
|
||||
rm -rf "$OSSUTIL_DIR" "$OSSUTIL_ZIP"
|
||||
chmod +x "$OSSUTIL_BIN"
|
||||
|
||||
# Candidate/pointer ordering: an older candidate must never overwrite
|
||||
# a newer pointer, even if its promotion job finishes later.
|
||||
CURRENT_LATEST="${RUNNER_TEMP}/current-latest.json"
|
||||
if "$OSSUTIL_BIN" cp oss://rustfs-version/latest.json "$CURRENT_LATEST" 2>/dev/null; then
|
||||
python3 - "$CURRENT_LATEST" "$VERSION" <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
def key(version):
|
||||
match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$", version)
|
||||
if not match:
|
||||
raise SystemExit(3)
|
||||
core = tuple(int(part) for part in match.groups()[:3])
|
||||
prerelease = match.group(4)
|
||||
if prerelease is None:
|
||||
return core + (1,)
|
||||
return core + (0, tuple(int(p) if p.isdigit() else p for p in re.split(r"[.-]", prerelease)))
|
||||
|
||||
try:
|
||||
current = json.load(open(sys.argv[1], encoding="utf-8")).get("version", "")
|
||||
proposed = sys.argv[2]
|
||||
if current and key(current) > key(proposed):
|
||||
raise SystemExit(1)
|
||||
except SystemExit:
|
||||
raise
|
||||
except (OSError, ValueError, TypeError):
|
||||
raise SystemExit(3)
|
||||
PY
|
||||
guard_status=$?
|
||||
if [[ "$guard_status" -eq 1 ]]; then
|
||||
echo "Current latest.json points at a newer version; refusing to downgrade" >&2
|
||||
exit 0
|
||||
elif [[ "$guard_status" -ne 0 ]]; then
|
||||
echo "Existing latest.json is unparsable; refusing to overwrite it blindly" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create latest.json
|
||||
cat > latest.json << EOF
|
||||
{
|
||||
@@ -1084,16 +1347,59 @@ jobs:
|
||||
|
||||
echo "✅ Updated latest.json for ${RELEASE_TYPE} release $VERSION"
|
||||
|
||||
# Publish release (remove draft status)
|
||||
# Publish release (remove draft status). Requires the release-certification
|
||||
# aggregate: without a complete legal success there is no public write.
|
||||
publish-release:
|
||||
name: Publish Release
|
||||
needs: [ build-check, create-release, upload-release-assets ]
|
||||
needs: [ build-check, create-release, upload-release-assets, release-certification ]
|
||||
# Target-channel lock: concurrent releases serialize on this channel so an
|
||||
# older candidate cannot interleave its publish with a newer one.
|
||||
concurrency:
|
||||
group: release-channel-github-release
|
||||
cancel-in-progress: false
|
||||
if: needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||
runs-on: ubuntu-latest # gh CLI required; the sm-standard-2 fleet does not ship it
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download certification evidence
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-certification
|
||||
path: ./certification
|
||||
|
||||
- name: Verify release assets against certification
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ needs.build-check.outputs.version }}"
|
||||
mkdir -p "$RUNNER_TEMP/promotion-assets"
|
||||
gh release download "$TAG" --dir "$RUNNER_TEMP/promotion-assets" --pattern '*.zip'
|
||||
found=0
|
||||
for zip_file in "$RUNNER_TEMP"/promotion-assets/*.zip; do
|
||||
[[ -f "$zip_file" ]] || continue
|
||||
found=1
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact "$zip_file" \
|
||||
--artifact-name "$(basename "$zip_file")"
|
||||
done
|
||||
if [[ "$found" -ne 1 ]]; then
|
||||
echo "No release zip assets to verify against the certification" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -142,23 +142,15 @@ jobs:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pinned typos through GitHub API
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TYPOS_VERSION: 1.50.2
|
||||
run: |
|
||||
python3 .github/actions/setup/download_release_asset.py --repository crate-ci/typos --tag "v${TYPOS_VERSION}" --asset "typos-v${TYPOS_VERSION}-x86_64-unknown-linux-musl.tar.gz" --output "$RUNNER_TEMP/typos.tar.gz"
|
||||
mkdir -p "$RUNNER_TEMP/typos"
|
||||
tar -xzf "$RUNNER_TEMP/typos.tar.gz" -C "$RUNNER_TEMP/typos"
|
||||
- name: Typos check with custom config file
|
||||
run: '"$RUNNER_TEMP/typos/typos" .'
|
||||
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
|
||||
|
||||
# Fail early with compile-free checks for every pull request.
|
||||
quick-checks:
|
||||
name: Quick Checks
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
@@ -1145,16 +1137,11 @@ jobs:
|
||||
|
||||
- name: Install mc
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MC_VERSION: RELEASE.2025-08-13T08-35-41Z
|
||||
MC_SHA256: 01f866e9c5f9b87c2b09116fa5d7c06695b106242d829a8bb32990c00312e891
|
||||
run: |
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gh
|
||||
fi
|
||||
MC_BINARY="mc.linux-amd64.${MC_VERSION}"
|
||||
python3 .github/actions/setup/download_release_asset.py --repository minio/mc --tag "$MC_VERSION" --asset "$MC_BINARY" --output "$RUNNER_TEMP/mc" --sha256 "$MC_SHA256"
|
||||
curl -fsSLo "$RUNNER_TEMP/mc" "https://github.com/minio/mc/releases/download/${MC_VERSION}/${MC_BINARY}"
|
||||
echo "${MC_SHA256} $RUNNER_TEMP/mc" | sha256sum --check --status
|
||||
chmod +x "$RUNNER_TEMP/mc"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
@@ -329,13 +329,75 @@ jobs:
|
||||
echo " - Create latest: $create_latest"
|
||||
echo " - Source ref: $source_ref"
|
||||
|
||||
# Consume the trusted release certification for the exact version this
|
||||
# workflow wraps and verify the certified input digests. A manual dispatch
|
||||
# without that certification fails here and pushes no official tag; only
|
||||
# results from the trusted verification sources carry release authority.
|
||||
release-certification:
|
||||
name: Release Certification
|
||||
needs: build-check
|
||||
if: needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.build-check.outputs.source_ref }}
|
||||
|
||||
- name: Download and verify release certification
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_VALIDATION_TOKEN || github.token }}
|
||||
TRIGGERING_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
VERSION: ${{ needs.build-check.outputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p certification
|
||||
run_id="$TRIGGERING_RUN_ID"
|
||||
if [[ -z "$run_id" ]]; then
|
||||
run_id="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f branch="$VERSION" -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' || true)"
|
||||
fi
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "No certified Build and Release run found for ${VERSION}; failing closed" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! gh run download "$run_id" -n release-certification -D certification; then
|
||||
echo "Build run ${run_id} published no release-certification artifact; failing closed" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact-sha256 "$(jq -r '.deliverables[0].sha256' certification/candidate.json)" \
|
||||
--artifact-name "$(jq -r '.deliverables[0].name' certification/candidate.json)"
|
||||
|
||||
# Input digest binding: every zip this image wraps must be the
|
||||
# certified byte-for-byte content, never a mutable latest pointer.
|
||||
while IFS=$'\t' read -r name location expected; do
|
||||
echo "Verifying certified input ${name}"
|
||||
actual="$(curl -fsSL "$location" | sha256sum | awk '{print $1}')"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "Certified input ${name} does not match its recorded digest" >&2
|
||||
exit 1
|
||||
fi
|
||||
done < <(jq -r '.deliverables[] | select(.name | contains("linux")) | [.name, .location, .sha256] | @tsv' certification/candidate.json)
|
||||
|
||||
# Build multi-arch Docker images
|
||||
# Strategy: Build images using pre-built binaries from dl.rustfs.com
|
||||
# Supports both release and dev channel binaries based on build context
|
||||
# Only runs when should_build is true (which includes workflow success check)
|
||||
build-docker:
|
||||
name: Build Docker Images
|
||||
needs: build-check
|
||||
needs: [ build-check, release-certification ]
|
||||
if: needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
@@ -443,6 +505,14 @@ jobs:
|
||||
# Output tags
|
||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Staging identity for this run's promotion: official tags are only
|
||||
# created after the staged image verifies against the certification,
|
||||
# and they point at this same digest (no rebuild-after-verify).
|
||||
STAGING_TAG="staging-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}${VARIANT_SUFFIX}"
|
||||
STAGING_TAGS="${{ env.REGISTRY_DOCKERHUB }}:${STAGING_TAG},${{ env.REGISTRY_GHCR }}:${STAGING_TAG},${{ env.REGISTRY_QUAY }}:${STAGING_TAG}"
|
||||
echo "staging_tags=$STAGING_TAGS" >> "$GITHUB_OUTPUT"
|
||||
echo "staging_tag=$STAGING_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Generate labels
|
||||
LABELS="org.opencontainers.image.title=RustFS"
|
||||
LABELS="$LABELS,org.opencontainers.image.description=RustFS distributed object storage system"
|
||||
@@ -467,7 +537,9 @@ jobs:
|
||||
file: ${{ matrix.file }}
|
||||
platforms: ${{ env.DOCKER_PLATFORMS }}
|
||||
push: ${{ needs.build-check.outputs.should_push == 'true' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
# Push to staging tags only: official tags are created by the
|
||||
# certification-gated promotion step below from this same digest.
|
||||
tags: ${{ steps.meta.outputs.staging_tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# No layer cache. This build compiles nothing — it downloads a
|
||||
# release zip and runs apk/apt — so the cache could only save the
|
||||
@@ -493,6 +565,114 @@ jobs:
|
||||
no-cache: false
|
||||
pull: true
|
||||
|
||||
# A later-built wrapper: the staged image must complete this platform's
|
||||
# own binary identity verification before any official tag is written.
|
||||
# It may not inherit an unverified package's conclusion.
|
||||
- name: Verify staged image against certification
|
||||
if: needs.build-check.outputs.should_push == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_VALIDATION_TOKEN || github.token }}
|
||||
TRIGGERING_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
VERSION: ${{ needs.build-check.outputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p certification
|
||||
run_id="$TRIGGERING_RUN_ID"
|
||||
if [[ -z "$run_id" ]]; then
|
||||
run_id="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f branch="$VERSION" -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' || true)"
|
||||
fi
|
||||
gh run download "$run_id" -n release-certification -D certification
|
||||
|
||||
STAGING_REF="${{ env.REGISTRY_DOCKERHUB }}:staging-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}${{ matrix.suffix }}"
|
||||
case "${{ matrix.variant }}" in
|
||||
musl) libc="musl" ;;
|
||||
glibc) libc="gnu" ;;
|
||||
esac
|
||||
for platform in linux/amd64 linux/arm64; do
|
||||
case "$platform" in
|
||||
linux/amd64) arch_sub="x86_64" ;;
|
||||
linux/arm64) arch_sub="aarch64" ;;
|
||||
esac
|
||||
docker pull --platform "$platform" "$STAGING_REF"
|
||||
container="$(docker create --platform "$platform" "$STAGING_REF")"
|
||||
docker cp "$container:/usr/bin/rustfs" "$RUNNER_TEMP/staged-rustfs"
|
||||
docker rm "$container" >/dev/null
|
||||
actual="$(sha256sum "$RUNNER_TEMP/staged-rustfs" | awk '{print $1}')"
|
||||
expected="$(jq -r --arg needle "linux-${arch_sub}-${libc}" \
|
||||
'[.deliverables[] | select(.name | contains($needle)) | .binary_sha256][0] // empty' \
|
||||
certification/candidate.json)"
|
||||
if [[ -z "$expected" || "$actual" != "$expected" ]]; then
|
||||
echo "Staged image binary for ${platform} does not match the certified binary digest" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact "$RUNNER_TEMP/staged-rustfs" \
|
||||
--artifact-role binary
|
||||
done
|
||||
|
||||
- name: Promote staged image to official tags
|
||||
if: needs.build-check.outputs.should_push == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STAGING_REF="${{ env.REGISTRY_DOCKERHUB }}:staging-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}${{ matrix.suffix }}"
|
||||
IFS=',' read -r -a official_tags <<< "${{ steps.meta.outputs.tags }}"
|
||||
for tag in "${official_tags[@]}"; do
|
||||
docker buildx imagetools create -t "$tag" "$STAGING_REF"
|
||||
echo "Promoted $tag -> $STAGING_REF"
|
||||
done
|
||||
|
||||
# Downstream consumers (Helm) verify this record instead of inheriting
|
||||
# an unverified wrapper's conclusion.
|
||||
- name: Record image promotion
|
||||
if: needs.build-check.outputs.should_push == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_VALIDATION_TOKEN || github.token }}
|
||||
TRIGGERING_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
VERSION: ${{ needs.build-check.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p certification
|
||||
run_id="$TRIGGERING_RUN_ID"
|
||||
if [[ -z "$run_id" ]]; then
|
||||
run_id="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f branch="$VERSION" -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' || true)"
|
||||
fi
|
||||
gh run download "$run_id" -n release-certification -D certification
|
||||
STAGING_REF="${{ env.REGISTRY_DOCKERHUB }}:staging-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}${{ matrix.suffix }}"
|
||||
digest="$(docker buildx imagetools inspect "$STAGING_REF" --format '{{json .Manifest}}' | jq -r '.digest')"
|
||||
candidate_sha="$(python3 -c "import json,hashlib,sys; sys.path.insert(0,'scripts/release'); import release_gate; print(release_gate.canonical_sha256(json.load(open('certification/candidate.json'))))")"
|
||||
jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg digest "$digest" \
|
||||
--arg staging_ref "$STAGING_REF" \
|
||||
--arg candidate_sha256 "$candidate_sha" \
|
||||
--arg tags "${{ steps.meta.outputs.tags }}" \
|
||||
--slurpfile candidate certification/candidate.json \
|
||||
'{schema_version: 1, version: $version, image_digest: $digest, staging_ref: $staging_ref,
|
||||
promoted_tags: ($tags | split(",")), candidate_sha256: $candidate_sha256,
|
||||
binary_sha256s: ($candidate[0].deliverables | map(.binary_sha256) | unique)}' \
|
||||
> image-promotion-record.json
|
||||
|
||||
- name: Upload image promotion record
|
||||
if: needs.build-check.outputs.should_push == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: image-promotion-record${{ matrix.suffix }}
|
||||
path: image-promotion-record.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
# Note: Manifest creation is no longer needed as we only build one variant
|
||||
# Multi-arch manifests are automatically created by docker/build-push-action
|
||||
|
||||
|
||||
@@ -30,9 +30,102 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Consume the same trusted release certification as every other publication
|
||||
# exit and verify the image promotion record of the Docker workflow. The
|
||||
# chart wraps no binary of its own, so it may only reference an image whose
|
||||
# platform verification already passed; it must not inherit an unverified
|
||||
# package's conclusion. A run without that evidence fails closed here and
|
||||
# never reaches the chart repository.
|
||||
release-certification:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.raw_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Normalize release version
|
||||
id: version
|
||||
env:
|
||||
RAW_INPUT: ${{ github.event.inputs.version }}
|
||||
RAW_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
RAW="$RAW_INPUT"
|
||||
else
|
||||
RAW="$RAW_BRANCH"
|
||||
fi
|
||||
case "$RAW" in
|
||||
refs/tags/*) RAW_TAG="${RAW#refs/tags/}" ;;
|
||||
*) RAW_TAG="$RAW" ;;
|
||||
esac
|
||||
echo "raw_tag=$RAW_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download and verify release certification and image record
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_VALIDATION_TOKEN || github.token }}
|
||||
TRIGGERING_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RAW_TAG: ${{ steps.version.outputs.raw_tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p certification image-record
|
||||
|
||||
build_run="$TRIGGERING_RUN_ID"
|
||||
if [[ -z "$build_run" ]]; then
|
||||
build_run="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f branch="$RAW_TAG" -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' || true)"
|
||||
fi
|
||||
if [[ -z "$build_run" ]]; then
|
||||
echo "No certified Build and Release run found for ${RAW_TAG}; failing closed" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! gh run download "$build_run" -n release-certification -D certification; then
|
||||
echo "Build run ${build_run} published no release-certification artifact; failing closed" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact-sha256 "$(jq -r '.deliverables[0].sha256' certification/candidate.json)" \
|
||||
--artifact-name "$(jq -r '.deliverables[0].name' certification/candidate.json)"
|
||||
|
||||
docker_run="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/workflows/docker.yml/runs" \
|
||||
-f branch="$RAW_TAG" -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' || true)"
|
||||
if [[ -z "$docker_run" ]] || ! gh run download "$docker_run" -n image-promotion-record -D image-record; then
|
||||
echo "No image promotion record for ${RAW_TAG}; a chart may not wrap an unverified image" >&2
|
||||
exit 1
|
||||
fi
|
||||
candidate_sha="$(python3 -c "import json,hashlib,sys; sys.path.insert(0,'scripts/release'); import release_gate; print(release_gate.canonical_sha256(json.load(open('certification/candidate.json'))))")"
|
||||
record_sha="$(jq -r '.candidate_sha256 // empty' image-record/image-promotion-record.json)"
|
||||
if [[ "$record_sha" != "$candidate_sha" ]]; then
|
||||
echo "Image promotion record does not belong to this certification" >&2
|
||||
exit 1
|
||||
fi
|
||||
overlap="$(jq -r --slurpfile candidate certification/candidate.json \
|
||||
'[.binary_sha256s[]] - ($candidate[0].deliverables | map(.binary_sha256)) | length' \
|
||||
image-record/image-promotion-record.json)"
|
||||
if [[ "$overlap" -ne 0 ]]; then
|
||||
echo "Image promotion record carries binary digests outside the certification" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-helm-package:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [ release-certification ]
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
|
||||
(
|
||||
|
||||
+210
-14
@@ -225,10 +225,57 @@ jobs:
|
||||
echo " Build run ID: $BUILD_RUN_ID"
|
||||
echo " Build run number: $RUN_NUMBER"
|
||||
|
||||
# Consume the trusted release certification of the resolved build run and
|
||||
# verify the certified input digests before any package is wrapped. A run
|
||||
# without that certification fails closed here; packages built later must
|
||||
# complete their own verification and cannot inherit an unverified
|
||||
# conclusion.
|
||||
release-certification:
|
||||
name: Release Certification
|
||||
needs: resolve
|
||||
if: needs.resolve.result == 'success'
|
||||
runs-on: ubuntu-latest # gh CLI required; the sm-standard-2 fleet does not ship it
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download and verify release certification
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_VALIDATION_TOKEN || github.token }}
|
||||
BUILD_RUN_ID: ${{ needs.resolve.outputs.build_run_id }}
|
||||
BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p certification
|
||||
if [[ "$BUILD_TYPE" == "development" ]]; then
|
||||
# Development artifacts are explicitly uncertified and never touch
|
||||
# official or latest channels; only the staging/dev paths remain.
|
||||
jq -n '{schema_version: 1, certified: false, reason: "development artifacts are explicitly uncertified"}' \
|
||||
> certification/development-uncertified.json
|
||||
exit 0
|
||||
fi
|
||||
if ! gh run download "$BUILD_RUN_ID" -n release-certification -D certification; then
|
||||
echo "Build run ${BUILD_RUN_ID} published no release-certification artifact; failing closed" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact-sha256 "$(jq -r '.deliverables[0].sha256' certification/candidate.json)" \
|
||||
--artifact-name "$(jq -r '.deliverables[0].name' certification/candidate.json)"
|
||||
|
||||
# Build DEB and RPM packages for each architecture and libc variant
|
||||
package:
|
||||
name: Package (${{ matrix.arch }}-${{ matrix.libc }})
|
||||
needs: resolve
|
||||
needs: [ resolve, release-certification ]
|
||||
runs-on: ubuntu-latest # gh CLI required; the sm-standard-2 fleet does not ship it
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
@@ -494,6 +541,55 @@ jobs:
|
||||
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ RPM built: $RPM_FILE"
|
||||
|
||||
- name: Download certification evidence
|
||||
if: needs.resolve.outputs.build_type != 'development'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-certification
|
||||
path: ./certification
|
||||
|
||||
# Later-built wrapper verification: the binary inside each package must
|
||||
# match the certified binary digest for this platform before anything is
|
||||
# uploaded, and the package itself stays on staging until promotion.
|
||||
- name: Verify wrapped binary against certification
|
||||
if: needs.resolve.outputs.build_type != 'development'
|
||||
shell: bash
|
||||
env:
|
||||
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
|
||||
ARTIFACT_NAME: ${{ matrix.artifact_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected="$(jq -r --arg needle "$ARTIFACT_NAME" \
|
||||
'[.deliverables[] | select(.name | contains($needle)) | .binary_sha256][0] // empty' \
|
||||
certification/candidate.json)"
|
||||
if [[ -z "$expected" ]]; then
|
||||
echo "No certified binary digest recorded for ${ARTIFACT_NAME}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v rpm2cpio >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y rpm2cpio
|
||||
fi
|
||||
if ! command -v cpio >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y cpio
|
||||
fi
|
||||
for package_file in "$DEB_FILE" "$RPM_FILE"; do
|
||||
case "$package_file" in
|
||||
*.deb)
|
||||
dpkg-deb --fsys-tarfile "$package_file" | tar -xOf - ./usr/bin/rustfs > "$RUNNER_TEMP/wrapped-rustfs"
|
||||
;;
|
||||
*.rpm)
|
||||
rpm2cpio "$package_file" | cpio -i --to-stdout ./usr/bin/rustfs > "$RUNNER_TEMP/wrapped-rustfs"
|
||||
;;
|
||||
esac
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact "$RUNNER_TEMP/wrapped-rustfs" \
|
||||
--artifact-role binary
|
||||
done
|
||||
|
||||
- name: Upload packages to artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -531,10 +627,14 @@ jobs:
|
||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||
export AWS_DEFAULT_REGION="auto"
|
||||
|
||||
# Build-phase writes stay on staging for release types: the official
|
||||
# packages/release and packages/latest paths are written only by the
|
||||
# certification-gated promote-packages job, from these same staged
|
||||
# objects. Development packages keep the uncertified dev channel.
|
||||
if [[ "$BUILD_TYPE" == "development" ]]; then
|
||||
R2_PREFIX="artifacts/rustfs/packages/dev"
|
||||
else
|
||||
R2_PREFIX="artifacts/rustfs/packages/release"
|
||||
R2_PREFIX="artifacts/rustfs/packages/staging/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}"
|
||||
fi
|
||||
R2_PATH="s3://${R2_BUCKET}/${R2_PREFIX}/"
|
||||
|
||||
@@ -549,18 +649,6 @@ jobs:
|
||||
|
||||
echo "✅ Upload complete"
|
||||
|
||||
# Also upload as latest for release/prerelease
|
||||
if [[ "$BUILD_TYPE" == "release" || "$BUILD_TYPE" == "prerelease" ]]; then
|
||||
LATEST_PATH="s3://${R2_BUCKET}/artifacts/rustfs/packages/latest/"
|
||||
for f in "$DEB_FILE" "$RPM_FILE"; do
|
||||
if [[ -n "$f" && -f "$f" ]]; then
|
||||
echo "Uploading latest: $(basename "$f")"
|
||||
aws s3 cp "$f" "$LATEST_PATH" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
fi
|
||||
done
|
||||
echo "✅ Latest packages updated"
|
||||
fi
|
||||
|
||||
- name: Upload packages to GitHub Release
|
||||
if: needs.resolve.outputs.tag != ''
|
||||
env:
|
||||
@@ -614,6 +702,114 @@ jobs:
|
||||
|
||||
echo "✅ GitHub release assets updated"
|
||||
|
||||
# The only writer of the official packages/release and packages/latest R2
|
||||
# paths. Promotion re-checks each package's wrapped binary against the
|
||||
# passed certification list and copies the staged objects byte-for-byte:
|
||||
# no rebuild-after-verify, no mutable latest resolution downstream.
|
||||
promote-packages:
|
||||
name: Promote Packages
|
||||
needs: [ resolve, package, release-certification ]
|
||||
# Target-channel lock for the official package paths.
|
||||
concurrency:
|
||||
group: release-channel-r2-packages
|
||||
cancel-in-progress: false
|
||||
if: needs.resolve.outputs.tag != '' && (needs.resolve.outputs.build_type == 'release' || needs.resolve.outputs.build_type == 'prerelease')
|
||||
runs-on: ubuntu-latest # gh CLI required; the sm-standard-2 fleet does not ship it
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
env:
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download packages and certification
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: ./packages
|
||||
pattern: packages-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download certification evidence
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-certification
|
||||
path: ./certification
|
||||
|
||||
- name: Verify and promote staged packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "$R2_ACCESS_KEY_ID" || -z "$R2_SECRET_ACCESS_KEY" || -z "$R2_ENDPOINT" || -z "$R2_BUCKET" ]]; then
|
||||
echo "❌ R2 credentials missing; refusing to promote to the official channel" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STAGING_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/staging/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/"
|
||||
RELEASE_PATH="s3://${R2_BUCKET}/artifacts/rustfs/packages/release/"
|
||||
LATEST_PATH="s3://${R2_BUCKET}/artifacts/rustfs/packages/latest/"
|
||||
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y 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"
|
||||
|
||||
if ! command -v rpm2cpio >/dev/null 2>&1; then
|
||||
sudo apt-get install -y rpm2cpio
|
||||
fi
|
||||
if ! command -v cpio >/dev/null 2>&1; then
|
||||
sudo apt-get install -y cpio
|
||||
fi
|
||||
|
||||
promoted=0
|
||||
for package_file in packages/*.deb packages/*.rpm; do
|
||||
[[ -f "$package_file" ]] || continue
|
||||
name="$(basename "$package_file")"
|
||||
case "$package_file" in
|
||||
*.deb)
|
||||
dpkg-deb --fsys-tarfile "$package_file" | tar -xOf - ./usr/bin/rustfs > "$RUNNER_TEMP/wrapped-rustfs"
|
||||
;;
|
||||
*.rpm)
|
||||
rpm2cpio "$package_file" | cpio -i --to-stdout ./usr/bin/rustfs > "$RUNNER_TEMP/wrapped-rustfs"
|
||||
;;
|
||||
esac
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact "$RUNNER_TEMP/wrapped-rustfs" \
|
||||
--artifact-role binary
|
||||
|
||||
staged="$(mktemp)"
|
||||
aws s3 cp "${STAGING_PREFIX}${name}" "$staged" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
staged_sha="$(sha256sum "$staged" | awk '{print $1}')"
|
||||
built_sha="$(sha256sum "$package_file" | awk '{print $1}')"
|
||||
if [[ "$staged_sha" != "$built_sha" ]]; then
|
||||
echo "❌ Staged package ${name} does not match the verified bytes" >&2
|
||||
exit 1
|
||||
fi
|
||||
aws s3 cp "$staged" "${RELEASE_PATH}${name}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
aws s3 cp "$staged" "${LATEST_PATH}${name}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
rm -f "$staged"
|
||||
promoted=$((promoted + 1))
|
||||
echo "✅ Promoted ${name} (${built_sha})"
|
||||
done
|
||||
if [[ "$promoted" -eq 0 ]]; then
|
||||
echo "No staged packages found to promote" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Summary
|
||||
summary:
|
||||
name: Summary
|
||||
|
||||
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Notifications and audit
|
||||
|
||||
- Notify and audit targets that keep the default `queue_dir` now work on native Windows: the defaults `/opt/rustfs/events` and `/opt/rustfs/audit` resolve per node, to `%ProgramData%\RustFS\events` and `%ProgramData%\RustFS\audit` on Windows, instead of failing validation as non-absolute paths. The new `RUSTFS_TARGET_STORE_DIR` (Helm `config.rustfs.target_store_dir`) relocates both default queues on any platform. Linux and macOS behavior is unchanged when it is unset, persisted configuration is unchanged, and pending entries are not migrated when the root changes. See `docs/operations/target-queue-storage.md`.
|
||||
|
||||
### Deployment
|
||||
|
||||
- Existing volumes created by older root-running container images must be writable by the non-root runtime identity before upgrading. The default container UID/GID is `10001:10001`; stop the affected instances, verify host mount paths, and for ordinary bind mounts run `sudo chown -R 10001:10001 /actual/data /actual/logs` on each RustFS data/log mount. Account for ACLs, NFS, Kubernetes, and rootless UID mappings. Image-build ownership changes do not migrate mounted data. See the Docker Quick Start in [README.md](README.md).
|
||||
|
||||
Generated
+52
-56
@@ -4136,7 +4136,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "e2e_test"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-compression",
|
||||
@@ -9520,7 +9520,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -9680,7 +9680,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-audit"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"const-str",
|
||||
"futures",
|
||||
@@ -9702,7 +9702,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-checksums"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -9718,7 +9718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-common"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"hotpath",
|
||||
@@ -9733,7 +9733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-concurrency"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"insta",
|
||||
@@ -9746,7 +9746,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-config"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"const-str",
|
||||
"hotpath",
|
||||
@@ -9756,7 +9756,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-connect-symbol-catalog"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"hex-simd",
|
||||
"object 0.40.0",
|
||||
@@ -9768,7 +9768,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-credentials"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"hmac 0.13.0",
|
||||
@@ -9782,7 +9782,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-crypto"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -9803,7 +9803,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-data-usage"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rmp-serde",
|
||||
@@ -9813,7 +9813,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-ecstore"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-channel",
|
||||
@@ -9837,7 +9837,6 @@ dependencies = [
|
||||
"google-cloud-auth",
|
||||
"google-cloud-storage",
|
||||
"h2",
|
||||
"hashbrown 0.17.1",
|
||||
"hex-simd",
|
||||
"hmac 0.13.0",
|
||||
"hostname",
|
||||
@@ -9951,7 +9950,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-extension-schema"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"serde",
|
||||
@@ -9961,7 +9960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-filemeta"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"base64-simd",
|
||||
@@ -9990,7 +9989,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-heal"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64-simd",
|
||||
@@ -10031,7 +10030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-heal-contracts"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -10041,7 +10040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-iam"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -10091,7 +10090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-io-core"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hotpath",
|
||||
@@ -10104,7 +10103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-io-metrics"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"hotpath",
|
||||
@@ -10168,7 +10167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-keystone"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -10194,7 +10193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-kms"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -10244,14 +10243,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-license"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-lifecycle"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hotpath",
|
||||
@@ -10273,7 +10272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-lock"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"compact_str",
|
||||
@@ -10297,7 +10296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-log-analyzer"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"flate2",
|
||||
@@ -10316,7 +10315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-madmin"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
@@ -10354,7 +10353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-notify"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -10389,7 +10388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-object-capacity"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"futures",
|
||||
@@ -10408,7 +10407,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-object-data-cache"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"criterion",
|
||||
@@ -10425,7 +10424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-obs"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crossbeam-channel",
|
||||
@@ -10488,7 +10487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-policy"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64-simd",
|
||||
@@ -10519,7 +10518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-protocols"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"async-trait",
|
||||
@@ -10582,7 +10581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-protos"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"flatbuffers",
|
||||
"hotpath",
|
||||
@@ -10607,7 +10606,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-replication"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -10625,7 +10624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-rio"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"arc-swap",
|
||||
@@ -10671,7 +10670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-rio-v2"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -10694,7 +10693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3-client"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -10738,7 +10737,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3-ops"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rustfs-s3-types",
|
||||
@@ -10746,7 +10745,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3-types"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"serde",
|
||||
@@ -10755,25 +10754,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3select-api"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-compression",
|
||||
"async-trait",
|
||||
"base64-simd",
|
||||
"brotli 9.1.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc-fast",
|
||||
"datafusion",
|
||||
"flatbuffers",
|
||||
"flate2",
|
||||
"futures",
|
||||
"futures-core",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"lz4",
|
||||
"lz4_flex",
|
||||
"memchr",
|
||||
"metrics",
|
||||
"parking_lot 0.13.0",
|
||||
@@ -10796,7 +10792,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-s3select-query"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -10818,7 +10814,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-scanner"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -10869,7 +10865,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-scanner-metrics"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"jiff",
|
||||
@@ -10884,7 +10880,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-security-governance"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"thiserror 2.0.21",
|
||||
@@ -10892,7 +10888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-signer"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -10911,7 +10907,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-storage-api"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hotpath",
|
||||
@@ -10926,7 +10922,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-targets"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-nats",
|
||||
@@ -10980,7 +10976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-test-utils"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rustfs-data-usage",
|
||||
@@ -10996,7 +10992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-tls-runtime"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"hotpath",
|
||||
@@ -11035,7 +11031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-trusted-proxies"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -11072,7 +11068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-utils"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64-simd",
|
||||
@@ -11115,7 +11111,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-zip"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"futures",
|
||||
|
||||
+51
-52
@@ -75,7 +75,7 @@ edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.99.0"
|
||||
version = "1.0.5"
|
||||
version = "1.0.2"
|
||||
homepage = "https://rustfs.com"
|
||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||
@@ -92,56 +92,56 @@ redundant_clone = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.5" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.5" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.5" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.5" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.5" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.5" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.5" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.5" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.5" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.5" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.5" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.5" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.5" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.5" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.5" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.5" }
|
||||
rustfs-license = { path = "crates/license", version = "1.0.5" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.5" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.5" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.5" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.5" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.5" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.5" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.5" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.5" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.5", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.5" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.5" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.5" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.5" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.5" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.5" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.5" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.5" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.5" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.5" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.5" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.5" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.5" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.5" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.5" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.5" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.5" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.5" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.5" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.5" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.5" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.5" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.5" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.5" }
|
||||
rustfs = { path = "./rustfs", version = "1.0.2" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.2" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.2" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.2" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.2" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.2" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.2" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.2" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.2" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.2" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.2" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.2" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.2" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.2" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.2" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.2" }
|
||||
rustfs-license = { path = "crates/license", version = "1.0.2" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.2" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.2" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.2" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.2" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.2" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.2" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.2" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.2" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.2", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.2" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.2" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.2" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.2" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.2" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.2" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.2" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.2" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.2" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.2" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.2" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.2" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.2" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.2" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.2" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.2" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.2" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.2" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.2" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.2" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.2" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.2" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.2" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.2" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
@@ -296,7 +296,6 @@ local-ip-address = "0.6.13"
|
||||
log = "0.4"
|
||||
memmap2 = "0.9.12"
|
||||
lz4 = "1.28.1"
|
||||
lz4_flex = { version = "=0.14.0", default-features = false, features = ["std", "frame"] }
|
||||
matchit = "0.9.2"
|
||||
md-5 = "0.11.0"
|
||||
mime_guess = "2.0.5"
|
||||
|
||||
@@ -199,7 +199,7 @@ docker run -d --name rustfs -p 9000:9000 \
|
||||
Notes:
|
||||
- `RUSTFS_NOTIFY_ENABLE=true` enables the global notify module switch.
|
||||
- For ARN `arn:rustfs:sqs::primary:webhook`, use instance-scoped env vars with `_PRIMARY`.
|
||||
- If queue dir is omitted, the official image maps `/opt/rustfs/events` into the persistent `/data` volume so it is writable by the runtime user and pending events survive container recreation. Override `queue_dir` when using another deployment layout.
|
||||
- If queue dir is omitted, the official image maps `/opt/rustfs/events` into the persistent `/data` volume so it is writable by the runtime user and pending events survive container recreation. Override `queue_dir` when using another deployment layout, or set `RUSTFS_TARGET_STORE_DIR` to move every default queue; native Windows defaults to `%ProgramData%\RustFS`. See [Target queue storage](docs/operations/target-queue-storage.md).
|
||||
- `RUSTFS_NOTIFY_WEBHOOK_SKIP_TLS_VERIFY_PRIMARY` defaults to `false`; enabling it skips webhook TLS certificate verification, allows MITM attacks, and emits a startup warning. Prefer `RUSTFS_NOTIFY_WEBHOOK_CLIENT_CA_PRIMARY` for private CAs.
|
||||
- Since `1.0.0-beta.11`, webhook endpoints on private or container networks
|
||||
(`Docker Compose service names`, `host.docker.internal`, RFC 1918 addresses) are
|
||||
|
||||
@@ -276,6 +276,13 @@ Drive timeout profile preset:
|
||||
- values must be positive; `0` or an invalid value falls back to the default, and values above `60` are clamped to `60`.
|
||||
- the setting is read by the aggregating node only; it does not change the internode RPC wire contract. Any retry shares one round deadline rather than receiving a fresh timeout.
|
||||
|
||||
## Target queue directory
|
||||
|
||||
- `RUSTFS_TARGET_STORE_DIR`
|
||||
- absolute root for notify and audit target queues whose `queue_dir` is the default `/opt/rustfs/events` or `/opt/rustfs/audit`; they open `<root>/events` and `<root>/audit` on this node.
|
||||
- unset keeps those paths on Linux and macOS; native Windows uses `%ProgramData%\RustFS\events` and `%ProgramData%\RustFS\audit`.
|
||||
- explicit `queue_dir` values are never relocated, and the persisted target configuration is unchanged. Changing the root does not migrate pending entries. See `docs/operations/target-queue-storage.md`.
|
||||
|
||||
## Startup filesystem boundary policy
|
||||
|
||||
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
|
||||
|
||||
@@ -184,27 +184,15 @@ const _: () = assert!(!DEFAULT_INTERNODE_RPC_REPLAY_SCOPE_STRICT);
|
||||
/// Capacity (distinct nonces) of the process-local internode RPC replay cache that enforces
|
||||
/// one-time consumption of authenticated RPC signatures.
|
||||
///
|
||||
/// Nonces remain protected until their signed timestamp is outside the observed wall-clock
|
||||
/// validity window, including after clock rollback. Current-time signatures live about five
|
||||
/// minutes; the maximum accepted forward clock skew can extend that to ten minutes. A body-bound
|
||||
/// v3 request consumes both its v3 and compatibility-v2 nonces, so size for receiver nonce intake,
|
||||
/// not S3 request rate or CPU count.
|
||||
///
|
||||
/// Automatic admission budgets 13% of effective process memory for signed-time
|
||||
/// hash tables, allocator overhead and simultaneous old/new tables during growth.
|
||||
/// The reported count is an absolute UUID payload ceiling, not guaranteed throughput:
|
||||
/// actual byte admission can reject earlier depending on timestamp distribution.
|
||||
/// Tables grow with demand and are released together when their signed time expires.
|
||||
/// CPU count, a historical minimum, and a fixed 32M ceiling do not constrain this budget.
|
||||
/// If memory detection fails, the historical default remains a bounded count fallback.
|
||||
///
|
||||
/// Explicit values remain count ceilings with the historical minimum of 1,048,576
|
||||
/// for configuration compatibility, and bypass automatic byte admission. That
|
||||
/// compatibility floor does not apply to the automatically detected memory budget.
|
||||
/// Zero never disables replay protection.
|
||||
/// Overflow rejects the request without evicting a still-valid identity, increments
|
||||
/// `rustfs_system_network_internode_replay_cache_overflow_total`, and returns resource
|
||||
/// exhaustion rather than invalid credentials. Measure headroom before overriding.
|
||||
/// The cache retains each nonce for the ~10-minute signature freshness envelope. Once peers use
|
||||
/// replay-scoped v3 authentication, every authenticated RPC consumes one entry, so the steady
|
||||
/// state holds roughly `authenticated RPC RPS x 601s` entries. This default is the minimum floor:
|
||||
/// explicit operator values and resource-aware auto sizing both clamp upward to at least this
|
||||
/// value. Overflow fails closed — legitimate signed traffic is the only thing that can fill the
|
||||
/// cache (replays are rejected before insertion, and an attacker cannot mint valid nonces without
|
||||
/// the shared secret) — and increments
|
||||
/// `rustfs_system_network_internode_replay_cache_overflow_total`, so a sustained non-zero overflow
|
||||
/// counter means this capacity is undersized for the node's peak authenticated RPC rate.
|
||||
pub const ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: &str = "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY";
|
||||
pub const DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: usize = 1_048_576;
|
||||
|
||||
|
||||
@@ -144,6 +144,12 @@ pub const POSTGRES_QUEUE_LIMIT: &str = "queue_limit";
|
||||
/// Applies to both notify and audit target queue stores.
|
||||
pub const ENV_TARGET_STORE_COMPRESS: &str = "RUSTFS_TARGET_STORE_COMPRESS";
|
||||
|
||||
/// Environment variable that relocates the root of the default notify and audit queue directories.
|
||||
///
|
||||
/// It applies only to targets whose `queue_dir` is the logical default (`EVENT_DEFAULT_DIR` or
|
||||
/// `AUDIT_DEFAULT_DIR`); their queues move to `<root>/events` and `<root>/audit` on this node.
|
||||
pub const ENV_TARGET_STORE_DIR: &str = "RUSTFS_TARGET_STORE_DIR";
|
||||
|
||||
/// Queue-store compression is enabled by default to reduce disk footprint.
|
||||
pub const DEFAULT_TARGET_STORE_COMPRESS: bool = true;
|
||||
|
||||
|
||||
@@ -121,7 +121,6 @@ test-util = []
|
||||
e2e-test-hooks = []
|
||||
|
||||
[dependencies]
|
||||
hashbrown.workspace = true
|
||||
hotpath.workspace = true
|
||||
rustfs-filemeta.workspace = true
|
||||
rustfs-utils = { workspace = true, features = ["full"] }
|
||||
|
||||
@@ -640,17 +640,17 @@ pub mod rpc {
|
||||
pub use crate::cluster::rpc::{
|
||||
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, MAX_NETWORK_PROBE_BYTES, MAX_NETWORK_PROBE_DURATION,
|
||||
NetworkPeerProbeClient, NetworkPeerProbeError, NetworkPeerProbeMeasurement, NetworkPeerTarget, PEER_RESTDRY_RUN,
|
||||
PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, PeerS3Client, RpcReplayCacheAdmissionError, S3PeerSys,
|
||||
SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerBucketListing, ScannerDirtyUsageAcknowledgement,
|
||||
ScannerPeerActivity, ScannerPeerDirtyUsageBucket, ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease,
|
||||
ScannerScopedDirtyUsageAckEntry, TONIC_RPC_PREFIX, TonicInterceptor, build_put_file_auth_trailer,
|
||||
check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options, encode_heal_bucket_rpc_options, gen_signature_headers,
|
||||
gen_tonic_replay_scope_headers, gen_tonic_signature_headers, gen_tonic_signature_interceptor,
|
||||
node_service_time_out_client, node_service_time_out_client_no_auth, normalize_tonic_rpc_audience,
|
||||
rpc_auth_is_resource_exhausted, rpc_background_admission, set_tonic_canonical_body_digest, sign_ns_scanner_capability,
|
||||
sign_ns_scanner_capability_with_checkpoint_coverage, sign_ns_scanner_capability_with_tier_registry_generation,
|
||||
sign_put_file_capability, sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers,
|
||||
tonic_rpc_auth_failure_reason, verify_ns_scanner_capability, verify_ns_scanner_capability_with_checkpoint_coverage,
|
||||
PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG,
|
||||
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerBucketListing, ScannerDirtyUsageAcknowledgement, ScannerPeerActivity,
|
||||
ScannerPeerDirtyUsageBucket, ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease, ScannerScopedDirtyUsageAckEntry,
|
||||
TONIC_RPC_PREFIX, TonicInterceptor, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce,
|
||||
decode_heal_bucket_rpc_options, encode_heal_bucket_rpc_options, gen_signature_headers, gen_tonic_replay_scope_headers,
|
||||
gen_tonic_signature_headers, gen_tonic_signature_interceptor, node_service_time_out_client,
|
||||
node_service_time_out_client_no_auth, normalize_tonic_rpc_audience, rpc_background_admission,
|
||||
set_tonic_canonical_body_digest, sign_ns_scanner_capability, sign_ns_scanner_capability_with_checkpoint_coverage,
|
||||
sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof,
|
||||
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
|
||||
verify_ns_scanner_capability, verify_ns_scanner_capability_with_checkpoint_coverage,
|
||||
verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer, verify_put_file_capability,
|
||||
verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
|
||||
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
|
||||
|
||||
@@ -229,7 +229,6 @@ pub struct ListPathRawOptions {
|
||||
pub hidden_entry_budget: usize,
|
||||
pub pending_prefix: Option<String>,
|
||||
pub filter_prefix: Option<String>,
|
||||
pub identifier_tree_leaf: Option<String>,
|
||||
pub forward_to: Option<String>,
|
||||
pub min_disks: usize,
|
||||
/// Deliver every replica to the partial callback, including matching headers.
|
||||
@@ -269,7 +268,6 @@ impl Clone for ListPathRawOptions {
|
||||
hidden_entry_budget: self.hidden_entry_budget,
|
||||
pending_prefix: self.pending_prefix.clone(),
|
||||
filter_prefix: self.filter_prefix.clone(),
|
||||
identifier_tree_leaf: self.identifier_tree_leaf.clone(),
|
||||
forward_to: self.forward_to.clone(),
|
||||
min_disks: self.min_disks,
|
||||
preserve_replica_metadata: self.preserve_replica_metadata,
|
||||
@@ -302,7 +300,6 @@ fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
|
||||
pending_prefix: opts.pending_prefix.clone(),
|
||||
report_notfound: opts.report_not_found,
|
||||
filter_prefix: opts.filter_prefix.clone(),
|
||||
identifier_tree_leaf: opts.identifier_tree_leaf.clone(),
|
||||
forward_to: opts.forward_to.clone(),
|
||||
limit: opts.per_disk_limit,
|
||||
skip_total_timeout: opts.skip_walkdir_total_timeout,
|
||||
@@ -1236,21 +1233,6 @@ mod tests {
|
||||
assert!(!options.skip_total_timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_tree_selection_survives_job_clone_and_disk_request() {
|
||||
let request = ListPathRawOptions {
|
||||
path: "catalog/namespaces/".to_string(),
|
||||
identifier_tree_leaf: Some("table-entry.json".to_string()),
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
};
|
||||
let cloned = request.clone();
|
||||
let walk = walk_dir_options(&cloned);
|
||||
assert_eq!(walk.identifier_tree_leaf, request.identifier_tree_leaf);
|
||||
assert_eq!(walk.base_dir, request.path);
|
||||
assert!(walk.recursive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloned_reader_options_preserve_hidden_scan_budget_and_pending_prefix() {
|
||||
let request = ListPathRawOptions {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,17 +35,17 @@ pub use client::{
|
||||
// Re-exported through `api::rpc`; not every item is consumed inside this crate.
|
||||
#[allow(unused_imports)]
|
||||
pub use http_auth::{
|
||||
RPC_BACKGROUND_ADMISSION_HEADER, RpcReplayCacheAdmissionError, TONIC_RPC_PREFIX, build_auth_headers,
|
||||
build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, gen_signature_headers, gen_tonic_replay_scope_headers,
|
||||
gen_tonic_signature_headers, normalize_tonic_rpc_audience, rpc_auth_is_resource_exhausted, rpc_background_admission,
|
||||
set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, set_tonic_rolling_canonical_body_digest,
|
||||
set_tonic_rolling_mutation_body_digest, sign_ns_scanner_capability, sign_ns_scanner_capability_with_checkpoint_coverage,
|
||||
sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof,
|
||||
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason, verify_ns_scanner_capability,
|
||||
verify_ns_scanner_capability_with_checkpoint_coverage, verify_ns_scanner_capability_with_tier_registry_generation,
|
||||
verify_put_file_auth_trailer, verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response,
|
||||
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned,
|
||||
verify_tonic_rpc_response_proof, verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
|
||||
RPC_BACKGROUND_ADMISSION_HEADER, TONIC_RPC_PREFIX, build_auth_headers, build_put_file_auth_trailer,
|
||||
check_and_record_signed_rpc_nonce, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
||||
normalize_tonic_rpc_audience, rpc_background_admission, set_tonic_canonical_body_digest, set_tonic_mutation_body_digest,
|
||||
set_tonic_rolling_canonical_body_digest, set_tonic_rolling_mutation_body_digest, sign_ns_scanner_capability,
|
||||
sign_ns_scanner_capability_with_checkpoint_coverage, sign_ns_scanner_capability_with_tier_registry_generation,
|
||||
sign_put_file_capability, sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers,
|
||||
tonic_rpc_auth_failure_reason, verify_ns_scanner_capability, verify_ns_scanner_capability_with_checkpoint_coverage,
|
||||
verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer, verify_put_file_capability,
|
||||
verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
|
||||
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
|
||||
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport;
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::cluster::rpc::client::{
|
||||
node_service_time_out_client,
|
||||
};
|
||||
use crate::cluster::rpc::set_tonic_mutation_body_digest;
|
||||
use crate::core::pools::{PoolMeta, PoolMetaSaveGate, PoolMetaSaveGateWriteGuard};
|
||||
use crate::core::pools::{PoolMeta, PoolMetaWriteState};
|
||||
use crate::disk::error::DiskError;
|
||||
use crate::disk::error::{Error, Result};
|
||||
use crate::disk::error_reduce::{BUCKET_OP_IGNORED_ERRS, is_all_buckets_not_found, reduce_write_quorum_errs};
|
||||
@@ -798,12 +798,12 @@ fn try_acquire_bucket_heal_movement_guard<'a>(
|
||||
}
|
||||
|
||||
async fn acquire_bucket_heal_write_guard<'a>(
|
||||
gate: Option<&'a PoolMetaSaveGate>,
|
||||
) -> Result<Option<PoolMetaSaveGateWriteGuard<'a>>> {
|
||||
gate: Option<&'a tokio::sync::Mutex<PoolMetaWriteState>>,
|
||||
) -> Result<Option<tokio::sync::MutexGuard<'a, PoolMetaWriteState>>> {
|
||||
let Some(gate) = gate else {
|
||||
return Ok(None);
|
||||
};
|
||||
let guard = gate.write().await;
|
||||
let guard = gate.lock().await;
|
||||
guard
|
||||
.ensure_write_safe("bucket heal cannot run while pool metadata requires recovery")
|
||||
.map_err(|e| e.narrow_to_disk().unwrap_or_else(Error::other))?;
|
||||
@@ -2388,8 +2388,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_bucket_heal_refuses_receiver_with_unsafe_pool_metadata() {
|
||||
let gate = PoolMetaSaveGate::default();
|
||||
gate.write().await.observe_replicas(PoolMetaReplicaState {
|
||||
let gate = tokio::sync::Mutex::new(PoolMetaWriteState::default());
|
||||
gate.lock().await.observe_replicas(PoolMetaReplicaState {
|
||||
needs_repair: true,
|
||||
repair_write_safe: false,
|
||||
});
|
||||
|
||||
@@ -628,7 +628,7 @@ async fn pool_meta_native_physical_runtime_fork_after_apply_latches_original_gat
|
||||
let fork = identity_epoch_fork(&fixture).await;
|
||||
let since = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed original failure timestamp should be valid");
|
||||
{
|
||||
let mut state = fixture.store.pool_meta_save_gate.write().await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.lock().await;
|
||||
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
|
||||
state.write_blocked = false;
|
||||
state.block_context = None;
|
||||
@@ -685,7 +685,7 @@ async fn pool_meta_native_physical_runtime_fork_after_apply_latches_original_gat
|
||||
);
|
||||
assert_committed(&fixture).await;
|
||||
{
|
||||
let state = fixture.store.pool_meta_save_gate.write().await;
|
||||
let state = fixture.store.pool_meta_save_gate.lock().await;
|
||||
assert!(
|
||||
state.write_blocked,
|
||||
"candidate validation must propagate its permanent latch to the original state"
|
||||
@@ -706,7 +706,7 @@ async fn pool_meta_native_physical_runtime_fork_after_apply_latches_original_gat
|
||||
.expect("permanently blocked recovery should return without mutation"),
|
||||
"ordinary-readable repaired heads must not clear the original known-fork latch"
|
||||
);
|
||||
let state = fixture.store.pool_meta_save_gate.write().await;
|
||||
let state = fixture.store.pool_meta_save_gate.lock().await;
|
||||
assert!(state.write_blocked);
|
||||
assert_eq!(
|
||||
state
|
||||
@@ -979,7 +979,7 @@ async fn pool_meta_native_heal_v1_with_all_upgrade_gates_disabled_keeps_exact_ra
|
||||
async {
|
||||
let (fixture, raw) = legacy_native_owner_fixture(POOL_META_V1_VERSION, false).await;
|
||||
let guard = owner(&fixture.store).await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.write().await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.lock().await;
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
fixture.store.heal_pool_meta_replica_under_fence(0, guard, &mut state),
|
||||
@@ -1036,7 +1036,7 @@ async fn pool_meta_native_heal_rejects_legacy_source_with_trailing_bytes_without
|
||||
let before = snapshots(&fixture.store).await;
|
||||
let identities = owner_repair_identity_heads(&fixture.store).await;
|
||||
let guard = owner(&fixture.store).await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.write().await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.lock().await;
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
fixture.store.heal_pool_meta_replica_under_fence(1, guard, &mut state),
|
||||
@@ -1110,7 +1110,7 @@ async fn pool_meta_native_heal_mixed_v1_v2_preserves_observed_format_floor() {
|
||||
assert_eq!(selection.revision.version, POOL_META_VERSION, "the observed V2 format floor must be retained");
|
||||
assert_eq!(selection.canonical.as_deref(), Some(semantic_v2.as_slice()));
|
||||
let guard = owner(&fixture.store).await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.write().await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.lock().await;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30),
|
||||
fixture.store.heal_pool_meta_replica_under_fence(target_pool, guard, &mut state))
|
||||
.await.expect("mixed legacy heal must terminate")
|
||||
@@ -1199,7 +1199,7 @@ async fn pool_meta_native_heal_rejects_v1_and_v3_prepare_in_either_pool_order()
|
||||
let before = snapshots(&fixture.store).await;
|
||||
let identities = owner_repair_identity_heads(&fixture.store).await;
|
||||
let guard = owner(&fixture.store).await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.write().await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.lock().await;
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
fixture
|
||||
@@ -1301,7 +1301,7 @@ async fn pool_meta_native_heal_rejects_v1_legacy_dont_save_true_without_writes()
|
||||
let before = snapshots(&fixture.store).await;
|
||||
let identities = owner_repair_identity_heads(&fixture.store).await;
|
||||
let guard = owner(&fixture.store).await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.write().await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.lock().await;
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
fixture.store.heal_pool_meta_replica_under_fence(1, guard, &mut state),
|
||||
@@ -1410,7 +1410,7 @@ async fn pool_meta_native_heal_rejects_target_v1_dont_save_true_without_writes()
|
||||
let before = snapshots(&fixture.store).await;
|
||||
let identities = owner_repair_identity_heads(&fixture.store).await;
|
||||
let guard = owner(&fixture.store).await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.write().await;
|
||||
let mut state = fixture.store.pool_meta_save_gate.lock().await;
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
fixture.store.heal_pool_meta_replica_under_fence(1, guard, &mut state),
|
||||
|
||||
+135
-452
File diff suppressed because it is too large
Load Diff
@@ -527,8 +527,8 @@ mod decommission_lock_order_tests {
|
||||
rebalance_meta: tokio::sync::RwLock::new(other_store.rebalance_meta.read().await.clone()),
|
||||
decommission_cancelers: tokio::sync::RwLock::new(vec![None; other_store.pools.len()]),
|
||||
start_gate: tokio::sync::Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::new(
|
||||
other_store.pool_meta_save_gate.write().await.independent_clone_for_test(),
|
||||
pool_meta_save_gate: tokio::sync::Mutex::new(
|
||||
other_store.pool_meta_save_gate.lock().await.independent_clone_for_test(),
|
||||
),
|
||||
ctx,
|
||||
bucket_fence_registry: Arc::default(),
|
||||
|
||||
@@ -85,7 +85,6 @@ impl ScannerPauseBacklogRetirementPhase {
|
||||
pub struct ScannerPauseBacklogRetirementReplica {
|
||||
pub pool_index: usize,
|
||||
pub set_index: usize,
|
||||
pub set_id: Option<uuid::Uuid>,
|
||||
/// False for retiring members retained only to verify the old commit proof.
|
||||
pub writable: bool,
|
||||
pub data: Option<Vec<u8>>,
|
||||
@@ -141,7 +140,6 @@ async fn read_replica((set, writable): (Arc<SetDisks>, bool)) -> Result<ScannerP
|
||||
let mut replica = ScannerPauseBacklogRetirementReplica {
|
||||
pool_index: set.pool_index,
|
||||
set_index: set.set_index,
|
||||
set_id: Some(set.persistent_set_id()?),
|
||||
writable,
|
||||
data: None,
|
||||
};
|
||||
@@ -224,7 +222,6 @@ pub(crate) fn plan_scanner_pause_backlog_retirement(
|
||||
.map(|read| ScannerPauseBacklogRetirementReplica {
|
||||
pool_index: read.replica.pool_index,
|
||||
set_index: read.replica.set_index,
|
||||
set_id: read.replica.set_id,
|
||||
writable: read.replica.writable,
|
||||
data: read.replica.data.clone(),
|
||||
})
|
||||
|
||||
@@ -685,8 +685,12 @@ async fn clear_bucket_usage_memory(bucket: &str, guard: Option<&rustfs_lock::Nam
|
||||
}
|
||||
|
||||
pub async fn remove_bucket_usage_from_backend(store: Arc<ECStore>, bucket: &str) -> Result<(), Error> {
|
||||
remove_bucket_usage_for_namespace_change(store.as_ref(), bucket).await
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_bucket_usage_for_namespace_change(store: &ECStore, bucket: &str) -> Result<(), Error> {
|
||||
prepare_bucket_usage_for_namespace_change(bucket, None).await?;
|
||||
remove_bucket_usage_with_owned_namespace(store, bucket, None).await
|
||||
remove_bucket_usage_from_backend_with_guard_fenced(store, bucket, None).await
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_bucket_usage_for_namespace_change(
|
||||
@@ -726,37 +730,11 @@ where
|
||||
pub(crate) async fn remove_bucket_usage_from_backend_with_guard_fenced(
|
||||
store: &ECStore,
|
||||
bucket: &str,
|
||||
guard: Option<&Arc<rustfs_lock::NamespaceLockGuard>>,
|
||||
guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
) -> Result<(), Error> {
|
||||
let owned_store = crate::bucket::metadata_sys::object_store_in(&store.ctx).await?;
|
||||
if !std::ptr::eq(store, owned_store.as_ref()) {
|
||||
return Err(Error::other("usage cleanup requires its own metadata store"));
|
||||
}
|
||||
remove_bucket_usage_with_owned_namespace(owned_store, bucket, guard.cloned()).await
|
||||
}
|
||||
|
||||
async fn remove_bucket_usage_with_owned_namespace(
|
||||
store: Arc<ECStore>,
|
||||
bucket: &str,
|
||||
guard: Option<Arc<rustfs_lock::NamespaceLockGuard>>,
|
||||
) -> Result<(), Error> {
|
||||
let bucket = bucket.to_owned();
|
||||
let ctx = Arc::clone(&store.ctx);
|
||||
// Retain the bucket generation and movement/object guards through every
|
||||
// physical commit even when the namespace request stops waiting.
|
||||
ctx.run_detached_mutation(async move {
|
||||
let result = remove_bucket_usage_from_backend_with_store_and_guard_and_publication(
|
||||
store.as_ref(),
|
||||
&bucket,
|
||||
guard.as_deref(),
|
||||
Some(store.as_ref()),
|
||||
)
|
||||
.await;
|
||||
invalidate_bucket_usage_snapshot_caches(guard.as_deref(), &bucket).await?;
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(Error::from)?
|
||||
let result = remove_bucket_usage_from_backend_with_store_and_guard_and_publication(store, bucket, guard, Some(store)).await;
|
||||
invalidate_bucket_usage_snapshot_caches(guard, bucket).await?;
|
||||
result
|
||||
}
|
||||
|
||||
async fn invalidate_bucket_usage_snapshot_caches(
|
||||
@@ -871,9 +849,9 @@ where
|
||||
let primary_seed_epoch = match publication_store {
|
||||
Some(publication_store) => Some(
|
||||
publication_store
|
||||
.data_usage_namespace_cleanup_epoch()
|
||||
.scanner_data_usage_publication_epoch()
|
||||
.await
|
||||
.ok_or_else(|| Error::other("data usage namespace cleanup epoch is unavailable"))?,
|
||||
.ok_or_else(|| Error::other("data usage publication is blocked by data movement"))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
@@ -893,9 +871,9 @@ where
|
||||
let backup_seed_epoch = match publication_store {
|
||||
Some(publication_store) => Some(
|
||||
publication_store
|
||||
.data_usage_namespace_cleanup_epoch()
|
||||
.scanner_data_usage_publication_epoch()
|
||||
.await
|
||||
.ok_or_else(|| Error::other("data usage namespace cleanup epoch is unavailable"))?,
|
||||
.ok_or_else(|| Error::other("data usage publication is blocked by data movement"))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
@@ -1007,9 +985,9 @@ where
|
||||
let read_epoch = match publication {
|
||||
Some((publication_store, expected_publication_epoch)) => {
|
||||
let epoch = publication_store
|
||||
.data_usage_namespace_cleanup_epoch()
|
||||
.scanner_data_usage_publication_epoch()
|
||||
.await
|
||||
.ok_or_else(|| Error::other("data usage namespace cleanup epoch is unavailable"))?;
|
||||
.ok_or_else(|| Error::other("data usage publication is blocked by data movement"))?;
|
||||
if expected_publication_epoch.is_some_and(|expected| expected != epoch) {
|
||||
return Err(Error::other("data usage publication epoch changed before snapshot read"));
|
||||
}
|
||||
@@ -1044,8 +1022,9 @@ where
|
||||
ensure_bucket_namespace_guard(guard, bucket, "data usage snapshot commit")?;
|
||||
let publication_guard = match publication {
|
||||
Some((publication_store, expected_publication_epoch)) => {
|
||||
let Some((guard, publication_epoch)) = publication_store.data_usage_namespace_cleanup_guard().await else {
|
||||
return Err(Error::other("data usage namespace cleanup epoch is unavailable"));
|
||||
let Some((guard, publication_epoch)) = publication_store.scanner_data_usage_publication_admission_guard().await
|
||||
else {
|
||||
return Err(Error::other("data usage publication is blocked by data movement"));
|
||||
};
|
||||
if expected_publication_epoch
|
||||
.or(read_epoch)
|
||||
@@ -1057,27 +1036,18 @@ where
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let save_result = match publication {
|
||||
Some((publication_store, _)) => {
|
||||
publication_store
|
||||
.put_usage_snapshot_for_namespace_cleanup(object, revision.as_deref(), &mut put_data, guard)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
store
|
||||
.put_object(
|
||||
RUSTFS_META_BUCKET,
|
||||
object,
|
||||
&mut put_data,
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
http_preconditions: Some(http_preconditions),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
let save_result = store
|
||||
.put_object(
|
||||
RUSTFS_META_BUCKET,
|
||||
object,
|
||||
&mut put_data,
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
http_preconditions: Some(http_preconditions),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
drop(publication_guard);
|
||||
match save_result.map_err(|err| crate::config::com::map_system_metadata_write_error(err, object)) {
|
||||
Ok(_) => return Ok(()),
|
||||
@@ -3466,7 +3436,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: TokioMutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::default(),
|
||||
pool_meta_save_gate: TokioMutex::default(),
|
||||
ctx,
|
||||
bucket_fence_registry: Arc::default(),
|
||||
})
|
||||
|
||||
@@ -977,7 +977,6 @@ async fn restore_delete_rollback_with_namespace_owner(
|
||||
.insert(backup_path.clone());
|
||||
os::rename_all_with_owner(&backup_path, xl_path, object_dir, publication_root, namespace_owner.clone()).await
|
||||
};
|
||||
let restored_backup = restored.is_ok();
|
||||
match restored {
|
||||
Ok(()) => {}
|
||||
Err(DiskError::FileNotFound) if remove_new_metadata => {
|
||||
@@ -1007,16 +1006,6 @@ async fn restore_delete_rollback_with_namespace_owner(
|
||||
os::fsync_dir_with_owner(object_dir, namespace_owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
if restored_backup {
|
||||
// Renaming two hardlinks to the same inode can leave the backup in place.
|
||||
match os::remove_file_with_owner(&backup_path, namespace_owner.clone()).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
}
|
||||
if effective_durability(volume).syncs_commit_metadata() {
|
||||
os::fsync_dir_with_owner(&rollback_path, namespace_owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
@@ -3805,25 +3794,6 @@ async fn read_dir_entries_with_walk_stall(path: &Path, count: i32, stall: Option
|
||||
const WALK_DIR_BATCH_SIZE: usize = 32;
|
||||
#[cfg(not(test))]
|
||||
const WALK_DIR_BATCH_SIZE: usize = 256;
|
||||
|
||||
async fn read_identifier_tree_entries(path: &Path, leaf: &str, stall: Option<Duration>) -> Result<Vec<String>> {
|
||||
let mut entries = read_dir_with_walk_stall(path.to_path_buf(), stall).await?;
|
||||
let mut names = Vec::new();
|
||||
loop {
|
||||
let (next, batch, eof) = read_dir_entry_batch_with_walk_stall(entries, READ_DIR_BATCH_SIZE, stall).await?;
|
||||
entries = next;
|
||||
// Filter before retaining or sorting the inventory. A report flood
|
||||
// costs directory iteration, not one retained name/metadata read per report.
|
||||
names.extend(batch.into_iter().filter(|name| {
|
||||
name.strip_suffix(SLASH_SEPARATOR)
|
||||
.is_some_and(|component| component == leaf || super::is_identifier_tree_component(component))
|
||||
}));
|
||||
if eof {
|
||||
return Ok(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const READ_DIR_BATCH_SIZE: usize = 512;
|
||||
|
||||
async fn read_dir_with_walk_stall(path: PathBuf, stall: Option<Duration>) -> Result<std::fs::ReadDir> {
|
||||
@@ -11776,14 +11746,6 @@ impl LocalDisk {
|
||||
W: AsyncWrite + Unpin + Send + 'a,
|
||||
{
|
||||
Box::pin(async move {
|
||||
if opts.identifier_tree_leaf.is_some() {
|
||||
let relative = current
|
||||
.strip_prefix(&opts.base_dir)
|
||||
.ok_or_else(|| DiskError::other("identifier-tree walk escaped its root"))?;
|
||||
if relative.split('/').filter(|component| !component.is_empty()).count() > 512 {
|
||||
return Err(DiskError::other("identifier-tree walk exceeds its directory depth limit"));
|
||||
}
|
||||
}
|
||||
// The part of forward_to below this directory, taken before `current`
|
||||
// loses its trailing slash.
|
||||
let forward_rest = opts
|
||||
@@ -11816,12 +11778,7 @@ impl LocalDisk {
|
||||
// as a single logical read in API semantics.
|
||||
let read_dir_started = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
|
||||
let dir_path_abs = self.io_get_object_path(&opts.bucket, current.trim_start_matches(SLASH_SEPARATOR))?;
|
||||
let read_dir_result = if let Some(leaf) = opts.identifier_tree_leaf.as_deref() {
|
||||
read_identifier_tree_entries(&dir_path_abs, leaf, stall).await
|
||||
} else {
|
||||
read_dir_entries_with_walk_stall(&dir_path_abs, -1, stall).await
|
||||
};
|
||||
let read_dir_result = match read_dir_result {
|
||||
let read_dir_result = match read_dir_entries_with_walk_stall(&dir_path_abs, -1, stall).await {
|
||||
Err(err) if err == Error::FileNotFound && !skip_access_checks(&opts.bucket) => {
|
||||
let volume_dir = self.io_get_bucket_path(&opts.bucket)?;
|
||||
if let Err(access_err) = cached_access(&volume_dir).await {
|
||||
@@ -12118,16 +12075,6 @@ impl LocalDisk {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if let Some(leaf) = opts.identifier_tree_leaf.as_deref()
|
||||
&& entry != leaf
|
||||
{
|
||||
// Identifier directories can be namespaces at any depth,
|
||||
// including components named tables or maintenance. Their
|
||||
// own object metadata is outside this selected leaf scan.
|
||||
schedule_dir(&mut dir_stack, format!("{name}/"), true, None, true, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut meta = MetaCacheEntry {
|
||||
name,
|
||||
..Default::default()
|
||||
@@ -12177,8 +12124,7 @@ impl LocalDisk {
|
||||
} else {
|
||||
adaptive_batch_cap
|
||||
};
|
||||
let batch_names = if opts.identifier_tree_leaf.is_none()
|
||||
&& batch_limit > 1
|
||||
let batch_names = if batch_limit > 1
|
||||
&& dir_objes.is_empty()
|
||||
&& dir_stack.iter().all(|(_, _, _, scan_required, _)| !*scan_required)
|
||||
{
|
||||
@@ -12231,11 +12177,6 @@ impl LocalDisk {
|
||||
write_metacache_obj(out, &meta).await?;
|
||||
}
|
||||
|
||||
if opts.identifier_tree_leaf.is_some() {
|
||||
// A dotted leaf cannot contain identifier-tree descendants.
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_namespace_child_candidate = read.has_namespace_child_candidate;
|
||||
let dir_to_skip: HashSet<String> = if has_namespace_child_candidate || !dir_objes.is_empty() {
|
||||
read.data_dirs.into_iter().map(|dir| dir.to_string()).collect()
|
||||
@@ -12282,9 +12223,6 @@ impl LocalDisk {
|
||||
}
|
||||
Err(err) => {
|
||||
if err == Error::FileNotFound || err == Error::IsNotRegular {
|
||||
if opts.identifier_tree_leaf.is_some() {
|
||||
continue;
|
||||
}
|
||||
adaptive_batch_cap = 1;
|
||||
// NOT an object, append to stack (with slash)
|
||||
// If dirObject, but no metadata (which is unexpected) we skip it.
|
||||
@@ -15493,12 +15431,6 @@ impl DiskAPI for LocalDisk {
|
||||
super::lifecycle_test_io::observe(&opts.bucket, None);
|
||||
self.wait_for_startup_cleanup().await;
|
||||
|
||||
if let Some(leaf) = opts.identifier_tree_leaf.as_deref()
|
||||
&& (!opts.recursive || !opts.base_dir.ends_with('/') || !super::is_identifier_tree_leaf(leaf))
|
||||
{
|
||||
return Err(DiskError::other("invalid identifier-tree walk options"));
|
||||
}
|
||||
|
||||
// Callers that do not pin a stall budget inherit the configured default, so
|
||||
// every local walk keeps a liveness bound even when the wrapper-level total
|
||||
// timeout is skipped.
|
||||
@@ -15525,7 +15457,7 @@ impl DiskAPI for LocalDisk {
|
||||
|
||||
let mut skip_current_dir_object = false;
|
||||
let mut multipart_dir_to_skip: HashSet<String> = HashSet::new();
|
||||
if opts.identifier_tree_leaf.is_none() && opts.base_dir.ends_with(SLASH_SEPARATOR) {
|
||||
if opts.base_dir.ends_with(SLASH_SEPARATOR) {
|
||||
if let Ok(data) = with_walk_stall_timeout(
|
||||
stall,
|
||||
self.read_listing_metadata(
|
||||
@@ -19475,55 +19407,6 @@ mod test {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identifier_tree_walk_filters_report_batches_and_bounds_depth() {
|
||||
let dir = tempfile::tempdir().expect("create disk fixture");
|
||||
let root = dir.path().join("catalog/prefix");
|
||||
fs::create_dir_all(root.join("sales"))
|
||||
.await
|
||||
.expect("create identifier directory");
|
||||
fs::create_dir_all(root.join("table-entry.json"))
|
||||
.await
|
||||
.expect("create selected leaf");
|
||||
for index in 0..READ_DIR_BATCH_SIZE * 3 + 1 {
|
||||
fs::create_dir(root.join(format!("report-{index}.json")))
|
||||
.await
|
||||
.expect("create report directory");
|
||||
}
|
||||
let mut names = read_identifier_tree_entries(&root, "table-entry.json", Some(Duration::from_secs(5)))
|
||||
.await
|
||||
.expect("all directory batches should be filtered");
|
||||
names.sort();
|
||||
assert_eq!(names, ["sales/", "table-entry.json/"]);
|
||||
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("UTF-8 fixture path")).expect("valid endpoint");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("create local disk");
|
||||
let options = WalkDirOptions {
|
||||
bucket: "catalog".to_string(),
|
||||
base_dir: "prefix/".to_string(),
|
||||
recursive: true,
|
||||
identifier_tree_leaf: Some("table-entry.json".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let mut writer = MetacacheWriter::new(&mut output);
|
||||
let mut count = 0;
|
||||
let error = disk
|
||||
.scan_dir(
|
||||
format!("prefix/{}", "a/".repeat(513)),
|
||||
String::new(),
|
||||
&options,
|
||||
&mut writer,
|
||||
&mut count,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("excessive depth must fail before filesystem I/O");
|
||||
assert!(error.to_string().contains("directory depth limit"));
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
// #4644: the stall budget bounds a single drive read, never the walk as a
|
||||
// whole. A walk that keeps making progress must survive even when the total
|
||||
// time spent blocked on a slow consumer dwarfs the stall timeout.
|
||||
|
||||
@@ -7240,9 +7240,6 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_fused_prepare_checkpoint_reopens_before_head_publication() {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let _mode = durability_mode_override::set(DurabilityMode::Strict);
|
||||
let (_temp, disk, mut first, _) = fixture().await;
|
||||
first.version_id = Some(Uuid::new_v4());
|
||||
let mut second = first.clone();
|
||||
@@ -7266,25 +7263,12 @@ mod tests {
|
||||
let source = source_root.join(transaction.to_string());
|
||||
let destination = copy_root.join(transaction.to_string());
|
||||
std::fs::create_dir(&destination).expect("create checkpoint transaction");
|
||||
let source_head = source_root.join(STORAGE_FORMAT_FILE);
|
||||
let source_head_meta = std::fs::metadata(&source_head).expect("stat source head");
|
||||
let copy_head = copy_root.join(STORAGE_FORMAT_FILE);
|
||||
std::fs::copy(&source_head, ©_head).expect("capture head before publication");
|
||||
std::fs::copy(source_root.join(STORAGE_FORMAT_FILE), copy_root.join(STORAGE_FORMAT_FILE))
|
||||
.expect("capture head before publication");
|
||||
for entry in std::fs::read_dir(source).expect("read published preparation") {
|
||||
let entry = entry.expect("prepared file");
|
||||
assert!(entry.file_type().expect("prepared file type").is_file());
|
||||
let dst = destination.join(entry.file_name());
|
||||
if entry.file_name() == STORAGE_FORMAT_FILE_BACKUP {
|
||||
let backup_meta = entry.metadata().expect("stat prepared backup");
|
||||
assert_eq!(
|
||||
(backup_meta.dev(), backup_meta.ino()),
|
||||
(source_head_meta.dev(), source_head_meta.ino()),
|
||||
"fused preparation must hardlink backup to live head"
|
||||
);
|
||||
std::fs::hard_link(©_head, &dst).expect("preserve hardlinked backup");
|
||||
} else {
|
||||
std::fs::copy(entry.path(), dst).expect("capture prepared file");
|
||||
}
|
||||
std::fs::copy(entry.path(), destination.join(entry.file_name())).expect("capture prepared file");
|
||||
}
|
||||
});
|
||||
let opts = DeleteOptions {
|
||||
@@ -7308,42 +7292,10 @@ mod tests {
|
||||
.await
|
||||
.expect("actual preparation checkpoint must remain recoverable");
|
||||
assert_eq!(inspection.state, DeleteRecoveryState::NewPrepared);
|
||||
let reopened_tx = reopened
|
||||
.io_get_object_path("recovery", &format!("object/{transaction}"))
|
||||
.expect("reopened transaction path");
|
||||
os::fsync_dir_recorder::set_failure(&reopened_tx, ErrorKind::Other);
|
||||
assert!(
|
||||
reopened
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
inspection.fingerprint,
|
||||
DeleteRecoveryAction::Rollback
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"failed transaction directory sync after backup removal must retain journal"
|
||||
);
|
||||
assert!(reopened_tx.join(PREPARED).is_file());
|
||||
assert!(!reopened_tx.join(STORAGE_FORMAT_FILE_BACKUP).exists());
|
||||
let retry_inspection = reopened
|
||||
.delete_recovery_inspect("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("inspect interrupted rollback");
|
||||
assert_eq!(retry_inspection.state, DeleteRecoveryState::NewPrepared);
|
||||
let settled = reopened
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
retry_inspection.fingerprint,
|
||||
DeleteRecoveryAction::Rollback,
|
||||
)
|
||||
reopened
|
||||
.settle_delete_transaction("recovery", "object", transaction, inspection.fingerprint, DeleteRecoveryAction::Rollback)
|
||||
.await
|
||||
.expect("roll back interrupted preparation");
|
||||
assert_eq!(settled.state, DeleteRecoveryState::Absent, "same-inode backup must not survive rollback");
|
||||
assert!(!checkpoint_root.join(transaction.to_string()).exists());
|
||||
assert_eq!(
|
||||
fs::read(checkpoint_root.join(STORAGE_FORMAT_FILE))
|
||||
.await
|
||||
|
||||
@@ -2222,36 +2222,6 @@ pub struct WalkDirOptions {
|
||||
// Override the remote stream stall timeout for long background walks.
|
||||
#[serde(default)]
|
||||
pub stall_timeout_ms: Option<u64>,
|
||||
|
||||
/// Select this leaf below identifier-like directory components. The root is
|
||||
/// `base_dir`; unrelated object metadata must not be read or returned.
|
||||
/// Older peers ignore this additive field, so consumers must also filter.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identifier_tree_leaf: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn is_identifier_tree_component(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
}
|
||||
|
||||
pub(crate) fn is_identifier_tree_leaf(value: &str) -> bool {
|
||||
value.len() <= 255
|
||||
&& value.contains('.')
|
||||
&& !matches!(value, "." | "..")
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
|
||||
}
|
||||
|
||||
pub(crate) fn identifier_tree_leaf_matches(prefix: &str, leaf: &str, object: &str) -> bool {
|
||||
let Some(relative) = object.strip_prefix(prefix) else {
|
||||
return false;
|
||||
};
|
||||
let mut components = relative.split('/');
|
||||
components.next_back() == Some(leaf) && components.all(is_identifier_tree_component)
|
||||
}
|
||||
|
||||
impl WalkDirOptions {
|
||||
@@ -3187,7 +3157,6 @@ mod tests {
|
||||
skip_total_timeout: false,
|
||||
timeout_ms: Some(10_000),
|
||||
stall_timeout_ms: Some(20_000),
|
||||
identifier_tree_leaf: None,
|
||||
};
|
||||
|
||||
assert_eq!(opts.bucket, "test-bucket");
|
||||
@@ -3222,69 +3191,6 @@ mod tests {
|
||||
assert!(!decoded.skip_hidden_prefix_check);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_tree_leaf_selection_preserves_nested_identifier_directories() {
|
||||
let prefix = "catalog/namespaces/";
|
||||
let leaf = "table-entry.json";
|
||||
for relative in [
|
||||
"sales/tables/orders/table-entry.json",
|
||||
"sales/tables/orders/maintenance/tables/owner/table-entry.json",
|
||||
"maintenance/tables/tables/table-entry.json",
|
||||
] {
|
||||
assert!(identifier_tree_leaf_matches(prefix, leaf, &format!("{prefix}{relative}")));
|
||||
}
|
||||
for relative in [
|
||||
"sales/tables/orders/not-table-entry.json",
|
||||
"sales/tables/orders/table-entry.json/child",
|
||||
"sales/report.json/tables/owner/table-entry.json",
|
||||
"sales//tables/owner/table-entry.json",
|
||||
"sales/../tables/owner/table-entry.json",
|
||||
] {
|
||||
assert!(!identifier_tree_leaf_matches(prefix, leaf, &format!("{prefix}{relative}")));
|
||||
}
|
||||
assert!(!identifier_tree_leaf_matches(prefix, leaf, "other/sales/tables/orders/table-entry.json"));
|
||||
assert!(is_identifier_tree_leaf(leaf));
|
||||
for invalid in ["", ".", "..", "table-entry", "table/entry.json", "table\\entry.json"] {
|
||||
assert!(!is_identifier_tree_leaf(invalid), "invalid selector {invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_tree_walk_options_are_additive_on_json_and_messagepack_maps() {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LegacyView {
|
||||
bucket: String,
|
||||
base_dir: String,
|
||||
recursive: bool,
|
||||
}
|
||||
let options = WalkDirOptions {
|
||||
bucket: "catalog".to_string(),
|
||||
base_dir: "namespaces/".to_string(),
|
||||
recursive: true,
|
||||
identifier_tree_leaf: Some("table-entry.json".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_vec(&options).expect("encode current HTTP options");
|
||||
let messagepack = rmp_serde::to_vec_named(&options).expect("encode legacy RPC map");
|
||||
for old in [
|
||||
serde_json::from_slice::<LegacyView>(&json).expect("old JSON readers ignore the additive field"),
|
||||
rmp_serde::from_slice::<LegacyView>(&messagepack).expect("old MessagePack readers ignore the additive field"),
|
||||
] {
|
||||
assert_eq!(old.bucket, options.bucket);
|
||||
assert_eq!(old.base_dir, options.base_dir);
|
||||
assert!(old.recursive);
|
||||
}
|
||||
let mut old = serde_json::to_value(&options).expect("encode legacy options");
|
||||
old.as_object_mut().expect("options are a map").remove("identifier_tree_leaf");
|
||||
let from_json: WalkDirOptions = serde_json::from_value(old.clone()).expect("decode old HTTP options");
|
||||
let from_messagepack: WalkDirOptions =
|
||||
rmp_serde::from_slice(&rmp_serde::to_vec_named(&old).expect("encode old RPC map")).expect("decode old RPC map");
|
||||
assert!(from_json.identifier_tree_leaf.is_none());
|
||||
assert!(from_messagepack.identifier_tree_leaf.is_none());
|
||||
assert_eq!(from_json.base_dir, options.base_dir);
|
||||
assert_eq!(from_messagepack.base_dir, options.base_dir);
|
||||
}
|
||||
|
||||
/// Test DeleteOptions structure
|
||||
#[test]
|
||||
fn test_delete_options() {
|
||||
|
||||
@@ -593,7 +593,7 @@ impl ECStore {
|
||||
#[cfg(test)]
|
||||
crate::core::pools::observe_pool_activation_start_attempt(crate::core::pools::PoolActivationStartKind::Rebalance);
|
||||
let fleet_proof = acquire_pool_activation_fleet_proof(&self.ctx).await?;
|
||||
let mut pool_meta_guard = self.pool_meta_save_gate.write().await;
|
||||
let mut pool_meta_guard = self.pool_meta_save_gate.lock().await;
|
||||
pool_meta_guard.ensure_write_safe(stage)?;
|
||||
let activation_fence = acquire_pool_rebalance_activation_locks(pool.clone(), fleet_proof).await?;
|
||||
let pool_meta = self
|
||||
@@ -625,7 +625,7 @@ impl ECStore {
|
||||
{
|
||||
// Lock order: pool_meta_save_gate -> rebalance.bin -> pool.bin. These
|
||||
// shared fences admit a peer without waiting for the run's own entries.
|
||||
let mut pool_meta_guard = self.pool_meta_save_gate.write().await;
|
||||
let mut pool_meta_guard = self.pool_meta_save_gate.lock().await;
|
||||
pool_meta_guard.ensure_write_safe("rebalance worker activation")?;
|
||||
let mut worker_fence = acquire_pool_rebalance_worker_locks(pool.clone()).await?;
|
||||
let pool_meta = self
|
||||
@@ -654,7 +654,7 @@ impl ECStore {
|
||||
{
|
||||
// The admission read fences must already be dropped. Reacquire in the
|
||||
// original order and revalidate every durable boundary across that gap.
|
||||
let mut pool_meta_guard = self.pool_meta_save_gate.write().await;
|
||||
let mut pool_meta_guard = self.pool_meta_save_gate.lock().await;
|
||||
pool_meta_guard.ensure_write_safe("rebalance worker metadata update")?;
|
||||
let mut activation_fence = acquire_pool_rebalance_activation_locks(pool.clone(), None).await?;
|
||||
let pool_meta = self
|
||||
@@ -1714,7 +1714,7 @@ mod tests {
|
||||
persist_pool_meta_identity_for_startup(store.pools.clone(), &mut write_state, true)
|
||||
.await
|
||||
.expect("initialized pool metadata identity should persist");
|
||||
*store.pool_meta_save_gate.write().await = write_state;
|
||||
*store.pool_meta_save_gate.lock().await = write_state;
|
||||
// Simulate physical loss; raw deletion of authoritative pool.bin is
|
||||
// deliberately rejected by the native metadata mutation guard.
|
||||
for pool in &store.pools {
|
||||
|
||||
@@ -100,7 +100,7 @@ pub async fn test_store_with_persisted_rebalance_meta(
|
||||
rebalance_meta: tokio::sync::RwLock::new(Some(meta)),
|
||||
decommission_cancelers: tokio::sync::RwLock::new(vec![None]),
|
||||
start_gate: tokio::sync::Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::default(),
|
||||
pool_meta_save_gate: tokio::sync::Mutex::default(),
|
||||
ctx,
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
});
|
||||
@@ -335,7 +335,7 @@ async fn test_pool_stores_with_contexts(
|
||||
rebalance_meta: tokio::sync::RwLock::new(rebalance_meta.clone()),
|
||||
decommission_cancelers: tokio::sync::RwLock::new(vec![None; pool_count]),
|
||||
start_gate: tokio::sync::Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::new(pool_meta_write_state.independent_clone_for_test()),
|
||||
pool_meta_save_gate: tokio::sync::Mutex::new(pool_meta_write_state.independent_clone_for_test()),
|
||||
ctx: store_ctx,
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
})
|
||||
|
||||
@@ -3412,7 +3412,7 @@ fn test_store_with_rebalance_meta(meta: RebalanceMeta) -> Arc<crate::store::ECSt
|
||||
rebalance_meta: tokio::sync::RwLock::new(Some(meta)),
|
||||
decommission_cancelers: tokio::sync::RwLock::new(Vec::new()),
|
||||
start_gate: tokio::sync::Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::default(),
|
||||
pool_meta_save_gate: tokio::sync::Mutex::default(),
|
||||
ctx: crate::runtime::instance::bootstrap_ctx(),
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
})
|
||||
|
||||
@@ -93,7 +93,6 @@ use super::{
|
||||
delete_tier_mutation_intent_record_if_current, list_tier_coordinator_mutation_intent_records,
|
||||
list_tier_mutation_intent_records, load_tier_coordinator_mutation_intent_record_with_etag,
|
||||
load_tier_mutation_intent_record_with_etag, save_tier_coordinator_mutation_intent_record_if_absent,
|
||||
save_tier_mutation_intent_record_if_current,
|
||||
},
|
||||
warm_backend::WarmBackendImpl,
|
||||
};
|
||||
@@ -621,11 +620,7 @@ struct CommittedMutationRecoveryPlan {
|
||||
|
||||
impl TierMutationRecoverySnapshot {
|
||||
fn requires_config_lock(&self) -> bool {
|
||||
self.has_committed_mutation_blocks
|
||||
|| self.intents.iter().any(|recovered| {
|
||||
recovered.has_coordinator_record
|
||||
|| (recovered.has_peer_record && recovered.intent.state == TierMutationIntentState::Prepared)
|
||||
})
|
||||
self.has_committed_mutation_blocks || self.intents.iter().any(|recovered| recovered.has_coordinator_record)
|
||||
}
|
||||
|
||||
fn allowance(&self) -> MutationBlockAllowance {
|
||||
@@ -5107,9 +5102,6 @@ impl TierConfigMgr {
|
||||
|
||||
Self::reconcile_terminal_dual_prefix_mutation_intents(api.clone(), &mut snapshot).await?;
|
||||
Self::recover_prepared_coordinator_mutation_intents(api.clone(), &mut snapshot.coordinator_intents).await?;
|
||||
if config_lock.is_some() {
|
||||
Self::recover_expired_peer_only_prepares(api.clone(), &snapshot).await?;
|
||||
}
|
||||
|
||||
snapshot = Self::load_and_reconcile_mutation_recovery_snapshot(handle, api.clone(), true).await?;
|
||||
if snapshot.requires_config_lock() && config_lock.is_none() {
|
||||
@@ -5510,58 +5502,6 @@ impl TierConfigMgr {
|
||||
}
|
||||
}
|
||||
|
||||
async fn recover_expired_peer_only_prepares<S>(api: Arc<S>, snapshot: &TierMutationRecoverySnapshot) -> io::Result<()>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
let now = i64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos()).unwrap_or(i64::MAX);
|
||||
let orphaned = snapshot
|
||||
.intents
|
||||
.iter()
|
||||
.filter(|recovered| {
|
||||
recovered.has_peer_record
|
||||
&& !recovered.has_coordinator_record
|
||||
&& recovered.intent.state == TierMutationIntentState::Prepared
|
||||
&& Self::tier_mutation_tombstone_retention_elapsed(&recovered.intent, now)
|
||||
})
|
||||
.map(|recovered| recovered.intent.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if orphaned.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The caller holds the tier-config namespace lock. Only the exact
|
||||
// persisted candidate proves a commit; an old or different config
|
||||
// leaves the peer fence intact.
|
||||
let loaded = load_tier_config_for_recovery(api.clone()).await?;
|
||||
let Some(config_etag) = loaded.etag else {
|
||||
return Ok(());
|
||||
};
|
||||
for intent in orphaned {
|
||||
if loaded.persisted_digest != Some(intent.candidate_digest) {
|
||||
continue;
|
||||
}
|
||||
let _mutation_guard = acquire_tier_mutation_mutex(intent.mutation_id).await;
|
||||
let (mut current, record_etag) =
|
||||
match load_tier_mutation_intent_record_with_etag(api.clone(), intent.mutation_id).await {
|
||||
Ok(record) => record,
|
||||
Err(Error::ConfigNotFound) => continue,
|
||||
Err(err) => return Err(tier_mutation_replay_error(err)),
|
||||
};
|
||||
if current != intent {
|
||||
continue;
|
||||
}
|
||||
current
|
||||
.advance(TierMutationIntentState::Committed, Some(config_etag.clone()))
|
||||
.map_err(io::Error::other)?;
|
||||
match save_tier_mutation_intent_record_if_current(api.clone(), ¤t, &record_etag).await {
|
||||
Ok(()) | Err(Error::PreconditionFailed) => {}
|
||||
Err(err) => return Err(tier_mutation_replay_error(err)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recover_prepared_coordinator_mutation_intents<S>(api: Arc<S>, intents: &mut [TierMutationIntent]) -> io::Result<()>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
@@ -13439,208 +13379,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_recovers_expired_peer_only_prepare_for_persisted_add() {
|
||||
use crate::services::tier::tier_mutation_intent::save_tier_mutation_intent_record;
|
||||
|
||||
let store = Arc::new(CasConfigStore::default());
|
||||
let previous = empty_mgr();
|
||||
let mut candidate = empty_mgr();
|
||||
candidate.tiers.insert("COLD-A".to_string(), build_rustfs_tier("COLD-A"));
|
||||
let targets = build_tier_mutation_affected_targets(
|
||||
TierMutationIntentKind::Add,
|
||||
HashSet::from(["COLD-A".to_string()]),
|
||||
&previous,
|
||||
&candidate,
|
||||
)
|
||||
.expect("added tier should have a target");
|
||||
let mut intent = build_coordinator_tier_mutation_intent(TierMutationIntentKind::Add, None, &candidate, targets)
|
||||
.expect("intent should build")
|
||||
.expect("add should require an intent");
|
||||
intent.expires_at_unix_nanos = 1;
|
||||
candidate
|
||||
.save_tiering_config_if_current(store.clone(), None)
|
||||
.await
|
||||
.expect("candidate should persist");
|
||||
save_tier_mutation_intent_record(store.clone(), &intent)
|
||||
.await
|
||||
.expect("peer prepare should persist without a coordinator record");
|
||||
|
||||
let manager = TierConfigMgr::new();
|
||||
TIER_MUTATION_TEST_PEERS
|
||||
.scope(Vec::new(), async {
|
||||
TIER_DRIVER_TEST_FACTORY
|
||||
.scope(healthy_driver_factory(), async {
|
||||
TierConfigMgr::reload_handle_with(&manager, store.clone())
|
||||
.await
|
||||
.expect("matching saved candidate should recover");
|
||||
TierConfigMgr::acquire_operation_lease(&manager, "COLD-A")
|
||||
.await
|
||||
.expect("recovered tier should be readable");
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
TierConfigMgr::load_tier_mutation_intents(store)
|
||||
.await
|
||||
.expect("peer scan should succeed")
|
||||
.iter()
|
||||
.all(|record| record.state != TierMutationIntentState::Prepared),
|
||||
"the orphaned prepare must no longer block later reloads"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_keeps_peer_only_prepare_without_commit_proof() {
|
||||
use crate::services::tier::tier_mutation_intent::{
|
||||
save_tier_mutation_intent_record, tier_mutation_intent_record_object_name,
|
||||
};
|
||||
|
||||
for case in [
|
||||
"unexpired",
|
||||
"recently_expired",
|
||||
"different_candidate",
|
||||
"missing_config",
|
||||
"concurrent_change",
|
||||
] {
|
||||
let store = Arc::new(CasConfigStore::default());
|
||||
let previous = empty_mgr();
|
||||
let mut candidate = empty_mgr();
|
||||
candidate.tiers.insert("COLD-A".to_string(), build_rustfs_tier("COLD-A"));
|
||||
let targets = build_tier_mutation_affected_targets(
|
||||
TierMutationIntentKind::Add,
|
||||
HashSet::from(["COLD-A".to_string()]),
|
||||
&previous,
|
||||
&candidate,
|
||||
)
|
||||
.expect("added tier should have a target");
|
||||
let mut intent = build_coordinator_tier_mutation_intent(TierMutationIntentKind::Add, None, &candidate, targets)
|
||||
.expect("intent should build")
|
||||
.expect("add should require an intent");
|
||||
intent.expires_at_unix_nanos = match case {
|
||||
"unexpired" => tier_mutation_intent_expiry_unix_nanos(),
|
||||
"recently_expired" => {
|
||||
i64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos()).expect("test clock should fit")
|
||||
- 1_000_000_000
|
||||
}
|
||||
_ => 1,
|
||||
};
|
||||
if case == "different_candidate" {
|
||||
intent.candidate_digest = [0x51; 32];
|
||||
}
|
||||
if case != "missing_config" {
|
||||
candidate
|
||||
.save_tiering_config_if_current(store.clone(), None)
|
||||
.await
|
||||
.expect("candidate should persist");
|
||||
}
|
||||
save_tier_mutation_intent_record(store.clone(), &intent)
|
||||
.await
|
||||
.expect("peer prepare should persist");
|
||||
let expected = if case == "concurrent_change" {
|
||||
let mut changed = intent.clone();
|
||||
changed.candidate_digest = [0x52; 32];
|
||||
let object = tier_mutation_intent_record_object_name(intent.mutation_id).expect("record path should build");
|
||||
store
|
||||
.rewrite_on_next_if_match(object, changed.encode().expect("changed intent should encode"))
|
||||
.await;
|
||||
changed
|
||||
} else {
|
||||
intent
|
||||
};
|
||||
|
||||
let manager = TierConfigMgr::new();
|
||||
TIER_MUTATION_TEST_PEERS
|
||||
.scope(Vec::new(), async {
|
||||
TierConfigMgr::reload_handle_with(&manager, store.clone())
|
||||
.await
|
||||
.expect("unproven prepare should remain fenced");
|
||||
})
|
||||
.await;
|
||||
let error = match TierConfigMgr::acquire_operation_lease(&manager, "COLD-A").await {
|
||||
Ok(_) => panic!("{case}: unproven prepare must block reads"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error.message.contains("being replaced"), "{case}: {error}");
|
||||
let records = TierConfigMgr::load_tier_mutation_intents(store)
|
||||
.await
|
||||
.expect("peer scan should succeed");
|
||||
assert_eq!(records, vec![expected], "{case}: prepared evidence must remain unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn peer_only_prepare_recovery_retains_fence_until_peer_commit_succeeds() {
|
||||
use crate::services::tier::tier_mutation_intent::save_tier_mutation_intent_record;
|
||||
|
||||
let store = Arc::new(CasConfigStore::default());
|
||||
let previous = empty_mgr();
|
||||
let mut candidate = empty_mgr();
|
||||
candidate.tiers.insert("COLD-A".to_string(), build_rustfs_tier("COLD-A"));
|
||||
let targets = build_tier_mutation_affected_targets(
|
||||
TierMutationIntentKind::Add,
|
||||
HashSet::from(["COLD-A".to_string()]),
|
||||
&previous,
|
||||
&candidate,
|
||||
)
|
||||
.expect("added tier should have a target");
|
||||
let mut intent = build_coordinator_tier_mutation_intent(TierMutationIntentKind::Add, None, &candidate, targets)
|
||||
.expect("intent should build")
|
||||
.expect("add should require an intent");
|
||||
intent.expires_at_unix_nanos = 1;
|
||||
candidate
|
||||
.save_tiering_config_if_current(store.clone(), None)
|
||||
.await
|
||||
.expect("candidate should persist");
|
||||
save_tier_mutation_intent_record(store.clone(), &intent)
|
||||
.await
|
||||
.expect("peer prepare should persist");
|
||||
|
||||
let manager = TierConfigMgr::new();
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
TIER_MUTATION_TEST_PEERS
|
||||
.scope(vec![FakeTierMutationPeer::boxed("peer-a", calls.clone(), Err("offline"))], async {
|
||||
TierConfigMgr::reload_handle_with(&manager, store.clone())
|
||||
.await
|
||||
.expect_err("failed peer commit must leave recovery incomplete");
|
||||
})
|
||||
.await;
|
||||
let retained = TierConfigMgr::load_tier_mutation_intents(store.clone())
|
||||
.await
|
||||
.expect("peer scan should succeed");
|
||||
assert_eq!(retained.len(), 1);
|
||||
assert_eq!(retained[0].state, TierMutationIntentState::Committed);
|
||||
let error = match TierConfigMgr::acquire_operation_lease(&manager, "COLD-A").await {
|
||||
Ok(_) => panic!("failed peer commit must retain the fence"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error.message.contains("being replaced"), "{error}");
|
||||
|
||||
TIER_MUTATION_TEST_PEERS
|
||||
.scope(
|
||||
vec![FakeTierMutationPeer::boxed(
|
||||
"peer-a",
|
||||
calls.clone(),
|
||||
Ok(PeerTierMutationState::Committed),
|
||||
)],
|
||||
async {
|
||||
TIER_DRIVER_TEST_FACTORY
|
||||
.scope(healthy_driver_factory(), async {
|
||||
TierConfigMgr::reload_handle_with(&manager, store.clone())
|
||||
.await
|
||||
.expect("peer commit retry should recover");
|
||||
TierConfigMgr::acquire_operation_lease(&manager, "COLD-A")
|
||||
.await
|
||||
.expect("tier should be readable after the retry");
|
||||
})
|
||||
.await;
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(lock_unpoisoned(&calls).len(), 2, "recovery should retry the peer commit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinator_mutation_recovery_scan_blocks_new_operation_lease() {
|
||||
let store = Arc::new(CasConfigStore::default());
|
||||
|
||||
@@ -4608,19 +4608,6 @@ fn marker_purge_receipt_path(
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
/// The first disk slot UUID is assigned when this erasure set is formatted.
|
||||
/// It survives pool reordering and replacement of a disk in that slot.
|
||||
pub fn persistent_set_id(&self) -> Result<Uuid> {
|
||||
self.format
|
||||
.erasure
|
||||
.sets
|
||||
.get(self.set_index)
|
||||
.and_then(|disks| disks.first())
|
||||
.copied()
|
||||
.filter(|id| !id.is_nil())
|
||||
.ok_or_else(|| Error::other("erasure set has no persistent format identity"))
|
||||
}
|
||||
|
||||
/// Persist a quorum DELETE receipt outside the object marker itself. The
|
||||
/// receipt is written to the metadata bucket so it survives a node restart
|
||||
/// while an inaccessible member still holds the stale marker.
|
||||
|
||||
@@ -443,7 +443,6 @@ impl ECStore {
|
||||
},
|
||||
other => StorageError::Lock(other),
|
||||
})?;
|
||||
let ns_guard = Arc::new(ns_guard);
|
||||
|
||||
let recover = Box::pin(async {
|
||||
self.ensure_bucket_volume_absent_on_all_sets(bucket).await?;
|
||||
@@ -931,16 +930,15 @@ impl ECStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cleanup_bucket_usage(&self, bucket: &str, guard: Option<&Arc<rustfs_lock::NamespaceLockGuard>>) -> Result<()> {
|
||||
let namespace_guard = guard.map(Arc::as_ref);
|
||||
run_bucket_usage_cleanup(namespace_guard, bucket, async {
|
||||
crate::data_usage::prepare_bucket_usage_for_namespace_change(bucket, namespace_guard).await?;
|
||||
async fn cleanup_bucket_usage(&self, bucket: &str, guard: Option<&rustfs_lock::NamespaceLockGuard>) -> Result<()> {
|
||||
run_bucket_usage_cleanup(guard, bucket, async {
|
||||
crate::data_usage::prepare_bucket_usage_for_namespace_change(bucket, guard).await?;
|
||||
crate::data_usage::remove_bucket_usage_from_backend_with_guard_fenced(self, bucket, guard).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn cleanup_bucket_usage_best_effort(&self, bucket: &str, guard: Option<&Arc<rustfs_lock::NamespaceLockGuard>>) {
|
||||
async fn cleanup_bucket_usage_best_effort(&self, bucket: &str, guard: Option<&rustfs_lock::NamespaceLockGuard>) {
|
||||
if let Err(err) = self.cleanup_bucket_usage(bucket, guard).await {
|
||||
warn!(
|
||||
bucket = %bucket,
|
||||
@@ -1031,7 +1029,6 @@ impl ECStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ns_guard = ns_guard.map(Arc::new);
|
||||
|
||||
let existing_bucket_info = match self.get_bucket_info_from_sets(bucket, &BucketOptions::default()).await {
|
||||
Ok(info) => Some(info),
|
||||
@@ -1149,7 +1146,7 @@ impl ECStore {
|
||||
metadata_sys::set_new_bucket_metadata_intent_in(&self.ctx, meta.clone()).await?;
|
||||
if bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| ns_guard.as_deref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
{
|
||||
return Err(Error::other(format!("bucket metadata creation intent lock was lost: {bucket}")));
|
||||
}
|
||||
@@ -1165,7 +1162,7 @@ impl ECStore {
|
||||
|
||||
if let Err(err) = await_bucket_lifecycle_operation(
|
||||
bucket_lifecycle_guard.as_ref(),
|
||||
ns_guard.as_deref(),
|
||||
ns_guard.as_ref(),
|
||||
bucket,
|
||||
"physical bucket creation",
|
||||
await_bucket_namespace_operation(
|
||||
@@ -1194,9 +1191,9 @@ impl ECStore {
|
||||
{
|
||||
warn!("best-effort bucket heal after BucketExists failed: {heal_err}");
|
||||
}
|
||||
if confirmed_missing && !is_err_bucket_exists(&err) && ns_guard.as_deref().is_none_or(|guard| !guard.is_lock_lost()) {
|
||||
if confirmed_missing && !is_err_bucket_exists(&err) && ns_guard.as_ref().is_none_or(|guard| !guard.is_lock_lost()) {
|
||||
error!("make bucket failed: {err}");
|
||||
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_deref())
|
||||
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref())
|
||||
.await;
|
||||
}
|
||||
return Err(err);
|
||||
@@ -1217,10 +1214,10 @@ impl ECStore {
|
||||
.await;
|
||||
let metadata_lock_lost = bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| ns_guard.as_deref().is_some_and(|guard| guard.is_lock_lost());
|
||||
|| ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost());
|
||||
if let Err(err) = metadata_result {
|
||||
if !metadata_lock_lost {
|
||||
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_deref())
|
||||
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref())
|
||||
.await;
|
||||
}
|
||||
return Err(err);
|
||||
@@ -1583,7 +1580,6 @@ impl ECStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ns_guard = ns_guard.map(Arc::new);
|
||||
|
||||
let sr_mark_delete = opts.srdelete_op == SRBucketDeleteOp::MarkDelete;
|
||||
let sr_purge = opts.srdelete_op == SRBucketDeleteOp::Purge;
|
||||
@@ -1619,7 +1615,7 @@ impl ECStore {
|
||||
crate::bucket::metadata_sys::get_object_lock_config_and_incarnation_from_disk_with_guard_in(
|
||||
&self.ctx,
|
||||
bucket,
|
||||
Some(ns_guard.as_deref().unwrap_or(&guard)),
|
||||
Some(ns_guard.as_ref().unwrap_or(&guard)),
|
||||
)
|
||||
.await?;
|
||||
match state {
|
||||
@@ -1645,7 +1641,7 @@ impl ECStore {
|
||||
if sr_mark_delete {
|
||||
await_bucket_lifecycle_operation(
|
||||
bucket_lifecycle_guard.as_ref(),
|
||||
ns_guard.as_deref(),
|
||||
ns_guard.as_ref(),
|
||||
bucket,
|
||||
"bucket delete marker creation",
|
||||
await_bucket_namespace_operation(
|
||||
@@ -1684,7 +1680,7 @@ impl ECStore {
|
||||
object_lock_metadata_guard.as_ref().map(|(guard, _)| guard),
|
||||
bucket,
|
||||
"Object Lock configuration fence during bucket deletion",
|
||||
run_physical_bucket_deletion(ns_guard.as_deref(), bucket, async {
|
||||
run_physical_bucket_deletion(ns_guard.as_ref(), bucket, async {
|
||||
self.delete_bucket_on_sets(bucket, &delete_opts)
|
||||
.await
|
||||
.map_err(|err| to_object_err(err, vec![bucket]))
|
||||
@@ -1713,12 +1709,12 @@ impl ECStore {
|
||||
if let Some(guard) = bucket_lifecycle_guard.as_ref() {
|
||||
record_opts.add_bucket_lifecycle_lock_guard(guard);
|
||||
}
|
||||
if let Some(guard) = ns_guard.as_deref() {
|
||||
if let Some(guard) = ns_guard.as_ref() {
|
||||
record_opts.add_namespace_lock_guard(guard);
|
||||
}
|
||||
await_bucket_lifecycle_operation(
|
||||
bucket_lifecycle_guard.as_ref(),
|
||||
ns_guard.as_deref(),
|
||||
ns_guard.as_ref(),
|
||||
bucket,
|
||||
"bucket retirement publication",
|
||||
crate::bucket::retirement::commit_retirement(store.clone(), bucket, incarnation, &record_opts),
|
||||
@@ -1728,7 +1724,7 @@ impl ECStore {
|
||||
// V1 ledger must not block quota admission after same-name recreation.
|
||||
await_bucket_lifecycle_operation(
|
||||
bucket_lifecycle_guard.as_ref(),
|
||||
ns_guard.as_deref(),
|
||||
ns_guard.as_ref(),
|
||||
bucket,
|
||||
"retired bucket quota ledger cleanup",
|
||||
crate::bucket::quota::reservation::cleanup_retired_bucket_ledger(store, bucket, incarnation),
|
||||
@@ -1739,7 +1735,7 @@ impl ECStore {
|
||||
self.cleanup_bucket_usage_best_effort(bucket, ns_guard.as_ref()).await;
|
||||
|
||||
if let Err(err) = self
|
||||
.cleanup_deleted_bucket_metadata(bucket, sr_purge, ns_guard.as_deref())
|
||||
.cleanup_deleted_bucket_metadata(bucket, sr_purge, ns_guard.as_ref())
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
|
||||
@@ -527,7 +527,7 @@ impl ECStore {
|
||||
async fn acquire_heal_format_fence(
|
||||
&self,
|
||||
) -> Result<(
|
||||
crate::core::pools::PoolMetaSaveGateWriteGuard<'_>,
|
||||
tokio::sync::MutexGuard<'_, PoolMetaWriteState>,
|
||||
NamespaceLockGuard,
|
||||
NamespaceLockGuard,
|
||||
PoolMeta,
|
||||
@@ -538,7 +538,7 @@ impl ECStore {
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::other("heal format requires at least one storage pool"))?;
|
||||
let mut write_state = self.pool_meta_save_gate.write().await;
|
||||
let mut write_state = self.pool_meta_save_gate.lock().await;
|
||||
write_state.ensure_write_safe("heal format fence failed")?;
|
||||
|
||||
// Metadata fence order is part of the decommission/rebalance protocol:
|
||||
@@ -1079,7 +1079,7 @@ impl ECStore {
|
||||
pub(super) async fn handle_heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
||||
let movement_gate = self.ctx.data_movement_operation_gate();
|
||||
let _movement_guard = movement_gate.read().await;
|
||||
let save_guard = self.pool_meta_save_gate.write().await;
|
||||
let save_guard = self.pool_meta_save_gate.lock().await;
|
||||
save_guard.ensure_write_safe("bucket heal cannot run while pool metadata requires recovery")?;
|
||||
let mut fenced_pools = BTreeSet::new();
|
||||
{
|
||||
@@ -2527,7 +2527,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::default(),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx: crate::runtime::instance::bootstrap_ctx(),
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
}
|
||||
@@ -3550,14 +3550,10 @@ mod tests {
|
||||
async fn full_format_heal_preblocked_pool_metadata_never_writes_format() {
|
||||
let (temp_dir, store, shutdown) = multi_pool_heal_store().await;
|
||||
remove_heal_test_format(&temp_dir, &store, 0, 3).await;
|
||||
store
|
||||
.pool_meta_save_gate
|
||||
.write()
|
||||
.await
|
||||
.observe_replicas(PoolMetaReplicaState {
|
||||
needs_repair: true,
|
||||
repair_write_safe: false,
|
||||
});
|
||||
store.pool_meta_save_gate.lock().await.observe_replicas(PoolMetaReplicaState {
|
||||
needs_repair: true,
|
||||
repair_write_safe: false,
|
||||
});
|
||||
|
||||
let err = store
|
||||
.handle_heal_format(false)
|
||||
@@ -3692,7 +3688,7 @@ mod tests {
|
||||
let target = remove_heal_test_format(&temp_dir, &store, 0, 3).await;
|
||||
store.pools[0].disk_set[0].disks.write().await[3] = None;
|
||||
let barrier = HealFormatAfterSaveBarrier::install(&store.pools[0], 3);
|
||||
let recovery_latch = store.pool_meta_save_gate.write().await.aborted_transaction_latch_for_test();
|
||||
let recovery_latch = store.pool_meta_save_gate.lock().await.aborted_transaction_latch_for_test();
|
||||
let mut heal = tokio::spawn({
|
||||
let store = Arc::clone(&store);
|
||||
async move { store.handle_heal_format(false).await }
|
||||
@@ -3852,7 +3848,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let endpoints: EndpointServerPools = pools.iter().map(|pool| pool.endpoints.clone()).collect::<Vec<_>>().into();
|
||||
let state = source.pool_meta_save_gate.write().await.independent_clone_for_test();
|
||||
let state = source.pool_meta_save_gate.lock().await.independent_clone_for_test();
|
||||
Arc::new(ECStore {
|
||||
id: Uuid::new_v4(),
|
||||
disk_map: Default::default(),
|
||||
@@ -3862,7 +3858,7 @@ mod tests {
|
||||
rebalance_meta: tokio::sync::RwLock::new(None),
|
||||
decommission_cancelers: tokio::sync::RwLock::new(vec![None; 2]),
|
||||
start_gate: tokio::sync::Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::new(state),
|
||||
pool_meta_save_gate: tokio::sync::Mutex::new(state),
|
||||
ctx: Arc::clone(&source.ctx),
|
||||
bucket_fence_registry: Arc::default(),
|
||||
})
|
||||
@@ -4044,7 +4040,7 @@ mod tests {
|
||||
let missing = remove_pool_meta_shard(&store, 0).await;
|
||||
let (guard, admissions, mut state) = store.acquire_pool_meta_object_heal_fence(&[0]).await.expect("heal authority");
|
||||
assert!(admissions.into_iter().all(|admission| admission.is_ok()));
|
||||
assert!(store.pool_meta_save_gate.try_write().is_err(), "heal must retain the earlier save gate");
|
||||
assert!(store.pool_meta_save_gate.try_lock().is_err(), "heal must retain the earlier save gate");
|
||||
let writer_store = Arc::clone(&store);
|
||||
let writer = tokio::spawn(async move { writer_store.save_current_pool_meta_for_test(&[0]).await });
|
||||
tokio::task::yield_now().await;
|
||||
@@ -4691,14 +4687,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn bucket_heal_blocks_before_dispatch_after_unreadable_pool_meta_replica() {
|
||||
let store = minimal_heal_store().await;
|
||||
store
|
||||
.pool_meta_save_gate
|
||||
.write()
|
||||
.await
|
||||
.observe_replicas(PoolMetaReplicaState {
|
||||
needs_repair: true,
|
||||
repair_write_safe: false,
|
||||
});
|
||||
store.pool_meta_save_gate.lock().await.observe_replicas(PoolMetaReplicaState {
|
||||
needs_repair: true,
|
||||
repair_write_safe: false,
|
||||
});
|
||||
|
||||
let err = store
|
||||
.handle_heal_bucket("bucket", &HealOpts::default())
|
||||
@@ -4732,7 +4724,7 @@ mod tests {
|
||||
.await
|
||||
.expect("peer dispatch should start");
|
||||
assert!(
|
||||
store.pool_meta_save_gate.try_write().is_ok(),
|
||||
store.pool_meta_save_gate.try_lock().is_ok(),
|
||||
"coordinator must release its local save gate before waiting for peers"
|
||||
);
|
||||
assert!(
|
||||
@@ -5124,7 +5116,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::default(),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx: crate::runtime::instance::bootstrap_ctx(),
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
};
|
||||
@@ -5144,7 +5136,7 @@ mod tests {
|
||||
.save_for_startup(store.pools.clone())
|
||||
.await
|
||||
.expect("pool metadata should be persisted before format heal");
|
||||
*store.pool_meta_save_gate.write().await = crate::core::pools::PoolMetaWriteState::default();
|
||||
*store.pool_meta_save_gate.lock().await = PoolMetaWriteState::default();
|
||||
|
||||
let replacement_endpoint = store.pools[1].endpoints.endpoints.as_ref()[2].clone();
|
||||
tokio::fs::remove_file(
|
||||
|
||||
@@ -676,7 +676,7 @@ impl ECStore {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers,
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::new(pool_meta_write_state),
|
||||
pool_meta_save_gate: Mutex::new(pool_meta_write_state),
|
||||
// Adopt the caller's context (the process bootstrap one on the
|
||||
// legacy path) so startup writes (erasure type recorded before
|
||||
// this point) and later reads share one cell.
|
||||
@@ -725,7 +725,7 @@ impl ECStore {
|
||||
// Lock order is save gate -> distributed metadata writer, matching
|
||||
// runtime mutations. RPC locks are available before ECStore publication
|
||||
// and do not depend on the later fleet proof.
|
||||
let mut startup_write_state = self.pool_meta_save_gate.write().await;
|
||||
let mut startup_write_state = self.pool_meta_save_gate.lock().await;
|
||||
let startup_pool_meta_guard = if should_persist_pool_meta {
|
||||
let pool = self
|
||||
.pools
|
||||
|
||||
@@ -127,30 +127,28 @@ impl AuthoritativeListOperations for ECStore {
|
||||
marker: Option<String>,
|
||||
max_keys: i32,
|
||||
) -> Result<ListObjectVersionsInfo> {
|
||||
self.list_unversioned_authoritative_page(bucket, prefix, marker, max_keys, None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_identifier_tree_leaves_authoritative(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
leaf: &str,
|
||||
marker: Option<String>,
|
||||
max_keys: i32,
|
||||
) -> Result<ListObjectVersionsInfo> {
|
||||
let valid_prefix = prefix
|
||||
.strip_suffix('/')
|
||||
.is_some_and(|root| !root.is_empty() && root.split('/').all(|part| !part.is_empty() && !matches!(part, "." | "..")));
|
||||
if !valid_prefix || !crate::disk::is_identifier_tree_leaf(leaf) {
|
||||
if max_keys <= 0 || max_keys > MAX_OBJECT_LIST {
|
||||
return Err(StorageError::InvalidArgument(
|
||||
bucket.to_string(),
|
||||
prefix.to_string(),
|
||||
"identifier-tree listing requires a directory prefix and a dotted leaf name".to_string(),
|
||||
"authoritative page size is out of range".to_string(),
|
||||
));
|
||||
}
|
||||
self.list_unversioned_authoritative_page(bucket, prefix, marker, max_keys, Some(leaf.to_string()))
|
||||
.await
|
||||
let opts = ListPathOptions {
|
||||
bucket: bucket.to_string(),
|
||||
prefix: prefix.to_string(),
|
||||
marker: marker.clone(),
|
||||
limit: max_keys,
|
||||
recursive: true,
|
||||
incl_deleted: true,
|
||||
versioned: true,
|
||||
authoritative: true,
|
||||
caller: ListObjectsGatherCaller::S3ListVersions,
|
||||
..Default::default()
|
||||
};
|
||||
let mut page = self.list_path(&opts).await?;
|
||||
page.entries.forward_past(marker);
|
||||
unversioned_authoritative_page(bucket, page)
|
||||
}
|
||||
|
||||
async fn walk_authoritative(
|
||||
@@ -438,8 +436,6 @@ pub struct ListPathOptions {
|
||||
// Internal bounded pages require GET-equivalent per-set resolution.
|
||||
pub authoritative: bool,
|
||||
|
||||
pub identifier_tree_leaf: Option<String>,
|
||||
|
||||
pub stop_disk_at_limit: bool,
|
||||
|
||||
pub pool_idx: Option<usize>,
|
||||
@@ -1105,39 +1101,6 @@ impl ListPathOptions {
|
||||
}
|
||||
|
||||
impl ECStore {
|
||||
async fn list_unversioned_authoritative_page(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
marker: Option<String>,
|
||||
max_keys: i32,
|
||||
identifier_tree_leaf: Option<String>,
|
||||
) -> Result<ListObjectVersionsInfo> {
|
||||
if max_keys <= 0 || max_keys > MAX_OBJECT_LIST {
|
||||
return Err(StorageError::InvalidArgument(
|
||||
bucket.to_string(),
|
||||
prefix.to_string(),
|
||||
"authoritative page size is out of range".to_string(),
|
||||
));
|
||||
}
|
||||
let opts = ListPathOptions {
|
||||
bucket: bucket.to_string(),
|
||||
prefix: prefix.to_string(),
|
||||
marker: marker.clone(),
|
||||
limit: max_keys,
|
||||
recursive: true,
|
||||
incl_deleted: true,
|
||||
versioned: true,
|
||||
authoritative: true,
|
||||
identifier_tree_leaf,
|
||||
caller: ListObjectsGatherCaller::S3ListVersions,
|
||||
..Default::default()
|
||||
};
|
||||
let mut page = self.list_path(&opts).await?;
|
||||
page.entries.forward_past(marker);
|
||||
unversioned_authoritative_page(bucket, page)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
// @continuation_token marker
|
||||
// @start_after as marker when continuation_token empty
|
||||
@@ -2973,33 +2936,6 @@ impl SetDisks {
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
fn resolve_authoritative_listed_entry(
|
||||
bucket: &str,
|
||||
selection: Option<(&str, &str)>,
|
||||
entries: MetaCacheEntries,
|
||||
errors: &[Option<DiskError>],
|
||||
disk_count: usize,
|
||||
parity: usize,
|
||||
) -> crate::disk::error::Result<Option<MetaCacheEntry>> {
|
||||
if disk_count == 0 || entries.0.len() > disk_count || entries.0.len() != errors.len() {
|
||||
return Err(DiskError::ErasureReadQuorum);
|
||||
}
|
||||
// An old peer may ignore the optional disk selector. Its noncandidate
|
||||
// rows must be ignored before metadata parsing. Selected keys retain
|
||||
// the original resolver and all of its quorum/corruption checks.
|
||||
if selection.is_some_and(|(prefix, leaf)| {
|
||||
entries
|
||||
.0
|
||||
.iter()
|
||||
.flatten()
|
||||
.next()
|
||||
.is_some_and(|entry| !crate::disk::identifier_tree_leaf_matches(prefix, leaf, &entry.name))
|
||||
}) {
|
||||
return Ok(None);
|
||||
}
|
||||
Self::resolve_listed_versions(bucket, entries, errors, disk_count, parity)
|
||||
}
|
||||
|
||||
/// Version scan for set-scoped callers that act on absence: set walks
|
||||
/// (tier reference proofs, tier-delete journals, pool maintenance) and the
|
||||
/// recursive prefix-delete scan. Every online drive is read and each
|
||||
@@ -3023,8 +2959,6 @@ impl SetDisks {
|
||||
let first_error = Arc::new(tokio::sync::Mutex::new(None));
|
||||
let error_sink = Arc::clone(&first_error);
|
||||
let bucket = opts.bucket.clone();
|
||||
let prefix = opts.prefix.clone();
|
||||
let identifier_tree_leaf = opts.identifier_tree_leaf.clone();
|
||||
let cancel = rx.clone();
|
||||
let result = list_path_raw_with_claim_tracker(
|
||||
rx,
|
||||
@@ -3042,7 +2976,6 @@ impl SetDisks {
|
||||
forward_to: opts.marker,
|
||||
min_disks: read_quorum,
|
||||
preserve_replica_metadata: true,
|
||||
identifier_tree_leaf: opts.identifier_tree_leaf,
|
||||
// A reader's local page may be consumed by stale entries. Only
|
||||
// the resolved, merged page may stop the authoritative walk.
|
||||
per_disk_limit: 0,
|
||||
@@ -3050,14 +2983,7 @@ impl SetDisks {
|
||||
walkdir_timeout: opts.walkdir_timeout,
|
||||
walkdir_stall_timeout: opts.walkdir_stall_timeout,
|
||||
partial: Some(Box::new(move |entries, errors| {
|
||||
let resolved = Self::resolve_authoritative_listed_entry(
|
||||
&bucket,
|
||||
identifier_tree_leaf.as_deref().map(|leaf| (prefix.as_str(), leaf)),
|
||||
entries,
|
||||
errors,
|
||||
disk_count,
|
||||
parity,
|
||||
);
|
||||
let resolved = Self::resolve_listed_versions(&bucket, entries, errors, disk_count, parity);
|
||||
let sender = sender.clone();
|
||||
let cancel = cancel.clone();
|
||||
let first_error = Arc::clone(&error_sink);
|
||||
@@ -7071,155 +6997,6 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_identifier_tree_filters_old_peer_rows_before_metadata_resolution() {
|
||||
let prefix = "table-catalog/namespaces/";
|
||||
let leaf = "table-entry.json";
|
||||
let corrupt = |name: &str| {
|
||||
MetaCacheEntries(vec![
|
||||
Some(MetaCacheEntry {
|
||||
name: format!("{prefix}{name}"),
|
||||
metadata: b"corrupt metadata".to_vec(),
|
||||
..Default::default()
|
||||
});
|
||||
4
|
||||
])
|
||||
};
|
||||
let report = "sales/tables/orders/maintenance/hash/jobs/job.json";
|
||||
let errors = vec![None; 4];
|
||||
assert!(
|
||||
SetDisks::resolve_authoritative_listed_entry(RUSTFS_META_BUCKET, None, corrupt(report), &errors, 4, 2,).is_err(),
|
||||
"the control must actually parse and reject the corrupt metadata"
|
||||
);
|
||||
assert!(
|
||||
SetDisks::resolve_authoritative_listed_entry(
|
||||
RUSTFS_META_BUCKET,
|
||||
Some((prefix, leaf)),
|
||||
corrupt(report),
|
||||
&errors,
|
||||
4,
|
||||
2,
|
||||
)
|
||||
.expect("an old peer's unrelated report is outside the selected scan")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
SetDisks::resolve_authoritative_listed_entry(
|
||||
RUSTFS_META_BUCKET,
|
||||
Some((prefix, leaf)),
|
||||
corrupt("sales/tables/orders/maintenance/tables/owner/table-entry.json"),
|
||||
&errors,
|
||||
4,
|
||||
2,
|
||||
)
|
||||
.is_err(),
|
||||
"a matching nested owner must still fail closed on corruption"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authoritative_identifier_tree_pages_preserve_nested_owners_and_read_quorum() {
|
||||
let (dirs, store) = single_set_store(4).await;
|
||||
let prefix = "table-catalog/identifier-tree/";
|
||||
let leaf = "table-entry.json";
|
||||
for (root, selected) in [
|
||||
("table-catalog/identifier-tree", leaf),
|
||||
(prefix, "../entry.json"),
|
||||
(prefix, "table-entry"),
|
||||
] {
|
||||
assert!(matches!(
|
||||
store
|
||||
.clone()
|
||||
.list_identifier_tree_leaves_authoritative(RUSTFS_META_BUCKET, root, selected, None, 2)
|
||||
.await,
|
||||
Err(StorageError::InvalidArgument(..))
|
||||
));
|
||||
}
|
||||
let names = [
|
||||
format!("{prefix}a/tables/first/{leaf}"),
|
||||
format!("{prefix}sales/tables/orders/maintenance/tables/owner/{leaf}"),
|
||||
format!("{prefix}z/tables/last/{leaf}"),
|
||||
];
|
||||
for name in &names {
|
||||
write_version_replicas(&dirs, RUSTFS_META_BUCKET, name, |slot| Some(unversioned_catalog_metadata(name, slot))).await;
|
||||
}
|
||||
for name in [
|
||||
format!("{prefix}sales"),
|
||||
format!("{prefix}sales/tables/orders/maintenance/hash/jobs/job.json"),
|
||||
] {
|
||||
write_version_replicas(&dirs, RUSTFS_META_BUCKET, &name, |_| Some(b"corrupt noncandidate metadata".to_vec())).await;
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.clone()
|
||||
.list_unversioned_objects_authoritative(RUSTFS_META_BUCKET, prefix, None, 100)
|
||||
.await
|
||||
.is_err(),
|
||||
"the ordinary authoritative scan must expose the corrupt control objects"
|
||||
);
|
||||
let first = store
|
||||
.clone()
|
||||
.list_identifier_tree_leaves_authoritative(RUSTFS_META_BUCKET, prefix, leaf, None, 2)
|
||||
.await
|
||||
.expect("noncandidate metadata must not be read");
|
||||
assert!(first.is_truncated);
|
||||
assert_eq!(
|
||||
first.objects.iter().map(|object| &object.name).collect::<Vec<_>>(),
|
||||
names[..2].iter().collect::<Vec<_>>()
|
||||
);
|
||||
let second = store
|
||||
.clone()
|
||||
.list_identifier_tree_leaves_authoritative(RUSTFS_META_BUCKET, prefix, leaf, first.next_marker, 2)
|
||||
.await
|
||||
.expect("the next page must retain the final owner");
|
||||
assert!(!second.is_truncated);
|
||||
assert_eq!(second.objects.iter().map(|object| &object.name).collect::<Vec<_>>(), vec![&names[2]]);
|
||||
|
||||
write_version_replicas(&dirs, RUSTFS_META_BUCKET, &names[0], |_| Some(b"corrupt selected metadata".to_vec())).await;
|
||||
assert!(
|
||||
store
|
||||
.clone()
|
||||
.list_identifier_tree_leaves_authoritative(RUSTFS_META_BUCKET, prefix, leaf, None, 2)
|
||||
.await
|
||||
.is_err(),
|
||||
"corruption at a selected key cannot be skipped to fill the page"
|
||||
);
|
||||
write_version_replicas(&dirs, RUSTFS_META_BUCKET, &names[0], |slot| {
|
||||
Some(unversioned_catalog_metadata(&names[0], slot))
|
||||
})
|
||||
.await;
|
||||
write_version_replicas(&dirs, RUSTFS_META_BUCKET, &names[0], |slot| {
|
||||
Some(versioned_object_metadata(RUSTFS_META_BUCKET, &names[0], &[(2, 1_700_000_002)], slot).to_vec())
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
store
|
||||
.clone()
|
||||
.list_identifier_tree_leaves_authoritative(RUSTFS_META_BUCKET, prefix, leaf, None, 2)
|
||||
.await
|
||||
.is_err(),
|
||||
"a versioned ownership entry cannot be accepted as an unversioned proof"
|
||||
);
|
||||
write_version_replicas(&dirs, RUSTFS_META_BUCKET, &names[0], |slot| {
|
||||
Some(unversioned_catalog_metadata(&names[0], slot))
|
||||
})
|
||||
.await;
|
||||
let set = &store.pools[0].disk_set[0];
|
||||
for disk in set.disks.write().await.iter_mut().skip(1) {
|
||||
*disk = None;
|
||||
}
|
||||
assert!(
|
||||
matches!(
|
||||
store
|
||||
.clone()
|
||||
.list_identifier_tree_leaves_authoritative(RUSTFS_META_BUCKET, prefix, leaf, None, 2)
|
||||
.await,
|
||||
Err(StorageError::ErasureReadQuorum)
|
||||
),
|
||||
"unavailable replicas cannot prove owner absence"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authoritative_store_walk_includes_reserved_prefix_versions() {
|
||||
let bucket = "catalog-cleanup-versions";
|
||||
|
||||
@@ -34,7 +34,7 @@ use crate::bucket::utils::check_put_object_part_args;
|
||||
use crate::bucket::utils::{check_valid_bucket_name, check_valid_bucket_name_strict, is_meta_bucketname};
|
||||
use crate::cluster::rpc::{RemoteClient, S3PeerSys};
|
||||
use crate::config::storageclass;
|
||||
use crate::core::pools::{DecommissionCanceler, PoolMeta, PoolMetaSaveGate};
|
||||
use crate::core::pools::{DecommissionCanceler, PoolMeta, PoolMetaWriteState};
|
||||
use crate::disk::endpoint::{Endpoint, EndpointType};
|
||||
use crate::disk::{DiskAPI, DiskInfo, DiskInfoOptions};
|
||||
use crate::error::{Error, Result};
|
||||
@@ -480,13 +480,9 @@ pub struct ECStore {
|
||||
/// Serializes full-document pool metadata saves and retains a fail-closed
|
||||
/// write block after startup observes an unreadable replica.
|
||||
///
|
||||
/// Immutable admission proofs share authority; publication, recovery and
|
||||
/// authority changes retain exclusive access through their existing I/O.
|
||||
/// Each writer publishes its final state on release, and health
|
||||
/// inspection reads that committed state without acquiring the gate.
|
||||
/// Lock order: acquire `pool_meta_save_gate`, then the distributed
|
||||
/// `pool.bin` fence, then clone `pool_meta` under a short read lock.
|
||||
pub(crate) pool_meta_save_gate: PoolMetaSaveGate,
|
||||
pub(crate) pool_meta_save_gate: Mutex<PoolMetaWriteState>,
|
||||
/// Per-instance runtime state (Phase 5, backlog#939).
|
||||
///
|
||||
/// Carries this instance's identity/runtime out of the process globals so
|
||||
@@ -559,6 +555,7 @@ impl Default for ScannerDataMovementPauseStatus {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct PoolMetaWriteGateStatus {
|
||||
pub writes_ready: bool,
|
||||
pub check_timed_out: bool,
|
||||
pub write_blocked: bool,
|
||||
pub transaction_aborted: bool,
|
||||
pub pool_meta_absent: bool,
|
||||
@@ -574,6 +571,7 @@ impl Default for PoolMetaWriteGateStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
writes_ready: true,
|
||||
check_timed_out: false,
|
||||
write_blocked: false,
|
||||
transaction_aborted: false,
|
||||
pool_meta_absent: false,
|
||||
@@ -1120,20 +1118,6 @@ impl ECStore {
|
||||
Some(epoch)
|
||||
}
|
||||
|
||||
/// Namespace cleanup edits an existing usage baseline; it does not certify
|
||||
/// a scan. Retain the movement fence without requiring scanning to resume.
|
||||
pub(crate) async fn data_usage_namespace_cleanup_guard(&self) -> Option<(tokio::sync::OwnedRwLockReadGuard<()>, u64)> {
|
||||
let operation_guard = self.ctx.data_movement_operation_gate().read_owned().await;
|
||||
if self.ctx.data_movement_operation_epoch_exhausted() || self.ctx.data_movement_generation_exhausted() {
|
||||
return None;
|
||||
}
|
||||
Some((operation_guard, self.ctx.data_movement_operation_epoch()))
|
||||
}
|
||||
|
||||
pub(crate) async fn data_usage_namespace_cleanup_epoch(&self) -> Option<u64> {
|
||||
self.data_usage_namespace_cleanup_guard().await.map(|(_, epoch)| epoch)
|
||||
}
|
||||
|
||||
/// Acquire a storage-owned read admission for a coordinator's final
|
||||
/// scanner publication. The guard remains in the context's lease table,
|
||||
/// so a local movement writer cannot pass the peer while its authoritative
|
||||
@@ -1952,7 +1936,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::default(),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx,
|
||||
bucket_fence_registry: Arc::default(),
|
||||
};
|
||||
@@ -2027,7 +2011,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::default(),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx,
|
||||
bucket_fence_registry: Arc::default(),
|
||||
})
|
||||
|
||||
@@ -2133,7 +2133,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::default(),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx: crate::runtime::instance::bootstrap_ctx(),
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
}
|
||||
|
||||
@@ -4937,87 +4937,6 @@ impl ECStore {
|
||||
Ok((idx, result))
|
||||
}
|
||||
|
||||
/// Compare the logical revision across all pools before publishing a
|
||||
/// namespace-only usage update to a writable pool. A suspended source is
|
||||
/// retained for decommission to reconcile against the newer target.
|
||||
pub(crate) async fn put_usage_snapshot_for_namespace_cleanup(
|
||||
&self,
|
||||
object: &str,
|
||||
expected_revision: Option<&str>,
|
||||
data: &mut PutObjReader,
|
||||
namespace_guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
) -> Result<ObjectInfo> {
|
||||
// The caller owns the bucket namespace and movement guards. Acquire
|
||||
// the fixed object domain, then every physical domain in pool order.
|
||||
let guards = self
|
||||
.acquire_all_physical_object_write_locks("usage_namespace_cleanup", RUSTFS_META_BUCKET, object)
|
||||
.await?;
|
||||
let mut opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
max_parity: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
suppress_read_repair: true,
|
||||
..Default::default()
|
||||
};
|
||||
for guard in &guards {
|
||||
guard.add_namespace_lock_fence(&mut opts);
|
||||
}
|
||||
if let Some(guard) = namespace_guard {
|
||||
opts.add_namespace_lock_guard(guard);
|
||||
}
|
||||
let source = match self
|
||||
.get_latest_object_info_for_conditional_write(RUSTFS_META_BUCKET, object, &opts)
|
||||
.await
|
||||
.and_then(|(info, idx)| resolve_latest_object_access(RUSTFS_META_BUCKET, object, info, idx, &opts))
|
||||
{
|
||||
Ok((info, _)) => Some(info),
|
||||
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => None,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if source.as_ref().and_then(|info| info.etag.as_deref()) != expected_revision {
|
||||
return Err(StorageError::PreconditionFailed);
|
||||
}
|
||||
if source.as_ref().is_some_and(|info| {
|
||||
info.version_id.is_some_and(|version| !version.is_nil()) || info.etag.as_ref().is_none_or(String::is_empty)
|
||||
}) {
|
||||
return Err(StorageError::other("usage cleanup requires an unversioned snapshot with a revision"));
|
||||
}
|
||||
let target = self.get_pool_idx_no_lock(RUSTFS_META_BUCKET, object, data.size()).await?;
|
||||
let target_info = match self.pools[target].get_object_info(RUSTFS_META_BUCKET, object, &opts).await {
|
||||
Ok(info) => Some(info),
|
||||
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => None,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
opts.http_preconditions = Some(match target_info.as_ref() {
|
||||
Some(info) => crate::storage_api_contracts::object::HTTPPreconditions {
|
||||
if_match: Some(
|
||||
info.etag
|
||||
.as_ref()
|
||||
.filter(|etag| !etag.is_empty())
|
||||
.cloned()
|
||||
.ok_or_else(|| StorageError::other("usage cleanup target has no revision"))?,
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
None => crate::storage_api_contracts::object::HTTPPreconditions {
|
||||
if_none_match: Some("*".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
let latest = source
|
||||
.iter()
|
||||
.chain(target_info.iter())
|
||||
.filter_map(|info| info.mod_time)
|
||||
.fold(OffsetDateTime::now_utc(), std::cmp::max);
|
||||
opts.mod_time = Some(
|
||||
latest
|
||||
.checked_add(time::Duration::nanoseconds(1))
|
||||
.ok_or_else(|| StorageError::other("usage cleanup snapshot timestamp is exhausted"))?,
|
||||
);
|
||||
opts.decommission_capacity_admission = crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
self.pools[target].put_object(RUSTFS_META_BUCKET, object, data, &opts).await
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, data))]
|
||||
#[hotpath::measure(impl_type = "ECStore")]
|
||||
pub(super) async fn handle_put_object(
|
||||
@@ -7086,7 +7005,7 @@ mod tests {
|
||||
};
|
||||
use crate::set_disk::same_distributed_lock_domain;
|
||||
use crate::set_disk::{SetDisks, disk_call_counters};
|
||||
use crate::storage_api_contracts::bucket::{BucketOptions, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::bucket::MakeBucketOptions;
|
||||
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
||||
use crate::storage_api_contracts::list::ListOperations as _;
|
||||
use bytes::Bytes;
|
||||
@@ -10084,7 +10003,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::default(),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx: crate::runtime::instance::bootstrap_ctx(),
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
}
|
||||
@@ -10176,7 +10095,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: crate::core::pools::PoolMetaSaveGate::default(),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx,
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
}
|
||||
@@ -10428,296 +10347,6 @@ mod tests {
|
||||
multipool_delete_test_store(bucket, true).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_fresh_bucket_after_canceled_decommission() {
|
||||
let (_dirs, store) = multipool_version_test_store("canceled-decommission-existing").await;
|
||||
let mut canceled = prepared_pool_test_status(1, false);
|
||||
canceled.decommission = Some(PoolDecommissionInfo {
|
||||
canceled: true,
|
||||
..Default::default()
|
||||
});
|
||||
*store.pool_meta.write().await = PoolMeta {
|
||||
pools: vec![prepared_pool_test_status(0, false), canceled],
|
||||
..Default::default()
|
||||
};
|
||||
store
|
||||
.handle_make_bucket("canceled-decommission-fresh", &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("canceled decommission must not block creation of a new bucket");
|
||||
assert!(
|
||||
store.scanner_data_usage_publication_blocked().await,
|
||||
"scanner publication must remain fenced"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_bucket_after_failed_decommission() {
|
||||
let (_dirs, store) = multipool_version_test_store("decommission-existing-bucket").await;
|
||||
let bucket = "decommission-new-bucket";
|
||||
let snapshot_time = std::time::SystemTime::now() - Duration::from_secs(60);
|
||||
let mut snapshot = rustfs_data_usage::DataUsageInfo {
|
||||
last_update: Some(snapshot_time),
|
||||
scanner_cycle: Some(37),
|
||||
scanner_epoch: Some(2),
|
||||
buckets_count: 1,
|
||||
usage_snapshot_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
snapshot.buckets_usage.insert(
|
||||
bucket.to_string(),
|
||||
rustfs_data_usage::BucketUsageInfo {
|
||||
size: 42,
|
||||
objects_count: 1,
|
||||
versions_count: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
snapshot.bucket_sizes.insert(bucket.to_string(), 42);
|
||||
snapshot.calculate_totals();
|
||||
crate::data_usage::store_data_usage_in_backend(snapshot, store.clone())
|
||||
.await
|
||||
.expect("store a stale bucket-usage baseline before the failed decommission");
|
||||
let (old_snapshot, usage_owner) = store
|
||||
.get_latest_accessible_object_info_with_idx(
|
||||
RUSTFS_META_BUCKET,
|
||||
crate::data_usage::DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("identify the pool holding the old usage baseline");
|
||||
let mut pool_statuses = vec![prepared_pool_test_status(0, false), prepared_pool_test_status(1, false)];
|
||||
pool_statuses[usage_owner].decommission = Some(PoolDecommissionInfo {
|
||||
failed: true,
|
||||
..Default::default()
|
||||
});
|
||||
*store.pool_meta.write().await = PoolMeta {
|
||||
pools: pool_statuses,
|
||||
..Default::default()
|
||||
};
|
||||
store
|
||||
.handle_make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("a failed decommission must not block creation of an unrelated bucket");
|
||||
store
|
||||
.handle_get_bucket_info(bucket, &BucketOptions::default())
|
||||
.await
|
||||
.expect("the created bucket must be visible");
|
||||
let usage = crate::data_usage::load_data_usage_from_backend(store.clone())
|
||||
.await
|
||||
.expect("load the cleaned usage baseline");
|
||||
assert!(!usage.buckets_usage.contains_key(bucket), "the new bucket must not inherit old usage");
|
||||
assert_eq!(usage.scanner_cycle, Some(37), "namespace cleanup must not certify a new scan");
|
||||
assert_eq!(usage.scanner_epoch, Some(2), "namespace cleanup must preserve the scanner authority");
|
||||
let object = crate::data_usage::DATA_USAGE_OBJ_NAME_PATH.as_str();
|
||||
let (_, current_owner) = store
|
||||
.get_latest_accessible_object_info_with_idx(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("read the new snapshot authority");
|
||||
assert_ne!(current_owner, usage_owner, "cleanup must publish to a writable pool");
|
||||
let retained_source = store.pools[usage_owner]
|
||||
.get_object_info(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("decommission retains its original source for reconciliation");
|
||||
assert_eq!(retained_source.etag, old_snapshot.etag);
|
||||
let stale_update = store
|
||||
.put_usage_snapshot_for_namespace_cleanup(
|
||||
object,
|
||||
old_snapshot.etag.as_deref(),
|
||||
&mut PutObjReader::from_vec(b"stale snapshot".to_vec()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(stale_update, Err(StorageError::PreconditionFailed)));
|
||||
let usage = crate::data_usage::load_data_usage_from_backend(store.clone())
|
||||
.await
|
||||
.expect("a stale source CAS must not replace the writable target");
|
||||
assert!(!usage.buckets_usage.contains_key(bucket));
|
||||
assert!(
|
||||
store.scanner_data_usage_publication_blocked().await,
|
||||
"scanner publication must remain fenced"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canceled_create_retains_usage_cleanup_namespace_owner() {
|
||||
use crate::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
|
||||
let (_dirs, store) = multipool_version_test_store("cleanup-cancellation-existing").await;
|
||||
let bucket = "cleanup-cancellation-new";
|
||||
let mut snapshot = rustfs_data_usage::DataUsageInfo {
|
||||
last_update: Some(std::time::SystemTime::now()),
|
||||
usage_snapshot_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
snapshot.buckets_usage.insert(
|
||||
bucket.to_owned(),
|
||||
rustfs_data_usage::BucketUsageInfo {
|
||||
size: 42,
|
||||
objects_count: 1,
|
||||
versions_count: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
snapshot.bucket_sizes.insert(bucket.to_owned(), 42);
|
||||
snapshot.calculate_totals();
|
||||
crate::data_usage::store_data_usage_in_backend(snapshot, store.clone())
|
||||
.await
|
||||
.expect("seed the stale namespace usage");
|
||||
let object = crate::data_usage::DATA_USAGE_OBJ_NAME_PATH.as_str();
|
||||
let (_, source) = store
|
||||
.get_latest_accessible_object_info_with_idx(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("find the source snapshot");
|
||||
let mut pools = vec![prepared_pool_test_status(0, false), prepared_pool_test_status(1, false)];
|
||||
pools[source].decommission = Some(PoolDecommissionInfo {
|
||||
canceled: true,
|
||||
..Default::default()
|
||||
});
|
||||
*store.pool_meta.write().await = PoolMeta {
|
||||
pools,
|
||||
..Default::default()
|
||||
};
|
||||
let barrier = PutObjectCommitBarrier::install(RUSTFS_META_BUCKET, object, PutObjectCommitPause::AfterNamespace);
|
||||
let create_store = store.clone();
|
||||
let create = tokio::spawn(async move { create_store.handle_make_bucket(bucket, &MakeBucketOptions::default()).await });
|
||||
tokio::time::timeout(Duration::from_secs(10), barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("cleanup reaches its physical commit with the namespace held");
|
||||
create.abort();
|
||||
assert!(create.await.expect_err("the request waiter was canceled").is_cancelled());
|
||||
let namespace = store
|
||||
.new_ns_lock(bucket, bucket)
|
||||
.await
|
||||
.expect("open the bucket namespace lock");
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), namespace.get_write_lock(Duration::from_secs(1)))
|
||||
.await
|
||||
.is_err(),
|
||||
"cancellation must not release the namespace before its cleanup finishes"
|
||||
);
|
||||
barrier.release();
|
||||
drop(barrier);
|
||||
tokio::time::timeout(Duration::from_secs(10), store.handle_make_bucket(bucket, &MakeBucketOptions::default()))
|
||||
.await
|
||||
.expect("the resumed create must not remain blocked")
|
||||
.expect("a new request resumes after the old cleanup releases its namespace");
|
||||
let usage = crate::data_usage::load_data_usage_from_backend(store.clone())
|
||||
.await
|
||||
.expect("read the cleaned baseline");
|
||||
assert!(!usage.buckets_usage.contains_key(bucket));
|
||||
assert!(store.scanner_data_usage_publication_blocked().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_cleanup_rejects_an_unreadable_pool() {
|
||||
let (_dirs, store) = multipool_version_test_store("cleanup-unreadable-pool").await;
|
||||
let snapshot = rustfs_data_usage::DataUsageInfo {
|
||||
last_update: Some(std::time::SystemTime::now()),
|
||||
usage_snapshot_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
crate::data_usage::store_data_usage_in_backend(snapshot, store.clone())
|
||||
.await
|
||||
.expect("seed the usage baseline");
|
||||
let object = crate::data_usage::DATA_USAGE_OBJ_NAME_PATH.as_str();
|
||||
let (before, source) = store
|
||||
.get_latest_accessible_object_info_with_idx(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("find the baseline owner");
|
||||
let unavailable = 1 - source;
|
||||
for disk in store.pools[unavailable]
|
||||
.get_disks_by_key(object)
|
||||
.disks
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.flatten()
|
||||
{
|
||||
disk.close().await.expect("stop disk health monitoring");
|
||||
disk.force_offline_for_test();
|
||||
}
|
||||
let update = store
|
||||
.put_usage_snapshot_for_namespace_cleanup(
|
||||
object,
|
||||
before.etag.as_deref(),
|
||||
&mut PutObjReader::from_vec(b"unproven authority".to_vec()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(update.is_err(), "an unreadable pool may hold a newer snapshot");
|
||||
let after = store.pools[source]
|
||||
.get_object_info(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the readable baseline remains intact");
|
||||
assert_eq!(after.etag, before.etag);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_cleanup_rejects_namespace_loss_before_physical_commit() {
|
||||
use crate::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
use rustfs_lock::{LocalClient, LockRequest, LockType, NamespaceLock, ObjectKey};
|
||||
|
||||
let (_dirs, store) = multipool_version_test_store("cleanup-fence-existing").await;
|
||||
let snapshot = rustfs_data_usage::DataUsageInfo {
|
||||
last_update: Some(std::time::SystemTime::now()),
|
||||
usage_snapshot_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
crate::data_usage::store_data_usage_in_backend(snapshot, store.clone())
|
||||
.await
|
||||
.expect("seed the protected baseline");
|
||||
let object = crate::data_usage::DATA_USAGE_OBJ_NAME_PATH.as_str();
|
||||
let (before, _) = store
|
||||
.get_latest_accessible_object_info_with_idx(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("read the baseline revision");
|
||||
let ttl = Duration::from_secs(60);
|
||||
let namespace = NamespaceLock::new("usage-cleanup-commit-fence".to_owned(), Arc::new(LocalClient::new()));
|
||||
let request = LockRequest::new(ObjectKey::new("cleanup-fence-existing", ""), LockType::Exclusive, "usage-cleanup")
|
||||
.with_acquire_timeout(Duration::from_secs(1))
|
||||
.with_ttl(ttl)
|
||||
.with_refresh_interval(ttl);
|
||||
let guard = Arc::new(
|
||||
namespace
|
||||
.acquire_guard(&request)
|
||||
.await
|
||||
.expect("acquire the namespace lease")
|
||||
.expect("namespace lease is available"),
|
||||
);
|
||||
let barrier = PutObjectCommitBarrier::install(RUSTFS_META_BUCKET, object, PutObjectCommitPause::AfterNamespace);
|
||||
let update_store = store.clone();
|
||||
let update_guard = guard.clone();
|
||||
let revision = before.etag.clone();
|
||||
let update = tokio::spawn(async move {
|
||||
let _movement = update_store
|
||||
.data_usage_namespace_cleanup_guard()
|
||||
.await
|
||||
.expect("admit the namespace-only update");
|
||||
update_store
|
||||
.put_usage_snapshot_for_namespace_cleanup(
|
||||
object,
|
||||
revision.as_deref(),
|
||||
&mut PutObjReader::from_vec(b"must not commit".to_vec()),
|
||||
Some(update_guard.as_ref()),
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("the update reaches its commit barrier");
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(ttl + Duration::from_millis(1)).await;
|
||||
assert!(guard.is_lock_lost(), "the parent namespace lease has expired");
|
||||
tokio::time::resume();
|
||||
barrier.release();
|
||||
assert!(update.await.expect("the cleanup task completes").is_err());
|
||||
let (after, _) = store
|
||||
.get_latest_accessible_object_info_with_idx(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the original baseline remains readable");
|
||||
assert_eq!(after.etag, before.etag, "a lost namespace lease must fence the physical write");
|
||||
}
|
||||
|
||||
async fn multipool_delete_test_store(bucket: &str, versioned: bool) -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let (mut dirs, first_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
|
||||
|
||||
@@ -159,7 +159,7 @@ pub(crate) fn notify_configuration_hint() -> String {
|
||||
format!(
|
||||
"No notify targets configured. Check {}=true and instance-scoped target env vars (for example {webhook_enable_primary} + {webhook_endpoint_primary} for arn:rustfs:sqs::primary:webhook). If using default queue_dir, ensure {} is writable.",
|
||||
rustfs_config::ENV_NOTIFY_ENABLE,
|
||||
rustfs_config::EVENT_DEFAULT_DIR,
|
||||
rustfs_targets::store::resolve_queue_dir(rustfs_config::EVENT_DEFAULT_DIR),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,8 +64,6 @@ async-compression = { workspace = true, features = ["tokio", "gzip", "bzip2"] }
|
||||
async-trait.workspace = true
|
||||
arc-swap.workspace = true
|
||||
bytes = { workspace = true, features = ["serde"] }
|
||||
base64-simd.workspace = true
|
||||
flatbuffers.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
crc-fast.workspace = true
|
||||
rustfs-common.workspace = true
|
||||
@@ -77,7 +75,6 @@ futures-core = { workspace = true }
|
||||
flate2.workspace = true
|
||||
brotli.workspace = true
|
||||
lz4.workspace = true
|
||||
lz4_flex.workspace = true
|
||||
http.workspace = true
|
||||
memchr.workspace = true
|
||||
s3s = { workspace = true, features = ["minio"] }
|
||||
|
||||
@@ -221,7 +221,7 @@ struct ThriftCompactCursor<'a> {
|
||||
remaining: usize,
|
||||
}
|
||||
|
||||
impl<'a> ThriftCompactCursor<'a> {
|
||||
impl ThriftCompactCursor<'_> {
|
||||
fn error() -> ParquetBudgetError {
|
||||
ParquetBudgetError::Invalid("Parquet thrift metadata is malformed".to_owned())
|
||||
}
|
||||
@@ -241,14 +241,6 @@ impl<'a> ThriftCompactCursor<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn binary(&mut self) -> Result<&'a [u8]> {
|
||||
let len = usize::try_from(self.varint()?).map_err(|_| Self::error())?;
|
||||
self.charge(len.checked_mul(2).ok_or_else(budget_error)?)?;
|
||||
let start = self.pos;
|
||||
self.skip_bytes(len)?;
|
||||
Ok(&self.bytes[start..self.pos])
|
||||
}
|
||||
|
||||
fn varint(&mut self) -> Result<u64> {
|
||||
let mut value = 0_u64;
|
||||
for shift in (0..64).step_by(7) {
|
||||
@@ -343,7 +335,11 @@ impl<'a> ThriftCompactCursor<'a> {
|
||||
THRIFT_COMPACT_BYTE => self.skip_bytes(1),
|
||||
THRIFT_COMPACT_I16 | THRIFT_COMPACT_I32 | THRIFT_COMPACT_I64 => self.varint().map(drop),
|
||||
THRIFT_COMPACT_DOUBLE => self.skip_bytes(8),
|
||||
THRIFT_COMPACT_BINARY => self.binary().map(drop),
|
||||
THRIFT_COMPACT_BINARY => {
|
||||
let len = usize::try_from(self.varint()?).map_err(|_| Self::error())?;
|
||||
self.charge(len.checked_mul(2).ok_or_else(budget_error)?)?;
|
||||
self.skip_bytes(len)
|
||||
}
|
||||
THRIFT_COMPACT_LIST | THRIFT_COMPACT_SET => {
|
||||
let (element, count) = self.list_header()?;
|
||||
for _ in 0..count {
|
||||
@@ -590,98 +586,21 @@ impl<'a> ThriftCompactCursor<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn arrow_schema_metadata(&mut self) -> Result<Option<&'a [u8]>> {
|
||||
let (element, count) = self.list_header()?;
|
||||
if element != THRIFT_COMPACT_STRUCT {
|
||||
return Err(Self::error());
|
||||
}
|
||||
let mut arrow_schema = None;
|
||||
for _ in 0..count {
|
||||
let mut field = 0;
|
||||
let mut key = None;
|
||||
let mut value = None;
|
||||
loop {
|
||||
let header = self.byte()?;
|
||||
if header == THRIFT_COMPACT_STOP {
|
||||
break;
|
||||
}
|
||||
field = self.field_id(field, header)?;
|
||||
let kind = header & 0x0f;
|
||||
if matches!(field, 1 | 2) && kind == THRIFT_COMPACT_BINARY {
|
||||
let bytes = self.binary()?;
|
||||
if field == 1 {
|
||||
key = Some(bytes);
|
||||
} else {
|
||||
value = Some(bytes);
|
||||
}
|
||||
} else {
|
||||
self.skip_field(MetadataStruct::KeyValue, field, kind, 1)?;
|
||||
}
|
||||
}
|
||||
// Match the reader's last key with a value; absent values are ignored.
|
||||
if key == Some(b"ARROW:schema".as_slice()) && value.is_some() {
|
||||
arrow_schema = value;
|
||||
}
|
||||
}
|
||||
Ok(arrow_schema)
|
||||
}
|
||||
|
||||
fn validate_arrow_schema(&mut self, encoded: &[u8]) -> Result<()> {
|
||||
use datafusion::arrow::ipc;
|
||||
let invalid = || ParquetBudgetError::Invalid("Parquet Arrow schema metadata is malformed".to_owned());
|
||||
// This reservation precedes base64 allocation. The binary field has
|
||||
// already reserved its owned metadata copies; decoding needs another buffer.
|
||||
self.charge(encoded.len())?;
|
||||
let bytes = base64_simd::STANDARD.decode_to_vec(encoded).map_err(|_| invalid())?;
|
||||
let message = if bytes.len() > 8 && bytes.starts_with(&[255; 4]) {
|
||||
&bytes[8..]
|
||||
} else {
|
||||
bytes.as_slice()
|
||||
};
|
||||
// The verifier visits shared offsets for every occurrence, before the
|
||||
// reader expands them into owned fields and metadata. Split the remaining
|
||||
// allowance between string/buffer copies and per-table Rust containers:
|
||||
// four owned copies per apparent byte and 1 KiB per visited table.
|
||||
let defaults = flatbuffers::VerifierOptions::default();
|
||||
let options = flatbuffers::VerifierOptions {
|
||||
max_apparent_size: defaults.max_apparent_size.min(self.remaining / 8),
|
||||
max_tables: defaults.max_tables.min(self.remaining / (2 * 1024)),
|
||||
..defaults
|
||||
};
|
||||
let message = ipc::root_as_message_with_opts(&options, message).map_err(|error| match error {
|
||||
flatbuffers::InvalidFlatbuffer::TooManyTables
|
||||
| flatbuffers::InvalidFlatbuffer::ApparentSizeTooLarge
|
||||
| flatbuffers::InvalidFlatbuffer::DepthLimitReached => budget_error(),
|
||||
_ => invalid(),
|
||||
})?;
|
||||
let schema = message.header_as_schema().ok_or_else(invalid)?;
|
||||
if schema.fields().is_none() {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk the footer's `FileMetaData`. The pinned parquet reserves the
|
||||
/// declared `row_groups` capacity before reading one element; charge that
|
||||
/// reservation to the budget so a small footer cannot request gigabytes.
|
||||
fn file_metadata(&mut self, limit: usize) -> Result<()> {
|
||||
let mut field_id = 0_i16;
|
||||
let mut reserved = 0_usize;
|
||||
let mut arrow_schema = None;
|
||||
loop {
|
||||
let header = self.byte()?;
|
||||
if header == THRIFT_COMPACT_STOP {
|
||||
if let Some(encoded) = arrow_schema {
|
||||
self.validate_arrow_schema(encoded)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let kind = header & 0x0f;
|
||||
field_id = self.field_id(field_id, header)?;
|
||||
if field_id == 2 && kind == THRIFT_COMPACT_LIST {
|
||||
self.schema()?;
|
||||
} else if field_id == 5 && kind == THRIFT_COMPACT_LIST {
|
||||
arrow_schema = self.arrow_schema_metadata()?;
|
||||
} else if field_id == FILE_METADATA_FIELD_ROW_GROUPS && kind == THRIFT_COMPACT_LIST {
|
||||
let (element, count) = self.list_header()?;
|
||||
if element != THRIFT_COMPACT_STRUCT {
|
||||
@@ -841,34 +760,21 @@ fn validate_compressed_page(payload: &[u8], sizes: PageHeaderSizes, codec: Compr
|
||||
bounded_stream_size(brotli::Decompressor::new(payload, 4096), expanded)?;
|
||||
Ok(window)
|
||||
}
|
||||
Compression::LZ4 if payload.starts_with(&[4, 34, 77, 24]) || payload.starts_with(&[2, 33, 76, 24]) => {
|
||||
let (block, linked) = if payload.starts_with(&[2, 33, 76, 24]) {
|
||||
(8 * 1024 * 1024, false)
|
||||
} else {
|
||||
let descriptor = *payload.get(5).ok_or_else(ThriftCompactCursor::error)?;
|
||||
let block = match (descriptor >> 4) & 7 {
|
||||
4 => 64 * 1024,
|
||||
5 => 256 * 1024,
|
||||
6 => 1024 * 1024,
|
||||
7 => 4 * 1024 * 1024,
|
||||
_ => return Err(ThriftCompactCursor::error()),
|
||||
};
|
||||
(block, payload[4] & 0x20 == 0)
|
||||
Compression::LZ4 if payload.starts_with(&[4, 34, 77, 24]) => {
|
||||
let descriptor = *payload.get(5).ok_or_else(ThriftCompactCursor::error)?;
|
||||
let block = match (descriptor >> 4) & 7 {
|
||||
4 => 64 * 1024,
|
||||
5 => 256 * 1024,
|
||||
6 => 1024 * 1024,
|
||||
7 => 4 * 1024 * 1024,
|
||||
_ => return Err(ThriftCompactCursor::error()),
|
||||
};
|
||||
// Match parquet's pinned fallback decoder: compressed input plus
|
||||
// output, with an extra output block and 64 KiB history when linked.
|
||||
let working = if linked { block * 3 + 64 * 1024 } else { block * 2 };
|
||||
let working = block * 2;
|
||||
if working > limit {
|
||||
return Err(budget_error());
|
||||
}
|
||||
match bounded_stream_size(lz4_flex::frame::FrameDecoder::new(payload), expanded) {
|
||||
// Hadoop data can share the magic prefix, and parquet also
|
||||
// tries bounded RAW decoding after a frame error. The bounded
|
||||
// sink proved the frame produced no more than `expanded` bytes
|
||||
// before that error; let the actual reader select its fallback.
|
||||
Err(ParquetBudgetError::Codec(_)) => Ok(working),
|
||||
result => result.map(|()| working),
|
||||
}
|
||||
bounded_stream_size(lz4::Decoder::new(payload)?, expanded)?;
|
||||
Ok(working)
|
||||
}
|
||||
_ => Ok(0),
|
||||
}
|
||||
@@ -945,49 +851,6 @@ mod tests {
|
||||
assert!(validate_compressed_page(&payload, compressed_page(&payload, 64), Compression::LZ4, 1024 * 1024).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_lz4_scratch_is_admitted_before_the_fallback_decoder() {
|
||||
let payload = [2, 33, 76, 24, 5, 0, 0, 0, 0x40, 42, 0, 0, 0];
|
||||
let header = compressed_page(&payload, 4);
|
||||
assert!(matches!(
|
||||
validate_compressed_page(&payload, header, Compression::LZ4, 1024 * 1024),
|
||||
Err(ParquetBudgetError::Exceeded)
|
||||
));
|
||||
let admitted = validate_compressed_page(&payload, header, Compression::LZ4, 32 * 1024 * 1024)
|
||||
.expect("legacy LZ4 remains supported with enough scratch memory");
|
||||
assert!(admitted >= 16 * 1024 * 1024);
|
||||
assert!(validate_compressed_page(&payload, compressed_page(&payload, 1), Compression::LZ4, 32 * 1024 * 1024).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hadoop_lz4_can_share_the_legacy_magic_without_becoming_a_frame() {
|
||||
let size = usize::try_from(u32::from_be_bytes([2, 33, 76, 24])).expect("Hadoop size");
|
||||
let bytes = vec![b'x'; size];
|
||||
let block = lz4_flex::block::compress(&bytes);
|
||||
let mut payload = vec![2, 33, 76, 24];
|
||||
payload.extend(u32::try_from(block.len()).expect("compressed block").to_be_bytes());
|
||||
payload.extend(block);
|
||||
validate_compressed_page(&payload, compressed_page(&payload, size), Compression::LZ4, 64 * 1024 * 1024)
|
||||
.expect("Hadoop framing takes priority over the frame fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_lz4_accounts_for_the_history_and_both_block_buffers() {
|
||||
use lz4_flex::frame::{BlockMode, BlockSize, FrameEncoder, FrameInfo};
|
||||
let frame = FrameInfo::new().block_mode(BlockMode::Linked).block_size(BlockSize::Max64KB);
|
||||
let mut encoder = FrameEncoder::with_frame_info(frame, Vec::new());
|
||||
encoder.write_all(&vec![b'x'; 256 * 1024]).expect("linked input");
|
||||
let payload = encoder.finish().expect("linked frame");
|
||||
let header = compressed_page(&payload, 256 * 1024);
|
||||
assert!(matches!(
|
||||
validate_compressed_page(&payload, header, Compression::LZ4, 192 * 1024),
|
||||
Err(ParquetBudgetError::Exceeded)
|
||||
));
|
||||
let admitted = validate_compressed_page(&payload, header, Compression::LZ4, 1024 * 1024)
|
||||
.expect("linked frame with enough scratch memory");
|
||||
assert!(admitted >= 256 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_v2_does_not_invoke_the_column_codec() {
|
||||
let payload = b"levels-and-uncompressed-values";
|
||||
@@ -1078,170 +941,6 @@ mod tests {
|
||||
validate_file_footer(&bytes, 1024 * 1024).expect("valid logical annotations remain accepted");
|
||||
}
|
||||
|
||||
fn shared_arrow_metadata_file(entries: usize) -> Vec<u8> {
|
||||
use datafusion::{
|
||||
arrow::ipc,
|
||||
parquet::{
|
||||
file::{properties::WriterProperties, writer::SerializedFileWriter},
|
||||
schema::parser::parse_message_type,
|
||||
},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
let mut builder = flatbuffers::FlatBufferBuilder::new();
|
||||
let name = builder.create_string("v");
|
||||
let value = builder.create_string(&"x".repeat(64 * 1024));
|
||||
let data_type = ipc::Int::create(
|
||||
&mut builder,
|
||||
&ipc::IntArgs {
|
||||
bitWidth: 32,
|
||||
is_signed: true,
|
||||
},
|
||||
);
|
||||
let field = ipc::Field::create(
|
||||
&mut builder,
|
||||
&ipc::FieldArgs {
|
||||
name: Some(name),
|
||||
nullable: true,
|
||||
type_type: ipc::Type::Int,
|
||||
type_: Some(data_type.as_union_value()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let fields = builder.create_vector(&[field]);
|
||||
let mut metadata = Vec::new();
|
||||
for n in 0..entries {
|
||||
let key = builder.create_string(&format!("key-{n}"));
|
||||
metadata.push(ipc::KeyValue::create(
|
||||
&mut builder,
|
||||
&ipc::KeyValueArgs {
|
||||
key: Some(key),
|
||||
value: Some(value),
|
||||
},
|
||||
));
|
||||
}
|
||||
let metadata = builder.create_vector(&metadata);
|
||||
let schema = ipc::Schema::create(
|
||||
&mut builder,
|
||||
&ipc::SchemaArgs {
|
||||
fields: Some(fields),
|
||||
custom_metadata: Some(metadata),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let message = ipc::Message::create(
|
||||
&mut builder,
|
||||
&ipc::MessageArgs {
|
||||
version: ipc::MetadataVersion::V5,
|
||||
header_type: ipc::MessageHeader::Schema,
|
||||
header: Some(schema.as_union_value()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
builder.finish(message, None);
|
||||
let encoded = base64_simd::STANDARD.encode_to_string(builder.finished_data());
|
||||
let properties = WriterProperties::builder()
|
||||
.set_key_value_metadata(Some(vec![datafusion::parquet::file::metadata::KeyValue::new(
|
||||
"ARROW:schema".to_owned(),
|
||||
Some(encoded),
|
||||
)]))
|
||||
.build();
|
||||
let schema = Arc::new(parse_message_type("message root { OPTIONAL INT32 v; }").expect("physical schema"));
|
||||
let mut bytes = Vec::new();
|
||||
SerializedFileWriter::new(&mut bytes, schema, Arc::new(properties))
|
||||
.expect("writer")
|
||||
.close()
|
||||
.expect("footer");
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_arrow_metadata_is_admitted_before_owned_schema_conversion() {
|
||||
use datafusion::parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
|
||||
let bytes = shared_arrow_metadata_file(256);
|
||||
assert!(bytes.len() < 100 * 1024, "shared offsets keep the encoded file small");
|
||||
let native = ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes.clone())).expect("valid Arrow metadata");
|
||||
assert_eq!(native.schema().metadata().len(), 256);
|
||||
assert!(
|
||||
matches!(validate_file_footer(&bytes, 2 * 1024 * 1024), Err(ParquetBudgetError::Exceeded)),
|
||||
"32 MiB of owned metadata must not pass a 2 MiB preflight"
|
||||
);
|
||||
validate_file_footer(&bytes, 512 * 1024 * 1024).expect("sufficient budget preserves shared metadata");
|
||||
}
|
||||
|
||||
fn arrow_hint_footer(entries: &[(&[u8], Option<&[u8]>)]) -> Vec<u8> {
|
||||
let count = u8::try_from(entries.len()).expect("small key/value fixture");
|
||||
assert!(count < 15);
|
||||
let mut bytes = vec![0x59, (count << 4) | THRIFT_COMPACT_STRUCT];
|
||||
for (key, value) in entries {
|
||||
bytes.push(0x18);
|
||||
bytes.extend(varint(u64::try_from(key.len()).expect("key length")));
|
||||
bytes.extend(*key);
|
||||
if let Some(value) = value {
|
||||
bytes.push(0x18);
|
||||
bytes.extend(varint(u64::try_from(value.len()).expect("value length")));
|
||||
bytes.extend(*value);
|
||||
}
|
||||
bytes.push(0);
|
||||
}
|
||||
bytes.push(0);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_hint_framing_and_duplicate_keys_match_the_reader() {
|
||||
use datafusion::arrow::{
|
||||
datatypes::{DataType, Field, Schema},
|
||||
ipc,
|
||||
};
|
||||
let schema = Schema::new(vec![Field::new("v", DataType::Int32, true)]);
|
||||
let mut builder = flatbuffers::FlatBufferBuilder::new();
|
||||
let schema = ipc::convert::schema_to_fb_offset(&mut builder, &schema);
|
||||
let message = ipc::Message::create(
|
||||
&mut builder,
|
||||
&ipc::MessageArgs {
|
||||
header_type: ipc::MessageHeader::Schema,
|
||||
header: Some(schema.as_union_value()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
builder.finish(message, None);
|
||||
let raw = builder.finished_data();
|
||||
let mut framed = vec![255; 4];
|
||||
framed.extend(u32::try_from(raw.len()).expect("small schema").to_le_bytes());
|
||||
framed.extend(raw);
|
||||
for message in [raw, framed.as_slice()] {
|
||||
let encoded = base64_simd::STANDARD.encode_to_string(message);
|
||||
let footer = arrow_hint_footer(&[
|
||||
(b"ARROW:schema", Some(b"invalid")),
|
||||
(b"ARROW:schema", Some(encoded.as_bytes())),
|
||||
(b"ARROW:schema", None),
|
||||
]);
|
||||
validate_footer(&footer, 1024 * 1024).expect("last present Arrow metadata wins, both IPC framings are supported");
|
||||
assert!(matches!(
|
||||
validate_footer(
|
||||
&arrow_hint_footer(&[
|
||||
(b"ARROW:schema", Some(encoded.as_bytes())),
|
||||
(b"ARROW:schema", Some(b"invalid"))
|
||||
]),
|
||||
1024 * 1024
|
||||
),
|
||||
Err(ParquetBudgetError::Invalid(_))
|
||||
));
|
||||
}
|
||||
validate_footer(&arrow_hint_footer(&[(b"other", Some(b"invalid"))]), 1024 * 1024).expect("other metadata is opaque");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_hint_value_before_key_is_still_admitted() {
|
||||
// Compact struct fields are identified by ID, not required to be ordered.
|
||||
let mut bytes = vec![0x59, 0x1c, 0x28, 3];
|
||||
bytes.extend(b"%%%");
|
||||
bytes.extend([0x08, 2, 12]);
|
||||
bytes.extend(b"ARROW:schema");
|
||||
bytes.extend([0, 0]);
|
||||
assert!(matches!(validate_footer(&bytes, 1024 * 1024), Err(ParquetBudgetError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_collections_are_bounded_before_decoder_allocation() {
|
||||
let mut huge = vec![0x49, 0xfc];
|
||||
|
||||
@@ -1106,98 +1106,6 @@ mod tests {
|
||||
assert_eq!(error.select_error(), SelectError::ResourceExhausted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parquet_legacy_lz4_checks_native_scratch_before_query_decode() {
|
||||
use datafusion::parquet::{basic::Compression, file::metadata::ParquetMetaDataWriter};
|
||||
let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int32, false)]));
|
||||
let batch =
|
||||
RecordBatch::try_new(Arc::clone(&schema), vec![Arc::new(datafusion::arrow::array::Int32Array::from(vec![42]))])
|
||||
.expect("one integer row");
|
||||
let properties = datafusion::parquet::file::properties::WriterProperties::builder()
|
||||
.set_dictionary_enabled(false)
|
||||
.build();
|
||||
let mut original = Vec::new();
|
||||
let mut writer = ArrowWriter::try_new(&mut original, schema, Some(properties)).expect("writer");
|
||||
writer.write(&batch).expect("row");
|
||||
writer.close().expect("footer");
|
||||
let metadata = ParquetRecordBatchReaderBuilder::try_new(Bytes::from(original))
|
||||
.expect("metadata")
|
||||
.metadata()
|
||||
.clone();
|
||||
// A valid legacy frame encoding the four little-endian bytes for 42.
|
||||
let payload = [2, 33, 76, 24, 5, 0, 0, 0, 0x40, 42, 0, 0, 0];
|
||||
let mut page = vec![0x15, 0, 0x15, 8, 0x15, 26, 0x2c, 0x15, 2, 0x15, 0, 0x15, 6, 0x15, 6, 0, 0];
|
||||
page.extend(payload);
|
||||
let column = metadata
|
||||
.row_group(0)
|
||||
.column(0)
|
||||
.clone()
|
||||
.into_builder()
|
||||
.set_compression(Compression::LZ4)
|
||||
.set_data_page_offset(4)
|
||||
.set_total_compressed_size(i64::try_from(page.len()).expect("small page"))
|
||||
.build()
|
||||
.expect("legacy column");
|
||||
let group = metadata
|
||||
.row_group(0)
|
||||
.clone()
|
||||
.into_builder()
|
||||
.set_column_metadata(vec![column])
|
||||
.build()
|
||||
.expect("row group");
|
||||
let metadata = metadata.as_ref().clone().into_builder().set_row_groups(vec![group]).build();
|
||||
let mut encoded = b"PAR1".to_vec();
|
||||
encoded.extend(page);
|
||||
ParquetMetaDataWriter::new(&mut encoded, &metadata)
|
||||
.finish()
|
||||
.expect("legacy footer");
|
||||
assert!(encoded.len() < 4096);
|
||||
let mut native = ParquetRecordBatchReaderBuilder::try_new(Bytes::from(encoded.clone()))
|
||||
.expect("legacy metadata")
|
||||
.build()
|
||||
.expect("native reader");
|
||||
let decoded = native.next().expect("one batch").expect("legacy fallback decode");
|
||||
assert_eq!(
|
||||
decoded
|
||||
.column(0)
|
||||
.as_primitive::<datafusion::arrow::datatypes::Int32Type>()
|
||||
.value(0),
|
||||
42
|
||||
);
|
||||
let store = Arc::new(InMemory::new());
|
||||
store
|
||||
.put(&Path::from("legacy.parquet"), Bytes::from(encoded).into())
|
||||
.await
|
||||
.expect("fixture");
|
||||
let tight = parquet_session_with_memory(store.clone(), 2 * 1024 * 1024);
|
||||
let result = ParquetSelectTable::try_new(&tight.state(), &parquet_input("legacy.parquet"), None).await;
|
||||
let error = match result {
|
||||
Ok(_) => panic!("legacy decoder scratch exceeds the query budget"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.select_error(), SelectError::ResourceExhausted);
|
||||
let roomy = parquet_session_with_memory(store, 128 * 1024 * 1024);
|
||||
let table = ParquetSelectTable::try_new(&roomy.state(), &parquet_input("legacy.parquet"), None)
|
||||
.await
|
||||
.expect("admitted legacy file");
|
||||
roomy.register_table("source", table).expect("table");
|
||||
let rows = roomy
|
||||
.sql("SELECT v FROM source")
|
||||
.await
|
||||
.expect("plan")
|
||||
.collect()
|
||||
.await
|
||||
.expect("query");
|
||||
assert_eq!(rows.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
|
||||
assert_eq!(
|
||||
rows[0]
|
||||
.column(0)
|
||||
.as_primitive::<datafusion::arrow::datatypes::Int32Type>()
|
||||
.value(0),
|
||||
42
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parquet_nested_views_decode_through_the_actual_file_schema() {
|
||||
use datafusion::arrow::array::{ListArray, StringArray};
|
||||
|
||||
@@ -690,19 +690,6 @@ pub(super) enum ScannerPauseBacklogAttemptDecision {
|
||||
pub struct ScannerPauseBacklogReplicaId {
|
||||
pub pool_index: usize,
|
||||
pub set_index: usize,
|
||||
/// Stable format identity; indices are only the current routing location.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub set_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
impl ScannerPauseBacklogReplicaId {
|
||||
fn from_set(set: &SetDisks) -> Result<Self, EcstoreError> {
|
||||
Ok(Self {
|
||||
pool_index: set.pool_index,
|
||||
set_index: set.set_index,
|
||||
set_id: Some(set.persistent_set_id()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
@@ -719,23 +706,9 @@ impl ScannerPauseBacklogCommitRecord {
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
self.ledger.validate()?;
|
||||
if self.replicas.is_empty()
|
||||
|| !self
|
||||
.replicas
|
||||
.windows(2)
|
||||
.all(|pair| (pair[0].pool_index, pair[0].set_index) < (pair[1].pool_index, pair[1].set_index))
|
||||
{
|
||||
if self.replicas.is_empty() || !self.replicas.windows(2).all(|pair| pair[0] < pair[1]) {
|
||||
return Err("scanner pause backlog commit has invalid replica membership".to_string());
|
||||
}
|
||||
let mut identities = BTreeSet::new();
|
||||
if self
|
||||
.replicas
|
||||
.iter()
|
||||
.filter_map(|id| id.set_id)
|
||||
.any(|id| id.is_nil() || !identities.insert(id))
|
||||
{
|
||||
return Err("scanner pause backlog commit has invalid persistent set identities".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -774,10 +747,6 @@ impl ScannerPauseBacklogReplicaRecord {
|
||||
}
|
||||
if let Some(committed) = &self.committed {
|
||||
committed.validate()?;
|
||||
if committed.replicas.iter().any(|id| id.set_id.is_some()) && committed.replicas.iter().any(|id| id.set_id.is_none())
|
||||
{
|
||||
return Err("scanner pause backlog membership mixes positional and persistent identities".to_string());
|
||||
}
|
||||
if let Some(stable) = &self.stable {
|
||||
let stable_key = (stable.writer_epoch, stable.generation);
|
||||
let committed_key = (committed.ledger.writer_epoch, committed.ledger.generation);
|
||||
@@ -938,19 +907,9 @@ fn unix_now() -> u64 {
|
||||
}
|
||||
|
||||
async fn read_scanner_pause_backlog_replica(store: Arc<SetDisks>) -> ScannerPauseBacklogReplica {
|
||||
let id = match ScannerPauseBacklogReplicaId::from_set(&store) {
|
||||
Ok(id) => id,
|
||||
Err(error) => {
|
||||
return ScannerPauseBacklogReplica {
|
||||
id: ScannerPauseBacklogReplicaId {
|
||||
pool_index: store.pool_index,
|
||||
set_index: store.set_index,
|
||||
set_id: None,
|
||||
},
|
||||
revision: None,
|
||||
state: ScannerPauseBacklogReplicaState::Unavailable(error.to_string()),
|
||||
};
|
||||
}
|
||||
let id = ScannerPauseBacklogReplicaId {
|
||||
pool_index: store.pool_index,
|
||||
set_index: store.set_index,
|
||||
};
|
||||
let reader = match store
|
||||
.get_object_reader(
|
||||
@@ -1374,48 +1333,8 @@ fn select_scanner_pause_backlog_roll_forward(replicas: &[ScannerPauseBacklogRepl
|
||||
.then(|| newest.clone())
|
||||
}
|
||||
|
||||
/// Resolve stored membership against immutable set identities before checking
|
||||
/// commit proofs. A missing identity is never rebound to a new set at its old
|
||||
/// index. Legacy records retain their positional interpretation until an
|
||||
/// existing commit or rollback proof permits publishing the bound membership.
|
||||
fn normalize_scanner_pause_backlog_membership(
|
||||
replicas: &mut [ScannerPauseBacklogReplica],
|
||||
) -> Result<(), ScannerPauseBacklogSelectionError> {
|
||||
let mut by_location = HashMap::with_capacity(replicas.len());
|
||||
let mut by_identity = HashMap::with_capacity(replicas.len());
|
||||
for replica in replicas.iter() {
|
||||
let id = replica.id;
|
||||
if by_location.insert((id.pool_index, id.set_index), id).is_some()
|
||||
|| id
|
||||
.set_id
|
||||
.is_some_and(|identity| identity.is_nil() || by_identity.insert(identity, id).is_some())
|
||||
{
|
||||
return Err(ScannerPauseBacklogSelectionError::AuthorityConflict(
|
||||
"scanner pause backlog has duplicate or invalid storage identities".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
for replica in replicas {
|
||||
if let ScannerPauseBacklogReplicaState::Valid(record) = &mut replica.state
|
||||
&& let Some(commit) = record.committed.as_mut()
|
||||
{
|
||||
for member in &mut commit.replicas {
|
||||
let current = match member.set_id {
|
||||
Some(identity) => by_identity.get(&identity),
|
||||
None => by_location.get(&(member.pool_index, member.set_index)),
|
||||
};
|
||||
if let Some(current) = current {
|
||||
*member = *current;
|
||||
}
|
||||
}
|
||||
commit.replicas.sort_unstable();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_scanner_pause_backlog_replicas(
|
||||
mut replicas: Vec<ScannerPauseBacklogReplica>,
|
||||
replicas: Vec<ScannerPauseBacklogReplica>,
|
||||
) -> Result<LoadedScannerPauseBacklog, ScannerPauseBacklogSelectionFailure> {
|
||||
if replicas.is_empty() {
|
||||
return Err(scanner_pause_backlog_selection_failure(
|
||||
@@ -1435,9 +1354,6 @@ fn select_scanner_pause_backlog_replicas(
|
||||
)),
|
||||
));
|
||||
}
|
||||
if let Err(error) = normalize_scanner_pause_backlog_membership(&mut replicas) {
|
||||
return Err(scanner_pause_backlog_selection_failure(replicas, error));
|
||||
}
|
||||
|
||||
let replica_ids = scanner_pause_backlog_replica_ids(&replicas);
|
||||
let authoritative_commit = match select_scanner_pause_backlog_commit(&replicas, &replica_ids) {
|
||||
@@ -1596,13 +1512,12 @@ fn decode_scanner_pause_backlog_retirement_replicas(
|
||||
) -> Result<(Vec<ScannerPauseBacklogReplica>, Vec<ScannerPauseBacklogReplica>), ScannerPauseBacklogRetirementError> {
|
||||
let mut ids = BTreeSet::new();
|
||||
let mut decoded = Vec::with_capacity(replicas.len());
|
||||
let mut writable_ids = BTreeSet::new();
|
||||
let mut surviving = Vec::new();
|
||||
let mut has_source = false;
|
||||
for replica in replicas {
|
||||
let id = ScannerPauseBacklogReplicaId {
|
||||
pool_index: replica.pool_index,
|
||||
set_index: replica.set_index,
|
||||
set_id: replica.set_id,
|
||||
};
|
||||
if !ids.insert(id) {
|
||||
return Err(scanner_pause_backlog_retirement_invalid_record(
|
||||
@@ -1633,7 +1548,7 @@ fn decode_scanner_pause_backlog_retirement_replicas(
|
||||
state,
|
||||
};
|
||||
if replica.pool_index != source_pool_index && replica.writable {
|
||||
writable_ids.insert(id);
|
||||
surviving.push(decoded_replica.clone());
|
||||
}
|
||||
decoded.push(decoded_replica);
|
||||
}
|
||||
@@ -1642,12 +1557,6 @@ fn decode_scanner_pause_backlog_retirement_replicas(
|
||||
"scanner pause backlog retirement has no native source record",
|
||||
));
|
||||
}
|
||||
normalize_scanner_pause_backlog_membership(&mut decoded).map_err(scanner_pause_backlog_retirement_selection_error)?;
|
||||
let surviving = decoded
|
||||
.iter()
|
||||
.filter(|replica| writable_ids.contains(&replica.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
Ok((decoded, surviving))
|
||||
}
|
||||
|
||||
@@ -1965,9 +1874,11 @@ where
|
||||
let loaded_ids = loaded.replicas.iter().map(|replica| replica.id).collect::<BTreeSet<_>>();
|
||||
let writable_ids = writable
|
||||
.iter()
|
||||
.map(|set| ScannerPauseBacklogReplicaId::from_set(set))
|
||||
.collect::<Result<BTreeSet<_>, _>>()
|
||||
.map_err(|error| error.to_string())?;
|
||||
.map(|set| ScannerPauseBacklogReplicaId {
|
||||
pool_index: set.pool_index,
|
||||
set_index: set.set_index,
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
if writable_ids != loaded_ids {
|
||||
return Err("scanner pause backlog replica topology changed during commit".to_string());
|
||||
}
|
||||
@@ -1990,7 +1901,10 @@ where
|
||||
let results = tokio::spawn(async move {
|
||||
let mut results = Vec::with_capacity(writable.len());
|
||||
for set in writable {
|
||||
let id = ScannerPauseBacklogReplicaId::from_set(&set).map_err(|error| error.to_string())?;
|
||||
let id = ScannerPauseBacklogReplicaId {
|
||||
pool_index: set.pool_index,
|
||||
set_index: set.set_index,
|
||||
};
|
||||
if only.as_ref().is_some_and(|only| !only.contains(&id)) {
|
||||
continue;
|
||||
}
|
||||
@@ -2014,10 +1928,10 @@ where
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok::<_, String>(results)
|
||||
results
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("scanner pause backlog publication owner failed: {err}"))??;
|
||||
.map_err(|err| format!("scanner pause backlog publication owner failed: {err}"))?;
|
||||
|
||||
let failures = results
|
||||
.iter()
|
||||
@@ -2530,142 +2444,6 @@ mod tests {
|
||||
(root, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn native_legacy_upgrade_and_pool_reindex_preserve_backlog_authority() {
|
||||
assert_native_legacy_upgrade_and_pool_reindex_preserve_backlog_authority(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn native_pool_reindex_with_failed_membership_publication_preserves_authority() {
|
||||
assert_native_legacy_upgrade_and_pool_reindex_preserve_backlog_authority(true);
|
||||
}
|
||||
|
||||
fn assert_native_legacy_upgrade_and_pool_reindex_preserve_backlog_authority(fail_publication: bool) {
|
||||
run_native_retirement_test(async move || {
|
||||
use crate::storage_api::owner::NativeScannerPauseBacklogWriteFault;
|
||||
|
||||
let (root, store) = native_pause_backlog_store(3, 1).await;
|
||||
let now = unix_now();
|
||||
let original = seed_native_expansion_ledger(&store, now).await;
|
||||
// Model bytes written by the previous positional-membership writer.
|
||||
for id in native_replica_ids(&store) {
|
||||
let (bytes, _) = native_replica_bytes(&store.pools[id.pool_index].disk_set[0]).await;
|
||||
let ScannerPauseBacklogReplicaState::Valid(mut record) = decode_scanner_pause_backlog_ledger(&bytes) else {
|
||||
panic!("native seed is valid");
|
||||
};
|
||||
record.replica_schema_version = 1;
|
||||
for member in &mut record.committed.as_mut().expect("seed commit").replicas {
|
||||
member.set_id = None;
|
||||
}
|
||||
record.validate().expect("previous schema fixture");
|
||||
replace_native_pause_backlog_replica(&store, id, *record).await;
|
||||
}
|
||||
let fault =
|
||||
NativeScannerPauseBacklogWriteFault::fail_before_write(Arc::clone(&store.pools[1].disk_set[0]), "publish", 1);
|
||||
assert!(
|
||||
ScannerPauseBacklogController::claim(Arc::clone(&store), now + 60)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
drop(fault);
|
||||
let interrupted = load_scanner_pause_backlog(Arc::clone(&store))
|
||||
.await
|
||||
.expect("mixed legacy and bound records retain the rollback proof");
|
||||
assert_eq!(interrupted.ledger, original);
|
||||
let upgraded = ScannerPauseBacklogController::claim(Arc::clone(&store), now + 61)
|
||||
.await
|
||||
.expect("retry finishes the format upgrade");
|
||||
let expected = upgraded.loaded.ledger.clone();
|
||||
drop(upgraded);
|
||||
for pool in &store.pools {
|
||||
let (bytes, _) = native_replica_bytes(&pool.disk_set[0]).await;
|
||||
let ScannerPauseBacklogReplicaState::Valid(record) = decode_scanner_pause_backlog_ledger(&bytes) else {
|
||||
panic!("upgraded native record is valid");
|
||||
};
|
||||
assert_eq!(record.replica_schema_version, 1);
|
||||
assert!(
|
||||
record
|
||||
.committed
|
||||
.expect("upgraded membership")
|
||||
.replicas
|
||||
.iter()
|
||||
.all(|id| id.set_id.is_some())
|
||||
);
|
||||
}
|
||||
let surviving_id = native_replica_id(&store, 2, 0).set_id;
|
||||
let (source, _) = native_replica_bytes(&store.pools[1].disk_set[0]).await;
|
||||
store
|
||||
.prepare_scanner_pause_backlog_retirement_for_test(1, source.len() * 2)
|
||||
.await
|
||||
.expect("start retiring the middle pool");
|
||||
store
|
||||
.retire_scanner_pause_backlog_for_test(1, 0)
|
||||
.await
|
||||
.expect("hand off the native backlog");
|
||||
store
|
||||
.complete_scanner_pause_backlog_retirement_for_test(1)
|
||||
.await
|
||||
.expect("persist the completed middle pool through the ILM receipt checks");
|
||||
shutdown_native_retirement_store(store).await;
|
||||
|
||||
let reordered = super::super::tests::setup_scanner_cycle_store_with_pool_order(
|
||||
root.path(),
|
||||
false,
|
||||
&[0, 2, 3],
|
||||
1,
|
||||
NATIVE_RETIREMENT_DRIVES_PER_SET,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(native_replica_id(&reordered, 1, 0).set_id, surviving_id);
|
||||
assert_ne!(native_replica_id(&reordered, 2, 0).set_id, surviving_id);
|
||||
let loaded = load_scanner_pause_backlog(Arc::clone(&reordered))
|
||||
.await
|
||||
.expect("removing a middle pool and appending a new one preserves native authority");
|
||||
assert_eq!(loaded.ledger, expected);
|
||||
assert_eq!(loaded.persistence_state, "membership_repair_pending");
|
||||
if fail_publication {
|
||||
// Match the reported partial publication: the two existing
|
||||
// pools reject the new commit while the replacement accepts it.
|
||||
let faults = [0, 1].map(|pool| {
|
||||
NativeScannerPauseBacklogWriteFault::fail_before_write(
|
||||
Arc::clone(&reordered.pools[pool].disk_set[0]),
|
||||
"publish",
|
||||
2,
|
||||
)
|
||||
});
|
||||
let error = ScannerPauseBacklogController::claim(Arc::clone(&reordered), now + 120)
|
||||
.await
|
||||
.err()
|
||||
.expect("the incomplete publication must fail");
|
||||
assert!(error.contains("pool 0 set 0") && error.contains("pool 1 set 0"), "{error}");
|
||||
drop(faults);
|
||||
let recovered = load_scanner_pause_backlog(Arc::clone(&reordered))
|
||||
.await
|
||||
.expect("partial publication after reindexing retains proven authority");
|
||||
assert_eq!(recovered.ledger, expected);
|
||||
assert!(recovered.durable);
|
||||
}
|
||||
let (source, _) = native_replica_bytes(&reordered.pools[1].disk_set[0]).await;
|
||||
reordered
|
||||
.prepare_scanner_pause_backlog_retirement_for_test(1, source.len() * 2)
|
||||
.await
|
||||
.expect("retire the reindexed member before a scanner writer completes the membership change");
|
||||
reordered
|
||||
.retire_scanner_pause_backlog_for_test(1, 0)
|
||||
.await
|
||||
.expect("retirement uses the same bound authority");
|
||||
let surviving = load_scanner_pause_backlog(Arc::clone(&reordered))
|
||||
.await
|
||||
.expect("remaining members retain the complete ledger");
|
||||
assert_eq!(surviving.ledger, expected);
|
||||
assert!(surviving.all_current_replicas_stable());
|
||||
shutdown_native_retirement_store(reordered).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn native_single_pool_partial_commit_converges_across_24_sets() {
|
||||
@@ -2814,7 +2592,9 @@ mod tests {
|
||||
);
|
||||
|
||||
let stable = durable_ledger(unix_now());
|
||||
let members = native_replica_ids(&store);
|
||||
let members = (0..3)
|
||||
.flat_map(|pool_index| (0..2).map(move |set_index| replica_id(pool_index, set_index)))
|
||||
.collect::<Vec<_>>();
|
||||
let committed = ScannerPauseBacklogCommitRecord::new(stable.clone(), members);
|
||||
let record = ScannerPauseBacklogReplicaRecord::new(Some(stable), Some(committed));
|
||||
let bytes = encode_scanner_pause_backlog_record(&record).expect("stable replica should encode");
|
||||
@@ -3241,7 +3021,10 @@ mod tests {
|
||||
let now = unix_now().saturating_add(60);
|
||||
let replacement = claim_scanner_pause_backlog_writer(&original, now).expect("next writer generation");
|
||||
let new_commit = ScannerPauseBacklogCommitRecord::new(replacement.clone(), current_ids.clone());
|
||||
let failed_id = native_replica_id(&store, failed_pool, 0);
|
||||
let failed_id = ScannerPauseBacklogReplicaId {
|
||||
pool_index: failed_pool,
|
||||
set_index: 0,
|
||||
};
|
||||
let fault = NativeScannerPauseBacklogWriteFault::fail_before_write(
|
||||
Arc::clone(&store.pools[failed_pool].disk_set[0]),
|
||||
"publish",
|
||||
@@ -3770,8 +3553,10 @@ mod tests {
|
||||
.authoritative_commit
|
||||
.expect("old cohort commit");
|
||||
let now = unix_now().saturating_add(60);
|
||||
let ids = native_replica_ids(&store);
|
||||
let newer_member = native_replica_id(&store, 2, 1);
|
||||
let ids = (0..3)
|
||||
.flat_map(|pool_index| (0..2).map(move |set_index| replica_id(pool_index, set_index)))
|
||||
.collect::<Vec<_>>();
|
||||
let newer_member = replica_id(2, 1);
|
||||
let newer = claim_scanner_pause_backlog_writer(&original, now).expect("newer generation");
|
||||
let newer_commit = ScannerPauseBacklogCommitRecord::new(newer.clone(), ids.clone());
|
||||
for id in &ids {
|
||||
@@ -3832,12 +3617,14 @@ mod tests {
|
||||
let (_root, store, original) = native_expanded_backlog_store(1, false).await;
|
||||
let now = unix_now().saturating_add(60);
|
||||
let wedge = native_interleaved_expansion_wedge(&store, &original, now).await;
|
||||
let ids = native_replica_ids(&store);
|
||||
let ids = (0..3)
|
||||
.flat_map(|pool_index| (0..2).map(move |set_index| replica_id(pool_index, set_index)))
|
||||
.collect::<Vec<_>>();
|
||||
// The retiring source holds the newest stable ledger.
|
||||
let newest = claim_scanner_pause_backlog_writer(&wedge.acknowledged, now + 1).expect("newest generation");
|
||||
replace_native_pause_backlog_replica(
|
||||
&store,
|
||||
native_replica_id(&store, 0, 0),
|
||||
replica_id(0, 0),
|
||||
ScannerPauseBacklogReplicaRecord::new(
|
||||
Some(newest.clone()),
|
||||
Some(ScannerPauseBacklogCommitRecord::new(newest.clone(), ids)),
|
||||
@@ -3888,8 +3675,10 @@ mod tests {
|
||||
set_runtime_error(None);
|
||||
let (_root, store, original) = native_expanded_backlog_store(1, false).await;
|
||||
let now = unix_now().saturating_add(60);
|
||||
let ids = native_replica_ids(&store);
|
||||
let newer_member = native_replica_id(&store, 2, 1);
|
||||
let ids = (0..3)
|
||||
.flat_map(|pool_index| (0..2).map(move |set_index| replica_id(pool_index, set_index)))
|
||||
.collect::<Vec<_>>();
|
||||
let newer_member = replica_id(2, 1);
|
||||
let others = ids.iter().copied().filter(|id| *id != newer_member).collect::<Vec<_>>();
|
||||
let middle = claim_scanner_pause_backlog_writer(&original, now).expect("middle generation");
|
||||
let newest = claim_scanner_pause_backlog_writer(&middle, now + 1).expect("newest generation");
|
||||
@@ -4048,8 +3837,10 @@ mod tests {
|
||||
let (_root, store, original) = native_expanded_backlog_store(1, false).await;
|
||||
let now = unix_now().saturating_add(60);
|
||||
let wedge = native_interleaved_expansion_wedge(&store, &original, now).await;
|
||||
let ids = native_replica_ids(&store);
|
||||
assert_eq!(wedge.last, native_replica_id(&store, 2, 1));
|
||||
let ids = (0..3)
|
||||
.flat_map(|pool_index| (0..2).map(move |set_index| replica_id(pool_index, set_index)))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(wedge.last, replica_id(2, 1));
|
||||
|
||||
// A different stable ledger at the newest key is a fork.
|
||||
let fork = claim_scanner_pause_backlog_writer(&original, now + 7).expect("forked writer generation");
|
||||
@@ -4060,7 +3851,7 @@ mod tests {
|
||||
assert_ne!(fork, wedge.acknowledged);
|
||||
replace_native_pause_backlog_replica(
|
||||
&store,
|
||||
native_replica_id(&store, 1, 0),
|
||||
replica_id(1, 0),
|
||||
ScannerPauseBacklogReplicaRecord::new(
|
||||
Some(fork.clone()),
|
||||
Some(ScannerPauseBacklogCommitRecord::new(fork, ids.clone())),
|
||||
@@ -4077,7 +3868,7 @@ mod tests {
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(native_replica_snapshot(&store).await, forked, "a refused claim keeps every record");
|
||||
replace_native_pause_backlog_replica(&store, native_replica_id(&store, 1, 0), wedge.rollback.clone()).await;
|
||||
replace_native_pause_backlog_replica(&store, replica_id(1, 0), wedge.rollback.clone()).await;
|
||||
|
||||
// A missing member that a stored commit names may have lost a
|
||||
// newer stable ledger.
|
||||
@@ -4112,7 +3903,7 @@ mod tests {
|
||||
.expect_err("retirement must refuse an unproven authority");
|
||||
assert!(refused.to_string().contains("unsafe authority conflict"), "{refused}");
|
||||
assert_eq!(native_replica_bytes(&store.pools[0].disk_set[0]).await.0, source_bytes);
|
||||
replace_native_pause_backlog_replica(&store, native_replica_id(&store, 1, 1), wedge.rollback.clone()).await;
|
||||
replace_native_pause_backlog_replica(&store, replica_id(1, 1), wedge.rollback.clone()).await;
|
||||
|
||||
// The same records without the conflict converge and retire.
|
||||
for set in 0..2 {
|
||||
@@ -4229,7 +4020,7 @@ mod tests {
|
||||
.authoritative_commit
|
||||
.as_ref()
|
||||
.expect("seed retains the old cohort proof");
|
||||
assert_eq!(committed.replicas, vec![native_replica_id(&store, 0, 0), native_replica_id(&store, 0, 1)]);
|
||||
assert_eq!(committed.replicas, vec![replica_id(0, 0), replica_id(0, 1)]);
|
||||
assert_eq!(loaded.replica_count, 6);
|
||||
if loaded.healthy_replicas == loaded.replica_count {
|
||||
break loaded;
|
||||
@@ -4246,13 +4037,13 @@ mod tests {
|
||||
.authoritative_commit
|
||||
.as_ref()
|
||||
.expect("seed retains the old cohort proof");
|
||||
assert_eq!(committed.replicas, vec![native_replica_id(&store, 0, 0), native_replica_id(&store, 0, 1)]);
|
||||
assert_eq!(committed.replicas, vec![replica_id(0, 0), replica_id(0, 1)]);
|
||||
assert_eq!(loaded.replica_count, 6);
|
||||
assert_eq!(loaded.healthy_replicas, 6);
|
||||
let seeded = loaded
|
||||
.replicas
|
||||
.iter()
|
||||
.find(|replica| replica.id == native_replica_id(&store, 2, 0))
|
||||
.find(|replica| replica.id == replica_id(2, 0))
|
||||
.expect("the canceled caller's admitted seed replica");
|
||||
assert!(matches!(
|
||||
&seeded.state,
|
||||
@@ -4264,7 +4055,7 @@ mod tests {
|
||||
let remaining = loaded
|
||||
.replicas
|
||||
.iter()
|
||||
.find(|replica| replica.id == native_replica_id(&store, 2, 1))
|
||||
.find(|replica| replica.id == replica_id(2, 1))
|
||||
.expect("the remaining seed replica");
|
||||
assert!(matches!(
|
||||
&remaining.state,
|
||||
@@ -4477,12 +4268,7 @@ mod tests {
|
||||
.prepare_scanner_pause_backlog_retirement_for_test(0, source_before.len() * 2)
|
||||
.await
|
||||
.expect("retire the old cohort's first pool");
|
||||
let surviving = [
|
||||
native_replica_id(&store, 1, 0),
|
||||
native_replica_id(&store, 1, 1),
|
||||
native_replica_id(&store, 2, 0),
|
||||
native_replica_id(&store, 2, 1),
|
||||
];
|
||||
let surviving = [replica_id(1, 0), replica_id(1, 1), replica_id(2, 0), replica_id(2, 1)];
|
||||
let newer = claim_scanner_pause_backlog_writer(&old.ledger, now + 2).expect("surviving writer");
|
||||
replace_native_pause_backlog_replica(
|
||||
&store,
|
||||
@@ -4547,7 +4333,7 @@ mod tests {
|
||||
assert_eq!(loaded.ledger, original);
|
||||
assert!(loaded.durable && loaded.stable_matches_ledger);
|
||||
let committed = loaded.authoritative_commit.expect("survivor commit proof");
|
||||
assert_eq!(committed.replicas, vec![native_replica_id(&store, 2, 0), native_replica_id(&store, 2, 1)]);
|
||||
assert_eq!(committed.replicas, vec![replica_id(2, 0), replica_id(2, 1)]);
|
||||
}
|
||||
shutdown_native_retirement_store(store).await;
|
||||
});
|
||||
@@ -5125,135 +4911,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn replica_id(pool_index: usize, set_index: usize) -> ScannerPauseBacklogReplicaId {
|
||||
ScannerPauseBacklogReplicaId {
|
||||
pool_index,
|
||||
set_index,
|
||||
set_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn native_replica_id(store: &ECStore, pool_index: usize, set_index: usize) -> ScannerPauseBacklogReplicaId {
|
||||
ScannerPauseBacklogReplicaId::from_set(&store.pools[pool_index].disk_set[set_index])
|
||||
.expect("native replica has an immutable format identity")
|
||||
}
|
||||
|
||||
fn native_replica_ids(store: &ECStore) -> Vec<ScannerPauseBacklogReplicaId> {
|
||||
store
|
||||
.pools
|
||||
.iter()
|
||||
.flat_map(|pool| pool.disk_set.iter())
|
||||
.map(|set| ScannerPauseBacklogReplicaId::from_set(set).expect("native replica format identity"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bound_replica_id(pool_index: usize, identity: u128) -> ScannerPauseBacklogReplicaId {
|
||||
ScannerPauseBacklogReplicaId {
|
||||
pool_index,
|
||||
set_index: 0,
|
||||
set_id: Some(uuid::Uuid::from_u128(identity)),
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_replica(id: ScannerPauseBacklogReplicaId) -> ScannerPauseBacklogReplica {
|
||||
ScannerPauseBacklogReplica {
|
||||
id,
|
||||
revision: Some(DataUsageCacheRevision::Missing),
|
||||
state: ScannerPauseBacklogReplicaState::Missing,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_membership_survives_pool_reindexing() {
|
||||
let ledger = durable_ledger(50);
|
||||
let previous = bound_replica_id(2, 22);
|
||||
let current = bound_replica_id(1, 22);
|
||||
let added = bound_replica_id(2, 33);
|
||||
let record = replica_record_for_members(&ledger, &ledger, &[previous]);
|
||||
assert_eq!(record.replica_schema_version, 1);
|
||||
let loaded = select_scanner_pause_backlog_replicas(vec![
|
||||
missing_replica(bound_replica_id(0, 11)),
|
||||
decoded_replica(current, &record),
|
||||
missing_replica(added),
|
||||
])
|
||||
.expect("a surviving set keeps its commit proof after its routing index changes");
|
||||
assert_eq!(loaded.ledger, ledger);
|
||||
assert!(loaded.durable);
|
||||
assert_eq!(loaded.authoritative_commit.expect("surviving native proof").replicas, vec![current]);
|
||||
|
||||
let mut replicas = vec![retirement_replica(current, &record)];
|
||||
for id in [bound_replica_id(0, 11), added] {
|
||||
replicas.push(ScannerPauseBacklogRetirementReplica {
|
||||
pool_index: id.pool_index,
|
||||
set_index: id.set_index,
|
||||
set_id: id.set_id,
|
||||
writable: true,
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
let plan = plan_scanner_pause_backlog_retirement(1, &replicas)
|
||||
.expect("reindexed native authority permits safe retirement")
|
||||
.expect("new survivors need the existing ledger");
|
||||
assert_eq!(planned_stable_ledger(&plan), ledger);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_membership_does_not_rebind_a_missing_set_to_its_reused_index() {
|
||||
let ledger = durable_ledger(50);
|
||||
let missing = bound_replica_id(2, 22);
|
||||
let replacement = bound_replica_id(2, 33);
|
||||
let record = replica_record_for_members(&ledger, &ledger, &[missing]);
|
||||
assert!(
|
||||
select_scanner_pause_backlog_replicas(vec![
|
||||
missing_replica(bound_replica_id(0, 11)),
|
||||
decoded_replica(replacement, &record),
|
||||
])
|
||||
.is_err(),
|
||||
"copied bytes on a new set cannot impersonate the missing commit member"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_membership_binds_only_after_its_existing_proof_is_verified() {
|
||||
let ledger = durable_ledger(50);
|
||||
let legacy = replica_record_for_members(&ledger, &ledger, &[replica_id(2, 0)]);
|
||||
assert_eq!(legacy.replica_schema_version, 1);
|
||||
let current = bound_replica_id(2, 22);
|
||||
let loaded = select_scanner_pause_backlog_replicas(vec![decoded_replica(current, &legacy)])
|
||||
.expect("unchanged legacy membership remains readable");
|
||||
let upgraded = stable_scanner_pause_backlog_record(
|
||||
&loaded.ledger,
|
||||
loaded.authoritative_commit.as_ref(),
|
||||
&scanner_pause_backlog_replica_ids(&loaded.replicas),
|
||||
);
|
||||
upgraded
|
||||
.validate()
|
||||
.expect("the replacement record binds every committed member");
|
||||
assert_eq!(upgraded.replica_schema_version, 1);
|
||||
assert_eq!(upgraded.committed.expect("bound membership").replicas, vec![current]);
|
||||
|
||||
assert!(
|
||||
select_scanner_pause_backlog_replicas(vec![
|
||||
decoded_replica(bound_replica_id(1, 22), &legacy),
|
||||
missing_replica(bound_replica_id(2, 33)),
|
||||
])
|
||||
.is_err(),
|
||||
"an already stranded legacy proof must not guess its old member's identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_membership_rejects_nil_duplicate_and_mixed_identities() {
|
||||
let ledger = durable_ledger(50);
|
||||
for members in [
|
||||
vec![bound_replica_id(0, 0)],
|
||||
vec![bound_replica_id(0, 11), bound_replica_id(1, 11)],
|
||||
vec![bound_replica_id(0, 11), bound_replica_id(0, 22)],
|
||||
vec![bound_replica_id(0, 11), replica_id(1, 0)],
|
||||
] {
|
||||
let record = replica_record_for_members(&ledger, &ledger, &members);
|
||||
assert_eq!(record.replica_schema_version, 1);
|
||||
assert!(record.validate().is_err());
|
||||
}
|
||||
ScannerPauseBacklogReplicaId { pool_index, set_index }
|
||||
}
|
||||
|
||||
fn decoded_replica(
|
||||
@@ -5497,7 +5155,6 @@ mod tests {
|
||||
.map(|set_index| ScannerPauseBacklogReplicaId {
|
||||
pool_index: 0,
|
||||
set_index,
|
||||
set_id: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
replica_record_for_members(stable, committed, &replicas)
|
||||
@@ -5510,7 +5167,6 @@ mod tests {
|
||||
ScannerPauseBacklogRetirementReplica {
|
||||
pool_index: id.pool_index,
|
||||
set_index: id.set_index,
|
||||
set_id: id.set_id,
|
||||
writable: true,
|
||||
data: Some(serde_json::to_vec(record).expect("native record bytes")),
|
||||
}
|
||||
@@ -5638,7 +5294,6 @@ mod tests {
|
||||
replicas.extend(targets.iter().map(|id| ScannerPauseBacklogRetirementReplica {
|
||||
pool_index: id.pool_index,
|
||||
set_index: id.set_index,
|
||||
set_id: id.set_id,
|
||||
writable: true,
|
||||
data: None,
|
||||
}));
|
||||
|
||||
@@ -113,37 +113,17 @@ pub(super) async fn setup_scanner_cycle_store_at_path_with_layout_and_disk_prein
|
||||
sets_per_pool: usize,
|
||||
drives_per_set: usize,
|
||||
preinitialize_disks: bool,
|
||||
) -> Arc<ECStore> {
|
||||
setup_scanner_cycle_store_with_pool_order(
|
||||
root,
|
||||
seed_usage_baseline,
|
||||
&(0..pool_count).collect::<Vec<_>>(),
|
||||
sets_per_pool,
|
||||
drives_per_set,
|
||||
preinitialize_disks,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn setup_scanner_cycle_store_with_pool_order(
|
||||
root: &Path,
|
||||
seed_usage_baseline: bool,
|
||||
pool_order: &[usize],
|
||||
sets_per_pool: usize,
|
||||
drives_per_set: usize,
|
||||
preinitialize_disks: bool,
|
||||
) -> Arc<ECStore> {
|
||||
init_ecstore_config_for_scanner_tests();
|
||||
let pool_count = pool_order.len();
|
||||
let mut pools = Vec::with_capacity(pool_count);
|
||||
for (pool_index, physical_pool) in pool_order.iter().enumerate() {
|
||||
for pool_index in 0..pool_count {
|
||||
let mut endpoints = Vec::new();
|
||||
for set_index in 0..sets_per_pool {
|
||||
for disk_index in 0..drives_per_set {
|
||||
let disk_path = if sets_per_pool == 1 {
|
||||
root.join(format!("pool{physical_pool}/disk{disk_index}"))
|
||||
root.join(format!("pool{pool_index}/disk{disk_index}"))
|
||||
} else {
|
||||
root.join(format!("pool{physical_pool}/set{set_index}/disk{disk_index}"))
|
||||
root.join(format!("pool{pool_index}/set{set_index}/disk{disk_index}"))
|
||||
};
|
||||
tokio::fs::create_dir_all(&disk_path)
|
||||
.await
|
||||
@@ -164,7 +144,7 @@ pub(super) async fn setup_scanner_cycle_store_with_pool_order(
|
||||
cmd_line: if pool_count == 1 && sets_per_pool == 1 {
|
||||
"scanner-cycle-metrics".to_string()
|
||||
} else {
|
||||
format!("scanner-cycle-metrics-pool-{physical_pool}")
|
||||
format!("scanner-cycle-metrics-pool-{pool_index}")
|
||||
},
|
||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
});
|
||||
|
||||
@@ -591,34 +591,6 @@ pub trait AuthoritativeListOperations: ListOperations {
|
||||
max_keys: i32,
|
||||
) -> Result<Self::ListObjectVersionsInfo, Self::Error>;
|
||||
|
||||
/// Enumerate an exact dotted leaf below identifier-like directory components
|
||||
/// containing ASCII letters, digits, `_` or `-`. Noncandidate object metadata
|
||||
/// is outside the scan. Namespace components may occur at any depth; reserved
|
||||
/// words do not identify a subtree that can be skipped.
|
||||
///
|
||||
/// Returned keys retain the quorum, unversioned, EOF and cancellation
|
||||
/// guarantees of `list_unversioned_objects_authoritative`. Older peers may
|
||||
/// perform extra I/O; the coordinator must filter their extra rows before
|
||||
/// resolving metadata. Callers fence concurrent mutations separately.
|
||||
///
|
||||
/// # Errors
|
||||
/// Rejects an invalid directory prefix/leaf, invalid page size, uncertain
|
||||
/// matching metadata, versioned entries or insufficient read quorum. Backends
|
||||
/// without this capability return Unsupported; ordinary listing is not a fallback.
|
||||
async fn list_identifier_tree_leaves_authoritative(
|
||||
self: Arc<Self>,
|
||||
_bucket: &str,
|
||||
_prefix: &str,
|
||||
_leaf: &str,
|
||||
_marker: Option<String>,
|
||||
_max_keys: i32,
|
||||
) -> Result<Self::ListObjectVersionsInfo, Self::Error>
|
||||
where
|
||||
Self::Error: From<std::io::Error>,
|
||||
{
|
||||
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "authoritative identifier-tree listing is not supported").into())
|
||||
}
|
||||
|
||||
/// Resolves every set at its version read quorum, including reserved prefixes.
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::TargetError;
|
||||
use crate::store::resolve_queue_dir;
|
||||
use crate::target::nats::validate_jetstream_settings;
|
||||
use crate::target::pulsar::validate_pulsar_broker;
|
||||
use async_nats::ServerAddr;
|
||||
@@ -29,6 +30,12 @@ use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use url::Url;
|
||||
|
||||
/// Return the queue directory this node opens for a target, with logical defaults resolved.
|
||||
pub(super) fn queue_dir_or_default(config: &KVS, key: &str, default_queue_dir: &str) -> String {
|
||||
let queue_dir = config.lookup(key).unwrap_or_else(|| default_queue_dir.to_string());
|
||||
resolve_queue_dir(&queue_dir).into_owned()
|
||||
}
|
||||
|
||||
pub(super) fn split_env_field_and_instance(rest: &str, valid_fields: &HashSet<String>) -> Option<(String, String)> {
|
||||
let normalized = rest.to_lowercase();
|
||||
if valid_fields.contains(&normalized) {
|
||||
@@ -124,7 +131,7 @@ pub(super) fn validate_nats_server_config(server: &ServerAddr, config: &KVS, def
|
||||
));
|
||||
}
|
||||
|
||||
let queue_dir = config.lookup(NATS_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string());
|
||||
let queue_dir = queue_dir_or_default(config, NATS_QUEUE_DIR, default_queue_dir);
|
||||
if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() {
|
||||
return Err(TargetError::Configuration("NATS queue directory must be an absolute path".to_string()));
|
||||
}
|
||||
@@ -204,9 +211,7 @@ pub(super) fn validate_pulsar_broker_config(broker: &str, config: &KVS, default_
|
||||
));
|
||||
}
|
||||
|
||||
let queue_dir = config
|
||||
.lookup(PULSAR_QUEUE_DIR)
|
||||
.unwrap_or_else(|| default_queue_dir.to_string());
|
||||
let queue_dir = queue_dir_or_default(config, PULSAR_QUEUE_DIR, default_queue_dir);
|
||||
if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() {
|
||||
return Err(TargetError::Configuration("Pulsar queue directory must be an absolute path".to_string()));
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::common::{
|
||||
parse_jetstream_ack_timeout_secs, parse_jetstream_enable, parse_target_bool, parse_url, validate_nats_server_config,
|
||||
validate_outbound_http_url, validate_pulsar_broker_config,
|
||||
parse_jetstream_ack_timeout_secs, parse_jetstream_enable, parse_target_bool, parse_url, queue_dir_or_default,
|
||||
validate_nats_server_config, validate_outbound_http_url, validate_pulsar_broker_config,
|
||||
};
|
||||
use crate::error::TargetError;
|
||||
use crate::target::{
|
||||
@@ -118,7 +118,7 @@ pub fn build_amqp_args(config: &KVS, default_queue_dir: &str, target_type: Targe
|
||||
tls_ca: config.lookup(AMQP_TLS_CA).unwrap_or_default(),
|
||||
tls_client_cert: config.lookup(AMQP_TLS_CLIENT_CERT).unwrap_or_default(),
|
||||
tls_client_key: config.lookup(AMQP_TLS_CLIENT_KEY).unwrap_or_default(),
|
||||
queue_dir: config.lookup(AMQP_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()),
|
||||
queue_dir: queue_dir_or_default(config, AMQP_QUEUE_DIR, default_queue_dir),
|
||||
queue_limit: config
|
||||
.lookup(AMQP_QUEUE_LIMIT)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
@@ -146,9 +146,7 @@ pub fn build_webhook_args(config: &KVS, default_queue_dir: &str, target_type: Ta
|
||||
enable: true,
|
||||
endpoint: endpoint_url,
|
||||
auth_token: config.lookup(WEBHOOK_AUTH_TOKEN).unwrap_or_default(),
|
||||
queue_dir: config
|
||||
.lookup(WEBHOOK_QUEUE_DIR)
|
||||
.unwrap_or_else(|| default_queue_dir.to_string()),
|
||||
queue_dir: queue_dir_or_default(config, WEBHOOK_QUEUE_DIR, default_queue_dir),
|
||||
queue_limit: config
|
||||
.lookup(WEBHOOK_QUEUE_LIMIT)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
@@ -180,9 +178,7 @@ pub fn validate_webhook_config(config: &KVS, default_queue_dir: &str) -> Result<
|
||||
));
|
||||
}
|
||||
|
||||
let queue_dir = config
|
||||
.lookup(WEBHOOK_QUEUE_DIR)
|
||||
.unwrap_or_else(|| default_queue_dir.to_string());
|
||||
let queue_dir = queue_dir_or_default(config, WEBHOOK_QUEUE_DIR, default_queue_dir);
|
||||
if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() {
|
||||
return Err(TargetError::Configuration("Webhook queue directory must be an absolute path".to_string()));
|
||||
}
|
||||
@@ -234,7 +230,7 @@ pub fn build_mqtt_args(config: &KVS, default_queue_dir: &str, target_type: Targe
|
||||
config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(),
|
||||
config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(),
|
||||
)?,
|
||||
queue_dir: config.lookup(MQTT_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()),
|
||||
queue_dir: queue_dir_or_default(config, MQTT_QUEUE_DIR, default_queue_dir),
|
||||
queue_limit: config
|
||||
.lookup(MQTT_QUEUE_LIMIT)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
@@ -311,7 +307,7 @@ pub fn build_nats_args(config: &KVS, default_queue_dir: &str, target_type: Targe
|
||||
tls_client_cert: config.lookup(NATS_TLS_CLIENT_CERT).unwrap_or_default(),
|
||||
tls_client_key: config.lookup(NATS_TLS_CLIENT_KEY).unwrap_or_default(),
|
||||
tls_required: parse_target_bool(config.lookup(NATS_TLS_REQUIRED).as_deref()).unwrap_or(false),
|
||||
queue_dir: config.lookup(NATS_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()),
|
||||
queue_dir: queue_dir_or_default(config, NATS_QUEUE_DIR, default_queue_dir),
|
||||
queue_limit: config
|
||||
.lookup(NATS_QUEUE_LIMIT)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
@@ -353,9 +349,7 @@ pub fn build_pulsar_args(config: &KVS, default_queue_dir: &str, target_type: Tar
|
||||
tls_ca: config.lookup(PULSAR_TLS_CA).unwrap_or_default(),
|
||||
tls_allow_insecure: parse_target_bool(config.lookup(PULSAR_TLS_ALLOW_INSECURE).as_deref()).unwrap_or(false),
|
||||
tls_hostname_verification: parse_target_bool(config.lookup(PULSAR_TLS_HOSTNAME_VERIFICATION).as_deref()).unwrap_or(true),
|
||||
queue_dir: config
|
||||
.lookup(PULSAR_QUEUE_DIR)
|
||||
.unwrap_or_else(|| default_queue_dir.to_string()),
|
||||
queue_dir: queue_dir_or_default(config, PULSAR_QUEUE_DIR, default_queue_dir),
|
||||
queue_limit: config
|
||||
.lookup(PULSAR_QUEUE_LIMIT)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
@@ -405,9 +399,7 @@ pub fn build_redis_args(
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or_else(|| Duration::from_secs(15)),
|
||||
queue_dir: config
|
||||
.lookup(REDIS_QUEUE_DIR)
|
||||
.unwrap_or_else(|| default_queue_dir.to_string()),
|
||||
queue_dir: queue_dir_or_default(config, REDIS_QUEUE_DIR, default_queue_dir),
|
||||
queue_limit: config
|
||||
.lookup(REDIS_QUEUE_LIMIT)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
@@ -463,9 +455,7 @@ pub fn build_postgres_args(config: &KVS, default_queue_dir: &str, target_type: T
|
||||
tls_ca: config.lookup(POSTGRES_TLS_CA).unwrap_or_default(),
|
||||
tls_client_cert: config.lookup(POSTGRES_TLS_CLIENT_CERT).unwrap_or_default(),
|
||||
tls_client_key: config.lookup(POSTGRES_TLS_CLIENT_KEY).unwrap_or_default(),
|
||||
queue_dir: config
|
||||
.lookup(POSTGRES_QUEUE_DIR)
|
||||
.unwrap_or_else(|| default_queue_dir.to_string()),
|
||||
queue_dir: queue_dir_or_default(config, POSTGRES_QUEUE_DIR, default_queue_dir),
|
||||
queue_limit: config
|
||||
.lookup(POSTGRES_QUEUE_LIMIT)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
@@ -529,9 +519,7 @@ pub fn build_kafka_args(config: &KVS, default_queue_dir: &str, target_type: Targ
|
||||
},
|
||||
sasl_username,
|
||||
sasl_password,
|
||||
queue_dir: config
|
||||
.lookup(KAFKA_QUEUE_DIR)
|
||||
.unwrap_or_else(|| default_queue_dir.to_string()),
|
||||
queue_dir: queue_dir_or_default(config, KAFKA_QUEUE_DIR, default_queue_dir),
|
||||
queue_limit: config
|
||||
.lookup(KAFKA_QUEUE_LIMIT)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
@@ -569,9 +557,7 @@ pub fn build_mysql_args(config: &KVS, default_queue_dir: &str, target_type: Targ
|
||||
tls_ca: config.lookup(MYSQL_TLS_CA).unwrap_or_default(),
|
||||
tls_client_cert: config.lookup(MYSQL_TLS_CLIENT_CERT).unwrap_or_default(),
|
||||
tls_client_key: config.lookup(MYSQL_TLS_CLIENT_KEY).unwrap_or_default(),
|
||||
queue_dir: config
|
||||
.lookup(MYSQL_QUEUE_DIR)
|
||||
.unwrap_or_else(|| default_queue_dir.to_string()),
|
||||
queue_dir: queue_dir_or_default(config, MYSQL_QUEUE_DIR, default_queue_dir),
|
||||
queue_limit: config
|
||||
.lookup(MYSQL_QUEUE_LIMIT)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
use crate::error::StoreError;
|
||||
use rustfs_config::notify::{COMPRESS_EXT, DEFAULT_EXT};
|
||||
use rustfs_config::{
|
||||
DEFAULT_LIMIT, DEFAULT_TARGET_STORE_COMPRESS, DEFAULT_TARGET_STORE_MAX_BYTES, ENV_TARGET_STORE_COMPRESS,
|
||||
ENV_TARGET_STORE_MAX_BYTES, EnableState,
|
||||
AUDIT_DEFAULT_DIR, DEFAULT_LIMIT, DEFAULT_TARGET_STORE_COMPRESS, DEFAULT_TARGET_STORE_MAX_BYTES, ENV_TARGET_STORE_COMPRESS,
|
||||
ENV_TARGET_STORE_DIR, ENV_TARGET_STORE_MAX_BYTES, EVENT_DEFAULT_DIR, EnableState,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use snap::raw::{Decoder, Encoder};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::Write,
|
||||
@@ -155,6 +156,46 @@ fn queue_store_compression_enabled() -> bool {
|
||||
resolve_queue_store_compression_from_env_value(value.as_deref())
|
||||
}
|
||||
|
||||
/// Map a configured target `queue_dir` to the directory this node opens.
|
||||
///
|
||||
/// `EVENT_DEFAULT_DIR` and `AUDIT_DEFAULT_DIR` are persisted cluster-wide as logical
|
||||
/// defaults, so every node resolves them locally. `RUSTFS_TARGET_STORE_DIR` relocates
|
||||
/// their root on any platform. Without it, Windows uses `%ProgramData%\RustFS`, because
|
||||
/// the Unix literal has no drive prefix and is not an absolute path there. Every other
|
||||
/// value, including an explicit operator path, is returned unchanged.
|
||||
pub fn resolve_queue_dir(queue_dir: &str) -> Cow<'_, str> {
|
||||
let store_root = std::env::var(ENV_TARGET_STORE_DIR).ok();
|
||||
resolve_queue_dir_with(queue_dir, store_root.as_deref(), platform_queue_root().as_deref())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn platform_queue_root() -> Option<PathBuf> {
|
||||
std::env::var_os("ProgramData")
|
||||
.filter(|root| !root.is_empty())
|
||||
.map(|root| PathBuf::from(root).join("RustFS"))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn platform_queue_root() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_queue_dir_with<'a>(queue_dir: &'a str, store_root: Option<&str>, platform_root: Option<&Path>) -> Cow<'a, str> {
|
||||
let leaf = match queue_dir {
|
||||
EVENT_DEFAULT_DIR => "events",
|
||||
AUDIT_DEFAULT_DIR => "audit",
|
||||
_ => return Cow::Borrowed(queue_dir),
|
||||
};
|
||||
let root = match store_root.map(str::trim).filter(|root| !root.is_empty()) {
|
||||
Some(root) => Path::new(root),
|
||||
None => match platform_root {
|
||||
Some(root) => root,
|
||||
None => return Cow::Borrowed(queue_dir),
|
||||
},
|
||||
};
|
||||
Cow::Owned(root.join(leaf).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Represents a key for an entry in the store
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Key {
|
||||
@@ -1365,6 +1406,56 @@ mod tests {
|
||||
thread,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn logical_default_queue_dirs_resolve_under_the_configured_root() {
|
||||
let root = Path::new("/srv/rustfs-queues");
|
||||
let platform = Path::new("/platform/RustFS");
|
||||
assert_eq!(
|
||||
resolve_queue_dir_with(EVENT_DEFAULT_DIR, Some("/srv/rustfs-queues"), Some(platform)),
|
||||
root.join("events").to_string_lossy()
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_queue_dir_with(AUDIT_DEFAULT_DIR, Some(" /srv/rustfs-queues "), None),
|
||||
root.join("audit").to_string_lossy()
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_queue_dir_with(EVENT_DEFAULT_DIR, Some(" "), Some(platform)),
|
||||
platform.join("events").to_string_lossy(),
|
||||
"a blank override must fall back to the platform root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_and_unmapped_queue_dirs_are_preserved() {
|
||||
let platform = Path::new("/platform/RustFS");
|
||||
for explicit in [
|
||||
"/var/lib/rustfs/events",
|
||||
"/opt/rustfs/events/",
|
||||
"/opt/rustfs",
|
||||
"",
|
||||
"relative/queue",
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve_queue_dir_with(explicit, Some("/srv/rustfs-queues"), Some(platform)),
|
||||
explicit,
|
||||
"only the exact logical defaults may be relocated"
|
||||
);
|
||||
}
|
||||
assert_eq!(resolve_queue_dir_with(EVENT_DEFAULT_DIR, None, None), EVENT_DEFAULT_DIR);
|
||||
assert_eq!(resolve_queue_dir_with(AUDIT_DEFAULT_DIR, None, None), AUDIT_DEFAULT_DIR);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_logical_default_queue_dirs_are_absolute_without_an_override() {
|
||||
let platform = platform_queue_root().expect("Windows defines ProgramData");
|
||||
for default in [EVENT_DEFAULT_DIR, AUDIT_DEFAULT_DIR] {
|
||||
let resolved = resolve_queue_dir_with(default, None, Some(&platform));
|
||||
assert!(Path::new(resolved.as_ref()).is_absolute(), "{default} resolved to {resolved}");
|
||||
assert!(resolved.contains("RustFS"), "{default} resolved to {resolved}");
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_store_dir(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("rustfs-targets-{name}-{}", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
@@ -72,8 +72,6 @@ Ordinary publication rechecks the selected pool against the durable pool metadat
|
||||
|
||||
Running, queued, failed, canceled, and completed decommission states exclude the source from new ordinary publication, including new multipart uploads. Previously created multipart uploads retain their drain path while the source remains non-terminal; terminal source states reject further multipart publication. Failed and canceled entries become writable for new ordinary publication only after an allowed clear operation removes that state. This check does not change repair admission or the separate fence for operations that only release capacity.
|
||||
|
||||
Bucket creation and deletion remove stale bucket usage through namespace maintenance, independently of the scanner publication pause. Failed or canceled decommission state alone does not reject that cleanup. The update checks the logical snapshot revision while holding the fixed and physical object locks, then conditionally publishes to a writable pool; a suspended source remains available for decommission to reconcile against the newer target. Cleanup preserves the scanner epoch and cycle, and retains the bucket namespace and movement guards until its physical writes finish even if the request is canceled. Namespace lease loss still fences the write. Scanner publication continues to require its existing movement admission.
|
||||
|
||||
For mixed batch deletes, only the pools selected to receive new delete markers are publication targets. Exact-version deletions on other pools remain protected by the same pool metadata read fence, without treating the retiring source or an unrelated reserved target as a destination for those markers.
|
||||
|
||||
### Shared Capacity On Healthy Targets
|
||||
@@ -145,10 +143,6 @@ Existing capacity-owner and mutation checks reconcile the pending intent before
|
||||
source cleanup; the replica exception does not clear an unknown intent, rewrite
|
||||
the native target, or change the scanner's committed-membership selection.
|
||||
|
||||
Backlog membership also carries an optional `set_id`, the first disk-slot UUID from the erasure set's persisted format. This identity survives pool removal, reordering, and disk replacement; `pool_index` and `set_index` remain routing locations. Readers resolve bound members by that UUID before checking the existing commit and rollback proofs. A new pool at a removed member's old index cannot impersonate that member. New publications bind every member, while positional records remain readable and gain identities only after their existing authority is verified. Replica schema 1 is retained; 1.0.4 readers ignore the additive identity field and still use positional membership.
|
||||
|
||||
Finish upgrading all scanner-capable nodes before changing pool membership. An older writer can replace a bound record with a positional one. If a legacy record was already stranded by pool reindexing, the missing identity cannot be guessed safely: restore the known topology in which its proof was readable, then let the updated writer bind the membership before changing it again. Do not delete or reset the backlog ledger to bypass this check. Partial writes, canceled callers, missing members, and conflicting authority remain subject to the native CAS and recovery rules above.
|
||||
|
||||
## MinIO Divergence Decisions
|
||||
|
||||
Behavior that is close to MinIO but not byte-for-byte identical. Changing either decision requires an operator compatibility note and updated characterization tests.
|
||||
|
||||
@@ -45,9 +45,18 @@ In a distributed deployment, startup publishes `FullReady` only after that local
|
||||
- Liveness reports process availability and must not depend on storage, IAM,
|
||||
lock quorum, or peer health.
|
||||
- Node readiness (`/health/ready`, `/minio/health/ready`) reports whether this process can serve reads: storage read quorum, IAM, and shared-lock quorum. It is the Kubernetes Service membership signal. Losing write quorum, the pool-metadata writer, or exclusive-lock quorum does not remove a node that can still read. The public health handler still returns 503 until startup publishes `FullReady`, so a process that has never opened S3 admission does not join the Service. Runtime storage diagnostics do not change that startup gate.
|
||||
- Immutable pool metadata admission proofs share the local authority gate as readers. Publication, recovery, identity changes and operations requiring serialization retain exclusive ownership and their existing distributed namespace fences. A multi-phase operation releases its reader before acquiring the writer and revalidates under exclusive ownership; it never upgrades a held reader. The gate is fair, so a writer queued behind a reader that is waiting on namespace I/O also delays later admissions until that reader finishes. Every writer publishes the authority it leaves before releasing exclusive ownership. Health inspection reads that published authority without acquiring the gate, so neither a held nor a queued writer delays it. A writer's changes become visible when it releases the gate; a latch recorded by a transaction outcome after its writer released is shared with the published authority and visible immediately. Mutating paths still check authority under the gate.
|
||||
- A blocked pool metadata writer degrades cluster-write readiness with `pool_meta_write_blocked`. It does not withdraw `/health/ready`, and the node probe omits that reason from `degradedReasons`. Metadata authority inspection reads the state published by the last local writer and does not wait for the save gate, so a held or queued metadata writer does not degrade either probe; a block that writer installs appears when it releases the gate.
|
||||
- The authenticated cluster snapshot extends its existing node-local metadata gate inspection with safe reason, failure phase, and original block time. It changes no admission or recovery decision. Runtime readiness and gate status are separate observations.
|
||||
- A blocked pool metadata writer degrades cluster-write readiness with
|
||||
`pool_meta_write_blocked`. It does not withdraw `/health/ready`, and the node
|
||||
probe omits that reason from `degradedReasons`. Metadata save-gate inspection
|
||||
is bounded to 100 ms; contention reports `pool_metadata_check_timeout` on the
|
||||
cluster-write probe without installing a block. The node probe keeps the last
|
||||
confirmed writable observation for `details.poolMetadata` during a transient
|
||||
inspection timeout, while preserving a confirmed block and failing that
|
||||
detail closed when no fresh observation exists.
|
||||
- The authenticated cluster snapshot extends its existing node-local metadata
|
||||
gate inspection with safe reason, failure phase, and original block time. It
|
||||
distinguishes timeout from a block and changes no admission or recovery
|
||||
decision. Runtime readiness and gate status are separate bounded observations.
|
||||
- Cluster write readiness (`/minio/health/cluster`) requires storage write quorum, the pool-metadata write gate, and exclusive-lock quorum. Use it when a caller must know that new writes can commit. It is not the Service membership probe.
|
||||
- Cluster read readiness uses the storage read-quorum path and cluster-health timeout behavior. Its lock dependency still uses the per-set majority/write-lock health check. Actual shared namespace locks require `n - n/2`, so the cluster read probe is conservative: a four-client set can still admit reads with two clients while that probe returns 503. Node `/health/ready` uses the shared-lock threshold instead, because that is what object reads acquire.
|
||||
- `HEAD` health probes keep header/status semantics and do not require response
|
||||
@@ -82,7 +91,7 @@ For a healthy IAM and metadata writer in a four-node, one-drive-per-node EC 2+2
|
||||
|
||||
With two of four nodes up, GET, HEAD, and List of a bucket that this process has not yet cached still succeed. PUT and other mutations fail because write quorum and exclusive locks are gone.
|
||||
|
||||
The node path reads local disk-handle health and reuses the same reachable-host observation as its lock dependency, including the existing `RUSTFS_HEALTH_READINESS_CACHE_TTL_MS` cache. Only `Online` drives count; a reachable host with a `Returning` drive does not yet prove data I/O has recovered. It does not call cluster `storage_info`, local `disk_info`, or add disk-info RPCs. The entire storage inventory snapshot has a separate 100 ms wait budget; expiry reports `storage_readiness_check_timeout` and fails closed. Pool metadata inspection reads the authority published by the last local writer and does not wait for the save gate. These observations are not an atomic cluster snapshot and do not bypass the existing lock-probe timing or cache policy.
|
||||
The node path reads local disk-handle health and reuses the same reachable-host observation as its lock dependency, including the existing `RUSTFS_HEALTH_READINESS_CACHE_TTL_MS` cache. Only `Online` drives count; a reachable host with a `Returning` drive does not yet prove data I/O has recovered. It does not call cluster `storage_info`, local `disk_info`, or add disk-info RPCs. The entire storage inventory snapshot has a separate 100 ms wait budget; expiry reports `storage_readiness_check_timeout` and fails closed. Pool metadata inspection retains its own 100 ms budget; a timeout is counted by `rustfs_pool_metadata_check_timeouts_total` and uses the last confirmed node-local gate state only within the same cache TTL. These observations are not an atomic cluster snapshot and do not bypass the existing lock-probe timing or cache policy.
|
||||
|
||||
The new fields are additive. Their absence in an older response is not evidence of storage quorum. Minimal responses still contain only the existing top-level fields, liveness remains dependency-independent, and HEAD responses remain bodyless.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ RustFS S3 Tables is an Iceberg REST Catalog and table-bucket implementation on t
|
||||
| `/_iceberg/v1` | Supported compatibility alias | MinIO AIStor-style alias; smoke profile defaults to signing name `s3tables`. |
|
||||
| S3 object data plane | Supported | Data, metadata, manifest, and delete files are ordinary S3 objects with table-aware policy checks on warehouse paths. |
|
||||
| S3 table-bucket listings | Supported | `ListObjects`, `ListObjectsV2`, `ListObjectVersions`, metadata-list extensions, and multipart-upload listings omit warehouse objects unless the caller has `admin:GetTableMetadata` for that table, and always omit internal `.rustfs-table` keys. Protected cursors are encrypted with a key derived from the cluster root credentials, so rotating those credentials invalidates cursors already issued. |
|
||||
| Object-backed warehouse-index recovery | Supported with bounded request fallback | Data-plane requests repair or verify a missing warehouse-prefix index using authoritative table-entry pages. The scan selects `table-entry.json` below identifier-like directory components at any depth; namespace components named `tables` or `maintenance` remain valid. Noncandidate report names are filtered in directory batches before inventory retention and metadata reads, so report history does not consume ownership pages. Selected entries retain GET-equivalent quorum and corruption checks. More than 4,096 table entries fails closed with a retryable service error and requires operator backfill. An optional table-entry directory (see Maintenance Matrix) answers these lookups without the prefix listing. |
|
||||
| Object-backed warehouse-index recovery | Supported with bounded request fallback | Data-plane requests repair or verify a missing warehouse-prefix index by listing the catalog prefix. Only table entries count toward the 4,096-object limit, so maintenance reports and other per-table objects cannot exhaust it; the listing itself is bounded to 64 pages plus one page per table entry already listed, and after each page the cursor skips the rest of the maintenance subtree the page ended in, so a report flood under one table costs a bounded number of pages. Larger catalogs fail closed with a retryable service error and require operator backfill. An optional table-entry directory (see Maintenance Matrix) answers these lookups without the prefix listing. |
|
||||
| Table bucket enablement | Supported | A regular bucket is enabled for catalog use and addressed as the REST catalog warehouse. |
|
||||
| Catalog-vended table credentials | Automated when enabled | Disabled by default. LoadTable vends credentials only when `X-Iceberg-Access-Delegation` contains the exact `vended-credentials` token; the dedicated credentials endpoint uses the same issuer path. |
|
||||
| AWS S3 Tables endpoint shape | Profile generator | Generates the AWS catalog URI and warehouse ARN shape for migration docs. API parity not claimed. |
|
||||
@@ -132,8 +132,6 @@ Object-backed commit publication identifies every catalog object it validated by
|
||||
|
||||
An optional per-bucket directory of table entries under `.rustfs.sys` answers data-plane index-miss lookups without listing the catalog prefix. It is off by default: `RUSTFS_TABLE_CATALOG_DIRECTORY_INDEX=true` and `RUSTFS_TABLE_CATALOG_DIRECTORY_INDEX_FLEET_CONFIRMED=true` on every node, then `POST /iceberg/v1/{warehouse}/catalog/directory` (`admin:MigrateTableCatalog`) advances a backfill of 128 catalog objects per call until the directory reports `Ready`; `GET` reports the phase and `DELETE` rolls the directory back. Entry writers add a directory candidate only while the directory exists, and the backfill covers earlier entries. The directory state occupies the backing-migration fence object: nodes without this feature answer catalog mutations on that bucket with a parse error until they are upgraded, which is what the fleet-confirmed flag attests, and restores require disabling the directory first. Object catalog backups exclude the derived directory state and candidates. Re-enable the directory after a restore to rebuild it from the restored entries.
|
||||
|
||||
Fallback ownership scans, including the check before metadata cleanup, use the optional `identifier_tree_leaf` walk selector. An absent selector preserves ordinary listing behavior. Older peers ignore the additive field; the coordinator filters their noncandidate rows before metadata resolution and retains read-quorum checks for every selected entry. Old peers may do more I/O or fail on unrelated corrupt metadata, so a mixed-version scan can refuse work until those peers are upgraded. Such failures never establish owner absence. This selector changes no catalog or on-disk format and requires no fleet-confirmation flag.
|
||||
|
||||
|
||||
|
||||
| Capability | Status | Claim |
|
||||
|
||||
@@ -77,7 +77,7 @@ An unreadable replica, lost fence, or conditional-write conflict leaves the orig
|
||||
|
||||
- The first block emits `decommission_state` with `state=pool_metadata_blocked`, `reason`, `phase`, and `blocked_since`. A change in recovery failure classification emits `state=pool_metadata_recovery_pending`; successful recovery emits `state=pool_metadata_recovered` with the original timestamp.
|
||||
- `rustfs_pool_metadata_blocks_total{reason}` and `rustfs_pool_metadata_recoveries_total` count block and recovery transitions. The original cause and phase remain attached to local typed errors; storage/RPC error numbers and on-disk formats are unchanged.
|
||||
- Cluster-write readiness reports `pool_meta_write_blocked`; node readiness reports the writer only through `details.poolMetadata`. Readiness reads the authority published by the last local metadata writer instead of waiting for the save gate, so an active or queued metadata transaction does not degrade readiness by itself. A block installed by a writer appears when that writer releases the gate. Cluster probes retain their existing cache and overall timeout behavior. Liveness and cluster-read quorum checks are unchanged. The authenticated node-local status and safe error fields are described in [S3 write failure diagnostics](s3-write-failure-diagnostics.md).
|
||||
- Node and cluster-write readiness include `pool_meta_write_blocked`. Waiting for the metadata save mutex is bounded to 100 ms and reports `pool_metadata_check_timeout`, not a persistent block. Cluster probes retain their existing cache and overall timeout behavior. Liveness and cluster-read quorum checks are unchanged. The authenticated node-local status and safe error fields are described in [S3 write failure diagnostics](s3-write-failure-diagnostics.md).
|
||||
|
||||
Pool metadata CAS explicitly selects `TailDrainedWithOwnerRepair`: it waits for the complete rename result and returns repair responsibility to its existing deployment and transaction owner when write quorum commits but a replica fails or signatures diverge. Cache invalidation and the committed cleanup handoff precede that error. Ordinary system metadata retains quorum-success semantics under `Quorum` and `TailDrained`; draining a tail does not require every disk to succeed. A repair-required completion does not enter rollback cleanup or wait for another temporary-data deletion. The committed bytes are not rolled back. Generic MRF and heal-channel requests cannot supply the deployment and transaction authority needed to repair system metadata; pool CAS retains the uncertain outcome and its existing recovery fence. Ordinary object repair keeps its bucket-incarnation fencing.
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ When the whole cluster (or several nodes) went down and nodes are brought back o
|
||||
|
||||
2. Logs say what the node waits for. The IAM recovery loop retries with backoff and logs `event="iam_bootstrap_retry_failed"` with an actionable `hint` field (for example, "storage read quorum not met yet; waiting for enough cluster nodes/disks to come online"). After repeated failures the level escalates from WARN to ERROR; this still does not kill the process.
|
||||
3. Recovery is automatic. Once storage read quorum is available, pending nodes can finish IAM bootstrap on the next retry. `/health/ready` returns `200` when startup has published `FullReady` and the node can still serve reads (storage read quorum, IAM, and shared-lock quorum). Write quorum is reported separately and is required by `/minio/health/cluster`, not by Service membership. Restarting pending nodes does not speed this up.
|
||||
4. Check readiness detail while waiting. `/health/ready` (and `/minio/health/ready`) separate `storage` / `poolMetadata` / `iam` / `lock` readiness. `storage.ready` follows read quorum (`readinessScope` is `read_quorum`); `storage.readQuorum` and `storage.writeQuorum` show the two observations. `poolMetadata.ready` is the metadata writer and does not by itself withdraw `/health/ready`. `degradedReasons` on this probe names read-path causes such as `storage_quorum_unavailable` and `storage_and_lock_unavailable`. `pool_meta_write_blocked` stays on `/minio/health/cluster`. See the [storage detail contract](../architecture/readiness-matrix.md#storage-detail-contract) for probe scopes and sampling limits:
|
||||
4. Check readiness detail while waiting. `/health/ready` (and `/minio/health/ready`) separate `storage` / `poolMetadata` / `iam` / `lock` readiness. `storage.ready` follows read quorum (`readinessScope` is `read_quorum`); `storage.readQuorum` and `storage.writeQuorum` show the two observations. `poolMetadata.ready` is the metadata writer and does not by itself withdraw `/health/ready`. `degradedReasons` on this probe names read-path causes such as `storage_quorum_unavailable` and `storage_and_lock_unavailable`. `pool_meta_write_blocked` and `pool_metadata_check_timeout` stay on `/minio/health/cluster`. See the [storage detail contract](../architecture/readiness-matrix.md#storage-detail-contract) for probe scopes and sampling limits:
|
||||
|
||||
```bash
|
||||
curl -s http://<node>:9000/health/ready | jq
|
||||
|
||||
@@ -45,14 +45,15 @@ The probe round timeout is configured independently; see [Admin peer probe timeo
|
||||
|
||||
## Correlate bounded diagnostics
|
||||
|
||||
Normal operation does not require success logs at WARN. Request counters remain available with WARN logging, while runtime readiness diagnostics distinguish `pool_meta_write_blocked` from insufficient storage quorum. Do not clear a metadata write fence merely to make readiness green.
|
||||
Normal operation does not require success logs at WARN. Request counters remain available with WARN logging, while runtime readiness diagnostics distinguish `pool_meta_write_blocked`, `pool_metadata_check_timeout`, and insufficient storage quorum. Do not clear a metadata write fence merely to make readiness green.
|
||||
|
||||
Query the authenticated `/rustfs/admin/v4/cluster/snapshot` endpoint on the affected node. `snapshot.pool_meta_write_gate` describes that node's metadata writer, not a fleet-wide aggregate. Its existing booleans are preserved; `state` and optional block details extend the same read-only inspection, which reads the authority published by the node's last metadata writer without waiting for the save gate. A block that an active writer installs appears when that writer releases the gate. Nodes running earlier releases can also report `check_timeout`, the `pool_metadata_check_timeout` readiness reason, and `rustfs_pool_metadata_check_timeouts_total` when their 100 ms gate inspection expired; current nodes emit none of them. This starts no recovery, disk reads, or additional RPCs. Runtime readiness and the gate are inspected separately, so a whole cluster snapshot is not atomic across sections.
|
||||
Query the authenticated `/rustfs/admin/v4/cluster/snapshot` endpoint on the affected node. `snapshot.pool_meta_write_gate` describes that node's metadata writer, not a fleet-wide aggregate. Its existing booleans are preserved; `state` and optional block details extend the same bounded, read-only gate inspection. This starts no recovery, disk reads, or additional RPCs. Runtime readiness and the gate are inspected separately, so a whole cluster snapshot is not atomic across sections.
|
||||
|
||||
| `state` | Meaning |
|
||||
| --- | --- |
|
||||
| `writable` | No metadata write block was observed. Other dependencies may still make the node unready. |
|
||||
| `blocked` | A metadata write block was observed; `reason`, `phase`, and `sinceUnixSecs` describe its typed failure context. |
|
||||
| `check_timeout` | The existing 100 ms inspection budget expired. Readiness remains false, but a write block is not asserted. |
|
||||
| `unavailable` | The object store or a recognized metadata observation was unavailable; no block details are invented. |
|
||||
|
||||
For `blocked`, `reason` reuses the metadata failure classification, `phase` identifies the operation stage that caused the block (not live recovery-worker progress), and `sinceUnixSecs` is the original block time in Unix seconds. Polling does not reset that time. Recovery replaces the observation with `writable` and removes the previous block details. An unavailable store reports `writesReady=false` with `state="unavailable"`. Older responses may omit `state` and block details; missing fields alone do not prove a healthy writer. Operation text, raw replica errors, disk paths, and credentials are excluded. See [Pool metadata recovery](pool-metadata-recovery.md) for recovery and escalation boundaries.
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
# Target queue storage limits
|
||||
# Target queue storage
|
||||
|
||||
## Queue directory location
|
||||
|
||||
A target's `queue_dir` is stored in the cluster-wide target configuration. The built-in defaults `/opt/rustfs/events` and `/opt/rustfs/audit` are logical defaults: each node resolves them to a local directory when it validates or opens a queue. The stock notify and audit default tables both persist `/opt/rustfs/events`; `/opt/rustfs/audit` applies only to an audit target whose configuration has no `queue_dir` key. Any other `queue_dir`, including an explicit operator path, is used as written and must be absolute.
|
||||
|
||||
| Node setting | `/opt/rustfs/events` resolves to | `/opt/rustfs/audit` resolves to |
|
||||
|---|---|---|
|
||||
| `RUSTFS_TARGET_STORE_DIR=<root>` (any platform) | `<root>/events` | `<root>/audit` |
|
||||
| Windows, variable unset | `%ProgramData%\RustFS\events` | `%ProgramData%\RustFS\audit` |
|
||||
| Linux/macOS, variable unset | `/opt/rustfs/events` | `/opt/rustfs/audit` |
|
||||
|
||||
`RUSTFS_TARGET_STORE_DIR` must be an absolute path; a relative value makes target validation fail with the queue-directory error. The queue store creates missing directories when it opens, so the resolved directory only needs to be creatable by the RustFS process. An admin request that names a default path explicitly is checked at its resolved location and, as for any explicit `queue_dir`, that directory must already exist.
|
||||
|
||||
The official container image links `/opt/rustfs/events` and `/opt/rustfs/audit` into `/data/.rustfs`, so its queues survive container recreation without this variable. Windows previously rejected targets that kept the default because the Unix literal is not an absolute Windows path; those targets now validate and queue under `%ProgramData%\RustFS`.
|
||||
|
||||
Setting or changing `RUSTFS_TARGET_STORE_DIR` on an existing node moves where its default queues are opened. Entries still pending in the previous directory are not migrated: let them drain first, or move the directory contents while RustFS is stopped. Rolling back to a version without this variable returns default queues to the previous location.
|
||||
|
||||
## Queue size limits
|
||||
|
||||
Disk queues for notification and audit targets enforce both their configured entry limit and a shared byte limit for each queue directory. `RUSTFS_TARGET_STORE_MAX_BYTES` sets that byte limit; the default is 1 GiB. The value must be a positive integer in bytes. Missing, zero, invalid, and overflowing values use the default.
|
||||
|
||||
|
||||
@@ -116,18 +116,40 @@ Functional-chain health runs on a hosted runner independently of the lab fleet.
|
||||
|
||||
## Release validation
|
||||
|
||||
Post-merge and tag-driven; not a substitute for a PR gate.
|
||||
Post-merge and tag-driven; not a substitute for a PR gate. Every public publication exit consumes the release certification described here; this certification path is separate from the reusable CI artifact contract in [verification plans](ci-verification-pipeline.md), whose archives expire after one day and are never used as release certification.
|
||||
|
||||
The versioned plan is `.config/release-validation-plan.json` (`schema_version=1`): release types, target OS/arch/ABI, supported features, a fixed harness SHA per lane, required case IDs, environment capabilities, timeouts, applicability conditions, and an explicit `upgrade_sources` list carrying the real artifact digests of the supported N-1/N-2 sources plus a stated rollback basis — compatibility and rollback are never invented or defaulted. `scripts/release/release_gate.py validate` expands and freezes `plan_sha256` and the required case set before reading results, then binds that plan, a `candidate.json` (product 40-character SHA, version, release profile, features, immutable deliverable locations with SHA256 and wrapped-binary SHA256, image digest) and the shared G01 evidence-envelope set (rustfs/backlog#2658) of one parent candidate batch, and writes machine JSON, JUnit and a summary. Within a batch the unique key is `(lane, platform{os,arch,abi}, case_id)`; the top-level run/attempt is the parent batch and the nested workflow run/attempt is the child workflow identity, and both are validated separately (workflow SHA and product build SHA are bound independently and need not be equal). Missing, duplicate or extra cases, stale attempts, wrong digests, wrong platforms, wrong harness SHAs, zero tests, cancellation, timeout, untrusted sources and any non-PASS required item block with classified errors; only a complete legal success returns 0. `verify-promotion` re-checks the exact bytes about to be published against the passed list and fails on a swapped package or an edited plan after verification. preview, prerelease and stable all require an explicit plan; preview may use the plan's smaller pre-declared set but never bypasses artifact identity and startup checks. development artifacts are explicitly uncertified and never touch official or latest channels.
|
||||
|
||||
| Trigger | Workflow / job | Result |
|
||||
|---|---|---|
|
||||
| Nightly schedule, dispatch | `build.yml` `build-rustfs` (development build over the full platform matrix) | build artifacts and the dev download channel; no release publication |
|
||||
| Valid release or preview tag | `build.yml` `build-rustfs`, `create-release`, `upload-release-assets`, `publish-release` | draft release, checksummed assets, publish |
|
||||
| Successful non-preview release-tag build (`workflow_run`) | `docker.yml` `build-docker`, `scan-docker-image` | multi-architecture images and vulnerability report |
|
||||
| Successful release-tag build (`workflow_run`) | `package.yml` `package` | DEB/RPM packages and checksums uploaded to the release |
|
||||
| Successful non-preview release-tag build (`workflow_run`) | `helm-package.yml` `build-helm-package`, `publish-helm-package` | versioned chart and repository index |
|
||||
| Nightly schedule, dispatch | `build.yml` `build-rustfs` (development build over the full platform matrix) | build artifacts and the uncertified dev download channel; no release publication |
|
||||
| Valid release or preview tag | `build.yml` `build-rustfs`, `release-certification`, `create-release`, `upload-release-assets`, `publish-release`, `promote-release-artifacts` | staged binaries, draft release, checksummed assets, then certified publish and promotion of the same bytes |
|
||||
| Successful non-preview release-tag build (`workflow_run`) | `docker.yml` `release-certification`, `build-docker`, `scan-docker-image` | multi-architecture images staged, verified against the certification and promoted, plus vulnerability report |
|
||||
| Successful release-tag build (`workflow_run`) | `package.yml` `release-certification`, `package`, `promote-packages` | DEB/RPM packages with wrapped-binary verification, staged then promoted with checksums |
|
||||
| Successful non-preview release-tag build (`workflow_run`) | `helm-package.yml` `release-certification`, `build-helm-package`, `publish-helm-package` | versioned chart and repository index, only over a certified image promotion record |
|
||||
| Final tag's release published | `build.yml` `cleanup-preview-releases` | deletes every `<target>-preview.<N>` Release for that target; the tags are kept |
|
||||
|
||||
Use an exact preview tag for an end-to-end release rehearsal. Manual dispatches are backfill/debug paths and do not prove the automatic `workflow_run` chain.
|
||||
Publication exits and their gate:
|
||||
|
||||
| Exit | Writer | Certification consumed |
|
||||
|---|---|---|
|
||||
| GitHub Release draft → public | `build.yml` `publish-release` | `release-certification` success and per-asset `verify-promotion` |
|
||||
| Official R2 binary path `artifacts/rustfs/release/` | `build.yml` `promote-release-artifacts` | `release-certification` plus `verify-promotion`; the build job itself only writes `artifacts/rustfs/staging/<run>/<attempt>/` |
|
||||
| `latest.json` version pointer | `build.yml` `update-latest-version` | transitively through `publish-release` |
|
||||
| Image channels (Docker Hub, GHCR, Quay tags) | `docker.yml` `build-docker` promotion step | certified input digests, staged image binary identity and `verify-promotion`, then promotion of the same digest with `imagetools create` |
|
||||
| Official R2 package paths `packages/release` and `packages/latest` | `package.yml` `promote-packages` | `release-certification` and wrapped-binary `verify-promotion`; the package job only stages |
|
||||
| GitHub release package assets | `package.yml` `package` | `release-certification` and wrapped-binary `verify-promotion` before upload |
|
||||
| Helm chart repository (`rustfs/helm` push) | `helm-package.yml` `publish-helm-package` | `release-certification` plus the Docker workflow's image-promotion record bound to the same candidate |
|
||||
|
||||
Downstream workflows fail closed when the trusted certification is absent: a manual dispatch without certification fails and pushes no official tag. Only results from the trusted verification sources listed in the plan (currently the `rustfs/s3chaos` `release-gate.yml` envelope runs) carry release authority; a PR-uploaded JSON set cannot certify a release. Retries reuse the same candidate, only the staged bytes are promoted (no rebuild-after-verify), and no downstream step re-resolves a mutable `latest`.
|
||||
|
||||
Use an exact preview tag for an end-to-end release rehearsal. Manual dispatches are backfill/debug paths and do not prove the automatic `workflow_run` chain. The committed fixtures under `testdata/release-gate/` and the commands below are contract rehearsals of this orchestration; they are explicitly not product release evidence and not live verifier results.
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s scripts/release -p 'test_release_gate.py'
|
||||
python3 scripts/release/release_gate.py validate --plan testdata/release-gate/plan.json --candidate testdata/release-gate/candidate.json --results testdata/release-gate/pass --output target/release-gate-check
|
||||
bash scripts/security/check_preview_release_workflow.sh
|
||||
```
|
||||
|
||||
## Change checklist
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ behavior.
|
||||
| config.rustfs.obs_environment | string | `"development"` | |
|
||||
| config.rustfs.obs_log_directory | string | `"/logs"` | Log directory inside the RustFS container. Set to `""` to disable log PVCs and mounts. |
|
||||
| config.rustfs.region | string | `"us-east-1"` | |
|
||||
| config.rustfs.target_store_dir | string | `""` | Absolute root for notify and audit target queues that keep the default `queue_dir`, set as `RUSTFS_TARGET_STORE_DIR`; queues move to `<root>/events` and `<root>/audit`. Empty keeps the image layout, where `/opt/rustfs/events` and `/opt/rustfs/audit` link into `/data/.rustfs`. |
|
||||
| config.rustfs.volumes | string | `""` | Explicit distributed volume topology. When empty, the chart generates the topology and normally injects `RUSTFS_LOCAL_ENDPOINT_HOST`; custom topologies must configure local endpoint identity explicitly when needed. |
|
||||
| config.rustfs.log_rotation.size | int | `"100"` | Default log rotation size mb for rustfs. |
|
||||
| config.rustfs.log_rotation.time | string | `"hour"` | Default log rotation time for rustfs. |
|
||||
|
||||
@@ -21,6 +21,9 @@ data:
|
||||
{{- if .region }}
|
||||
RUSTFS_REGION: {{ .region | quote }}
|
||||
{{- end }}
|
||||
{{- if .target_store_dir }}
|
||||
RUSTFS_TARGET_STORE_DIR: {{ .target_store_dir | quote }}
|
||||
{{- end }}
|
||||
{{- if .domains }}
|
||||
RUSTFS_SERVER_DOMAINS: {{ include "rustfs.serverDomains" $ | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -143,6 +143,9 @@ config:
|
||||
console_enable: "true"
|
||||
console_address: ":9001"
|
||||
region: "us-east-1"
|
||||
# Root for notify/audit target queues that keep the default queue_dir; queues use
|
||||
# <root>/events and <root>/audit. Empty keeps the image layout (/opt/rustfs -> /data/.rustfs).
|
||||
target_store_dir: ""
|
||||
# Optionally enable support for virtual-hosted-style requests.
|
||||
# See more information: https://docs.rustfs.com/integration/virtual.html
|
||||
domains: "" # e.g. "example.com"
|
||||
|
||||
@@ -211,7 +211,9 @@ pub(crate) struct ClusterPoolMetaWriteGateView {
|
||||
|
||||
impl From<ClusterPoolMetaWriteGateSnapshot> for ClusterPoolMetaWriteGateView {
|
||||
fn from(snapshot: ClusterPoolMetaWriteGateSnapshot) -> Self {
|
||||
let state = if snapshot.writes_ready {
|
||||
let state = if snapshot.check_timed_out {
|
||||
"check_timeout"
|
||||
} else if snapshot.writes_ready {
|
||||
"writable"
|
||||
} else if snapshot.write_blocked || snapshot.transaction_aborted {
|
||||
"blocked"
|
||||
@@ -704,6 +706,7 @@ fn summarize_storage_readiness(snapshot: &ClusterReadOnlySnapshot) -> Capability
|
||||
.filter_map(|reason| match reason {
|
||||
ReadinessDegradedReason::StorageQuorumUnavailable
|
||||
| ReadinessDegradedReason::PoolMetaWriteBlocked
|
||||
| ReadinessDegradedReason::PoolMetadataCheckTimeout
|
||||
| ReadinessDegradedReason::StorageAndIamUnavailable
|
||||
| ReadinessDegradedReason::StorageAndLockUnavailable
|
||||
| ReadinessDegradedReason::StorageIamAndLockUnavailable => Some(reason.as_str()),
|
||||
@@ -1117,6 +1120,14 @@ mod tests {
|
||||
|
||||
for (snapshot, state) in [
|
||||
(ClusterPoolMetaWriteGateSnapshot::default(), "writable"),
|
||||
(
|
||||
ClusterPoolMetaWriteGateSnapshot {
|
||||
writes_ready: false,
|
||||
check_timed_out: true,
|
||||
..Default::default()
|
||||
},
|
||||
"check_timeout",
|
||||
),
|
||||
(
|
||||
ClusterPoolMetaWriteGateSnapshot {
|
||||
writes_ready: false,
|
||||
|
||||
@@ -560,6 +560,8 @@ where
|
||||
}
|
||||
|
||||
pub(crate) async fn validate_queue_dir(queue_dir: &str) -> S3Result<()> {
|
||||
let queue_dir = rustfs_targets::store::resolve_queue_dir(queue_dir);
|
||||
let queue_dir = queue_dir.as_ref();
|
||||
if !queue_dir.is_empty() {
|
||||
if !Path::new(queue_dir).is_absolute() {
|
||||
return Err(s3_error!(InvalidArgument, "queue_dir must be an absolute path"));
|
||||
@@ -1074,3 +1076,58 @@ mod instance_runtime_status_tests {
|
||||
assert!(statuses.contains_key(&("PRIMARY".into(), "custom-two".into())));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod queue_dir_resolution_tests {
|
||||
use super::validate_queue_dir;
|
||||
use rustfs_config::server_config::KVS;
|
||||
use rustfs_config::{AUDIT_DEFAULT_DIR, ENV_TARGET_STORE_DIR, EVENT_DEFAULT_DIR, REDIS_QUEUE_DIR, REDIS_URL};
|
||||
use rustfs_targets::{config::build_redis_args, target::TargetType};
|
||||
use serial_test::serial;
|
||||
|
||||
fn redis_config() -> KVS {
|
||||
let mut config = KVS::new();
|
||||
config.insert(REDIS_URL.to_string(), "redis://127.0.0.1:6379/".to_string());
|
||||
config
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn default_queue_dirs_follow_the_target_store_root() {
|
||||
let root = tempfile::tempdir().expect("queue store root");
|
||||
let root_text = root.path().to_str().expect("UTF-8 queue store root").to_owned();
|
||||
temp_env::async_with_vars([(ENV_TARGET_STORE_DIR, Some(root_text.as_str()))], async {
|
||||
let notify = build_redis_args(&redis_config(), EVENT_DEFAULT_DIR, "events", TargetType::NotifyEvent)
|
||||
.expect("default notify queue");
|
||||
assert_eq!(notify.queue_dir, root.path().join("events").to_string_lossy());
|
||||
let audit =
|
||||
build_redis_args(&redis_config(), AUDIT_DEFAULT_DIR, "audit", TargetType::AuditLog).expect("default audit queue");
|
||||
assert_eq!(audit.queue_dir, root.path().join("audit").to_string_lossy());
|
||||
|
||||
let explicit = root.path().join("operator-queue").to_string_lossy().into_owned();
|
||||
let mut config = redis_config();
|
||||
config.insert(REDIS_QUEUE_DIR.to_string(), explicit.clone());
|
||||
let args = build_redis_args(&config, EVENT_DEFAULT_DIR, "events", TargetType::NotifyEvent).expect("explicit queue");
|
||||
assert_eq!(args.queue_dir, explicit, "an operator path must not be relocated");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn explicit_logical_default_is_validated_at_its_resolved_directory() {
|
||||
let root = tempfile::tempdir().expect("queue store root");
|
||||
let root_text = root.path().to_str().expect("UTF-8 queue store root").to_owned();
|
||||
temp_env::async_with_vars([(ENV_TARGET_STORE_DIR, Some(root_text.as_str()))], async {
|
||||
let missing = validate_queue_dir(EVENT_DEFAULT_DIR)
|
||||
.await
|
||||
.expect_err("the resolved queue directory does not exist yet");
|
||||
assert!(missing.message().is_some_and(|message| message.contains("does not exist")));
|
||||
std::fs::create_dir(root.path().join("events")).expect("resolved queue directory");
|
||||
validate_queue_dir(EVENT_DEFAULT_DIR)
|
||||
.await
|
||||
.expect("the logical default must be checked where this node stores it");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ pub struct ClusterListingDiagnosticsSnapshot {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClusterPoolMetaWriteGateSnapshot {
|
||||
pub writes_ready: bool,
|
||||
pub check_timed_out: bool,
|
||||
pub write_blocked: bool,
|
||||
pub transaction_aborted: bool,
|
||||
pub pool_meta_absent: bool,
|
||||
@@ -123,6 +124,7 @@ impl Default for ClusterPoolMetaWriteGateSnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
writes_ready: true,
|
||||
check_timed_out: false,
|
||||
write_blocked: false,
|
||||
transaction_aborted: false,
|
||||
pool_meta_absent: false,
|
||||
@@ -228,6 +230,7 @@ async fn current_pool_meta_write_gate_snapshot() -> ClusterPoolMetaWriteGateSnap
|
||||
let status = store.pool_meta_write_gate_status().await;
|
||||
ClusterPoolMetaWriteGateSnapshot {
|
||||
writes_ready: status.writes_ready,
|
||||
check_timed_out: status.check_timed_out,
|
||||
write_blocked: status.write_blocked,
|
||||
transaction_aborted: status.transaction_aborted,
|
||||
pool_meta_absent: status.pool_meta_absent,
|
||||
|
||||
+3
-65
@@ -28,19 +28,6 @@ const MAX_VERSIONS_EXCEEDED_MESSAGE: &str = "You've exceeded the limit on the nu
|
||||
const SLOW_DOWN_READ_CODE: &str = "SlowDownRead";
|
||||
const SLOW_DOWN_READ_MESSAGE: &str = "Resource requested is unreadable, please reduce your request rate";
|
||||
const NAMESPACE_LOCK_TIMEOUT_CAUSE: &str = "namespace_lock_timeout";
|
||||
const RPC_ADMISSION_METADATA_KEY: &str = "x-rustfs-rpc-admission";
|
||||
const RPC_REPLAY_ADMISSION_METADATA_VALUE: &str = "replay-cache";
|
||||
|
||||
/// Preserve temporary replay admission refusal through the RPC and S3 boundaries.
|
||||
pub(crate) fn rpc_replay_admission_exhausted_status() -> tonic::Status {
|
||||
let mut status = tonic::Status::resource_exhausted("RPC replay admission exhausted");
|
||||
status.metadata_mut().insert(
|
||||
RPC_ADMISSION_METADATA_KEY,
|
||||
tonic::metadata::MetadataValue::from_static(RPC_REPLAY_ADMISSION_METADATA_VALUE),
|
||||
);
|
||||
status
|
||||
}
|
||||
|
||||
/// Public message for `StorageError::UnsupportedDisk` on a request path. The
|
||||
/// only request-time producer is the delete-transaction protocol when a
|
||||
/// participant answers Unimplemented (a peer still on a release without the
|
||||
@@ -601,14 +588,9 @@ fn body_read_error_code(err: &(dyn std::error::Error + 'static)) -> Option<S3Err
|
||||
return Some(S3ErrorCode::BadDigest);
|
||||
}
|
||||
|
||||
rpc_unavailable |= error.downcast_ref::<tonic::Status>().is_some_and(|status| {
|
||||
status.code() == tonic::Code::Unavailable
|
||||
|| (status.code() == tonic::Code::ResourceExhausted
|
||||
&& status
|
||||
.metadata()
|
||||
.get(RPC_ADMISSION_METADATA_KEY)
|
||||
.is_some_and(|value| value.as_encoded_bytes() == RPC_REPLAY_ADMISSION_METADATA_VALUE.as_bytes()))
|
||||
});
|
||||
rpc_unavailable |= error
|
||||
.downcast_ref::<tonic::Status>()
|
||||
.is_some_and(|status| status.code() == tonic::Code::Unavailable);
|
||||
upload_limit_exceeded |= error.is::<UploadLimitExceeded>();
|
||||
server_source_failed |= error.is::<ServerSideSourceReadError>();
|
||||
client_body_timed_out |= error.is::<ClientBodyReadTimeout>();
|
||||
@@ -1760,50 +1742,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_admission_refusal_preserves_retryable_status_across_io_boundaries() {
|
||||
let status = rpc_replay_admission_exhausted_status;
|
||||
let original = StorageError::from(status());
|
||||
let cloned = original.clone();
|
||||
let wire = status().into_http::<()>();
|
||||
let received = tonic::Status::from_header_map(wire.headers()).expect("replay admission status on the wire");
|
||||
for error in [
|
||||
ApiError::from(original),
|
||||
ApiError::from(cloned),
|
||||
ApiError::from(StorageError::from(received)),
|
||||
ApiError::from(StorageError::Io(IoError::other(IoError::other(status())))),
|
||||
ApiError::from(IoError::other(status())),
|
||||
ApiError::from(IoError::other(StorageError::from(status()))),
|
||||
] {
|
||||
assert_eq!(error.code, S3ErrorCode::ServiceUnavailable);
|
||||
assert_eq!(error.diagnostic().rpc_code, Some(tonic::Code::ResourceExhausted));
|
||||
assert_eq!(S3Error::from(error).status_code(), Some(StatusCode::SERVICE_UNAVAILABLE));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_admission_marker_requires_its_exact_value_and_resource_code() {
|
||||
for code in [
|
||||
tonic::Code::Unauthenticated,
|
||||
tonic::Code::Internal,
|
||||
tonic::Code::ResourceExhausted,
|
||||
] {
|
||||
for marker in ["replay-cache", "unknown-admission"] {
|
||||
if code == tonic::Code::ResourceExhausted && marker == "replay-cache" {
|
||||
continue;
|
||||
}
|
||||
let mut status = tonic::Status::new(code, "RPC replay admission exhausted");
|
||||
status
|
||||
.metadata_mut()
|
||||
.insert(RPC_ADMISSION_METADATA_KEY, marker.parse().expect("valid test marker"));
|
||||
let error = ApiError::from(StorageError::from(status));
|
||||
assert_eq!(error.code, S3ErrorCode::InternalError);
|
||||
}
|
||||
}
|
||||
let error = ApiError::from(IoError::other("RPC replay admission exhausted"));
|
||||
assert_eq!(error.code, S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rpc_unavailable_mapping_does_not_match_other_codes_or_untyped_messages() {
|
||||
for code in [
|
||||
|
||||
@@ -614,6 +614,22 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn writable_node_readiness_keeps_200_for_transient_pool_metadata_timeout() {
|
||||
let mut report = ready_report();
|
||||
report.degraded_reasons = vec![ReadinessDegradedReason::PoolMetadataCheckTimeout];
|
||||
|
||||
with_var(rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("false"), || {
|
||||
let parts = build_health_response_parts(Method::GET, HealthProbe::Readiness, Some(&report), "rustfs", None, None);
|
||||
|
||||
assert_eq!(parts.status_code, StatusCode::OK);
|
||||
let payload = parts.payload.expect("GET readiness body");
|
||||
assert_eq!(payload["ready"], true);
|
||||
assert_eq!(payload["degradedReasons"], json!(["pool_metadata_check_timeout"]));
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_collects_object_stalls_and_recovers_on_completion() {
|
||||
let object_traffic_health = ObjectTrafficHealth::enabled_for_test(Duration::ZERO);
|
||||
|
||||
@@ -3405,16 +3405,6 @@ fn hyper_body_write_close_kind(debug: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
fn rpc_auth_failure_status(error: &std::io::Error) -> Status {
|
||||
if storage::rpc_auth_is_resource_exhausted(error) {
|
||||
crate::error::rpc_replay_admission_exhausted_status()
|
||||
} else if storage::tonic_rpc_auth_failure_reason(error) == "stale_boot_epoch" {
|
||||
Status::unavailable("RPC boot epoch changed")
|
||||
} else {
|
||||
Status::unauthenticated("No valid auth token")
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn check_auth(req: Request<()>) -> std::result::Result<Request<()>, Status> {
|
||||
let local_node_name =
|
||||
@@ -3477,12 +3467,6 @@ fn check_auth(req: Request<()>) -> std::result::Result<Request<()>, Status> {
|
||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
failure_reason,
|
||||
);
|
||||
let rejection = rpc_auth_failure_status(&e);
|
||||
if rejection.code() == tonic::Code::ResourceExhausted {
|
||||
// Existing scoped counters describe overload without producing an
|
||||
// authentication error log for every otherwise valid request.
|
||||
return rejection;
|
||||
}
|
||||
if failure_reason == "stale_boot_epoch" {
|
||||
// The signature is valid, but the peer restarted before this request. Reject it
|
||||
// before execution and let the client retry after its authenticated epoch refresh.
|
||||
@@ -3501,7 +3485,7 @@ fn check_auth(req: Request<()>) -> std::result::Result<Request<()>, Status> {
|
||||
error = %e,
|
||||
"RPC boot epoch changed; request rejected for retry"
|
||||
);
|
||||
rejection
|
||||
Status::unavailable("RPC boot epoch changed")
|
||||
} else {
|
||||
error!(
|
||||
event = EVENT_RPC_SIGNATURE_VERIFICATION_FAILED,
|
||||
@@ -3518,7 +3502,7 @@ fn check_auth(req: Request<()>) -> std::result::Result<Request<()>, Status> {
|
||||
error = %e,
|
||||
"RPC signature verification failed"
|
||||
);
|
||||
rejection
|
||||
Status::unauthenticated("No valid auth token")
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -7017,35 +7001,6 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rpc_auth_resource_exhaustion_does_not_report_invalid_credentials() {
|
||||
for source in [
|
||||
storage::RpcReplayCacheAdmissionError::Capacity,
|
||||
storage::RpcReplayCacheAdmissionError::MemoryBudget,
|
||||
] {
|
||||
let error = std::io::Error::new(std::io::ErrorKind::WouldBlock, source);
|
||||
let status = rpc_auth_failure_status(&error);
|
||||
assert_eq!(status.code(), tonic::Code::ResourceExhausted);
|
||||
let api = crate::error::ApiError::from(crate::storage_api::error::StorageError::from(status));
|
||||
let response = s3s::S3Error::from(api);
|
||||
assert_eq!(response.status_code(), Some(http::StatusCode::SERVICE_UNAVAILABLE));
|
||||
}
|
||||
for message in [
|
||||
"RPC request replay detected",
|
||||
"Invalid RPC v2 signature",
|
||||
"Missing signature header",
|
||||
] {
|
||||
assert_eq!(
|
||||
rpc_auth_failure_status(&std::io::Error::other(message)).code(),
|
||||
tonic::Code::Unauthenticated
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
rpc_auth_failure_status(&std::io::Error::other("RPC boot epoch is stale")).code(),
|
||||
tonic::Code::Unavailable
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn rpc_auth_rejection_records_failure_reason_metric() {
|
||||
|
||||
+295
-51
@@ -37,7 +37,7 @@ use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::OnceLock,
|
||||
sync::{Mutex as StdMutex, OnceLock},
|
||||
time::Instant,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
@@ -80,6 +80,7 @@ const LOG_COMPONENT_SERVER: &str = "server";
|
||||
const LOG_SUBSYSTEM_READINESS: &str = "readiness";
|
||||
const METRIC_RUNTIME_READINESS_READY: &str = "rustfs_runtime_readiness_ready";
|
||||
const METRIC_RUNTIME_READINESS_DEGRADED_TOTAL: &str = "rustfs_runtime_readiness_degraded_total";
|
||||
const METRIC_POOL_METADATA_CHECK_TIMEOUT_TOTAL: &str = "rustfs_pool_metadata_check_timeouts_total";
|
||||
|
||||
pub use crate::shared_types::{
|
||||
DependencyReadiness, DependencyReadinessReport, MissingReadinessDrive, ReadinessDegradedReason, StorageReadinessDetails,
|
||||
@@ -292,6 +293,66 @@ struct StorageWriteReadinessStatus {
|
||||
pool_metadata_reason: Option<ReadinessDegradedReason>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct NodePoolMetadataReadinessCache {
|
||||
entry: Option<NodePoolMetadataReadinessCacheEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct NodePoolMetadataReadinessCacheEntry {
|
||||
cached_at: Instant,
|
||||
observed_at: Instant,
|
||||
status: StorageWriteReadinessStatus,
|
||||
}
|
||||
|
||||
impl NodePoolMetadataReadinessCache {
|
||||
fn observe(
|
||||
&mut self,
|
||||
observed: StorageWriteReadinessStatus,
|
||||
observed_at: Instant,
|
||||
now: Instant,
|
||||
ttl: Duration,
|
||||
) -> StorageWriteReadinessStatus {
|
||||
if ttl.is_zero() {
|
||||
self.entry = None;
|
||||
return observed;
|
||||
}
|
||||
|
||||
if observed.pool_metadata_reason == Some(ReadinessDegradedReason::PoolMetadataCheckTimeout) {
|
||||
let Some(previous) = self.entry else {
|
||||
return observed;
|
||||
};
|
||||
if now.saturating_duration_since(previous.cached_at) > ttl {
|
||||
return observed;
|
||||
}
|
||||
|
||||
// A confirmed write block must never be hidden by a later timeout.
|
||||
if previous.status.pool_metadata_reason == Some(ReadinessDegradedReason::PoolMetaWriteBlocked) {
|
||||
return previous.status;
|
||||
}
|
||||
|
||||
// A transient inspection outage is diagnostic, not proof that the
|
||||
// writer became unsafe. Keep the last confirmed writable state.
|
||||
if previous.status.ready {
|
||||
return StorageWriteReadinessStatus {
|
||||
ready: true,
|
||||
pool_metadata_reason: Some(ReadinessDegradedReason::PoolMetadataCheckTimeout),
|
||||
};
|
||||
}
|
||||
return observed;
|
||||
}
|
||||
|
||||
if self.entry.is_none_or(|entry| entry.observed_at <= observed_at) {
|
||||
self.entry = Some(NodePoolMetadataReadinessCacheEntry {
|
||||
cached_at: now,
|
||||
observed_at,
|
||||
status: observed,
|
||||
});
|
||||
}
|
||||
observed
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_metadata_write_readiness(result: Result<(), StorageError>) -> StorageWriteReadinessStatus {
|
||||
match result {
|
||||
Ok(()) => StorageWriteReadinessStatus {
|
||||
@@ -299,9 +360,13 @@ fn pool_metadata_write_readiness(result: Result<(), StorageError>) -> StorageWri
|
||||
pool_metadata_reason: None,
|
||||
},
|
||||
Err(error) => {
|
||||
let pool_metadata_reason = error
|
||||
.pool_metadata_failure()
|
||||
.map(|_| ReadinessDegradedReason::PoolMetaWriteBlocked);
|
||||
let pool_metadata_reason = if error.pool_metadata_failure().is_some() {
|
||||
Some(ReadinessDegradedReason::PoolMetaWriteBlocked)
|
||||
} else if matches!(error, StorageError::Timeout) {
|
||||
Some(ReadinessDegradedReason::PoolMetadataCheckTimeout)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
StorageWriteReadinessStatus {
|
||||
ready: false,
|
||||
pool_metadata_reason,
|
||||
@@ -369,6 +434,11 @@ fn health_cluster_timeout() -> Duration {
|
||||
)
|
||||
}
|
||||
|
||||
fn node_pool_metadata_readiness_cache() -> &'static StdMutex<NodePoolMetadataReadinessCache> {
|
||||
static CACHE: OnceLock<StdMutex<NodePoolMetadataReadinessCache>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| StdMutex::new(NodePoolMetadataReadinessCache::default()))
|
||||
}
|
||||
|
||||
fn storage_readiness_cache() -> &'static Mutex<Option<StorageReadinessCacheEntry>> {
|
||||
static CACHE: OnceLock<Mutex<Option<StorageReadinessCacheEntry>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(None))
|
||||
@@ -473,6 +543,26 @@ async fn update_storage_readiness_cache(status: StorageWriteReadinessStatus) {
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_node_pool_metadata_timeout_policy(
|
||||
observed: StorageWriteReadinessStatus,
|
||||
observed_at: Instant,
|
||||
) -> StorageWriteReadinessStatus {
|
||||
if observed.pool_metadata_reason == Some(ReadinessDegradedReason::PoolMetadataCheckTimeout) {
|
||||
counter!(METRIC_POOL_METADATA_CHECK_TIMEOUT_TOTAL).increment(1);
|
||||
}
|
||||
let mut cache = node_pool_metadata_readiness_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cache.observe(observed, observed_at, Instant::now(), health_readiness_cache_ttl())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn reset_node_pool_metadata_readiness_cache() {
|
||||
*node_pool_metadata_readiness_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = NodePoolMetadataReadinessCache::default();
|
||||
}
|
||||
|
||||
async fn load_cached_lock_quorum_status() -> Option<LockQuorumObservation> {
|
||||
let ttl = health_readiness_cache_ttl();
|
||||
if ttl.is_zero() {
|
||||
@@ -765,6 +855,9 @@ fn record_readiness_report(report: &DependencyReadinessReport) {
|
||||
&& report.readiness.peer_health_ready;
|
||||
gauge!(METRIC_RUNTIME_READINESS_READY).set(if ready { 1.0 } else { 0.0 });
|
||||
for reason in &report.degraded_reasons {
|
||||
if ready && *reason == ReadinessDegradedReason::PoolMetadataCheckTimeout {
|
||||
continue;
|
||||
}
|
||||
counter!(METRIC_RUNTIME_READINESS_DEGRADED_TOTAL, "reason" => reason.as_str()).increment(1);
|
||||
}
|
||||
}
|
||||
@@ -851,7 +944,7 @@ pub async fn collect_node_readiness_report() -> DependencyReadinessReport {
|
||||
let mut storage_check_timed_out = false;
|
||||
if let Some(store) = runtime_sources::current_object_store_handle() {
|
||||
let pool_metadata_status = store.pool_meta_write_status().await;
|
||||
storage = pool_metadata_write_readiness(pool_metadata_status);
|
||||
storage = apply_node_pool_metadata_timeout_policy(pool_metadata_write_readiness(pool_metadata_status), Instant::now());
|
||||
match node_storage_snapshot(store.as_ref(), &lock_observation.online_hosts).await {
|
||||
Ok((info, unavailable_drives, missing_drives)) => {
|
||||
details.unavailable_drives = unavailable_drives;
|
||||
@@ -2834,7 +2927,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_metadata_observation_distinguishes_block_and_unavailable() {
|
||||
fn pool_metadata_observation_distinguishes_block_timeout_and_unavailable() {
|
||||
let blocked = blocked_pool_metadata_status();
|
||||
assert!(!blocked.ready);
|
||||
assert_eq!(blocked.pool_metadata_reason, Some(ReadinessDegradedReason::PoolMetaWriteBlocked));
|
||||
@@ -2842,76 +2935,227 @@ mod tests {
|
||||
let writable = pool_metadata_write_readiness(Ok(()));
|
||||
assert!(writable.ready);
|
||||
assert_eq!(writable.pool_metadata_reason, None);
|
||||
let timed_out = pool_metadata_write_readiness(Err(StorageError::Timeout));
|
||||
assert!(!timed_out.ready);
|
||||
assert_eq!(timed_out.pool_metadata_reason, Some(ReadinessDegradedReason::PoolMetadataCheckTimeout));
|
||||
let unavailable = pool_metadata_write_readiness(Err(StorageError::other("pool metadata writes remain blocked")));
|
||||
assert!(!unavailable.ready);
|
||||
assert_eq!(
|
||||
unavailable.pool_metadata_reason, None,
|
||||
"error text alone must not be interpreted as a typed write block"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_report_records_ready_gauge_and_degraded_reasons() {
|
||||
let record = |report: DependencyReadinessReport| {
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
metrics::with_local_recorder(&recorder, || record_readiness_report(&report));
|
||||
snapshotter.snapshot().into_vec()
|
||||
};
|
||||
let ready = DependencyReadiness {
|
||||
storage_ready: true,
|
||||
let readiness = DependencyReadiness {
|
||||
storage_ready: false,
|
||||
iam_ready: true,
|
||||
lock_quorum_ready: true,
|
||||
peer_health_ready: true,
|
||||
};
|
||||
let report = dependency_readiness_report_from_write_status(readiness, timed_out);
|
||||
assert!(!report.readiness.storage_ready, "inspection timeout must remain fail-closed");
|
||||
assert_eq!(report.degraded_reasons, vec![ReadinessDegradedReason::PoolMetadataCheckTimeout]);
|
||||
assert_eq!(report.degraded_reasons[0].as_str(), "pool_metadata_check_timeout");
|
||||
}
|
||||
|
||||
let entries = record(DependencyReadinessReport {
|
||||
readiness: ready,
|
||||
degraded_reasons: Vec::new(),
|
||||
storage_details: None,
|
||||
#[test]
|
||||
#[serial]
|
||||
fn sticky_pool_metadata_timeout_uses_dedicated_metric_without_degraded_count() {
|
||||
reset_node_pool_metadata_readiness_cache();
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
with_var(rustfs_config::ENV_HEALTH_READINESS_CACHE_TTL_MS, Some("60000"), || {
|
||||
let writable = StorageWriteReadinessStatus {
|
||||
ready: true,
|
||||
pool_metadata_reason: None,
|
||||
};
|
||||
assert_eq!(apply_node_pool_metadata_timeout_policy(writable, Instant::now()), writable);
|
||||
let sticky = apply_node_pool_metadata_timeout_policy(
|
||||
StorageWriteReadinessStatus {
|
||||
ready: false,
|
||||
pool_metadata_reason: Some(ReadinessDegradedReason::PoolMetadataCheckTimeout),
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
assert!(sticky.ready);
|
||||
record_readiness_report(&DependencyReadinessReport {
|
||||
readiness: DependencyReadiness {
|
||||
storage_ready: true,
|
||||
iam_ready: true,
|
||||
lock_quorum_ready: true,
|
||||
peer_health_ready: true,
|
||||
},
|
||||
degraded_reasons: vec![ReadinessDegradedReason::PoolMetadataCheckTimeout],
|
||||
storage_details: None,
|
||||
});
|
||||
});
|
||||
});
|
||||
let gauge = entries.iter().find_map(|(composite, _, _, value)| {
|
||||
reset_node_pool_metadata_readiness_cache();
|
||||
|
||||
let entries = snapshotter.snapshot().into_vec();
|
||||
let ready = entries.iter().find_map(|(composite, _, _, value)| {
|
||||
(composite.kind() == MetricKind::Gauge && composite.key().name() == METRIC_RUNTIME_READINESS_READY).then_some(value)
|
||||
});
|
||||
assert!(
|
||||
matches!(gauge, Some(DebugValue::Gauge(value)) if value.into_inner() == 1.0),
|
||||
"a ready report must set the readiness gauge: {gauge:?}"
|
||||
);
|
||||
assert!(
|
||||
entries
|
||||
.iter()
|
||||
.all(|(composite, _, _, _)| composite.key().name() != METRIC_RUNTIME_READINESS_DEGRADED_TOTAL),
|
||||
"a ready report without reasons must not count degradation"
|
||||
);
|
||||
assert!(matches!(ready, Some(DebugValue::Gauge(value)) if value.into_inner() == 1.0));
|
||||
|
||||
let entries = record(DependencyReadinessReport {
|
||||
readiness: DependencyReadiness {
|
||||
storage_ready: false,
|
||||
..ready
|
||||
},
|
||||
degraded_reasons: vec![ReadinessDegradedReason::PoolMetaWriteBlocked],
|
||||
storage_details: None,
|
||||
});
|
||||
let gauge = entries.iter().find_map(|(composite, _, _, value)| {
|
||||
(composite.kind() == MetricKind::Gauge && composite.key().name() == METRIC_RUNTIME_READINESS_READY).then_some(value)
|
||||
});
|
||||
assert!(
|
||||
matches!(gauge, Some(DebugValue::Gauge(value)) if value.into_inner() == 0.0),
|
||||
"a degraded report must clear the readiness gauge: {gauge:?}"
|
||||
);
|
||||
let degraded = entries.iter().find_map(|(composite, _, _, value)| {
|
||||
(composite.kind() == MetricKind::Counter
|
||||
&& composite.key().name() == METRIC_RUNTIME_READINESS_DEGRADED_TOTAL
|
||||
&& composite
|
||||
.key()
|
||||
.labels()
|
||||
.any(|label| label.key() == "reason" && label.value() == "pool_meta_write_blocked"))
|
||||
.any(|label| label.key() == "reason" && label.value() == "pool_metadata_check_timeout"))
|
||||
.then_some(value)
|
||||
});
|
||||
assert!(
|
||||
matches!(degraded, Some(DebugValue::Counter(1))),
|
||||
"each degraded reason must be counted once: {degraded:?}"
|
||||
assert!(degraded.is_none());
|
||||
|
||||
let timeout = entries.iter().find_map(|(composite, _, _, value)| {
|
||||
(composite.kind() == MetricKind::Counter && composite.key().name() == METRIC_POOL_METADATA_CHECK_TIMEOUT_TOTAL)
|
||||
.then_some(value)
|
||||
});
|
||||
assert!(matches!(timeout, Some(DebugValue::Counter(1))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn node_pool_metadata_timeout_preserves_recent_writable_observation() {
|
||||
reset_node_pool_metadata_readiness_cache();
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_READINESS_CACHE_TTL_MS, Some("60000"))], async {
|
||||
let writable = StorageWriteReadinessStatus {
|
||||
ready: true,
|
||||
pool_metadata_reason: None,
|
||||
};
|
||||
assert_eq!(apply_node_pool_metadata_timeout_policy(writable, Instant::now()), writable);
|
||||
|
||||
let timed_out = apply_node_pool_metadata_timeout_policy(
|
||||
StorageWriteReadinessStatus {
|
||||
ready: false,
|
||||
pool_metadata_reason: Some(ReadinessDegradedReason::PoolMetadataCheckTimeout),
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
|
||||
assert!(timed_out.ready);
|
||||
assert_eq!(timed_out.pool_metadata_reason, Some(ReadinessDegradedReason::PoolMetadataCheckTimeout));
|
||||
})
|
||||
.await;
|
||||
reset_node_pool_metadata_readiness_cache();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn node_pool_metadata_timeout_fails_closed_with_empty_cache() {
|
||||
reset_node_pool_metadata_readiness_cache();
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_READINESS_CACHE_TTL_MS, Some("60000"))], async {
|
||||
let timed_out = apply_node_pool_metadata_timeout_policy(
|
||||
StorageWriteReadinessStatus {
|
||||
ready: false,
|
||||
pool_metadata_reason: Some(ReadinessDegradedReason::PoolMetadataCheckTimeout),
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
|
||||
assert!(!timed_out.ready);
|
||||
assert_eq!(timed_out.pool_metadata_reason, Some(ReadinessDegradedReason::PoolMetadataCheckTimeout));
|
||||
})
|
||||
.await;
|
||||
reset_node_pool_metadata_readiness_cache();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_pool_metadata_timeout_fails_closed_after_cache_ttl() {
|
||||
let mut cache = NodePoolMetadataReadinessCache::default();
|
||||
let observed_at = Instant::now();
|
||||
let ttl = Duration::from_secs(60);
|
||||
let writable = StorageWriteReadinessStatus {
|
||||
ready: true,
|
||||
pool_metadata_reason: None,
|
||||
};
|
||||
assert_eq!(cache.observe(writable, observed_at, observed_at, ttl), writable);
|
||||
|
||||
let timed_out = cache.observe(
|
||||
StorageWriteReadinessStatus {
|
||||
ready: false,
|
||||
pool_metadata_reason: Some(ReadinessDegradedReason::PoolMetadataCheckTimeout),
|
||||
},
|
||||
observed_at + Duration::from_secs(61),
|
||||
observed_at + Duration::from_secs(61),
|
||||
ttl,
|
||||
);
|
||||
|
||||
assert!(!timed_out.ready);
|
||||
assert_eq!(timed_out.pool_metadata_reason, Some(ReadinessDegradedReason::PoolMetadataCheckTimeout));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn node_pool_metadata_timeout_never_hides_confirmed_block() {
|
||||
reset_node_pool_metadata_readiness_cache();
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_READINESS_CACHE_TTL_MS, Some("60000"))], async {
|
||||
let blocked = StorageWriteReadinessStatus {
|
||||
ready: false,
|
||||
pool_metadata_reason: Some(ReadinessDegradedReason::PoolMetaWriteBlocked),
|
||||
};
|
||||
let newer_observation = Instant::now();
|
||||
assert_eq!(apply_node_pool_metadata_timeout_policy(blocked, newer_observation), blocked);
|
||||
assert_eq!(
|
||||
apply_node_pool_metadata_timeout_policy(
|
||||
StorageWriteReadinessStatus {
|
||||
ready: true,
|
||||
pool_metadata_reason: None,
|
||||
},
|
||||
newer_observation
|
||||
.checked_sub(Duration::from_secs(1))
|
||||
.expect("test instant has elapsed time"),
|
||||
),
|
||||
StorageWriteReadinessStatus {
|
||||
ready: true,
|
||||
pool_metadata_reason: None,
|
||||
}
|
||||
);
|
||||
|
||||
let timed_out = apply_node_pool_metadata_timeout_policy(
|
||||
StorageWriteReadinessStatus {
|
||||
ready: false,
|
||||
pool_metadata_reason: Some(ReadinessDegradedReason::PoolMetadataCheckTimeout),
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
|
||||
assert_eq!(timed_out, blocked);
|
||||
})
|
||||
.await;
|
||||
reset_node_pool_metadata_readiness_cache();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn node_pool_metadata_timeout_fails_closed_without_fresh_observation() {
|
||||
reset_node_pool_metadata_readiness_cache();
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_READINESS_CACHE_TTL_MS, Some("60000"))], async {
|
||||
let writable = StorageWriteReadinessStatus {
|
||||
ready: true,
|
||||
pool_metadata_reason: None,
|
||||
};
|
||||
assert_eq!(apply_node_pool_metadata_timeout_policy(writable, Instant::now()), writable);
|
||||
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_READINESS_CACHE_TTL_MS, Some("0"))], async {
|
||||
let timed_out = apply_node_pool_metadata_timeout_policy(
|
||||
StorageWriteReadinessStatus {
|
||||
ready: false,
|
||||
pool_metadata_reason: Some(ReadinessDegradedReason::PoolMetadataCheckTimeout),
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
|
||||
assert!(!timed_out.ready);
|
||||
assert_eq!(timed_out.pool_metadata_reason, Some(ReadinessDegradedReason::PoolMetadataCheckTimeout));
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
reset_node_pool_metadata_readiness_cache();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -44,6 +44,7 @@ pub enum ReadinessDegradedReason {
|
||||
ObjectReadStalled,
|
||||
ObjectWriteStalled,
|
||||
PoolMetaWriteBlocked,
|
||||
PoolMetadataCheckTimeout,
|
||||
StorageReadinessCheckTimeout,
|
||||
ClusterHealthTimeout,
|
||||
PeerHealthUnavailable,
|
||||
@@ -64,6 +65,7 @@ impl ReadinessDegradedReason {
|
||||
ReadinessDegradedReason::ObjectReadStalled => "object_read_stalled",
|
||||
ReadinessDegradedReason::ObjectWriteStalled => "object_write_stalled",
|
||||
ReadinessDegradedReason::PoolMetaWriteBlocked => "pool_meta_write_blocked",
|
||||
ReadinessDegradedReason::PoolMetadataCheckTimeout => "pool_metadata_check_timeout",
|
||||
ReadinessDegradedReason::StorageReadinessCheckTimeout => "storage_readiness_check_timeout",
|
||||
ReadinessDegradedReason::ClusterHealthTimeout => "cluster_health_timeout",
|
||||
ReadinessDegradedReason::PeerHealthUnavailable => "peer_health_unavailable",
|
||||
|
||||
@@ -1664,9 +1664,6 @@ async fn handle_put_file(req: Request<Incoming>, require_auth: bool) -> Response
|
||||
INTERNODE_TRANSPORT_BACKEND_TCP_HTTP,
|
||||
)
|
||||
{
|
||||
if crate::storage::storage_api::rpc_auth_is_resource_exhausted(&e) {
|
||||
return response_with_status(StatusCode::SERVICE_UNAVAILABLE, "RPC replay admission exhausted");
|
||||
}
|
||||
log_internode_rpc_response_failure!(
|
||||
StatusCode::FORBIDDEN,
|
||||
&path,
|
||||
|
||||
@@ -621,7 +621,7 @@ pub(crate) mod ecstore_rpc {
|
||||
KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, MAX_NETWORK_PROBE_BYTES, MAX_NETWORK_PROBE_DURATION, NetworkPeerProbeClient,
|
||||
NetworkPeerProbeError, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, PeerS3Client,
|
||||
SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, TONIC_RPC_PREFIX, check_and_record_signed_rpc_nonce,
|
||||
decode_heal_bucket_rpc_options, normalize_tonic_rpc_audience, rpc_auth_is_resource_exhausted, rpc_background_admission,
|
||||
decode_heal_bucket_rpc_options, normalize_tonic_rpc_audience, rpc_background_admission,
|
||||
sign_ns_scanner_capability_with_checkpoint_coverage, sign_put_file_capability, sign_tonic_rpc_response_proof,
|
||||
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
|
||||
verify_put_file_auth_trailer, verify_rpc_signature, verify_tonic_canonical_body_digest,
|
||||
@@ -630,8 +630,8 @@ pub(crate) mod ecstore_rpc {
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{
|
||||
RpcReplayCacheAdmissionError, ScannerScopedDirtyUsageAckEntry, build_put_file_auth_trailer, gen_signature_headers,
|
||||
gen_tonic_replay_scope_headers, gen_tonic_signature_headers, set_tonic_canonical_body_digest, verify_put_file_capability,
|
||||
ScannerScopedDirtyUsageAckEntry, build_put_file_auth_trailer, gen_signature_headers, gen_tonic_replay_scope_headers,
|
||||
gen_tonic_signature_headers, set_tonic_canonical_body_digest, verify_put_file_capability,
|
||||
verify_tonic_boot_epoch_response, verify_tonic_rpc_response_proof,
|
||||
};
|
||||
}
|
||||
@@ -2433,10 +2433,6 @@ pub(crate) fn verify_tonic_rpc_signature_with_bootstrap(
|
||||
ecstore_rpc::verify_tonic_rpc_signature_with_bootstrap(audience, path, headers, allow_replay_scope_bootstrap)
|
||||
}
|
||||
|
||||
pub(crate) fn rpc_auth_is_resource_exhausted(error: &std::io::Error) -> bool {
|
||||
ecstore_rpc::rpc_auth_is_resource_exhausted(error)
|
||||
}
|
||||
|
||||
pub(crate) fn tonic_rpc_auth_failure_reason(error: &std::io::Error) -> &'static str {
|
||||
ecstore_rpc::tonic_rpc_auth_failure_reason(error)
|
||||
}
|
||||
|
||||
@@ -186,13 +186,10 @@ pub(crate) mod server {
|
||||
pub(crate) use crate::storage::storage_api::ecstore_disk::{
|
||||
DiskAPI, DiskOption, new_disk, with_background_resource_unit,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::storage::storage_api::ecstore_rpc::RpcReplayCacheAdmissionError;
|
||||
pub(crate) use crate::storage::storage_api::{
|
||||
RemoteBackgroundIo, ServerContextSlot, TONIC_RPC_PREFIX, normalize_tonic_rpc_audience,
|
||||
rpc_auth_is_resource_exhausted, rpc_background_admission, tonic_boot_epoch_challenge,
|
||||
tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason, verify_tonic_rpc_signature_with_bootstrap,
|
||||
with_remote_background_io,
|
||||
RemoteBackgroundIo, ServerContextSlot, TONIC_RPC_PREFIX, normalize_tonic_rpc_audience, rpc_background_admission,
|
||||
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
|
||||
verify_tonic_rpc_signature_with_bootstrap, with_remote_background_io,
|
||||
};
|
||||
|
||||
pub(crate) fn try_current_local_node_name() -> Option<String> {
|
||||
|
||||
@@ -181,6 +181,10 @@ const WAREHOUSE_INDEX_STATE_FILE: &str = "state.json";
|
||||
const TABLE_RENAME_ROOT: &str = "renames";
|
||||
const WAREHOUSE_INDEX_MAX_PREFIX_DEPTH: usize = 64;
|
||||
const TABLE_DATA_PLANE_INDEX_MISS_SCAN_MAX_CATALOG_OBJECTS: usize = 4096;
|
||||
/// Listing pages an index-miss scan may consume before failing closed. Only
|
||||
/// table entries count toward the object limit, so maintenance reports and
|
||||
/// other catalog objects cannot trip it; this bounds the listing work itself.
|
||||
const TABLE_DATA_PLANE_INDEX_MISS_SCAN_MAX_PAGES: usize = 64;
|
||||
const EXTERNAL_CATALOG_ROOT: &str = "external-catalog";
|
||||
const EXTERNAL_CATALOG_BRIDGE_FILE: &str = "bridge.json";
|
||||
const MAINTENANCE_ROOT: &str = "maintenance";
|
||||
|
||||
@@ -40,7 +40,7 @@ pub(super) use backup_types::{TableCatalogBackupStatus, TableCatalogRestoreStatu
|
||||
use manifest::table_catalog_backing_manifest;
|
||||
pub(crate) use object::ObjectTableCatalogStore;
|
||||
#[cfg(test)]
|
||||
pub(super) use object::bounded_table_entry_objects_for_data_plane_scan;
|
||||
pub(super) use object::{bounded_table_entry_objects_for_data_plane_scan, maintenance_subtree_skip_cursor};
|
||||
|
||||
struct PeriodicTableMaintenanceQueueGuard<'a> {
|
||||
table_id: &'a str,
|
||||
@@ -1055,20 +1055,6 @@ pub(crate) trait TableCatalogObjectBackend: Clone + Send + Sync + 'static {
|
||||
))
|
||||
}
|
||||
|
||||
/// GET-equivalent table ownership entries, selected before metadata reads.
|
||||
/// Report history must not consume pages. No ordinary S3 list fallback is safe.
|
||||
async fn list_table_entry_objects_page(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_prefix: &str,
|
||||
_start_after: Option<&str>,
|
||||
_limit: NonZeroUsize,
|
||||
) -> TableCatalogStoreResult<TableCatalogObjectListPage> {
|
||||
Err(TableCatalogStoreError::Unsupported(
|
||||
"authoritative table-entry pagination is not supported by this backend".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// GET-equivalent, bounded enumeration for publishing authoritative indexes.
|
||||
/// Backends without this contract must not fall back to an ordinary S3 list.
|
||||
async fn list_catalog_objects_page(
|
||||
@@ -2629,33 +2615,6 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_table_entry_objects_page(
|
||||
&self,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
start_after: Option<&str>,
|
||||
limit: NonZeroUsize,
|
||||
) -> TableCatalogStoreResult<TableCatalogObjectListPage> {
|
||||
let max_keys = i32::try_from(limit.get())
|
||||
.map_err(|_| TableCatalogStoreError::Invalid("catalog page size exceeds storage API range".to_string()))?;
|
||||
let page = self
|
||||
.store
|
||||
.clone()
|
||||
.list_identifier_tree_leaves_authoritative(
|
||||
bucket,
|
||||
prefix,
|
||||
TABLE_ENTRY_FILE,
|
||||
start_after.map(str::to_string),
|
||||
max_keys,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| storage_error_to_catalog("list authoritative table entries", err))?;
|
||||
Ok(TableCatalogObjectListPage {
|
||||
objects: page.objects.into_iter().map(|object| object.name).collect(),
|
||||
is_truncated: page.is_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_catalog_objects_page(
|
||||
&self,
|
||||
bucket: &str,
|
||||
|
||||
@@ -430,6 +430,20 @@ pub(in crate::table_catalog) fn bounded_table_entry_objects_for_data_plane_scan(
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// When `object` lies in a table's `maintenance/` subtree, the cursor that skips
|
||||
/// the rest of that subtree. Only keys under that directory are skipped, never
|
||||
/// the table entry or any other table beside it.
|
||||
pub(in crate::table_catalog) fn maintenance_subtree_skip_cursor(prefix: &str, object: &str) -> Option<String> {
|
||||
let relative = object.strip_prefix(prefix)?;
|
||||
let mut segments = relative.splitn(5, '/');
|
||||
let namespace = segments.next()?;
|
||||
let root = segments.next()?;
|
||||
let table = segments.next()?;
|
||||
let maintenance = segments.next()?;
|
||||
(root == TABLE_ROOT && maintenance == MAINTENANCE_ROOT && segments.next().is_some())
|
||||
.then(|| format!("{prefix}{namespace}/{root}/{table}/{maintenance}/\u{10ffff}"))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ObjectTableCatalogStore<B> {
|
||||
pub(in crate::table_catalog) backend: B,
|
||||
@@ -2847,21 +2861,41 @@ where
|
||||
max_catalog_objects: usize,
|
||||
) -> TableCatalogStoreResult<Vec<String>> {
|
||||
let mut objects = Vec::new();
|
||||
let mut entries = 0_usize;
|
||||
let mut pages = 0_usize;
|
||||
let mut cursor: Option<String> = None;
|
||||
let prefix = self.paths.namespace_entries_prefix(table_bucket);
|
||||
let limit = NonZeroUsize::new(TABLE_CATALOG_LIST_MAX_KEYS)
|
||||
.ok_or_else(|| TableCatalogStoreError::Internal("table catalog listing page size must be positive".to_string()))?;
|
||||
loop {
|
||||
// A page that ends inside a table's maintenance subtree skips the
|
||||
// rest of it, so each entry can waste at most one page: honest
|
||||
// catalogs scale with their entries while a report flood under one
|
||||
// table stays within the fixed budget.
|
||||
if pages >= TABLE_DATA_PLANE_INDEX_MISS_SCAN_MAX_PAGES.saturating_add(entries) {
|
||||
return Err(TableCatalogStoreError::Unavailable(format!(
|
||||
"table data-plane warehouse-index miss scan exceeds the {TABLE_DATA_PLANE_INDEX_MISS_SCAN_MAX_PAGES}-page listing budget"
|
||||
)));
|
||||
}
|
||||
let page = self
|
||||
.backend
|
||||
.list_table_entry_objects_page(self.catalog_bucket(), &prefix, cursor.as_deref(), limit)
|
||||
.list_objects_page(self.catalog_bucket(), &prefix, cursor.as_deref(), limit)
|
||||
.await?;
|
||||
let next_cursor = page.objects.last().cloned();
|
||||
pages += 1;
|
||||
let next_cursor = page
|
||||
.objects
|
||||
.last()
|
||||
.map(|last| maintenance_subtree_skip_cursor(&prefix, last).unwrap_or_else(|| last.clone()));
|
||||
entries += page
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|object| object.ends_with(TABLE_ENTRY_FILE))
|
||||
.count();
|
||||
objects.extend(page.objects);
|
||||
if !page.is_truncated {
|
||||
return bounded_table_entry_objects_for_data_plane_scan(objects, false, max_catalog_objects);
|
||||
}
|
||||
if objects.len() > max_catalog_objects {
|
||||
if entries > max_catalog_objects {
|
||||
return bounded_table_entry_objects_for_data_plane_scan(objects, true, max_catalog_objects);
|
||||
}
|
||||
let Some(next_cursor) = next_cursor else {
|
||||
|
||||
@@ -594,29 +594,6 @@ impl TestCatalogObjectBackend {
|
||||
state.list_calls = 0;
|
||||
}
|
||||
|
||||
async fn overridden_list_page(&self, bucket: &str, prefix: &str) -> Option<TableCatalogObjectListPage> {
|
||||
let pause = self
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.list_page_pauses
|
||||
.get_mut(&(bucket.to_string(), prefix.to_string()))
|
||||
.and_then(std::collections::VecDeque::pop_front);
|
||||
if let Some(pause) = pause {
|
||||
pause.started.notify_one();
|
||||
pause.release.notified().await;
|
||||
}
|
||||
let mut state = self.state.lock().await;
|
||||
let page = state
|
||||
.list_page_overrides
|
||||
.get_mut(&(bucket.to_string(), prefix.to_string()))
|
||||
.and_then(std::collections::VecDeque::pop_front);
|
||||
if page.is_some() {
|
||||
state.list_calls += 1;
|
||||
}
|
||||
page
|
||||
}
|
||||
|
||||
pub(crate) async fn write_lock_acquisition_count(&self, bucket: &str, object: &str) -> usize {
|
||||
self.state
|
||||
.lock()
|
||||
@@ -1254,7 +1231,29 @@ impl TableCatalogObjectBackend for TestCatalogObjectBackend {
|
||||
start_after: Option<&str>,
|
||||
limit: NonZeroUsize,
|
||||
) -> TableCatalogStoreResult<TableCatalogObjectListPage> {
|
||||
if let Some(page) = self.overridden_list_page(bucket, prefix).await {
|
||||
let pause = self
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.list_page_pauses
|
||||
.get_mut(&(bucket.to_string(), prefix.to_string()))
|
||||
.and_then(std::collections::VecDeque::pop_front);
|
||||
if let Some(pause) = pause {
|
||||
pause.started.notify_one();
|
||||
pause.release.notified().await;
|
||||
}
|
||||
let injected = {
|
||||
let mut state = self.state.lock().await;
|
||||
let page = state
|
||||
.list_page_overrides
|
||||
.get_mut(&(bucket.to_string(), prefix.to_string()))
|
||||
.and_then(std::collections::VecDeque::pop_front);
|
||||
if page.is_some() {
|
||||
state.list_calls += 1;
|
||||
}
|
||||
page
|
||||
};
|
||||
if let Some(page) = injected {
|
||||
return Ok(page);
|
||||
}
|
||||
let mut objects = self.list_objects(bucket, prefix).await?;
|
||||
@@ -1267,34 +1266,6 @@ impl TableCatalogObjectBackend for TestCatalogObjectBackend {
|
||||
Ok(TableCatalogObjectListPage { objects, is_truncated })
|
||||
}
|
||||
|
||||
async fn list_table_entry_objects_page(
|
||||
&self,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
start_after: Option<&str>,
|
||||
limit: NonZeroUsize,
|
||||
) -> TableCatalogStoreResult<TableCatalogObjectListPage> {
|
||||
if let Some(page) = self.overridden_list_page(bucket, prefix).await {
|
||||
return Ok(page);
|
||||
}
|
||||
// This in-memory backend has no erasure replicas. Production selects
|
||||
// candidates before metadata I/O and then proves each key at read quorum.
|
||||
let mut inventory = self.list_objects_for_cleanup(bucket, prefix).await?;
|
||||
inventory.retain(|object| object.key.rsplit('/').next() == Some(TABLE_ENTRY_FILE));
|
||||
if inventory
|
||||
.iter()
|
||||
.any(|object| object.version_id.is_some() || object.delete_marker)
|
||||
{
|
||||
return Err(TableCatalogStoreError::Unavailable("versioned table ownership entry".to_string()));
|
||||
}
|
||||
let mut objects = inventory.into_iter().map(|object| object.key).collect::<Vec<_>>();
|
||||
objects.sort();
|
||||
objects.retain(|object| start_after.is_none_or(|after| object.as_str() > after));
|
||||
let is_truncated = objects.len() >= limit.get();
|
||||
objects.truncate(limit.get());
|
||||
Ok(TableCatalogObjectListPage { objects, is_truncated })
|
||||
}
|
||||
|
||||
async fn list_objects_for_cleanup(
|
||||
&self,
|
||||
bucket: &str,
|
||||
|
||||
@@ -1814,6 +1814,43 @@ fn table_data_plane_index_miss_scan_rejects_catalogs_above_its_object_limit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_data_plane_index_miss_scan_skips_only_a_table_maintenance_subtree() {
|
||||
let prefix = "catalog/namespaces/";
|
||||
let skip = |object: &str| maintenance_subtree_skip_cursor(prefix, &format!("{prefix}{object}"));
|
||||
assert_eq!(
|
||||
skip("sales/tables/orders/maintenance/hash/jobs/job.json").as_deref(),
|
||||
Some("catalog/namespaces/sales/tables/orders/maintenance/\u{10ffff}")
|
||||
);
|
||||
assert_eq!(
|
||||
skip("sales/tables/orders/maintenance/hash/config.json").as_deref(),
|
||||
Some("catalog/namespaces/sales/tables/orders/maintenance/\u{10ffff}")
|
||||
);
|
||||
// A table or namespace literally named "maintenance" is not a subtree.
|
||||
assert_eq!(skip("sales/tables/maintenance/table-entry.json"), None);
|
||||
assert_eq!(skip("maintenance/tables/orders/table-entry.json"), None);
|
||||
assert_eq!(
|
||||
skip("maintenance/tables/maintenance/maintenance/hash/jobs/job.json").as_deref(),
|
||||
Some("catalog/namespaces/maintenance/tables/maintenance/maintenance/\u{10ffff}")
|
||||
);
|
||||
// Entries, bridges, views and a bare file named maintenance never jump.
|
||||
assert_eq!(skip("sales/tables/orders/table-entry.json"), None);
|
||||
assert_eq!(skip("sales/tables/orders/external-catalog/bridge.json"), None);
|
||||
assert_eq!(skip("sales/views/report/maintenance/hash/jobs/job.json"), None);
|
||||
assert_eq!(skip("sales/tables/orders/maintenance"), None);
|
||||
assert_eq!(skip("sales/namespace.json"), None);
|
||||
assert_eq!(maintenance_subtree_skip_cursor(prefix, "other/sales/tables/orders/maintenance/x/y"), None);
|
||||
// The jump lands before the entry that follows the subtree.
|
||||
assert!(
|
||||
"catalog/namespaces/sales/tables/orders/maintenance/\u{10ffff}"
|
||||
< "catalog/namespaces/sales/tables/orders/table-entry.json"
|
||||
);
|
||||
assert!(
|
||||
"catalog/namespaces/sales/tables/orders/maintenance/\u{10ffff}"
|
||||
< "catalog/namespaces/sales/tables/orders0/table-entry.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_lock_authority_failures_are_typed_as_unavailable() {
|
||||
for error in [
|
||||
@@ -6843,123 +6880,39 @@ async fn seed_noise_table_with_job_reports(
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_data_plane_index_miss_scan_pages_scale_with_entries_not_job_reports() {
|
||||
let mut baseline_pages = None;
|
||||
for reports in [0, 80] {
|
||||
let backend = TestCatalogObjectBackend::default();
|
||||
let store = ObjectTableCatalogStore::new(backend.clone());
|
||||
let bucket = "analytics";
|
||||
let namespace = Namespace::parse("sales").expect("namespace should parse");
|
||||
let table = IdentifierSegment::parse("orders").expect("table should parse");
|
||||
let current = default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
|
||||
let object = "tables/table-id/data/part-00001.parquet";
|
||||
|
||||
seed_table_for_metadata_maintenance(&store, bucket, &namespace, &table, current).await;
|
||||
backend
|
||||
.delete_object(RUSTFS_META_BUCKET, &store.paths.warehouse_index_entry_path(bucket, "tables/table-id/"))
|
||||
.await
|
||||
.expect("warehouse index entry should be removed");
|
||||
// Keep the same table inventory with and without historical job reports.
|
||||
let noise_namespace = Namespace::parse("noise").expect("namespace should parse");
|
||||
for index in 0..1000 {
|
||||
let noise_table = IdentifierSegment::parse(format!("noise-{index:04}")).expect("table should parse");
|
||||
seed_noise_table_with_job_reports(&store, bucket, &noise_namespace, &noise_table, &format!("noise-{index}"), reports)
|
||||
.await;
|
||||
}
|
||||
backend.reset_call_counts().await;
|
||||
|
||||
let resource = table_data_plane_resource_for_object(&store, bucket, object)
|
||||
.await
|
||||
.expect("job history must not exhaust the index-miss listing budget")
|
||||
.expect("the scan must retain table-aware protection");
|
||||
assert_eq!(resource.table, "orders");
|
||||
let pages = backend.list_call_count().await;
|
||||
assert!(pages > 0, "the missing index must exercise enumeration");
|
||||
if let Some(expected) = baseline_pages {
|
||||
assert_eq!(pages, expected, "job history must not consume ownership listing pages");
|
||||
} else {
|
||||
baseline_pages = Some(pages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_entry_scan_preserves_nested_namespaces_named_like_maintenance() {
|
||||
for name in ["sales", "sales.tables.orders.maintenance"] {
|
||||
let backend = TestCatalogObjectBackend::default();
|
||||
let store = ObjectTableCatalogStore::new(backend.clone());
|
||||
let bucket = "analytics";
|
||||
let namespace = Namespace::parse(name).expect("namespace should parse");
|
||||
let owner = IdentifierSegment::parse("zz-owner").expect("owner should parse");
|
||||
let current = default_table_metadata_file_path(&namespace, &owner, "00001.metadata.json");
|
||||
seed_table_for_metadata_maintenance(&store, bucket, &namespace, &owner, current).await;
|
||||
backend
|
||||
.delete_object(RUSTFS_META_BUCKET, &store.paths.warehouse_index_entry_path(bucket, "tables/table-id/"))
|
||||
.await
|
||||
.expect("remove the owner index to exercise catalog fallback");
|
||||
for index in 0..1000 {
|
||||
let table = IdentifierSegment::parse(format!("table-{index:04}")).expect("table should parse");
|
||||
seed_noise_table_with_job_reports(&store, bucket, &namespace, &table, &format!("id-{index}"), 0).await;
|
||||
}
|
||||
let resource = store
|
||||
.resolve_table_data_plane_resource_for_authorization(bucket, "tables/table-id/data/part.parquet")
|
||||
.await
|
||||
.expect("registered table entries must remain enumerable")
|
||||
.unwrap_or_else(|| panic!("namespace {name} lost its table owner across a page boundary"));
|
||||
assert_eq!(resource.namespace, name);
|
||||
assert_eq!(resource.table, "zz-owner");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_maintenance_preserves_an_owner_after_nested_namespace_page_boundary() {
|
||||
let backend = TestCatalogObjectBackend::default();
|
||||
let store = ObjectTableCatalogStore::new(backend.clone());
|
||||
let bucket = "analytics";
|
||||
let namespace = Namespace::parse("sales").expect("namespace should parse");
|
||||
let table = IdentifierSegment::parse("orders").expect("table should parse");
|
||||
let current = default_table_metadata_file_path(&namespace, &table, "00002.metadata.json");
|
||||
let orphan = default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
|
||||
seed_table_for_metadata_maintenance(&store, bucket, &namespace, &table, current.clone()).await;
|
||||
backend
|
||||
.seed_object(bucket, ¤t, br#"{"metadata-log":[]}"#.to_vec())
|
||||
.await;
|
||||
backend.seed_object(bucket, &orphan, br#"{"metadata-log":[]}"#.to_vec()).await;
|
||||
let report = store
|
||||
.plan_table_metadata_maintenance(bucket, "sales", "orders", 0)
|
||||
.await
|
||||
.expect("the unowned old metadata should initially be a cleanup candidate");
|
||||
assert_eq!(report.deletable_metadata_locations, vec![orphan.clone()]);
|
||||
let current = default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
|
||||
let object = "tables/table-id/data/part-00001.parquet";
|
||||
|
||||
let nested = Namespace::parse("sales.tables.orders.maintenance").expect("nested namespace should parse");
|
||||
store
|
||||
.create_namespace(test_namespace_entry(bucket, &nested))
|
||||
seed_table_for_metadata_maintenance(&store, bucket, &namespace, &table, current).await;
|
||||
backend
|
||||
.delete_object(RUSTFS_META_BUCKET, &store.paths.warehouse_index_entry_path(bucket, "tables/table-id/"))
|
||||
.await
|
||||
.expect("nested namespace should be created");
|
||||
for index in 0..1100 {
|
||||
let noise = IdentifierSegment::parse(format!("table-{index:04}")).expect("table should parse");
|
||||
seed_noise_table_with_job_reports(&store, bucket, &nested, &noise, &format!("noise-{index}"), 0).await;
|
||||
.expect("warehouse index entry should be removed");
|
||||
// 1000 honest tables with 80 historical job reports each: ~80 pages of
|
||||
// objects, beyond the fixed page budget on its own, yet well within one
|
||||
// extra page per table entry.
|
||||
let noise_namespace = Namespace::parse("noise").expect("namespace should parse");
|
||||
for index in 0..1000 {
|
||||
let noise_table = IdentifierSegment::parse(format!("noise-{index:04}")).expect("table should parse");
|
||||
seed_noise_table_with_job_reports(&store, bucket, &noise_namespace, &noise_table, &format!("noise-{index}"), 80).await;
|
||||
}
|
||||
let owner = IdentifierSegment::parse("zz-owner").expect("owner should parse");
|
||||
let mut other = test_table_entry(bucket, &nested, &owner, orphan.clone());
|
||||
other.table_id = "other-id".to_string();
|
||||
other.table_uuid = "other-uuid".to_string();
|
||||
other.warehouse_location = format!("s3://{bucket}/tables/other-id");
|
||||
store
|
||||
.create_table(other)
|
||||
backend.reset_call_counts().await;
|
||||
|
||||
let resource = table_data_plane_resource_for_object(&store, bucket, object)
|
||||
.await
|
||||
.expect("another table may adopt the metadata before cleanup");
|
||||
let deletes_before = backend.state.lock().await.delete_attempts.clone();
|
||||
let result = store
|
||||
.delete_table_metadata_maintenance_report(bucket, "sales", "orders", report)
|
||||
.await;
|
||||
.expect("job history must not exhaust the index-miss listing budget")
|
||||
.expect("the scan must retain table-aware protection");
|
||||
assert_eq!(resource.table, "orders");
|
||||
let pages = backend.list_call_count().await;
|
||||
assert!(
|
||||
backend
|
||||
.object_exists(bucket, &orphan)
|
||||
.await
|
||||
.expect("read back the current owner's metadata")
|
||||
pages > TABLE_DATA_PLANE_INDEX_MISS_SCAN_MAX_PAGES && pages <= TABLE_DATA_PLANE_INDEX_MISS_SCAN_MAX_PAGES + 1001,
|
||||
"the honest catalog needs {pages} pages: more than the fixed budget, at most one extra per entry"
|
||||
);
|
||||
assert_eq!(backend.state.lock().await.delete_attempts, deletes_before);
|
||||
assert_matches!(result, Err(TableCatalogStoreError::Conflict(message)) if message.contains("exclusive metadata"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -6977,7 +6930,8 @@ async fn table_data_plane_index_miss_scan_bounds_a_job_report_flood_under_one_ta
|
||||
.delete_object(RUSTFS_META_BUCKET, &store.paths.warehouse_index_entry_path(bucket, "tables/table-id/"))
|
||||
.await
|
||||
.expect("warehouse index entry should be removed");
|
||||
// Historical report volume must not consume ownership pagination.
|
||||
// A table-scoped principal floods one table's job history: 70 reports per
|
||||
// page budget would have exhausted the old fixed budget bucket-wide.
|
||||
for job in 0..70_000 {
|
||||
backend
|
||||
.seed_object(
|
||||
@@ -6997,7 +6951,7 @@ async fn table_data_plane_index_miss_scan_bounds_a_job_report_flood_under_one_ta
|
||||
.expect("the scan must retain table-aware protection");
|
||||
assert_eq!(resource.table, "orders");
|
||||
let pages = backend.list_call_count().await;
|
||||
assert!(pages <= 2, "report history must not consume {pages} ownership pages");
|
||||
assert!(pages <= 2, "the page that ends inside the flood skips the rest of it, not {pages} pages");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Contract tests for scripts/release/release_gate.py (rustfs/backlog#2662).
|
||||
|
||||
Every fixture under testdata/release-gate/ is a contract rehearsal only: these
|
||||
tests prove the gate's fail-closed orchestration and must never be read as
|
||||
product release evidence or as R02 live verification results.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
GATE = ROOT / "scripts" / "release" / "release_gate.py"
|
||||
FIXTURES = ROOT / "testdata" / "release-gate"
|
||||
PLAN = FIXTURES / "plan.json"
|
||||
CANDIDATE = FIXTURES / "candidate.json"
|
||||
ARTIFACT = FIXTURES / "artifacts" / "rustfs-linux-x86_64-gnu-v9.9.9-fixture.zip"
|
||||
ARTIFACT_NAME = "rustfs-linux-x86_64-gnu-v9.9.9-fixture.zip"
|
||||
REAL_PLAN = ROOT / ".config" / "release-validation-plan.json"
|
||||
|
||||
|
||||
def run_gate(*arguments):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(GATE), *map(str, arguments)],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def run_validate(results, output, plan=PLAN, candidate=CANDIDATE):
|
||||
return run_gate(
|
||||
"validate",
|
||||
"--plan", plan,
|
||||
"--candidate", candidate,
|
||||
"--results", results,
|
||||
"--output", output,
|
||||
)
|
||||
|
||||
|
||||
def run_promote(output, plan=PLAN, candidate=CANDIDATE, summary=None, artifact=ARTIFACT,
|
||||
artifact_name=ARTIFACT_NAME, artifact_sha256=None):
|
||||
arguments = [
|
||||
"verify-promotion",
|
||||
"--plan", plan,
|
||||
"--candidate", candidate,
|
||||
"--summary", summary or output,
|
||||
]
|
||||
if artifact is not None:
|
||||
arguments += ["--artifact", artifact]
|
||||
if artifact_name is not None:
|
||||
arguments += ["--artifact-name", artifact_name]
|
||||
if artifact_sha256 is not None:
|
||||
arguments += ["--artifact-sha256", artifact_sha256]
|
||||
if output is not None:
|
||||
arguments += ["--output", output]
|
||||
return run_gate(*arguments)
|
||||
|
||||
|
||||
def load_summary(output):
|
||||
return json.loads((Path(output) / "release-gate-summary.json").read_text())
|
||||
|
||||
|
||||
def finding_codes(summary):
|
||||
return {finding["code"] for finding in summary["findings"]}
|
||||
|
||||
|
||||
class GateFixtureTests(unittest.TestCase):
|
||||
"""Assertion matrix rows from rustfs/backlog#2662 exercised on the fixtures."""
|
||||
|
||||
def setUp(self):
|
||||
self._temporary = tempfile.TemporaryDirectory(prefix="release-gate-test.")
|
||||
self.addCleanup(self._temporary.cleanup)
|
||||
self.output = Path(self._temporary.name)
|
||||
|
||||
def validate_fixture(self, name, expect):
|
||||
output = self.output / name
|
||||
result = run_validate(FIXTURES / name, output)
|
||||
self.assertEqual(result.returncode, expect, result.stderr)
|
||||
return result, load_summary(output)
|
||||
|
||||
def test_all_required_pass_and_identity_consistent(self):
|
||||
# Matrix: all required PASS and identity consistent -> validate and
|
||||
# verify-promotion return 0.
|
||||
result, summary = self.validate_fixture("pass", 0)
|
||||
self.assertEqual(summary["state"], "pass")
|
||||
self.assertTrue(summary["publish_allowed"])
|
||||
self.assertEqual(len(summary["required_cases"]), 5)
|
||||
self.assertEqual(len(summary["observed"]), 5)
|
||||
self.assertEqual(summary["findings"], [])
|
||||
expansion = json.loads((self.output / "pass" / "plan-expansion.json").read_text())
|
||||
self.assertEqual(len(expansion["required_cases"]), 5)
|
||||
promote = run_promote(self.output / "pass")
|
||||
self.assertEqual(promote.returncode, 0, promote.stderr)
|
||||
|
||||
def test_cross_platform_same_case_and_split_shas_accepted(self):
|
||||
# Matrix: the same case name on two platforms, and a workflow SHA that
|
||||
# differs from the (legally bound) product SHA, must not be rejected.
|
||||
result, summary = self.validate_fixture("cross-platform", 0)
|
||||
self.assertEqual(summary["state"], "pass")
|
||||
product_commit = json.loads(CANDIDATE.read_text())["product_commit"]
|
||||
workflows = []
|
||||
for name in sorted((FIXTURES / "cross-platform").glob("*.json")):
|
||||
if name.name == "batch.json":
|
||||
continue
|
||||
envelope = json.loads(name.read_text())
|
||||
workflows.append(envelope["workflow"]["workflow_sha"])
|
||||
self.assertNotEqual(envelope["workflow"]["workflow_sha"], product_commit)
|
||||
self.assertEqual(len(workflows), 5)
|
||||
case_names = [entry["case_id"] for entry in summary["observed"]]
|
||||
self.assertEqual(case_names.count("startup"), 2)
|
||||
self.assertEqual(case_names.count("artifact-identity"), 2)
|
||||
|
||||
def test_missing_case_names_the_case(self):
|
||||
result, summary = self.validate_fixture("missing-case", 1)
|
||||
self.assertIn("missing_case", finding_codes(summary))
|
||||
missing = [f for f in summary["findings"] if f["code"] == "missing_case"]
|
||||
self.assertTrue(any(f.get("case_id") == "startup" for f in missing), missing)
|
||||
self.assertFalse(summary["publish_allowed"])
|
||||
|
||||
def test_duplicate_key_names_the_case(self):
|
||||
result, summary = self.validate_fixture("duplicate-key", 1)
|
||||
self.assertIn("duplicate_key", finding_codes(summary))
|
||||
duplicates = [f for f in summary["findings"] if f["code"] == "duplicate_key"]
|
||||
self.assertTrue(any(f.get("case_id") == "startup" for f in duplicates), duplicates)
|
||||
self.assertFalse(summary["publish_allowed"])
|
||||
|
||||
def test_extra_unplanned_case_names_the_case(self):
|
||||
result, summary = self.validate_fixture("extra-case", 1)
|
||||
self.assertIn("extra_case", finding_codes(summary))
|
||||
extra = [f for f in summary["findings"] if f["code"] == "extra_case"]
|
||||
self.assertTrue(any(f.get("case_id") == "surprise-case" for f in extra), extra)
|
||||
self.assertFalse(summary["publish_allowed"])
|
||||
|
||||
def test_non_pass_required_items_block(self):
|
||||
# Matrix: any skipped / infra_error / quarantined required item blocks
|
||||
# and no official publish write is allowed.
|
||||
for name in ("non-pass-skipped", "non-pass-infra-error", "non-pass-quarantined"):
|
||||
with self.subTest(name=name):
|
||||
result, summary = self.validate_fixture(name, 1)
|
||||
self.assertIn("non_pass", finding_codes(summary))
|
||||
self.assertFalse(summary["publish_allowed"])
|
||||
|
||||
def test_child_workflow_cancellation_blocks(self):
|
||||
result, summary = self.validate_fixture("child-cancelled", 1)
|
||||
self.assertIn("cancelled", finding_codes(summary))
|
||||
self.assertFalse(summary["publish_allowed"])
|
||||
|
||||
def test_batch_timeout_blocks(self):
|
||||
result, summary = self.validate_fixture("timeout", 1)
|
||||
self.assertIn("timeout", finding_codes(summary))
|
||||
self.assertFalse(summary["publish_allowed"])
|
||||
|
||||
def test_same_tag_different_bytes_is_rejected(self):
|
||||
result, summary = self.validate_fixture("same-tag-different-bytes", 1)
|
||||
self.assertIn("digest_mismatch", finding_codes(summary))
|
||||
self.assertFalse(summary["publish_allowed"])
|
||||
|
||||
def test_stale_attempt_cannot_be_reused(self):
|
||||
result, summary = self.validate_fixture("stale-attempt", 1)
|
||||
self.assertIn("stale_attempt", finding_codes(summary))
|
||||
self.assertFalse(summary["publish_allowed"])
|
||||
|
||||
def test_wrong_platform_is_rejected(self):
|
||||
result, summary = self.validate_fixture("wrong-platform", 1)
|
||||
self.assertIn("platform_mismatch", finding_codes(summary))
|
||||
self.assertFalse(summary["publish_allowed"])
|
||||
|
||||
def test_wrong_harness_sha_cannot_be_reused(self):
|
||||
result, summary = self.validate_fixture("wrong-harness", 1)
|
||||
self.assertIn("harness_mismatch", finding_codes(summary))
|
||||
self.assertFalse(summary["publish_allowed"])
|
||||
|
||||
def test_zero_tests_block(self):
|
||||
result, summary = self.validate_fixture("zero-tests", 1)
|
||||
self.assertIn("zero_tests", finding_codes(summary))
|
||||
self.assertFalse(summary["publish_allowed"])
|
||||
|
||||
def test_untrusted_source_blocks(self):
|
||||
# A PR-uploaded or foreign JSON set must never gain release authority.
|
||||
result, summary = self.validate_fixture("untrusted-source", 1)
|
||||
self.assertIn("untrusted_source", finding_codes(summary))
|
||||
self.assertFalse(summary["publish_allowed"])
|
||||
|
||||
def test_swapped_package_after_verification_fails_promotion(self):
|
||||
# Matrix: swapping a published package after verification must fail
|
||||
# verify-promotion.
|
||||
output = self.output / "certified"
|
||||
self.assertEqual(run_validate(FIXTURES / "pass", output).returncode, 0)
|
||||
promote = run_promote(
|
||||
output,
|
||||
artifact=FIXTURES / "promotion-swap" / ARTIFACT_NAME,
|
||||
summary=output,
|
||||
)
|
||||
self.assertEqual(promote.returncode, 1, promote.stdout)
|
||||
self.assertIn("artifact_mismatch", promote.stderr)
|
||||
report = json.loads((output / "promotion-check.json").read_text())
|
||||
self.assertEqual(report["state"], "blocked")
|
||||
self.assertEqual(finding_codes(report), {"artifact_mismatch"})
|
||||
|
||||
def test_edited_plan_after_verification_fails_promotion(self):
|
||||
# Matrix: editing the plan after verification must fail
|
||||
# verify-promotion; the old certification cannot be reused.
|
||||
output = self.output / "certified"
|
||||
self.assertEqual(run_validate(FIXTURES / "pass", output).returncode, 0)
|
||||
promote = run_promote(
|
||||
output,
|
||||
plan=FIXTURES / "promotion-edit" / "plan.json",
|
||||
summary=output,
|
||||
)
|
||||
self.assertEqual(promote.returncode, 1, promote.stdout)
|
||||
self.assertIn("plan_edited", promote.stderr)
|
||||
|
||||
def test_promotion_without_certification_fails(self):
|
||||
output = self.output / "blocked"
|
||||
self.assertEqual(run_validate(FIXTURES / "missing-case", output).returncode, 1)
|
||||
promote = run_promote(output, summary=output)
|
||||
self.assertEqual(promote.returncode, 1, promote.stdout)
|
||||
self.assertIn("certification_missing", promote.stderr)
|
||||
|
||||
def test_summary_junit_and_expansion_are_written(self):
|
||||
output = self.output / "pass"
|
||||
self.assertEqual(run_validate(FIXTURES / "pass", output).returncode, 0)
|
||||
summary = load_summary(output)
|
||||
self.assertEqual(summary["plan_sha256"], json.loads(CANDIDATE.read_text())["plan_sha256"])
|
||||
junit = (output / "release-gate.junit.xml").read_text()
|
||||
self.assertIn("release-gate", junit)
|
||||
self.assertIn("identity/linux-x86_64-gnu/startup", junit)
|
||||
markdown = (output / "release-gate-summary.md").read_text()
|
||||
self.assertIn("state: pass", markdown)
|
||||
|
||||
def test_error_messages_carry_no_credentials(self):
|
||||
for name in ("missing-case", "stale-attempt", "untrusted-source", "zero-tests"):
|
||||
output = self.output / name
|
||||
run_validate(FIXTURES / name, output)
|
||||
text = output.joinpath("release-gate-summary.json").read_text()
|
||||
text += (output / "release-gate-summary.md").read_text()
|
||||
lowered = text.lower()
|
||||
for marker in ("secret", "password", "access_key", "akia", "bearer "):
|
||||
self.assertNotIn(marker, lowered, f"{name} leaked a credential-shaped string")
|
||||
|
||||
|
||||
class PlanContractTests(unittest.TestCase):
|
||||
def test_real_plan_is_explicit_about_sources_and_rollback(self):
|
||||
sys.path.insert(0, str(GATE.parent))
|
||||
import release_gate
|
||||
|
||||
plan = json.loads(REAL_PLAN.read_text())
|
||||
info = release_gate.validate_plan(plan)
|
||||
sources = info["upgrade_sources"]
|
||||
self.assertGreaterEqual(len(sources["sources"]), 1)
|
||||
self.assertFalse(sources["rollback_supported"])
|
||||
self.assertTrue(sources["basis"].strip())
|
||||
self.assertTrue(sources["rollback_basis"].strip())
|
||||
for source in sources["sources"]:
|
||||
self.assertEqual(len(source["artifact_sha256"]), 64)
|
||||
|
||||
def test_plan_requires_trusted_workflows_and_frozen_harness(self):
|
||||
sys.path.insert(0, str(GATE.parent))
|
||||
import release_gate
|
||||
|
||||
plan = json.loads(REAL_PLAN.read_text())
|
||||
plan["trusted_workflows"] = []
|
||||
with self.assertRaises(release_gate.GateError):
|
||||
release_gate.validate_plan(plan)
|
||||
plan = json.loads(REAL_PLAN.read_text())
|
||||
plan["lanes"][0]["harness_sha"] = "not-a-sha"
|
||||
with self.assertRaises(release_gate.GateError):
|
||||
release_gate.validate_plan(plan)
|
||||
plan = json.loads(REAL_PLAN.read_text())
|
||||
plan["upgrade_sources"].pop("rollback_supported")
|
||||
with self.assertRaises(release_gate.GateError):
|
||||
release_gate.validate_plan(plan)
|
||||
|
||||
def test_plan_sha256_is_semantic_and_detects_required_set_edits(self):
|
||||
sys.path.insert(0, str(GATE.parent))
|
||||
import release_gate
|
||||
|
||||
plan = json.loads(PLAN.read_text())
|
||||
baseline = release_gate.canonical_sha256(plan)
|
||||
self.assertEqual(release_gate.canonical_sha256(json.loads(json.dumps(plan))), baseline)
|
||||
edited = json.loads(PLAN.read_text())
|
||||
edited["lanes"][0]["cases"] = ["artifact-identity"]
|
||||
self.assertNotEqual(release_gate.canonical_sha256(edited), baseline)
|
||||
|
||||
|
||||
class PublicationWiringTests(unittest.TestCase):
|
||||
"""Static checks that the real publication exits consume the gate."""
|
||||
|
||||
def read(self, relative):
|
||||
return (ROOT / relative).read_text()
|
||||
|
||||
def job_block(self, workflow, job):
|
||||
match = re.search(
|
||||
rf"^ {re.escape(job)}:\n(.*?)(?=^ [A-Za-z#])",
|
||||
workflow,
|
||||
re.S | re.M,
|
||||
)
|
||||
self.assertIsNotNone(match, f"job {job} not found")
|
||||
return match.group(1)
|
||||
|
||||
def test_build_publish_requires_certification(self):
|
||||
workflow = self.read(".github/workflows/build.yml")
|
||||
self.assertIn("\n release-certification:", workflow)
|
||||
publish_block = self.job_block(workflow, "publish-release")
|
||||
self.assertIn("release-certification", publish_block)
|
||||
self.assertIn("release_gate.py validate", workflow)
|
||||
self.assertIn("release_gate.py verify-promotion", workflow)
|
||||
|
||||
def test_build_time_official_r2_writes_are_staged(self):
|
||||
workflow = self.read(".github/workflows/build.yml")
|
||||
self.assertIn("artifacts/rustfs/staging/", workflow)
|
||||
release_writes = [
|
||||
line for line in workflow.splitlines()
|
||||
if "artifacts/rustfs/release/" in line
|
||||
]
|
||||
# Only the certification-gated promotion job may write the official path.
|
||||
self.assertTrue(release_writes, "the promotion job must write the official R2 path")
|
||||
promotion_block = self.job_block(workflow, "promote-release-artifacts")
|
||||
self.assertIn("verify-promotion", promotion_block)
|
||||
self.assertIn("artifacts/rustfs/release/", promotion_block)
|
||||
build_block = self.job_block(workflow, "build-rustfs")
|
||||
self.assertNotIn('R2_PATH="s3://${R2_BUCKET}/artifacts/rustfs/release/"', build_block)
|
||||
|
||||
def test_downstream_workflows_verify_certification(self):
|
||||
docker = self.read(".github/workflows/docker.yml")
|
||||
self.assertIn("\n release-certification:", docker)
|
||||
build_docker_block = self.job_block(docker, "build-docker")
|
||||
self.assertIn("release-certification", build_docker_block)
|
||||
self.assertIn("verify-promotion", docker)
|
||||
package = self.read(".github/workflows/package.yml")
|
||||
self.assertIn("\n release-certification:", package)
|
||||
package_block = self.job_block(package, "package")
|
||||
self.assertIn("release-certification", package_block)
|
||||
helm = self.read(".github/workflows/helm-package.yml")
|
||||
self.assertIn("\n release-certification:", helm)
|
||||
helm_block = self.job_block(helm, "build-helm-package")
|
||||
self.assertIn("release-certification", helm_block)
|
||||
|
||||
def test_manual_downstream_dispatch_without_certification_fails(self):
|
||||
# Every downstream certification job must fail closed when the trusted
|
||||
# certification artifact is absent (e.g. a manual dispatch without one).
|
||||
for relative in (
|
||||
".github/workflows/docker.yml",
|
||||
".github/workflows/package.yml",
|
||||
".github/workflows/helm-package.yml",
|
||||
):
|
||||
workflow = self.read(relative)
|
||||
certification_block = self.job_block(workflow, "release-certification")
|
||||
self.assertIn("fail", certification_block.lower(), relative)
|
||||
self.assertIn("release-certification", certification_block, relative)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the release download entry point without fetching external tools."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / ".github/actions/setup/download_release_asset.py"
|
||||
|
||||
|
||||
class ReleaseAssetTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.body = b"verified tool fixture"
|
||||
self.sha256 = hashlib.sha256(self.body).hexdigest()
|
||||
self.asset = {"name": "tool.zip", "id": 17, "size": len(self.body), "digest": "sha256:" + self.sha256}
|
||||
self.metadata = {"tag_name": "v1.0", "assets": [self.asset]}
|
||||
self.output = self.root / "tool.zip"
|
||||
self.output.write_bytes(b"previous file")
|
||||
self.calls = self.root / "calls.jsonl"
|
||||
gh = self.root / "gh"
|
||||
gh.write_text(f"#!{sys.executable}\n" + '''import json,os,pathlib,sys
|
||||
args=sys.argv[1:]
|
||||
with pathlib.Path(os.environ['CALLS']).open('a') as out:out.write(json.dumps(args)+'\\n')
|
||||
if '/releases/tags/' in args[-1]:
|
||||
if os.environ.get('FAIL_METADATA'):raise SystemExit(19)
|
||||
print(os.environ['METADATA'])
|
||||
else:
|
||||
if os.environ.get('FAIL_DOWNLOAD'):raise SystemExit(23)
|
||||
sys.stdout.buffer.write(bytes.fromhex(os.environ['BODY']))
|
||||
''')
|
||||
gh.chmod(0o755)
|
||||
self.env = dict(os.environ, PATH=str(self.root) + os.pathsep + os.environ["PATH"], CALLS=str(self.calls), GH_TOKEN="synthetic-do-not-log-token")
|
||||
|
||||
def invoke(self, extra=(), **env):
|
||||
environment = dict(self.env, METADATA=json.dumps(self.metadata), BODY=self.body.hex(), **env)
|
||||
return subprocess.run([sys.executable, str(SCRIPT), "--repository", "owner/repo", "--tag", "v1.0", "--asset", "tool.zip", "--output", str(self.output), *extra], env=environment, capture_output=True, text=True)
|
||||
|
||||
def assert_failed_atomically(self, result):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.output.read_bytes(), b"previous file")
|
||||
self.assertEqual(list(self.root.glob(".release-asset-*")), [])
|
||||
self.assertNotIn(self.env["GH_TOKEN"], result.stdout + result.stderr)
|
||||
|
||||
def test_valid_asset_uses_api_and_installs_only_verified_bytes(self):
|
||||
result = self.invoke(("--sha256", self.sha256))
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(self.output.read_bytes(), self.body)
|
||||
calls = [json.loads(line) for line in self.calls.read_text().splitlines()]
|
||||
self.assertEqual(calls[0], ["api", "--hostname", "github.com", "repos/owner/repo/releases/tags/v1.0"])
|
||||
self.assertEqual(calls[1], ["api", "--hostname", "github.com", "--header", "Accept: application/octet-stream", "repos/owner/repo/releases/assets/17"])
|
||||
self.assertNotIn(self.env["GH_TOKEN"], json.dumps(calls) + result.stdout + result.stderr)
|
||||
|
||||
def test_download_failure_preserves_destination(self):
|
||||
self.assert_failed_atomically(self.invoke(FAIL_DOWNLOAD="1"))
|
||||
|
||||
def test_missing_gh_fails_without_replacing_destination(self):
|
||||
(self.root / "gh").unlink()
|
||||
self.env["PATH"] = str(self.root)
|
||||
self.assert_failed_atomically(self.invoke())
|
||||
|
||||
def test_metadata_failure_preserves_destination(self):
|
||||
self.assert_failed_atomically(self.invoke(FAIL_METADATA="1"))
|
||||
|
||||
def test_corrupt_download_fails_digest_validation(self):
|
||||
self.body = b"modified tool fixture"
|
||||
self.assert_failed_atomically(self.invoke())
|
||||
|
||||
def test_truncated_download_fails_size_validation(self):
|
||||
self.asset["size"] += 1
|
||||
self.assert_failed_atomically(self.invoke())
|
||||
|
||||
def test_pinned_digest_cannot_be_replaced_by_release_metadata(self):
|
||||
self.assert_failed_atomically(self.invoke(("--sha256", "0" * 64)))
|
||||
self.assertEqual(len(self.calls.read_text().splitlines()), 1)
|
||||
|
||||
def test_ambiguous_or_missing_assets_fail_before_download(self):
|
||||
for assets in ([], [self.asset, self.asset]):
|
||||
with self.subTest(assets=len(assets)):
|
||||
self.metadata["assets"] = assets
|
||||
self.assert_failed_atomically(self.invoke())
|
||||
|
||||
def test_metadata_tag_must_match_requested_version(self):
|
||||
self.metadata["tag_name"] = "v2.0"
|
||||
self.assert_failed_atomically(self.invoke())
|
||||
|
||||
def test_malformed_release_inventory_fails_before_download(self):
|
||||
for metadata in ([], {"tag_name": "v1.0", "assets": None}, {"tag_name": "v1.0", "assets": [None]}):
|
||||
with self.subTest(metadata=metadata):
|
||||
self.metadata = metadata
|
||||
self.assert_failed_atomically(self.invoke())
|
||||
|
||||
def test_missing_digest_or_invalid_identity_fails_before_download(self):
|
||||
for field, value in [("digest", None), ("digest", "sha256:invalid"), ("id", True), ("id", 0), ("size", 0), ("size", 513 * 1024 * 1024)]:
|
||||
with self.subTest(field=field, value=value):
|
||||
self.asset[field] = value
|
||||
self.assert_failed_atomically(self.invoke())
|
||||
self.asset = {"name": "tool.zip", "id": 17, "size": len(self.body), "digest": "sha256:" + self.sha256}
|
||||
self.metadata["assets"] = [self.asset]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -206,6 +206,13 @@ grep -q 'RUSTFS_SCANNER_ALERT_EXCESS_VERSIONS: "100"' <<<"$scanner_config_output
|
||||
grep -q 'RUSTFS_SCANNER_ALERT_EXCESS_VERSION_SIZE: "1099511627776"' <<<"$scanner_config_output"
|
||||
grep -q 'RUSTFS_SCANNER_ALERT_EXCESS_FOLDERS: "65538"' <<<"$scanner_config_output"
|
||||
|
||||
target_store_output=$(render_chart --set config.rustfs.target_store_dir=/data/rustfs0/.rustfs)
|
||||
grep -q 'RUSTFS_TARGET_STORE_DIR: "/data/rustfs0/.rustfs"' <<<"$target_store_output"
|
||||
if grep -q 'RUSTFS_TARGET_STORE_DIR' <<<"$(render_chart)"; then
|
||||
echo "RUSTFS_TARGET_STORE_DIR must not be rendered by default" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail-closed credential checks. Rendering must fail when no credentials,
|
||||
# existingSecret, or allowInsecureDefaults override is supplied.
|
||||
default_render_status=0
|
||||
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"product_commit": "3333333333333333333333333333333333333333",
|
||||
"version": "9.9.9-fixture",
|
||||
"release_profile": "stable",
|
||||
"features": [
|
||||
"pyroscope"
|
||||
],
|
||||
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
|
||||
"validation_batch": {
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2
|
||||
},
|
||||
"deliverables": [
|
||||
{
|
||||
"kind": "zip",
|
||||
"name": "rustfs-linux-x86_64-gnu-v9.9.9-fixture.zip",
|
||||
"location": "https://example.invalid/artifacts/runs/fixture-batch-1/2/rustfs-linux-x86_64-gnu-v9.9.9-fixture.zip",
|
||||
"sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
|
||||
"size": 36,
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa"
|
||||
}
|
||||
],
|
||||
"image": {
|
||||
"ref": "example.invalid/rustfs:9.9.9-fixture",
|
||||
"digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555",
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"status": "completed"
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"evidence_profile": "release-artifact",
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
|
||||
"tier": "release-certification",
|
||||
"lane": "identity",
|
||||
"case_id": "artifact-identity",
|
||||
"product_commit": "3333333333333333333333333333333333333333",
|
||||
"harness_commit": "1111111111111111111111111111111111111111",
|
||||
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"arch": "aarch64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
"started_at": "2026-09-26T10:02:00Z",
|
||||
"finished_at": "2026-09-26T10:02:30Z",
|
||||
"state": "pass",
|
||||
"reason_code": "ok",
|
||||
"reason_class": "NOT_APPLICABLE",
|
||||
"evidence": [
|
||||
{
|
||||
"path": "cases/identity/artifact-identity-linux-aarch64-gnu.json",
|
||||
"sha256": "fa138d5891e5098b97137b0edbb4779c4350285fdc76cec7e6214809b34b3075",
|
||||
"size": 64
|
||||
}
|
||||
],
|
||||
"workflow": {
|
||||
"repository": "rustfs/s3chaos",
|
||||
"workflow_id": "release-gate.yml",
|
||||
"run_id": "child-identity-linux-aarch64-gnu",
|
||||
"run_attempt": 1,
|
||||
"workflow_sha": "1111111111111111111111111111111111111111"
|
||||
},
|
||||
"artifact": {
|
||||
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
|
||||
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"evidence_profile": "release-artifact",
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
|
||||
"tier": "release-certification",
|
||||
"lane": "identity",
|
||||
"case_id": "startup",
|
||||
"product_commit": "3333333333333333333333333333333333333333",
|
||||
"harness_commit": "1111111111111111111111111111111111111111",
|
||||
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"arch": "aarch64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
"started_at": "2026-09-26T10:03:00Z",
|
||||
"finished_at": "2026-09-26T10:03:30Z",
|
||||
"state": "pass",
|
||||
"reason_code": "ok",
|
||||
"reason_class": "NOT_APPLICABLE",
|
||||
"evidence": [
|
||||
{
|
||||
"path": "cases/identity/startup-linux-aarch64-gnu.json",
|
||||
"sha256": "314b19c4fee5f406cbf7ee0d6a436059115f1ff06f6864830045f9d452ab91c9",
|
||||
"size": 64
|
||||
}
|
||||
],
|
||||
"workflow": {
|
||||
"repository": "rustfs/s3chaos",
|
||||
"workflow_id": "release-gate.yml",
|
||||
"run_id": "child-identity-linux-aarch64-gnu",
|
||||
"run_attempt": 1,
|
||||
"workflow_sha": "1111111111111111111111111111111111111111"
|
||||
},
|
||||
"artifact": {
|
||||
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
|
||||
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"evidence_profile": "release-artifact",
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
|
||||
"tier": "release-certification",
|
||||
"lane": "identity",
|
||||
"case_id": "artifact-identity",
|
||||
"product_commit": "3333333333333333333333333333333333333333",
|
||||
"harness_commit": "1111111111111111111111111111111111111111",
|
||||
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
"started_at": "2026-09-26T10:00:00Z",
|
||||
"finished_at": "2026-09-26T10:00:30Z",
|
||||
"state": "pass",
|
||||
"reason_code": "ok",
|
||||
"reason_class": "NOT_APPLICABLE",
|
||||
"evidence": [
|
||||
{
|
||||
"path": "cases/identity/artifact-identity-linux-x86_64-gnu.json",
|
||||
"sha256": "37e58ec3fc6fe3ac7ff2fcbeebdb62f6a6aaaa34ec771a0f3306c821da0336f7",
|
||||
"size": 64
|
||||
}
|
||||
],
|
||||
"workflow": {
|
||||
"repository": "rustfs/s3chaos",
|
||||
"workflow_id": "release-gate.yml",
|
||||
"run_id": "child-identity-linux-x86_64-gnu",
|
||||
"run_attempt": 1,
|
||||
"workflow_sha": "1111111111111111111111111111111111111111"
|
||||
},
|
||||
"artifact": {
|
||||
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
|
||||
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"evidence_profile": "release-artifact",
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
|
||||
"tier": "release-certification",
|
||||
"lane": "identity",
|
||||
"case_id": "startup",
|
||||
"product_commit": "3333333333333333333333333333333333333333",
|
||||
"harness_commit": "1111111111111111111111111111111111111111",
|
||||
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
"started_at": "2026-09-26T10:01:00Z",
|
||||
"finished_at": "2026-09-26T10:01:30Z",
|
||||
"state": "infra_error",
|
||||
"reason_code": "child-cancelled",
|
||||
"reason_class": "ENV",
|
||||
"evidence": [
|
||||
{
|
||||
"path": "cases/identity/startup-linux-x86_64-gnu.json",
|
||||
"sha256": "8b8774175c55c400b337610a89fe725ad2637af8fcc4973a82ab218f9182b80d",
|
||||
"size": 64
|
||||
}
|
||||
],
|
||||
"workflow": {
|
||||
"repository": "rustfs/s3chaos",
|
||||
"workflow_id": "release-gate.yml",
|
||||
"run_id": "child-identity-linux-x86_64-gnu",
|
||||
"run_attempt": 1,
|
||||
"workflow_sha": "1111111111111111111111111111111111111111",
|
||||
"status": "cancelled"
|
||||
},
|
||||
"artifact": {
|
||||
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
|
||||
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"evidence_profile": "release-artifact",
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
|
||||
"tier": "release-certification",
|
||||
"lane": "upgrade",
|
||||
"case_id": "upgrade-smoke",
|
||||
"product_commit": "3333333333333333333333333333333333333333",
|
||||
"harness_commit": "2222222222222222222222222222222222222222",
|
||||
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
"started_at": "2026-09-26T10:04:00Z",
|
||||
"finished_at": "2026-09-26T10:04:30Z",
|
||||
"state": "pass",
|
||||
"reason_code": "ok",
|
||||
"reason_class": "NOT_APPLICABLE",
|
||||
"evidence": [
|
||||
{
|
||||
"path": "cases/upgrade/upgrade-smoke-linux-x86_64-gnu.json",
|
||||
"sha256": "c1ce56808b52859d2590b34b5990aed7bf560b134e44bd0c09f1873c9f764b38",
|
||||
"size": 64
|
||||
}
|
||||
],
|
||||
"workflow": {
|
||||
"repository": "rustfs/s3chaos",
|
||||
"workflow_id": "release-gate.yml",
|
||||
"run_id": "child-upgrade-linux-x86_64-gnu",
|
||||
"run_attempt": 1,
|
||||
"workflow_sha": "2222222222222222222222222222222222222222"
|
||||
},
|
||||
"artifact": {
|
||||
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
|
||||
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"status": "completed"
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"evidence_profile": "release-artifact",
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
|
||||
"tier": "release-certification",
|
||||
"lane": "identity",
|
||||
"case_id": "artifact-identity",
|
||||
"product_commit": "3333333333333333333333333333333333333333",
|
||||
"harness_commit": "1111111111111111111111111111111111111111",
|
||||
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"arch": "aarch64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
"started_at": "2026-09-26T10:02:00Z",
|
||||
"finished_at": "2026-09-26T10:02:30Z",
|
||||
"state": "pass",
|
||||
"reason_code": "ok",
|
||||
"reason_class": "NOT_APPLICABLE",
|
||||
"evidence": [
|
||||
{
|
||||
"path": "cases/identity/artifact-identity-linux-aarch64-gnu.json",
|
||||
"sha256": "fa138d5891e5098b97137b0edbb4779c4350285fdc76cec7e6214809b34b3075",
|
||||
"size": 64
|
||||
}
|
||||
],
|
||||
"workflow": {
|
||||
"repository": "rustfs/s3chaos",
|
||||
"workflow_id": "release-gate.yml",
|
||||
"run_id": "child-cross-identity-linux-aarch64-gnu-artifact-identity",
|
||||
"run_attempt": 1,
|
||||
"workflow_sha": "4444444444444444444444444444444444444444"
|
||||
},
|
||||
"artifact": {
|
||||
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
|
||||
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"evidence_profile": "release-artifact",
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
|
||||
"tier": "release-certification",
|
||||
"lane": "identity",
|
||||
"case_id": "startup",
|
||||
"product_commit": "3333333333333333333333333333333333333333",
|
||||
"harness_commit": "1111111111111111111111111111111111111111",
|
||||
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"arch": "aarch64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
"started_at": "2026-09-26T10:03:00Z",
|
||||
"finished_at": "2026-09-26T10:03:30Z",
|
||||
"state": "pass",
|
||||
"reason_code": "ok",
|
||||
"reason_class": "NOT_APPLICABLE",
|
||||
"evidence": [
|
||||
{
|
||||
"path": "cases/identity/startup-linux-aarch64-gnu.json",
|
||||
"sha256": "314b19c4fee5f406cbf7ee0d6a436059115f1ff06f6864830045f9d452ab91c9",
|
||||
"size": 64
|
||||
}
|
||||
],
|
||||
"workflow": {
|
||||
"repository": "rustfs/s3chaos",
|
||||
"workflow_id": "release-gate.yml",
|
||||
"run_id": "child-cross-identity-linux-aarch64-gnu-startup",
|
||||
"run_attempt": 1,
|
||||
"workflow_sha": "4444444444444444444444444444444444444444"
|
||||
},
|
||||
"artifact": {
|
||||
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
|
||||
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"evidence_profile": "release-artifact",
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
|
||||
"tier": "release-certification",
|
||||
"lane": "identity",
|
||||
"case_id": "artifact-identity",
|
||||
"product_commit": "3333333333333333333333333333333333333333",
|
||||
"harness_commit": "1111111111111111111111111111111111111111",
|
||||
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
"started_at": "2026-09-26T10:00:00Z",
|
||||
"finished_at": "2026-09-26T10:00:30Z",
|
||||
"state": "pass",
|
||||
"reason_code": "ok",
|
||||
"reason_class": "NOT_APPLICABLE",
|
||||
"evidence": [
|
||||
{
|
||||
"path": "cases/identity/artifact-identity-linux-x86_64-gnu.json",
|
||||
"sha256": "37e58ec3fc6fe3ac7ff2fcbeebdb62f6a6aaaa34ec771a0f3306c821da0336f7",
|
||||
"size": 64
|
||||
}
|
||||
],
|
||||
"workflow": {
|
||||
"repository": "rustfs/s3chaos",
|
||||
"workflow_id": "release-gate.yml",
|
||||
"run_id": "child-cross-identity-linux-x86_64-gnu-artifact-identity",
|
||||
"run_attempt": 1,
|
||||
"workflow_sha": "4444444444444444444444444444444444444444"
|
||||
},
|
||||
"artifact": {
|
||||
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
|
||||
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"evidence_profile": "release-artifact",
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
|
||||
"tier": "release-certification",
|
||||
"lane": "identity",
|
||||
"case_id": "startup",
|
||||
"product_commit": "3333333333333333333333333333333333333333",
|
||||
"harness_commit": "1111111111111111111111111111111111111111",
|
||||
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
"started_at": "2026-09-26T10:01:00Z",
|
||||
"finished_at": "2026-09-26T10:01:30Z",
|
||||
"state": "pass",
|
||||
"reason_code": "ok",
|
||||
"reason_class": "NOT_APPLICABLE",
|
||||
"evidence": [
|
||||
{
|
||||
"path": "cases/identity/startup-linux-x86_64-gnu.json",
|
||||
"sha256": "8b8774175c55c400b337610a89fe725ad2637af8fcc4973a82ab218f9182b80d",
|
||||
"size": 64
|
||||
}
|
||||
],
|
||||
"workflow": {
|
||||
"repository": "rustfs/s3chaos",
|
||||
"workflow_id": "release-gate.yml",
|
||||
"run_id": "child-cross-identity-linux-x86_64-gnu-startup",
|
||||
"run_attempt": 1,
|
||||
"workflow_sha": "4444444444444444444444444444444444444444"
|
||||
},
|
||||
"artifact": {
|
||||
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
|
||||
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"evidence_profile": "release-artifact",
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
|
||||
"tier": "release-certification",
|
||||
"lane": "upgrade",
|
||||
"case_id": "upgrade-smoke",
|
||||
"product_commit": "3333333333333333333333333333333333333333",
|
||||
"harness_commit": "2222222222222222222222222222222222222222",
|
||||
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
"started_at": "2026-09-26T10:04:00Z",
|
||||
"finished_at": "2026-09-26T10:04:30Z",
|
||||
"state": "pass",
|
||||
"reason_code": "ok",
|
||||
"reason_class": "NOT_APPLICABLE",
|
||||
"evidence": [
|
||||
{
|
||||
"path": "cases/upgrade/upgrade-smoke-linux-x86_64-gnu.json",
|
||||
"sha256": "c1ce56808b52859d2590b34b5990aed7bf560b134e44bd0c09f1873c9f764b38",
|
||||
"size": 64
|
||||
}
|
||||
],
|
||||
"workflow": {
|
||||
"repository": "rustfs/s3chaos",
|
||||
"workflow_id": "release-gate.yml",
|
||||
"run_id": "child-cross-upgrade-linux-x86_64-gnu-upgrade-smoke",
|
||||
"run_attempt": 1,
|
||||
"workflow_sha": "4444444444444444444444444444444444444444"
|
||||
},
|
||||
"artifact": {
|
||||
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
|
||||
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"status": "completed"
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"evidence_profile": "release-artifact",
|
||||
"run_id": "fixture-batch-1",
|
||||
"run_attempt": 2,
|
||||
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
|
||||
"tier": "release-certification",
|
||||
"lane": "identity",
|
||||
"case_id": "artifact-identity",
|
||||
"product_commit": "3333333333333333333333333333333333333333",
|
||||
"harness_commit": "1111111111111111111111111111111111111111",
|
||||
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"arch": "aarch64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
"started_at": "2026-09-26T10:02:00Z",
|
||||
"finished_at": "2026-09-26T10:02:30Z",
|
||||
"state": "pass",
|
||||
"reason_code": "ok",
|
||||
"reason_class": "NOT_APPLICABLE",
|
||||
"evidence": [
|
||||
{
|
||||
"path": "cases/identity/artifact-identity-linux-aarch64-gnu.json",
|
||||
"sha256": "fa138d5891e5098b97137b0edbb4779c4350285fdc76cec7e6214809b34b3075",
|
||||
"size": 64
|
||||
}
|
||||
],
|
||||
"workflow": {
|
||||
"repository": "rustfs/s3chaos",
|
||||
"workflow_id": "release-gate.yml",
|
||||
"run_id": "child-identity-linux-aarch64-gnu",
|
||||
"run_attempt": 1,
|
||||
"workflow_sha": "1111111111111111111111111111111111111111"
|
||||
},
|
||||
"artifact": {
|
||||
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
|
||||
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
|
||||
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user