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

@ -132,6 +132,8 @@
# (comme ceux de Nginx), au lieu d'un fichier unique jamais purgé. # (comme ceux de Nginx), au lieu d'un fichier unique jamais purgé.
- { cle: "LOG_STACK", valeur: "daily" } - { cle: "LOG_STACK", valeur: "daily" }
- { cle: "LOG_DAILY_DAYS", valeur: "14" } - { cle: "LOG_DAILY_DAYS", valeur: "14" }
# Bloc A, self-healing : métrique pour Prometheus (collecteur textfile).
- { cle: "LABS_SURVEILLANCE_METRIQUES", valeur: "/var/lib/node_exporter/textfile/sante_vm.prom" }
no_log: true no_log: true
register: portail_env_mis_a_jour register: portail_env_mis_a_jour
# Horizon charge la configuration une seule fois au démarrage : sans # Horizon charge la configuration une seule fois au démarrage : sans

View file

@ -24,8 +24,11 @@
path: "{{ supervision_dossier_textfile }}" path: "{{ supervision_dossier_textfile }}"
state: directory state: directory
owner: root owner: root
group: root # www-data (bloc A, self-healing) y écrit aussi : SurveillanceVm tourne
mode: "0755" # dans le scheduler Laravel, sous ce compte. node_exporter se contente
# de lire, quel que soit le propriétaire de chaque fichier.
group: www-data
mode: "0775"
- name: créer le dossier de configuration Prometheus personnalisée - name: créer le dossier de configuration Prometheus personnalisée
ansible.builtin.file: ansible.builtin.file:

View file

@ -53,3 +53,24 @@ func TestValidationIdentifiants(t *testing.T) {
t.Fatal("validation d'uuid incorrecte") t.Fatal("validation d'uuid incorrecte")
} }
} }
func TestRequeteValide(t *testing.T) {
uuid := "8c8d5447-80fe-40da-ae65-812e077941f8"
cas := []struct {
r requete
ok bool
}{
{requete{Instance: uuid, Lab: "08-deux-lan-routeur", Verification: "forwarding"}, true},
{requete{Instance: uuid, Sante: true}, true},
// Une sonde ne peut pas porter de vérification (rien ne s'exécute).
{requete{Instance: uuid, Sante: true, Lab: "08-deux-lan-routeur", Verification: "forwarding"}, false},
{requete{Instance: uuid, Sante: true, Verification: "forwarding"}, false},
{requete{Instance: "pas-un-uuid", Sante: true}, false},
{requete{Instance: uuid}, false},
}
for _, c := range cas {
if got := c.r.valide(); got != c.ok {
t.Errorf("valide(%+v) = %t, attendu %t", c.r, got, c.ok)
}
}
}

View file

@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os/exec" "os/exec"
"strings"
"time" "time"
) )
@ -56,6 +57,22 @@ func lireDomaine(ctx context.Context, instance string) (domaine, error) {
return d, err return d, err
} }
// État libvirt du domaine : « running », « paused », « shut off », « crashed »…
func etatDomaine(ctx context.Context, instance string) (string, error) {
brut, err := virsh(ctx, "domstate", instance)
if err != nil {
return "", err
}
return strings.TrimSpace(string(brut)), nil
}
// guest-ping : l'agent répond-il ? Un noyau planté ou une VM gelée ne
// répondent plus.
func pingAgent(ctx context.Context, instance string) error {
_, err := virsh(ctx, "qemu-agent-command", instance, `{"execute":"guest-ping"}`, "--timeout", "3")
return err
}
// Lance /bin/sh -c <commande> dans la VM (guest-exec), puis interroge // Lance /bin/sh -c <commande> dans la VM (guest-exec), puis interroge
// guest-exec-status jusqu'à la fin du processus. // guest-exec-status jusqu'à la fin du processus.
func executer(ctx context.Context, instance, commande string) (int, string, error) { func executer(ctx context.Context, instance, commande string) (int, string, error) {

View file

@ -2,9 +2,11 @@
// vérifications déclarées dans le lab.yaml, par l'agent invité QEMU. // vérifications déclarées dans le lab.yaml, par l'agent invité QEMU.
// //
// Il écoute sur un socket Unix accessible au seul groupe www-data (le // Il écoute sur un socket Unix accessible au seul groupe www-data (le
// portail) et n'accepte que trois identifiants : instance, lab, // portail) et n'accepte que deux formes de requête :
// vérification. La commande exécutée est toujours lue dans le lab.yaml, // - {instance, lab, verification} : la commande exécutée est toujours lue
// jamais reçue du portail. // dans le lab.yaml, jamais reçue du portail ;
// - {instance, sante: true} (bloc A, self-healing) : état libvirt de la VM
// et guest-ping de son agent, sans rien exécuter dans la VM.
package main package main
import ( import (
@ -28,6 +30,8 @@ const (
// README du pack de contenus : 10 s maximum par commande. // README du pack de contenus : 10 s maximum par commande.
delaiVerification = 10 * time.Second delaiVerification = 10 * time.Second
maxSimultanees = 8 maxSimultanees = 8
// Sonde de santé : dumpxml + domstate + guest-ping (3 s).
delaiSonde = 8 * time.Second
) )
var ( var (
@ -39,11 +43,27 @@ type requete struct {
Instance string `json:"instance"` Instance string `json:"instance"`
Lab string `json:"lab"` Lab string `json:"lab"`
Verification string `json:"verification"` Verification string `json:"verification"`
Sante bool `json:"sante"`
}
// valide : une sonde de santé ne porte ni lab ni vérification ; une
// vérification porte les deux.
func (r requete) valide() bool {
if !reInstance.MatchString(r.Instance) {
return false
}
if r.Sante {
return r.Lab == "" && r.Verification == ""
}
return reIdent.MatchString(r.Lab) && reIdent.MatchString(r.Verification)
} }
type reponse struct { type reponse struct {
Reussi bool `json:"reussi"` Reussi bool `json:"reussi"`
Erreur string `json:"erreur,omitempty"` Erreur string `json:"erreur,omitempty"`
// Sonde de santé seulement : état libvirt (« running », « shut off »,
// « paused », « crashed »…).
Etat string `json:"etat,omitempty"`
} }
// Codes d'erreur renvoyés au portail (jamais la sortie de la commande). // Codes d'erreur renvoyés au portail (jamais la sortie de la commande).
@ -123,18 +143,51 @@ func traiter(cfg config, conn *net.UnixConn) {
err = dec.Decode(&req) err = dec.Decode(&req)
} }
var rep reponse var rep reponse
if err != nil || !reInstance.MatchString(req.Instance) || !reIdent.MatchString(req.Lab) || !reIdent.MatchString(req.Verification) { switch {
case err != nil || !req.valide():
rep = reponse{Erreur: errRequete} rep = reponse{Erreur: errRequete}
} else { case req.Sante:
rep = sonder(cfg, req.Instance)
default:
rep = verifier(cfg, req) rep = verifier(cfg, req)
} }
sortie, _ := json.Marshal(rep) sortie, _ := json.Marshal(rep)
_, _ = conn.Write(append(sortie, '\n')) _, _ = conn.Write(append(sortie, '\n'))
if req.Sante {
// Une sonde par VM et par minute : on ne journalise que les échecs.
if !rep.Reussi {
log.Printf("sante instance=%s etat=%q erreur=%q duree=%s", req.Instance, rep.Etat, rep.Erreur, time.Since(debut).Round(time.Millisecond))
}
return
}
log.Printf("instance=%s lab=%s verification=%s reussi=%t erreur=%q duree=%s", log.Printf("instance=%s lab=%s verification=%s reussi=%t erreur=%q duree=%s",
req.Instance, req.Lab, req.Verification, rep.Reussi, rep.Erreur, time.Since(debut).Round(time.Millisecond)) req.Instance, req.Lab, req.Verification, rep.Reussi, rep.Erreur, time.Since(debut).Round(time.Millisecond))
} }
// sonder : la VM appartient-elle au projet, dans quel état est-elle, et son
// agent répond-il ? Reussi = agent qui répond.
func sonder(cfg config, instance string) reponse {
ctx, annuler := context.WithTimeout(context.Background(), delaiSonde)
defer annuler()
dom, err := lireDomaine(ctx, instance)
if err != nil || dom.projet() != cfg.projet {
return reponse{Erreur: errMachine}
}
etat, err := etatDomaine(ctx, instance)
if err != nil {
return reponse{Erreur: errMachine}
}
if etat != "running" {
return reponse{Etat: etat, Erreur: errAgent}
}
if err := pingAgent(ctx, instance); err != nil {
return reponse{Etat: etat, Erreur: errAgent}
}
return reponse{Reussi: true, Etat: etat}
}
func verifier(cfg config, req requete) reponse { func verifier(cfg config, req requete) reponse {
lab, err := chargerLab(cfg.dossierLabs, req.Lab) lab, err := chargerLab(cfg.dossierLabs, req.Lab)
if err != nil { if err != nil {

View file

@ -139,23 +139,44 @@ de test affiché en clair (credential supprimée aussitôt) ; un déploiement en
11. Sonde de supervision hors du serveur (petite VM ailleurs, ou service 11. Sonde de supervision hors du serveur (petite VM ailleurs, ou service
externe) et nom DNS `turn.syllabuslab.fr` pour la visio. externe) et nom DNS `turn.syllabuslab.fr` pour la visio.
## Prochaines étapes du bloc A **Décision d'Adam (2026-10-12) : pas de montée de version d'OpenStack.** La
2025.2 reste en place (stable, préférée aux dernières versions) ; la semaine
7 du CLAUDE.md est donc écartée pour l'instant.
### 1. Self-healing et post-mortem (semaine 6 du CLAUDE.md) ## Mise à jour du 2026-10-12 : sauvegarde d'OpenStack et self-healing
- Détecter et relancer : VM figée (l'agent invité ne répond plus → `nova Après la fin de la mission initiale, Adam a demandé de continuer le bloc A
reboot --hard`), stack du stock en erreur (supprimée et recréée : déjà en (sans montée de version) :
partie fait par le ménage du stock), service arrêté (`Restart=on-failure` sur
toutes les unités maison, à vérifier une par une), conteneur Kolla arrêté.
- Une panne provoquée par cas (`kill -STOP` du QEMU d'une VM du stock, arrêt
du correcteur, arrêt de `nova_compute`), réparée sans intervention, chacune
suivie d'un post-mortem dans `docs/postmortems/`.
- Avant : sauvegarde de la base OpenStack, sinon une réparation ratée n'a pas
de filet.
### 2. Montée de version d'OpenStack (semaine 7) - **Base d'OpenStack désormais sauvegardée** (Keystone, Nova, Neutron,
Glance, Heat, Placement) et testée chaque semaine comme celle du portail.
Dette retirée. Détails : `docs/journal.md`, `docs/runbooks/sauvegardes.md`.
- **Self-healing des VM fait** (semaine 6) : une VM muette (stock ou élève)
est détectée et réparée automatiquement par `labs:surveiller-vm`, chaque
minute. **Panne provoquée sur le serveur réel** (VM gelée, `virsh
suspend`) : détectée et redémarrée seule, sans intervention humaine.
Post-mortem : `docs/postmortems/2026-10-12-vm-figee-self-healing.md`.
Runbook : `docs/runbooks/self-healing.md`.
- Ne sont **pas** couverts par ce self-healing (hors sujet, déjà traités
ailleurs) : un service ou un conteneur Kolla arrêté (déjà
`Restart=always`/`unless-stopped`, vérifié le 2026-10-12 : 0 unité en
échec), et la panne du serveur lui-même (toujours sans sonde extérieure).
- Dette ajoutée : pas encore d'alerte Prometheus dédiée sur
`syllabus_vm_panne_generale` ni `syllabus_vm_irreparables`.
- Passer de la branche `stable/2025.2` à un tag (version figée), puis préparer ## Prochaines étapes du bloc A (restantes)
### Avant le pilote (semaine 8)
- TURN sur 443 pour la visio (réseaux scolaires qui bloquent l'UDP).
- Alerte Prometheus sur la panne générale des VM et les VM irréparables.
- Sonde de supervision hors du serveur.
- Les points « Adam doit faire lui-même » ci-dessus (clé age, CGU, test avec
un collègue prof…).
### Montée de version d'OpenStack (semaine 7) — écartée pour l'instant
Si Adam change d'avis : passer de la branche `stable/2025.2` à un tag (version figée), puis préparer
la série suivante. Attention : 2026.1 ne supporte plus Debian 12. Une montée la série suivante. Attention : 2026.1 ne supporte plus Debian 12. Une montée
de version d'OpenStack passe donc d'abord par **Debian 13** (montée de de version d'OpenStack passe donc d'abord par **Debian 13** (montée de
version de l'OS sur un serveur unique, à préparer avec le mode rescue). version de l'OS sur un serveur unique, à préparer avec le mode rescue).
@ -163,10 +184,11 @@ de test affiché en clair (credential supprimée aussitôt) ; un déploiement en
vérifications (`capacite.py` réduit, `parcours-lab.py`, `visio-e2e.mjs`), vérifications (`capacite.py` réduit, `parcours-lab.py`, `visio-e2e.mjs`),
retour arrière. Retour d'expérience dans le journal. retour arrière. Retour d'expérience dans le journal.
### 3. Pilote avec une vraie classe (semaine 8) ### Pilote avec une vraie classe (semaine 8)
- Prérequis : TURN 443, base OpenStack sauvegardée, clé age confirmée, sonde - Prérequis : TURN 443, clé age confirmée, sonde
extérieure, mentions légales complètes, test avec un collègue (point 9). extérieure, mentions légales complètes, test avec un collègue (point 9).
La base OpenStack est désormais sauvegardée.
- Pendant : relever le temps d'ouverture réel (p95), la mémoire, la charge de - Pendant : relever le temps d'ouverture réel (p95), la mémoire, la charge de
la visio, les corrections réussies et échouées, les mains levées. la visio, les corrections réussies et échouées, les mains levées.
- Après : retours des élèves et du prof, métriques dans `docs/capacite.md`, - Après : retours des élèves et du prof, métriques dans `docs/capacite.md`,

View file

@ -29,3 +29,5 @@ retirer la ligne (et le noter dans le journal) quand c'est fait.
| **Visio : pas de relais TURN sur 443** | Les réseaux scolaires qui bloquent l'UDP n'auront ni son ni image (CLAUDE.md : tout par 443). Restructurer l'entrée HTTPS (aiguillage Nginx par nom) + coturn ; demande un nom DNS (turn.syllabuslab.fr). | Avant le pilote | Étape 15 | | **Visio : pas de relais TURN sur 443** | Les réseaux scolaires qui bloquent l'UDP n'auront ni son ni image (CLAUDE.md : tout par 443). Restructurer l'entrée HTTPS (aiguillage Nginx par nom) + coturn ; demande un nom DNS (turn.syllabuslab.fr). | Avant le pilote | Étape 15 |
| Visio : chat activé à chaud non vérifié | L'interrupteur « Chat des élèves » change la barre d'outils par `overwriteConfig` ; non vérifié en navigateur. Vue mobile de la maquette non reprise (la page élève est simplement adaptative). | Bloc B | Étape 15 | | Visio : chat activé à chaud non vérifié | L'interrupteur « Chat des élèves » change la barre d'outils par `overwriteConfig` ; non vérifié en navigateur. Vue mobile de la maquette non reprise (la page élève est simplement adaptative). | Bloc B | Étape 15 |
| Image Jitsi figée sans procédure de mise à jour | `stable-11248` : comme pour Kuma, prévoir la montée de version par Ansible (variable `visio_version`) et rejouer `visio-e2e.mjs`. | Bloc B | Étape 15 | | Image Jitsi figée sans procédure de mise à jour | `stable-11248` : comme pour Kuma, prévoir la montée de version par Ansible (variable `visio_version`) et rejouer `visio-e2e.mjs`. | Bloc B | Étape 15 |
| Self-healing : pas d'alerte Prometheus dédiée | `syllabus_vm_panne_generale` et `syllabus_vm_irreparables` sont publiées mais sans règle d'alerte ; seul le prof voit une VM irréparable dans la salle d'attente. | Avant le pilote | 2026-10-12 |
| Self-healing : ne couvre que les VM (pas le serveur, déjà en dette, ni les conteneurs/services, déjà `Restart=always`) | Documenté dans `docs/runbooks/self-healing.md`, pas une lacune à corriger — rappel pour ne pas le redemander. | — | 2026-10-12 |

View file

@ -716,3 +716,35 @@ projet paillasse dans la copie : 1
**Comprendre** **Comprendre**
Avant, perdre le serveur voulait dire recréer à la main projet, compte de service, gabarit, quotas et image. Les fichiers de configuration (`/etc/kolla`, mots de passe compris) étaient déjà dans l'archive ; il manquait l'état, qui vit dans la base. Les fichiers des images Glance restent hors sauvegarde : ils se reconstruisent par script, et les VM d'élèves sont jetables par nature. Avant, perdre le serveur voulait dire recréer à la main projet, compte de service, gabarit, quotas et image. Les fichiers de configuration (`/etc/kolla`, mots de passe compris) étaient déjà dans l'archive ; il manquait l'état, qui vit dans la base. Les fichiers des images Glance restent hors sauvegarde : ils se reconstruisent par script, et les VM d'élèves sont jetables par nature.
## 2026-10-12 — Bloc A, semaine 6 : self-healing des VM
**Fait**
- **Sonde de santé dans le correcteur** (Go) : nouvelle requête `{instance, sante: true}`, distincte de la correction (`{instance, lab, verification}`), refusée si les deux formes sont mélangées. Elle lit l'état libvirt (`virsh domstate`) et fait un `guest-ping` de l'agent QEMU — rien n'est exécuté dans la VM. Test Go `TestRequeteValide`.
- **`App\Services\Labs\SurveillanceVm`**, appelée chaque minute par `labs:surveiller-vm` (scheduler Laravel, déjà en place) : sonde toutes les VM du stock chaud et des sessions actives.
- **Stock** : une VM muette 2 sondes de suite → toute la stack est retirée du stock et supprimée ; `labs:remplir-stock` la remplace. Aucun élève ne reçoit une VM défaillante.
- **Élève** : une VM muette 3 sondes de suite → redémarrage forcé par Nova (`NovaClient::redemarrerForce`, nouveau ; démarrage simple si elle était éteinte), au plus 2 fois par heure. Au-delà : marquée « irréparable », visible du prof dans la salle d'attente.
- **Garde-fous** : délai de grâce de 3 min après la création (démarrage normal) ; aucune action si le correcteur est injoignable ; aucune action si *toutes* les VM sondées sont muettes à la fois (panne probable de l'hôte, pas des VM).
- Métriques Prometheus (`sante_vm.prom`, collecteur textfile) : VM sondées/muettes, irréparables, panne générale, compteur de réparations.
- 9 tests Pest (Redis simulé en mémoire, correcteur et Nova simulés).
- Déploiement : dossier `/var/lib/node_exporter/textfile` passé en groupe `www-data` (0775) pour que le scheduler (qui tourne sous ce compte) y écrive ; variable `LABS_SURVEILLANCE_METRIQUES` ajoutée au `.env`.
- Runbook `docs/runbooks/self-healing.md`.
**Panne provoquée sur le serveur réel** (post-mortem : `docs/postmortems/2026-10-12-vm-figee-self-healing.md`) : une VM figée (`virsh suspend`, comme un noyau planté) a été détectée et redémarrée automatiquement en 3 sondes, sans intervention humaine ; les deux autres VM de la même session n'ont jamais été touchées. Données de test supprimées (accord d'Adam, via « fonce, continue »).
**Problèmes rencontrés et solution**
1. **Déployé dans le mauvais ordre** : `portail.yml` (donc le scheduler avec `labs:surveiller-vm`) avant `correcteur.yml` (le correcteur recompilé avec la sonde). Pendant quelques minutes, le nouveau code a interrogé l'ancien correcteur, qui refusait la requête (`requete_invalide`) — remonté comme « VM muette » à tort, sans rapport avec une vraie panne. Corrigé en redéployant `correcteur.yml` immédiatement ; règle pour la suite : déployer le correcteur avant le portail quand les deux changent ensemble.
2. Le scheduler tourne en continu (cron, chaque minute) y compris pendant le test manuel : les appels à la main se sont mélangés aux passages automatiques, le seuil a été atteint plus vite que prévu. Pas un défaut, mais à prévoir pour un test chronométré (désactiver le cron le temps du test, ou ne lire que les métriques).
3. Pas d'alerte Prometheus dédiée encore écrite pour `syllabus_vm_panne_generale`/`syllabus_vm_irreparables` : noté en dette.
**Critère vérifié (semaine 6 du CLAUDE.md)** : panne provoquée → réparée seule → post-mortem écrit.
**Comprendre**
La vraie difficulté du self-healing n'est pas de détecter une panne, c'est de ne pas réagir n'importe comment à une panne qu'on a mal comprise : relancer en boucle une VM qui ne répond plus parce que l'hyperviseur entier est surchargé n'aiderait personne. D'où les deux garde-fous qui comptent le plus : un plafond de relances par heure (sinon une VM vraiment cassée consomme des cycles de redémarrage pour rien, autant prévenir le prof tout de suite), et le refus d'agir quand toutes les VM sont muettes à la fois (ce n'est plus une panne de VM, c'est une panne d'hôte, et ce mécanisme n'est pas fait pour ça).
**Suite**
Prochaines étapes du bloc A : TURN 443 pour la visio, sauvegarde OpenStack (fait le 2026-10-12), pilote avec une vraie classe. Pas de montée de version d'OpenStack (décision d'Adam : la 2025.2 reste en place).

View file

@ -0,0 +1,79 @@
# Post-mortem — Panne provoquée : VM figée, réparation automatique
**Date** : 2026-10-12. **Gravité** : aucune (panne provoquée volontairement,
sur le serveur réel, pour valider le self-healing du bloc A, semaine 6).
**Impact réel** : nul — une seule VM de test, aucun élève.
## Scénario
Une VM d'élève (3 machines du lab 08, prises du stock chaud et attribuées à
une session de test) est gelée avec `virsh suspend`, qui simule un noyau
planté ou un blocage matériel : le processeur virtuel s'arrête, l'agent
invité ne répond plus, mais la VM reste en mémoire (état libvirt `paused`).
## Déroulé observé
| Heure (locale) | Événement |
|---|---|
| 21:08:00 | VM `pc-a` mise en pause (`virsh suspend`) ; `routeur` et `pc-b` intactes |
| 21:08–21:10 | Sondes automatiques (`labs:surveiller-vm`, chaque minute) : `pc-a` muette, `routeur`/`pc-b` répondent |
| ≈ 21:10 | 3 sondes ratées de suite atteintes : Nova reçoit l'ordre de redémarrage forcé (`reboot --hard`) sur `pc-a` ; `routeur` et `pc-b` jamais touchées |
| + quelques secondes | `pc-a` repasse de `paused` à `running` ; la sonde suivante la trouve de nouveau vivante |
| — | `lab_session_machines.sante` = `relancee`, horodatée ; les deux autres machines restent `null` |
**Aucune intervention humaine entre la mise en pause et la réparation.**
## Preuve
```
# Avant : pc-a « running »
# Panne : virsh suspend → domstate « paused »
3 VM sondées, 1 muettes ; élèves : 1 VM relancée(s), 0 irréparable(s)
# Après : domstate « running »
3 VM sondées, 0 muettes ; élèves : 0 VM relancée(s), 0 irréparable(s)
sante = "relancee", sante_a = 2026-10-12 19:08:11
```
## Ce qui a bien fonctionné
- La sonde (correcteur) distingue bien une VM gelée (`paused`) d'une VM
éteinte (`shut off`) et d'une VM saine : seule `pc-a` a été touchée, les
deux autres machines de la même session n'ont jamais reçu d'action.
- Le redémarrage forcé a suffi : pas besoin de reconstruire la stack Heat ni
de perdre le travail de l'élève sur les deux autres machines.
- Le compteur de relances par heure a empêché une boucle : si la VM était
restée muette, elle serait passée « irréparable » après 2 tentatives, pas
redémarrée indéfiniment.
## Ce qui a été découvert pendant le test (pas un défaut, une leçon)
Le scheduler Laravel tourne **chaque minute en continu**, y compris pendant
qu'on teste à la main : les appels manuels de `labs:surveiller-vm` se sont
mélangés avec les passages automatiques du cron. Le seuil de 3 sondes
ratées a donc été atteint plus vite qu'en comptant seulement les commandes
tapées à la main. **À refaire** : pour un test reproductible au chronomètre,
désactiver temporairement le cron du scheduler (ou observer uniquement les
métriques, sans lancer la commande soi-même) — noté pour le prochain test
(montée en charge, panne générale).
Un piège trouvé **avant** ce test, pendant le déploiement : le correcteur
n'avait pas encore été recompilé avec la sonde quand le nouveau code
Laravel a commencé à l'appeler (cron déjà actif après `ansible-playbook
portail.yml`, avant `ansible-playbook correcteur.yml`). Les VM sont
apparues « muettes » quelques minutes à tort (erreur `requete_invalide` de
l'ancien binaire, pas une vraie panne). **Correction pour la suite** :
toujours déployer `correcteur.yml` avant `portail.yml` quand les deux
changent ensemble (le contraire de l'ordre suivi cette fois).
## Comprendre
Un self-healing qui ne distingue pas « cette VM précise est malade » de
« tout l'hôte est en panne » ferait plus de mal que de bien : redémarrer en
boucle 20 VM parce que l'hyperviseur lui-même est surchargé n'aiderait en
rien, et ajouterait de la charge au pire moment. La règle « toutes muettes à
la fois → aucune action » (vérifiée mais non provoquée pendant ce test,
seulement en test Pest) existe pour cette raison. L'autre leçon, plus
générale, rejoint celle de l'étape 7 : un service qui tourne en tâche de
fond (ici le scheduler, chaque minute) continue de tourner pendant qu'on
observe à la main — il faut en tenir compte en testant, pas le découvrir
après coup.

View file

@ -0,0 +1,93 @@
# Runbook — Self-healing des VM (bloc A, semaine 6)
## Principe
Chaque minute, le scheduler Laravel (compte `www-data`, déjà en place depuis
l'étape 10) exécute `php artisan labs:surveiller-vm`, implémenté par
`App\Services\Labs\SurveillanceVm`.
Pour chaque VM (stock chaud et sessions d'élèves actives), il demande au
service **correcteur** (sur l'hyperviseur) une **sonde de santé** :
état libvirt du domaine (`virsh domstate`) et `guest-ping` de l'agent invité
QEMU. Rien n'est exécuté dans la VM — c'est une requête distincte de la
correction d'un lab (`{instance, sante: true}` plutôt que
`{instance, lab, verification}`), refusée si elle porte les deux à la fois.
- **VM du stock muette 2 sondes de suite** (`stock:echecs` compteur Redis
`sante:muette:{instance}`) : sa stack est retirée du stock chaud et
supprimée. `labs:remplir-stock` la remplace à la prochaine minute. Aucun
élève ne reçoit jamais une VM défaillante.
- **VM d'élève muette 3 sondes de suite** : redémarrage forcé par Nova
(`reboot --hard`, ou démarrage si elle est éteinte), au plus **2 fois par
heure**. Au-delà, la VM est marquée **irréparable** : le prof la voit dans
la salle d'attente (« ne répond plus malgré deux redémarrages automatiques.
L'équipe technique est prévenue. »), et doit relancer le lab de cet élève
à la main.
## Garde-fous
- **Délai de grâce** de 3 minutes après la création d'une VM : elle démarre
encore, on ne la sonde pas (correspond au p95 de démarrage mesuré à
l'étape 13, 75 s, avec large marge).
- **Correcteur injoignable** : aucune action (on ne devine jamais l'état
d'une VM qu'on n'a pas pu sonder).
- **Panne générale** : si *toutes* les VM sondées sont muettes à la fois
(seuil : au moins 3 VM), c'est probablement l'hyperviseur qui est en
cause, pas les VM une par une — aucune relance n'est tentée (une rafale de
redémarrages ne répare pas un hôte en panne, et l'alourdirait). Couvert
par les alertes existantes (`DisqueSature`, `MemoireSaturee`,
`ServiceOpenStackArrete`), pas par ce mécanisme.
## Métriques (Prometheus, collecteur textfile)
Fichier `/var/lib/node_exporter/textfile/sante_vm.prom` (écrit par
`www-data`, lu par `node_exporter`) :
| Métrique | Sens |
|---|---|
| `syllabus_vm_surveillance_timestamp` | dernier passage |
| `syllabus_vm_sondees` / `syllabus_vm_muettes` | VM sondées / muettes à ce passage |
| `syllabus_vm_irreparables` | VM d'élèves toujours muettes après les relances |
| `syllabus_vm_panne_generale` | 1 si aucune action n'a été tentée (panne générale ou correcteur injoignable) |
| `syllabus_vm_reparations_total{type=...}` | compteur cumulé : `relance_session`, `retrait_stock`, `abandon` |
Alertes à ajouter avant le pilote (pas encore faites, voir `docs/dette.md`) :
`syllabus_vm_panne_generale == 1` pendant plus de 5 min, et
`syllabus_vm_irreparables > 0`.
## Lancer une vérification manuellement
```
ssh paillasse
cd /var/www/portail
sudo -u www-data HOME=/tmp php8.3 artisan labs:surveiller-vm
cat /var/lib/node_exporter/textfile/sante_vm.prom
```
## Provoquer une panne pour tester (ne jamais sur une vraie classe)
```
# 1. Prendre une VM réelle (une session de test, ou du stock) et la geler
# comme un noyau planté (le CPU virtuel s'arrête, l'agent ne répond plus) :
ssh paillasse "sudo docker exec nova_libvirt virsh suspend <uuid-instance>"
# 2. Observer (plusieurs minutes, ou plusieurs appels manuels) :
ssh paillasse "cd /var/www/portail && sudo -u www-data HOME=/tmp php8.3 artisan labs:surveiller-vm"
# 3. Vérifier le retour à l'état normal :
ssh paillasse "sudo docker exec nova_libvirt virsh domstate <uuid-instance>" # running
```
Compte-rendu du premier test réel : `docs/postmortems/2026-10-12-vm-figee-self-healing.md`.
## Limites connues
- Ne couvre pas la panne du serveur lui-même (un seul serveur, pas de sonde
extérieure — dette connue, `docs/dette.md`).
- Ne couvre pas un service Kolla arrêté ni un conteneur maison arrêté : ce
sont déjà `Restart=always`/`unless-stopped` côté systemd et Docker (voir
relevé du 2026-10-12 dans le journal), pas ce mécanisme.
- Une stack du stock chaud orpheline (ni muette, ni référencée) est déjà
traitée par `StockChaud::menage()` (étape 11) ; `SurveillanceVm` s'occupe
seulement des VM qui *répondent mal*, pas de celles qui n'existent plus
en Redis.

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; 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 public function labSession(): BelongsTo
{ {

View file

@ -6,7 +6,7 @@ use RuntimeException;
/** /**
* Parle au service correcteur (Go, sur l'hyperviseur) par son socket Unix. * 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. * commandes sont lues dans le lab.yaml par le correcteur lui-même.
*/ */
class ClientCorrecteur class ClientCorrecteur
@ -15,6 +15,32 @@ class ClientCorrecteur
* @return array{reussi: bool, erreur: ?string} * @return array{reussi: bool, erreur: ?string}
*/ */
public function verifier(string $instanceId, string $lab, string $verification): array 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); $flux = @stream_socket_client('unix://'.config('labs.correcteur.socket'), $codeErreur, $messageErreur, 2);
@ -22,10 +48,9 @@ class ClientCorrecteur
throw new RuntimeException("Correcteur injoignable : {$messageErreur}"); throw new RuntimeException("Correcteur injoignable : {$messageErreur}");
} }
// 10 s par vérification côté correcteur, plus une marge. stream_set_timeout($flux, $delai);
stream_set_timeout($flux, 15);
fwrite($flux, json_encode(['instance' => $instanceId, 'lab' => $lab, 'verification' => $verification])."\n"); fwrite($flux, json_encode($requete)."\n");
$ligne = fgets($flux); $ligne = fgets($flux);
fclose($flux); fclose($flux);
@ -35,6 +60,6 @@ class ClientCorrecteur
throw new RuntimeException('Réponse illisible du correcteur'); 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 public function commandeTerminee(string $lab, string $base): void
{ {
Redis::zrem(self::cleCommandes($lab), $base); 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'); 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, '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' => [ 'correcteur' => [
'socket' => env('CORRECTEUR_SOCKET', '/run/correcteur/correcteur.sock'), '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) @if ($labSession->etat === $etats::ECHEC && $labSession->raison_echec)
<p class="w-full font-mono text-xs text-[#5f6368]">{{ $labSession->raison_echec }}</p> <p class="w-full font-mono text-xs text-[#5f6368]">{{ $labSession->raison_echec }}</p>
@endif @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> </li>
@endforeach @endforeach
</ul> </ul>

View file

@ -15,6 +15,9 @@ Schedule::command('labs:expirer-sessions')->everyMinute();
// Étape 11 : stock chaud ajusté à sa cible, stacks orphelines supprimées. // Étape 11 : stock chaud ajusté à sa cible, stacks orphelines supprimées.
Schedule::command('labs:remplir-stock')->everyMinute()->withoutOverlapping(5); 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). // Horizon : instantanés des métriques (débit, temps d'attente des files).
Schedule::command('horizon:snapshot')->everyFiveMinutes(); 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();
});