241 lines
9.8 KiB
PHP
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);
|
|
}
|
|
}
|