Paillasse/portail/app/Services/Labs/SurveillanceVm.php
2026-10-12 21:12:43 +02:00

241 lines
9.8 KiB
PHP

<?php
namespace App\Services\Labs;
use App\Models\LabSession;
use App\Models\LabSessionMachine;
use App\Services\OpenStack\NovaClient;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Throwable;
/**
* Self-healing des VM (bloc A, semaine 6), appelé chaque minute par
* labs:surveiller-vm. Chaque VM est sondée par le correcteur (état libvirt
* et guest-ping de l'agent invité : rien n'est exécuté dans la VM).
*
* - VM du stock muette 2 minutes de suite : sa stack sort du stock et est
* supprimée ; labs:remplir-stock la remplace. Aucun élève ne la reçoit.
* - VM d'élève muette 3 minutes de suite : redémarrage forcé par Nova (ou
* démarrage si elle est éteinte), 2 fois par heure au plus ; au-delà, le
* prof voit « ne répond plus » dans la salle d'attente.
*
* Garde-fous : délai de grâce après la création (la VM démarre) ; aucune
* action si le correcteur est injoignable ou si toutes les VM sont muettes
* à la fois (panne de l'hôte, pas des VM : on ne redémarre pas tout).
*
* Clés Redis : sante:muette:{instance} (sondes ratées de suite, 10 min),
* sante:relances:{instance} (relances de l'heure), sante:compteurs (totaux
* pour Prometheus).
*/
class SurveillanceVm
{
// Une VM fraîchement créée démarre encore (≈ 50 s, 75 s au p95 sous charge).
public const GRACE_SECONDES = 180;
public const SEUIL_STOCK = 2;
public const SEUIL_SESSION = 3;
public const RELANCES_MAX_PAR_HEURE = 2;
// En dessous, « toutes muettes » peut être une vraie panne de ces VM.
private const MIN_VM_PANNE_GENERALE = 3;
public function __construct(
private readonly ClientCorrecteur $correcteur,
private readonly NovaClient $nova,
private readonly StockChaud $stock,
) {}
/**
* @return array{sondees: int, muettes: int, retirees_stock: int, relancees: int, irreparables: int, panne_generale: bool, correcteur_injoignable: bool}
*/
public function surveiller(): array
{
$rapport = ['sondees' => 0, 'muettes' => 0, 'retirees_stock' => 0, 'relancees' => 0, 'irreparables' => 0, 'panne_generale' => false, 'correcteur_injoignable' => false];
$limite = time() - self::GRACE_SECONDES;
$stacks = array_values(array_filter($this->stock->pretes(), fn ($s) => $s['cree_a'] < $limite));
$machines = LabSessionMachine::query()
->whereHas('labSession', fn ($q) => $q->where('etat', LabSession::ACTIF))
->where('created_at', '<', now()->subSeconds(self::GRACE_SECONDES))
->get();
// 1. Sonder tout, avant d'agir sur quoi que ce soit.
$etats = [];
try {
foreach ($stacks as $stack) {
foreach ($stack['instances'] as $instance) {
$etats[$instance] = $this->correcteur->sante($instance);
}
}
foreach ($machines as $machine) {
$etats[$machine->instance_id] = $this->correcteur->sante($machine->instance_id);
}
} catch (Throwable $e) {
Log::error('Surveillance des VM : correcteur injoignable, aucune action', ['raison' => $e->getMessage()]);
$rapport['correcteur_injoignable'] = true;
$this->publierMetriques($rapport);
return $rapport;
}
$muettes = array_filter($etats, fn ($e) => ! $e['repond']);
$rapport['sondees'] = count($etats);
$rapport['muettes'] = count($muettes);
if (count($etats) >= self::MIN_VM_PANNE_GENERALE && count($muettes) === count($etats)) {
Log::critical("Surveillance des VM : les {$rapport['sondees']} VM sont muettes à la fois, panne de l'hôte probable : aucune action");
$rapport['panne_generale'] = true;
$this->publierMetriques($rapport);
return $rapport;
}
// 2. Compter les sondes ratées de suite, oublier celles qui répondent.
$suite = [];
foreach ($etats as $instance => $etat) {
if ($etat['repond']) {
Redis::del("sante:muette:{$instance}");
} else {
$suite[$instance] = (int) Redis::incr("sante:muette:{$instance}");
Redis::expire("sante:muette:{$instance}", 600);
}
}
// 3. Stock : une VM muette suffit à écarter toute la stack.
foreach ($stacks as $stack) {
$pire = max(array_map(fn ($i) => $suite[$i] ?? 0, array_values($stack['instances'])));
if ($pire >= self::SEUIL_STOCK && $this->stock->retirer($stack['lab'], $stack['brut'])) {
Log::warning("Surveillance des VM : stack {$stack['nom']} du stock muette depuis {$pire} min, retirée et remplacée");
Redis::hincrby('sante:compteurs', 'retraits_stock', 1);
foreach ($stack['instances'] as $instance) {
Redis::del("sante:muette:{$instance}");
}
$rapport['retirees_stock']++;
}
}
// 4. Sessions d'élèves.
foreach ($machines as $machine) {
$instance = $machine->instance_id;
if (($etats[$instance]['repond'] ?? false)) {
if ($machine->sante === LabSessionMachine::IRREPARABLE) {
$machine->update(['sante' => null, 'sante_a' => now()]);
}
continue;
}
if (($suite[$instance] ?? 0) < self::SEUIL_SESSION) {
continue;
}
if ($this->reparer($machine, $etats[$instance]['etat'])) {
$rapport['relancees']++;
}
}
$rapport['irreparables'] = LabSessionMachine::query()
->whereHas('labSession', fn ($q) => $q->where('etat', LabSession::ACTIF))
->where('sante', LabSessionMachine::IRREPARABLE)
->count();
$this->publierMetriques($rapport);
return $rapport;
}
private function reparer(LabSessionMachine $machine, ?string $etat): bool
{
$instance = $machine->instance_id;
$cle = "sante:relances:{$instance}";
if ((int) Redis::get($cle) >= self::RELANCES_MAX_PAR_HEURE) {
if ($machine->sante !== LabSessionMachine::IRREPARABLE) {
Log::error("Surveillance des VM : {$machine->nom} ({$instance}) toujours muette après ".self::RELANCES_MAX_PAR_HEURE.' relances, le prof est prévenu');
Redis::hincrby('sante:compteurs', 'abandons', 1);
$machine->update(['sante' => LabSessionMachine::IRREPARABLE, 'sante_a' => now()]);
}
return false;
}
try {
if ($etat === 'shut off') {
$this->nova->demarrer($instance);
} else {
$this->nova->redemarrerForce($instance);
}
} catch (Throwable $e) {
Log::error("Surveillance des VM : relance de {$instance} refusée par Nova", ['raison' => $e->getMessage()]);
return false;
}
Redis::incr($cle);
Redis::expire($cle, 3600);
Redis::del("sante:muette:{$instance}");
Redis::hincrby('sante:compteurs', 'relances_session', 1);
$machine->update(['sante' => LabSessionMachine::RELANCEE, 'sante_a' => now()]);
Log::warning("Surveillance des VM : {$machine->nom} ({$instance}, état « {$etat} ») relancée automatiquement");
return true;
}
/**
* Métriques pour Prometheus (collecteur textfile de node_exporter). Le
* fichier est créé par Ansible au nom de www-data ; une seule écriture
* courte, verrouillée.
*
* @param array<string, int|bool> $rapport
*/
private function publierMetriques(array $rapport): void
{
$fichier = config('labs.surveillance.metriques');
if (! $fichier) {
return;
}
$compteurs = Redis::hgetall('sante:compteurs') ?: [];
$lignes = [
'# HELP syllabus_vm_surveillance_timestamp Dernier passage de la surveillance des VM.',
'# TYPE syllabus_vm_surveillance_timestamp gauge',
'syllabus_vm_surveillance_timestamp '.time(),
'# HELP syllabus_vm_sondees VM sondées au dernier passage.',
'# TYPE syllabus_vm_sondees gauge',
'syllabus_vm_sondees '.$rapport['sondees'],
'# HELP syllabus_vm_muettes VM dont l\'agent invité ne répond pas.',
'# TYPE syllabus_vm_muettes gauge',
'syllabus_vm_muettes '.$rapport['muettes'],
'# HELP syllabus_vm_irreparables VM d\'élèves toujours muettes après les relances.',
'# TYPE syllabus_vm_irreparables gauge',
'syllabus_vm_irreparables '.$rapport['irreparables'],
'# HELP syllabus_vm_panne_generale 1 si toutes les VM sont muettes à la fois (aucune action).',
'# TYPE syllabus_vm_panne_generale gauge',
'syllabus_vm_panne_generale '.(int) ($rapport['panne_generale'] || $rapport['correcteur_injoignable']),
'# HELP syllabus_vm_reparations_total Réparations automatiques depuis la remise à zéro de Redis.',
'# TYPE syllabus_vm_reparations_total counter',
'syllabus_vm_reparations_total{type="relance_session"} '.(int) ($compteurs['relances_session'] ?? 0),
'syllabus_vm_reparations_total{type="retrait_stock"} '.(int) ($compteurs['retraits_stock'] ?? 0),
'syllabus_vm_reparations_total{type="abandon"} '.(int) ($compteurs['abandons'] ?? 0),
];
$flux = @fopen($fichier, 'c');
if ($flux === false) {
Log::warning("Surveillance des VM : métriques non écrites dans {$fichier}");
return;
}
flock($flux, LOCK_EX);
ftruncate($flux, 0);
fwrite($flux, implode("\n", $lignes)."\n");
fflush($flux);
flock($flux, LOCK_UN);
fclose($flux);
}
}