#!/usr/bin/env bash # 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. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" ENDPOINT="" ADMIN_TOKEN="" ACCESS_KEY="" SECRET_KEY="" AWS_SIGV4_SCOPE="aws:amz:us-east-1:s3" OUT_DIR="" LOG_GLOB="/var/log/rustfs/*.log" METRIC_TYPES="1" METRIC_SAMPLES="1" METRIC_INTERVAL="" BUCKET_FILTER="" MIN_DISTINCT_REASONS="2" DRY_RUN=false SHOW_HELP=false declare -a SAMPLES=() REASON_COUNTS_JSONL="" MATCHED_EXPECTED_TXT="" usage() { cat <<'USAGE' Usage: scripts/manual_transition_failure_samples.sh --endpoint --sample [options] Required for real runs: --endpoint Admin endpoint base, e.g. https://127.0.0.1:9000 --sample Failure sample spec. Repeatable. Example: auth::RemoteAuth Optional: --admin-token Bearer token for /admin/v3 endpoints --access-key Access key for SigV4-signed admin/metrics calls --secret-key Secret key for SigV4-signed admin/metrics calls --aws-sigv4-scope curl --aws-sigv4 scope, default aws:amz:us-east-1:s3 --log-glob Log glob to scan, default /var/log/rustfs/*.log --metric-types /admin/v3/metrics type flags, default 1 --metric-samples /admin/v3/metrics sample count, default 1 --metric-interval /admin/v3/metrics interval query param --bucket Optional bucket filter for focused log output --min-distinct-reasons Minimum distinct tier failure reasons required, default 2 --out-dir Artifact output dir --dry-run Generate the sample command template without admin calls --help Show usage Output: - failure_attribution_summary.csv - sample_plan.md - commands.txt - per-sample job_status.json, metrics.json, and log_events.json Typical #1509 evidence target: scripts/manual_transition_failure_samples.sh \ --endpoint https://127.0.0.1:9000 \ --access-key "$ACCESS_KEY" \ --secret-key "$SECRET_KEY" \ --sample auth::RemoteAuth \ --sample network::RemoteNetwork \ --min-distinct-reasons 2 \ --out-dir target/manual-transition-failure-samples USAGE } arg_value() { local flag="$1" local value="${2:-}" if [[ -z "$value" || "$value" == --* ]]; then echo "ERROR: missing value for $flag" >&2 exit 1 fi printf '%s' "$value" } require_cmd() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then echo "ERROR: required command not found: $cmd" >&2 exit 1 fi } normalize_uuid() { local raw="${1//-/}" printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' } validate_uuid() { local raw="$1" local normalized normalized="$(normalize_uuid "$raw")" if ! [[ "$normalized" =~ ^[0-9a-f]{32}$ ]]; then echo "ERROR: invalid uuid in --sample: $raw" >&2 exit 1 fi printf '%s' "$normalized" } csv_escape() { local value="${1:-}" value="${value//\"/\"\"}" printf '"%s"' "$value" } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --endpoint) ENDPOINT="$(arg_value "$1" "${2:-}")"; shift 2 ;; --admin-token) ADMIN_TOKEN="$(arg_value "$1" "${2:-}")"; shift 2 ;; --access-key) ACCESS_KEY="$(arg_value "$1" "${2:-}")"; shift 2 ;; --secret-key) SECRET_KEY="$(arg_value "$1" "${2:-}")"; shift 2 ;; --aws-sigv4-scope) AWS_SIGV4_SCOPE="$(arg_value "$1" "${2:-}")"; shift 2 ;; --sample) SAMPLES+=("$(arg_value "$1" "${2:-}")"); shift 2 ;; --log-glob) LOG_GLOB="$(arg_value "$1" "${2:-}")"; shift 2 ;; --metric-types) METRIC_TYPES="$(arg_value "$1" "${2:-}")"; shift 2 ;; --metric-samples) METRIC_SAMPLES="$(arg_value "$1" "${2:-}")"; shift 2 ;; --metric-interval) METRIC_INTERVAL="$(arg_value "$1" "${2:-}")"; shift 2 ;; --bucket) BUCKET_FILTER="$(arg_value "$1" "${2:-}")"; shift 2 ;; --min-distinct-reasons) MIN_DISTINCT_REASONS="$(arg_value "$1" "${2:-}")"; shift 2 ;; --out-dir) OUT_DIR="$(arg_value "$1" "${2:-}")"; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; -h|--help) SHOW_HELP=true; shift ;; *) echo "ERROR: unknown arg: $1" >&2 usage exit 1 ;; esac done } validate_args() { if [[ "$SHOW_HELP" == true ]]; then usage exit 0 fi if [[ -z "$ENDPOINT" ]]; then echo "ERROR: --endpoint is required" >&2 exit 1 fi if ! [[ "$MIN_DISTINCT_REASONS" =~ ^[0-9]+$ ]] || (( MIN_DISTINCT_REASONS < 1 )); then echo "ERROR: --min-distinct-reasons must be a positive integer" >&2 exit 1 fi if [[ "$DRY_RUN" != true && "${#SAMPLES[@]}" -eq 0 ]]; then echo "ERROR: at least one --sample is required unless --dry-run is set" >&2 exit 1 fi if [[ -z "$OUT_DIR" ]]; then OUT_DIR="${PROJECT_ROOT}/target/manual-transition-failure-samples/$(date +%Y%m%dT%H%M%S)" fi if [[ -n "$ADMIN_TOKEN" && ( -n "$ACCESS_KEY" || -n "$SECRET_KEY" ) ]]; then echo "ERROR: --admin-token cannot be combined with --access-key/--secret-key" >&2 exit 1 fi if [[ -n "$ACCESS_KEY" && -z "$SECRET_KEY" || -z "$ACCESS_KEY" && -n "$SECRET_KEY" ]]; then echo "ERROR: --access-key and --secret-key must be provided together" >&2 exit 1 fi } sample_label() { local spec="$1" local label="${spec%%:*}" if [[ -z "$label" || "$label" == "$spec" ]]; then echo "ERROR: invalid --sample spec: $spec" >&2 exit 1 fi if ! [[ "$label" =~ ^[A-Za-z0-9._-]+$ ]]; then echo "ERROR: invalid --sample label: $label" >&2 exit 1 fi printf '%s' "$label" } sample_job_id() { local spec="$1" local rest="${spec#*:}" local job_id="${rest%%:*}" if [[ -z "$job_id" || "$job_id" == "$rest" && "$rest" == "$spec" ]]; then echo "ERROR: invalid --sample spec: $spec" >&2 exit 1 fi validate_uuid "$job_id" } sample_expected_reason() { local spec="$1" local rest="${spec#*:}" local job_id="${rest%%:*}" if [[ "$rest" == "$job_id" ]]; then printf '' return fi printf '%s' "${rest#*:}" } curl_json() { local path="$1" local out_file="$2" local url="${ENDPOINT%/}${path}" local args=(-fsS) if [[ -n "$ADMIN_TOKEN" ]]; then args+=(-H "Authorization: Bearer ${ADMIN_TOKEN}") elif [[ -n "$ACCESS_KEY" ]]; then args+=(--aws-sigv4 "$AWS_SIGV4_SCOPE" --user "${ACCESS_KEY}:${SECRET_KEY}") fi curl "${args[@]}" "$url" > "$out_file" } metrics_path() { local path="/rustfs/admin/v3/metrics?types=${METRIC_TYPES}&n=${METRIC_SAMPLES}" if [[ -n "$METRIC_INTERVAL" ]]; then path="${path}&interval=${METRIC_INTERVAL}" fi printf '%s' "$path" } write_dry_run_plan() { mkdir -p "$OUT_DIR" cat >"${OUT_DIR}/sample_plan.md" <<'PLAN' # Manual transition failure attribution sample plan Use this artifact to turn #1509 auth/network/timeout checks into repeatable evidence. ## Prepare controlled samples - auth: configure a temporary remote tier with invalid credentials, run one async manual transition, keep the returned job_id - network: configure a temporary remote tier endpoint that is unreachable or refuses connections, run one async manual transition, keep the returned job_id - timeout: configure a throttled or blackholed remote tier endpoint with a short client timeout, run one async manual transition, keep the returned job_id ## Capture evidence Replace the placeholders and run: ```bash scripts/manual_transition_failure_samples.sh \ --endpoint \ --access-key "$ACCESS_KEY" \ --secret-key "$SECRET_KEY" \ --sample auth:: \ --sample network:: \ --sample timeout:: \ --min-distinct-reasons 2 \ --out-dir target/manual-transition-failure-samples/ ``` ## Close gate - at least two samples have `matched_expected_reason=true` - `distinct_reason_count` across all samples is greater than or equal to `--min-distinct-reasons` - no status/log artifact exposes secrets or full credential material - terminal partial/failed counts agree with `tier_failure_by_reason` PLAN cat >"${OUT_DIR}/commands.txt" < --secret-key --sample auth:: --sample network:: --min-distinct-reasons ${MIN_DISTINCT_REASONS} --out-dir ${OUT_DIR} COMMANDS echo "[DRY-RUN] generated ${OUT_DIR}/sample_plan.md" echo "[DRY-RUN] generated ${OUT_DIR}/commands.txt" } collect_logs() { local job_id="$1" local out_file="$2" local raw_file local err_file local log_paths=() local match raw_file="$(mktemp)" err_file="$(mktemp)" while IFS= read -r match; do [[ -n "$match" ]] && log_paths+=("$match") done < <(compgen -G "$LOG_GLOB" || true) if [[ "${#log_paths[@]}" -eq 0 ]]; then log_paths=("$LOG_GLOB") fi if ! rg --no-filename --color=never -- "${job_id}|lifecycle_tier_operation_failed|lifecycle_worker_state" "${log_paths[@]}" >"$raw_file" 2>"$err_file"; then : fi JOB_ID="$job_id" BUCKET_FILTER="$BUCKET_FILTER" jq -R -s ' split("\n") | map(fromjson? | select(. != null)) | map(select( (.job_id? // "") == env.JOB_ID or (.event? // "") == "lifecycle_tier_operation_failed" or ((.event? // "") == "lifecycle_worker_state" and ((.state? // "") | startswith("manual_transition_"))) )) | if env.BUCKET_FILTER == "" then . else map(select((.bucket? // "") == env.BUCKET_FILTER)) end | map({ timestamp, event, state, job_id, bucket, object, version_id, tier, operation, reason, error, failure_reason }) ' <"$raw_file" >"$out_file" if [[ -s "$err_file" ]]; then sed 's/^/DEBUG: /' "$err_file" >&2 fi rm -f "$raw_file" "$err_file" } write_command_notes() { cat >"${OUT_DIR}/commands.txt" < \\\n'; elif [[ -n "$ACCESS_KEY" ]]; then printf ' --access-key --secret-key \\\n'; fi) $(printf ' --sample %q \\\n' "${SAMPLES[@]}") # Per-job deep dive scripts/manual_transition_debug.sh --endpoint ${ENDPOINT} --job-id --log-glob '${LOG_GLOB}' scripts/manual_transition_journal_audit.sh --endpoint ${ENDPOINT} --job-id --out-dir ${OUT_DIR}/journal- COMMANDS } collect_sample() { local spec="$1" local summary_csv="$2" local label job_id expected sample_dir status_file metrics_file logs_file local status failure_reason reason_counts total_failures distinct_reasons matched_expected label="$(sample_label "$spec")" job_id="$(sample_job_id "$spec")" expected="$(sample_expected_reason "$spec")" sample_dir="${OUT_DIR}/${label}-${job_id}" status_file="${sample_dir}/job_status.json" metrics_file="${sample_dir}/metrics.json" logs_file="${sample_dir}/log_events.json" mkdir -p "$sample_dir" curl_json "/rustfs/admin/v3/ilm/transition/jobs/${job_id}" "$status_file" curl_json "$(metrics_path)" "$metrics_file" collect_logs "$job_id" "$logs_file" status="$(jq -r '.status // ""' "$status_file")" failure_reason="$(jq -r '.failure_reason // ""' "$status_file")" reason_counts="$(jq -c '.report.tier_failure_by_reason // {}' "$status_file")" total_failures="$(jq -r '(.report.tier_failure_by_reason // {}) | [.[]] | add // 0' "$status_file")" distinct_reasons="$(jq -r '(.report.tier_failure_by_reason // {}) | keys | length' "$status_file")" if [[ -z "$expected" ]]; then matched_expected="NA" else matched_expected="$(jq -r --arg expected "$expected" '(.report.tier_failure_by_reason // {}) | (.[$expected] // 0) > 0' "$status_file")" fi printf '%s\n' "$reason_counts" >> "$REASON_COUNTS_JSONL" if [[ "$matched_expected" == "true" ]]; then printf '%s\n' "$label" >> "$MATCHED_EXPECTED_TXT" fi printf '%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n' \ "$(csv_escape "$label")" \ "$(csv_escape "$job_id")" \ "$(csv_escape "$expected")" \ "$(csv_escape "$status")" \ "$(csv_escape "$failure_reason")" \ "$(csv_escape "$total_failures")" \ "$(csv_escape "$matched_expected")" \ "$(csv_escape "$distinct_reasons")" \ "$(csv_escape "$reason_counts")" \ "$(csv_escape "$status_file")" \ "$(csv_escape "$logs_file")" \ "$(csv_escape "$metrics_file")" >> "$summary_csv" } assert_gate() { local distinct_count local matched_count distinct_count="$(jq -s 'map(. // {}) | add // {} | keys | length' "$REASON_COUNTS_JSONL")" matched_count="$(wc -l < "$MATCHED_EXPECTED_TXT" | awk '{$1=$1; print}')" { echo "min_distinct_reasons=${MIN_DISTINCT_REASONS}" echo "observed_distinct_reasons=${distinct_count}" echo "matched_expected_samples=${matched_count}" } > "${OUT_DIR}/gate_summary.txt" if (( distinct_count < MIN_DISTINCT_REASONS )); then echo "ERROR: observed distinct tier failure reasons (${distinct_count}) below required minimum (${MIN_DISTINCT_REASONS})" >&2 exit 1 fi } main() { parse_args "$@" validate_args if [[ "$DRY_RUN" == true ]]; then write_dry_run_plan exit 0 fi require_cmd curl require_cmd jq require_cmd rg require_cmd awk require_cmd tr mkdir -p "$OUT_DIR" local summary_csv="${OUT_DIR}/failure_attribution_summary.csv" { echo "sample,job_id,expected_reason,status,failure_reason,total_tier_failures,matched_expected_reason,distinct_reason_count,reason_counts,status_file,log_file,metrics_file" } > "$summary_csv" REASON_COUNTS_JSONL="${OUT_DIR}/reason_counts.jsonl" MATCHED_EXPECTED_TXT="${OUT_DIR}/matched_expected_samples.txt" : > "$REASON_COUNTS_JSONL" : > "$MATCHED_EXPECTED_TXT" write_command_notes for sample in "${SAMPLES[@]}"; do collect_sample "$sample" "$summary_csv" done assert_gate echo "Artifacts: $OUT_DIR" echo " - $summary_csv" echo " - ${OUT_DIR}/gate_summary.txt" echo " - ${OUT_DIR}/commands.txt" } main "$@"