Compare commits

...

53 Commits

Author SHA1 Message Date
cxymds 9d90884c30 fix(heal): query automatic tasks on their owning node 2026-10-23 04:41:01 +08:00
cxymds 7ed6c9320d test(scanner): reuse storage boundary for scope regression 2026-10-23 02:00:08 +08:00
cxymds 53a5d11743 fix(scanner): bind object heals to the scanning disk scope 2026-10-23 01:28:51 +08:00
cxymds 757b751482 test(ecstore): cover default heal owners and copy metadata races 2026-10-20 11:32:20 +08:00
cxymds 09fb813a00 test(ecstore): fence multipart continue complete and abort during heal 2026-10-20 10:06:54 +08:00
cxymds 738e5fa14a ci(validation): require G04 native and runtime admission 2026-10-20 09:31:57 +08:00
cxymds 8380466f3a test(ecstore): fence marker and batch deletes during heal 2026-10-20 09:26:19 +08:00
cxymds 170ec8d983 ci(validation): require complete race native runtime preflight 2026-10-20 08:59:50 +08:00
cxymds bd0671ba78 test(ecstore): fence version races and bind cleanup slots 2026-10-20 06:27:54 +08:00
cxymds f954164048 ci(validation): admit source-bound G01 native evidence before fleet execution 2026-10-20 04:35:09 +08:00
cxymds 6afdec9fe1 test(ecstore): exercise heal during committed overwrite cleanup 2026-10-20 04:07:05 +08:00
cxymds 87bd36b313 merge: sync validation fixes with current main 2026-10-20 03:35:22 +08:00
cxymds a4c02d5ac3 fix(filemeta): classify supported inline framing damage as corrupt 2026-10-20 01:59:26 +08:00
cxymds 62ff96774f fix(s3): map typed RPC unavailability to service refusal 2026-10-20 00:34:17 +08:00
cxymds 85c706cac0 fix(observability): retain typed object error diagnostics 2026-10-19 22:13:24 +08:00
cxymds 7951bb6298 fix(heal): retain exhausted admin batch repair receipts 2026-10-16 15:33:15 +08:00
cxymds a3230599a8 fix(ci): allow recovery of completed cancelled validation runs 2026-10-15 19:19:31 +08:00
cxymds 1c7fc818dd fix(ecstore): retain below-quorum data during no-remove heal 2026-10-13 02:02:13 +08:00
cxymds 5475bd06bc chore(ecstore): shrink typed-error ratchet baseline 2026-10-13 01:07:12 +08:00
cxymds a3ba311ab0 fix(ecstore): retain typed quorum errors for missing parts 2026-10-13 00:59:50 +08:00
cxymds e517d35fa2 test(ci): archive scoped F07 directory fsync evidence 2026-10-12 23:47:15 +08:00
cxymds 1b7a197267 fix(ci): isolate validation package publication credentials 2026-10-12 23:30:45 +08:00
Chris 3b416b76cd Merge branch 'main' into cxymds/validation-runner 2026-10-12 06:18:15 -07:00
cxymds bebee3d5d0 ci(validation): verify C11 native storage evidence 2026-10-12 19:07:12 +08:00
cxymds 1aa9be87d1 test(ecstore): verify incarnation fence at repair commit 2026-10-12 18:38:26 +08:00
cxymds e224eb48e5 fix(heal): deeply verify read repair payload failures 2026-10-12 17:40:36 +08:00
cxymds 16cf19817e ci(validation): dispatch isolated packages through the driver 2026-10-12 12:29:11 +08:00
cxymds 32fd1e704a test(validation): verify isolated package publication failures 2026-10-12 12:22:53 +08:00
cxymds ac197ea655 test(validation): enforce private candidate manifest schema 2026-10-12 12:18:15 +08:00
cxymds e18ed73942 ci(validation): publish isolated immutable candidate packages 2026-10-12 12:06:59 +08:00
cxymds 3c131f2b2e fix(heal): preserve typed errors and SSE-C response headers 2026-10-12 11:36:29 +08:00
cxymds c5f4b5a4f7 fix(heal): preserve typed bucket errors for bounded retries 2026-10-12 10:45:27 +08:00
cxymds 03fb3d78a0 ci(validation): consume pinned manager native prerequisites 2026-10-11 11:55:36 +08:00
cxymds b8126e50f7 test(ci): expose manager regressions through tools-only dispatch 2026-10-11 11:21:35 +08:00
cxymds 59fb72aa82 test(ci): add isolated manager native validation builds 2026-10-11 11:20:46 +08:00
cxymds 9de01c9170 docs(ci): describe supported validation recovery 2026-10-11 11:07:20 +08:00
cxymds 2ec26fa4b9 feat(ci): expose read-only validation fleet inventory 2026-10-11 10:42:48 +08:00
cxymds f4507178a3 test(ci): bind C10 execution to the verified shared codec 2026-10-11 10:09:26 +08:00
cxymds 9b4cc40abd test(validation): build optional C10 fixture codec on hosted Linux 2026-10-11 09:44:09 +08:00
cxymds b14ff4eeb1 test(validation): bind C07 to pinned native authority evidence 2026-10-11 09:27:59 +08:00
cxymds ae9b5cbabf Run validation tool resolution on hosted Linux without acquiring the fleet lock 2026-10-11 07:49:26 +08:00
cxymds 6c78b0d996 test(validation): route native tools through the existing driver 2026-10-11 06:06:17 +08:00
cxymds 0066e34764 test(validation): add optional native Heal regression tooling 2026-10-11 06:03:43 +08:00
cxymds fbddcc2965 ci: audit retained validation environments before finalization 2026-10-10 23:28:39 +08:00
cxymds b1793b55a0 test(validation): verify Heal tools against released shard captures 2026-10-10 21:11:43 +08:00
cxymds 835bd3f52d test(validation): verify pinned Heal tools before fleet execution 2026-10-10 20:46:40 +08:00
cxymds cb32d55878 test(validation): expose tool artifacts through the existing manual lane 2026-10-10 20:35:14 +08:00
cxymds 864e58592c test(validation): build the candidate-bound Heal inspector artifact 2026-10-10 20:32:34 +08:00
cxymds 3016209330 test(validation): provide the Python client for Heal cases 2026-10-10 20:30:10 +08:00
cxymds 64e0eeba91 Install validation client for replication cases 2026-10-10 18:52:42 +08:00
cxymds 35fa7488a5 Prepare Python client for pool validation cases 2026-10-10 18:41:48 +08:00
cxymds 1b77846f8e Install pinned ILM client for canonical validation lane 2026-10-10 18:26:49 +08:00
cxymds b0c861841e test(validation): add pinned auto-testing manual lane 2026-10-10 17:34:37 +08:00
42 changed files with 5680 additions and 124 deletions
+436 -5
View File
@@ -20,13 +20,65 @@ on:
workflow_dispatch:
inputs:
build_run_id:
description: Successful nightly build run on main
description: Successful nightly or validation package run; empty for package-only build
type: string
required: true
required: false
default: ''
build_run_attempt:
description: Exact successful build attempt
type: string
required: true
required: false
default: ''
validation_package_sha:
description: Exact source SHA for an isolated package-only build
type: string
required: false
default: ''
validation_case:
description: Canonical case ID, environment.inspect for read-only resources, or empty for functional chain
type: string
required: false
default: ''
auto_testing_sha:
description: Exact auto-testing commit for a validation case
type: string
required: false
default: ''
validation_tools_only:
description: Build candidate-bound validation tools without running a fleet case
type: boolean
required: false
default: false
validation_native_tools:
description: Include exact-source native regressions in a tools-only build
type: boolean
required: false
default: false
validation_shared_tools:
description: Include the controlled C10 shared-reference encoder in a tools-only build
type: boolean
required: false
default: false
validation_manager_tools:
description: Include exact checkpoint/MRF/incarnation regressions in a tools-only build
type: boolean
required: false
default: false
validation_cleanup_run_id:
description: Failed validation run whose supported retained environment needs evidence-based finalization
type: string
required: false
default: ''
validation_tools_run_id:
description: Successful tool build run; required by Heal physical metadata checks
type: string
required: false
default: ''
validation_tools_run_attempt:
description: Exact tool build attempt
type: string
required: false
default: '1'
workflow_run:
workflows: ["Nightly GNU Build"]
types: [completed]
@@ -36,12 +88,47 @@ permissions:
actions: read
concurrency:
group: rustfs-functional-chain-runs
group: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' && format('rustfs-functional-validation-package-{0}', inputs.validation_package_sha) || github.event_name == 'workflow_dispatch' && inputs.validation_tools_only && 'rustfs-functional-validation-tools' || 'rustfs-functional-chain-runs' }}
cancel-in-progress: false
jobs:
validation-package-request:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate exclusive package-only request
env:
PACKAGE_INPUTS: ${{ toJSON(inputs) }}
run: |
python3 - <<'PY'
import json
import os
import re
value = json.loads(os.environ["PACKAGE_INPUTS"])
if re.fullmatch(r"[0-9a-f]{40}", value["validation_package_sha"]) is None:
raise ValueError("package build requires an exact source SHA")
exclusive = ("build_run_id", "build_run_attempt", "validation_case", "auto_testing_sha",
"validation_tools_only", "validation_native_tools", "validation_shared_tools",
"validation_manager_tools", "validation_cleanup_run_id", "validation_tools_run_id")
if any(value.get(name) for name in exclusive):
raise ValueError("package-only build cannot select existing candidate, tools, fleet or recovery execution")
PY
validation-package:
needs: validation-package-request
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' }}
uses: ./.github/workflows/rustfs-validation-package.yml
with:
source_sha: ${{ inputs.validation_package_sha }}
secrets:
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 }}
prepare:
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
if: ${{ (github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && inputs.validation_case == '' && !inputs.validation_tools_only && !inputs.validation_native_tools && !inputs.validation_shared_tools && !inputs.validation_manager_tools && inputs.validation_cleanup_run_id == '') || github.event.workflow_run.event == 'schedule' }}
runs-on: smoke-testing
timeout-minutes: 10
outputs:
@@ -89,6 +176,350 @@ jobs:
echo "performance_ready=offline" >> "$GITHUB_OUTPUT"
fi
validation:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && (inputs.validation_case != '' || inputs.validation_tools_only || inputs.validation_native_tools || inputs.validation_shared_tools || inputs.validation_manager_tools || inputs.validation_cleanup_run_id != '') }}
runs-on: ${{ inputs.validation_tools_only && 'ubuntu-latest' || 'smoke-testing' }}
timeout-minutes: 90
outputs:
source_sha: ${{ steps.candidate.outputs.source_sha }}
concurrency:
group: ${{ inputs.validation_tools_only && 'rustfs-validation-tools-resolver' || 'rustfs-shared-functional-tests-v2' }}
cancel-in-progress: false
env:
RUSTFS_ACCESS_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_ACCESS_KEY || '' }}
RUSTFS_SECRET_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_SECRET_KEY || '' }}
RUSTFS_API_ENDPOINT: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT) || '' }}
RUSTFS_NODES: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_NODES || vars.RUSTFS_NODES) || '' }}
RUSTFS_SSH_USER: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER) || '' }}
steps:
- name: Checkout candidate resolver
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Initialize validation evidence
run: |
set -euo pipefail
umask 077
VALIDATION_ROOT="${RUNNER_TEMP}/rustfs-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${VALIDATION_ROOT}"
printf 'VALIDATION_ROOT=%s\n' "${VALIDATION_ROOT}" >> "${GITHUB_ENV}"
- name: Resolve immutable candidate
id: candidate
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
BUILD_RUN_ID: ${{ inputs.build_run_id }}
BUILD_RUN_ATTEMPT: ${{ inputs.build_run_attempt }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
CLEANUP_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
SELECTED_CASE: ${{ inputs.validation_case }}
TOOLS_ONLY: ${{ inputs.validation_tools_only }}
NATIVE_TOOLS: ${{ inputs.validation_native_tools }}
SHARED_TOOLS: ${{ inputs.validation_shared_tools }}
MANAGER_TOOLS: ${{ inputs.validation_manager_tools }}
run: |
set -euo pipefail
python3 - <<'PY'
import json
import os
from pathlib import Path
import re
from scripts.resolve_functional_candidate import resolve
revision = os.environ["AUTO_TESTING_SHA"]
cleanup = os.environ["CLEANUP_RUN_ID"]
if os.environ["TOOLS_ONLY"] == "true" and (cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("tools-only execution cannot select a fleet case or cleanup")
if os.environ["NATIVE_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("native regressions require tools-only execution, without a fleet case or cleanup")
if os.environ["SHARED_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("shared fixture builds require tools-only execution, without a fleet case or cleanup")
if os.environ["MANAGER_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("manager regressions require tools-only execution, without a fleet case or cleanup")
if cleanup and (not cleanup.isdigit() or int(cleanup) <= 0 or os.environ["SELECTED_CASE"] or os.environ["TOOLS_ONLY"] == "true"):
raise ValueError("cleanup requires one exact failed run and is exclusive with case/tool execution")
if re.fullmatch(r"[0-9a-f]{40}", revision) is None:
raise ValueError("validation requires an exact auto-testing commit SHA")
candidate = resolve(int(os.environ["BUILD_RUN_ID"]), int(os.environ["BUILD_RUN_ATTEMPT"]), allow_validation=True)
manifest = candidate["manifest"]
root = Path(os.environ["VALIDATION_ROOT"])
(root / "candidate-source.json").write_text(json.dumps(candidate, sort_keys=True) + "\n")
(root / "candidate.json").write_text(json.dumps({
"package_url": manifest["package_url"],
"package_sha256": manifest["package_sha256"],
"source_sha": manifest["source_sha"],
}, sort_keys=True) + "\n")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write("source_sha=" + manifest["source_sha"] + "\n")
PY
- name: Checkout exact auto-testing revision
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install validation Python client
if: ${{ !inputs.validation_tools_only && (startsWith(inputs.validation_case, 'ilm.') || startsWith(inputs.validation_case, 'pool.') || startsWith(inputs.validation_case, 'bucket-replication.') || startsWith(inputs.validation_case, 'site-replication.') || startsWith(inputs.validation_case, 'heal.')) }}
run: |
set -euo pipefail
python3 -m venv "${RUNNER_TEMP}/rustfs-validation-python"
"${RUNNER_TEMP}/rustfs-validation-python/bin/python" -m pip install \
-r auto-testing/scripts/validation_ilm/requirements.txt
printf '%s\n' "${RUNNER_TEMP}/rustfs-validation-python/bin" >> "${GITHUB_PATH}"
- name: Validate Heal tool build identity
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
env:
GH_TOKEN: ${{ github.token }}
TOOL_RUN_ID: ${{ inputs.validation_tools_run_id }}
TOOL_RUN_ATTEMPT: ${{ inputs.validation_tools_run_attempt }}
run: |
set -euo pipefail
[[ "${TOOL_RUN_ID}" =~ ^[1-9][0-9]*$ ]]
[[ "${TOOL_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${TOOL_RUN_ID}/attempts/${TOOL_RUN_ATTEMPT}" \
> "${VALIDATION_ROOT}/tool-run.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
value = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "tool-run.json").read_text())
if (value.get("conclusion") != "success" or value.get("status") != "completed"
or value.get("run_attempt") != int(os.environ["TOOL_RUN_ATTEMPT"])
or value.get("path") not in {".github/workflows/rustfs-functional-chain.yml", ".github/workflows/rustfs-validation-tools.yml"}):
raise ValueError("Heal tool run is not an exact successful validation build attempt")
PY
- name: Download pinned Heal metadata inspector
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-tools-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify Heal metadata inspector
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
run: |
set -euo pipefail
TOOL_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-tools-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
python3 -B auto-testing/scripts/validation_heal/inspector.py \
--directory "${TOOL_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/tool-manifest.json"
printf 'VALIDATION_HEAL_TOOL_DIRECTORY=%s\n' "${TOOL_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Select exact-source race native evidence
id: race_native
if: ${{ inputs.validation_case == 'heal.race-unversioned-overwrite' || inputs.validation_case == 'heal.race-versioned-put' || inputs.validation_case == 'heal.race-version-delete' || inputs.validation_case == 'heal.race-markers-and-batch-delete' }}
env:
CANDIDATE_SHA: ${{ steps.candidate.outputs.source_sha }}
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
run: |
set -euo pipefail
required=false
if [ -f auto-testing/scripts/validation_heal/race_native.py ]; then
status=0
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
--commit "${CANDIDATE_SHA}" --case-id "${VALIDATION_CASE_ID}" || status=$?
if [ "${status}" -eq 0 ]; then
required=true
elif [ "${status}" -ne 2 ]; then
exit "${status}"
fi
fi
reviewed=false
case "${CANDIDATE_SHA}:${VALIDATION_CASE_ID}" in
6afdec9fe1b0b88007f2e5b445c19bf73d51d3ca:heal.race-unversioned-overwrite | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-unversioned-overwrite | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-versioned-put | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-version-delete | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-unversioned-overwrite | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-versioned-put | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-version-delete | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-markers-and-batch-delete) reviewed=true ;;
esac
if [ "${reviewed}" = true ] && [ "${required}" != true ]; then
printf 'Race candidate requires its reviewed native collector before fleet execution\n' >&2
exit 1
fi
printf 'required=%s\n' "${required}" >> "${GITHUB_OUTPUT}"
- name: Download pinned native storage evidence
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || steps.race_native.outputs.required == 'true' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify C07 native source and exact test evidence
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/authority.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/native-authority-manifest.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify C11 physical storage and exact final-rename barrier
if: ${{ inputs.validation_case == 'heal.bucket-incarnation-recreation' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/incarnation_native.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${{ inputs.auto_testing_sha }}" \
> "${VALIDATION_ROOT}/native-incarnation-manifest.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify exact-source race native and runtime evidence
if: ${{ steps.race_native.outputs.required == 'true' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/race_native_evidence.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --case-id "${VALIDATION_CASE_ID}" \
> "${VALIDATION_ROOT}/native-race-manifest.json"
VALIDATION_HEAL_NATIVE_DIRECTORY="${NATIVE_DIRECTORY}" \
python3 -B auto-testing/scripts/validation_heal/race.py preflight \
--candidate "${VALIDATION_ROOT}/candidate.json" --testing-commit "${AUTO_TESTING_SHA}" \
--case-id "${VALIDATION_CASE_ID}" \
> "${VALIDATION_ROOT}/native-race-runtime-preflight.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Download pinned manager persistence and incarnation evidence
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-manager-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify exact manager native prerequisites before fleet mutation
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
env:
VALIDATION_CASE: ${{ inputs.validation_case }}
run: |
set -euo pipefail
MANAGER_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-manager-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${MANAGER_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/manager_fixture.py \
--directory "${MANAGER_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--case-id "${VALIDATION_CASE}" > "${VALIDATION_ROOT}/manager-prerequisite-manifest.json"
printf 'VALIDATION_HEAL_MANAGER_DIRECTORY=%s\n' "${MANAGER_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Download pinned C10 shared-reference encoder
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-shared-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify C10 exact-source codec and original tests
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
run: |
set -euo pipefail
SHARED_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-shared-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${SHARED_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/shared_fixture.py \
--directory "${SHARED_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/shared-fixture-manifest.json"
printf 'VALIDATION_HEAL_SHARED_DIRECTORY=%s\n' "${SHARED_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Run selected canonical validation case
if: ${{ !inputs.validation_tools_only && inputs.validation_cleanup_run_id == '' && inputs.validation_case != 'environment.inspect' }}
env:
VALIDATION_CASE: ${{ inputs.validation_case }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
./auto-testing/scripts/run_validation_case.sh \
"${VALIDATION_CASE}" "${VALIDATION_ROOT}/candidate.json" \
"${AUTO_TESTING_SHA}" "${VALIDATION_ROOT}/run"
- name: Inspect current Heal fleet resources without mutation
if: ${{ !inputs.validation_tools_only && inputs.validation_case == 'environment.inspect' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/environment.py \
--candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --evidence "${VALIDATION_ROOT}/environment"
- name: Audit and finalize retained validation environment
if: ${{ inputs.validation_cleanup_run_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
FAILED_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
gh run view "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
--json status,conclusion,headBranch,workflowName > "${VALIDATION_ROOT}/failed-run.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
run = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "failed-run.json").read_text())
if (run["status"] != "completed" or run["conclusion"] not in {"failure", "cancelled"}
or run["workflowName"] != "RustFS Functional Chain" or run["headBranch"] != os.environ["GITHUB_REF_NAME"]):
raise ValueError("cleanup source must be a completed failed or cancelled Functional Chain run")
PY
gh run download "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
--pattern "rustfs-validation-${FAILED_RUN_ID}-*" --dir "${VALIDATION_ROOT}/failed-artifact"
python3 -B auto-testing/scripts/validation_heal/recover_environment.py \
--failed-run-id "${FAILED_RUN_ID}" --artifact "${VALIDATION_ROOT}/failed-artifact" \
--candidate "${VALIDATION_ROOT}/candidate.json" --evidence "${VALIDATION_ROOT}/recovery"
- name: Show validation report
if: ${{ always() }}
run: |
if [ -f "${VALIDATION_ROOT}/run/report.md" ]; then
cat "${VALIDATION_ROOT}/run/report.md" >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Upload validation evidence
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-${{ github.run_id }}-${{ github.run_attempt }}
if-no-files-found: error
validation-tools:
needs: [validation]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && inputs.validation_tools_only }}
uses: ./.github/workflows/rustfs-validation-tools.yml
with:
candidate_sha: ${{ needs.validation.outputs.source_sha }}
auto_testing_sha: ${{ inputs.auto_testing_sha }}
build_native_heal: ${{ inputs.validation_native_tools }}
build_shared_heal: ${{ inputs.validation_shared_tools }}
build_manager_native: ${{ inputs.validation_manager_tools }}
secrets: inherit
upgrade:
needs: [prepare]
if: ${{ always() && needs.prepare.result == 'success' }}
@@ -0,0 +1,244 @@
# 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.
name: RustFS Validation Package
on:
workflow_call:
inputs:
source_sha:
description: Exact RustFS commit SHA to build
required: true
type: string
secrets:
R2_ACCESS_KEY_ID:
required: true
R2_SECRET_ACCESS_KEY:
required: true
R2_ENDPOINT:
required: true
R2_BUCKET:
required: true
workflow_dispatch:
inputs:
source_sha:
description: Exact RustFS commit SHA to build
required: true
type: string
permissions:
contents: read
concurrency:
group: rustfs-validation-package-${{ inputs.source_sha }}
cancel-in-progress: false
jobs:
resolve-source:
if: github.repository == 'rustfs/rustfs'
name: Resolve exact validation source
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
source_sha: ${{ steps.source.outputs.sha }}
source_ref: ${{ steps.source.outputs.ref }}
steps:
- name: Validate requested source
id: source
env:
REQUESTED_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
[[ "${REQUESTED_SHA}" =~ ^[0-9a-f]{40}$ ]]
source_directory="$(mktemp -d "${RUNNER_TEMP}/validation-source.XXXXXX")"
trap 'rm -rf "${source_directory}"' EXIT
git init -q "${source_directory}"
git -C "${source_directory}" fetch --no-tags --depth=1 https://github.com/rustfs/rustfs.git "${REQUESTED_SHA}"
test "$(git -C "${source_directory}" rev-parse FETCH_HEAD)" = "${REQUESTED_SHA}"
echo "sha=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
echo "ref=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
build:
needs: resolve-source
name: Build immutable validation DEB
runs-on: ubuntu-latest
timeout-minutes: 150
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
steps:
- name: Checkout exact source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ needs.resolve-source.outputs.source_sha }}
- name: Confirm checked out source
env:
EXPECTED_SHA: ${{ needs.resolve-source.outputs.source_sha }}
run: test "$(git rev-parse HEAD)" = "${EXPECTED_SHA}"
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: validation-package-hosted-x86_64-unknown-linux-gnu
cache-save-if: "false"
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Build RustFS
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
- name: Build validation DEB
shell: bash
run: |
set -euo pipefail
command -v fakeroot >/dev/null 2>&1 || sudo apt-get install -y -qq fakeroot
package_dir="rustfs-validation-${VALIDATION_SOURCE_SHA}"
package_file="${RUNNER_TEMP}/validation-package/rustfs.deb"
mkdir -p "${RUNNER_TEMP}/validation-package"
binary="target/x86_64-unknown-linux-gnu/release/rustfs"
test -x "${binary}"
mkdir -p "${package_dir}/DEBIAN" "${package_dir}/usr/bin" "${package_dir}/etc/default" \
"${package_dir}/lib/systemd/system" "${package_dir}/usr/share/doc/rustfs"
cp "${binary}" "${package_dir}/usr/bin/rustfs"
chmod 755 "${package_dir}/usr/bin/rustfs"
cp deploy/build/rustfs.service "${package_dir}/lib/systemd/system/"
cat > "${package_dir}/etc/default/rustfs" <<'ENVEOF'
# RustFS Environment Configuration
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ADDRESS=":9000"
ENVEOF
cat > "${package_dir}/DEBIAN/control" <<'CONTROLEOF'
Package: rustfs
Version: 0.0.0~validation
Section: utils
Priority: optional
Architecture: amd64
Depends: libc6 (>= 2.31)
Maintainer: RustFS Team <support@rustfs.com>
Description: RustFS immutable validation package
Candidate package for RustFS validation only.
CONTROLEOF
printf '%s\n' /etc/default/rustfs > "${package_dir}/DEBIAN/conffiles"
./scripts/release/package_service_scripts.sh after-install > "${package_dir}/DEBIAN/postinst"
./scripts/release/package_service_scripts.sh before-remove > "${package_dir}/DEBIAN/prerm"
./scripts/release/package_service_scripts.sh after-remove > "${package_dir}/DEBIAN/postrm"
chmod 755 "${package_dir}/DEBIAN/postinst" "${package_dir}/DEBIAN/prerm" "${package_dir}/DEBIAN/postrm"
cp LICENSE README.md "${package_dir}/usr/share/doc/rustfs/"
fakeroot dpkg-deb --build "${package_dir}" "${package_file}"
test -s "${package_file}"
- name: Upload opaque validation package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-package/rustfs.deb
if-no-files-found: error
publish:
needs: [resolve-source, build]
name: Publish immutable validation DEB
# Candidate-controlled build scripts never run on this fresh hosted runner.
runs-on: ubuntu-latest
timeout-minutes: 20
env:
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
VALIDATION_SOURCE_REF: ${{ needs.resolve-source.outputs.source_ref }}
steps:
- name: Download opaque validation package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-package-input
- name: Validate and stage opaque package bytes
shell: bash
run: |
set -euo pipefail
python3 -I - <<'PYTHON'
import os
from pathlib import Path
import shutil
import stat
root = Path(os.environ["RUNNER_TEMP"])
incoming = root / "validation-package-input"
if incoming.is_symlink() or not incoming.is_dir() or sorted(p.name for p in incoming.iterdir()) != ["rustfs.deb"]:
raise SystemExit("Validation artifact must contain exactly one rustfs.deb file")
fd = os.open(incoming / "rustfs.deb", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as source:
info = os.fstat(source.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size == 0:
raise SystemExit("Validation package must be a nonempty regular file")
staged = root / "validation-publication"
staged.mkdir(mode=0o700)
with (staged / "rustfs.deb").open("xb") as target:
shutil.copyfileobj(source, target)
PYTHON
- name: Prepare trusted publication tools
id: tools
env:
AWS_EC2_METADATA_DISABLED: true
shell: bash
run: |
set -euo pipefail
AWS_CLI=aws
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
sudo apt-get update
sudo apt-get install -y -qq python3-venv
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/validation-awscli.XXXXXX")"
python3 -m venv "${AWS_CLI_DIR}"
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
fi
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
echo "aws_cli=${AWS_CLI}" >> "${GITHUB_OUTPUT}"
- name: Upload validation package to immutable R2 path
id: publish
env:
PACKAGE_FILE: ${{ runner.temp }}/validation-publication/rustfs.deb
AWS_CLI: ${{ steps.tools.outputs.aws_cli }}
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
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 "Validation package publication requires complete R2 configuration" >&2
exit 1
fi
[[ "${VALIDATION_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]
test "${VALIDATION_SOURCE_REF}" = "${VALIDATION_SOURCE_SHA}"
test -s "${PACKAGE_FILE}" && test ! -L "${PACKAGE_FILE}"
export AWS_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID}" AWS_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY}" AWS_DEFAULT_REGION=auto
package_sha256="$(sha256sum "${PACKAGE_FILE}" | cut -d ' ' -f 1)"
key="artifacts/rustfs/packages/validation/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${package_sha256}/rustfs.deb"
url="https://dl.rustfs.com/${key}"
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${key}" --body "${PACKAGE_FILE}" \
--if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
downloaded_sha256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${url}" | sha256sum | cut -d ' ' -f 1)"
test "${downloaded_sha256}" = "${package_sha256}"
manifest="${RUNNER_TEMP}/validation-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
jq -n --arg workflow_sha "${GITHUB_SHA}" --arg source_ref "${VALIDATION_SOURCE_REF}" \
--arg source_sha "${VALIDATION_SOURCE_SHA}" --arg channel validation \
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
--arg package_url "${url}" --arg package_sha256 "${package_sha256}" \
'{schema: 3, workflow_sha: $workflow_sha, source_ref: $source_ref, source_sha: $source_sha, channel: $channel, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' > "${manifest}"
echo "candidate_file=${manifest}" >> "${GITHUB_OUTPUT}"
- name: Upload validation candidate manifest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.publish.outputs.candidate_file }}
if-no-files-found: error
@@ -0,0 +1,343 @@
# Build source-bound helpers separately so live validation needs only artifacts.
name: RustFS Validation Tools
on:
workflow_call:
inputs:
candidate_sha:
type: string
required: true
auto_testing_sha:
type: string
required: true
build_native_heal:
type: boolean
required: false
default: false
build_shared_heal:
type: boolean
required: false
default: false
build_manager_native:
type: boolean
required: false
default: false
secrets:
PF_TESTING_GH_TOKEN:
required: true
workflow_dispatch:
inputs:
candidate_sha:
description: Exact RustFS source SHA from the validation candidate manifest
type: string
required: true
auto_testing_sha:
description: Exact auto-testing SHA containing the Heal metadata inspector
type: string
required: true
build_native_heal:
description: Also compile and run separate exact-source native Heal regressions
type: boolean
required: false
default: false
build_shared_heal:
description: Build the controlled C10 shared-reference fixture encoder
type: boolean
required: false
default: false
build_manager_native:
description: Build and run exact candidate checkpoint/MRF/incarnation regressions
type: boolean
required: false
default: false
permissions:
contents: read
jobs:
manager-native-regressions:
if: ${{ inputs.build_manager_native }}
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable manager native inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact manager candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact manager collector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install manager native build dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
df -h .
- name: Build exact unmodified candidate manager test executable
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/manager_native.py build \
--source candidate --commit "${CANDIDATE_SHA}" --out manager-tools
- name: Run exact manager regressions independently
run: |
set -euo pipefail
python3 -B auto-testing/scripts/validation_heal/manager_native.py run \
--directory manager-tools --out manager-evidence
- name: Upload manager executable identities and original diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
path: |
manager-tools/heal-manager-native-tests
manager-tools/*.json
manager-tools/*.jsonl
manager-tools/*.stderr
manager-tools/*.log
manager-evidence/
if-no-files-found: warn
native-heal-regressions:
if: ${{ inputs.build_native_heal }}
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable native source inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact native candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact native collector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install native build dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
df -h .
- name: Build and enumerate the unmodified candidate test executable
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
use_race_native=false
if [[ -f auto-testing/scripts/validation_heal/race_native.py ]]; then
support_rc=0
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
--commit "${CANDIDATE_SHA}" || support_rc=$?
case "${support_rc}" in
0) use_race_native=true ;;
2) ;; # The original exact-source collector handles legacy candidates.
*) exit "${support_rc}" ;;
esac
fi
if [[ "${use_race_native}" == true ]]; then
python3 -B auto-testing/scripts/validation_heal/race_native.py build \
--source candidate --commit "${CANDIDATE_SHA}" --testing-commit "${AUTO_TESTING_SHA}" --out native-tools
else
python3 -B auto-testing/scripts/validation_heal/native_regressions.py build \
--source candidate --commit "${CANDIDATE_SHA}" --out native-tools
fi
- name: Run exact native regressions separately from fleet validation
run: |
set -euo pipefail
if [[ -f native-tools/race-native.json ]]; then
test ! -e native-tools/native-regressions.json
python3 -B auto-testing/scripts/validation_heal/race_native.py run \
--directory native-tools --out native-evidence
else
python3 -B auto-testing/scripts/validation_heal/native_regressions.py run \
--directory native-tools --out native-evidence
fi
- name: Run existing F07 directory fsync failure regressions
if: ${{ inputs.candidate_sha == '1aa9be87d18cf941e5020f19e8622c26ba4b10ac' }}
run: |
set -euo pipefail
# Older immutable harness pins retain their original native suite;
# absence of this additive collector must never fabricate its proof.
if [[ ! -f auto-testing/scripts/validation_heal/persistence_native.py ]]; then
mkdir native-persistence-evidence
echo '{"complete":false,"reason":"pinned harness predates F07 directory-fsync collector","requiredNativeCoverageComplete":false,"canonicalCasePassClaimed":false}' \
> native-persistence-evidence/not-available.json
exit 0
fi
python3 -B auto-testing/scripts/validation_heal/persistence_native.py run \
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
--testing-commit "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/persistence_native.py verify \
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
--testing-commit "${AUTO_TESTING_SHA}"
- name: Upload native executable identities and original diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
path: |
native-tools/ecstore-native-tests
native-tools/ecstore-race-native-tests
native-tools/*.json
native-tools/*.jsonl
native-tools/*.stderr
native-tools/*.log
native-evidence/
native-persistence-evidence/
if-no-files-found: warn
heal-metadata-inspector:
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable source inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact inspector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Build the read-only candidate metadata decoder
run: |
set -euo pipefail
test "$(git -C candidate rev-parse HEAD)" = "${CANDIDATE_SHA}"
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
df -h .
python3 auto-testing/scripts/validation_heal/prepare_heal_meta_inspector.py \
--source candidate --commit "${CANDIDATE_SHA}" --out inspector-build
# Seed the helper's separate graph from the candidate's frozen graph.
# Cargo adds the helper package; both lock identities are retained.
cp candidate/Cargo.lock inspector-build/Cargo.lock
cargo build --release --manifest-path inspector-build/Cargo.toml
python3 auto-testing/scripts/validation_heal/check_bitrot_tool.py \
--binary inspector-build/target/release/heal-meta-inspect --source candidate
mkdir validation-tools
cp inspector-build/target/release/heal-meta-inspect validation-tools/
cp inspector-build/Cargo.lock validation-tools/inspector-Cargo.lock
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
source = json.loads(Path("inspector-build/source.json").read_text())
binary = Path("validation-tools/heal-meta-inspect")
source.update(built=True, testingCommit=os.environ["AUTO_TESTING_SHA"],
binarySha256=hashlib.sha256(binary.read_bytes()).hexdigest(),
inspectorLockSha256=hashlib.sha256(Path("validation-tools/inspector-Cargo.lock").read_bytes()).hexdigest(),
toolchain=subprocess.check_output(["rustc", "-vV"], text=True),
target="x86_64-unknown-linux-gnu", runId=os.environ["GITHUB_RUN_ID"],
runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
Path("validation-tools/heal-meta-inspect.json").write_text(json.dumps(source, indent=2) + "\n")
PY
git -C candidate diff --exit-code
- name: Build and test the controlled C10 metadata encoder
if: ${{ inputs.build_shared_heal }}
run: |
set -euo pipefail
python3 -B auto-testing/scripts/validation_heal/prepare_heal_shared_fixture.py \
--source candidate --commit "${CANDIDATE_SHA}" --out shared-build
cp candidate/Cargo.lock shared-build/Cargo.lock
export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/inspector-build/target"
cargo build --release --manifest-path shared-build/Cargo.toml
mkdir shared-tools
if ! cargo test --release --manifest-path shared-build/Cargo.toml -- --test-threads=1 \
> shared-tools/shared-fixture-tests.log 2>&1; then
cat shared-tools/shared-fixture-tests.log
exit 1
fi
cp "${CARGO_TARGET_DIR}/release/heal-shared-fixture" shared-tools/
cp shared-build/Cargo.lock shared-tools/shared-Cargo.lock
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
source = json.loads(Path("shared-build/source.json").read_text())
source.update(schemaVersion=1, built=True, sourceUnmodified=True,
candidateSourceTree=subprocess.check_output(["git", "-C", "candidate", "rev-parse", "HEAD^{tree}"], text=True).strip(),
testingCommit=os.environ["AUTO_TESTING_SHA"],
binarySha256=hashlib.sha256(Path("shared-tools/heal-shared-fixture").read_bytes()).hexdigest(),
sharedLockSha256=hashlib.sha256(Path("shared-tools/shared-Cargo.lock").read_bytes()).hexdigest(),
toolchain=subprocess.check_output(["rustc", "-vV"], text=True), target="x86_64-unknown-linux-gnu",
runId=os.environ["GITHUB_RUN_ID"], runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
Path("shared-tools/heal-shared-fixture.json").write_text(json.dumps(source, indent=2) + "\n")
PY
git -C candidate diff --exit-code
- name: Upload controlled C10 fixture encoder evidence
if: ${{ always() && inputs.build_shared_heal }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
path: shared-tools/
if-no-files-found: warn
- name: Upload immutable validation tools
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
path: validation-tools/
if-no-files-found: error
+12 -4
View File
@@ -3186,9 +3186,17 @@ pub mod test_support {
use crate::store::init_local_disks_with_instance_ctx;
pub async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
let mut dirs = Vec::with_capacity(4);
let mut endpoints = Vec::with_capacity(4);
for disk_idx in 0..4 {
isolated_store_over_temp_disks_with_count(4).await
}
pub async fn isolated_store_over_temp_disks_with_count(disk_count: usize) -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
assert!(
matches!(disk_count, 4 | 16),
"isolated store fixture requires a registered logical set geometry"
);
let mut dirs = Vec::with_capacity(disk_count);
let mut endpoints = Vec::with_capacity(disk_count);
for disk_idx in 0..disk_count {
let dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
@@ -3201,7 +3209,7 @@ pub mod test_support {
let endpoint_pools = EndpointServerPools(vec![PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 4,
drives_per_set: disk_count,
endpoints: Endpoints::from(endpoints),
cmd_line: "metadata-sys-cache-test".to_string(),
platform: "test".to_string(),
@@ -603,6 +603,10 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter(
request.object_version_id = version_id.filter(|value| !value.is_empty()).map(str::to_string);
// A degraded read's surviving minority is not a recreate instruction; the
// locked storage read decides whether the target is absent (backlog#2900).
// Reader failures include checksum damage that metadata-only healing cannot detect.
if matches!(reason, "missing_shards" | "decode_error") {
request.scan_mode = Some(rustfs_heal_contracts::heal_channel::HealScanMode::Deep);
}
let request_id = request.id.clone();
let bucket = bucket.to_string();
@@ -13360,6 +13364,61 @@ mod tests {
release_read_repair_heal_reservation(&key).await;
}
#[tokio::test]
async fn read_repair_reader_failure_requests_deep_scan_for_the_exact_version() {
fn captured() -> &'static std::sync::Mutex<Option<rustfs_heal_contracts::heal_channel::HealChannelRequest>> {
static REQUEST: OnceLock<std::sync::Mutex<Option<rustfs_heal_contracts::heal_channel::HealChannelRequest>>> =
OnceLock::new();
REQUEST.get_or_init(|| std::sync::Mutex::new(None))
}
fn capture(request: rustfs_heal_contracts::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture {
*captured().lock().expect("read-repair request capture must be available") = Some(request);
Box::pin(async { ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Accepted) })
}
for (reason, expected_scan) in [
("missing_shards", Some(rustfs_heal_contracts::heal_channel::HealScanMode::Deep)),
("decode_error", Some(rustfs_heal_contracts::heal_channel::HealScanMode::Deep)),
("metadata_read_error", None),
] {
let object = format!("reader-failure-{}", Uuid::new_v4());
let version = Uuid::new_v4().to_string();
submit_read_repair_heal_with_submitter(
ReadRepairHealSubmission {
bucket: "reader-failure",
object: &object,
version_id: Some(&version),
pool_index: 1,
set_index: 2,
part_number: Some(1),
reason,
mrf_intent: None,
},
capture,
)
.await;
let request = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Some(request) = captured().lock().expect("captured request must be readable").take() {
break request;
}
tokio::task::yield_now().await;
}
})
.await
.expect("the actual asynchronous admission must be captured");
assert_eq!(request.scan_mode, expected_scan, "reader damage needs payload verification");
assert_eq!(request.source, HealRequestSource::ReadRepair);
assert_eq!(request.bucket, "reader-failure");
assert_eq!(request.object_prefix.as_deref(), Some(object.as_str()));
assert_eq!(request.object_version_id.as_deref(), Some(version.as_str()));
assert_eq!((request.pool_index, request.set_index), (Some(1), Some(2)));
assert_eq!(request.recreate_missing, None, "storage evidence decides absence");
let key = ReadRepairHealCacheKey::new("reader-failure", &object, Some(&version), 1, 2);
release_read_repair_heal_reservation(&key).await;
}
}
// ========================================================================
// backlog#898 — groups A/C: pure old-data-dir cleanup classification.
// ========================================================================
+2
View File
@@ -891,6 +891,8 @@ pub(crate) use ops::heal::AbsenceProofRequest;
#[cfg(test)]
pub(crate) use ops::heal::DanglingDeleteFailure;
pub(crate) use ops::heal::HealedObjectAbsence;
#[cfg(test)]
pub(crate) use ops::heal::ReadRepairPauseScope;
#[cfg(test)]
pub(crate) use ops::hermetic_set_disks_isolated;
File diff suppressed because it is too large Load Diff
+291 -8
View File
@@ -8079,13 +8079,23 @@ fn notify_put_object_commit_namespace_acquired(bucket: &str, object: &str) {
}
}
#[cfg(test)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DeleteObjectCommitPause {
BeforeNamespace,
BeforeCommit,
AfterPublish,
}
#[cfg(test)]
struct DeleteObjectCommitBarrierState {
bucket: String,
object: String,
pause_after_publish: bool,
pause: DeleteObjectCommitPause,
arrived: tokio::sync::Notify,
release: tokio::sync::Notify,
namespace_pending: tokio::sync::Notify,
namespace_acquired: std::sync::atomic::AtomicBool,
}
#[cfg(test)]
@@ -8100,21 +8110,27 @@ static DELETE_OBJECT_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option
#[cfg(test)]
impl DeleteObjectCommitBarrier {
pub(crate) fn install(bucket: &str, object: &str) -> Self {
Self::install_with_mode(bucket, object, false)
Self::install_with_mode(bucket, object, DeleteObjectCommitPause::BeforeCommit)
}
pub(crate) fn install_before_namespace(bucket: &str, object: &str) -> Self {
Self::install_with_mode(bucket, object, DeleteObjectCommitPause::BeforeNamespace)
}
#[cfg(feature = "test-util")]
pub(crate) fn install_after_publish(bucket: &str, object: &str) -> Self {
Self::install_with_mode(bucket, object, true)
Self::install_with_mode(bucket, object, DeleteObjectCommitPause::AfterPublish)
}
fn install_with_mode(bucket: &str, object: &str, pause_after_publish: bool) -> Self {
fn install_with_mode(bucket: &str, object: &str, pause: DeleteObjectCommitPause) -> Self {
let state = Arc::new(DeleteObjectCommitBarrierState {
bucket: bucket.to_string(),
object: object.to_string(),
pause_after_publish,
pause,
arrived: tokio::sync::Notify::new(),
release: tokio::sync::Notify::new(),
namespace_pending: tokio::sync::Notify::new(),
namespace_acquired: std::sync::atomic::AtomicBool::new(false),
});
let mut slot = DELETE_OBJECT_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
@@ -8134,6 +8150,19 @@ impl DeleteObjectCommitBarrier {
pub(crate) fn release(&self) {
self.state.release.notify_one();
}
pub(crate) async fn release_and_wait_until_namespace_pending(&self) {
assert_eq!(self.state.pause, DeleteObjectCommitPause::BeforeNamespace);
let pending = self.state.namespace_pending.notified();
self.release();
tokio::time::timeout(Duration::from_secs(5), pending)
.await
.expect("delete object must poll its actual namespace acquisition to Pending");
}
pub(crate) fn namespace_acquired(&self) -> bool {
self.state.namespace_acquired.load(std::sync::atomic::Ordering::Acquire)
}
}
#[cfg(test)]
@@ -8150,6 +8179,43 @@ impl Drop for DeleteObjectCommitBarrier {
}
}
#[cfg(test)]
async fn pause_delete_object_before_namespace(bucket: &str, object: &str) {
let barrier = DELETE_OBJECT_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("delete object commit barrier mutex should not poison")
.as_ref()
.filter(|barrier| {
barrier.bucket == bucket && barrier.object == object && barrier.pause == DeleteObjectCommitPause::BeforeNamespace
})
.cloned();
if let Some(barrier) = barrier {
barrier.arrived.notify_one();
barrier.release.notified().await;
}
}
#[cfg(test)]
fn notify_delete_object_namespace(bucket: &str, object: &str, acquired: bool) {
let barrier = DELETE_OBJECT_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("delete object commit barrier mutex should not poison")
.as_ref()
.filter(|barrier| {
barrier.bucket == bucket && barrier.object == object && barrier.pause == DeleteObjectCommitPause::BeforeNamespace
})
.cloned();
if let Some(barrier) = barrier {
if acquired {
barrier.namespace_acquired.store(true, std::sync::atomic::Ordering::Release);
} else {
barrier.namespace_pending.notify_one();
}
}
}
#[cfg(test)]
async fn pause_delete_object_commit(bucket: &str, object: &str) {
let barrier = DELETE_OBJECT_COMMIT_BARRIER
@@ -8157,7 +8223,9 @@ async fn pause_delete_object_commit(bucket: &str, object: &str) {
.lock()
.expect("delete object commit barrier mutex should not poison")
.as_ref()
.filter(|barrier| barrier.bucket == bucket && barrier.object == object && !barrier.pause_after_publish)
.filter(|barrier| {
barrier.bucket == bucket && barrier.object == object && barrier.pause == DeleteObjectCommitPause::BeforeCommit
})
.cloned();
if let Some(barrier) = barrier {
barrier.arrived.notify_one();
@@ -8172,7 +8240,9 @@ async fn pause_delete_object_commit_after_publish(bucket: &str, object: &str) {
.lock()
.expect("delete object commit barrier mutex should not poison")
.as_ref()
.filter(|barrier| barrier.bucket == bucket && barrier.object == object && barrier.pause_after_publish)
.filter(|barrier| {
barrier.bucket == bucket && barrier.object == object && barrier.pause == DeleteObjectCommitPause::AfterPublish
})
.cloned();
if let Some(barrier) = barrier {
barrier.arrived.notify_one();
@@ -10417,7 +10487,20 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
// Guard lock for single object delete
let _lock_guard = if (!opts.delete_prefix || opts.delete_prefix_object) && !opts.no_lock {
Some(Arc::new(self.acquire_write_lock_diag("delete_object", bucket, object).await?))
#[cfg(test)]
let guard = {
pause_delete_object_before_namespace(bucket, object).await;
let guard = self
.acquire_write_lock_diag_with_pending_hook("delete_object", bucket, object, || {
notify_delete_object_namespace(bucket, object, false);
})
.await?;
notify_delete_object_namespace(bucket, object, true);
guard
};
#[cfg(not(test))]
let guard = self.acquire_write_lock_diag("delete_object", bucket, object).await?;
Some(Arc::new(guard))
} else {
None
};
@@ -21108,6 +21191,206 @@ mod put_object_tmp_cleanup_tests {
.await;
}
#[tokio::test]
async fn deep_heal_preserves_committed_generation_during_old_data_cleanup() {
use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode};
for (disk_count, parity, read_repair) in [(4, 2, false), (4, 2, true), (16, 4, false), (16, 4, true)] {
let (dirs, disks, set) = super::hermetic_set_disks_support::hermetic_set_disks_for_pool_with_default_parity_isolated(
disk_count, 0, parity,
)
.await;
let bucket = "native-heal-commit-cleanup";
let object = format!("generation-{disk_count}-{parity}-{read_repair}.bin");
for disk in &disks {
disk.make_volume(bucket).await.expect("create actual fixture volume");
}
let options = ObjectOptions {
write_completion: crate::object_api::WriteCompletion::TailDrained,
shard_integrity_write_mode: Some(crate::object_api::ShardIntegrityWriteMode::Protected),
..Default::default()
};
set.put_object(bucket, &object, &mut PutObjReader::from_vec(vec![0x17; TEST_OBJECT_SIZE]), &options)
.await
.expect("acknowledge original generation on every disk");
let old = disks[0]
.read_version("", bucket, &object, "", &ReadOptions::default())
.await
.expect("read actual original generation");
let old_dir = old.data_dir.expect("original external data directory");
let old_part = dirs[0]
.path()
.join(bucket)
.join(&object)
.join(old_dir.to_string())
.join("part.1");
assert!(tokio::fs::try_exists(&old_part).await.expect("original part exists"));
// Cleanup slots follow the writer's shuffled erasure distribution,
// not the physical fixture order. Pause every real cleanup slot so
// no physical old shard can disappear before the proof below.
let barriers: Vec<_> = (0..disk_count)
.map(|slot| rename_fanout_barrier::arm(&object, slot, rename_fanout_barrier::PHASE_CLEANUP))
.collect();
let writer_set = Arc::clone(&set);
let writer_object = object.clone();
let writer_options = options.clone();
let payload = vec![0xa3; TEST_OBJECT_SIZE];
let writer_payload = payload.clone();
let writer = tokio::spawn(async move {
writer_set
.put_object(bucket, &writer_object, &mut PutObjReader::from_vec(writer_payload), &writer_options)
.await
});
tokio::time::timeout(Duration::from_secs(30), async {
for barrier in &barriers {
barrier.wait_until_paused().await;
}
})
.await
.expect("overwrite commits before its real old-data cleanup pause");
assert!(!writer.is_finished(), "tail-drained ACK must remain pending at cleanup");
assert!(
tokio::fs::try_exists(&old_part)
.await
.expect("old source still present at pause")
);
let mut committed = Vec::new();
let mut healthy_metadata = Vec::new();
for disk in &disks {
let info = disk
.read_version("", bucket, &object, "", &ReadOptions::default())
.await
.expect("read independently committed physical generation without GET");
assert_eq!((info.erasure.data_blocks, info.erasure.parity_blocks), (disk_count - parity, parity));
assert!(info.data.is_none(), "use actual external parts");
assert_ne!(info.data_dir, Some(old_dir));
committed.push(info);
healthy_metadata.push(
disk.read_all(bucket, &format!("{object}/xl.meta"))
.await
.expect("committed raw metadata"),
);
}
let new_dir = committed[0].data_dir.expect("new committed data directory");
assert!(committed.iter().all(|info| info.data_dir == Some(new_dir)));
let new_part = dirs[0]
.path()
.join(bucket)
.join(&object)
.join(new_dir.to_string())
.join("part.1");
let expected_part = tokio::fs::read(&new_part).await.expect("exact new source bytes");
let mut committed_parts = Vec::new();
for dir in &dirs {
let original = dir.path().join(bucket).join(&object).join(old_dir.to_string()).join("part.1");
assert!(
tokio::fs::try_exists(original)
.await
.expect("every old cleanup slot is paused")
);
committed_parts.push(
tokio::fs::read(dir.path().join(bucket).join(&object).join(new_dir.to_string()).join("part.1"))
.await
.expect("every new encoded shard before fault"),
);
}
tokio::fs::remove_file(&new_part)
.await
.expect("remove only the newly committed target shard");
println!(
"native_g01: geometry={disk_count}/{parity} read_repair={read_repair} old_cleanup_paused new_generation_committed target_part_missing"
);
let (result, error) = tokio::time::timeout(
Duration::from_secs(30),
set.heal_object(
bucket,
&object,
"",
&HealOpts {
scan_mode: HealScanMode::Deep,
read_repair,
..Default::default()
},
),
)
.await
.expect("real locked Heal must progress during old cleanup")
.expect("complete actual deep Heal");
assert!(error.is_none(), "new generation must remain repairable: {error:?}");
assert_eq!(result.drives_healed(), Some(1));
// Shared data-phase repair deliberately does not certify full integrity.
assert_eq!(result.integrity_verified, !read_repair);
assert_eq!(tokio::fs::read(&new_part).await.expect("rebuilt part before any GET"), expected_part);
let repaired = disks[0]
.read_version("", bucket, &object, "", &ReadOptions::default())
.await
.expect("read actual repaired generation");
assert_eq!(repaired.data_dir, Some(new_dir));
assert_eq!(repaired.mod_time, committed[0].mod_time);
for (disk, expected) in disks.iter().zip(&healthy_metadata).skip(1) {
assert_eq!(
disk.read_all(bucket, &format!("{object}/xl.meta"))
.await
.expect("healthy raw metadata after Heal"),
*expected
);
}
println!(
"native_g01: geometry={disk_count}/{parity} read_repair={read_repair} locked_heal_completed new_generation_part_rebuilt_before_get"
);
for barrier in &barriers {
barrier.release();
}
let acknowledged = tokio::time::timeout(Duration::from_secs(30), writer)
.await
.expect("cleanup drains")
.expect("writer joins")
.expect("overwrite ACK remains successful");
assert_eq!(acknowledged.size, i64::try_from(payload.len()).expect("fixture size fits i64"));
drop(barriers);
for (slot, dir) in dirs.iter().enumerate() {
let root = dir.path().join(bucket).join(&object);
assert!(
!tokio::fs::try_exists(root.join(old_dir.to_string()))
.await
.expect("old cleanup completed")
);
assert_eq!(
tokio::fs::read(root.join(new_dir.to_string()).join("part.1"))
.await
.expect("new shard survives cleanup before GET"),
committed_parts[slot],
"old cleanup must preserve every exact committed encoded shard"
);
}
let mut reader = set
.get_object_reader(bucket, &object, None, HeaderMap::new(), &options)
.await
.expect("read final acknowledged generation");
let mut body = Vec::new();
reader
.stream
.read_to_end(&mut body)
.await
.expect("read complete final payload");
assert_eq!(body, payload);
for disk in &disks {
let final_info = disk
.read_version("", bucket, &object, "", &ReadOptions::default())
.await
.expect("final physical generation");
assert_eq!(final_info.data_dir, Some(new_dir), "old cleanup must not revert the healed generation");
}
println!(
"native_g01: geometry={disk_count}/{parity} read_repair={read_repair} overwrite_ack_preserved final_physical_generation_and_full_body_verified"
);
}
}
#[tokio::test]
async fn committed_put_releases_namespace_lock_before_old_data_cleanup() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
+106 -4
View File
@@ -1430,7 +1430,10 @@ impl SetDisks {
"Create bitrot reader failed read quorum"
);
record_get_object_pipeline_failure(GET_STAGE_READER_SETUP, reason);
return Err(to_object_err(read_err.into(), vec![bucket, object]));
// The metadata selected this object, but the part cannot
// be reconstructed. A dominant disk I/O/not-found error
// must not erase the object's read-quorum failure.
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
}
let reason = GetObjectFailureReason::ReadQuorum;
error!(
@@ -1455,7 +1458,7 @@ impl SetDisks {
"Create bitrot reader did not have enough disks"
);
record_get_object_pipeline_failure(GET_STAGE_READER_SETUP, reason);
return Err(Error::other(format!("not enough disks to read: {:?}", reader_setup.errors)));
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
}
// Check if we have missing shards even though we can read successfully
@@ -1990,10 +1993,10 @@ impl SetDisks {
if let Some(read_err) = reduce_read_quorum_errs(&reader_setup.errors, OBJECT_OP_IGNORED_ERRS, erasure.data_shards) {
let reason = classify_disk_error(&read_err);
record_get_object_pipeline_failure_for_path(metrics_path, GET_STAGE_READER_SETUP, reason);
return Err(to_object_err(read_err.into(), vec![bucket, object]));
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
}
record_get_object_pipeline_failure_for_path(metrics_path, GET_STAGE_READER_SETUP, GetObjectFailureReason::ReadQuorum);
return Err(Error::other(format!("not enough disks to read: {:?}", reader_setup.errors)));
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
}
let missing_shards = reader_setup.completed_failed_shards();
@@ -7799,6 +7802,105 @@ mod tests {
(data_dir, files)
}
#[tokio::test]
#[serial_test::serial]
async fn external_missing_shards_keep_typed_read_quorum() {
use tokio::io::AsyncReadExt;
let payload = vec![0x37; 256 * 1024];
for readable_shards in [1, 2, 4] {
let erasure = coding::Erasure::new(4, 2, 1024 * 1024);
let mut fi = codec_streaming_test_fileinfo(i64::try_from(payload.len()).expect("fixture size fits i64"), 1);
fi.erasure.data_blocks = erasure.data_shards;
fi.erasure.parity_blocks = erasure.parity_shards;
fi.erasure.block_size = erasure.block_size;
fi.erasure.distribution = (1..=erasure.total_shard_count()).collect();
let (dirs, disks) = local_test_disks(erasure.total_shard_count(), CODEC_STREAMING_TEST_BUCKET).await;
let (data_dir, files) = codec_streaming_external_files(&erasure, &payload, &disks).await;
fi.data_dir = Some(data_dir);
for directory in dirs.iter().skip(readable_shards) {
tokio::fs::remove_file(
directory
.path()
.join(CODEC_STREAMING_TEST_BUCKET)
.join(CODEC_STREAMING_TEST_OBJECT)
.join(data_dir.to_string())
.join("part.1"),
)
.await
.expect("remove real part while retaining its selected metadata");
}
let mut output = Vec::new();
let legacy = SetDisks::get_object_with_fileinfo(
CODEC_STREAMING_TEST_BUCKET,
CODEC_STREAMING_TEST_OBJECT,
Arc::new(ErasureCache::new()),
0,
fi.size,
&mut output,
fi.clone(),
files.clone(),
&disks,
0,
0,
false,
true,
false,
false,
GET_OBJECT_PATH_LEGACY_DUPLEX,
"plain_single_part",
"le_1mib",
)
.await;
let streaming = SetDisks::get_object_mid_size_reader_with_fileinfo(
CODEC_STREAMING_TEST_BUCKET,
CODEC_STREAMING_TEST_OBJECT,
Arc::new(ErasureCache::new()),
&fi,
&files,
&disks,
0,
0,
false,
true,
"plain_single_part",
"le_1mib",
false,
)
.await;
if readable_shards < erasure.data_shards {
for error in [
legacy.expect_err("legacy read must reject quorum minus one"),
streaming.err().expect("streaming setup must reject quorum minus one"),
] {
assert_eq!(
error,
Error::InsufficientReadQuorum(
CODEC_STREAMING_TEST_BUCKET.to_owned(),
CODEC_STREAMING_TEST_OBJECT.to_owned(),
)
);
}
assert!(output.is_empty(), "quorum rejection must precede all response bytes");
} else {
legacy.expect("legacy reader retains reconstructable quorum");
assert_eq!(output, payload, "legacy read returns every original byte at quorum");
let GetCodecStreamingReaderBuildOutcome::Reader(mut reader) =
streaming.expect("streaming quorum remains readable")
else {
panic!("mid-size quorum must yield an actual reader");
};
let mut decoded = Vec::new();
reader
.read_to_end(&mut decoded)
.await
.expect("streaming reader returns the complete body");
assert_eq!(decoded, payload, "streaming read returns every original byte at quorum");
}
}
}
#[test]
#[serial]
fn payload_read_repair_is_suppressed_only_for_read_only_requests() {
+204
View File
@@ -1950,6 +1950,210 @@ mod tests {
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial]
async fn bucket_incarnation_fence_survives_caller_abort_at_real_repair_commit() {
use crate::set_disk::ReadRepairPauseScope;
let (_root, store, shutdown) = multi_pool_heal_store().await;
let bucket = format!("heal-commit-owner-{}", Uuid::new_v4().simple());
let object = "history.bin";
let version = Uuid::new_v4().to_string();
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("original bucket");
let old = store
.bucket_incarnation_id_from_disk(&bucket)
.await
.expect("original identity");
let set = store.pools[0].get_disks(0);
let mut reader = PutObjReader::from_vec(vec![0x17; 1024 * 1024]);
set.put_object(
&bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
version_id: Some(version.clone()),
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("original acknowledged version");
let missing = set.disks.read().await[0].clone().expect("repair target");
let healthy = set.disks.read().await[1].clone().expect("healthy target");
let baseline = healthy
.read_all(&bucket, &format!("{object}/xl.meta"))
.await
.expect("healthy metadata baseline");
let original = healthy
.read_version("", &bucket, object, &version, &Default::default())
.await
.expect("original version metadata");
assert!(original.data.is_none(), "the commit barrier fixture must use actual external data shards");
missing
.delete(
&bucket,
object,
DeleteOptions {
recursive: true,
immediate: true,
..Default::default()
},
)
.await
.expect("remove one physical shard");
// The scope pauses the real repair after reconstruction, immediately
// before its generation check and final shard rename.
let (_pause, arrived, release) = ReadRepairPauseScope::install_incarnation_commit(&bucket, object);
let (drained_tx, drained_rx) = tokio::sync::oneshot::channel();
let worker_store = store.clone();
let worker_bucket = bucket.clone();
let worker_version = version.clone();
let opts = HealOpts {
pool: Some(0),
set: Some(0),
scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Deep,
..Default::default()
};
let mut waiter = tokio::spawn(async move {
worker_store
.run_bucket_heal_at_incarnation(&worker_bucket, old, &opts, move |store, bucket, opts| async move {
let result = store.heal_object_with_proof(&bucket, object, &worker_version, &opts).await?;
drained_tx
.send(result)
.expect("retain the actual storage result after caller cancellation");
Ok(())
})
.await
});
tokio::select! {
paused = tokio::time::timeout(std::time::Duration::from_secs(10), arrived.notified()) => {
paused.expect("real missing-shard repair reaches the final commit pause");
}
completed = &mut waiter => panic!("repair ended before the actual final commit barrier: {completed:?}"),
}
assert!(
missing.read_xl(&bucket, object, false).await.is_err(),
"final shard is not published at the pause"
);
println!("incarnation_commit_barrier: final_rename_paused");
waiter.abort();
assert!(waiter.await.expect_err("caller aborted").is_cancelled());
let mut writer = Box::pin(store.acquire_bucket_lifecycle_write_lock(&bucket));
assert!(
futures::poll!(&mut writer).is_pending(),
"bucket deletion/recreation cannot acquire its generation writer while the cancelled repair still owns commit"
);
println!("incarnation_commit_barrier: caller_aborted_writer_pending");
release.notify_one();
let result = tokio::time::timeout(std::time::Duration::from_secs(10), drained_rx)
.await
.expect("actual repair drains after release")
.expect("retained repair result");
assert!(
result.error.is_none(),
"physical repair must succeed before lifecycle handoff: {:?}",
result.error
);
let guard = tokio::time::timeout(std::time::Duration::from_secs(10), writer)
.await
.expect("generation writer acquires only after physical repair drains")
.expect("generation writer");
assert!(
missing.read_xl(&bucket, object, false).await.is_ok(),
"the physical shard is published before writer admission"
);
let repaired = missing
.read_version("", &bucket, object, &version, &Default::default())
.await
.expect("repaired exact version metadata");
assert_eq!(repaired.version_id, original.version_id);
assert_eq!(repaired.data_dir, original.data_dir);
let checked = missing
.check_parts(&bucket, object, &repaired)
.await
.expect("verify the published physical shard");
assert_eq!(
checked.results,
vec![crate::disk::CHECK_PART_SUCCESS],
"the actual published part passes bitrot verification"
);
assert_eq!(
healthy
.read_all(&bucket, &format!("{object}/xl.meta"))
.await
.expect("healthy after repair"),
baseline,
"repair does not overwrite the surviving version"
);
println!("incarnation_commit_barrier: repair_drained_writer_acquired");
drop(guard);
set.delete_object(
&bucket,
object,
ObjectOptions {
versioned: true,
version_id: Some(version.clone()),
..Default::default()
},
)
.await
.expect("normal explicit deletion of the old version");
store
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("normal empty bucket deletion");
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("same-name successor bucket");
let new = store
.bucket_incarnation_id_from_disk(&bucket)
.await
.expect("successor identity");
assert_ne!(old, new);
let successor_version = Uuid::new_v4().to_string();
let mut successor_reader = PutObjReader::from_vec(vec![0x29; 512 * 1024]);
set.put_object(
&bucket,
object,
&mut successor_reader,
&ObjectOptions {
versioned: true,
version_id: Some(successor_version),
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("acknowledged successor version");
let successor = healthy
.read_all(&bucket, &format!("{object}/xl.meta"))
.await
.expect("successor metadata");
assert!(
store
.heal_object_at_incarnation(&bucket, object, &version, old, &opts)
.await
.is_err()
);
assert_eq!(
healthy
.read_all(&bucket, &format!("{object}/xl.meta"))
.await
.expect("successor after obsolete admission"),
successor,
"obsolete admission cannot change the same-name successor"
);
println!("incarnation_commit_barrier: successor_rejects_obsolete_admission");
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial]
async fn bucket_incarnation_heal_owner_survives_cancelled_waiter_and_queued_writer() {
+46
View File
@@ -2343,6 +2343,52 @@ mod test {
}
}
#[test]
fn truncated_inline_xlmeta_is_file_corrupt_only_for_supported_formats() {
let mut meta = FileMeta::new();
meta.data
.replace("fixture", vec![0x5a; 4095])
.expect("seed real inline framing");
let inline_len = meta.data.as_slice().len();
let original = meta.marshal_msg().expect("serialize valid inline metadata");
let inline_start = original.len() - inline_len;
let parsed = FileMeta::load(&original).expect("complete inline metadata must decode");
assert_eq!(parsed.data, meta.data);
for cut in inline_start + 1..original.len() {
assert_eq!(
FileMeta::load(&original[..cut]).expect_err("incomplete inline framing must fail"),
Error::FileCorrupt,
"inline truncation at byte {cut} must remain repairable"
);
}
for inline_version in [0, 2] {
let mut unsupported = original[..original.len() / 2].to_vec();
unsupported[inline_start] = inline_version;
assert_ne!(
FileMeta::load(&unsupported).expect_err("malformed unsupported inline format must fail"),
Error::FileCorrupt,
"unknown inline version must not authorize repair"
);
}
for (header_ver, meta_ver) in [
(XL_HEADER_VERSION + 1, XL_META_VERSION),
(XL_HEADER_VERSION, XL_META_VERSION + 1),
] {
let mut headers = Vec::new();
rmp::encode::write_uint(&mut headers, u64::from(header_ver)).expect("write future header");
rmp::encode::write_uint(&mut headers, u64::from(meta_ver)).expect("write future metadata version");
rmp::encode::write_uint(&mut headers, 0).expect("write empty version count");
let mut unsupported = build_xl_buffer(&headers);
unsupported.extend_from_slice(&meta.data.as_slice()[..inline_len / 2]);
assert_ne!(
FileMeta::load(&unsupported).expect_err("unsupported metadata with truncated inline data must fail"),
Error::FileCorrupt,
"future metadata schema must not authorize repair"
);
}
}
#[test]
fn truncated_xlmeta_index_and_format_reads_are_file_corrupt() {
let buf = FileMeta::default()
+7 -1
View File
@@ -188,7 +188,13 @@ impl FileMeta {
if !buf.is_empty() {
InlineData::validate_slice(buf).map_err(|e| {
error!("data validation failed: {}", e);
e
// Validation only examines bytes already read. Classify damage
// as repairable corruption only for formats we understand.
if InlineData::version_ok_slice(buf) && Self::decode_xl_headers(meta).is_ok() {
Error::FileCorrupt
} else {
e
}
})?;
}
+4 -4
View File
@@ -168,11 +168,11 @@ impl InlineData {
self.0.as_slice()
}
pub fn version_ok(&self) -> bool {
if self.0.is_empty() {
return true;
}
Self::version_ok_slice(&self.0)
}
self.0[0] > 0 && self.0[0] <= INLINE_DATA_VER
pub(crate) fn version_ok_slice(raw: &[u8]) -> bool {
raw.first().is_none_or(|version| *version > 0 && *version <= INLINE_DATA_VER)
}
pub fn after_version(&self) -> &[u8] {
+27 -17
View File
@@ -196,6 +196,20 @@ impl HealChannelProcessor {
.map_err(Error::other)
}
/// Query a local automatic task; other tokens require coordinator routing.
pub async fn execute_local_automatic_query(
&self,
heal_path: &str,
client_token: &str,
since_seq: Option<u64>,
) -> Result<Option<HealChannelResponse>> {
self.heal_manager
.get_local_automatic_task_report(heal_path, client_token, since_seq)
.await?
.map(|report| Self::query_report_response(client_token.to_owned(), Ok(report)))
.transpose()
}
/// Execute cancellation directly against the manager.
pub async fn execute_cancel_request(&self, heal_path: String, client_token: String) -> Result<HealChannelResponse> {
let (response_tx, response_rx) = oneshot::channel();
@@ -463,6 +477,13 @@ impl HealChannelProcessor {
.await
};
let response = Self::query_report_response(client_token, report)?;
let _ = response_tx.send(Ok(response.clone()));
self.publish_response(response);
Ok(())
}
fn query_report_response(client_token: String, report: Result<HealTaskReport>) -> Result<HealChannelResponse> {
let outcome = report.as_ref().ok().and_then(|report| report.outcome.clone());
let settings = report
.as_ref()
@@ -581,27 +602,21 @@ impl HealChannelProcessor {
0,
),
Err(crate::Error::InvalidClientToken) => {
let response = HealChannelResponse {
return Ok(HealChannelResponse {
request_id: client_token,
success: false,
data: None,
error: Some("invalid heal client token".to_string()),
};
let _ = response_tx.send(Ok(response.clone()));
self.publish_response(response);
return Ok(());
});
}
Err(err) => {
let error_text = err.to_string();
let response = HealChannelResponse {
return Ok(HealChannelResponse {
request_id: client_token,
success: false,
data: None,
error: Some(error_text),
};
let _ = response_tx.send(Ok(response.clone()));
self.publish_response(response);
return Ok(());
});
}
};
@@ -618,17 +633,12 @@ impl HealChannelProcessor {
},
)?;
let response = HealChannelResponse {
Ok(HealChannelResponse {
request_id: client_token,
success: true,
data: Some(data),
error: detail,
};
let _ = response_tx.send(Ok(response.clone()));
self.publish_response(response);
Ok(())
})
}
/// Process cancel request
+77 -29
View File
@@ -234,6 +234,8 @@ fn record_displaced_terminal(
request: &HealRequest,
) -> Arc<CompletedHealStatus> {
let terminal = Arc::new(CompletedHealStatus {
task_id: request.id.clone(),
source: request.source,
outcome: None,
progress: None,
retained_bytes: std::sync::OnceLock::new(),
@@ -628,7 +630,10 @@ fn retry_budget_for_result(task: &HealTask, result: &Result<()>, retryable_batch
if task.retry_attempts >= MAX_RECOVERABLE_HEAL_RETRIES {
return None;
}
if task.has_batch_failure() && !retryable_batch_failure {
// Admin traversals already exhaust their per-object retries. Restarting
// the whole traversal under the same token discards those receipts and
// reports previously repaired objects as unchanged on the next scan.
if task.has_batch_failure() && (task.source == HealRequestSource::Admin || !retryable_batch_failure) {
return None;
}
@@ -1015,9 +1020,9 @@ pub struct HealManager {
/// cascade without re-locking.
enum TaskStateLookup {
Active(Arc<HealTask>),
Retrying(HealTaskStatus, HealOptions),
Retrying(HealTaskStatus, HealOptions, HealRequestSource),
Completed(Arc<CompletedHealStatus>),
Queued(HealOptions),
Queued(HealOptions, HealRequestSource),
/// A durable root heal record that cannot be admitted or quarantined;
/// it answers status queries with the reason until an operator repairs it.
Unrunnable(String),
@@ -2430,6 +2435,23 @@ impl HealManager {
/// completed entry. `heal_path` additionally constrains the map matches
/// the way the `*_for_path` variants always have.
async fn lookup_task_state(&self, canonical_task_id: &str, heal_path: Option<&str>) -> Result<TaskStateLookup> {
let state = self.lookup_memory_task_state(canonical_task_id, heal_path).await;
if !matches!(state, TaskStateLookup::NotFound) {
return Ok(state);
}
if let Some(completed) = self.root_recovery.completed(canonical_task_id).await?
&& heal_path.is_none_or(|path| heal_type_matches_path(&completed.heal_type, path))
{
return Ok(TaskStateLookup::Completed(Arc::new(completed)));
}
// A durable record that cannot run still answers with its reason.
if let Some(reason) = self.root_recovery.record_issue(canonical_task_id).await {
return Ok(TaskStateLookup::Unrunnable(reason));
}
Ok(TaskStateLookup::NotFound)
}
async fn lookup_memory_task_state(&self, canonical_task_id: &str, heal_path: Option<&str>) -> TaskStateLookup {
let matches_path = |heal_type: &HealType| heal_path.is_none_or(|path| heal_type_matches_path(heal_type, path));
{
@@ -2438,7 +2460,7 @@ impl HealManager {
.get(canonical_task_id)
.filter(|task| matches_path(&task.heal_type))
{
return Ok(TaskStateLookup::Active(Arc::clone(task)));
return TaskStateLookup::Active(Arc::clone(task));
}
}
@@ -2448,7 +2470,7 @@ impl HealManager {
.get(canonical_task_id)
.filter(|retrying| matches_path(&retrying.request.heal_type))
{
return Ok(TaskStateLookup::Retrying(retrying.status(), retrying.request.options.clone()));
return TaskStateLookup::Retrying(retrying.status(), retrying.request.options.clone(), retrying.request.source);
}
}
@@ -2461,7 +2483,7 @@ impl HealManager {
prune_completed_heal_statuses(&mut completed_heals);
if let Some(completed) = completed_heals.get(canonical_task_id).filter(|c| matches_path(&c.heal_type)) {
if completed_status_is_retrying(&completed.status) {
return Ok(TaskStateLookup::Completed(Arc::clone(completed)));
return TaskStateLookup::Completed(Arc::clone(completed));
}
terminal_completed = Some(Arc::clone(completed));
}
@@ -2470,7 +2492,7 @@ impl HealManager {
{
let queue = self.heal_queue.lock().await;
if let Some(request) = queue.request_matching_id_and_path(canonical_task_id, heal_path) {
return Ok(TaskStateLookup::Queued(request.options.clone()));
return TaskStateLookup::Queued(request.options.clone(), request.source);
}
}
@@ -2483,22 +2505,10 @@ impl HealManager {
.cloned();
}
if terminal_completed.is_none()
&& let Some(completed) = self.root_recovery.completed(canonical_task_id).await?
&& matches_path(&completed.heal_type)
{
terminal_completed = Some(Arc::new(completed));
}
if let Some(completed) = terminal_completed {
return Ok(TaskStateLookup::Completed(completed));
return TaskStateLookup::Completed(completed);
}
// A durable root record that can neither run nor be quarantined still
// answers status queries with its reason.
if let Some(reason) = self.root_recovery.record_issue(canonical_task_id).await {
return Ok(TaskStateLookup::Unrunnable(reason));
}
Ok(TaskStateLookup::NotFound)
TaskStateLookup::NotFound
}
async fn publish_admin_terminal(
@@ -2522,6 +2532,8 @@ impl HealManager {
) -> Result<bool> {
let previous = self.completed_heals.lock().await.get(task_id).cloned();
let mut completed = CompletedHealStatus {
task_id: task_id.to_owned(),
source,
outcome: None,
progress: None,
retained_bytes: std::sync::OnceLock::new(),
@@ -2557,9 +2569,9 @@ impl HealManager {
let canonical_task_id = self.canonical_task_id(task_id).await;
match self.lookup_task_state(&canonical_task_id, None).await? {
TaskStateLookup::Active(task) => Ok(task.get_status().await),
TaskStateLookup::Retrying(status, _) => Ok(status),
TaskStateLookup::Retrying(status, _, _) => Ok(status),
TaskStateLookup::Completed(completed) => Ok(completed.status.clone()),
TaskStateLookup::Queued(_) => Ok(HealTaskStatus::Pending),
TaskStateLookup::Queued(_, _) => Ok(HealTaskStatus::Pending),
TaskStateLookup::Unrunnable(error) => Ok(HealTaskStatus::Failed { error }),
TaskStateLookup::NotFound => Err(Error::TaskNotFound {
task_id: task_id.to_string(),
@@ -2578,9 +2590,9 @@ impl HealManager {
let canonical_task_id = self.canonical_task_id(task_id).await;
match self.lookup_task_state(&canonical_task_id, None).await? {
TaskStateLookup::Active(task) => Ok(active_task_report(&task, since).await),
TaskStateLookup::Retrying(status, options) => Ok(empty_task_report_with_options(status, options)),
TaskStateLookup::Retrying(status, options, _) => Ok(empty_task_report_with_options(status, options)),
TaskStateLookup::Completed(completed) => Ok(completed_task_report(&completed, since)),
TaskStateLookup::Queued(options) => Ok(empty_task_report_with_options(HealTaskStatus::Pending, options)),
TaskStateLookup::Queued(options, _) => Ok(empty_task_report_with_options(HealTaskStatus::Pending, options)),
TaskStateLookup::Unrunnable(error) => {
Ok(empty_task_report_with_options(HealTaskStatus::Failed { error }, HealOptions::default()))
}
@@ -2594,6 +2606,42 @@ impl HealManager {
self.get_task_report_for_path_since(heal_path, task_id, None).await
}
/// Resolve a node-owned automatic task without granting local control over
/// Admin tasks. The source and report come from the same state snapshot.
pub async fn get_local_automatic_task_report(
&self,
heal_path: &str,
task_id: &str,
since: Option<u64>,
) -> Result<Option<HealTaskReport>> {
let canonical_task_id = self.canonical_task_id(task_id).await;
if canonical_task_id != task_id {
return Ok(None);
}
let state = self.lookup_memory_task_state(&canonical_task_id, Some(heal_path)).await;
let source = match &state {
TaskStateLookup::Active(task) => task.source,
TaskStateLookup::Retrying(_, _, source) | TaskStateLookup::Queued(_, source) => *source,
TaskStateLookup::Completed(completed) if completed.task_id == task_id => completed.source,
TaskStateLookup::Completed(_) => return Ok(None),
TaskStateLookup::Unrunnable(_) | TaskStateLookup::NotFound => return Ok(None),
};
if !matches!(
source,
HealRequestSource::Scanner | HealRequestSource::Mrf | HealRequestSource::AutoHeal | HealRequestSource::ReadRepair
) {
return Ok(None);
}
let report = match state {
TaskStateLookup::Active(task) => active_task_report(&task, since).await,
TaskStateLookup::Retrying(status, options, _) => empty_task_report_with_options(status, options),
TaskStateLookup::Completed(completed) => completed_task_report(&completed, since),
TaskStateLookup::Queued(options, _) => empty_task_report_with_options(HealTaskStatus::Pending, options),
TaskStateLookup::Unrunnable(_) | TaskStateLookup::NotFound => return Ok(None),
};
Ok(Some(report))
}
/// Incremental variant of [`Self::get_task_report_for_path`] (HS-06).
pub async fn get_task_report_for_path_since(
&self,
@@ -2604,9 +2652,9 @@ impl HealManager {
let canonical_task_id = self.canonical_task_id(task_id).await;
match self.lookup_task_state(&canonical_task_id, Some(heal_path)).await? {
TaskStateLookup::Active(task) => Ok(active_task_report(&task, since).await),
TaskStateLookup::Retrying(status, options) => Ok(empty_task_report_with_options(status, options)),
TaskStateLookup::Retrying(status, options, _) => Ok(empty_task_report_with_options(status, options)),
TaskStateLookup::Completed(completed) => Ok(completed_task_report(&completed, since)),
TaskStateLookup::Queued(options) => Ok(empty_task_report_with_options(HealTaskStatus::Pending, options)),
TaskStateLookup::Queued(options, _) => Ok(empty_task_report_with_options(HealTaskStatus::Pending, options)),
TaskStateLookup::Unrunnable(error) => {
Ok(empty_task_report_with_options(HealTaskStatus::Failed { error }, HealOptions::default()))
}
@@ -2630,9 +2678,9 @@ impl HealManager {
let canonical_task_id = self.canonical_task_id(task_id).await;
match self.lookup_task_state(&canonical_task_id, Some(heal_path)).await? {
TaskStateLookup::Active(task) => Ok(task.get_status().await),
TaskStateLookup::Retrying(status, _) => Ok(status),
TaskStateLookup::Retrying(status, _, _) => Ok(status),
TaskStateLookup::Completed(completed) => Ok(completed.status.clone()),
TaskStateLookup::Queued(_) => Ok(HealTaskStatus::Pending),
TaskStateLookup::Queued(_, _) => Ok(HealTaskStatus::Pending),
TaskStateLookup::Unrunnable(error) => Ok(HealTaskStatus::Failed { error }),
TaskStateLookup::NotFound => {
if self.path_has_task(heal_path).await {
+5
View File
@@ -107,7 +107,9 @@ pub(super) enum QueuePushOutcome {
#[derive(Debug, Clone)]
pub(super) struct CompletedHealStatus {
pub(super) task_id: String,
pub(super) heal_type: HealType,
pub(super) source: HealRequestSource,
/// Options used to execute the task, retained for token-scoped status.
pub(super) options: HealOptions,
pub(super) status: HealTaskStatus,
@@ -135,6 +137,7 @@ impl CompletedHealStatus {
fn measure_retained_bytes(&self) -> usize {
let mut bytes = size_of::<Self>();
let mut add = |amount: usize| bytes = bytes.saturating_add(amount);
add(self.task_id.capacity());
add(self.outcome.as_ref().map_or(0, |outcome| outcome.retained_bytes()));
match &self.heal_type {
HealType::Cluster => {}
@@ -256,7 +259,9 @@ impl CompletedHealStatus {
outcome.finish(Some(crate::heal::outcome::HealAbortReason::Cancelled));
}
let mut snapshot = Self {
task_id: task.id.clone(),
heal_type: task.heal_type.clone(),
source: task.source,
options: task.options.clone(),
status,
progress: Some(task.get_progress().await),
@@ -459,6 +459,8 @@ impl RootHealTerminal {
fn into_completed(self) -> CompletedHealStatus {
CompletedHealStatus {
task_id: self.task_id,
source: HealRequestSource::Admin,
outcome: None,
progress: self.progress,
retained_bytes: std::sync::OnceLock::new(),
+217
View File
@@ -115,6 +115,8 @@ struct MockStorage;
fn completed_retention_fixture(completed_at: SystemTime) -> CompletedHealStatus {
CompletedHealStatus {
task_id: "fixture-token".to_string(),
source: HealRequestSource::Internal,
outcome: None,
heal_type: HealType::Cluster,
options: HealOptions::default(),
@@ -2636,6 +2638,181 @@ async fn test_active_duplicate_token_can_query_and_cancel_original_task() {
);
}
#[tokio::test]
async fn local_automatic_query_preserves_source_path_token_and_state() {
use crate::heal::channel::HealChannelProcessor;
use crate::heal::outcome::{
HealExecutionOutcome, HealObjectDisposition, HealObjectIdentity, HealObjectKind, HealObjectOutcome, HealTaskOutcome,
HealTraversalCoverage,
};
for source in [
HealRequestSource::Scanner,
HealRequestSource::Mrf,
HealRequestSource::AutoHeal,
HealRequestSource::ReadRepair,
HealRequestSource::Admin,
HealRequestSource::Internal,
] {
for state in ["queued", "active", "retrying", "completed", "displaced"] {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = Arc::new(HealManager::new_without_root_recovery_for_test(storage.clone(), None));
let mut request = HealRequest::object("bucket".to_string(), "object".to_string(), None);
request.source = source;
let token = request.id.clone();
let task = Arc::new(HealTask::from_request(request.clone(), storage));
let mut snapshot = CompletedHealStatus::snapshot(&task, HealTaskStatus::Completed).await;
assert_eq!(snapshot.source, source, "completion must retain actual task source");
let mut outcome = HealTaskOutcome::default();
outcome.execution = HealExecutionOutcome::Completed;
outcome.coverage = HealTraversalCoverage::Complete;
outcome.record(HealObjectOutcome {
identity: HealObjectIdentity {
kind: HealObjectKind::Object,
bucket: "bucket".to_string(),
object: "object".to_string(),
version_id: None,
bucket_incarnation_id: Some(uuid::Uuid::nil()),
pool_index: Some(0),
set_index: Some(0),
},
disposition: HealObjectDisposition::VerifiedHealthy,
detail: None,
});
snapshot.outcome = Some(Arc::new(outcome));
snapshot.seqed_items = vec![(
3,
HealResultItem {
bucket: "bucket".to_string(),
object: "object".to_string(),
..Default::default()
},
)];
snapshot.min_seq = 3;
snapshot.next_seq = 4;
let expected_status = match state {
"queued" => {
manager.heal_queue.lock().await.push(request.clone());
HealTaskStatus::Pending
}
"active" => {
manager.active_heals.lock().await.insert(token.clone(), task);
HealTaskStatus::Pending
}
"retrying" => {
manager.retrying_heals.lock().await.insert(
token.clone(),
RetryingHeal {
request: request.clone(),
error: "transient".to_string(),
cancel_token: CancellationToken::new(),
},
);
HealTaskStatus::Retrying {
error: "transient".to_string(),
retry_attempt: request.retry_attempts,
}
}
"completed" => {
manager.completed_heals.lock().await.insert(token.clone(), Arc::new(snapshot));
HealTaskStatus::Completed
}
"displaced" => {
let terminal = record_displaced_terminal(&manager.displaced_terminals, &request);
terminal.status.clone()
}
_ => unreachable!("fixed test states"),
};
let automatic = !matches!(source, HealRequestSource::Admin | HealRequestSource::Internal);
let report = manager
.get_local_automatic_task_report("bucket/object", &token, Some(2))
.await
.expect("local source lookup");
assert_eq!(report.is_some(), automatic, "source={source:?} state={state}");
if let Some(report) = report {
assert_eq!(report.status, expected_status);
if state == "completed" {
let outcome = report.outcome.as_ref().expect("retained structured outcome");
assert_eq!(outcome.objects.len(), 1);
assert_eq!(outcome.objects[0].identity.object, "object");
assert_eq!(outcome.objects[0].identity.pool_index, Some(0));
assert_eq!(outcome.objects[0].identity.set_index, Some(0));
assert_eq!(outcome.objects[0].disposition, HealObjectDisposition::VerifiedHealthy);
assert_eq!(report.min_seq, 3);
assert_eq!(report.next_seq, 4);
assert_eq!(report.result_items.len(), 1);
assert_eq!(report.result_items[0].object, "object");
}
let processor = HealChannelProcessor::new(manager.clone());
let local = processor
.execute_local_automatic_query("bucket/object", &token, Some(2))
.await
.expect("local response")
.expect("automatic task response");
let direct = processor
.execute_query_request_since("bucket/object".to_string(), token.clone(), Some(2))
.await
.expect("original query response");
assert!(local.success);
assert_eq!(local.request_id, direct.request_id);
assert_eq!(local.data, direct.data, "same report encoding and cursor contract");
assert_eq!(local.error, direct.error);
let coordinator = Arc::new(HealManager::new_without_root_recovery_for_test(Arc::new(MockStorage), None));
let missing = HealChannelProcessor::new(coordinator)
.execute_query_request("bucket/object".to_string(), token.clone())
.await
.expect("coordinator query");
let body: serde_json::Value =
serde_json::from_slice(missing.data.as_deref().expect("coordinator response")).expect("coordinator JSON");
assert_eq!(body["summary"], "notFound", "a remote manager does not own the automatic task");
if state == "completed" {
let completed = manager
.completed_heals
.lock()
.await
.get(&token)
.cloned()
.expect("original completion");
manager
.completed_heals
.lock()
.await
.insert("retired-alias".to_string(), completed);
assert!(
manager
.get_local_automatic_task_report("bucket/object", "retired-alias", None)
.await
.expect("retired alias lookup")
.is_none()
);
}
}
assert!(
manager
.get_local_automatic_task_report("bucket/other", &token, None)
.await
.expect("wrong path lookup")
.is_none()
);
assert!(
manager
.get_local_automatic_task_report("bucket/object", "unknown", None)
.await
.expect("unknown token lookup")
.is_none()
);
manager.insert_task_alias("admin-alias", &token).await;
assert!(
manager
.get_local_automatic_task_report("bucket/object", "admin-alias", None)
.await
.expect("alias lookup")
.is_none()
);
}
}
}
#[tokio::test]
async fn test_queued_duplicate_token_can_query_and_cancel_original_request() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
@@ -3451,6 +3628,38 @@ async fn test_retry_request_rescans_batch_when_all_exhausted_objects_are_retryab
}
}
#[tokio::test]
async fn admin_exhausted_batches_do_not_restart_the_traversal() {
for heal_type in [
HealType::Bucket {
bucket: "bucket".to_owned(),
},
HealType::Prefix {
bucket: "bucket".to_owned(),
prefix: "prefix/".to_owned(),
},
HealType::Cluster,
] {
let mut request = HealRequest::new(heal_type, HealOptions::default(), HealPriority::Normal);
request.source = HealRequestSource::Admin;
let task = HealTask::from_request(request, Arc::new(MockStorage));
let result = Err(task
.record_batch_failure(BatchHealFailure {
scope: "completed-admin-traversal".to_owned(),
failed: 1,
retryable: 1,
permanent: 0,
first_object: "exhausted-object".to_owned(),
first_error: Error::Storage(EcstoreError::SlowDown).to_string(),
})
.await);
assert!(
retry_request_for_result_with_budget(&task, &result).await.is_none(),
"the exhausted admin batch must retain its original object receipts"
);
}
}
#[tokio::test]
async fn test_retry_request_does_not_rescan_cancelled_or_timed_out_retryable_batch() {
for terminal_error in [Error::TaskCancelled, Error::TaskTimeout] {
@@ -3533,6 +3742,8 @@ async fn insert_retrying_request(manager: &HealManager, request: HealRequest) ->
Arc::new(CompletedHealStatus {
progress: None,
outcome: None,
source: request.source,
task_id: request.id.clone(),
retained_bytes: std::sync::OnceLock::new(),
heal_type: request.heal_type,
options: request.options.clone(),
@@ -4381,6 +4592,8 @@ async fn test_retrying_completion_outranks_the_queue_for_the_same_id() {
Arc::new(CompletedHealStatus {
progress: None,
outcome: None,
source: request.source,
task_id: request.id.clone(),
retained_bytes: std::sync::OnceLock::new(),
heal_type: request.heal_type.clone(),
options: request.options.clone(),
@@ -4420,6 +4633,8 @@ async fn test_get_task_status_reads_recent_completed_status() {
Arc::new(CompletedHealStatus {
progress: None,
outcome: None,
source: HealRequestSource::Internal,
task_id: "completed-token".to_string(),
retained_bytes: std::sync::OnceLock::new(),
heal_type: HealType::Bucket {
bucket: "bucket".to_string(),
@@ -4453,6 +4668,8 @@ async fn test_get_task_report_for_path_reads_completed_items() {
Arc::new(CompletedHealStatus {
progress: None,
outcome: None,
source: HealRequestSource::Internal,
task_id: "completed-token".to_string(),
retained_bytes: std::sync::OnceLock::new(),
heal_type: HealType::Object {
bucket: "bucket".to_string(),
@@ -115,6 +115,8 @@ fn admin_request(heal_type: HealType) -> HealRequest {
fn completed_admin_status(heal_type: &HealType, completed_at: SystemTime) -> CompletedHealStatus {
CompletedHealStatus {
task_id: "admin-fixture".to_string(),
source: HealRequestSource::Admin,
outcome: None,
heal_type: heal_type.clone(),
options: HealOptions::default(),
@@ -1237,6 +1239,8 @@ async fn root_recovery_completed_non_root_admin_is_queryable_after_restart() {
.expect("durable bucket responsibility");
let completed = CompletedHealStatus {
outcome: None,
source: request.source,
task_id: request.id.clone(),
heal_type: request.heal_type.clone(),
options: request.options.clone(),
status: HealTaskStatus::Completed,
+3 -3
View File
@@ -306,9 +306,9 @@ impl HealTask {
let mut progress = self.progress.write().await;
progress.update_stage(3, 3);
}
Err(Error::TaskExecutionFailed {
message: format!("Failed to heal bucket {bucket}: {e}"),
})
// Keep storage/quorum identity for the cluster retry classifier.
// The structured log above already carries the bucket context.
Err(e)
}
}
}
+89
View File
@@ -782,6 +782,85 @@ mod canonical_outcome {
);
}
#[tokio::test(start_paused = true)]
async fn cluster_bucket_write_quorum_retries_preserve_budget_and_truthful_coverage() {
for (failures, succeeds) in [(1, true), (4, false)] {
let storage = Arc::new(MockStorage {
listed_buckets: Mutex::new(Some(vec!["bucket-a".to_string(), "bucket-b".to_string()])),
typed_bucket_heal_errors: Mutex::new(HashMap::from([(
"bucket-a".to_string(),
(0..failures)
.map(|_| Error::Storage(EcstoreError::ErasureWriteQuorum))
.collect(),
)])),
..Default::default()
});
let task = HealTask::from_request(
HealRequest::new(
HealType::Cluster,
HealOptions {
recursive: true,
timeout: None,
..Default::default()
},
HealPriority::Normal,
),
storage.clone(),
);
let result = task.execute().await;
assert_eq!(
result.is_ok(),
succeeds,
"original cluster task must honor its bounded bucket retry budget"
);
let outcome = task.get_outcome().await;
if succeeds {
assert_eq!(outcome.execution, HealExecutionOutcome::Completed);
assert_eq!(outcome.coverage, HealTraversalCoverage::Complete);
} else {
assert_eq!(outcome.execution, HealExecutionOutcome::Aborted(HealAbortReason::Untraversable));
assert_eq!(outcome.coverage, HealTraversalCoverage::Partial);
let failure = task.take_batch_failure().await.expect("exhausted bucket retains its failure");
assert_eq!((failure.failed, failure.retryable, failure.permanent), (1, 1, 0));
}
let calls = storage.bucket_heal_calls.lock().expect("original bucket calls");
assert_eq!(
calls.iter().filter(|bucket| bucket.as_str() == "bucket-a").count(),
if succeeds { 2 } else { 4 }
);
assert_eq!(calls.last().map(String::as_str), Some("bucket-b"), "later buckets must still be visited");
}
}
#[tokio::test]
async fn bucket_prepass_preserves_typed_quorum_and_terminal_access_denial() {
for error in [
Error::Storage(EcstoreError::ErasureWriteQuorum),
Error::Disk(DiskError::FileAccessDenied),
] {
let retryable = error.is_recoverable_heal();
let storage = Arc::new(MockStorage {
typed_bucket_heal_errors: Mutex::new(HashMap::from([("bucket-a".to_string(), VecDeque::from([error]))])),
..Default::default()
});
let task = bucket_task(storage.clone());
let observed = task
.heal_bucket("bucket-a")
.await
.expect_err("bucket prepass must retain the storage error");
assert!(matches!(
observed,
Error::Storage(EcstoreError::ErasureWriteQuorum) | Error::Disk(DiskError::FileAccessDenied)
));
assert_eq!(observed.is_recoverable_heal(), retryable);
assert_eq!(storage.bucket_heal_calls.lock().expect("bucket calls").as_slice(), ["bucket-a"]);
assert!(
storage.heal_object_calls.lock().expect("object calls").is_empty(),
"failed prepass must not heal objects"
);
}
}
#[tokio::test(start_paused = true)]
async fn cancellation_and_deadline_leave_partial_coverage() {
for cancel in [false, true] {
@@ -1538,6 +1617,7 @@ struct MockStorage {
listed_buckets: Mutex<Option<Vec<String>>>,
bucket_discovery_incomplete: bool,
bucket_heal_errors: Mutex<HashMap<String, VecDeque<&'static str>>>,
typed_bucket_heal_errors: Mutex<HashMap<String, VecDeque<Error>>>,
bucket_heal_calls: Mutex<Vec<String>>,
delete_recovery_results: Mutex<HashMap<String, Result<Option<HealResultItem>>>>,
delete_recovery_calls: Mutex<Vec<String>>,
@@ -2337,6 +2417,15 @@ impl HealStorageAPI for MockStorage {
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
self.bucket_heal_calls.lock().unwrap().push(bucket.to_string());
self.bucket_heal_opts.lock().unwrap().push(*opts);
if let Some(error) = self
.typed_bucket_heal_errors
.lock()
.unwrap()
.get_mut(bucket)
.and_then(VecDeque::pop_front)
{
return Err(error);
}
if let Some(message) = self
.bucket_heal_errors
.lock()
@@ -550,3 +550,159 @@ fn admin_normal_protected_repairs_report_repaired_for_data_and_parity() {
.join()
.expect("heal fixture result");
}
#[test]
#[serial]
fn admin_mixed_batch_retains_repairs_after_object_retry_exhaustion() {
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("mixed batch runtime")
.block_on(async {
use rustfs_heal::heal::{
manager::{HealConfig, HealManager},
outcome::{HealExecutionOutcome, HealFailureClass, HealTraversalCoverage},
task::{HealOptions, HealPriority, HealRequest, HealTaskStatus, HealType},
};
use rustfs_heal_contracts::heal_channel::HealRequestSource;
use std::sync::Arc;
let root = tempfile::tempdir().expect("mixed batch fixture");
let env = TestECStoreEnv::builder().base_dir(root.path()).build().await;
let bucket = "admin-mixed-batch";
env.make_bucket(bucket, false).await;
let set = env.ecstore.pools[0].get_disks(0);
let disks = set.disks.read().await.iter().flatten().cloned().collect::<Vec<_>>();
let mut originals = Vec::new();
for (object, missing, byte) in [("healthy", 0, 0x11), ("repaired", 1, 0x52), ("failed", 3, 0xa3)] {
env.ecstore
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(vec![byte; 5 * 1024 * 1024 + 123]),
&ObjectOptions {
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Protected),
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("commit complete fixture");
for (slot, disk) in disks.iter().enumerate() {
let meta = disk
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("fixture metadata");
assert_eq!(meta.erasure.data_blocks, 2, "failed fixture must retain R-1 shards");
let path = env.disk_paths[slot]
.join(bucket)
.join(object)
.join(meta.data_dir.expect("external shard").to_string())
.join("part.1");
let bytes = tokio::fs::read(&path).await.expect("original part");
if slot < missing {
tokio::fs::remove_file(&path).await.expect("inject missing part");
}
originals.push((object, path, bytes, slot < missing));
}
}
let storage = Arc::new(ECStoreHealStorage::new(env.ecstore.clone()));
let manager = HealManager::new(
storage.clone(),
Some(HealConfig {
enable_auto_heal: false,
..Default::default()
}),
);
manager.start().await.expect("start mixed batch manager");
let mut request = HealRequest::new(
HealType::Bucket {
bucket: bucket.to_owned(),
},
HealOptions {
recursive: true,
scan_mode: HealScanMode::Deep,
pool_index: Some(0),
set_index: Some(0),
timeout: Some(Duration::from_secs(150)),
..Default::default()
},
HealPriority::High,
);
request.source = HealRequestSource::Admin;
let token = request.id.clone();
manager.submit_heal_request(request).await.expect("admit original mixed task");
let report = tokio::time::timeout(Duration::from_secs(150), async {
loop {
let report = manager.get_task_report(&token).await.expect("same-token report");
if matches!(
report.status,
HealTaskStatus::Completed | HealTaskStatus::Failed { .. } | HealTaskStatus::Timeout
) {
break report;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("mixed task terminal deadline");
manager.stop().await.expect("stop mixed task manager");
for (object, path, original, missing) in originals {
if object == "failed" && missing {
assert!(!path.exists(), "below-R failure must not invent a shard");
} else {
assert_eq!(tokio::fs::read(&path).await.expect("preserved or repaired shard"), original);
}
}
assert!(matches!(report.status, HealTaskStatus::Failed { .. }), "{report:?}");
let outcome = report.outcome.as_ref().expect("canonical mixed outcome");
assert_eq!(outcome.execution, HealExecutionOutcome::CompletedWithErrors);
assert_eq!(outcome.coverage, HealTraversalCoverage::Complete);
assert_eq!(
(
outcome.counters.processed,
outcome.counters.healed,
outcome.counters.unchanged,
outcome.counters.failed,
outcome.counters.skipped,
outcome.counters.unknown,
outcome.counters.attempt_failures
),
(3, 1, 1, 1, 0, 0, 4),
"{outcome:?}"
);
for (object, disposition) in [
("healthy", HealObjectDisposition::VerifiedHealthy),
("repaired", HealObjectDisposition::Repaired),
("failed", HealObjectDisposition::Failed(HealFailureClass::RetryExhausted)),
] {
let receipt = outcome
.objects
.iter()
.find(|item| item.identity.object == object)
.expect("exact object receipt");
assert_eq!(receipt.disposition, disposition);
}
drop(manager);
let restarted = HealManager::new(
storage,
Some(HealConfig {
enable_auto_heal: false,
..Default::default()
}),
);
restarted.start().await.expect("restart with retained failed batch");
let restored = restarted.get_task_report(&token).await.expect("original token after restart");
assert_eq!(restored.status, report.status);
assert_eq!(restored.outcome, report.outcome);
assert_eq!(restarted.get_queue_length().await, 0, "terminal batch must not replay");
restarted.stop().await.expect("stop restarted mixed batch manager");
});
})
.expect("mixed batch thread")
.join()
.expect("mixed batch result");
}
+7 -5
View File
@@ -20,10 +20,10 @@ use std::time::{Duration, Instant, SystemTime};
use crate::ReplTargetSizeSummary;
use crate::data_usage_define::{
DATA_USAGE_SCAN_CHECKPOINT_VERSION, DataUsageCache, DataUsageCacheInfo, DataUsageEntry, DataUsageHash, DataUsageHashMap,
DataUsageRawEnumerationCursor, DataUsageScanCheckpoint, DataUsageScanCheckpointReason, MAX_DATA_USAGE_CACHE_DEPTH,
PendingScannerHeal, PendingScannerHealKind, ScannerSizeSummaryExt, SizeReconciliationEntry, SizeSummary, hash_path,
scan_frontier_covers_path,
DATA_USAGE_SCAN_CHECKPOINT_VERSION, DataUsageCache, DataUsageCacheInfo, DataUsageCacheSource, DataUsageEntry, DataUsageHash,
DataUsageHashMap, DataUsageRawEnumerationCursor, DataUsageScanCheckpoint, DataUsageScanCheckpointReason,
MAX_DATA_USAGE_CACHE_DEPTH, PendingScannerHeal, PendingScannerHealKind, ScannerSizeSummaryExt, SizeReconciliationEntry,
SizeSummary, hash_path, scan_frontier_covers_path,
};
use crate::error::ScannerError;
use crate::raw_page_index::{RawEnumerationPageIndex, RawEnumerationPageWriter};
@@ -1494,7 +1494,7 @@ impl FolderScanner {
let candidate_type = pending_scanner_heal_candidate_type(kind);
let priority = request.priority;
let scan_mode = request.scan_mode.unwrap_or(self.scan_mode);
let result = match send_scanner_heal_request(candidate_type, request).await {
let result = match send_scanner_heal_request(candidate_type, request, scanner_disk_heal_scope(&self.local_disk)).await {
Ok(result) => result,
Err(err) => {
self.update_pending_scanner_heal_after_admission(
@@ -1960,6 +1960,7 @@ impl FolderScanner {
lifecycle: active_life_cycle.clone(),
object_lock: active_object_lock.clone(),
replication: active_replication.clone(),
heal_scope: None,
heal_enabled,
heal_bitrot: self.scan_mode == HealScanMode::Deep,
debug: self.data_usage_scanner_debug,
@@ -3205,6 +3206,7 @@ pub(crate) async fn scan_data_folder_scoped(
mod item_actions;
mod ledger;
pub(crate) use item_actions::scanner_disk_heal_scope;
use item_actions::*;
pub use item_actions::{GetSizeFn, ScannerItem};
use ledger::*;
@@ -474,10 +474,23 @@ pub(super) fn record_heal_candidate_admission(
}
}
pub(crate) fn scanner_disk_heal_scope(disk: &Disk) -> Option<DataUsageCacheSource> {
let location = disk.get_disk_location();
location.valid().then_some(())?;
Some(DataUsageCacheSource::new(location.pool_idx?, location.set_idx?))
}
pub(super) async fn send_scanner_heal_request(
candidate_type: &'static str,
request: HealChannelRequest,
mut request: HealChannelRequest,
scope: Option<DataUsageCacheSource>,
) -> Result<HealAdmissionResult, ScannerError> {
if request.object_prefix.as_deref().is_some_and(|object| !object.is_empty()) {
let scope = scope
.ok_or_else(|| ScannerError::Other("scanner object heal requires the scanning disk's pool and set".to_string()))?;
request.pool_index = Some(scope.pool_index);
request.set_index = Some(scope.set_index);
}
let priority = request.priority;
let trace_context = scanner_heal_candidate_trace_context(&request);
match send_heal_request_with_admission(request).await {
@@ -533,6 +546,8 @@ pub struct ScannerItem {
pub lifecycle: Option<Arc<BucketLifecycleConfiguration>>,
pub object_lock: Option<Arc<ObjectLockConfiguration>>,
pub replication: Option<Arc<ReplicationConfig>>,
/// Actual scanning-disk location; refreshed by the disk reader before actions.
pub heal_scope: Option<DataUsageCacheSource>,
pub heal_enabled: bool,
pub heal_bitrot: bool,
pub debug: bool,
@@ -1153,6 +1168,7 @@ impl ScannerItem {
scan_mode,
HealChannelPriority::Low,
),
self.heal_scope,
)
.await;
@@ -1345,6 +1361,7 @@ mod tests {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
+92 -4
View File
@@ -311,7 +311,10 @@ async fn build_test_scanner() -> (FolderScanner, std::path::PathBuf) {
.await
.expect("failed to create test directory");
let endpoint = Endpoint::try_from(temp_dir.to_string_lossy().as_ref()).expect("failed to create endpoint");
let mut endpoint = Endpoint::try_from(temp_dir.to_string_lossy().as_ref()).expect("failed to create endpoint");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(0);
let disk = new_disk(
&endpoint,
&DiskOption {
@@ -741,6 +744,7 @@ fn test_classify_get_size_failure_marks_metadata_heal_object_path() {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -772,6 +776,7 @@ fn test_classify_get_size_failure_records_transient_metadata_error_without_heal(
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -828,6 +833,7 @@ async fn test_heal_replication_only_queues_pending_null_deletes() {
lifecycle: None,
object_lock: None,
replication: Some(Arc::new(ReplicationConfig::new(None, None))),
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -2217,6 +2223,7 @@ async fn test_heal_actions_returns_actual_size_without_inline_heal() {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: true,
heal_bitrot: true,
debug: false,
@@ -2325,7 +2332,7 @@ async fn test_scan_folder_exits_when_abandoned_child_listing_finishes() {
let _guard = TestGuard::new(60, 100, &mut scanner, temp_dir.clone());
let heal_starts = Arc::new(AtomicUsize::new(0));
let heal_starts_clone = heal_starts.clone();
let healed_versions = Arc::new(Mutex::new(Vec::<Option<String>>::new()));
let healed_versions = Arc::new(Mutex::new(Vec::new()));
let healed_versions_clone = healed_versions.clone();
let mut heal_rx =
rustfs_heal_contracts::heal_channel::init_heal_channel().expect("heal channel should initialize once for scanner tests");
@@ -2339,7 +2346,7 @@ async fn test_scan_folder_exits_when_abandoned_child_listing_finishes() {
healed_versions_clone
.lock()
.expect("heal version capture lock should not be poisoned")
.push(request.object_version_id);
.push((request.object_prefix, request.object_version_id, request.pool_index, request.set_index));
let _ = response_tx.send(Ok(HealAdmissionResult::Accepted));
}
}
@@ -2417,9 +2424,90 @@ async fn test_scan_folder_exits_when_abandoned_child_listing_finishes() {
.lock()
.expect("heal version capture lock should not be poisoned")
.iter()
.any(|version| version.as_deref() == Some(orphan_version_text.as_str())),
.any(|(_, version, _, _)| version.as_deref() == Some(orphan_version_text.as_str())),
"sub-quorum orphan version must be submitted as an exact heal candidate"
);
assert!(
healed_versions
.lock()
.expect("heal scope capture lock should not be poisoned")
.iter()
.filter(|(object, _, _, _)| object.is_some())
.all(|(_, _, pool, set)| (*pool, *set) == (Some(0), Some(0))),
"high-priority discovered objects must retain the scanning disk's scope"
);
let bucket_admission = scanner
.send_required_scanner_heal_request(
PendingScannerHealKind::Bucket,
bucket.to_string(),
None,
None,
build_bucket_heal_request(bucket.to_string(), HealChannelPriority::High),
)
.await
.expect("submit bucket request");
assert_eq!(bucket_admission, HealAdmissionResult::Accepted);
assert_eq!(
healed_versions.lock().expect("read captured bucket scope").last(),
Some(&(None, None, None, None)),
"bucket healing must retain its cross-pool scope"
);
scanner.new_cache.info.name = bucket.to_string();
scanner.new_cache.info.pending_heals = vec![pending_heal(
PendingScannerHealKind::Object,
bucket,
Some("pending-object"),
Some(&orphan_version_text),
0,
1,
)];
scanner
.retry_pending_scanner_heals()
.await
.expect("retry persisted object heal");
assert_eq!(
healed_versions.lock().expect("read retried object scope").last(),
Some(&(Some("pending-object".to_string()), Some(orphan_version_text.clone()), Some(0), Some(0))),
"persisted retries must bind the current scanning disk"
);
let unscoped_endpoint = Endpoint::try_from(temp_dir.to_string_lossy().as_ref()).expect("parse uninitialized endpoint");
scanner.local_disk = new_disk(
&unscoped_endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("open disk without initialized location");
let request_count = healed_versions.lock().expect("count admitted requests").len();
let deferred = scanner
.send_required_scanner_heal_request(
PendingScannerHealKind::Object,
bucket.to_string(),
Some("unscoped-object".to_string()),
Some(orphan_version_text.clone()),
build_object_heal_request(
bucket.to_string(),
"unscoped-object".to_string(),
Some(orphan_version_text),
HealScanMode::Deep,
HealChannelPriority::High,
),
)
.await
.expect("defer object with unknown disk location");
assert_eq!(deferred, HealAdmissionResult::Full);
assert_eq!(healed_versions.lock().expect("count unchanged admissions").len(), request_count);
assert!(
scanner
.new_cache
.info
.pending_heals
.iter()
.any(|entry| entry.object.as_deref() == Some("unscoped-object")),
"an unscoped high-priority object must remain pending"
);
for (path, expected) in expected_metadata {
assert_eq!(
tokio::fs::read(&path)
+2
View File
@@ -130,6 +130,8 @@ impl ScannerIODisk for Disk {
// `object_infos`.
global_metrics().record_scanner_versions_scanned(object_infos.len() as u64);
// A caller-provided item or persisted usage cache cannot choose another set.
item.heal_scope = crate::scanner_folder::scanner_disk_heal_scope(self);
item.apply_actions(object_infos, lock_config, versioning_config, tier_names, &mut size_summary)
.await;
+4
View File
@@ -1765,6 +1765,7 @@ async fn scanner_item_object_lock_uses_cached_config() {
lifecycle: None,
object_lock: Some(cached.clone()),
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -4370,6 +4371,7 @@ async fn get_size_treats_missing_metadata_as_skip_file() {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -4425,6 +4427,7 @@ async fn get_size_marks_corrupt_metadata_for_heal() {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -4507,6 +4510,7 @@ async fn get_size_counts_delete_markers_separately_from_versions() {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -0,0 +1,139 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_filemeta::{FileInfo, FileMeta};
use rustfs_heal_contracts::heal_channel::{HealAdmissionResult, HealChannelCommand, HealRequestSource, init_heal_channel};
use rustfs_scanner::DataUsageCacheSource;
use rustfs_scanner::scanner_folder::ScannerItem;
use rustfs_scanner::scanner_io::ScannerIODisk;
use tokio::sync::mpsc;
use super::storage_api::lifecycle::{DiskOption, Endpoint, STORAGE_FORMAT_FILE, new_disk};
#[test]
fn scanner_object_requests_bind_the_actual_disk_scope() {
const CHILD: &str = "RUSTFS_TEST_SCANNER_SCOPE_CHILD";
if std::env::var_os(CHILD).is_none() {
let status = std::process::Command::new(std::env::current_exe().expect("locate integration test executable"))
.args([
"--exact",
"scanner_heal_scope::scanner_object_requests_bind_the_actual_disk_scope",
"--nocapture",
])
.env(CHILD, "1")
.status()
.expect("run scanner scope regression in an isolated process");
assert!(status.success(), "isolated scanner scope regression failed: {status}");
return;
}
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build isolated scanner runtime")
.block_on(check_scanner_object_scope());
}
async fn check_scanner_object_scope() {
let mut commands = init_heal_channel().expect("initialize isolated scanner request channel");
let (captured, mut requests) = mpsc::unbounded_channel();
let responder = tokio::spawn(async move {
while let Some(command) = commands.recv().await {
if let HealChannelCommand::Start { request, response_tx } = command {
captured.send(request).expect("retain scanner request");
let _ = response_tx.send(Ok(HealAdmissionResult::Accepted));
}
}
});
for (pool, set, disk_index) in [
(Some(0), Some(0), Some(1)),
(Some(2), Some(3), Some(4)),
(None, Some(3), Some(4)),
(Some(2), None, Some(4)),
(Some(2), Some(3), None),
(None, None, None),
] {
for version_id in [None, Some(uuid::Uuid::from_u128(42))] {
let root = tempfile::tempdir().expect("create scanner disk");
let bucket = "scanner-scope-bucket";
let object = "object";
let path = root.path().join(bucket).join(object).join(STORAGE_FORMAT_FILE);
tokio::fs::create_dir_all(path.parent().expect("metadata parent"))
.await
.expect("create object directory");
let mut info = FileInfo::new(object, 4, 2);
info.volume = bucket.to_string();
info.name = object.to_string();
info.size = 1;
info.version_id = version_id;
info.data_dir = Some(uuid::Uuid::new_v4());
info.erasure.index = 1;
info.mod_time = Some(time::OffsetDateTime::from_unix_timestamp(1).expect("valid object timestamp"));
info.add_object_part(1, "fixture-etag".to_string(), 1, info.mod_time, 1, None, None);
let mut meta = FileMeta::new();
meta.add_version(info).expect("encode original null version");
let original = meta.marshal_msg().expect("marshal original metadata");
tokio::fs::write(&path, &original).await.expect("write original metadata");
let mut endpoint = Endpoint::try_from(root.path().to_string_lossy().as_ref()).expect("parse scanning disk endpoint");
if let Some(pool) = pool {
endpoint.set_pool_index(pool);
}
if let Some(set) = set {
endpoint.set_set_index(set);
}
if let Some(disk_index) = disk_index {
endpoint.set_disk_index(disk_index);
}
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("open actual scanning disk");
let item = ScannerItem {
path: path.to_string_lossy().to_string(),
bucket: bucket.to_string(),
prefix: object.to_string(),
object_name: STORAGE_FORMAT_FILE.to_string(),
file_type: tokio::fs::metadata(&path).await.expect("stat original metadata").file_type(),
lifecycle: None,
object_lock: None,
replication: None,
// This foreign caller-provided scope must never reach admission.
heal_scope: Some(DataUsageCacheSource::new(99, 98)),
heal_enabled: true,
heal_bitrot: true,
debug: false,
};
disk.get_size_with_tier_names(item, &[]).await.expect("scan original object");
if let (Some(pool), Some(set), Some(_)) = (pool, set, disk_index) {
let request = requests.try_recv().expect("scanner submitted one real object request");
assert_eq!(request.pool_index, Some(pool));
assert_eq!(request.set_index, Some(set));
assert_eq!(request.bucket, bucket);
assert_eq!(request.object_prefix.as_deref(), Some(object));
assert_eq!(request.object_version_id, version_id.map(|value| value.to_string()));
assert_eq!(request.source, HealRequestSource::Scanner);
assert_eq!(request.no_lock, None);
assert_eq!(request.recreate_missing, Some(false));
}
assert!(requests.try_recv().is_err(), "no duplicate or unscoped request may be admitted");
assert_eq!(tokio::fs::read(&path).await.expect("read original metadata"), original);
}
}
responder.abort();
}
@@ -39,6 +39,9 @@ use uuid::Uuid;
mod storage_api;
#[path = "lifecycle/scanner_heal_scope.rs"]
mod scanner_heal_scope;
use storage_api::lifecycle::{
BUCKET_LIFECYCLE_CONFIG, BUCKET_VERSIONING_CONFIG, BucketOperations, BucketOptions, BucketVersioningSys, CompletePart,
DeleteAfterObjectLockSnapshotBarrier, DiskOption, ECStore, EcstoreError, Endpoint, EndpointServerPools, Endpoints,
@@ -467,6 +470,7 @@ async fn scan_object_with_lifecycle(disk_path: &Path, bucket: &str, object: &str
lifecycle,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -504,6 +508,7 @@ async fn scan_object_metadata(disk_path: &Path, bucket: &str, object: &str) {
lifecycle: None,
object_lock: None,
replication: None,
heal_scope: None,
heal_enabled: false,
heal_bitrot: false,
debug: false,
@@ -4,6 +4,10 @@ Admin heal tokens retain their terminal status for ten minutes from the original
## Publication and Recovery
Authenticated token queries first inspect the receiving node's in-memory automatic tasks. An original Scanner, MRF, automatic-Heal, or read-repair token can return its path-bound snapshot on that node, including the retained outcome and incremental cursor. The task source and original ID remain attached to the completion snapshot; aliases cannot enter this local query path. The local lookup never reads administrator recovery records.
Administrator tokens and unknown local identities continue through the cluster coordinator. Starting and cancelling tasks retain their coordinator route. Querying another node does not discover or aggregate a remote automatic task, and this path does not extend automatic-task retention across process restart.
The original schema-1 `terminal-root-heal-<token>.json` remains the commit marker. Its separate `heal-terminal-report-<token>.json` report has its own schema version and embeds the exact terminal identity. Both files belong to the same coordinator disk as the pending intent.
Publication writes the report through the storage owner's conditional file update, writes the terminal marker, and then conditionally removes the pending intent. Recovery never treats a report without its marker as committed. A committed terminal continues to suppress stale pending work even when its report is unreadable. An initial publication failure leaves the existing responsibility available for recovery.
+2
View File
@@ -30,6 +30,8 @@ The [scanner cache cost profile](scanner-cache-cost.md) separates clone, subtree
The [Pool layout compatibility reference](pool-layout-compatibility.md) defines the topology and EC regression matrix for single-drive, single-node multi-drive, and multi-node expansion pools.
The [immutable validation candidate reference](validation-candidates.md) describes isolated package builds and exact candidate resolution for the current auto-testing fleet.
## Naming conventions
### Reserved test-name substrings (migration gate)
+52
View File
@@ -0,0 +1,52 @@
# Immutable validation candidates
Canonical validation on the current auto-testing fleet consumes one exact
package, source commit and candidate-bound tool build. A successful package
build is provenance evidence, not a canonical test PASS.
The scheduled Functional Chain continues to resolve successful Nightly GNU
Build attempts from `main`. Optional manual validation can also resolve a
validation package built without publishing nightly aliases or release assets.
## Build a validation package
Dispatch **RustFS Functional Chain** from the desired driver revision with
only `validation_package_sha` set to the full 40-character RustFS source SHA.
Keep candidate, case, helper, tools and cleanup inputs empty or false. The
request validator rejects mixed package/fleet execution before building.
The package job calls `rustfs-validation-package.yml`, checks out the requested
source, confirms its Git SHA and builds the ordinary release binary and DEB.
This job uses build runners and does not acquire or mutate the Heal fleet.
The same workflow can be dispatched directly with its `source_sha` input when
it is available on the repository's default branch.
Publication requires complete R2 configuration, conditional `PutObject` with
`If-None-Match: *`, and a public download whose SHA-256 matches the local DEB.
Failure emits no candidate manifest. An existing object is never overwritten.
The only object path is:
```text
artifacts/rustfs/packages/validation/runs/<run>/<attempt>/<package-sha256>/rustfs.deb
```
The schema-3 artifact `validation-candidate-<run>-<attempt>` records the
workflow SHA independently of the exact source SHA and source ref, the
`validation` channel, build run/attempt, immutable URL and package checksum.
The resolver verifies the successful manual run, repository/workflow identity,
exact artifact name and digest, archive members, schema and immutable URL.
The ordinary scheduled chain does not enable validation package resolution.
## Validate the candidate
After the package run succeeds, dispatch Functional Chain with its exact
`build_run_id` and `build_run_attempt`, a canonical `validation_case`, and an
exact `auto_testing_sha`. Heal also requires a new tool build verified against
that source and helper revision. Reassess source-bound contracts and native
prerequisites when the source changes; an older tool artifact does not certify
a new candidate.
The fleet lane retains the shared environment lock, per-case evidence and
cleanup gate. Record new results separately. A package build, native test,
diagnostic replay, clean recovery or partial topology result cannot replace
complete original source assertions or rewrite a retained canonical FAIL.
+95 -13
View File
@@ -1051,6 +1051,21 @@ async fn route_cluster_heal_control(
})
}
async fn query_heal_status_on_owner<L, C, CF>(
local: L,
coordinator: C,
) -> S3Result<rustfs_heal_contracts::heal_channel::HealChannelResponse>
where
L: Future<Output = S3Result<Option<rustfs_heal_contracts::heal_channel::HealChannelResponse>>>,
C: FnOnce() -> CF,
CF: Future<Output = S3Result<rustfs_heal_contracts::heal_channel::HealChannelResponse>>,
{
match local.await? {
Some(response) => Ok(response),
None => coordinator().await,
}
}
async fn execute_after_heal_start_preflight<P, PF, E, EF, T>(
endpoints: &EndpointServerPools,
options: &HealOpts,
@@ -1511,20 +1526,41 @@ impl Operation for HealHandler {
let heal_path = encode_heal_control_path(&hip.bucket, &hip.obj_prefix);
if !hip.client_token.is_empty() && !hip.force_start && !hip.force_stop {
let client_token = hip.client_token.clone();
let request_id = uuid::Uuid::new_v4().to_string();
let context = app_context
.clone()
.ok_or_else(|| cluster_heal_control_unavailable("app_context_unavailable"))?;
let route = prepare_heal_control_route(&context)?;
let envelope = rustfs_protos::heal_control::Envelope::query(
request_id.clone(),
new_heal_control_metadata(&route)?,
heal_path,
client_token.clone(),
hip.since_seq,
// Automatic workers run on their owning node. Only an exact
// path/token/source snapshot can answer locally; Admin tokens and
// unknown identities retain the coordinator authority boundary.
let response = query_heal_status_on_owner(
async {
let Some(manager) = rustfs_heal::get_heal_manager() else {
return Ok(None);
};
let processor = rustfs_heal::HealChannelProcessor::new(manager.clone());
timeout(
PEER_HEAL_STATUS_TIMEOUT,
processor.execute_local_automatic_query(&heal_path, &client_token, hip.since_seq),
)
.await
.map_err(|_| cluster_heal_control_unavailable("local_task_query_expired"))?
.map_err(|_| cluster_heal_control_unavailable("local_task_query_failed"))
},
|| async {
let request_id = uuid::Uuid::new_v4().to_string();
let context = app_context
.clone()
.ok_or_else(|| cluster_heal_control_unavailable("app_context_unavailable"))?;
let route = prepare_heal_control_route(&context)?;
let envelope = rustfs_protos::heal_control::Envelope::query(
request_id.clone(),
new_heal_control_metadata(&route)?,
heal_path.clone(),
client_token.clone(),
hip.since_seq,
)
.map_err(|err| s3_error!(InternalError, "encode heal control query failed: {err}"))?;
submit_cluster_heal_channel_command(context, route, envelope, &request_id, client_token.clone()).await
},
)
.map_err(|err| s3_error!(InternalError, "encode heal control query failed: {err}"))?;
let response = submit_cluster_heal_channel_command(context, route, envelope, &request_id, client_token).await?;
.await?;
if !response.success {
return Err(s3_error!(
InternalError,
@@ -1972,6 +2008,52 @@ mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use uuid::Uuid;
#[tokio::test]
async fn local_automatic_heal_query_preserves_owner_and_coordinator_errors() {
let response = || rustfs_heal_contracts::heal_channel::HealChannelResponse {
request_id: "original-token".to_string(),
success: true,
data: Some(b"retained-owner-report".to_vec()),
error: None,
};
let called = AtomicBool::new(false);
let local = super::query_heal_status_on_owner(async { Ok(Some(response())) }, || async {
called.store(true, Ordering::SeqCst);
Ok(response())
})
.await
.expect("local automatic owner response");
assert!(!called.load(Ordering::SeqCst));
assert_eq!(local.request_id, "original-token");
assert_eq!(local.data.as_deref(), Some(b"retained-owner-report".as_slice()));
let fallback = super::query_heal_status_on_owner(async { Ok(None) }, || async {
called.store(true, Ordering::SeqCst);
Ok(response())
})
.await
.expect("unknown local identity uses coordinator");
assert!(called.swap(false, Ordering::SeqCst));
assert_eq!(fallback.data, local.data);
let error =
super::query_heal_status_on_owner(async { Err(s3s::s3_error!(InternalError, "local lookup failed")) }, || async {
called.store(true, Ordering::SeqCst);
Ok(response())
})
.await
.expect_err("local failure must not become coordinator success");
assert_eq!(error.code(), &S3ErrorCode::InternalError);
assert!(!called.load(Ordering::SeqCst));
let error = super::query_heal_status_on_owner(async { Ok(None) }, || async {
Err(s3s::s3_error!(ServiceUnavailable, "coordinator unavailable"))
})
.await
.expect_err("coordinator error must remain visible");
assert_eq!(error.code(), &S3ErrorCode::ServiceUnavailable);
}
#[test]
fn mrf_legacy_risk_action_requires_explicit_acknowledgment_and_audit_fields() {
let base = json!({
+4 -1
View File
@@ -3141,7 +3141,10 @@ impl DefaultObjectUsecase {
// resolver; only the response headers, authorization and audit
// summary are derived here, without a second KMS unwrap.
Some(headers) => (
Some(headers.server_side_encryption),
headers
.sse_customer_algorithm
.is_none()
.then_some(headers.server_side_encryption),
headers.sse_customer_algorithm,
headers.sse_customer_key_md5,
headers.ssekms_key_id,
+4 -3
View File
@@ -462,12 +462,13 @@ impl DefaultObjectUsecase {
})?;
let metadata_map = info.user_defined.clone();
let server_side_encryption = metadata_map
.get("x-amz-server-side-encryption")
.map(|v| ServerSideEncryption::from(v.clone()));
let sse_customer_algorithm = metadata_map
.get("x-amz-server-side-encryption-customer-algorithm")
.map(|v| SSECustomerAlgorithm::from(v.clone()));
let server_side_encryption = metadata_map
.get("x-amz-server-side-encryption")
.filter(|_| sse_customer_algorithm.is_none())
.map(|v| ServerSideEncryption::from(v.clone()));
let sse_customer_key_md5 = metadata_map.get("x-amz-server-side-encryption-customer-key-md5").cloned();
let sse_kms_key_id = metadata_map.get("x-amz-server-side-encryption-aws-kms-key-id").cloned();
let storage_class = response_storage_class(&info, &metadata_map);
+67 -3
View File
@@ -1437,7 +1437,10 @@ impl DefaultObjectUsecase {
let output = PutObjectOutput {
e_tag,
server_side_encryption: committed.effective_sse.clone(),
server_side_encryption: committed
.effective_sse
.clone()
.filter(|_| committed.sse_customer_algorithm.is_none()),
sse_customer_algorithm: committed.sse_customer_algorithm.clone(),
sse_customer_key_md5: committed.sse_customer_key_md5.clone(),
ssekms_key_id: committed.effective_kms_key_id.clone(),
@@ -3963,6 +3966,12 @@ mod tests {
async fn sse_c_put_is_accepted_on_a_bucket_with_default_encryption() {
use md5::{Digest as _, Md5};
let context = crate::storage::storage_api::bootstrap_instance_ctx();
assert!(
context.object_encryption_resolver().is_some(),
"real SSE-C reads need the production encryption resolver"
);
for (algorithm, kms_key_id, prefix) in [
(ServerSideEncryption::AES256, None, "ssec-over-aes256-default"),
(ServerSideEncryption::AWS_KMS, Some("bucket-key"), "ssec-over-kms-default"),
@@ -3984,14 +3993,69 @@ mod tests {
.content_length(Some(i64::try_from(payload.len()).expect("test payload length must fit i64")))
.sse_customer_algorithm(Some("AES256".to_string()))
.sse_customer_key(Some(base64_simd::STANDARD.encode_to_string(customer_key)))
.sse_customer_key_md5(Some(key_md5))
.sse_customer_key_md5(Some(key_md5.clone()))
.build()
.expect("SSE-C PUT input must build");
DefaultObjectUsecase::from_global()
let usecase = DefaultObjectUsecase::from_global();
let response = usecase
.execute_put_object(&FS::new(), build_request(input, Method::PUT))
.await
.unwrap_or_else(|err| panic!("a {algorithm} default bucket must accept an SSE-C PUT: {err:?}"));
assert_eq!(response.output.server_side_encryption, None);
assert_eq!(response.output.sse_customer_algorithm.as_deref(), Some("AES256"));
assert_eq!(response.output.sse_customer_key_md5.as_deref(), Some(key_md5.as_str()));
let encoded_key = base64_simd::STANDARD.encode_to_string(customer_key);
let mut read_headers = HeaderMap::new();
read_headers.insert("x-amz-server-side-encryption-customer-algorithm", HeaderValue::from_static("AES256"));
read_headers.insert(
"x-amz-server-side-encryption-customer-key",
HeaderValue::from_str(&encoded_key).expect("encoded customer key is a valid header"),
);
read_headers.insert(
"x-amz-server-side-encryption-customer-key-md5",
HeaderValue::from_str(&key_md5).expect("customer key MD5 is a valid header"),
);
let head_input = HeadObjectInput::builder()
.bucket(bucket.clone())
.key("ledger.csv".to_string())
.sse_customer_algorithm(Some("AES256".to_string()))
.sse_customer_key(Some(encoded_key.clone()))
.sse_customer_key_md5(Some(key_md5.clone()))
.build()
.expect("SSE-C HEAD input must build");
let mut head_request = build_request(head_input, Method::HEAD);
head_request.headers = read_headers.clone();
let head = Box::pin(usecase.execute_head_object(head_request))
.await
.expect("matching SSE-C key must authorize HEAD");
assert_eq!(head.output.server_side_encryption, None);
assert_eq!(head.output.sse_customer_algorithm.as_deref(), Some("AES256"));
assert_eq!(head.output.sse_customer_key_md5.as_deref(), Some(key_md5.as_str()));
let get_input = GetObjectInput::builder()
.bucket(bucket.clone())
.key("ledger.csv".to_string())
.sse_customer_algorithm(Some("AES256".to_string()))
.sse_customer_key(Some(encoded_key))
.sse_customer_key_md5(Some(key_md5.clone()))
.build()
.expect("SSE-C GET input must build");
let mut get_request = build_request(get_input, Method::GET);
get_request.headers = read_headers;
let get = Box::pin(usecase.execute_get_object(get_request))
.await
.expect("matching SSE-C key must authorize GET");
assert_eq!(get.output.server_side_encryption, None);
assert_eq!(get.output.sse_customer_algorithm.as_deref(), Some("AES256"));
assert_eq!(get.output.sse_customer_key_md5.as_deref(), Some(key_md5.as_str()));
let mut body = get.output.body.expect("SSE-C GET must include the decrypted body");
let mut plaintext = Vec::new();
while let Some(chunk) = body.next().await {
plaintext.extend_from_slice(&chunk.expect("SSE-C body must decrypt completely"));
}
assert_eq!(plaintext, payload, "header classification must preserve the complete decrypted payload");
let stored = store
.get_object_info(&bucket, "ledger.csv", &ObjectOptions::default())
+148 -3
View File
@@ -301,6 +301,7 @@ impl std::error::Error for ApiError {
pub(crate) struct ApiErrorDiagnostic {
pub storage_code: Option<StorageErrorCode>,
pub io_kind: Option<std::io::ErrorKind>,
pub io_os_error: Option<i32>,
pub rpc_code: Option<tonic::Code>,
pub pool_metadata: Option<PoolMetadataDiagnostic>,
pub truncated: bool,
@@ -324,10 +325,16 @@ impl From<&PoolMetadataError> for PoolMetadataDiagnostic {
}
}
impl ApiError {
pub(crate) fn diagnostic(&self) -> ApiErrorDiagnostic {
impl ApiErrorDiagnostic {
pub(crate) fn from_error(error: &(dyn std::error::Error + 'static)) -> Self {
let mut diagnostic = ApiErrorDiagnostic::default();
let mut current = std::error::Error::source(self);
// S3Error retains its payload through an inherent accessor; its
// std::error::Error implementation does not expose that source.
let mut current = if let Some(error) = error.downcast_ref::<S3Error>() {
error.source().map(|source| source as &(dyn std::error::Error + 'static))
} else {
std::error::Error::source(error)
};
for _ in 0..16 {
let Some(error) = current else {
return diagnostic;
@@ -345,8 +352,11 @@ impl ApiError {
}
current = if let Some(io) = error.downcast_ref::<std::io::Error>() {
diagnostic.io_kind = Some(io.kind());
diagnostic.io_os_error = io.raw_os_error();
// io::Error::source can skip the wrapped error itself.
io.get_ref().map(|source| source as &(dyn std::error::Error + 'static))
} else if let Some(error) = error.downcast_ref::<S3Error>() {
error.source().map(|source| source as &(dyn std::error::Error + 'static))
} else {
error.source()
};
@@ -354,6 +364,12 @@ impl ApiError {
diagnostic.truncated = current.is_some();
diagnostic
}
}
impl ApiError {
pub(crate) fn diagnostic(&self) -> ApiErrorDiagnostic {
ApiErrorDiagnostic::from_error(self)
}
/// Access-denied error with the exact message emitted by the authorization
/// paths in `storage::access`; callers there match on the code only.
@@ -534,6 +550,16 @@ impl ApiError {
}
}
fn error_chain_has_unavailable_rpc(err: &(dyn std::error::Error + 'static)) -> bool {
error_chain_find(err, |error| {
error
.downcast_ref::<tonic::Status>()
.is_some_and(|status| status.code() == tonic::Code::Unavailable)
.then_some(())
})
.is_some()
}
fn error_chain_has_type<T>(err: &(dyn std::error::Error + 'static)) -> bool
where
T: std::error::Error + 'static,
@@ -765,6 +791,7 @@ impl From<StorageError> for ApiError {
StorageError::BucketNameInvalid(_) => S3ErrorCode::InvalidBucketName,
StorageError::ObjectNameInvalid(_, _) => S3ErrorCode::InvalidArgument,
StorageError::BucketExists(_) => S3ErrorCode::BucketAlreadyOwnedByYou,
StorageError::Io(io_error) if error_chain_has_unavailable_rpc(io_error) => S3ErrorCode::ServiceUnavailable,
StorageError::DiskFull | StorageError::StorageFull => S3ErrorCode::ServiceUnavailable,
// Retryable on an upgraded node (or once the cluster upgrade
// completes); see UNSUPPORTED_PARTICIPANT_MESSAGE.
@@ -919,6 +946,13 @@ impl From<std::io::Error> for ApiError {
};
}
}
if error_chain_has_unavailable_rpc(&err) {
return ApiError {
code: S3ErrorCode::ServiceUnavailable,
message: ApiError::error_code_to_message(&S3ErrorCode::ServiceUnavailable),
source: Some(Box::new(err)),
};
}
ApiError {
code: S3ErrorCode::InternalError,
message: err.to_string(),
@@ -1215,6 +1249,35 @@ mod tests {
assert!(!format!("{:?}", error.diagnostic()).contains("secret"));
}
#[test]
fn api_error_diagnostic_survives_s3_conversion_without_changing_response() {
let api = ApiError::from(StorageError::Io(IoError::from_raw_os_error(30)));
let before = api.diagnostic();
assert_eq!(before.io_os_error, Some(30));
let mut error: S3Error = api.into();
error.set_request_id("server-owned-request");
let code = error.code().clone();
let status = error.status_code();
let message = error.message().map(str::to_owned);
assert_eq!(super::ApiErrorDiagnostic::from_error(&error), before);
assert_eq!(error.code(), &code);
assert_eq!(error.status_code(), status);
assert_eq!(error.message(), message.as_deref());
assert_eq!(error.request_id(), Some("server-owned-request"));
assert!(error.source().is_some());
assert!(std::error::Error::source(&error).is_none());
let error: S3Error = ApiError::from(StorageError::Io(IoError::other(StorageError::ErasureReadQuorum))).into();
assert_eq!(
super::ApiErrorDiagnostic::from_error(&error).storage_code,
Some(StorageErrorCode::ErasureReadQuorum)
);
let nested: S3Error = ApiError::from(StorageError::ErasureReadQuorum).into();
let api = ApiError::from(StorageError::Io(IoError::other(nested)));
assert_eq!(api.diagnostic().storage_code, Some(StorageErrorCode::ErasureReadQuorum));
}
#[test]
fn api_error_diagnostic_bounds_cyclic_error_chains() {
#[derive(Debug)]
@@ -1236,6 +1299,10 @@ mod tests {
};
assert!(error.diagnostic().truncated);
assert!(!format!("{:?}", error.diagnostic()).contains("secret"));
let error: S3Error = error.into();
let diagnostic = super::ApiErrorDiagnostic::from_error(&error);
assert!(diagnostic.truncated);
assert!(!format!("{diagnostic:?}").contains("secret"));
}
#[derive(Debug)]
@@ -1405,6 +1472,84 @@ mod tests {
}
}
#[test]
fn rpc_unavailable_preserves_service_refusal_across_io_boundaries() {
let status = || tonic::Status::unavailable("private transport detail must not reach the S3 response");
let storage = || StorageError::from(status());
let original = storage();
let cloned = original.clone();
for error in [
ApiError::from(original),
ApiError::from(cloned),
ApiError::from(StorageError::Io(IoError::other(IoError::other(status())))),
ApiError::from(IoError::other(status())),
ApiError::from(IoError::other(storage())),
] {
assert_eq!(error.code, S3ErrorCode::ServiceUnavailable);
assert_eq!(error.message, ApiError::error_code_to_message(&S3ErrorCode::ServiceUnavailable));
assert_eq!(error.diagnostic().rpc_code, Some(tonic::Code::Unavailable));
let s3_error = S3Error::from(error);
assert_eq!(s3_error.status_code(), Some(StatusCode::SERVICE_UNAVAILABLE));
}
}
#[test]
fn rpc_unavailable_mapping_does_not_match_other_codes_or_untyped_messages() {
for code in [
tonic::Code::Cancelled,
tonic::Code::Unknown,
tonic::Code::InvalidArgument,
tonic::Code::DeadlineExceeded,
tonic::Code::NotFound,
tonic::Code::AlreadyExists,
tonic::Code::PermissionDenied,
tonic::Code::ResourceExhausted,
tonic::Code::FailedPrecondition,
tonic::Code::Aborted,
tonic::Code::OutOfRange,
tonic::Code::Unimplemented,
tonic::Code::Internal,
tonic::Code::DataLoss,
tonic::Code::Unauthenticated,
] {
for error in [
ApiError::from(StorageError::from(tonic::Status::new(code, "rpc unavailable"))),
ApiError::from(IoError::other(tonic::Status::new(code, "rpc unavailable"))),
] {
assert_eq!(error.code, S3ErrorCode::InternalError, "{code:?}");
}
}
for error in [
ApiError::from(StorageError::Io(IoError::other("status: Unavailable"))),
ApiError::from(IoError::other("status: Unavailable")),
] {
assert_eq!(error.code, S3ErrorCode::InternalError);
}
}
#[test]
fn rpc_unavailable_mapping_keeps_client_digest_error_precedence() {
#[derive(Debug)]
struct DigestWithRpcSource(tonic::Status);
impl std::fmt::Display for DigestWithRpcSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("UploadStreamError: Sha256Mismatch")
}
}
impl std::error::Error for DigestWithRpcSource {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.0)
}
}
let source = || DigestWithRpcSource(tonic::Status::unavailable("private transport failure"));
for error in [
ApiError::from(StorageError::Io(IoError::other(source()))),
ApiError::from(IoError::other(source())),
] {
assert_eq!(error.code, S3ErrorCode::BadDigest);
}
}
#[test]
fn body_size_limit_exceeded_maps_to_entity_too_large_across_io_boundaries() {
// Shape observed in production (issue #7596):
+112 -3
View File
@@ -22,7 +22,7 @@ use super::{
update_bucket_metadata_config_if_incarnation,
};
use crate::admin::handlers::site_replication::site_replication_bucket_meta_hook;
use crate::error::ApiError;
use crate::error::{ApiError, ApiErrorDiagnostic};
use crate::storage::access::{apply_bucket_generation_guard, bucket_config_mutation_incarnation, has_bypass_governance_header};
use crate::storage::helper::OperationHelper;
use crate::storage::options::get_opts;
@@ -60,6 +60,32 @@ const LOG_COMPONENT_STORAGE: &str = "storage";
const LOG_SUBSYSTEM_OBJECT: &str = "object";
const LOG_SUBSYSTEM_OBJECT_LOCK: &str = "object_lock";
const LOG_SUBSYSTEM_TAGGING: &str = "tagging";
const EVENT_OBJECT_INTERNAL_ERROR: &str = "object_internal_error";
fn log_object_internal_error(operation: &'static str, request_id: Option<&str>, error: &S3Error) {
if error.code() != &S3ErrorCode::InternalError {
return;
}
// Generated S3 adapters serialize handler errors without retaining their
// typed sources in logs. Keep only bounded classifications at this boundary.
let diagnostic = ApiErrorDiagnostic::from_error(error);
error!(
event = EVENT_OBJECT_INTERNAL_ERROR,
component = LOG_COMPONENT_STORAGE,
subsystem = LOG_SUBSYSTEM_OBJECT,
result = "error",
operation,
request_id = request_id.unwrap_or("unavailable"),
storage_error_code = ?diagnostic.storage_code,
io_error_kind = ?diagnostic.io_kind,
io_os_error = diagnostic.io_os_error,
rpc_error_code = ?diagnostic.rpc_code,
pool_metadata_reason = diagnostic.pool_metadata.map(|context| context.reason),
pool_metadata_phase = diagnostic.pool_metadata.map(|context| context.phase),
source_chain_truncated = diagnostic.truncated,
"Object request returned an internal error"
);
}
use crate::app::storage_api::object_usecase::bucket::replication::{
OperatorRuleContract, ReplicateDecision, get_read_proxy_targets, must_replicate_metadata, schedule_metadata_replication,
@@ -645,8 +671,16 @@ impl S3 for FS {
#[instrument(level = "debug", skip(self, req))]
async fn delete_object(&self, req: S3Request<DeleteObjectInput>) -> S3Result<S3Response<DeleteObjectOutput>> {
crate::hp_guard!("S3::delete_object");
let request_id = req
.extensions
.get::<super::request_context::RequestContext>()
.map(|ctx| ctx.request_id.clone());
let usecase = s3_api::object_usecase_for(self);
Box::pin(usecase.execute_delete_object(req)).await
let result = Box::pin(usecase.execute_delete_object(req)).await;
if let Err(error) = &result {
log_object_internal_error("DeleteObject", request_id.as_deref(), error);
}
result
}
#[instrument(level = "debug", skip(self))]
@@ -964,8 +998,16 @@ impl S3 for FS {
)]
async fn get_object(&self, req: S3Request<GetObjectInput>) -> S3Result<S3Response<GetObjectOutput>> {
crate::hp_guard!("S3::get_object");
let request_id = req
.extensions
.get::<super::request_context::RequestContext>()
.map(|ctx| ctx.request_id.clone());
let usecase = s3_api::object_usecase_for(self);
usecase.execute_get_object(req).await
let result = usecase.execute_get_object(req).await;
if let Err(error) = &result {
log_object_internal_error("GetObject", request_id.as_deref(), error);
}
result
}
async fn get_object_acl(&self, req: S3Request<GetObjectAclInput>) -> S3Result<S3Response<GetObjectAclOutput>> {
@@ -2064,6 +2106,73 @@ mod tests {
use s3s::{S3, S3Error, S3ErrorCode, S3Request};
use std::sync::atomic::Ordering;
#[test]
fn object_internal_error_logs_only_correlated_bounded_classifications() {
#[derive(Clone, Default)]
struct Writer(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for Writer {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("log buffer").extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Writer {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
let writer = Writer::default();
let subscriber = tracing_subscriber::fmt()
.json()
.without_time()
.with_writer(writer.clone())
.finish();
let mut status = tonic::Status::internal("secret source message");
status
.metadata_mut()
.insert("authorization", "secret-token".parse().expect("metadata"));
let error: S3Error = crate::error::ApiError::from(crate::storage::StorageError::from(status)).into();
let original_code = error.code().clone();
let original_status = error.status_code();
let original_message = error.message().map(str::to_owned);
tracing::subscriber::with_default(subscriber, || {
super::log_object_internal_error("GetObject", Some("server-owned-request"), &error);
let mut opaque = S3Error::with_message(S3ErrorCode::InternalError, "secret outer message");
opaque.set_source(Box::new(std::io::Error::other("secret inner message")));
super::log_object_internal_error("DeleteObject", None, &opaque);
let unavailable: S3Error = crate::error::ApiError::from(crate::storage::StorageError::from(
tonic::Status::unavailable("secret transport message"),
))
.into();
assert_eq!(unavailable.code(), &S3ErrorCode::ServiceUnavailable);
assert_eq!(unavailable.status_code(), Some(StatusCode::SERVICE_UNAVAILABLE));
super::log_object_internal_error("GetObject", None, &unavailable);
});
let output = String::from_utf8(writer.0.lock().expect("log buffer").clone()).expect("UTF8 log");
assert!(!output.contains("secret"));
assert!(!output.contains("authorization"));
let rows = output
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).expect("JSON log"))
.collect::<Vec<_>>();
assert_eq!(rows.len(), 2);
for row in &rows {
assert_eq!(row["fields"]["event"], "object_internal_error");
assert_eq!(row["fields"]["source_chain_truncated"], false);
}
assert_eq!(rows[0]["fields"]["request_id"], "server-owned-request");
assert_eq!(rows[0]["fields"]["rpc_error_code"], "Some(Internal)");
assert_eq!(rows[1]["fields"]["request_id"], "unavailable");
assert_eq!(rows[1]["fields"]["io_error_kind"], "Some(Other)");
assert_eq!(error.code(), &original_code);
assert_eq!(error.status_code(), original_status);
assert_eq!(error.message(), original_message.as_deref());
}
#[test]
fn website_config_rejects_missing_index_and_invalid_redirect_rules() {
assert!(validate_website_configuration(&WebsiteConfiguration::default()).is_err());
+1 -1
View File
@@ -62,7 +62,7 @@
1|crates/ecstore/src/set_disk/ops/heal.rs
8|crates/ecstore/src/set_disk/ops/multipart.rs
2|crates/ecstore/src/set_disk/ops/object.rs
3|crates/ecstore/src/set_disk/read.rs
1|crates/ecstore/src/set_disk/read.rs
5|crates/ecstore/src/store/bucket.rs
1|crates/ecstore/src/store/heal_walk.rs
10|crates/ecstore/src/store/init.rs
+30 -10
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Resolve a nightly manifest from its exact GitHub build attempt and artifact."""
"""Resolve a candidate manifest from its exact GitHub build attempt and artifact."""
from __future__ import annotations
import hashlib
@@ -15,6 +15,7 @@ import zipfile
REPOSITORY = "rustfs/rustfs"
ROOT = Path(__file__).resolve().parents[1]
MAX_ARCHIVE = 1024 * 1024
VALIDATION_BUILD_WORKFLOWS = {".github/workflows/rustfs-validation-package.yml", ".github/workflows/rustfs-functional-chain.yml"}
def require(condition, message):
@@ -39,8 +40,11 @@ def validate_manifest(manifest, run):
require(isinstance(manifest, dict), "manifest must be an object")
common = {"schema", "source_sha", "build_run_id", "build_run_attempt", "package_url", "package_sha256"}
version = manifest.get("schema")
require(type(version) is int and version in (1, 2), "unsupported candidate schema")
require(set(manifest) == (common if version == 1 else common | {"workflow_sha", "source_ref"}), "unexpected candidate fields")
require(type(version) is int and version in (1, 2, 3), "unsupported candidate schema")
expected_fields = common if version == 1 else common | {"workflow_sha", "source_ref"}
if version == 3:
expected_fields |= {"channel"}
require(set(manifest) == expected_fields, "unexpected candidate fields")
require(positive(manifest["build_run_id"]) and positive(manifest["build_run_attempt"]), "invalid build identity")
require((manifest["build_run_id"], manifest["build_run_attempt"]) == (run["id"], run["run_attempt"]), "candidate belongs to another build attempt")
require(sha(manifest["source_sha"]) and sha(manifest["package_sha256"], 64), "invalid candidate hash")
@@ -49,7 +53,11 @@ def validate_manifest(manifest, run):
else:
require(manifest["workflow_sha"] == run["head_sha"] and sha(manifest["workflow_sha"]), "candidate workflow SHA differs from artifact provenance")
require(isinstance(manifest["source_ref"], str) and bool(re.fullmatch(r"[A-Za-z0-9_./-]{1,200}", manifest["source_ref"])), "invalid build source ref")
expected = (f"https://dl.rustfs.com/artifacts/rustfs/packages/nightly/runs/{run['id']}/"
if version == 3:
require(manifest["channel"] == "validation", "validation candidate has the wrong publication channel")
require(manifest["source_ref"] == manifest["source_sha"], "validation candidate must bind an exact source SHA")
channel = "validation" if version == 3 else "nightly"
expected = (f"https://dl.rustfs.com/artifacts/rustfs/packages/{channel}/runs/{run['id']}/"
f"{run['run_attempt']}/{manifest['package_sha256']}/rustfs.deb")
require(manifest["package_url"] == expected, "package URL does not bind the run, attempt and checksum")
return manifest
@@ -72,19 +80,31 @@ def read_json_artifact(archive, artifact, run, expected_name, member, max_json=1
def read_manifest(archive, artifact, run):
name = f"nightly-candidate-{run['id']}-{run['run_attempt']}"
return validate_manifest(read_json_artifact(archive, artifact, run, name, name + ".json"), run)
prefix = "validation-candidate" if run.get("path") in VALIDATION_BUILD_WORKFLOWS else "nightly-candidate"
name = f"{prefix}-{run['id']}-{run['run_attempt']}"
manifest = validate_manifest(read_json_artifact(archive, artifact, run, name, name + ".json"), run)
if prefix == "validation-candidate":
require(manifest["schema"] == 3 and manifest["channel"] == "validation",
"validation workflow requires a schema-3 validation candidate")
else:
require(manifest["schema"] != 3, "nightly workflow cannot publish a validation candidate")
return manifest
def resolve(run_id, attempt):
def resolve(run_id, attempt, *, allow_validation=False):
require(positive(run_id) and positive(attempt), "build run and attempt are required positive integers")
endpoint = f"repos/{REPOSITORY}/actions/runs/{run_id}"
run = api(f"{endpoint}/attempts/{attempt}")
require(run.get("id") == run_id and run.get("run_attempt") == attempt, "GitHub returned a different build attempt")
require(run.get("path") == ".github/workflows/nightly-gnu.yml" and run.get("head_branch") == "main", "candidate must come from nightly-gnu on main")
path = run.get("path")
validation = allow_validation and path in VALIDATION_BUILD_WORKFLOWS
require(path == ".github/workflows/nightly-gnu.yml" and run.get("head_branch") == "main" or validation,
"candidate must come from nightly-gnu on main or an explicitly allowed validation build")
require((run.get("head_repository") or {}).get("full_name") == REPOSITORY, "candidate came from another repository")
require(run.get("event") in ("schedule", "workflow_dispatch") and run.get("status") == "completed" and run.get("conclusion") == "success", "nightly attempt has not completed successfully")
name = f"nightly-candidate-{run_id}-{attempt}"
require(run.get("event") in ("schedule", "workflow_dispatch") and run.get("status") == "completed" and run.get("conclusion") == "success", "candidate attempt has not completed successfully")
if validation:
require(run.get("event") == "workflow_dispatch", "validation candidate must be manually dispatched")
name = f"{'validation' if validation else 'nightly'}-candidate-{run_id}-{attempt}"
artifacts = []
for page in range(1, 11):
batch = api(f"{endpoint}/artifacts?per_page=100&page={page}")["artifacts"]
+332
View File
@@ -4,6 +4,7 @@ import copy
import hashlib
import io
import json
import os
from pathlib import Path
import subprocess
import tempfile
@@ -15,6 +16,245 @@ import functional_chain_evidence as evidence
import resolve_functional_candidate as candidate
class RaceNativeToolsWorkflowTests(unittest.TestCase):
def chain_shell_step(self, name):
source = (candidate.ROOT / '.github/workflows/rustfs-functional-chain.yml').read_text()
step = source.split(' - name: ' + name + '\n', 1)[1].split('\n - name:', 1)[0]
shell = step.split(' run: |\n', 1)[1]
return '\n'.join(line[10:] for line in shell.splitlines()
if not line.strip() or line.startswith(' '))
def test_race_actual_workflow_selection_preserves_legacy_and_propagates_failures(self):
shell = self.chain_shell_step('Select exact-source race native evidence')
g01 = 'heal.race-unversioned-overwrite'
cases = [(False, 2, 0, 'false', 'a' * 40, g01), (True, 2, 0, 'false', 'a' * 40, g01),
(True, 0, 0, 'true', 'a' * 40, g01), (True, 1, 1, None, 'a' * 40, g01),
(True, 42, 42, None, 'a' * 40, g01)]
reviewed = [('6afdec9fe1b0b88007f2e5b445c19bf73d51d3ca', g01)]
reviewed += [('bd0671ba781d8ebf509f4f03533228dfb44496f1', case) for case in
(g01, 'heal.race-versioned-put', 'heal.race-version-delete')]
reviewed += [('8380466f3a27c3c3ef4115084178a0ab99a78f32', case) for case in
(g01, 'heal.race-versioned-put', 'heal.race-version-delete', 'heal.race-markers-and-batch-delete')]
for source, case in reviewed:
cases += [(False, 2, 1, None, source, case), (True, 2, 1, None, source, case),
(True, 0, 0, 'true', source, case)]
for helper, support, code, required, source, case in cases:
with self.subTest(helper=helper, support=support, source=source, case=case), tempfile.TemporaryDirectory() as name:
root = Path(name)
env = self.fixture(root, new_helper=helper)
output = root / 'output'
env.update(SUPPORT_STATUS=str(support), GITHUB_OUTPUT=str(output), CANDIDATE_SHA=source,
VALIDATION_CASE_ID=case)
result = subprocess.run(['bash', '-c', shell], cwd=root, env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, code, result.stderr)
self.assertEqual(output.read_text() if output.exists() else None,
'required=' + required + '\n' if required else None)
if helper:
calls = [json.loads(line) for line in (root / 'calls.jsonl').read_text().splitlines()]
self.assertEqual(calls, [{'kind': 'race_native', 'args': ['supports', '--commit', source,
'--case-id', case]}])
def test_race_actual_admission_requires_native_and_runtime_before_exporting_directory(self):
shell = self.chain_shell_step('Verify exact-source race native and runtime evidence')
for case in ('heal.race-unversioned-overwrite', 'heal.race-versioned-put', 'heal.race-version-delete',
'heal.race-markers-and-batch-delete'):
for native_exit, runtime in [(0, 0), (42, 0), (0, 42), (0, 'missing'), (0, 'collector-only')]:
with self.subTest(case=case, native_exit=native_exit, runtime=runtime), tempfile.TemporaryDirectory() as name:
root = Path(name)
env = self.fixture(root, new_helper=True)
directory = root / 'auto-testing/scripts/validation_heal'
(directory / 'race_native_evidence.py').write_text(
'import os,sys\nprint("{}")\nsys.exit(int(os.environ["VERIFY_EXIT"]))\n')
if runtime == 'collector-only':
(directory / 'race.py').write_text(
'import argparse\np=argparse.ArgumentParser()\np.add_argument("--rc-bin", required=True)\np.parse_args()\n')
elif runtime != 'missing':
(directory / 'race.py').write_text(
'import json,os,pathlib,sys\n'
'assert not pathlib.Path(os.environ["GITHUB_ENV"]).exists()\n'
'assert sys.argv[1] == "preflight"\n'
'assert sys.argv[-2:] == ["--case-id",os.environ["VALIDATION_CASE_ID"]]\n'
'assert pathlib.Path(os.environ["VALIDATION_HEAL_NATIVE_DIRECTORY"],"tool-run.json").is_file()\n'
'pathlib.Path("runtime-call.json").write_text(json.dumps(sys.argv[1:]))\n'
'sys.exit(int(os.environ["RUNTIME_EXIT"]))\n')
validation = root / 'validation'
validation.mkdir()
(validation / 'tool-run.json').write_text('{"id":123}')
native = root / 'rustfs-validation-native-77-1'
native.mkdir()
output = root / 'env'
env.update(RUNNER_TEMP=str(root), GITHUB_RUN_ID='77', GITHUB_RUN_ATTEMPT='1',
VALIDATION_ROOT=str(validation), GITHUB_ENV=str(output), VERIFY_EXIT=str(native_exit),
RUNTIME_EXIT=str(runtime), VALIDATION_CASE_ID=case)
result = subprocess.run(['bash', '-c', shell], cwd=root, env=env, capture_output=True, text=True)
expected = native_exit or (runtime if isinstance(runtime, int) else 2)
self.assertEqual(result.returncode, expected, result.stderr)
self.assertEqual((native / 'tool-run.json').read_bytes(), (validation / 'tool-run.json').read_bytes())
self.assertEqual(output.exists(), expected == 0)
self.assertEqual((root / 'runtime-call.json').exists(), native_exit == 0 and isinstance(runtime, int))
if expected == 0:
self.assertEqual(output.read_text(), 'VALIDATION_HEAL_NATIVE_DIRECTORY=' + str(native) + '\n')
workflow = (candidate.ROOT / '.github/workflows/rustfs-functional-chain.yml').read_text()
self.assertLess(workflow.index('Verify exact-source race native and runtime'),
workflow.index('Run selected canonical validation case'))
self.assertIn("steps.race_native.outputs.required == 'true'", workflow)
self.assertIn("inputs.validation_case == 'heal.race-markers-and-batch-delete'", workflow)
def shell_step(self, name):
from check_test_wiring import yaml_block
source = (candidate.ROOT / '.github/workflows/rustfs-validation-tools.yml').read_text()
job = yaml_block(source.splitlines(), 'native-heal-regressions', 2)
start = job.index(' - name: ' + name)
end = next((index for index in range(start + 1, len(job))
if job[index].startswith(' - name: ')), len(job))
step = job[start:end]
return '\n'.join(line[10:] for line in step[step.index(' run: |') + 1:]
if not line.strip() or line.startswith(' '))
def fixture(self, root, *, new_helper):
directory = root / 'auto-testing/scripts/validation_heal'
directory.mkdir(parents=True)
stub = '''import json, os, pathlib, sys
kind = pathlib.Path(__file__).stem
with pathlib.Path('calls.jsonl').open('a') as out:
out.write(json.dumps({'kind': kind, 'args': sys.argv[1:]}) + '\\n')
if sys.argv[1] == 'supports': sys.exit(int(os.environ['SUPPORT_STATUS']))
sys.exit(int(os.environ.get(kind.upper() + '_FAIL', '0')))
'''
(directory / 'native_regressions.py').write_text(stub)
if new_helper: (directory / 'race_native.py').write_text(stub)
bin_dir = root / 'bin'
bin_dir.mkdir()
git = bin_dir / 'git'
git.write_text('#!/bin/sh\nprintf "%s\\n" "$FAKE_HEAD"\n')
git.chmod(0o755)
env = dict(os.environ, AUTO_TESTING_SHA='b' * 40, CANDIDATE_SHA='a' * 40,
FAKE_HEAD='b' * 40, SUPPORT_STATUS='2', PATH=str(bin_dir) + os.pathsep + os.environ['PATH'])
env.pop('BASH_ENV', None)
return env
def test_actual_native_build_routes_old_new_unknown_and_failed_collectors(self):
shell = self.shell_step('Build and enumerate the unmodified candidate test executable')
for mode, helper, support, failure, head, expected_code, expected_calls in [
('old-harness', False, 2, None, 'b', 0, [('native_regressions', 'build')]),
('legacy', True, 2, None, 'b', 0, [('race_native', 'supports'), ('native_regressions', 'build')]),
('race', True, 0, None, 'b', 0, [('race_native', 'supports'), ('race_native', 'build')]),
('support-error', True, 42, None, 'b', 42, [('race_native', 'supports')]),
('invalid-support', True, 1, None, 'b', 1, [('race_native', 'supports')]),
('race-build-error', True, 0, 'RACE_NATIVE_FAIL', 'b', 42, [('race_native', 'supports'), ('race_native', 'build')]),
('legacy-build-error', True, 2, 'NATIVE_REGRESSIONS_FAIL', 'b', 42, [('race_native', 'supports'), ('native_regressions', 'build')]),
('wrong-helper', True, 0, None, 'c', 1, []),
]:
with tempfile.TemporaryDirectory() as temp, self.subTest(mode=mode):
root = Path(temp)
env = self.fixture(root, new_helper=helper)
env.update(SUPPORT_STATUS=str(support), FAKE_HEAD=head * 40)
if failure: env[failure] = '42'
result = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', shell],
cwd=root, env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, expected_code, result.stderr)
calls = [json.loads(line) for line in (root / 'calls.jsonl').read_text().splitlines()] if (root / 'calls.jsonl').exists() else []
self.assertEqual([(row['kind'], row['args'][0]) for row in calls], expected_calls)
for row in calls:
if row['args'][0] == 'supports':
self.assertEqual(row['args'], ['supports', '--commit', 'a' * 40])
else:
expected = ['build', '--source', 'candidate', '--commit', 'a' * 40]
if row['kind'] == 'race_native': expected += ['--testing-commit', 'b' * 40]
self.assertEqual(row['args'], expected + ['--out', 'native-tools'])
def test_actual_native_run_rejects_ambiguous_formats_and_propagates_failures(self):
shell = self.shell_step('Run exact native regressions separately from fleet validation')
for mode, helper, manifests, failure, expected_code, expected_kind in [
('legacy', False, ['native-regressions.json'], None, 0, 'native_regressions'),
('race', True, ['race-native.json'], None, 0, 'race_native'),
('ambiguous', True, ['native-regressions.json', 'race-native.json'], None, 1, None),
('legacy-error', True, ['native-regressions.json'], 'NATIVE_REGRESSIONS_FAIL', 42, 'native_regressions'),
('race-error', True, ['race-native.json'], 'RACE_NATIVE_FAIL', 42, 'race_native'),
('missing-race-helper', False, ['race-native.json'], None, 2, None),
]:
with tempfile.TemporaryDirectory() as temp, self.subTest(mode=mode):
root = Path(temp)
env = self.fixture(root, new_helper=helper)
if failure: env[failure] = '42'
(root / 'native-tools').mkdir()
for manifest in manifests: (root / 'native-tools' / manifest).write_text('{}')
result = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', shell],
cwd=root, env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, expected_code, result.stderr)
calls = [json.loads(line) for line in (root / 'calls.jsonl').read_text().splitlines()] if (root / 'calls.jsonl').exists() else []
self.assertEqual(len(calls), int(expected_kind is not None))
if expected_kind:
self.assertEqual(calls, [{'kind': expected_kind, 'args': ['run', '--directory', 'native-tools', '--out', 'native-evidence']}])
def test_native_upload_retains_both_fixed_executable_kinds(self):
source = (candidate.ROOT / '.github/workflows/rustfs-validation-tools.yml').read_text()
self.assertIn(' native-tools/ecstore-native-tests\n', source)
self.assertIn(' native-tools/ecstore-race-native-tests\n', source)
class PersistenceToolsWorkflowTests(unittest.TestCase):
def test_supplement_preserves_old_pins_and_propagates_actual_collector_failure(self):
from check_test_wiring import yaml_block
source = (candidate.ROOT / '.github/workflows/rustfs-validation-tools.yml').read_text()
job = yaml_block(source.splitlines(), 'native-heal-regressions', 2)
start = job.index(' - name: Run existing F07 directory fsync failure regressions')
end = job.index(' - name: Upload native executable identities and original diagnostics')
step = job[start:end]
shell = '\n'.join(line[10:] for line in step[step.index(' run: |') + 1:]
if not line.strip() or line.startswith(' '))
for mode in ('old-harness', 'success', 'run-failure', 'verify-failure'):
with tempfile.TemporaryDirectory() as temp, self.subTest(mode=mode):
root = Path(temp)
if mode != 'old-harness':
collector = root / 'auto-testing/scripts/validation_heal/persistence_native.py'
collector.parent.mkdir(parents=True)
collector.write_text('''import json, os, pathlib, sys
with pathlib.Path('calls.jsonl').open('a') as out: out.write(json.dumps(sys.argv[1:]) + '\\n')
sys.exit(42 if os.environ['COLLECTOR_MODE'] == sys.argv[1] + '-failure' else 0)
''')
env = dict(os.environ, AUTO_TESTING_SHA='b' * 40, COLLECTOR_MODE=mode)
env.pop('BASH_ENV', None)
result = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', shell],
cwd=root, env=env, capture_output=True, text=True)
self.assertEqual(result.returncode == 0, mode in ('old-harness', 'success'), result.stderr)
if mode == 'old-harness':
report = json.loads((root / 'native-persistence-evidence/not-available.json').read_text())
self.assertIs(report['complete'], False)
self.assertIs(report['requiredNativeCoverageComplete'], False)
self.assertIs(report['canonicalCasePassClaimed'], False)
self.assertFalse((root / 'calls.jsonl').exists())
else:
calls = [json.loads(line) for line in (root / 'calls.jsonl').read_text().splitlines()]
self.assertEqual([call[0] for call in calls], ['run'] if mode == 'run-failure' else ['run', 'verify'])
for call in calls: self.assertEqual(call[-2:], ['--testing-commit', 'b' * 40])
class InterruptedCleanupGuardTests(unittest.TestCase):
def test_actual_workflow_guard_rejects_live_success_and_unrelated_runs(self):
source = (candidate.ROOT / ".github/workflows/rustfs-functional-chain.yml").read_text()
step = source[source.index(' - name: Audit and finalize retained validation environment'):]
guard = step.split(" python3 - <<'PY'", 1)[1].split(" PY", 1)[0]
guard = "\n".join(line[10:] for line in guard.splitlines())
for state, conclusion, branch, workflow, accepted in (
("completed", "failure", "topic", "RustFS Functional Chain", True),
("completed", "cancelled", "topic", "RustFS Functional Chain", True),
("in_progress", "cancelled", "topic", "RustFS Functional Chain", False),
("completed", "success", "topic", "RustFS Functional Chain", False),
("completed", "cancelled", "other", "RustFS Functional Chain", False),
("completed", "cancelled", "topic", "Other Workflow", False),
):
with self.subTest(state=state, conclusion=conclusion, branch=branch, workflow=workflow), tempfile.TemporaryDirectory() as temp:
Path(temp, "failed-run.json").write_text(json.dumps({"status": state, "conclusion": conclusion,
"headBranch": branch, "workflowName": workflow}))
with mock.patch.dict(os.environ, VALIDATION_ROOT=temp, GITHUB_REF_NAME="topic"):
if accepted:
exec(compile(guard, "workflow-cleanup-guard", "exec"), {})
else:
with self.assertRaises(ValueError):
exec(compile(guard, "workflow-cleanup-guard", "exec"), {})
class CandidateTests(unittest.TestCase):
def setUp(self):
self.run = {"id": 123, "run_attempt": 2, "head_sha": "a" * 40}
@@ -76,6 +316,98 @@ class CandidateTests(unittest.TestCase):
self.assertEqual(result["artifact_id"], 789)
self.assertEqual(result["manifest"]["source_sha"], "b" * 40)
def test_validation_manifest_binds_requested_source_and_private_channel(self):
manifest = {**self.manifest, "schema": 3, "channel": "validation", "source_ref": self.manifest["source_sha"],
"package_url": self.manifest["package_url"].replace("/packages/nightly/", "/packages/validation/")}
run = {**self.run, "path": ".github/workflows/rustfs-validation-package.yml"}
payload = io.BytesIO()
with zipfile.ZipFile(payload, "w") as archive:
archive.writestr("validation-candidate-123-2.json", json.dumps(manifest))
raw = payload.getvalue()
artifact = {"id": 789, "name": "validation-candidate-123-2", "expired": False,
"size_in_bytes": len(raw), "digest": "sha256:" + hashlib.sha256(raw).hexdigest(),
"workflow_run": {"id": 123, "head_sha": "a" * 40}}
result = candidate.read_manifest(raw, artifact, run)
self.assertEqual(result["channel"], "validation")
self.assertIn("/packages/validation/runs/123/2/", result["package_url"])
def test_validation_resolver_requires_manual_workflow_dispatch(self):
payload, artifact = self.archive()
run = {"id": 123, "run_attempt": 2, "head_sha": "a" * 40,
"path": ".github/workflows/rustfs-validation-package.yml", "head_branch": "main",
"head_repository": {"full_name": "rustfs/rustfs"}, "event": "schedule",
"status": "completed", "conclusion": "success", "run_started_at": "2026-10-12T00:00:00Z"}
with mock.patch.object(candidate, "api", side_effect=[run]):
with self.assertRaisesRegex(ValueError, "manually dispatched"):
candidate.resolve(123, 2, allow_validation=True)
def test_validation_workflow_rejects_legacy_manifest_schema(self):
manifest = {**self.manifest}
payload_buffer = io.BytesIO()
with zipfile.ZipFile(payload_buffer, "w") as archive:
archive.writestr("validation-candidate-123-2.json", json.dumps(manifest))
payload = payload_buffer.getvalue()
artifact = {"id": 789, "name": "validation-candidate-123-2", "expired": False,
"size_in_bytes": len(payload), "digest": "sha256:" + hashlib.sha256(payload).hexdigest(),
"workflow_run": {"id": 123, "head_sha": "a" * 40}}
run = {**self.run, "path": ".github/workflows/rustfs-validation-package.yml"}
with self.assertRaisesRegex(ValueError, "schema-3"):
candidate.read_manifest(payload, artifact, run)
def test_private_candidate_resolution_is_explicit_and_attempt_bound(self):
self.manifest.update(schema=3, channel="validation", source_ref=self.manifest["source_sha"],
package_url=self.manifest["package_url"].replace("/packages/nightly/", "/packages/validation/"))
payload, artifact = self.archive(["validation-candidate-123-2.json"])
artifact["name"] = "validation-candidate-123-2"
base = {**self.run, "head_branch": "cxymds/validation-runner", "head_repository": {"full_name": "rustfs/rustfs"},
"event": "workflow_dispatch", "status": "completed", "conclusion": "success",
"run_started_at": "2026-10-12T00:00:00Z"}
for path in candidate.VALIDATION_BUILD_WORKFLOWS:
run = {**base, "path": path}
with self.subTest(path=path), mock.patch.object(candidate, "api", side_effect=[run, {"artifacts": [artifact]}, payload]):
resolved = candidate.resolve(123, 2, allow_validation=True)
self.assertEqual(resolved["manifest"]["source_sha"], "b" * 40)
self.assertEqual(resolved["workflow_sha"], "a" * 40)
with mock.patch.object(candidate, "api", return_value=run), self.assertRaises(ValueError):
candidate.resolve(123, 2)
for field, value in (("id", 124), ("run_attempt", 1), ("event", "push"), ("status", "in_progress"),
("conclusion", "failure"), ("path", ".github/workflows/other.yml"),
("head_repository", {"full_name": "untrusted/rustfs"})):
run = {**base, "path": ".github/workflows/rustfs-functional-chain.yml", field: value}
with self.subTest(field=field), mock.patch.object(candidate, "api", return_value=run), self.assertRaises(ValueError):
candidate.resolve(123, 2, allow_validation=True)
def test_private_manifest_rejects_alias_channel_source_and_schema_substitution(self):
valid = {**self.manifest, "schema": 3, "channel": "validation", "source_ref": self.manifest["source_sha"],
"package_url": self.manifest["package_url"].replace("/packages/nightly/", "/packages/validation/")}
for key, value in (("channel", "nightly"), ("source_ref", "main"), ("source_sha", "e" * 40),
("package_url", self.manifest["package_url"]), ("workflow_sha", "e" * 40),
("build_run_attempt", 3), ("schema", True)):
with self.subTest(key=key), self.assertRaises(ValueError):
candidate.validate_manifest({**valid, key: value}, self.run)
def test_package_only_mode_rejects_conflicting_fleet_and_tool_requests(self):
from check_test_wiring import yaml_block
workflow = Path(__file__).resolve().parents[1] / ".github/workflows/rustfs-functional-chain.yml"
job = yaml_block(workflow.read_text().splitlines(), "validation-package-request", 2)
begin = job.index(" run: |") + 1
script = "\n".join(line[10:] for line in job[begin:] if not line.strip() or line.startswith(" "))
import os
inputs = {"validation_package_sha": "a" * 40}
env = {**os.environ, "PACKAGE_INPUTS": json.dumps(inputs)}
result = subprocess.run(["bash", "-e", "-c", script], env=env, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
for field in ("build_run_id", "build_run_attempt", "validation_case", "auto_testing_sha", "validation_tools_only",
"validation_native_tools", "validation_shared_tools", "validation_manager_tools",
"validation_cleanup_run_id", "validation_tools_run_id"):
with self.subTest(field=field):
env["PACKAGE_INPUTS"] = json.dumps({**inputs, field: "not-empty"})
result = subprocess.run(["bash", "-e", "-c", script], env=env, capture_output=True)
self.assertNotEqual(result.returncode, 0)
for value in ("main", "a" * 39, "a" * 40 + "\nFORGED=value"):
env["PACKAGE_INPUTS"] = json.dumps({"validation_package_sha": value})
self.assertNotEqual(subprocess.run(["bash", "-e", "-c", script], env=env, capture_output=True).returncode, 0)
class EvidenceTests(unittest.TestCase):
def setUp(self):
+166
View File
@@ -221,5 +221,171 @@ SH
self.assertNotIn(" overwrite: true", upload)
class ValidationPackageTests(unittest.TestCase):
def setUp(self):
# Exercise the real upload shell with the same conditional object-store
# fixture used by the nightly publication tests.
self.fixture = NightlyCandidateTests("test_success_binds_actual_package_checkout_and_attempt")
self.fixture.setUp()
self.addCleanup(self.fixture.temp.cleanup)
self.workflow = ROOT / ".github/workflows/rustfs-validation-package.yml"
job = yaml_block(self.workflow.read_text().splitlines(), "publish", 2)
start = job.index(" - name: Upload validation package to immutable R2 path")
end = job.index(" - name: Upload validation candidate manifest")
upload = job[start:end]
begin = upload.index(" run: |") + 1
self.fixture.shell = "\n".join(line[10:] for line in upload[begin:] if not line.strip() or line.startswith(" "))
self.fixture.shims.write_text(self.fixture.shims.read_text().replace("/packages/nightly/runs/*", "/packages/validation/runs/*"))
self.fixture.env.update(PACKAGE_FILE=self.fixture.package.name, VALIDATION_SOURCE_SHA=self.fixture.sha,
VALIDATION_SOURCE_REF=self.fixture.sha, GITHUB_SHA="f" * 40, AWS_CLI="aws")
def shell(name, end_name):
step = job[job.index(" - name: " + name):job.index(" - name: " + end_name)]
return "\n".join(line[10:] for line in step[step.index(" run: |") + 1:]
if not line.strip() or line.startswith(" "))
self.stage_shell = shell("Validate and stage opaque package bytes", "Prepare trusted publication tools")
self.tools_shell = shell("Prepare trusted publication tools", "Upload validation package to immutable R2 path")
def stage(self):
env = dict(os.environ, RUNNER_TEMP=str(self.fixture.root))
env.pop("BASH_ENV", None)
return subprocess.run(["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", self.stage_shell],
cwd=self.fixture.root, env=env, capture_output=True, text=True)
def test_success_uploads_only_one_immutable_object_and_exact_source(self):
result = self.fixture.run_publish()
self.assertEqual(result.returncode, 0, result.stderr)
manifest = self.fixture.manifest()
self.assertEqual(manifest["schema"], 3)
self.assertEqual(manifest["channel"], "validation")
self.assertEqual(manifest["source_sha"], self.fixture.sha)
self.assertEqual(manifest["source_ref"], self.fixture.sha)
self.assertEqual(manifest["workflow_sha"], "f" * 40)
validate_manifest(manifest, {"id": 12345, "run_attempt": 1, "head_sha": "f" * 40})
objects = [path for path in self.fixture.store.rglob("*") if path.is_file()]
self.assertEqual(len(objects), 1)
self.assertEqual(objects[0].read_bytes(), self.fixture.package.read_bytes())
self.assertEqual(str(objects[0].relative_to(self.fixture.store)),
f"artifacts/rustfs/packages/validation/runs/12345/1/{self.fixture.digest}/rustfs.deb")
self.assertNotIn("s3 cp", (self.fixture.root / "aws.log").read_text())
def test_failure_or_missing_credentials_cannot_certify_a_candidate(self):
for index, mode in enumerate(("upload-failure", "missing-public-url", "wrong-public-bytes", "incomplete-download")):
with self.subTest(mode=mode):
result = self.fixture.run_publish(FAKE_MODE=mode, GITHUB_RUN_ID=str(20000 + index))
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.fixture.output.exists())
self.assertEqual(list(self.fixture.root.glob("validation-candidate-*.json")), [])
for key in ("R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_ENDPOINT", "R2_BUCKET"):
with self.subTest(key=key):
self.assertNotEqual(self.fixture.run_publish(**{key: ""}).returncode, 0)
self.assertFalse(self.fixture.output.exists())
def test_stale_source_and_duplicate_objects_are_rejected(self):
for source in ("not-an-exact-sha", "e" * 40):
with self.subTest(source=source):
result = self.fixture.run_publish(VALIDATION_SOURCE_SHA=source)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.fixture.store.exists())
result = self.fixture.run_publish()
self.assertEqual(result.returncode, 0, result.stderr)
manifest = self.fixture.manifest()
key = manifest["package_url"].removeprefix("https://dl.rustfs.com/")
stored = self.fixture.store / key
stored.write_bytes(b"preexisting conflicting object")
(self.fixture.root / "validation-candidate-12345-1.json").unlink()
result = self.fixture.run_publish()
self.assertNotEqual(result.returncode, 0)
self.assertEqual(stored.read_bytes(), b"preexisting conflicting object")
self.assertFalse(self.fixture.output.exists())
def test_exact_source_and_workflow_identity_are_preserved_separately(self):
lines = self.workflow.read_text().splitlines()
build = "\n".join(yaml_block(lines, "build", 2))
publish = "\n".join(yaml_block(lines, "publish", 2))
source = "\n".join(yaml_block(lines, "resolve-source", 2))
self.assertIn('[[ "${REQUESTED_SHA}" =~ ^[0-9a-f]{40}$ ]]', source)
self.assertIn('test "$(git -C "${source_directory}" rev-parse FETCH_HEAD)" = "${REQUESTED_SHA}"', source)
self.assertIn("ref: ${{ needs.resolve-source.outputs.source_sha }}", build)
self.assertIn('--arg source_sha "${VALIDATION_SOURCE_SHA}"', publish)
self.assertIn('--arg workflow_sha "${GITHUB_SHA}"', publish)
self.assertNotIn("PF_TESTING_GH_TOKEN", build)
self.assertNotIn("publish_nightly_assets", build)
def test_candidate_execution_and_publication_credentials_are_in_separate_jobs(self):
lines = self.workflow.read_text().splitlines()
build = "\n".join(yaml_block(lines, "build", 2))
publish = "\n".join(yaml_block(lines, "publish", 2))
self.assertIn("uses: ./.github/actions/setup", build)
self.assertIn("cargo build", build)
self.assertNotIn("secrets.", build)
self.assertNotIn("id-token: write", build)
self.assertIn("needs: [resolve-source, build]", publish)
self.assertIn("runs-on: ubuntu-latest", publish)
for forbidden in ("actions/checkout@", "actions/cache@", "uses: ./", "needs.build.outputs", "cargo ", "dpkg", "source "):
with self.subTest(forbidden=forbidden): self.assertNotIn(forbidden, publish)
name = "name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}"
self.assertIn(name, build)
self.assertIn(name, publish)
self.assertIn("VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}", publish)
self.assertIn("VALIDATION_SOURCE_REF: ${{ needs.resolve-source.outputs.source_ref }}", publish)
prepare = publish.split(" - name: Upload validation package to immutable R2 path")[0]
self.assertNotIn("secrets.", prepare)
self.assertIn("secrets.R2_SECRET_ACCESS_KEY", publish)
def test_staging_rejects_nonregular_empty_or_extra_artifact_entries(self):
incoming = self.fixture.root / "validation-package-input"
incoming.mkdir()
package = incoming / "rustfs.deb"
for kind in ("missing", "empty", "symlink", "directory", "fifo", "extra", "directory-symlink"):
with self.subTest(kind=kind):
if kind == "empty": package.touch()
elif kind == "symlink": package.symlink_to(self.fixture.package)
elif kind == "directory": package.mkdir()
elif kind == "fifo": os.mkfifo(package)
elif kind == "extra":
package.write_bytes(b"opaque")
(incoming / "BASH_ENV").write_text("exit 1")
elif kind == "directory-symlink":
incoming.rmdir()
incoming.symlink_to(self.fixture.root, target_is_directory=True)
self.assertNotEqual(self.stage().returncode, 0)
self.assertFalse((self.fixture.root / "validation-publication").exists())
if incoming.is_symlink():
incoming.unlink()
incoming.mkdir()
elif package.is_dir(): package.rmdir()
else: package.unlink(missing_ok=True)
(incoming / "BASH_ENV").unlink(missing_ok=True)
def test_staging_and_publication_never_execute_candidate_package_or_python_hooks(self):
incoming = self.fixture.root / "validation-package-input"
incoming.mkdir()
marker = self.fixture.root / "candidate-executed"
payload = f'#!/bin/bash\nprintf "%s" "$R2_SECRET_ACCESS_KEY" > "{marker}"\n'.encode()
(incoming / "rustfs.deb").write_bytes(payload)
(self.fixture.root / "sitecustomize.py").write_text(f"open({str(marker)!r}, 'w').write('python hook')")
result = self.stage()
self.assertEqual(result.returncode, 0, result.stderr)
staged = self.fixture.root / "validation-publication/rustfs.deb"
self.assertEqual(staged.read_bytes(), payload)
result = self.fixture.run_publish(PACKAGE_FILE=str(staged))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(marker.exists())
self.assertEqual(self.fixture.manifest()["package_sha256"], hashlib.sha256(payload).hexdigest())
def test_cli_bootstrap_finishes_without_publication_secrets(self):
for mode in ("success", "old-cli", "bootstrap-failure", "install-failure", "broken-install"):
with self.subTest(mode=mode):
(self.fixture.root / "installed").unlink(missing_ok=True)
env = dict(self.fixture.env, FAKE_MODE=mode)
for key in ("R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_ENDPOINT", "R2_BUCKET"):
env.pop(key)
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", self.tools_shell],
cwd=self.fixture.root, env=env, capture_output=True, text=True)
self.assertEqual(result.returncode == 0, mode in ("success", "old-cli"), result.stderr)
self.assertFalse(self.fixture.store.exists())
self.assertEqual(list(self.fixture.root.glob("validation-candidate-*.json")), [])
if __name__ == "__main__":
unittest.main()