#!/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: 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 </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 <