Files
ROMFASTSQL/proxmox/cluster/scripts/cluster-shutdown.ps1
Marius d7b4007af8 feat(cluster): corosync ring1 pe insula, test de repornire cu WoL, fixuri in scripturi
Ring1 aplicat si verificat pe cluster live: config_version 17, ring1_addr pe
10.10.10.20x la fiecare nod, interface{linknumber:1}. Validat cu `corosync -t`
inainte de instalare. Testat prin oprirea reala a lui ring0 pe pveelite: link0
disconnected, link1 connected, cvorum 3/3 neatins - exact scenariul care pe
27 august a lasat nodul mort 16 ore.

Test de repornire completa a clusterului, cu Wake-on-LAN (trezire in ~15s).
Insula a urcat singura pe toate trei nodurile; ipoteza enumerarii tarzii a
USB-ului nu s-a materializat. Unealta noua: wake-cluster.ps1.

Testul a scos la iveala o linie ramasa in /etc/fstab pe pve1 si pveelite, care
monta storage-ul NFS de pe IP-ul de productie inaintea lui pvestatd. Backup-ul
trecea tacut pe reteaua gresita, cu storage.cfg corect. cluster-startup verifica
acum asta automat, impreuna cu IP-urile de insula si conectivitatea reala.

Patru bug-uri gasite prin rulare pe cluster live:

- sonda Oracle nu avea timeout: un sqlplus agatat pe o instanta in pornire a
  blocat cluster-startup 14 minute, fara mesaj, cu propriul prag de 600s
  nefolosit, fiindca bucla n-a apucat o iteratie;
- `bash -c` in loc de `bash -lc`: sqlplus lipsea din PATH, deci baza nu se
  oprea si containerul s-ar fi inchis peste ea;
- PowerShell 5.1 pierde ghilimelele duble catre exe-uri native, deci comanda
  ajungea rupta pe nod - trecut pe trimitere codificata base64;
- backup-ul de crontab si fisierul de stare se rescriau la o a doua rulare,
  lasand monitorizarea oprita permanent si lista de repornit goala.

Documentatia de oprire planificata pornea de la o afirmatie devenita falsa
("nu exista datacenter.cfg") si de la o comanda care ar fi adaugat o a doua
linie `ha:`. Actualizata, impreuna cu inventarul de guest-uri (VM 304 lipsea
din toate listele de ordine si cadea in maturarea de dupa Oracle).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJFQZcA7uerXRaL1NrsXpS
2026-08-29 21:13:34 +03:00

466 lines
18 KiB
PowerShell

<#
.SYNOPSIS
Oprire controlata a intregului cluster Proxmox `romfast`.
.DESCRIPTION
Opreste guest-urile in ordinea corecta de dependente, apoi nodurile, fara ca
HA sa relocheze resursele si fara ca watchdog-ul sa reseteze hard vreun nod.
Procedura completa si explicatiile: docs/oprire-planificata-cluster.md
RULEAZA DE PE STATIA DE ADMIN, nu de pe un nod Proxmox. Motivul: nodul care
se opreste ultimul nu poate raporta rezultatul propriei opriri.
Necesita clientul OpenSSH din Windows (C:\Windows\System32\OpenSSH\ssh.exe)
si o cheie SSH acceptata de root pe toate cele trei noduri.
.PARAMETER Yes
Nu cere confirmari.
.PARAMETER DryRun
Arata ce ar face, fara sa execute nimic care modifica starea.
.PARAMETER NoNodes
Opreste doar guest-urile, lasa nodurile pornite.
.EXAMPLE
.\cluster-shutdown.ps1 -DryRun
.EXAMPLE
.\cluster-shutdown.ps1
#>
[CmdletBinding()]
param(
[switch]$Yes,
[switch]$DryRun,
[switch]$NoNodes
)
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------- configurare
$NodeIp = [ordered]@{
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 si serverul NFS.
$NodeShutdownOrder = @('pveelite', 'pve1', 'pvemini')
# Ordinea de OPRIRE a guest-urilor: consumatorii intai, baza de date ULTIMA.
# VM 201 (roacentral) si CT 104 (flowise) folosesc Oracle din CT 108.
# Orice guest pornit care nu apare aici e oprit la final, intr-o maturare.
$GuestShutdownOrder = @(
303, # Win11-Adina - desktop, fara dependente
304, # Win11-Marius - desktop, fara dependente
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
109, # oracle-dr-windows
301, # docker-portainer-template
108 # central-oracle - ULTIMUL
)
$OracleCt = 108
$OracleDocker = 'oracle-xe'
$GuestTimeout = 300 # secunde asteptate pentru shutdown-ul unui guest
$NodeTimeout = 600 # secunde asteptate pana un nod nu mai raspunde la ping
$CronBackup = '/root/crontab.backup-mentenanta.txt'
# Cron-uri de dezactivat cat timp clusterul e jos (altfel mail storm, iar
# vm109-watchdog.sh chiar porneste VM 109).
$CronPattern = 'oom-alert|pvemini-down-alert|pveelite-down-alert|vm109-watchdog'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$StateFile = if ($env:CLUSTER_STATE_FILE) { $env:CLUSTER_STATE_FILE } else { Join-Path $ScriptDir '.cluster-state.txt' }
# ------------------------------------------------------------------- utilitare
function Write-Step { param([string]$Text) Write-Host ""; Write-Host "== $Text" -ForegroundColor Green }
function Write-Ok { param([string]$Text) Write-Host " [ok] $Text" -ForegroundColor Green }
function Write-Warn { param([string]$Text) Write-Host " [!] $Text" -ForegroundColor Yellow }
function Write-Log { param([string]$Text) Write-Host "[$(Get-Date -Format HH:mm:ss)] $Text" -ForegroundColor DarkGray }
function Stop-WithError { param([string]$Text) Write-Host " [X] $Text" -ForegroundColor Red; exit 1 }
function Test-Alive {
param([string]$Address, [int]$TimeoutMs = 2000)
try {
$p = New-Object System.Net.NetworkInformation.Ping
return ($p.Send($Address, $TimeoutMs).Status -eq 'Success')
} catch {
return $false
}
}
# Executa o comanda pe un nod. Intoarce liniile de output.
# NU redirectam stderr: in PS 5.1 asta transforma fiecare linie intr-un
# ErrorRecord si strica $LASTEXITCODE chiar cand comanda a reusit.
function Invoke-Node {
param([string]$Node, [string]$Command)
$out = ssh -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new -o LogLevel=ERROR "root@$($NodeIp[$Node])" $Command
return $out
}
# Comenzi cu ghilimele imbricate NU se trimit prin Invoke-Node.
#
# PowerShell 5.1 pierde ghilimelele duble cand paseaza un argument catre un
# executabil nativ: `bash -lc "printf '...' | sqlplus"` ajunge pe nod ca
# `bash -lc printf '...' | sqlplus`, adica printf fara format, iar sqlplus rulat
# pe NOD, unde nu exista. Esecul e usor de citit gresit ca "lipseste sqlplus".
# In loc sa ne luptam cu nivelurile de citare, trimitem scriptul codificat.
# Verificat pe 2026-08-29, dupa ce varianta directa a esuat pe cluster live.
function Invoke-NodeScript {
param([string]$Node, [string]$Script)
$lf = $Script -replace "`r`n", "`n"
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($lf))
return Invoke-Node -Node $Node -Command "echo $b64 | base64 -d | bash"
}
# Ca Invoke-Node, dar respecta -DryRun (pentru comenzi care modifica starea).
function Invoke-NodeChange {
param([string]$Node, [string]$Command)
if ($DryRun) {
Write-Host " [dry-run] ${Node}: $Command" -ForegroundColor DarkGray
return @()
}
return Invoke-Node -Node $Node -Command $Command
}
function Confirm-Step {
param([string]$Message)
if ($Yes -or $DryRun) { return }
Write-Host ""
Write-Host $Message -ForegroundColor Yellow
$reply = Read-Host "Scrie DA ca sa continui"
if ($reply -ne 'DA') { Stop-WithError "Anulat de utilizator." }
}
# Parseaza `pct list` / `qm list` in obiecte.
function Get-NodeGuests {
param([string]$Node)
$result = @()
$ctLines = Invoke-Node -Node $Node -Command 'pct list'
foreach ($line in ($ctLines | Select-Object -Skip 1)) {
$f = ($line -split '\s+') | Where-Object { $_ -ne '' }
if ($f.Count -ge 2) {
$result += [pscustomobject]@{ Type='ct'; Node=$Node; Id=[int]$f[0]; Status=$f[1] }
}
}
$vmLines = Invoke-Node -Node $Node -Command 'qm list'
foreach ($line in ($vmLines | Select-Object -Skip 1)) {
$f = ($line -split '\s+') | Where-Object { $_ -ne '' }
if ($f.Count -ge 3) {
$result += [pscustomobject]@{ Type='vm'; Node=$Node; Id=[int]$f[0]; Status=$f[2] }
}
}
return $result
}
function Get-GuestStatus {
param([string]$Node, [string]$Type, [int]$Id)
$cmd = if ($Type -eq 'ct') { "pct status $Id" } else { "qm status $Id" }
$out = Invoke-Node -Node $Node -Command $cmd
if ($out -match 'status:\s*(\w+)') { return $Matches[1] }
return 'unknown'
}
# ------------------------------------------------------------ pas 1: preflight
Write-Step "Preflight"
foreach ($node in $NodeIp.Keys) {
if (-not (Test-Alive $NodeIp[$node])) {
Stop-WithError "$node ($($NodeIp[$node])) nu raspunde la ping."
}
$h = Invoke-Node -Node $node -Command 'hostname'
if ($LASTEXITCODE -ne 0 -or -not $h) { Stop-WithError "$node nu accepta SSH cu cheie." }
Write-Ok "$node ($($NodeIp[$node])) - reachable"
}
$quorum = Invoke-Node -Node 'pvemini' -Command 'pvecm status'
if (($quorum -join "`n") -notmatch 'Quorate:\s*Yes') {
Stop-WithError "Clusterul NU are quorum. Opreste-te si investigheaza."
}
$haStatus = Invoke-Node -Node 'pvemini' -Command 'ha-manager status'
$master = ($haStatus | Where-Object { $_ -match '^master\s+(\S+)' } | ForEach-Object { $Matches[1] })
Write-Ok "quorum OK, HA master: $master"
$runningTasks = 0
foreach ($node in $NodeIp.Keys) {
$t = Invoke-Node -Node $node -Command "pvesh get /nodes/$node/tasks --limit 20 --output-format json"
$runningTasks += ([regex]::Matches(($t -join ''), '"status":"running"')).Count
}
if ($runningTasks -gt 0) {
Write-Warn "$runningTasks task-uri in executie pe cluster (backup? migrare?)."
Confirm-Step "Sunt task-uri active. Continui oricum?"
} else {
Write-Ok "niciun task in executie"
}
# ------------------------------------------- pas 2: inventar + salvare stare
Write-Step "Inventar guest-uri"
$haSids = @()
foreach ($line in (Invoke-Node -Node 'pvemini' -Command 'ha-manager config')) {
if ($line -match '^(ct|vm):(\d+)') { $haSids += "$($Matches[1]):$($Matches[2])" }
}
$inventory = @()
foreach ($node in $NodeIp.Keys) {
foreach ($g in (Get-NodeGuests -Node $node)) {
$sid = "$($g.Type):$($g.Id)"
$g | Add-Member -NotePropertyName Ha -NotePropertyValue $(if ($haSids -contains $sid) { 'ha' } else { 'noha' })
$inventory += $g
}
}
$running = @($inventory | Where-Object { $_.Status -eq 'running' })
if ($running.Count -eq 0) {
Write-Warn "Niciun guest pornit. Trec direct la oprirea nodurilor."
} else {
foreach ($g in $running) {
" {0,-3} {1,-9} {2,-4} {3}" -f $g.Type, $g.Node, $g.Id, $g.Ha | Write-Host
}
Write-Ok "$($running.Count) guest-uri pornite"
}
# Salveaza starea LOCAL - cluster-startup porneste exact ce era pornit, ca sa nu
# reporneasca VM-uri oprite intentionat (302, 310, 301...).
# Format identic cu varianta bash, ca cele doua sa fie interschimbabile.
if (-not $DryRun) {
# Nu rescrie peste o stare existenta cu un inventar gol. Cazul real: scriptul
# e rulat a doua oara (dupa -NoNodes, sau dupa o eroare), cand guest-urile sunt
# deja oprite - inventarul e gol si am pierde exact lista de repornit.
$existing = @()
if (Test-Path $StateFile) {
$existing = @(Get-Content $StateFile | Where-Object { $_ -notmatch '^\s*#' -and $_.Trim() })
}
if ($running.Count -eq 0 -and $existing.Count -gt 0) {
Write-Warn "Niciun guest pornit, dar $StateFile contine deja $($existing.Count) intrari - il PASTREZ."
Write-Warn "Altfel cluster-startup n-ar mai sti ce sa porneasca."
} else {
$lines = @("# stare cluster salvata la $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
foreach ($g in $running) { $lines += "$($g.Type) $($g.Node) $($g.Id) $($g.Status) $($g.Ha)" }
Set-Content -Path $StateFile -Value $lines -Encoding utf8
Write-Ok "stare salvata in $StateFile"
}
}
Confirm-Step "Se opresc $($running.Count) guest-uri, apoi nodurile: $($NodeShutdownOrder -join ', ')."
# -------------------------------------------- pas 3: shutdown_policy=freeze
Write-Step "Shutdown policy -> freeze"
$dcCfg = Invoke-Node -Node 'pvemini' -Command 'cat /etc/pve/datacenter.cfg 2>/dev/null || true'
$currentPolicy = $null
if (($dcCfg -join "`n") -match 'shutdown_policy=(\w+)') { $currentPolicy = $Matches[1] }
if ($currentPolicy -eq 'freeze') {
Write-Ok "deja pe freeze"
} else {
if ($null -ne $currentPolicy -or ($dcCfg | Measure-Object).Count -gt 0) {
# sed sterge DOAR linia 'ha:', nu rescrie fisierul: din 2026-08-29 acolo
# sta si 'migration: network=10.10.10.0/24,type=insecure', fara de care
# replicarea se intoarce tacut pe reteaua de productie.
Invoke-NodeChange -Node 'pvemini' -Command 'cp /etc/pve/datacenter.cfg /root/datacenter.cfg.backup-mentenanta' | Out-Null
Invoke-NodeChange -Node 'pvemini' -Command "sed -i '/^ha:/d' /etc/pve/datacenter.cfg; printf 'ha: shutdown_policy=freeze\n' >> /etc/pve/datacenter.cfg" | Out-Null
} else {
Invoke-NodeChange -Node 'pvemini' -Command "printf 'ha: shutdown_policy=freeze\n' > /etc/pve/datacenter.cfg" | Out-Null
}
$was = if ($currentPolicy) { $currentPolicy } else { 'nesetat' }
Write-Ok "setat freeze (era: $was)"
}
# ---------------------------------------- pas 4: dezactivare cron-uri alerta
Write-Step "Dezactivare cron-uri de alerta"
$sedCron = "crontab -l | sed -E '/^[^#]/ s%^(.*($CronPattern)\.sh.*)`$%#MENTENANTA \1%' | crontab -"
foreach ($node in $NodeIp.Keys) {
# Backup DOAR daca crontab-ul curent nu e deja comentat. Altfel o a doua rulare
# a scriptului (dupa o eroare, de exemplu) ar salva peste backup versiunea deja
# comentata, iar cluster-startup ar "restaura" monitorizarea oprita - tacut.
Invoke-NodeChange -Node $node -Command "if crontab -l 2>/dev/null | grep -q '^#MENTENANTA'; then echo 'backup pastrat'; else crontab -l > $CronBackup 2>/dev/null || true; echo 'backup nou'; fi" | Out-Null
Invoke-NodeChange -Node $node -Command $sedCron | Out-Null
if (-not $DryRun) {
$n = Invoke-Node -Node $node -Command "crontab -l | grep -c '^#MENTENANTA' || true"
Write-Ok "$node - $n linii dezactivate, backup in $CronBackup"
}
}
Write-Warn "Monitorizarea e acum OPRITA pe tot clusterul. cluster-startup o restaureaza."
# --------------------------------------------- pas 5: shutdown curat Oracle
Write-Step "Oracle - shutdown immediate"
$oracleGuest = $running | Where-Object { $_.Type -eq 'ct' -and $_.Id -eq $OracleCt } | Select-Object -First 1
if (-not $oracleGuest) {
Write-Warn "CT $OracleCt nu ruleaza - sar peste shutdown-ul bazei."
} else {
Write-Log "CT $OracleCt pe $($oracleGuest.Node) - opresc instanta..."
if ($DryRun) {
Write-Host " [dry-run] shutdown immediate in $OracleDocker" -ForegroundColor DarkGray
} else {
# 'bash -lc', NU 'bash -c': fara shell de login, PATH-ul Oracle nu e incarcat
# si comanda esueaza cu "sqlplus: command not found" - adica baza ramane
# DESCHISA, iar containerul ar fi oprit peste ea.
# '2>&1' se face pe nod, nu in PowerShell: altfel stderr-ul lui ssh devine
# ErrorRecord in PS 5.1 si arunca exceptie in loc sa fie tratat aici.
# 'timeout 180': un 'shutdown immediate' poate dura, dar nu la infinit. Fara
# el, un sqlplus blocat ar tine scriptul agatat fara niciun mesaj (patit pe
# 2026-08-29, la sonda echivalenta din cluster-startup).
$sqlScript = @"
timeout 180 pct exec $OracleCt -- docker exec $OracleDocker bash -lc "printf 'shutdown immediate\nexit\n' | sqlplus -s / as sysdba" 2>&1
"@
$out = Invoke-NodeScript -Node $oracleGuest.Node -Script $sqlScript
# Codul de iesire nu e o dovada: sqlplus intoarce 0 si cand n-a inchis nimic.
# Singura confirmare reala e mesajul instantei.
if (($out -join "`n") -match 'ORACLE instance shut down') {
Write-Ok "instanta Oracle oprita curat"
} else {
Write-Warn "Baza NU a confirmat 'ORACLE instance shut down'. Ultimele linii:"
$out | Select-Object -Last 8 | Write-Host
Confirm-Step "Oprirea CT $OracleCt peste o baza posibil deschisa. Continui?"
}
}
}
# ------------------------------------------------ pas 6: oprire guest-uri
Write-Step "Oprire guest-uri"
function Stop-Guest {
param([pscustomobject]$Guest)
$sid = "$($Guest.Type):$($Guest.Id)"
$cmd = if ($Guest.Type -eq 'ct') { 'pct' } else { 'qm' }
if ($Guest.Ha -eq 'ha') {
# Pentru resursele HA se foloseste ha-manager, NU pct/qm shutdown: din CLI
# acestea nu actualizeaza state-ul HA, iar CRM-ul ar reporni guest-ul.
Invoke-NodeChange -Node $Guest.Node -Command "ha-manager set $sid --state stopped" | Out-Null
} else {
Invoke-NodeChange -Node $Guest.Node -Command "$cmd shutdown $($Guest.Id) --timeout $GuestTimeout" | Out-Null
}
if ($DryRun) { return }
$waited = 0
while ($waited -lt $GuestTimeout) {
if ((Get-GuestStatus -Node $Guest.Node -Type $Guest.Type -Id $Guest.Id) -eq 'stopped') {
Write-Ok "$sid oprit (${waited}s)"
return
}
Start-Sleep -Seconds 5
$waited += 5
}
Write-Warn "$sid nu s-a oprit in ${GuestTimeout}s - fortez stop"
Invoke-Node -Node $Guest.Node -Command "$cmd stop $($Guest.Id)" | Out-Null
Start-Sleep -Seconds 5
if ((Get-GuestStatus -Node $Guest.Node -Type $Guest.Type -Id $Guest.Id) -ne 'stopped') {
Stop-WithError "$sid REFUZA sa se opreasca. Rezolva manual inainte de a opri nodurile."
}
Write-Ok "$sid oprit fortat"
}
# intai in ordinea definita...
foreach ($id in $GuestShutdownOrder) {
$g = $running | Where-Object { $_.Id -eq $id } | Select-Object -First 1
if (-not $g) { continue }
Write-Log "opresc $($g.Type):$($g.Id) pe $($g.Node) ($($g.Ha))"
Stop-Guest -Guest $g
}
# ...apoi orice a mai ramas pornit si nu era in lista
foreach ($g in $running) {
if ($GuestShutdownOrder -contains $g.Id) { continue }
Write-Warn "guest neplanificat inca pornit: $($g.Type):$($g.Id) pe $($g.Node) - il opresc"
Stop-Guest -Guest $g
}
# ------------------------------------------- pas 7: verificare obligatorie
Write-Step "Verificare inainte de oprirea nodurilor"
if ($DryRun) {
Write-Warn "dry-run - sar peste verificare"
} else {
$ha = Invoke-Node -Node 'pvemini' -Command 'ha-manager status'
$bad = @($ha | Where-Object { $_ -match 'started|migrate|relocate|fence' })
if ($bad.Count -gt 0) {
$bad | Write-Host
Stop-WithError "Exista servicii HA inca active. NU opri nodurile."
}
Write-Ok "toate serviciile HA sunt in state stopped"
$still = 0
foreach ($node in $NodeIp.Keys) {
$still += @((Get-NodeGuests -Node $node) | Where-Object { $_.Status -eq 'running' }).Count
}
if ($still -ne 0) { Stop-WithError "$still guest-uri inca pornite. NU opri nodurile." }
Write-Ok "0 guest-uri pornite pe cluster"
Write-Ok "watchdog-ul nu mai e armat - pierderea quorumului e inofensiva"
}
if ($NoNodes) {
Write-Step "-NoNodes: nodurile raman pornite. Gata."
exit 0
}
Confirm-Step "Se opresc nodurile in ordinea: $($NodeShutdownOrder -join ', ') (pvemini ultimul)."
# ------------------------------------------------- pas 8: oprire noduri
Write-Step "Oprire noduri"
foreach ($node in $NodeShutdownOrder) {
Write-Log "opresc $node ($($NodeIp[$node]))..."
Invoke-NodeChange -Node $node -Command 'systemctl poweroff --no-block' | Out-Null
if ($DryRun) { continue }
$waited = 0
$down = $false
while ($waited -lt $NodeTimeout) {
if (-not (Test-Alive $NodeIp[$node])) {
Write-Ok "$node OPRIT (${waited}s)"
$down = $true
break
}
Start-Sleep -Seconds 10
$waited += 10
}
if (-not $down) {
Write-Warn "$node inca raspunde la ping dupa ${NodeTimeout}s."
Write-Warn "Verifica manual - sshd poate fi deja jos desi reteaua e sus."
}
}
Write-Step "Cluster oprit"
Write-Host ""
Write-Host " Stare salvata: $StateFile"
Write-Host " Backup crontab: $CronBackup (pe fiecare nod)"
Write-Host " Shutdown policy: freeze"
Write-Host ""
Write-Host " La revenirea curentului: .\cluster-startup.ps1"
Write-Host ""