From 393cc8c7363b4da0e164f183c9823ede6609bf17 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 27 Aug 2026 14:13:42 +0300 Subject: [PATCH] fix(ups): shutdown-ul orchestrat la pana de curent nu functiona deloc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upsmon face fork in doua procese, iar NOTIFYCMD ruleaza in cel neprivilegiat, ca user `nut`: shell nologin, home /var/lib/nut, NICIO cheie SSH. Verificat pe pvemini — pentru `nut`, `qm list`, `pct list` si `ssh root@pve1` esueaza toate. Deci ups-shutdown-cluster.sh nu putea opri niciun guest si nu putea ajunge la niciun nod. Singurul lucru care mergea erau emailurile, pentru care exista deja `nut ALL=(root) NOPASSWD: /usr/bin/perl`. De asta /var/log/ups-shutdown.log nu continea nicio oprire reusita din 2025-10-06 incoace: la fiecare pana de curent se trimiteau emailuri si nu se oprea nimic, iar pve1/pveelite mergeau pana se termina bateria. Reparat: - /etc/sudoers.d/nut-shutdown da userului nut dreptul sa ruleze scriptul ca root - upssched-cmd il invoca prin sudo (liniile 355 si 413) - scriptul refuza sa porneasca daca nu e root, cu mesaj explicit Rescris scriptul: - seteaza shutdown_policy=freeze INAINTE de orice. Fara asta, politica implicita `conditional` transforma fiecare poweroff de nod in failover catre un nod care se stinge si el imediat — exact incidentul 2026-01-11, cu VM 201 migrat in timpul unei pene - guest-urile HA se opresc cu `ha-manager set --state stopped`, nu cu pct/qm shutdown, care din CLI nu actualizeaza state-ul HA - Oracle primeste `shutdown immediate` inainte de oprirea containerului - consumatorii se opresc in paralel, Oracle ultimul - detecteaza lipsa quorumului si cade pe oprire directa, in loc sa blocheze - flock: ONBATT si LOWBATT pot declansa amandoua scriptul - sare peste nodurile care nu raspund la ping in loc sa astepte timeout SSH - timeout pe notificarile email: nu consumam baterie pe SMTP - --dry-run si --force; credentialele UPS pot veni din /etc/nut/ups-shutdown.conf in loc sa fie in script ups-shutdown-test.sh nu mai duplica logica: invoca scriptul real cu --dry-run. Un test cu cod propriu testeaza altceva decat ce ruleaza in realitate. Testat pe pvemini prin lantul complet: sudo -u nut sudo /usr/local/bin/ups-shutdown-cluster.sh --dry-run --force Descopera corect toate cele 11 guest-uri, inclusiv cele de pe pve1 (deci SSH-ul merge), si gaseste shutdown.stayoff pe UPS. RAMANE DE VERIFICAT FIZIC: ups.load e 8%, putin pentru trei servere. Daca pve1 si pveelite nu sunt alimentate din UPS, mor instant la pana si toata orchestrarea e decorativa. Nu se poate verifica din software. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RRyaDj39hPQ89SZS6URRpS --- proxmox/cluster/ups/README.md | 52 +- .../cluster/ups/config/sudoers.d-nut-shutdown | 25 + .../ups/scripts/ups-shutdown-cluster.sh | 583 ++++++++++++------ .../cluster/ups/scripts/ups-shutdown-test.sh | 80 +-- 4 files changed, 496 insertions(+), 244 deletions(-) create mode 100644 proxmox/cluster/ups/config/sudoers.d-nut-shutdown mode change 100644 => 100755 proxmox/cluster/ups/scripts/ups-shutdown-cluster.sh mode change 100644 => 100755 proxmox/cluster/ups/scripts/ups-shutdown-test.sh diff --git a/proxmox/cluster/ups/README.md b/proxmox/cluster/ups/README.md index d4172c6..89fc462 100644 --- a/proxmox/cluster/ups/README.md +++ b/proxmox/cluster/ups/README.md @@ -52,12 +52,56 @@ Restart servicii ### Cluster Proxmox - **pvemini (10.0.20.201)** - Nod PRIMARY - Are UPS-ul conectat fizic - - Rulează NUT server și driver + - Rulează NUT server și driver (`MODE=standalone`) + - Orchestrează oprirea celorlalte noduri prin SSH - Ultimul nod care se oprește - **pve1 (10.0.20.200)** - Nod SECONDARY - - Se oprește primul în caz de baterie critică -- **pve2 (10.0.20.202)** - Nod SECONDARY - - Se oprește primul în caz de baterie critică + - **NU rulează NUT deloc** — e oprit prin SSH de pe pvemini +- **pveelite (10.0.20.202)** - Nod SECONDARY + - **NU rulează NUT deloc** — e oprit prin SSH de pe pvemini + +### Cum ajung secundarele să se oprească + +NUT nu e instalat pe pve1 și pveelite. Nu sunt clienți `netclient` ai lui pvemini. +Singurul mecanism care le oprește e scriptul `ups-shutdown-cluster.sh`, care +rulează pe pvemini și face SSH în ele. + +**Consecință:** dacă scriptul nu rulează sau eșuează, secundarele NU se opresc — +merg până se termină bateria și apoi sunt tăiate hard. NUT-ul de pe pvemini își +oprește curat doar propriul nod, prin `SHUTDOWNCMD`. + +> ### ⚠️ De verificat fizic: sunt toate trei nodurile în UPS? +> +> `ups.load` era **8%** la ultima verificare, ceea ce e puțin pentru trei +> servere. Dacă pve1 și pveelite nu sunt alimentate din UPS, ele mor instantaneu +> la pană, SSH-ul din script eșuează, iar toată orchestrarea e decorativă. +> **Niciun script nu poate verifica asta — trebuie urmărite cablurile.** + +### Lanțul de permisiuni (esențial) + +`upsmon` face fork în două procese: unul root și unul neprivilegiat, ca user +`nut`. **`NOTIFYCMD` (deci `upssched`, deci scriptul de shutdown) rulează ca +`nut`**, care are shell `nologin`, home `/var/lib/nut` și **nicio cheie SSH**. + +De aceea `upssched-cmd` invocă scriptul prin `sudo`, iar +`/etc/sudoers.d/nut-shutdown` îi dă userului `nut` dreptul de a-l rula ca root. + +Fără asta, scriptul nu poate face nimic util: `pct`, `qm`, `ha-manager` și +`ssh` eșuează toate. **Exact asta s-a întâmplat între 2025-10-06 și 2026-08-27** — +scriptul trimitea emailuri (singurul lucru pentru care `nut` avea sudo) și atât. +`/var/log/ups-shutdown.log` nu conținea nicio oprire reușită. + +### Testare + +```bash +# rulare în gol a procedurii reale, ca root +ssh root@10.0.20.201 /usr/local/bin/ups-shutdown-test.sh + +# testarea lanțului complet de permisiuni, exact ca la o pană reală +ssh root@10.0.20.201 "sudo -u nut sudo /usr/local/bin/ups-shutdown-cluster.sh --dry-run --force" +``` + +A doua comandă e cea care contează: dacă ea merge, merge și la o pană adevărată. ### Monitorizare - **VM 201 (Windows 11)** - Monitorizare vizuală via WinNUT diff --git a/proxmox/cluster/ups/config/sudoers.d-nut-shutdown b/proxmox/cluster/ups/config/sudoers.d-nut-shutdown new file mode 100644 index 0000000..86ecaf8 --- /dev/null +++ b/proxmox/cluster/ups/config/sudoers.d-nut-shutdown @@ -0,0 +1,25 @@ +# /etc/sudoers.d/nut-shutdown +# +# upssched (NOTIFYCMD din upsmon.conf) rulează ca user `nut`, nu ca root: +# upsmon face fork în două procese, iar cel care execută NOTIFYCMD e cel +# neprivilegiat. Userul `nut` are shell nologin, home /var/lib/nut și NICIO +# cheie SSH. +# +# Fără regulile de aici, shutdown-ul orchestrat NU poate face nimic: +# - `pct` / `qm` / `ha-manager` -> permission denied +# - `ssh root@pve1 ...` -> nut nu are chei +# - `shutdown` / `systemctl poweroff` -> permission denied +# Singurul lucru care funcționa era trimiterea emailurilor, pentru care exista +# deja regula `nut ALL=(root) NOPASSWD: /usr/bin/perl` din /etc/sudoers.d/nut. +# De asta /var/log/ups-shutdown.log nu conținea nicio oprire reușită. +# +# Instalare: +# cp sudoers.d-nut-shutdown /etc/sudoers.d/nut-shutdown +# chmod 440 /etc/sudoers.d/nut-shutdown +# visudo -c # verificare sintaxă, obligatorie +# +# Un path fără argumente în sudoers permite orice argumente pentru acel +# executabil — deci `--dry-run` și `--force` funcționează cu aceeași regulă. + +nut ALL=(root) NOPASSWD: /usr/local/bin/ups-shutdown-cluster.sh +nut ALL=(root) NOPASSWD: /usr/local/bin/ups-shutdown-test.sh diff --git a/proxmox/cluster/ups/scripts/ups-shutdown-cluster.sh b/proxmox/cluster/ups/scripts/ups-shutdown-cluster.sh old mode 100644 new mode 100755 index 5b857a0..e7547f5 --- a/proxmox/cluster/ups/scripts/ups-shutdown-cluster.sh +++ b/proxmox/cluster/ups/scripts/ups-shutdown-cluster.sh @@ -1,230 +1,443 @@ #!/bin/bash # -# Script de shutdown orchestrat pentru cluster Proxmox cand UPS este pe baterie critica -# Trimite notificari email via PVE::Notify pentru fiecare pas +# ups-shutdown-cluster.sh — oprire orchestrată a clusterului Proxmox `romfast` +# când UPS-ul e pe baterie. # -# Creat: 2025-10-06 -# Actualizat: 2026-01-14 - Fix permisiuni log file, adaugat sudo pentru PVE::Notify +# Rulează PE pvemini (nodul cu UPS-ul pe USB), declanșat de upssched: +# ONBATT + 3 min → shutdown orchestrat +# LOWBATT → shutdown imediat +# +# TREBUIE SĂ RULEZE CA ROOT. upssched rulează ca user `nut`, deci upssched-cmd +# îl invocă prin `sudo` (vezi /etc/sudoers.d/nut-shutdown). Fără asta, scriptul +# nu poate face nici pct/qm/ha-manager, nici ssh (nut nu are chei), nici +# shutdown — exact motivul pentru care versiunea veche nu a funcționat +# niciodată, deși trimitea emailuri. +# +# Utilizare manuală: +# ups-shutdown-cluster.sh --dry-run # arată ce ar face, nu atinge nimic +# ups-shutdown-cluster.sh --force # rulează chiar dacă UPS-ul e pe rețea +# +# Rescris: 2026-08-27 +# + +# NU folosim `set -e`: într-o oprire de urgență, un ssh eșuat către un nod deja +# mort nu trebuie să oprească restul procedurii. +set -uo pipefail + +# ---------------------------------------------------------------- configurare LOGFILE=/var/log/ups-shutdown.log -NODES=("10.0.20.200" "10.0.20.202") # pve1, pveelite (pvemini va fi ultimul) -NODE_NAMES=("pve1" "pveelite") # Nume pentru notificari +LOCKFILE=/run/ups-shutdown.lock + +SECONDARY_IPS=(10.0.20.200 10.0.20.202) +SECONDARY_NAMES=(pve1 pveelite) + +ORACLE_CT=108 +ORACLE_DOCKER=oracle-xe + UPS_NAME="nutdev1" UPS_USER="admin" -UPS_PASS="parola99" +UPS_PASS="parola99" # suprascris de /etc/nut/ups-shutdown.conf, dacă există + +# Bugete de timp — sunt secunde de baterie, nu le mări fără să știi autonomia. +GUEST_STOP_WAIT=150 # cât așteptăm guest-urile obișnuite +ORACLE_SQL_WAIT=60 # cât așteptăm `shutdown immediate` +ORACLE_CT_WAIT=60 # cât așteptăm oprirea CT 108 după aceea +NODE_DOWN_WAIT=90 # cât așteptăm nodurile secundare +SSH_TIMEOUT=5 +NOTIFY_TIMEOUT=20 # emailurile nu au voie să consume bateria + TEMPLATE_DIR="/etc/pve/notification-templates/default" HOSTNAME=$(hostname) FQDN=$(hostname -f 2>/dev/null || hostname) -# Asigura ca fisierul de log exista si are permisiunile corecte -# Scriptul poate rula ca user 'nut', deci fisierul trebuie sa fie writable -ensure_logfile() { - if [ ! -f "$LOGFILE" ]; then - touch "$LOGFILE" 2>/dev/null || sudo touch "$LOGFILE" - fi - if [ ! -w "$LOGFILE" ]; then - sudo chown nut:nut "$LOGFILE" 2>/dev/null - sudo chmod 664 "$LOGFILE" 2>/dev/null - fi -} +# Credențiale în afara scriptului, dacă fișierul există. +[[ -r /etc/nut/ups-shutdown.conf ]] && source /etc/nut/ups-shutdown.conf -ensure_logfile +DRY_RUN=0 +FORCE=0 +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --force) FORCE=1 ;; + -h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + esac +done -log_message() { - echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a $LOGFILE 2>/dev/null +# ------------------------------------------------------------------- utilitare + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOGFILE" 2>/dev/null logger -t ups-shutdown "$1" } -# Obtine status UPS -get_ups_info() { - echo "Status: $(upsc $UPS_NAME ups.status 2>/dev/null || echo 'UNKNOWN')" - echo "Battery: $(upsc $UPS_NAME battery.charge 2>/dev/null || echo '?')%" - echo "Input: $(upsc $UPS_NAME input.voltage 2>/dev/null || echo '?')V" +do_run() { # execută, sau doar loghează dacă --dry-run + if (( DRY_RUN )); then + log " [dry-run] $*" + return 0 + fi + "$@" } -# Trimite notificare email via PVE::Notify +ssh_node() { + local ip=$1; shift + ssh -o BatchMode=yes -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no \ + -o LogLevel=ERROR "root@$ip" "$@" +} + +node_alive() { ping -c 1 -W 2 "$1" >/dev/null 2>&1; } + +get_ups_info() { + echo "Status: $(upsc $UPS_NAME ups.status 2>/dev/null || echo UNKNOWN)" \ + "| Baterie: $(upsc $UPS_NAME battery.charge 2>/dev/null || echo '?')%" \ + "| Input: $(upsc $UPS_NAME input.voltage 2>/dev/null || echo '?')V" +} + +# Notificare email via PVE::Notify. Mărginită în timp — nu consumăm baterie pe mail. send_notification() { - local EVENT_TYPE="$1" - local EVENT_TITLE="$2" - local EVENT_DESC="$3" - local SEVERITY="$4" + local EVENT_TYPE="$1" EVENT_TITLE="$2" EVENT_DESC="$3" SEVERITY="$4" - local UPS_STATUS=$(upsc $UPS_NAME ups.status 2>/dev/null || echo "UNKNOWN") - local BATTERY_CHARGE=$(upsc $UPS_NAME battery.charge 2>/dev/null || echo "0") - local INPUT_VOLTAGE=$(upsc $UPS_NAME input.voltage 2>/dev/null || echo "0") - local EVENT_DATE=$(date '+%Y-%m-%d %H:%M:%S') + (( DRY_RUN )) && { log " [dry-run] email: $EVENT_TITLE"; return 0; } - log_message "Sending notification: $EVENT_TITLE" + local UPS_STATUS BATTERY_CHARGE INPUT_VOLTAGE EVENT_DATE + UPS_STATUS=$(upsc $UPS_NAME ups.status 2>/dev/null || echo UNKNOWN) + BATTERY_CHARGE=$(upsc $UPS_NAME battery.charge 2>/dev/null || echo 0) + INPUT_VOLTAGE=$(upsc $UPS_NAME input.voltage 2>/dev/null || echo 0) + EVENT_DATE=$(date '+%Y-%m-%d %H:%M:%S') - /usr/bin/sudo /usr/bin/perl -I/usr/share/perl5 << EOFPERL 2>&1 | tee -a $LOGFILE + log "Trimit notificare: $EVENT_TITLE" + + timeout $NOTIFY_TIMEOUT /usr/bin/perl -I/usr/share/perl5 <>"$LOGFILE" 2>&1 use strict; use warnings; use PVE::Notify; my \$template_data = { - 'hostname' => '$FQDN', - 'event_date' => '$EVENT_DATE', - 'event_type' => '$EVENT_TYPE', - 'event_title' => '$EVENT_TITLE', + 'hostname' => '$FQDN', + 'event_date' => '$EVENT_DATE', + 'event_type' => '$EVENT_TYPE', + 'event_title' => '$EVENT_TITLE', 'event_description' => '$EVENT_DESC', - 'event_class' => 'shutdown', - 'alert_type' => 'danger', - 'ups_status' => '$UPS_STATUS', - 'battery_charge' => '$BATTERY_CHARGE', - 'input_voltage' => '$INPUT_VOLTAGE', - 'action_taken' => 'Shutdown in curs', - 'next_steps' => '' + 'event_class' => 'shutdown', + 'alert_type' => 'danger', + 'ups_status' => '$UPS_STATUS', + 'battery_charge' => '$BATTERY_CHARGE', + 'input_voltage' => '$INPUT_VOLTAGE', + 'action_taken' => 'Shutdown in curs', + 'next_steps' => '' }; +my \$fields = { 'hostname' => '$HOSTNAME', 'type' => 'ups-power-event' }; -my \$fields = { - 'hostname' => '$HOSTNAME', - 'type' => 'ups-power-event' -}; - -eval { - PVE::Notify::notify('$SEVERITY', 'ups-power-event', \$template_data, \$fields); - print "Notification sent\\n"; -}; -if (\$@) { - print STDERR "Notification failed: \$@\\n"; -} +eval { PVE::Notify::notify('$SEVERITY', 'ups-power-event', \$template_data, \$fields); print "Notification sent\\n"; }; +if (\$@) { print STDERR "Notification failed: \$@\\n"; } EOFPERL + return 0 } -log_message "========================================" -log_message "UPS SHUTDOWN ORCHESTRATION STARTED" -log_message "$(get_ups_info)" -log_message "========================================" +# ------------------------------------------------------------------- preflight -# Verifica daca UPS este intr-adevar pe baterie critica -UPS_STATUS=$(upsc $UPS_NAME ups.status 2>/dev/null) -if [[ ! $UPS_STATUS =~ (OB|LB) ]]; then - log_message "WARNING: UPS status is $UPS_STATUS - not critical. Aborting shutdown." +# Log-ul trebuie să existe și să fie scriabil. +[[ -f "$LOGFILE" ]] || touch "$LOGFILE" 2>/dev/null +chmod 664 "$LOGFILE" 2>/dev/null + +if [[ "$(id -u)" != "0" ]]; then + log "FATAL: scriptul rulează ca $(id -un), nu ca root." + log " upssched rulează ca 'nut'; upssched-cmd trebuie să-l invoce prin sudo." + log " Vezi /etc/sudoers.d/nut-shutdown." + exit 1 +fi + +# O singură instanță: ONBATT și LOWBATT pot declanșa amândouă scriptul. +exec 9>"$LOCKFILE" +if ! flock -n 9; then + log "O instanță rulează deja (lock $LOCKFILE). Ies." exit 0 fi -# Email: START SHUTDOWN -send_notification \ - "SHUTDOWN_START" \ - "Shutdown cluster PORNIT" \ - "UPS pe baterie critica. Se initiaza oprirea ordonata a cluster-ului Proxmox." \ - "error" +log "========================================" +log "UPS SHUTDOWN ORCHESTRAT — START$( ((DRY_RUN)) && echo ' (DRY-RUN)' )" +log "$(get_ups_info)" +log "========================================" -log_message "Step 1: Oprire VM-uri pe toate nodurile..." - -# Opreste VM-uri pe toate nodurile (inclusiv local) -for node in ${NODES[@]} localhost; do - if [ "$node" == "localhost" ]; then - NODE_NAME="pvemini (local)" +UPS_STATUS=$(upsc $UPS_NAME ups.status 2>/dev/null) +if [[ ! $UPS_STATUS =~ (OB|LB) ]]; then + if (( FORCE || DRY_RUN )); then + log "UPS status '$UPS_STATUS' nu e critic, dar continui (--force/--dry-run)." else - NODE_NAME=$node + log "UPS status '$UPS_STATUS' nu e critic. Abandonez." + exit 0 fi - - log_message " - Oprire VM-uri pe $NODE_NAME..." - - if [ "$node" == "localhost" ]; then - for vmid in $(qm list | awk 'NR>1 {print $1}'); do - vm_status=$(qm status $vmid | awk '{print $2}') - if [ "$vm_status" == "running" ]; then - log_message " * Oprire VM $vmid pe pvemini..." - qm shutdown $vmid --timeout 60 & - fi - done - else - ssh -o ConnectTimeout=5 root@$node " - for vmid in \$(qm list | awk 'NR>1 {print \$1}'); do - vm_status=\$(qm status \$vmid | awk '{print \$2}') - if [ \"\$vm_status\" == \"running\" ]; then - echo ' * Oprire VM '\$vmid' pe $node...' - qm shutdown \$vmid --timeout 60 & - fi - done - " 2>&1 | tee -a $LOGFILE - fi -done - -log_message "Step 2: Oprire containere LXC pe toate nodurile..." - -# Opreste containere LXC pe toate nodurile -for node in ${NODES[@]} localhost; do - if [ "$node" == "localhost" ]; then - NODE_NAME="pvemini (local)" - else - NODE_NAME=$node - fi - - log_message " - Oprire LXC pe $NODE_NAME..." - - if [ "$node" == "localhost" ]; then - pct list 2>/dev/null | awk 'NR>1 && $2=="running" {print $1}' | while read ctid; do - log_message " * Oprire container $ctid pe pvemini..." - pct shutdown $ctid --timeout 60 & - done - else - ssh -o ConnectTimeout=5 root@$node " - pct list 2>/dev/null | awk 'NR>1 && \$2==\"running\" {print \$1}' | while read ctid; do - echo ' * Oprire container '\$ctid' pe $node...' - pct shutdown \$ctid --timeout 60 & - done - " 2>&1 | tee -a $LOGFILE - fi -done - -log_message "Step 3: Asteptare 90 secunde pentru oprirea VM-urilor si LXC..." -sleep 90 - -log_message "Step 4: Oprire noduri secundare..." - -# Opreste nodurile secundare si trimite notificare pentru fiecare -for i in "${!NODES[@]}"; do - node="${NODES[$i]}" - node_name="${NODE_NAMES[$i]}" - - log_message " - Shutdown nod $node_name ($node)..." - - # Email: SHUTDOWN NOD SECUNDAR - send_notification \ - "SHUTDOWN_NODE" \ - "Shutdown $node_name trimis" \ - "Comanda shutdown a fost trimisa catre nodul $node_name ($node)." \ - "error" - - ssh -o ConnectTimeout=5 root@$node "shutdown -h +1 'UPS battery critical - shutting down'" 2>&1 | tee -a $LOGFILE & -done - -log_message "Step 5: Asteptare 60 secunde pentru shutdown noduri secundare..." -sleep 60 - -log_message "Step 6: Oprire nod local (pvemini - primary)..." - -# Email: SHUTDOWN NOD PRIMARY (ultimul email inainte de shutdown) -send_notification \ - "SHUTDOWN_PRIMARY" \ - "Shutdown pvemini (ULTIMUL NOD)" \ - "Se opreste nodul primary pvemini. Acesta este ultimul nod din cluster. UPS-ul se va opri dupa shutdown." \ - "error" - -log_message "Step 7: Oprire UPS dupa shutdown..." - -# Comanda UPS sa se opreasca dupa un delay (permite shutdown-ul sa se finalizeze) -# Verifica daca comanda este disponibila -if upscmd -l $UPS_NAME 2>/dev/null | grep -q "shutdown.stayoff"; then - log_message " - Comanda UPS shutdown.stayoff (oprire completa)..." - upscmd -u $UPS_USER -p $UPS_PASS $UPS_NAME shutdown.stayoff 2>&1 | tee -a $LOGFILE -elif upscmd -l $UPS_NAME 2>/dev/null | grep -q "shutdown.return"; then - log_message " - Comanda UPS shutdown.return (oprire cu restart la revenire curent)..." - upscmd -u $UPS_USER -p $UPS_PASS $UPS_NAME shutdown.return 2>&1 | tee -a $LOGFILE -else - log_message " - WARNING: Nu s-a gasit comanda UPS shutdown disponibila" fi -log_message "========================================" -log_message "UPS SHUTDOWN ORCHESTRATION COMPLETED" -log_message "$(get_ups_info)" -log_message "Local node will shutdown in 1 minute" -log_message "========================================" +# ---------------------------------------------- pas 1: quorum + freeze -# Opreste nodul local (ultimul) -shutdown -h +1 "UPS battery critical - primary node shutting down" +HAVE_QUORUM=0 +if pvecm status 2>/dev/null | grep -q 'Quorate: *Yes'; then + HAVE_QUORUM=1 + log "Pas 1: quorum OK." +else + log "Pas 1: ATENȚIE — clusterul NU are quorum." + log " Nodurile secundare sunt probabil deja moarte (nu sunt pe UPS?)." + log " /etc/pve e read-only: nu pot seta freeze și nu pot folosi ha-manager." + log " Trec pe oprire directă a guest-urilor, local." +fi +# `freeze` împiedică HA să relocheze resursele când nodurile se opresc. Fără el, +# politica implicită `conditional` transformă fiecare poweroff într-un failover +# către un nod care oricum se stinge în secunda următoare — exact incidentul +# 2026-01-11 (VM 201 migrat în timpul unei pene de curent). +if (( HAVE_QUORUM )); then + CURRENT_POLICY=$(grep -oP 'shutdown_policy=\K\w+' /etc/pve/datacenter.cfg 2>/dev/null) + if [[ "$CURRENT_POLICY" == "freeze" ]]; then + log " shutdown_policy deja pe freeze." + else + log " setez shutdown_policy=freeze (era: ${CURRENT_POLICY:-nesetat})" + if (( ! DRY_RUN )); then + if [[ -f /etc/pve/datacenter.cfg ]]; then + sed -i '/^ha:/d' /etc/pve/datacenter.cfg + printf 'ha: shutdown_policy=freeze\n' >> /etc/pve/datacenter.cfg + else + printf 'ha: shutdown_policy=freeze\n' > /etc/pve/datacenter.cfg + fi + fi + fi +fi + +send_notification "SHUTDOWN_START" "Shutdown cluster PORNIT" \ + "UPS pe baterie. Se inițiază oprirea ordonată a clusterului Proxmox." "error" + +# ------------------------------------------------- pas 2: inventar guest-uri + +log "Pas 2: inventar guest-uri..." + +declare -A HA_SID=() +if (( HAVE_QUORUM )); then + while read -r sid; do + [[ -n "$sid" ]] && HA_SID["$sid"]=1 + done < <(ha-manager config 2>/dev/null | grep -oE '^(ct|vm):[0-9]+') +fi + +# Linii: " " +GUESTS=() +collect_from() { + local ip=$1 name=$2 line id st + if [[ "$ip" == "local" ]]; then + while read -r id st; do [[ "$st" == running ]] && GUESTS+=("ct local $name $id"); done \ + < <(pct list 2>/dev/null | awk 'NR>1 {print $1, $2}') + while read -r id st; do [[ "$st" == running ]] && GUESTS+=("vm local $name $id"); done \ + < <(qm list 2>/dev/null | awk 'NR>1 {print $1, $3}') + else + node_alive "$ip" || { log " $name ($ip) nu răspunde — îl sar (probabil deja oprit)."; return; } + while read -r id st; do [[ "$st" == running ]] && GUESTS+=("ct $ip $name $id"); done \ + < <(ssh_node "$ip" "pct list 2>/dev/null | awk 'NR>1 {print \$1, \$2}'") + while read -r id st; do [[ "$st" == running ]] && GUESTS+=("vm $ip $name $id"); done \ + < <(ssh_node "$ip" "qm list 2>/dev/null | awk 'NR>1 {print \$1, \$3}'") + fi +} + +collect_from local "$HOSTNAME" +for i in "${!SECONDARY_IPS[@]}"; do + collect_from "${SECONDARY_IPS[$i]}" "${SECONDARY_NAMES[$i]}" +done + +log " ${#GUESTS[@]} guest-uri pornite: $(printf '%s ' "${GUESTS[@]##* }")" + +# ------------------------------------------------- funcții de oprire guest + +guest_is_stopped() { + local type=$1 ip=$2 id=$3 cmd out + [[ "$type" == ct ]] && cmd=pct || cmd=qm + if [[ "$ip" == "local" ]]; then + out=$($cmd status "$id" 2>/dev/null) + else + out=$(ssh_node "$ip" "$cmd status $id 2>/dev/null") + fi + [[ "$out" == *stopped* ]] +} + +# Trimite comanda de oprire, fără să aștepte. +issue_stop() { + local type=$1 ip=$2 id=$3 sid="$1:$3" cmd + [[ "$type" == ct ]] && cmd=pct || cmd=qm + + # Pentru resursele HA folosim ha-manager: din CLI, pct/qm shutdown NU + # actualizează state-ul HA, iar CRM-ul poate reporni guest-ul în mijlocul + # opririi. Fără quorum ha-manager nu merge, deci cădem pe oprire directă. + if (( HAVE_QUORUM )) && [[ -n "${HA_SID[$sid]:-}" ]]; then + do_run ha-manager set "$sid" --state stopped >/dev/null 2>&1 & + elif [[ "$ip" == "local" ]]; then + do_run $cmd shutdown "$id" --timeout $GUEST_STOP_WAIT >/dev/null 2>&1 & + else + do_run ssh_node "$ip" "$cmd shutdown $id --timeout $GUEST_STOP_WAIT" >/dev/null 2>&1 & + fi +} + +force_stop() { + local type=$1 ip=$2 id=$3 cmd + [[ "$type" == ct ]] && cmd=pct || cmd=qm + log " FORȚEZ oprirea $type:$id" + if [[ "$ip" == "local" ]]; then + do_run $cmd stop "$id" >/dev/null 2>&1 + else + do_run ssh_node "$ip" "$cmd stop $id" >/dev/null 2>&1 + fi +} + +# Așteaptă ca o listă de guest-uri să se oprească; forțează ce rămâne. +wait_for_stopped() { + local -n _list=$1 + local budget=$2 waited=0 remaining + + (( DRY_RUN )) && { log " [dry-run] aș aștepta oprirea a ${#_list[@]} guest-uri"; return 0; } + + while (( waited < budget )); do + remaining=() + for g in "${_list[@]}"; do + read -r type ip name id <<<"$g" + guest_is_stopped "$type" "$ip" "$id" || remaining+=("$g") + done + (( ${#remaining[@]} == 0 )) && { log " toate oprite (${waited}s)"; return 0; } + sleep 5; waited=$(( waited + 5 )) + done + + log " ${#remaining[@]} guest-uri nu s-au oprit în ${budget}s" + for g in "${remaining[@]}"; do + read -r type ip name id <<<"$g" + force_stop "$type" "$ip" "$id" + done + return 0 +} + +# ------------------------------- pas 3: oprire consumatori (tot, mai puțin Oracle) + +log "Pas 3: opresc consumatorii (Oracle rămâne ultimul)..." + +CONSUMERS=() +ORACLE_ENTRY="" +for g in "${GUESTS[@]}"; do + read -r type ip name id <<<"$g" + if [[ "$type" == ct && "$id" == "$ORACLE_CT" ]]; then + ORACLE_ENTRY="$g" + else + CONSUMERS+=("$g") + fi +done + +# În paralel: nu depind unul de altul, doar de Oracle. Bateria nu așteaptă. +for g in "${CONSUMERS[@]}"; do + read -r type ip name id <<<"$g" + log " opresc $type:$id pe $name" + issue_stop "$type" "$ip" "$id" +done +wait + +if (( ${#CONSUMERS[@]} > 0 )); then + wait_for_stopped CONSUMERS $GUEST_STOP_WAIT +fi + +# ----------------------------------------------- pas 4: Oracle, curat, la final + +if [[ -z "$ORACLE_ENTRY" ]]; then + log "Pas 4: CT $ORACLE_CT nu rulează — sar peste." +else + read -r otype oip oname oid <<<"$ORACLE_ENTRY" + log "Pas 4: opresc instanța Oracle din CT $oid ($oname)..." + + if (( DRY_RUN )); then + log " [dry-run] shutdown immediate în $ORACLE_DOCKER" + else + SQL="pct exec $oid -- docker exec $ORACLE_DOCKER bash -c \"printf 'shutdown immediate\nexit\n' | sqlplus -s / as sysdba\"" + if [[ "$oip" == "local" ]]; then + timeout $ORACLE_SQL_WAIT bash -c "$SQL" >>"$LOGFILE" 2>&1 + else + timeout $ORACLE_SQL_WAIT ssh_node "$oip" "$SQL" >>"$LOGFILE" 2>&1 + fi + if (( $? == 0 )); then + log " instanța Oracle oprită curat" + else + log " ATENȚIE: shutdown immediate a eșuat sau a depășit ${ORACLE_SQL_WAIT}s" + log " Baza va porni cu instance recovery. Continui — bateria nu așteaptă." + fi + fi + + log " opresc containerul CT $oid" + issue_stop "$otype" "$oip" "$oid" + wait + ORACLE_LIST=("$ORACLE_ENTRY") + wait_for_stopped ORACLE_LIST $ORACLE_CT_WAIT +fi + +# ------------------------------------------------------- pas 5: verificare + +if (( HAVE_QUORUM && ! DRY_RUN )); then + ACTIVE=$(ha-manager status 2>/dev/null | grep -cE 'started|migrate|relocate|fence') + if (( ACTIVE > 0 )); then + log "Pas 5: ATENȚIE — $ACTIVE servicii HA încă active. Continui oricum (baterie)." + ha-manager status 2>/dev/null | grep -E 'started|migrate|relocate|fence' >>"$LOGFILE" + else + log "Pas 5: toate serviciile HA sunt oprite. Watchdog-ul nu mai e armat." + fi +fi + +# --------------------------------------------- pas 6: oprire noduri secundare + +log "Pas 6: opresc nodurile secundare..." + +DOWN_TARGETS=() +for i in "${!SECONDARY_IPS[@]}"; do + ip="${SECONDARY_IPS[$i]}"; name="${SECONDARY_NAMES[$i]}" + if ! node_alive "$ip"; then + log " $name ($ip) nu răspunde — deja oprit." + continue + fi + log " trimit poweroff către $name ($ip)" + send_notification "SHUTDOWN_NODE" "Shutdown $name trimis" \ + "Comanda de oprire a fost trimisă către nodul $name ($ip)." "error" + do_run ssh_node "$ip" "systemctl poweroff --no-block" >>"$LOGFILE" 2>&1 + DOWN_TARGETS+=("$ip:$name") +done + +if (( ${#DOWN_TARGETS[@]} > 0 )) && (( ! DRY_RUN )); then + log " aștept oprirea lor (max ${NODE_DOWN_WAIT}s)..." + waited=0 + while (( waited < NODE_DOWN_WAIT )); do + still=() + for t in "${DOWN_TARGETS[@]}"; do + node_alive "${t%%:*}" && still+=("${t##*:}") + done + (( ${#still[@]} == 0 )) && { log " nodurile secundare sunt jos (${waited}s)"; break; } + sleep 10; waited=$(( waited + 10 )) + done + (( ${#still[@]} > 0 )) && log " ${still[*]} încă răspund după ${NODE_DOWN_WAIT}s — continui oricum." +fi + +# ------------------------------------------------ pas 7: oprire UPS + local + +send_notification "SHUTDOWN_PRIMARY" "Shutdown $HOSTNAME (ULTIMUL NOD)" \ + "Se oprește nodul primary $HOSTNAME. UPS-ul se va opri după." "error" + +log "Pas 7: comand oprirea UPS-ului..." +UPS_CMDS=$(upscmd -l $UPS_NAME 2>/dev/null) +if grep -q 'shutdown.stayoff' <<<"$UPS_CMDS"; then + log " shutdown.stayoff (oprire completă)" + do_run upscmd -u "$UPS_USER" -p "$UPS_PASS" $UPS_NAME shutdown.stayoff >>"$LOGFILE" 2>&1 +elif grep -q 'shutdown.return' <<<"$UPS_CMDS"; then + log " shutdown.return (repornire la revenirea curentului)" + do_run upscmd -u "$UPS_USER" -p "$UPS_PASS" $UPS_NAME shutdown.return >>"$LOGFILE" 2>&1 +else + log " ATENȚIE: nicio comandă de oprire disponibilă pe UPS" +fi + +log "========================================" +log "UPS SHUTDOWN ORCHESTRAT — TERMINAT" +log "$(get_ups_info)" +log "========================================" + +if (( DRY_RUN )); then + log "DRY-RUN: nodul local NU se oprește." + exit 0 +fi + +log "Opresc nodul local ($HOSTNAME) — ultimul." +systemctl poweroff --no-block exit 0 diff --git a/proxmox/cluster/ups/scripts/ups-shutdown-test.sh b/proxmox/cluster/ups/scripts/ups-shutdown-test.sh old mode 100644 new mode 100755 index d5b964f..aeed411 --- a/proxmox/cluster/ups/scripts/ups-shutdown-test.sh +++ b/proxmox/cluster/ups/scripts/ups-shutdown-test.sh @@ -1,63 +1,33 @@ #!/bin/bash # -# Script de TEST pentru shutdown orchestrat - NU oprește nimic +# ups-shutdown-test.sh — rulează procedura reală de shutdown UPS în gol. # +# Nu duplică logica: invocă exact scriptul care rulează la o pană de curent, +# cu --dry-run. Asta e tot rostul lui — dacă testul ar avea cod propriu, ar +# testa altceva decât ce se execută în realitate. +# +# Utilizare: +# ups-shutdown-test.sh # ca root +# sudo -u nut sudo /usr/local/bin/ups-shutdown-test.sh # verifică și lanțul de permisiuni +# +set -uo pipefail -LOGFILE=/var/log/ups-shutdown-test.log -NODES=(10.0.20.200 10.0.20.202) +SCRIPT=/usr/local/bin/ups-shutdown-cluster.sh -log_message() { - echo "[2025-10-06 20:03:03] $1" | tee -a $LOGFILE -} +if [[ ! -x "$SCRIPT" ]]; then + echo "EROARE: $SCRIPT lipsește sau nu e executabil." >&2 + exit 1 +fi -log_message "========================================" -log_message "UPS SHUTDOWN TEST STARTED (DRY RUN)" -log_message "UPS Status: $(upsc nutdev1 ups.status 2>/dev/null || echo 'UNKNOWN')" -log_message "Battery Charge: $(upsc nutdev1 battery.charge 2>/dev/null || echo 'UNKNOWN')%" -log_message "Input Voltage: $(upsc nutdev1 input.voltage 2>/dev/null || echo 'UNKNOWN')V" -log_message "Output Voltage: $(upsc nutdev1 output.voltage 2>/dev/null || echo 'UNKNOWN')V" -log_message "========================================" +if [[ "$(id -u)" != "0" ]]; then + echo "EROARE: rulează ca $(id -un), nu ca root." >&2 + echo " La o pană reală, upssched rulează ca 'nut' și invocă scriptul prin sudo." >&2 + echo " Testează lanțul complet cu: sudo -u nut sudo $SCRIPT --dry-run --force" >&2 + exit 1 +fi -log_message "TEST: Ar opri VM-urile de pe toate nodurile..." +echo "=== Test în gol al procedurii de shutdown UPS ===" +echo "Nu se oprește nimic. --force ocolește verificarea că UPS-ul e pe baterie." +echo -for node in ${NODES[@]} localhost; do - if [ "$node" == "localhost" ]; then - NODE_NAME="pvemini (local)" - else - NODE_NAME=$node - fi - - log_message " - VM-uri pe $NODE_NAME:" - - if [ "$node" == "localhost" ]; then - for vmid in $(qm list | awk 'NR>1 {print $1}'); do - vm_name=$(qm config $vmid | grep '^name:' | cut -d' ' -f2) - vm_status=$(qm status $vmid | awk '{print $2}') - log_message " * VM $vmid ($vm_name): $vm_status" - done - else - ssh -o ConnectTimeout=5 root@$node " - for vmid in \$(qm list | awk 'NR>1 {print \$1}'); do - vm_name=\$(qm config \$vmid | grep '^name:' | cut -d' ' -f2) - vm_status=\$(qm status \$vmid | awk '{print \$2}') - echo ' * VM '\$vmid' ('\$vm_name'): '\$vm_status - done - " 2>&1 | tee -a $LOGFILE - fi -done - -log_message "" -log_message "TEST: Ordinea de shutdown ar fi:" -log_message " 1. Toate VM-urile de pe toate nodurile (paralel)" -log_message " 2. Așteptare 90 secunde" -log_message " 3. Shutdown pve1 (10.0.20.200)" -log_message " 4. Shutdown pve2 (10.0.20.202)" -log_message " 5. Așteptare 30 secunde" -log_message " 6. Shutdown pvemini (10.0.20.201) - PRIMARY/LAST" -log_message "" -log_message "========================================" -log_message "UPS SHUTDOWN TEST COMPLETED (DRY RUN)" -log_message "NICIUN sistem nu a fost oprit - doar test" -log_message "========================================" - -exit 0 +exec "$SCRIPT" --dry-run --force "$@"