ajoute le self-healing des VM (sonde de santé, relance automatique, stock écarté) — bloc A, semaine 6
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b054888f5a
commit
f2649d0f8b
21 changed files with 938 additions and 28 deletions
25
portail/app/Console/Commands/SurveillerVm.php
Normal file
25
portail/app/Console/Commands/SurveillerVm.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Labs\SurveillanceVm;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SurveillerVm extends Command
|
||||
{
|
||||
protected $signature = 'labs:surveiller-vm';
|
||||
|
||||
protected $description = 'Sonde les VM (stock et élèves) et répare celles qui ne répondent plus';
|
||||
|
||||
public function handle(SurveillanceVm $surveillance): void
|
||||
{
|
||||
$r = $surveillance->surveiller();
|
||||
|
||||
$this->line(sprintf(
|
||||
'%d VM sondées, %d muettes ; stock : %d stack(s) retirée(s) ; élèves : %d VM relancée(s), %d irréparable(s)%s%s',
|
||||
$r['sondees'], $r['muettes'], $r['retirees_stock'], $r['relancees'], $r['irreparables'],
|
||||
$r['panne_generale'] ? ' ; PANNE GÉNÉRALE, aucune action' : '',
|
||||
$r['correcteur_injoignable'] ? ' ; CORRECTEUR INJOIGNABLE, aucune action' : '',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,17 @@ class LabSessionMachine extends Model
|
|||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['lab_session_id', 'nom', 'instance_id'];
|
||||
// Self-healing (SurveillanceVm) : la VM a été relancée automatiquement,
|
||||
// ou ne répond plus malgré les relances.
|
||||
public const RELANCEE = 'relancee';
|
||||
|
||||
public const IRREPARABLE = 'irreparable';
|
||||
|
||||
protected $fillable = ['lab_session_id', 'nom', 'instance_id', 'sante', 'sante_a'];
|
||||
|
||||
protected $casts = [
|
||||
'sante_a' => 'datetime',
|
||||
];
|
||||
|
||||
public function labSession(): BelongsTo
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use RuntimeException;
|
|||
|
||||
/**
|
||||
* Parle au service correcteur (Go, sur l'hyperviseur) par son socket Unix.
|
||||
* Le portail n'envoie que trois identifiants : jamais une commande. Les
|
||||
* Le portail n'envoie que des identifiants : jamais une commande. Les
|
||||
* commandes sont lues dans le lab.yaml par le correcteur lui-même.
|
||||
*/
|
||||
class ClientCorrecteur
|
||||
|
|
@ -15,6 +15,32 @@ class ClientCorrecteur
|
|||
* @return array{reussi: bool, erreur: ?string}
|
||||
*/
|
||||
public function verifier(string $instanceId, string $lab, string $verification): array
|
||||
{
|
||||
// 10 s par vérification côté correcteur, plus une marge.
|
||||
$reponse = $this->envoyer(['instance' => $instanceId, 'lab' => $lab, 'verification' => $verification], 15);
|
||||
|
||||
return ['reussi' => $reponse['reussi'], 'erreur' => $reponse['erreur'] ?? null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonde de santé (self-healing) : état libvirt de la VM et réponse de son
|
||||
* agent invité. Rien n'est exécuté dans la VM.
|
||||
*
|
||||
* @return array{repond: bool, etat: ?string, erreur: ?string}
|
||||
*/
|
||||
public function sante(string $instanceId): array
|
||||
{
|
||||
// 8 s maximum côté correcteur, plus une marge.
|
||||
$reponse = $this->envoyer(['instance' => $instanceId, 'sante' => true], 10);
|
||||
|
||||
return ['repond' => $reponse['reussi'], 'etat' => $reponse['etat'] ?? null, 'erreur' => $reponse['erreur'] ?? null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool> $requete
|
||||
* @return array{reussi: bool, erreur?: string, etat?: string}
|
||||
*/
|
||||
private function envoyer(array $requete, int $delai): array
|
||||
{
|
||||
$flux = @stream_socket_client('unix://'.config('labs.correcteur.socket'), $codeErreur, $messageErreur, 2);
|
||||
|
||||
|
|
@ -22,10 +48,9 @@ class ClientCorrecteur
|
|||
throw new RuntimeException("Correcteur injoignable : {$messageErreur}");
|
||||
}
|
||||
|
||||
// 10 s par vérification côté correcteur, plus une marge.
|
||||
stream_set_timeout($flux, 15);
|
||||
stream_set_timeout($flux, $delai);
|
||||
|
||||
fwrite($flux, json_encode(['instance' => $instanceId, 'lab' => $lab, 'verification' => $verification])."\n");
|
||||
fwrite($flux, json_encode($requete)."\n");
|
||||
$ligne = fgets($flux);
|
||||
fclose($flux);
|
||||
|
||||
|
|
@ -35,6 +60,6 @@ class ClientCorrecteur
|
|||
throw new RuntimeException('Réponse illisible du correcteur');
|
||||
}
|
||||
|
||||
return ['reussi' => $reponse['reussi'], 'erreur' => $reponse['erreur'] ?? null];
|
||||
return $reponse;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,44 @@ class StockChaud
|
|||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacks prêtes, tous labs confondus (lues par SurveillanceVm).
|
||||
*
|
||||
* @return list<array{lab: string, nom: string, id: string, instances: array<string, string>, cree_a: int, brut: string}>
|
||||
*/
|
||||
public function pretes(): array
|
||||
{
|
||||
$pretes = [];
|
||||
|
||||
foreach (array_keys($this->labsDisponibles()) as $lab) {
|
||||
foreach (Redis::lrange(self::clePretes($lab), 0, -1) as $brut) {
|
||||
$stack = json_decode($brut, true);
|
||||
|
||||
if (is_array($stack) && isset($stack['nom'], $stack['id'], $stack['instances'])) {
|
||||
$pretes[] = ['lab' => $lab, 'cree_a' => (int) ($stack['cree_a'] ?? 0), 'brut' => $brut] + $stack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $pretes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire une stack prête précise du stock et la supprime. Faux si elle
|
||||
* n'y est plus (déjà donnée à un élève entre-temps) : on n'y touche pas.
|
||||
*/
|
||||
public function retirer(string $lab, string $brut): bool
|
||||
{
|
||||
if ((int) Redis::lrem(self::clePretes($lab), 1, $brut) === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stack = json_decode($brut, true);
|
||||
$this->supprimerSansErreur($stack['nom'], $stack['id']);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function commandeTerminee(string $lab, string $base): void
|
||||
{
|
||||
Redis::zrem(self::cleCommandes($lab), $base);
|
||||
|
|
|
|||
241
portail/app/Services/Labs/SurveillanceVm.php
Normal file
241
portail/app/Services/Labs/SurveillanceVm.php
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,4 +30,35 @@ class NovaClient
|
|||
|
||||
return $reponse->json('remote_console.url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Redémarrage forcé (bloc A, self-healing) : équivalent d'un appui sur
|
||||
* le bouton reset. Le disque de la VM est conservé.
|
||||
*/
|
||||
public function redemarrerForce(string $instanceId): void
|
||||
{
|
||||
$this->action($instanceId, ['reboot' => ['type' => 'HARD']]);
|
||||
}
|
||||
|
||||
/** Démarre une VM éteinte (l'élève a lancé « poweroff »). */
|
||||
public function demarrer(string $instanceId): void
|
||||
{
|
||||
$this->action($instanceId, ['os-start' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $corps
|
||||
*/
|
||||
private function action(string $instanceId, array $corps): void
|
||||
{
|
||||
$reponse = Http::withHeaders([
|
||||
'X-Auth-Token' => $this->auth->jeton(),
|
||||
'OpenStack-API-Version' => 'compute 2.99',
|
||||
])
|
||||
->post(config('openstack.nova_url')."/servers/{$instanceId}/action", $corps);
|
||||
|
||||
if ($reponse->failed()) {
|
||||
throw new RuntimeException("Nova a refusé l'action ".array_key_first($corps)." sur {$instanceId} : ".$reponse->body());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ return [
|
|||
'avance_minutes' => 10,
|
||||
],
|
||||
|
||||
// Self-healing (SurveillanceVm) : fichier lu par le collecteur textfile
|
||||
// de node_exporter. Vide en dev et en test : pas de métriques.
|
||||
'surveillance' => [
|
||||
'metriques' => env('LABS_SURVEILLANCE_METRIQUES'),
|
||||
],
|
||||
|
||||
'correcteur' => [
|
||||
'socket' => env('CORRECTEUR_SOCKET', '/run/correcteur/correcteur.sock'),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Bloc A, self-healing : dernier incident de santé d'une VM d'élève, montré
|
||||
* au prof dans la salle d'attente (« relancée », « irréparable »).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('lab_session_machines', function (Blueprint $table) {
|
||||
$table->string('sante', 20)->nullable();
|
||||
$table->timestamp('sante_a')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('lab_session_machines', function (Blueprint $table) {
|
||||
$table->dropColumn(['sante', 'sante_a']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -53,6 +53,15 @@
|
|||
@if ($labSession->etat === $etats::ECHEC && $labSession->raison_echec)
|
||||
<p class="w-full font-mono text-xs text-[#5f6368]">{{ $labSession->raison_echec }}</p>
|
||||
@endif
|
||||
@if ($labSession->etat === $etats::ACTIF)
|
||||
@foreach ($labSession->machines as $machine)
|
||||
@if ($machine->sante === \App\Models\LabSessionMachine::IRREPARABLE)
|
||||
<p class="w-full font-mono text-sm font-bold text-[#A63D2A]">{{ $machine->nom }} ne répond plus malgré deux redémarrages automatiques. L'équipe technique est prévenue.</p>
|
||||
@elseif ($machine->sante === \App\Models\LabSessionMachine::RELANCEE && $machine->sante_a?->gt(now()->subMinutes(30)))
|
||||
<p class="w-full font-mono text-xs text-[#5f6368]">{{ $machine->nom }} était bloquée : redémarrée automatiquement à {{ $machine->sante_a->timezone('Europe/Paris')->format('H:i') }} (fichiers conservés).</p>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ Schedule::command('labs:expirer-sessions')->everyMinute();
|
|||
// Étape 11 : stock chaud ajusté à sa cible, stacks orphelines supprimées.
|
||||
Schedule::command('labs:remplir-stock')->everyMinute()->withoutOverlapping(5);
|
||||
|
||||
// Bloc A, self-healing : VM muettes du stock remplacées, VM d'élèves relancées.
|
||||
Schedule::command('labs:surveiller-vm')->everyMinute()->withoutOverlapping(5);
|
||||
|
||||
// Horizon : instantanés des métriques (débit, temps d'attente des files).
|
||||
Schedule::command('horizon:snapshot')->everyFiveMinutes();
|
||||
|
||||
|
|
|
|||
171
portail/tests/Feature/SurveillanceVmTest.php
Normal file
171
portail/tests/Feature/SurveillanceVmTest.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
use App\Models\LabSession;
|
||||
use App\Models\LabSessionMachine;
|
||||
use App\Services\Labs\ClientCorrecteur;
|
||||
use App\Services\Labs\StockChaud;
|
||||
use App\Services\Labs\SurveillanceVm;
|
||||
use App\Services\OpenStack\NovaClient;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
// Redis en mémoire : juste les commandes dont SurveillanceVm a besoin.
|
||||
beforeEach(function () {
|
||||
Redis::swap(new class
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $d = [];
|
||||
|
||||
public function incr($k)
|
||||
{
|
||||
return $this->d[$k] = (int) ($this->d[$k] ?? 0) + 1;
|
||||
}
|
||||
|
||||
public function get($k)
|
||||
{
|
||||
return $this->d[$k] ?? null;
|
||||
}
|
||||
|
||||
public function del($k)
|
||||
{
|
||||
unset($this->d[$k]);
|
||||
}
|
||||
|
||||
public function expire($k, $s) {}
|
||||
|
||||
public function hincrby($k, $c, $n)
|
||||
{
|
||||
$this->d[$k][$c] = ($this->d[$k][$c] ?? 0) + $n;
|
||||
}
|
||||
|
||||
public function hgetall($k)
|
||||
{
|
||||
return $this->d[$k] ?? [];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Session d'élève active avec ses VM, créées il y a $ilYa secondes.
|
||||
*
|
||||
* @param array<string, string> $instances nom => uuid
|
||||
* @return array<string, LabSessionMachine>
|
||||
*/
|
||||
function sessionActive(array $instances, int $ilYa = 600): array
|
||||
{
|
||||
$session = sessionEnAttente(LabSession::ACTIF);
|
||||
$machines = [];
|
||||
foreach ($instances as $nom => $uuid) {
|
||||
$m = $session->machines()->create(['nom' => $nom, 'instance_id' => $uuid]);
|
||||
$m->forceFill(['created_at' => now()->subSeconds($ilYa)])->save();
|
||||
$machines[$nom] = $m;
|
||||
}
|
||||
|
||||
return $machines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{repond: bool, etat: ?string}> $etats uuid => état
|
||||
*/
|
||||
function simuler(string $classe, Closure $attentes): void
|
||||
{
|
||||
app()->instance($classe, Mockery::mock($classe, $attentes));
|
||||
}
|
||||
|
||||
function surveillance(array $etats, array $pretes = [], ?Closure $nova = null, ?Closure $stock = null): SurveillanceVm
|
||||
{
|
||||
simuler(ClientCorrecteur::class, fn ($c) => $c->shouldReceive('sante')->andReturnUsing(
|
||||
fn (string $id) => ($etats[$id] ?? ['repond' => true, 'etat' => 'running']) + ['erreur' => null]
|
||||
));
|
||||
simuler(NovaClient::class, $nova ?? fn ($n) => $n->shouldNotReceive('redemarrerForce', 'demarrer'));
|
||||
simuler(StockChaud::class, function ($s) use ($pretes, $stock) {
|
||||
$s->shouldReceive('pretes')->andReturn($pretes);
|
||||
$stock ? $stock($s) : $s->shouldNotReceive('retirer');
|
||||
});
|
||||
|
||||
return app(SurveillanceVm::class);
|
||||
}
|
||||
|
||||
$muette = ['repond' => false, 'etat' => 'running'];
|
||||
|
||||
test('une VM d\'élève muette 3 minutes de suite est redémarrée de force, une seule fois', function () use ($muette) {
|
||||
$m = sessionActive(['pc-a' => 'uuid-a', 'pc-b' => 'uuid-b', 'routeur' => 'uuid-r']);
|
||||
$s = surveillance(['uuid-a' => $muette], nova: fn ($n) => $n->shouldReceive('redemarrerForce')->once()->with('uuid-a'));
|
||||
|
||||
expect($s->surveiller()['relancees'])->toBe(0)
|
||||
->and($s->surveiller()['relancees'])->toBe(0)
|
||||
->and($s->surveiller()['relancees'])->toBe(1);
|
||||
|
||||
expect($m['pc-a']->refresh()->sante)->toBe(LabSessionMachine::RELANCEE)
|
||||
->and($m['pc-b']->refresh()->sante)->toBeNull();
|
||||
});
|
||||
|
||||
test('une VM éteinte par l\'élève est redémarrée, pas réinitialisée', function () {
|
||||
sessionActive(['pc-a' => 'uuid-a', 'pc-b' => 'uuid-b', 'routeur' => 'uuid-r']);
|
||||
$s = surveillance(['uuid-a' => ['repond' => false, 'etat' => 'shut off']], nova: fn ($n) => $n->shouldReceive('demarrer')->once()->with('uuid-a'));
|
||||
|
||||
foreach (range(1, 3) as $_) {
|
||||
$s->surveiller();
|
||||
}
|
||||
});
|
||||
|
||||
test('après deux relances dans l\'heure, la VM est déclarée irréparable et le prof le voit', function () use ($muette) {
|
||||
$m = sessionActive(['pc-a' => 'uuid-a', 'pc-b' => 'uuid-b', 'routeur' => 'uuid-r']);
|
||||
$s = surveillance(['uuid-a' => $muette], nova: fn ($n) => $n->shouldReceive('redemarrerForce')->twice());
|
||||
|
||||
foreach (range(1, 9) as $_) {
|
||||
$r = $s->surveiller();
|
||||
}
|
||||
|
||||
expect($r['irreparables'])->toBe(1)
|
||||
->and($m['pc-a']->refresh()->sante)->toBe(LabSessionMachine::IRREPARABLE)
|
||||
->and(Redis::hgetall('sante:compteurs'))->toBe(['relances_session' => 2, 'abandons' => 1]);
|
||||
|
||||
$session = $m['pc-a']->labSession;
|
||||
$prof = $session->seance->classe->prof;
|
||||
$this->withSession(['jeton_session_web' => $prof->demarreNouvelleSession()])
|
||||
->actingAs($prof, 'web')
|
||||
->get(route('seances.attente', $session->seance))
|
||||
->assertSee('pc-a ne répond plus');
|
||||
});
|
||||
|
||||
test('toutes les VM muettes à la fois : panne de l\'hôte, aucune relance', function () use ($muette) {
|
||||
sessionActive(['pc-a' => 'uuid-a', 'pc-b' => 'uuid-b', 'routeur' => 'uuid-r']);
|
||||
$s = surveillance(['uuid-a' => $muette, 'uuid-b' => $muette, 'uuid-r' => $muette]);
|
||||
|
||||
foreach (range(1, 4) as $_) {
|
||||
$r = $s->surveiller();
|
||||
}
|
||||
|
||||
expect($r['panne_generale'])->toBeTrue()->and($r['relancees'])->toBe(0);
|
||||
});
|
||||
|
||||
test('une VM qui vient d\'être créée n\'est pas sondée (elle démarre)', function () use ($muette) {
|
||||
sessionActive(['pc-a' => 'uuid-a'], ilYa: 60);
|
||||
$s = surveillance(['uuid-a' => $muette]);
|
||||
|
||||
foreach (range(1, 4) as $_) {
|
||||
$r = $s->surveiller();
|
||||
}
|
||||
|
||||
expect($r['sondees'])->toBe(0);
|
||||
});
|
||||
|
||||
test('une stack du stock dont une VM est muette 2 minutes est retirée du stock', function () use ($muette) {
|
||||
$pretes = [
|
||||
['lab' => '08-deux-lan-routeur', 'nom' => 'stock-a-e1', 'id' => 'h1', 'instances' => ['pc-a' => 's1', 'routeur' => 's2', 'pc-b' => 's3'], 'cree_a' => time() - 600, 'brut' => 'A'],
|
||||
['lab' => '08-deux-lan-routeur', 'nom' => 'stock-b-e1', 'id' => 'h2', 'instances' => ['pc-a' => 's4', 'routeur' => 's5', 'pc-b' => 's6'], 'cree_a' => time() - 600, 'brut' => 'B'],
|
||||
];
|
||||
$s = surveillance(['s2' => $muette], $pretes, stock: fn ($st) => $st->shouldReceive('retirer')->once()->with('08-deux-lan-routeur', 'A')->andReturn(true));
|
||||
|
||||
expect($s->surveiller()['retirees_stock'])->toBe(0)
|
||||
->and($s->surveiller()['retirees_stock'])->toBe(1);
|
||||
});
|
||||
|
||||
test('correcteur injoignable : aucune action', function () {
|
||||
sessionActive(['pc-a' => 'uuid-a']);
|
||||
$this->mock(ClientCorrecteur::class, fn ($c) => $c->shouldReceive('sante')->andThrow(new RuntimeException('Correcteur injoignable')));
|
||||
$this->mock(NovaClient::class, fn ($n) => $n->shouldNotReceive('redemarrerForce', 'demarrer'));
|
||||
$this->mock(StockChaud::class, fn ($s) => $s->shouldReceive('pretes')->andReturn([]));
|
||||
|
||||
expect(app(SurveillanceVm::class)->surveiller()['correcteur_injoignable'])->toBeTrue();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue