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