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:
Adam Belkacemi 2026-10-12 21:12:43 +02:00
parent b054888f5a
commit f2649d0f8b
21 changed files with 938 additions and 28 deletions

View 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' : '',
));
}
}

View file

@ -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
{

View file

@ -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;
}
}

View file

@ -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);

View 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);
}
}

View file

@ -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());
}
}
}

View file

@ -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'),
],

View file

@ -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']);
});
}
};

View file

@ -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>

View file

@ -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();

View 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();
});