Files
ROMFASTSQL/proxmox/cluster/scripts/cluster-shutdown.sh
Claude Agent 0104c255a5 feat(cluster): cluster-shutdown/startup --allow-on-node pentru rulare pe pvemini
Rulare detasata din dashboard/Discord: nssh local fara ssh la sine, CT 171
ramane pornit si moare odata cu pvemini (HA freeze il reporneste), email cu
instructiunile de pornire, pauza si pentru testul DR si fereastra de patch.
Garda CT 171 nu mai depinde de /proc/1/environ (era sarita fara root).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KddsXCqEbKMhdFJDYbAsx8
2026-09-13 10:18:12 +00:00

494 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# cluster-shutdown.sh — oprire controlată a întregului cluster Proxmox `romfast`.
#
# Oprește guest-urile în ordinea corectă de dependențe, apoi nodurile, fără ca HA
# să relocheze resursele și fără ca watchdog-ul să reseteze hard vreun nod.
#
# Procedura completă și explicațiile: docs/oprire-planificata-cluster.md
#
# RULEAZĂ DE PE STAȚIA DE ADMIN, nu de pe un nod Proxmox. Motivul: nodul care se
# oprește ultimul nu poate raporta rezultatul propriei opriri.
# Excepție: cu --allow-on-node poate rula chiar pe pvemini (lansat din
# dashboard/Discord, detașat prin systemd-run) — nssh către pvemini devine
# execuție locală, iar pvemini se oprește ultimul, singur, la pasul 8.
#
# Utilizare:
# ./cluster-shutdown.sh # interactiv, cere confirmare
# ./cluster-shutdown.sh --yes # fără confirmare
# ./cluster-shutdown.sh --dry-run # arată ce ar face, nu execută nimic
# ./cluster-shutdown.sh --no-nodes # oprește doar guest-urile, lasă nodurile pornite
# ./cluster-shutdown.sh --allow-on-node # permite rularea chiar pe pvemini
#
set -euo pipefail
# ---------------------------------------------------------------- configurare
declare -A NODE_IP=(
[pve1]=10.0.20.200
[pvemini]=10.0.20.201
[pveelite]=10.0.20.202
)
# Ordinea de OPRIRE a nodurilor. pvemini ultimul — e nodul principal și serverul NFS.
NODE_SHUTDOWN_ORDER=(pveelite pve1 pvemini)
# Ordinea de OPRIRE a guest-urilor: consumatorii întâi, baza de date ULTIMA.
# VM 201 (roacentral) și CT 104 (flowise) folosesc Oracle din CT 108.
# Orice guest pornit care nu apare aici e oprit la final, într-o măturare.
GUEST_SHUTDOWN_ORDER=(
303 # Win11-Adina — desktop, fără dependențe
304 # Win11-Marius — desktop, fără dependențe
302 # oracle-test-302 — VM de test
310 # Win11-Template — template
201 # roacentral — IIS reverse proxy, consumator Oracle
101 # minecraft
110 # moltbot
104 # flowise — consumator Oracle
106 # gitea
103 # dokploy
102 # docker.romfast.ro
100 # portainer
171 # claude-agent — cu --allow-on-node rămâne pornit, vezi KEEP_RUNNING
109 # oracle-dr-windows
301 # docker-portainer-template
108 # central-oracle — ULTIMUL
)
# Cu --allow-on-node, acest guest NU e oprit explicit: ha-manager set --state
# stopped ar marca resursa ca oprită și HA n-ar mai reporni-o la boot. Moare
# oricum odată cu pvemini, iar shutdown_policy=freeze îl repornește după.
KEEP_RUNNING=171
ORACLE_CT=108
ORACLE_DOCKER=oracle-xe
GUEST_TIMEOUT=300 # secunde așteptate pentru shutdown-ul unui guest
NODE_TIMEOUT=600 # secunde așteptate până un nod nu mai răspunde la ping
CRON_BACKUP=/root/crontab.backup-mentenanta.txt
# Cron-uri de dezactivat cât timp clusterul e jos (altfel mail storm, iar
# vm109-watchdog.sh chiar pornește VM 109).
CRON_PATTERN='oom-alert|pvemini-down-alert|pveelite-down-alert|vm109-watchdog|weekly-dr-test-proxmox|vm109-patch-window'
# Destinatarul instrucțiunilor de pornire, trimise prin email după ce starea
# clusterului e salvată.
ALERT_RECIPIENT="${ALERT_RECIPIENT:-mmarius28@gmail.com}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_NODE=$(hostname)
ASSUME_YES=0
DRY_RUN=0
SKIP_NODES=0
ALLOW_ON_NODE=0
# ------------------------------------------------------------------- utilitare
c_red=$'\033[31m'; c_grn=$'\033[32m'; c_yel=$'\033[33m'; c_dim=$'\033[2m'; c_off=$'\033[0m'
log() { printf '%s[%s]%s %s\n' "$c_dim" "$(date +%H:%M:%S)" "$c_off" "$*"; }
ok() { printf '%s ✓%s %s\n' "$c_grn" "$c_off" "$*"; }
warn() { printf '%s !%s %s\n' "$c_yel" "$c_off" "$*"; }
die() { printf '%s ✗ %s%s\n' "$c_red" "$*" "$c_off" >&2; exit 1; }
step() { printf '\n%s══ %s%s\n' "$c_grn" "$*" "$c_off"; }
# ping portabil: Git Bash pe Windows folosește -n/-w, Linux folosește -c/-W
ping_host() {
if [[ "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* || "${OS:-}" == Windows_NT ]]; then
ping -n 1 -w 2000 "$1" >/dev/null 2>&1
else
ping -c 1 -W 2 "$1" >/dev/null 2>&1
fi
}
nssh() {
local node=$1; shift
if (( ALLOW_ON_NODE )) && [[ "$node" == "$LOCAL_NODE" ]]; then
bash -c "$*"
return
fi
ssh -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new \
"root@${NODE_IP[$node]}" "$@"
}
run() { # execută pe nod, sau doar afișează dacă --dry-run
local node=$1; shift
if (( DRY_RUN )); then
printf '%s [dry-run] %s: %s%s\n' "$c_dim" "$node" "$*" "$c_off"
return 0
fi
nssh "$node" "$@"
}
confirm() {
(( ASSUME_YES || DRY_RUN )) && return 0
local reply
printf '\n%s%s%s\n' "$c_yel" "$1" "$c_off"
read -r -p "Scrie DA ca să continui: " reply
[[ "$reply" == "DA" ]] || die "Anulat de utilizator."
}
# ------------------------------------------------------------- argumente
while (( $# )); do
case "$1" in
--yes|-y) ASSUME_YES=1 ;;
--dry-run) DRY_RUN=1 ;;
--no-nodes) SKIP_NODES=1 ;;
--allow-on-node) ALLOW_ON_NODE=1 ;;
-h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "Argument necunoscut: $1" ;;
esac
shift
done
STATE_FILE="${CLUSTER_STATE_FILE:-}"
if [[ -z "$STATE_FILE" ]]; then
if (( ALLOW_ON_NODE )); then
STATE_FILE=/opt/scripts/.cluster-state.txt
else
STATE_FILE="$SCRIPT_DIR/.cluster-state.txt"
fi
fi
# --------------------------------------------------------- garda de rulare
# Scriptul NU trebuie rulat de pe un nod Proxmox (s-ar opri singur la mijloc) și
# nici din CT 171 claude-agent (e în lista de oprit — s-ar sinucide).
# --allow-on-node ridică prima interdicție: rulat din dashboard/Discord, chiar
# pe pvemini, prin systemd-run.
if command -v pvecm >/dev/null 2>&1 && (( ! ALLOW_ON_NODE )); then
die "Rulezi pe un nod Proxmox. Scriptul trebuie rulat de pe stația de admin (sau cu --allow-on-node)."
fi
# Hostname-ul se verifica independent: /proc/1/environ nu e lizibil fara root,
# iar garda era sarita in tacere pentru userul claude.
if [[ "$(hostname)" == *claude-agent* ]]; then
die "Rulezi din CT 171 (claude-agent), care e în lista de oprit. Mută-te pe stația de admin."
fi
if [[ -r /proc/1/environ ]] && grep -qa 'container=lxc' /proc/1/environ 2>/dev/null; then
warn "Rulezi dintr-un container LXC. Asigură-te că nu e unul găzduit pe acest cluster."
fi
# ------------------------------------------------------ pas 1: preflight
step "Preflight"
for node in "${!NODE_IP[@]}"; do
ping_host "${NODE_IP[$node]}" || die "$node (${NODE_IP[$node]}) nu răspunde la ping."
nssh "$node" true 2>/dev/null || die "$node nu acceptă SSH cu cheie."
ok "$node (${NODE_IP[$node]}) — reachable"
done
MASTER=$(nssh pvemini "ha-manager status | awk '/^master/ {print \$2}'")
[[ -n "$MASTER" ]] || die "Nu pot citi HA master. Cluster instabil?"
nssh pvemini "pvecm status | grep -q 'Quorate: *Yes'" || die "Clusterul NU are quorum. Oprește-te și investighează."
ok "quorum OK, HA master: $MASTER"
RUNNING_TASKS=0
for node in "${!NODE_IP[@]}"; do
n=$(nssh "$node" "pvesh get /nodes/$node/tasks --limit 20 --output-format json 2>/dev/null | grep -o '\"status\":\"running\"' | wc -l" || echo 0)
RUNNING_TASKS=$(( RUNNING_TASKS + n ))
done
if (( RUNNING_TASKS > 0 )); then
warn "$RUNNING_TASKS task-uri în execuție pe cluster (backup? migrare?)."
confirm "Sunt task-uri active. Continui oricum?"
else
ok "niciun task în execuție"
fi
# ------------------------------------------- pas 2: inventar + salvare stare
step "Inventar guest-uri"
HA_SIDS=$(nssh pvemini "ha-manager config | grep -E '^(ct|vm):' | tr -d ' '" || true)
is_ha() { grep -qx "$1" <<<"$HA_SIDS"; }
# Format linie: <type> <node> <vmid> <status> <ha|noha>
INVENTORY=""
for node in "${!NODE_IP[@]}"; do
while read -r id status; do
[[ -n "$id" ]] || continue
local_ha=noha; is_ha "ct:$id" && local_ha=ha
INVENTORY+="ct $node $id $status $local_ha"$'\n'
done < <(nssh "$node" "pct list | awk 'NR>1 {print \$1, \$2}'")
while read -r id status; do
[[ -n "$id" ]] || continue
local_ha=noha; is_ha "vm:$id" && local_ha=ha
INVENTORY+="vm $node $id $status $local_ha"$'\n'
done < <(nssh "$node" "qm list | awk 'NR>1 {print \$1, \$3}'")
done
RUNNING=$(grep ' running ' <<<"$INVENTORY" || true)
RUNNING_COUNT=$(grep -c . <<<"${RUNNING:-}" || true)
if [[ -z "$RUNNING" ]]; then
warn "Niciun guest pornit. Trec direct la oprirea nodurilor."
else
printf '%s\n' "$RUNNING" | while read -r type node id status ha; do
printf ' %-3s %-9s %-4s %s\n' "$type" "$node" "$id" "$ha"
done
ok "$RUNNING_COUNT guest-uri pornite"
fi
# Salvează starea LOCAL — cluster-startup.sh pornește exact ce era pornit,
# ca să nu repornească VM-uri oprite intenționat (302, 310, 301...).
if (( ! DRY_RUN )); then
# Nu rescrie peste o stare existentă cu un inventar gol. Cazul real: scriptul
# e rulat a doua oară (după --no-nodes, sau după o eroare), când guest-urile
# sunt deja oprite — inventarul e gol și am pierde exact lista de repornit.
EXISTING=0
[[ -f "$STATE_FILE" ]] && EXISTING=$(grep -cv -e '^[[:space:]]*#' -e '^[[:space:]]*$' "$STATE_FILE" || true)
if [[ -z "$RUNNING" ]] && (( EXISTING > 0 )); then
warn "Niciun guest pornit, dar $STATE_FILE conține deja $EXISTING intrări — îl PĂSTREZ."
warn "Altfel cluster-startup.sh n-ar mai ști ce să pornească."
else
{ echo "# stare cluster salvată la $(date '+%Y-%m-%d %H:%M:%S')"
printf '%s\n' "${RUNNING:-}"; } > "$STATE_FILE"
ok "stare salvată în $STATE_FILE"
fi
fi
# Instrucțiuni de pornire, trimise acum cât timp mai există cine să le citească —
# clusterul urmează să se oprească.
if (( DRY_RUN )); then
printf '%s [dry-run] email: instrucțiuni de pornire către %s%s\n' "$c_dim" "$ALERT_RECIPIENT" "$c_off"
else
MAIL_BODY=$(cat <<EOF
Clusterul romfast intră în oprire planificată.
Pentru pornire:
1. JuiceSSH (prin Tailscale) → conectare la 10.0.20.36, port 22122.
2. Rulează: C:\wolcluster.bat
3. Așteaptă mesajul „Cluster sus" în Discord.
Rezervă, dacă WoL nu pornește toate nodurile:
ssh root@10.0.20.201 /opt/scripts/cluster-startup.sh --allow-on-node --yes
EOF
)
printf '%s\n' "$MAIL_BODY" | mail -s "[INFO] Cluster romfast: oprire în curs — cum îl pornești" "$ALERT_RECIPIENT" \
&& ok "instrucțiuni de pornire trimise către $ALERT_RECIPIENT" \
|| warn "emailul cu instrucțiuni de pornire NU a putut fi trimis"
fi
confirm "Se opresc $RUNNING_COUNT guest-uri, apoi nodurile: ${NODE_SHUTDOWN_ORDER[*]}."
# -------------------------------------------- pas 3: shutdown_policy=freeze
step "Shutdown policy → freeze"
CURRENT_POLICY=$(nssh pvemini "cat /etc/pve/datacenter.cfg 2>/dev/null | grep -oP 'shutdown_policy=\K\w+'" || true)
if [[ "$CURRENT_POLICY" == "freeze" ]]; then
ok "deja pe freeze"
else
if nssh pvemini "test -f /etc/pve/datacenter.cfg"; then
# sed șterge DOAR linia 'ha:', nu rescrie fișierul: din 2026-08-29 acolo
# stă și 'migration: network=10.10.10.0/24,type=insecure', fără de care
# replicarea se întoarce tăcut pe rețeaua de producție.
run pvemini "cp /etc/pve/datacenter.cfg /root/datacenter.cfg.backup-mentenanta"
run pvemini "sed -i '/^ha:/d' /etc/pve/datacenter.cfg; printf 'ha: shutdown_policy=freeze\n' >> /etc/pve/datacenter.cfg"
else
run pvemini "printf 'ha: shutdown_policy=freeze\n' > /etc/pve/datacenter.cfg"
fi
ok "setat freeze (era: ${CURRENT_POLICY:-nesetat})"
fi
# ---------------------------------------- pas 4: dezactivare cron-uri alertă
step "Dezactivare cron-uri de alertă"
for node in "${!NODE_IP[@]}"; do
# Backup DOAR dacă crontab-ul curent nu e deja comentat. Altfel o a doua rulare
# a scriptului (după o eroare, de exemplu) ar salva peste backup versiunea deja
# comentată, iar cluster-startup.sh ar „restaura" monitorizarea oprită — tăcut.
run "$node" "if crontab -l 2>/dev/null | grep -q '^#MENTENANTA'; then echo 'backup păstrat'; else crontab -l > $CRON_BACKUP 2>/dev/null || true; fi"
run "$node" "crontab -l | sed -E '/^[^#]/ s%^(.*($CRON_PATTERN)\.sh.*)\$%#MENTENANTA \1%' | crontab -"
if (( ! DRY_RUN )); then
n=$(nssh "$node" "crontab -l | grep -c '^#MENTENANTA' || true")
ok "$node — $n linii dezactivate, backup în $CRON_BACKUP"
fi
done
warn "Monitorizarea e acum OPRITĂ pe tot clusterul. cluster-startup.sh o restaurează."
# --------------------------------------------- pas 5: shutdown curat Oracle
step "Oracle — shutdown immediate"
ORACLE_NODE=$(awk -v id="$ORACLE_CT" '$1=="ct" && $3==id && $4=="running" {print $2}' <<<"$INVENTORY")
if [[ -z "$ORACLE_NODE" ]]; then
warn "CT $ORACLE_CT nu rulează — sar peste shutdown-ul bazei."
else
log "CT $ORACLE_CT pe $ORACLE_NODE — opresc instanța..."
if (( DRY_RUN )); then
printf '%s [dry-run] shutdown immediate în %s%s\n' "$c_dim" "$ORACLE_DOCKER" "$c_off"
else
# `bash -lc`, NU `bash -c`: fără shell de login, PATH-ul Oracle nu e încărcat
# și comanda eșuează cu „sqlplus: command not found" — adică baza rămâne
# DESCHISĂ, iar containerul ar fi oprit peste ea.
# `timeout 180`: un „shutdown immediate" poate dura, dar nu la infinit. Fără
# el, un sqlplus blocat ar ține scriptul agățat fără niciun mesaj (pățit pe
# 2026-08-29, la sonda echivalentă din cluster-startup.sh).
SQL_OUT=$(nssh "$ORACLE_NODE" "timeout 180 pct exec $ORACLE_CT -- docker exec $ORACLE_DOCKER bash -lc \"printf 'shutdown immediate\nexit\n' | sqlplus -s / as sysdba\" 2>&1" || true)
# Codul de ieșire nu e o dovadă: sqlplus întoarce 0 și când n-a închis nimic.
# Singura confirmare reală e mesajul instanței.
if grep -q 'ORACLE instance shut down' <<<"$SQL_OUT"; then
ok "instanța Oracle oprită curat"
else
warn "Baza NU a confirmat „ORACLE instance shut down\". Ultimele linii:"
tail -8 <<<"$SQL_OUT"
confirm "Oprirea CT $ORACLE_CT peste o bază posibil deschisă. Continui?"
fi
fi
fi
# ------------------------------------------------ pas 6: oprire guest-uri
step "Oprire guest-uri"
guest_status() {
local node=$1 type=$2 id=$3
if [[ "$type" == ct ]]; then
nssh "$node" "pct status $id 2>/dev/null | awk '{print \$2}'" || echo unknown
else
nssh "$node" "qm status $id 2>/dev/null | awk '{print \$2}'" || echo unknown
fi
}
stop_guest() {
local type=$1 node=$2 id=$3 ha=$4
local sid="$type:$id" cmd
[[ "$type" == ct ]] && cmd=pct || cmd=qm
if [[ "$ha" == ha ]]; then
# Pentru resursele HA se folosește ha-manager, NU pct/qm shutdown: din CLI
# acestea nu actualizează state-ul HA, iar CRM-ul ar reporni guest-ul.
run "$node" "ha-manager set $sid --state stopped"
else
run "$node" "$cmd shutdown $id --timeout $GUEST_TIMEOUT" || true
fi
(( DRY_RUN )) && return 0
local waited=0
while (( waited < GUEST_TIMEOUT )); do
[[ "$(guest_status "$node" "$type" "$id")" == "stopped" ]] && { ok "$sid oprit (${waited}s)"; return 0; }
sleep 5; waited=$(( waited + 5 ))
done
warn "$sid nu s-a oprit în ${GUEST_TIMEOUT}s — forțez stop"
nssh "$node" "$cmd stop $id" || true
sleep 5
[[ "$(guest_status "$node" "$type" "$id")" == "stopped" ]] \
|| die "$sid REFUZĂ să se oprească. Rezolvă manual înainte de a opri nodurile."
ok "$sid oprit forțat"
}
# întâi în ordinea definită...
for id in "${GUEST_SHUTDOWN_ORDER[@]}"; do
if (( ALLOW_ON_NODE )) && [[ "$id" == "$KEEP_RUNNING" ]]; then
warn "$id rămâne pornit; se oprește odată cu pvemini, HA freeze îl repornește"
continue
fi
line=$(awk -v id="$id" '$3==id && $4=="running"' <<<"$INVENTORY") || true
[[ -n "$line" ]] || continue
read -r type node vmid status ha <<<"$line"
log "opresc $type:$vmid pe $node ($ha)"
stop_guest "$type" "$node" "$vmid" "$ha"
done
# ...apoi orice a mai rămas pornit și nu era în listă
while read -r type node id status ha; do
[[ -n "${id:-}" ]] || continue
printf '%s\n' "${GUEST_SHUTDOWN_ORDER[@]}" | grep -qx "$id" && continue
warn "guest neplanificat încă pornit: $type:$id pe $node — îl opresc"
stop_guest "$type" "$node" "$id" "$ha"
done <<<"${RUNNING:-}"
# ------------------------------------------- pas 7: verificare obligatorie
step "Verificare înainte de oprirea nodurilor"
if (( DRY_RUN )); then
warn "dry-run — sar peste verificare"
else
if (( ALLOW_ON_NODE )); then
# ct:$KEEP_RUNNING rămâne intenționat „started" — nu e o scăpare.
BAD=$(nssh pvemini "ha-manager status | grep -E 'started|migrate|relocate|fence'" | grep -v "ct:$KEEP_RUNNING" || true)
else
BAD=$(nssh pvemini "ha-manager status | grep -E 'started|migrate|relocate|fence'" || true)
fi
[[ -z "$BAD" ]] || { printf '%s\n' "$BAD"; die "Există servicii HA încă active. NU opri nodurile."; }
ok "toate serviciile HA sunt în state stopped"
STILL=0
for node in "${!NODE_IP[@]}"; do
if (( ALLOW_ON_NODE )); then
n=$(nssh "$node" "{ pct list | awk '\$2==\"running\" && \$1!=$KEEP_RUNNING'; qm list; } | grep -c running || true")
else
n=$(nssh "$node" "{ pct list; qm list; } | grep -c running || true")
fi
(( STILL += n ))
done
(( STILL == 0 )) || die "$STILL guest-uri încă pornite. NU opri nodurile."
ok "0 guest-uri pornite pe cluster"
ok "watchdog-ul nu mai e armat — pierderea quorumului e inofensivă"
fi
if (( SKIP_NODES )); then
step "--no-nodes: nodurile rămân pornite. Gata."
exit 0
fi
confirm "Se opresc nodurile în ordinea: ${NODE_SHUTDOWN_ORDER[*]} (pvemini ultimul)."
# ------------------------------------------------- pas 8: oprire noduri
step "Oprire noduri"
for node in "${NODE_SHUTDOWN_ORDER[@]}"; do
log "opresc $node (${NODE_IP[$node]})..."
if (( ALLOW_ON_NODE )) && [[ "$node" == "$LOCAL_NODE" ]] && (( ! DRY_RUN )); then
# Nodul local (pvemini) e ultimul din listă — nu se poate ping-a pe sine
# după poweroff, deci nu intră în bucla de așteptare de mai jos. Trimite
# raportul final și se oprește direct.
REPORT="Cluster romfast oprit. Noduri oprite: ${NODE_SHUTDOWN_ORDER[*]}."
if [[ -n "${INFRA_LOG:-}" && -r "$INFRA_LOG" ]]; then
REPORT+=$'\n\nUltimele linii din jurnal:\n'"$(tail -n 30 "$INFRA_LOG")"
fi
printf '%s\n' "$REPORT" | mail -s "[INFO] Cluster romfast: oprit" "$ALERT_RECIPIENT" \
|| warn "raportul final nu a putut fi trimis"
postqueue -f 2>/dev/null || true
sleep 15
systemctl poweroff --no-block
exit 0
fi
run "$node" "systemctl poweroff --no-block" || true
(( DRY_RUN )) && continue
waited=0
while (( waited < NODE_TIMEOUT )); do
ping_host "${NODE_IP[$node]}" || { ok "$node OPRIT (${waited}s)"; break; }
sleep 10; waited=$(( waited + 10 ))
done
if (( waited >= NODE_TIMEOUT )); then
warn "$node încă răspunde la ping după ${NODE_TIMEOUT}s."
warn "Verifică manual — sshd poate fi deja jos deși rețeaua e sus."
fi
done
step "Cluster oprit"
cat <<EOF
Stare salvată: $STATE_FILE
Backup crontab: $CRON_BACKUP (pe fiecare nod)
Shutdown policy: freeze
La revenirea curentului: ./cluster-startup.sh
EOF