Files
awoooi/scripts/reboot-recovery/full-host-reboot-orchestrator.sh
ogt 6d2d59c027
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m17s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
fix(reboot): preserve ai triage blocker signal
2026-07-09 16:26:01 +08:00

308 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
# Full-host reboot recovery orchestrator.
# This is intentionally a coordinator: irreversible actions stay out of scope,
# while bounded service-start/recovery hooks are delegated to audited scripts.
MODE="check"
ARTIFACT_ROOT="${ARTIFACT_ROOT:-/tmp/awoooi-full-host-reboot-recovery}"
RUN_ID="${RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)}"
HOSTS="${HOSTS:-99 110 111 112 120 121 188}"
PUBLIC_ROUTES="${PUBLIC_ROUTES:-https://awoooi.wooo.work/api/v1/health https://awoooi.wooo.work/ https://stock.wooo.work/healthz https://stock.wooo.work/api/healthz https://gitea.wooo.work/ https://harbor.wooo.work/ https://registry.wooo.work/v2/}"
SSH_TIMEOUT_SECONDS="${SSH_TIMEOUT_SECONDS:-12}"
RECOVERY_SLO_SECONDS="${RECOVERY_SLO_SECONDS:-600}"
TELEGRAM_NOTIFY_SCRIPT="${TELEGRAM_NOTIFY_SCRIPT:-}"
DISABLE_TELEGRAM="${DISABLE_TELEGRAM:-0}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ARTIFACT_DIR="${ARTIFACT_ROOT%/}/${RUN_ID}"
EVENTS_TSV="${ARTIFACT_DIR}/events.tsv"
FINDINGS_TSV="${ARTIFACT_DIR}/findings.tsv"
ACTIONS_TSV="${ARTIFACT_DIR}/actions.tsv"
SUMMARY_TXT="${ARTIFACT_DIR}/summary.txt"
usage() {
cat <<'USAGE'
Usage:
full-host-reboot-orchestrator.sh [--check|--apply] [--artifact-dir DIR]
Modes:
--check Read-only orchestration and evidence collection.
--apply Run controlled apply hooks that are already allowlisted.
Environment:
HOSTS Space-separated host list, default: 99 110 111 112 120 121 188
PUBLIC_ROUTES Space-separated route readback list.
SSH_TIMEOUT_SECONDS SSH connect/command timeout, default: 12.
RECOVERY_SLO_SECONDS Full recovery target, default: 600.
TELEGRAM_NOTIFY_SCRIPT Optional no-secret wrapper script for Telegram notification.
DISABLE_TELEGRAM=1 Suppress Telegram wrapper even when configured.
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--check)
MODE="check"
shift
;;
--apply)
MODE="apply"
shift
;;
--artifact-dir)
ARTIFACT_DIR="$2"
ARTIFACT_ROOT="$(dirname "$2")"
RUN_ID="$(basename "$2")"
shift 2
;;
--help|-h)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
mkdir -p "$ARTIFACT_DIR"
: >"$EVENTS_TSV"
: >"$FINDINGS_TSV"
: >"$ACTIONS_TSV"
event() {
local level="$1" component="$2" message="$3"
printf '%s\t%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$level" "$component" "$message" | tee -a "$EVENTS_TSV" >/dev/null
}
finding() {
local severity="$1" component="$2" code="$3" message="$4"
printf '%s\t%s\t%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$severity" "$component" "$code" "$message" | tee -a "$FINDINGS_TSV" >/dev/null
}
action() {
local mode="$1" component="$2" code="$3" message="$4"
printf '%s\t%s\t%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$mode" "$component" "$code" "$message" | tee -a "$ACTIONS_TSV" >/dev/null
}
notify() {
local phase="$1" status="$2" message="$3"
if [[ "$DISABLE_TELEGRAM" == "1" || -z "$TELEGRAM_NOTIFY_SCRIPT" ]]; then
event "INFO" "telegram" "notify skipped phase=${phase} status=${status}: ${message}"
return 0
fi
if [[ ! -x "$TELEGRAM_NOTIFY_SCRIPT" ]]; then
finding "P1" "telegram" "TELEGRAM_WRAPPER_NOT_EXECUTABLE" "$TELEGRAM_NOTIFY_SCRIPT"
return 0
fi
"$TELEGRAM_NOTIFY_SCRIPT" --phase "$phase" --status "$status" --artifact-dir "$ARTIFACT_DIR" --message "$message" || {
finding "P1" "telegram" "TELEGRAM_NOTIFY_FAILED" "phase=${phase} status=${status}"
}
}
host_ip() {
case "$1" in
99) printf '192.168.0.99' ;;
110) printf '192.168.0.110' ;;
111) printf '192.168.0.111' ;;
112) printf '192.168.0.112' ;;
120) printf '192.168.0.120' ;;
121) printf '192.168.0.121' ;;
188) printf '192.168.0.188' ;;
*) printf '%s' "$1" ;;
esac
}
ssh_user() {
case "$1" in
188) printf 'ollama' ;;
*) printf 'wooo' ;;
esac
}
probe_host_tcp() {
local host="$1" ip
ip="$(host_ip "$host")"
if nc -z -w "$SSH_TIMEOUT_SECONDS" "$ip" 22 >/dev/null 2>&1; then
event "INFO" "host-${host}" "ssh tcp open ${ip}:22"
return 0
fi
finding "P0" "host-${host}" "HOST_SSH_PORT_DOWN" "${ip}:22 not reachable"
return 1
}
probe_host_command() {
local host="$1" ip user output_file
ip="$(host_ip "$host")"
user="$(ssh_user "$host")"
output_file="${ARTIFACT_DIR}/host-${host}-uptime.txt"
if timeout "$((SSH_TIMEOUT_SECONDS + 5))" ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout="$SSH_TIMEOUT_SECONDS" "${user}@${ip}" 'printf "host=%s\n" "$(hostname)"; uptime -s; uptime' >"$output_file" 2>&1; then
event "INFO" "host-${host}" "ssh command ok ${user}@${ip}"
return 0
fi
if grep -Eqi 'Permission denied|publickey|authentication' "$output_file"; then
finding "P0" "host-${host}" "SSH_AUTH_FAILED" "${user}@${ip}"
elif grep -Eqi 'timed out|Timeout|not responding' "$output_file"; then
finding "P0" "host-${host}" "SSH_COMMAND_SESSION_TIMEOUT" "${user}@${ip}"
else
finding "P0" "host-${host}" "SSH_COMMAND_FAILED" "${user}@${ip}; see ${output_file}"
fi
return 1
}
detect_recent_reboot() {
local host="$1" ip user output_file boot_epoch now_epoch age
ip="$(host_ip "$host")"
user="$(ssh_user "$host")"
output_file="${ARTIFACT_DIR}/host-${host}-boot.txt"
if ! timeout "$((SSH_TIMEOUT_SECONDS + 5))" ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout="$SSH_TIMEOUT_SECONDS" "${user}@${ip}" 'date +%s; cat /proc/stat | awk "/btime/ {print \$2}"' >"$output_file" 2>&1; then
event "WARN" "host-${host}" "recent reboot detection unavailable"
return 0
fi
now_epoch="$(sed -n '1p' "$output_file")"
boot_epoch="$(sed -n '2p' "$output_file")"
if [[ "$now_epoch" =~ ^[0-9]+$ && "$boot_epoch" =~ ^[0-9]+$ ]]; then
age=$((now_epoch - boot_epoch))
printf '%s\t%s\t%s\n' "$host" "$boot_epoch" "$age" >>"${ARTIFACT_DIR}/boot-age.tsv"
if (( age <= RECOVERY_SLO_SECONDS )); then
event "INFO" "host-${host}" "recent reboot detected age_seconds=${age}"
else
event "INFO" "host-${host}" "host already past reboot window age_seconds=${age}"
fi
fi
}
probe_public_routes() {
local route status output_file safe_name
for route in $PUBLIC_ROUTES; do
safe_name="$(printf '%s' "$route" | sed 's#[^A-Za-z0-9._-]#_#g')"
output_file="${ARTIFACT_DIR}/route-${safe_name}.txt"
status="$(curl -k -L --max-time 12 -o "$output_file" -w '%{http_code}' "$route" 2>"${output_file}.err" || true)"
printf '%s\t%s\n' "$route" "$status" >>"${ARTIFACT_DIR}/public-routes.tsv"
case "$status" in
200|204|301|302|307|308|401)
event "INFO" "route" "${route} status=${status}"
;;
502|503|504)
finding "P0" "route" "PUBLIC_ROUTE_UPSTREAM_FAILED" "${route} status=${status}"
;;
000)
finding "P0" "route" "PUBLIC_ROUTE_UNREACHABLE" "${route} status=${status}"
;;
*)
finding "P1" "route" "PUBLIC_ROUTE_UNEXPECTED_STATUS" "${route} status=${status}"
;;
esac
done
}
run_ai_log_triage() {
local triage_dir="${ARTIFACT_DIR}/ai-log-triage"
local rc=0
if [[ ! -x "${SCRIPT_DIR}/ai-log-triage-auto-recovery.sh" ]]; then
finding "P0" "recovery" "AI_LOG_TRIAGE_SCRIPT_MISSING" "${SCRIPT_DIR}/ai-log-triage-auto-recovery.sh"
return 1
fi
action "$MODE" "ai-log-triage" "RUN_AI_LOG_TRIAGE" "artifact_dir=${triage_dir}"
"${SCRIPT_DIR}/ai-log-triage-auto-recovery.sh" "--${MODE}" --artifact-dir "$triage_dir" || rc=$?
if [[ "$rc" -ne 0 && "$rc" -ne 2 ]]; then
finding "P0" "ai-log-triage" "AI_LOG_TRIAGE_FAILED" "see ${triage_dir}"
return 1
fi
if [[ "$rc" -eq 2 ]]; then
event "INFO" "ai-log-triage" "AI_LOG_TRIAGE_REPORTED_FINDINGS rc=2"
fi
if [[ -s "${triage_dir}/findings.tsv" ]]; then
awk -v now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -F '\t' \
'{print now "\t" $2 "\tai-log-triage\t" $1 "\thost=" $3 " evidence=" $4 " next_action=" $5}' \
"${triage_dir}/findings.tsv" >>"$FINDINGS_TSV" || true
fi
}
run_scorecard() {
local scorecard_file="${ARTIFACT_DIR}/scorecard.txt"
if [[ ! -f "${SCRIPT_DIR}/reboot-auto-recovery-slo-scorecard.py" ]]; then
finding "P0" "scorecard" "SCORECARD_SCRIPT_MISSING" "${SCRIPT_DIR}/reboot-auto-recovery-slo-scorecard.py"
return 1
fi
action "check" "scorecard" "RUN_REBOOT_SCORECARD" "$scorecard_file"
python3 "${SCRIPT_DIR}/reboot-auto-recovery-slo-scorecard.py" >"$scorecard_file" 2>&1 || {
finding "P0" "scorecard" "SCORECARD_FAILED" "see ${scorecard_file}"
return 1
}
}
run_stock_upstream_gate_hint() {
local stock_gate="/Users/ogt/codex-workspaces/gitea-restored-worktrees-20260701/stockplatform-v2/scripts/ops/stockplatform-api-upstream-recovery-gate.sh"
if [[ ! -x "$stock_gate" ]]; then
event "WARN" "stockplatform" "stock upstream recovery gate not executable or missing: ${stock_gate}"
return 0
fi
action "check" "stockplatform" "VERIFY_STOCK_API_UPSTREAM_GATE" "$stock_gate"
STOCKPLATFORM_DIRECT_EDGE_URL="${STOCKPLATFORM_DIRECT_EDGE_URL:-http://127.0.0.1:31235}" \
STOCKPLATFORM_PUBLIC_WEB_URL="${STOCKPLATFORM_PUBLIC_WEB_URL:-https://stock.wooo.work}" \
"$stock_gate" --verify-only >"${ARTIFACT_DIR}/stockplatform-api-upstream-gate.txt" 2>&1 || {
finding "P0" "stockplatform" "STOCK_API_UPSTREAM_GATE_BLOCKED" "see ${ARTIFACT_DIR}/stockplatform-api-upstream-gate.txt"
}
}
write_summary() {
local p0_count p1_count elapsed status
elapsed=$(( $(date +%s) - START_EPOCH ))
p0_count="$(awk -F '\t' '$2=="P0"{c++} END{print c+0}' "$FINDINGS_TSV")"
p1_count="$(awk -F '\t' '$2=="P1"{c++} END{print c+0}' "$FINDINGS_TSV")"
if (( p0_count == 0 )); then
status="RECOVERY_READY_FOR_RUNTIME_CONFIRMATION"
else
status="RECOVERY_BLOCKED_P0"
fi
{
printf 'run_id=%s\n' "$RUN_ID"
printf 'mode=%s\n' "$MODE"
printf 'status=%s\n' "$status"
printf 'elapsed_seconds=%s\n' "$elapsed"
printf 'slo_seconds=%s\n' "$RECOVERY_SLO_SECONDS"
printf 'p0_count=%s\n' "$p0_count"
printf 'p1_count=%s\n' "$p1_count"
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
printf '\n[P0 findings]\n'
awk -F '\t' '$2=="P0"{print}' "$FINDINGS_TSV" || true
printf '\n[Actions]\n'
cat "$ACTIONS_TSV"
} >"$SUMMARY_TXT"
cat "$SUMMARY_TXT"
}
START_EPOCH="$(date +%s)"
event "INFO" "orchestrator" "start mode=${MODE} run_id=${RUN_ID}"
notify "start" "running" "full-host reboot recovery started mode=${MODE}"
for host in $HOSTS; do
probe_host_tcp "$host" || true
done
for host in $HOSTS; do
probe_host_command "$host" || true
detect_recent_reboot "$host" || true
done
probe_public_routes
run_ai_log_triage || true
run_scorecard || true
run_stock_upstream_gate_hint || true
write_summary
if awk -F '\t' '$2=="P0"{found=1} END{exit found ? 0 : 1}' "$FINDINGS_TSV"; then
notify "finish" "blocked" "full-host reboot recovery has P0 blockers; artifact=${ARTIFACT_DIR}"
exit 1
fi
notify "finish" "ok" "full-host reboot recovery has no P0 blockers; artifact=${ARTIFACT_DIR}"
exit 0