From f2649d0f8b88fd183492a3e77bb76c34d04e49a5 Mon Sep 17 00:00:00 2001 From: Adam Belkacemi Date: Mon, 12 Oct 2026 21:12:43 +0200 Subject: [PATCH] =?UTF-8?q?ajoute=20le=20self-healing=20des=20VM=20(sonde?= =?UTF-8?q?=20de=20sant=C3=A9,=20relance=20automatique,=20stock=20=C3=A9ca?= =?UTF-8?q?rt=C3=A9)=20=E2=80=94=20bloc=20A,=20semaine=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- ansible/roles/portail/tasks/03-code.yml | 2 + ansible/roles/supervision/tasks/main.yml | 7 +- correcteur/lab_test.go | 21 ++ correcteur/libvirt.go | 17 ++ correcteur/main.go | 63 ++++- docs/bilan.md | 52 ++-- docs/dette.md | 2 + docs/journal.md | 32 +++ .../2026-10-12-vm-figee-self-healing.md | 79 ++++++ docs/runbooks/self-healing.md | 93 +++++++ portail/app/Console/Commands/SurveillerVm.php | 25 ++ portail/app/Models/LabSessionMachine.php | 12 +- .../app/Services/Labs/ClientCorrecteur.php | 35 ++- portail/app/Services/Labs/StockChaud.php | 38 +++ portail/app/Services/Labs/SurveillanceVm.php | 241 ++++++++++++++++++ portail/app/Services/OpenStack/NovaClient.php | 31 +++ portail/config/labs.php | 6 + ...0000_add_sante_to_lab_session_machines.php | 27 ++ .../resources/views/seances/attente.blade.php | 9 + portail/routes/console.php | 3 + portail/tests/Feature/SurveillanceVmTest.php | 171 +++++++++++++ 21 files changed, 938 insertions(+), 28 deletions(-) create mode 100644 docs/postmortems/2026-10-12-vm-figee-self-healing.md create mode 100644 docs/runbooks/self-healing.md create mode 100644 portail/app/Console/Commands/SurveillerVm.php create mode 100644 portail/app/Services/Labs/SurveillanceVm.php create mode 100644 portail/database/migrations/2026_10_12_180000_add_sante_to_lab_session_machines.php create mode 100644 portail/tests/Feature/SurveillanceVmTest.php diff --git a/ansible/roles/portail/tasks/03-code.yml b/ansible/roles/portail/tasks/03-code.yml index b03c8a2..25c01f4 100644 --- a/ansible/roles/portail/tasks/03-code.yml +++ b/ansible/roles/portail/tasks/03-code.yml @@ -132,6 +132,8 @@ # (comme ceux de Nginx), au lieu d'un fichier unique jamais purgé. - { cle: "LOG_STACK", valeur: "daily" } - { 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 register: portail_env_mis_a_jour # Horizon charge la configuration une seule fois au démarrage : sans diff --git a/ansible/roles/supervision/tasks/main.yml b/ansible/roles/supervision/tasks/main.yml index 4b96249..38b572a 100644 --- a/ansible/roles/supervision/tasks/main.yml +++ b/ansible/roles/supervision/tasks/main.yml @@ -24,8 +24,11 @@ path: "{{ supervision_dossier_textfile }}" state: directory owner: root - group: root - mode: "0755" + # www-data (bloc A, self-healing) y écrit aussi : SurveillanceVm tourne + # 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 ansible.builtin.file: diff --git a/correcteur/lab_test.go b/correcteur/lab_test.go index 2b37ae1..299dddd 100644 --- a/correcteur/lab_test.go +++ b/correcteur/lab_test.go @@ -53,3 +53,24 @@ func TestValidationIdentifiants(t *testing.T) { 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) + } + } +} diff --git a/correcteur/libvirt.go b/correcteur/libvirt.go index cf9bae6..ef17320 100644 --- a/correcteur/libvirt.go +++ b/correcteur/libvirt.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "os/exec" + "strings" "time" ) @@ -56,6 +57,22 @@ func lireDomaine(ctx context.Context, instance string) (domaine, error) { 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 dans la VM (guest-exec), puis interroge // guest-exec-status jusqu'à la fin du processus. func executer(ctx context.Context, instance, commande string) (int, string, error) { diff --git a/correcteur/main.go b/correcteur/main.go index b69298b..5666b6b 100644 --- a/correcteur/main.go +++ b/correcteur/main.go @@ -2,9 +2,11 @@ // 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 -// portail) et n'accepte que trois identifiants : instance, lab, -// vérification. La commande exécutée est toujours lue dans le lab.yaml, -// jamais reçue du portail. +// portail) et n'accepte que deux formes de requête : +// - {instance, lab, verification} : la commande exécutée est toujours lue +// 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 import ( @@ -28,6 +30,8 @@ const ( // README du pack de contenus : 10 s maximum par commande. delaiVerification = 10 * time.Second maxSimultanees = 8 + // Sonde de santé : dumpxml + domstate + guest-ping (3 s). + delaiSonde = 8 * time.Second ) var ( @@ -39,11 +43,27 @@ type requete struct { Instance string `json:"instance"` Lab string `json:"lab"` 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 { Reussi bool `json:"reussi"` 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). @@ -123,18 +143,51 @@ func traiter(cfg config, conn *net.UnixConn) { err = dec.Decode(&req) } 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} - } else { + case req.Sante: + rep = sonder(cfg, req.Instance) + default: rep = verifier(cfg, req) } sortie, _ := json.Marshal(rep) _, _ = 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", 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 { lab, err := chargerLab(cfg.dossierLabs, req.Lab) if err != nil { diff --git a/docs/bilan.md b/docs/bilan.md index 49ea32c..1baf7da 100644 --- a/docs/bilan.md +++ b/docs/bilan.md @@ -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 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 - reboot --hard`), stack du stock en erreur (supprimée et recréée : déjà en - 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. +Après la fin de la mission initiale, Adam a demandé de continuer le bloc A +(sans montée de version) : -### 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 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). @@ -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`), 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). + La base OpenStack est désormais sauvegardée. - 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. - Après : retours des élèves et du prof, métriques dans `docs/capacite.md`, diff --git a/docs/dette.md b/docs/dette.md index c3bb439..ec2ce61 100644 --- a/docs/dette.md +++ b/docs/dette.md @@ -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 : 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 | +| 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 | diff --git a/docs/journal.md b/docs/journal.md index 8ffa6fb..5cb85de 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -716,3 +716,35 @@ projet paillasse dans la copie : 1 **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. + +## 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). diff --git a/docs/postmortems/2026-10-12-vm-figee-self-healing.md b/docs/postmortems/2026-10-12-vm-figee-self-healing.md new file mode 100644 index 0000000..b5272bb --- /dev/null +++ b/docs/postmortems/2026-10-12-vm-figee-self-healing.md @@ -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. diff --git a/docs/runbooks/self-healing.md b/docs/runbooks/self-healing.md new file mode 100644 index 0000000..80752f4 --- /dev/null +++ b/docs/runbooks/self-healing.md @@ -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 " + +# 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 " # 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. diff --git a/portail/app/Console/Commands/SurveillerVm.php b/portail/app/Console/Commands/SurveillerVm.php new file mode 100644 index 0000000..54a2403 --- /dev/null +++ b/portail/app/Console/Commands/SurveillerVm.php @@ -0,0 +1,25 @@ +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' : '', + )); + } +} diff --git a/portail/app/Models/LabSessionMachine.php b/portail/app/Models/LabSessionMachine.php index 7fdb24c..b7778b4 100644 --- a/portail/app/Models/LabSessionMachine.php +++ b/portail/app/Models/LabSessionMachine.php @@ -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 { diff --git a/portail/app/Services/Labs/ClientCorrecteur.php b/portail/app/Services/Labs/ClientCorrecteur.php index 8d32e8c..18707ec 100644 --- a/portail/app/Services/Labs/ClientCorrecteur.php +++ b/portail/app/Services/Labs/ClientCorrecteur.php @@ -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 $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; } } diff --git a/portail/app/Services/Labs/StockChaud.php b/portail/app/Services/Labs/StockChaud.php index 1bfe5a0..b0f9b99 100644 --- a/portail/app/Services/Labs/StockChaud.php +++ b/portail/app/Services/Labs/StockChaud.php @@ -107,6 +107,44 @@ class StockChaud ])); } + /** + * Stacks prêtes, tous labs confondus (lues par SurveillanceVm). + * + * @return list, 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); diff --git a/portail/app/Services/Labs/SurveillanceVm.php b/portail/app/Services/Labs/SurveillanceVm.php new file mode 100644 index 0000000..a7e6488 --- /dev/null +++ b/portail/app/Services/Labs/SurveillanceVm.php @@ -0,0 +1,241 @@ + 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); + } +} diff --git a/portail/app/Services/OpenStack/NovaClient.php b/portail/app/Services/OpenStack/NovaClient.php index e6fba93..968eb28 100644 --- a/portail/app/Services/OpenStack/NovaClient.php +++ b/portail/app/Services/OpenStack/NovaClient.php @@ -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 $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()); + } + } } diff --git a/portail/config/labs.php b/portail/config/labs.php index becba6a..05e6eaf 100644 --- a/portail/config/labs.php +++ b/portail/config/labs.php @@ -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'), ], diff --git a/portail/database/migrations/2026_10_12_180000_add_sante_to_lab_session_machines.php b/portail/database/migrations/2026_10_12_180000_add_sante_to_lab_session_machines.php new file mode 100644 index 0000000..61f2eaf --- /dev/null +++ b/portail/database/migrations/2026_10_12_180000_add_sante_to_lab_session_machines.php @@ -0,0 +1,27 @@ +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']); + }); + } +}; diff --git a/portail/resources/views/seances/attente.blade.php b/portail/resources/views/seances/attente.blade.php index 8f0895f..8a05436 100644 --- a/portail/resources/views/seances/attente.blade.php +++ b/portail/resources/views/seances/attente.blade.php @@ -53,6 +53,15 @@ @if ($labSession->etat === $etats::ECHEC && $labSession->raison_echec)

{{ $labSession->raison_echec }}

@endif + @if ($labSession->etat === $etats::ACTIF) + @foreach ($labSession->machines as $machine) + @if ($machine->sante === \App\Models\LabSessionMachine::IRREPARABLE) +

{{ $machine->nom }} ne répond plus malgré deux redémarrages automatiques. L'équipe technique est prévenue.

+ @elseif ($machine->sante === \App\Models\LabSessionMachine::RELANCEE && $machine->sante_a?->gt(now()->subMinutes(30))) +

{{ $machine->nom }} était bloquée : redémarrée automatiquement à {{ $machine->sante_a->timezone('Europe/Paris')->format('H:i') }} (fichiers conservés).

+ @endif + @endforeach + @endif @endforeach diff --git a/portail/routes/console.php b/portail/routes/console.php index 9461212..f895a15 100644 --- a/portail/routes/console.php +++ b/portail/routes/console.php @@ -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(); diff --git a/portail/tests/Feature/SurveillanceVmTest.php b/portail/tests/Feature/SurveillanceVmTest.php new file mode 100644 index 0000000..b4ac8e1 --- /dev/null +++ b/portail/tests/Feature/SurveillanceVmTest.php @@ -0,0 +1,171 @@ + */ + 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 $instances nom => uuid + * @return array + */ +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 $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(); +});