ajoute le self-healing des VM (sonde de santé, relance automatique, stock écarté) — bloc A, semaine 6
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b054888f5a
commit
f2649d0f8b
21 changed files with 938 additions and 28 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
79
docs/postmortems/2026-10-12-vm-figee-self-healing.md
Normal file
79
docs/postmortems/2026-10-12-vm-figee-self-healing.md
Normal 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.
|
||||||
93
docs/runbooks/self-healing.md
Normal file
93
docs/runbooks/self-healing.md
Normal 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.
|
||||||
25
portail/app/Console/Commands/SurveillerVm.php
Normal file
25
portail/app/Console/Commands/SurveillerVm.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Labs\SurveillanceVm;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class SurveillerVm extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'labs:surveiller-vm';
|
||||||
|
|
||||||
|
protected $description = 'Sonde les VM (stock et élèves) et répare celles qui ne répondent plus';
|
||||||
|
|
||||||
|
public function handle(SurveillanceVm $surveillance): void
|
||||||
|
{
|
||||||
|
$r = $surveillance->surveiller();
|
||||||
|
|
||||||
|
$this->line(sprintf(
|
||||||
|
'%d VM sondées, %d muettes ; stock : %d stack(s) retirée(s) ; élèves : %d VM relancée(s), %d irréparable(s)%s%s',
|
||||||
|
$r['sondees'], $r['muettes'], $r['retirees_stock'], $r['relancees'], $r['irreparables'],
|
||||||
|
$r['panne_generale'] ? ' ; PANNE GÉNÉRALE, aucune action' : '',
|
||||||
|
$r['correcteur_injoignable'] ? ' ; CORRECTEUR INJOIGNABLE, aucune action' : '',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,17 @@ class LabSessionMachine extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
241
portail/app/Services/Labs/SurveillanceVm.php
Normal file
241
portail/app/Services/Labs/SurveillanceVm.php
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Labs;
|
||||||
|
|
||||||
|
use App\Models\LabSession;
|
||||||
|
use App\Models\LabSessionMachine;
|
||||||
|
use App\Services\OpenStack\NovaClient;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-healing des VM (bloc A, semaine 6), appelé chaque minute par
|
||||||
|
* labs:surveiller-vm. Chaque VM est sondée par le correcteur (état libvirt
|
||||||
|
* et guest-ping de l'agent invité : rien n'est exécuté dans la VM).
|
||||||
|
*
|
||||||
|
* - VM du stock muette 2 minutes de suite : sa stack sort du stock et est
|
||||||
|
* supprimée ; labs:remplir-stock la remplace. Aucun élève ne la reçoit.
|
||||||
|
* - VM d'élève muette 3 minutes de suite : redémarrage forcé par Nova (ou
|
||||||
|
* démarrage si elle est éteinte), 2 fois par heure au plus ; au-delà, le
|
||||||
|
* prof voit « ne répond plus » dans la salle d'attente.
|
||||||
|
*
|
||||||
|
* Garde-fous : délai de grâce après la création (la VM démarre) ; aucune
|
||||||
|
* action si le correcteur est injoignable ou si toutes les VM sont muettes
|
||||||
|
* à la fois (panne de l'hôte, pas des VM : on ne redémarre pas tout).
|
||||||
|
*
|
||||||
|
* Clés Redis : sante:muette:{instance} (sondes ratées de suite, 10 min),
|
||||||
|
* sante:relances:{instance} (relances de l'heure), sante:compteurs (totaux
|
||||||
|
* pour Prometheus).
|
||||||
|
*/
|
||||||
|
class SurveillanceVm
|
||||||
|
{
|
||||||
|
// Une VM fraîchement créée démarre encore (≈ 50 s, 75 s au p95 sous charge).
|
||||||
|
public const GRACE_SECONDES = 180;
|
||||||
|
|
||||||
|
public const SEUIL_STOCK = 2;
|
||||||
|
|
||||||
|
public const SEUIL_SESSION = 3;
|
||||||
|
|
||||||
|
public const RELANCES_MAX_PAR_HEURE = 2;
|
||||||
|
|
||||||
|
// En dessous, « toutes muettes » peut être une vraie panne de ces VM.
|
||||||
|
private const MIN_VM_PANNE_GENERALE = 3;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ClientCorrecteur $correcteur,
|
||||||
|
private readonly NovaClient $nova,
|
||||||
|
private readonly StockChaud $stock,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{sondees: int, muettes: int, retirees_stock: int, relancees: int, irreparables: int, panne_generale: bool, correcteur_injoignable: bool}
|
||||||
|
*/
|
||||||
|
public function surveiller(): array
|
||||||
|
{
|
||||||
|
$rapport = ['sondees' => 0, 'muettes' => 0, 'retirees_stock' => 0, 'relancees' => 0, 'irreparables' => 0, 'panne_generale' => false, 'correcteur_injoignable' => false];
|
||||||
|
$limite = time() - self::GRACE_SECONDES;
|
||||||
|
|
||||||
|
$stacks = array_values(array_filter($this->stock->pretes(), fn ($s) => $s['cree_a'] < $limite));
|
||||||
|
$machines = LabSessionMachine::query()
|
||||||
|
->whereHas('labSession', fn ($q) => $q->where('etat', LabSession::ACTIF))
|
||||||
|
->where('created_at', '<', now()->subSeconds(self::GRACE_SECONDES))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// 1. Sonder tout, avant d'agir sur quoi que ce soit.
|
||||||
|
$etats = [];
|
||||||
|
try {
|
||||||
|
foreach ($stacks as $stack) {
|
||||||
|
foreach ($stack['instances'] as $instance) {
|
||||||
|
$etats[$instance] = $this->correcteur->sante($instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($machines as $machine) {
|
||||||
|
$etats[$machine->instance_id] = $this->correcteur->sante($machine->instance_id);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('Surveillance des VM : correcteur injoignable, aucune action', ['raison' => $e->getMessage()]);
|
||||||
|
$rapport['correcteur_injoignable'] = true;
|
||||||
|
$this->publierMetriques($rapport);
|
||||||
|
|
||||||
|
return $rapport;
|
||||||
|
}
|
||||||
|
|
||||||
|
$muettes = array_filter($etats, fn ($e) => ! $e['repond']);
|
||||||
|
$rapport['sondees'] = count($etats);
|
||||||
|
$rapport['muettes'] = count($muettes);
|
||||||
|
|
||||||
|
if (count($etats) >= self::MIN_VM_PANNE_GENERALE && count($muettes) === count($etats)) {
|
||||||
|
Log::critical("Surveillance des VM : les {$rapport['sondees']} VM sont muettes à la fois, panne de l'hôte probable : aucune action");
|
||||||
|
$rapport['panne_generale'] = true;
|
||||||
|
$this->publierMetriques($rapport);
|
||||||
|
|
||||||
|
return $rapport;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Compter les sondes ratées de suite, oublier celles qui répondent.
|
||||||
|
$suite = [];
|
||||||
|
foreach ($etats as $instance => $etat) {
|
||||||
|
if ($etat['repond']) {
|
||||||
|
Redis::del("sante:muette:{$instance}");
|
||||||
|
} else {
|
||||||
|
$suite[$instance] = (int) Redis::incr("sante:muette:{$instance}");
|
||||||
|
Redis::expire("sante:muette:{$instance}", 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Stock : une VM muette suffit à écarter toute la stack.
|
||||||
|
foreach ($stacks as $stack) {
|
||||||
|
$pire = max(array_map(fn ($i) => $suite[$i] ?? 0, array_values($stack['instances'])));
|
||||||
|
|
||||||
|
if ($pire >= self::SEUIL_STOCK && $this->stock->retirer($stack['lab'], $stack['brut'])) {
|
||||||
|
Log::warning("Surveillance des VM : stack {$stack['nom']} du stock muette depuis {$pire} min, retirée et remplacée");
|
||||||
|
Redis::hincrby('sante:compteurs', 'retraits_stock', 1);
|
||||||
|
foreach ($stack['instances'] as $instance) {
|
||||||
|
Redis::del("sante:muette:{$instance}");
|
||||||
|
}
|
||||||
|
$rapport['retirees_stock']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Sessions d'élèves.
|
||||||
|
foreach ($machines as $machine) {
|
||||||
|
$instance = $machine->instance_id;
|
||||||
|
|
||||||
|
if (($etats[$instance]['repond'] ?? false)) {
|
||||||
|
if ($machine->sante === LabSessionMachine::IRREPARABLE) {
|
||||||
|
$machine->update(['sante' => null, 'sante_a' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($suite[$instance] ?? 0) < self::SEUIL_SESSION) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->reparer($machine, $etats[$instance]['etat'])) {
|
||||||
|
$rapport['relancees']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rapport['irreparables'] = LabSessionMachine::query()
|
||||||
|
->whereHas('labSession', fn ($q) => $q->where('etat', LabSession::ACTIF))
|
||||||
|
->where('sante', LabSessionMachine::IRREPARABLE)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->publierMetriques($rapport);
|
||||||
|
|
||||||
|
return $rapport;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reparer(LabSessionMachine $machine, ?string $etat): bool
|
||||||
|
{
|
||||||
|
$instance = $machine->instance_id;
|
||||||
|
$cle = "sante:relances:{$instance}";
|
||||||
|
|
||||||
|
if ((int) Redis::get($cle) >= self::RELANCES_MAX_PAR_HEURE) {
|
||||||
|
if ($machine->sante !== LabSessionMachine::IRREPARABLE) {
|
||||||
|
Log::error("Surveillance des VM : {$machine->nom} ({$instance}) toujours muette après ".self::RELANCES_MAX_PAR_HEURE.' relances, le prof est prévenu');
|
||||||
|
Redis::hincrby('sante:compteurs', 'abandons', 1);
|
||||||
|
$machine->update(['sante' => LabSessionMachine::IRREPARABLE, 'sante_a' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($etat === 'shut off') {
|
||||||
|
$this->nova->demarrer($instance);
|
||||||
|
} else {
|
||||||
|
$this->nova->redemarrerForce($instance);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error("Surveillance des VM : relance de {$instance} refusée par Nova", ['raison' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Redis::incr($cle);
|
||||||
|
Redis::expire($cle, 3600);
|
||||||
|
Redis::del("sante:muette:{$instance}");
|
||||||
|
Redis::hincrby('sante:compteurs', 'relances_session', 1);
|
||||||
|
$machine->update(['sante' => LabSessionMachine::RELANCEE, 'sante_a' => now()]);
|
||||||
|
Log::warning("Surveillance des VM : {$machine->nom} ({$instance}, état « {$etat} ») relancée automatiquement");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Métriques pour Prometheus (collecteur textfile de node_exporter). Le
|
||||||
|
* fichier est créé par Ansible au nom de www-data ; une seule écriture
|
||||||
|
* courte, verrouillée.
|
||||||
|
*
|
||||||
|
* @param array<string, int|bool> $rapport
|
||||||
|
*/
|
||||||
|
private function publierMetriques(array $rapport): void
|
||||||
|
{
|
||||||
|
$fichier = config('labs.surveillance.metriques');
|
||||||
|
|
||||||
|
if (! $fichier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$compteurs = Redis::hgetall('sante:compteurs') ?: [];
|
||||||
|
$lignes = [
|
||||||
|
'# HELP syllabus_vm_surveillance_timestamp Dernier passage de la surveillance des VM.',
|
||||||
|
'# TYPE syllabus_vm_surveillance_timestamp gauge',
|
||||||
|
'syllabus_vm_surveillance_timestamp '.time(),
|
||||||
|
'# HELP syllabus_vm_sondees VM sondées au dernier passage.',
|
||||||
|
'# TYPE syllabus_vm_sondees gauge',
|
||||||
|
'syllabus_vm_sondees '.$rapport['sondees'],
|
||||||
|
'# HELP syllabus_vm_muettes VM dont l\'agent invité ne répond pas.',
|
||||||
|
'# TYPE syllabus_vm_muettes gauge',
|
||||||
|
'syllabus_vm_muettes '.$rapport['muettes'],
|
||||||
|
'# HELP syllabus_vm_irreparables VM d\'élèves toujours muettes après les relances.',
|
||||||
|
'# TYPE syllabus_vm_irreparables gauge',
|
||||||
|
'syllabus_vm_irreparables '.$rapport['irreparables'],
|
||||||
|
'# HELP syllabus_vm_panne_generale 1 si toutes les VM sont muettes à la fois (aucune action).',
|
||||||
|
'# TYPE syllabus_vm_panne_generale gauge',
|
||||||
|
'syllabus_vm_panne_generale '.(int) ($rapport['panne_generale'] || $rapport['correcteur_injoignable']),
|
||||||
|
'# HELP syllabus_vm_reparations_total Réparations automatiques depuis la remise à zéro de Redis.',
|
||||||
|
'# TYPE syllabus_vm_reparations_total counter',
|
||||||
|
'syllabus_vm_reparations_total{type="relance_session"} '.(int) ($compteurs['relances_session'] ?? 0),
|
||||||
|
'syllabus_vm_reparations_total{type="retrait_stock"} '.(int) ($compteurs['retraits_stock'] ?? 0),
|
||||||
|
'syllabus_vm_reparations_total{type="abandon"} '.(int) ($compteurs['abandons'] ?? 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
$flux = @fopen($fichier, 'c');
|
||||||
|
if ($flux === false) {
|
||||||
|
Log::warning("Surveillance des VM : métriques non écrites dans {$fichier}");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flock($flux, LOCK_EX);
|
||||||
|
ftruncate($flux, 0);
|
||||||
|
fwrite($flux, implode("\n", $lignes)."\n");
|
||||||
|
fflush($flux);
|
||||||
|
flock($flux, LOCK_UN);
|
||||||
|
fclose($flux);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,4 +30,35 @@ class NovaClient
|
||||||
|
|
||||||
return $reponse->json('remote_console.url');
|
return $reponse->json('remote_console.url');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redémarrage forcé (bloc A, self-healing) : équivalent d'un appui sur
|
||||||
|
* le bouton reset. Le disque de la VM est conservé.
|
||||||
|
*/
|
||||||
|
public function redemarrerForce(string $instanceId): void
|
||||||
|
{
|
||||||
|
$this->action($instanceId, ['reboot' => ['type' => 'HARD']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Démarre une VM éteinte (l'élève a lancé « poweroff »). */
|
||||||
|
public function demarrer(string $instanceId): void
|
||||||
|
{
|
||||||
|
$this->action($instanceId, ['os-start' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $corps
|
||||||
|
*/
|
||||||
|
private function action(string $instanceId, array $corps): void
|
||||||
|
{
|
||||||
|
$reponse = Http::withHeaders([
|
||||||
|
'X-Auth-Token' => $this->auth->jeton(),
|
||||||
|
'OpenStack-API-Version' => 'compute 2.99',
|
||||||
|
])
|
||||||
|
->post(config('openstack.nova_url')."/servers/{$instanceId}/action", $corps);
|
||||||
|
|
||||||
|
if ($reponse->failed()) {
|
||||||
|
throw new RuntimeException("Nova a refusé l'action ".array_key_first($corps)." sur {$instanceId} : ".$reponse->body());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ return [
|
||||||
'avance_minutes' => 10,
|
'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'),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bloc A, self-healing : dernier incident de santé d'une VM d'élève, montré
|
||||||
|
* au prof dans la salle d'attente (« relancée », « irréparable »).
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('lab_session_machines', function (Blueprint $table) {
|
||||||
|
$table->string('sante', 20)->nullable();
|
||||||
|
$table->timestamp('sante_a')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('lab_session_machines', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['sante', 'sante_a']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -53,6 +53,15 @@
|
||||||
@if ($labSession->etat === $etats::ECHEC && $labSession->raison_echec)
|
@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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
171
portail/tests/Feature/SurveillanceVmTest.php
Normal file
171
portail/tests/Feature/SurveillanceVmTest.php
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\LabSession;
|
||||||
|
use App\Models\LabSessionMachine;
|
||||||
|
use App\Services\Labs\ClientCorrecteur;
|
||||||
|
use App\Services\Labs\StockChaud;
|
||||||
|
use App\Services\Labs\SurveillanceVm;
|
||||||
|
use App\Services\OpenStack\NovaClient;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
|
||||||
|
// Redis en mémoire : juste les commandes dont SurveillanceVm a besoin.
|
||||||
|
beforeEach(function () {
|
||||||
|
Redis::swap(new class
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
public array $d = [];
|
||||||
|
|
||||||
|
public function incr($k)
|
||||||
|
{
|
||||||
|
return $this->d[$k] = (int) ($this->d[$k] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get($k)
|
||||||
|
{
|
||||||
|
return $this->d[$k] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function del($k)
|
||||||
|
{
|
||||||
|
unset($this->d[$k]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expire($k, $s) {}
|
||||||
|
|
||||||
|
public function hincrby($k, $c, $n)
|
||||||
|
{
|
||||||
|
$this->d[$k][$c] = ($this->d[$k][$c] ?? 0) + $n;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hgetall($k)
|
||||||
|
{
|
||||||
|
return $this->d[$k] ?? [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session d'élève active avec ses VM, créées il y a $ilYa secondes.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $instances nom => uuid
|
||||||
|
* @return array<string, LabSessionMachine>
|
||||||
|
*/
|
||||||
|
function sessionActive(array $instances, int $ilYa = 600): array
|
||||||
|
{
|
||||||
|
$session = sessionEnAttente(LabSession::ACTIF);
|
||||||
|
$machines = [];
|
||||||
|
foreach ($instances as $nom => $uuid) {
|
||||||
|
$m = $session->machines()->create(['nom' => $nom, 'instance_id' => $uuid]);
|
||||||
|
$m->forceFill(['created_at' => now()->subSeconds($ilYa)])->save();
|
||||||
|
$machines[$nom] = $m;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $machines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{repond: bool, etat: ?string}> $etats uuid => état
|
||||||
|
*/
|
||||||
|
function simuler(string $classe, Closure $attentes): void
|
||||||
|
{
|
||||||
|
app()->instance($classe, Mockery::mock($classe, $attentes));
|
||||||
|
}
|
||||||
|
|
||||||
|
function surveillance(array $etats, array $pretes = [], ?Closure $nova = null, ?Closure $stock = null): SurveillanceVm
|
||||||
|
{
|
||||||
|
simuler(ClientCorrecteur::class, fn ($c) => $c->shouldReceive('sante')->andReturnUsing(
|
||||||
|
fn (string $id) => ($etats[$id] ?? ['repond' => true, 'etat' => 'running']) + ['erreur' => null]
|
||||||
|
));
|
||||||
|
simuler(NovaClient::class, $nova ?? fn ($n) => $n->shouldNotReceive('redemarrerForce', 'demarrer'));
|
||||||
|
simuler(StockChaud::class, function ($s) use ($pretes, $stock) {
|
||||||
|
$s->shouldReceive('pretes')->andReturn($pretes);
|
||||||
|
$stock ? $stock($s) : $s->shouldNotReceive('retirer');
|
||||||
|
});
|
||||||
|
|
||||||
|
return app(SurveillanceVm::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
$muette = ['repond' => false, 'etat' => 'running'];
|
||||||
|
|
||||||
|
test('une VM d\'élève muette 3 minutes de suite est redémarrée de force, une seule fois', function () use ($muette) {
|
||||||
|
$m = sessionActive(['pc-a' => 'uuid-a', 'pc-b' => 'uuid-b', 'routeur' => 'uuid-r']);
|
||||||
|
$s = surveillance(['uuid-a' => $muette], nova: fn ($n) => $n->shouldReceive('redemarrerForce')->once()->with('uuid-a'));
|
||||||
|
|
||||||
|
expect($s->surveiller()['relancees'])->toBe(0)
|
||||||
|
->and($s->surveiller()['relancees'])->toBe(0)
|
||||||
|
->and($s->surveiller()['relancees'])->toBe(1);
|
||||||
|
|
||||||
|
expect($m['pc-a']->refresh()->sante)->toBe(LabSessionMachine::RELANCEE)
|
||||||
|
->and($m['pc-b']->refresh()->sante)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une VM éteinte par l\'élève est redémarrée, pas réinitialisée', function () {
|
||||||
|
sessionActive(['pc-a' => 'uuid-a', 'pc-b' => 'uuid-b', 'routeur' => 'uuid-r']);
|
||||||
|
$s = surveillance(['uuid-a' => ['repond' => false, 'etat' => 'shut off']], nova: fn ($n) => $n->shouldReceive('demarrer')->once()->with('uuid-a'));
|
||||||
|
|
||||||
|
foreach (range(1, 3) as $_) {
|
||||||
|
$s->surveiller();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('après deux relances dans l\'heure, la VM est déclarée irréparable et le prof le voit', function () use ($muette) {
|
||||||
|
$m = sessionActive(['pc-a' => 'uuid-a', 'pc-b' => 'uuid-b', 'routeur' => 'uuid-r']);
|
||||||
|
$s = surveillance(['uuid-a' => $muette], nova: fn ($n) => $n->shouldReceive('redemarrerForce')->twice());
|
||||||
|
|
||||||
|
foreach (range(1, 9) as $_) {
|
||||||
|
$r = $s->surveiller();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($r['irreparables'])->toBe(1)
|
||||||
|
->and($m['pc-a']->refresh()->sante)->toBe(LabSessionMachine::IRREPARABLE)
|
||||||
|
->and(Redis::hgetall('sante:compteurs'))->toBe(['relances_session' => 2, 'abandons' => 1]);
|
||||||
|
|
||||||
|
$session = $m['pc-a']->labSession;
|
||||||
|
$prof = $session->seance->classe->prof;
|
||||||
|
$this->withSession(['jeton_session_web' => $prof->demarreNouvelleSession()])
|
||||||
|
->actingAs($prof, 'web')
|
||||||
|
->get(route('seances.attente', $session->seance))
|
||||||
|
->assertSee('pc-a ne répond plus');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toutes les VM muettes à la fois : panne de l\'hôte, aucune relance', function () use ($muette) {
|
||||||
|
sessionActive(['pc-a' => 'uuid-a', 'pc-b' => 'uuid-b', 'routeur' => 'uuid-r']);
|
||||||
|
$s = surveillance(['uuid-a' => $muette, 'uuid-b' => $muette, 'uuid-r' => $muette]);
|
||||||
|
|
||||||
|
foreach (range(1, 4) as $_) {
|
||||||
|
$r = $s->surveiller();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($r['panne_generale'])->toBeTrue()->and($r['relancees'])->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une VM qui vient d\'être créée n\'est pas sondée (elle démarre)', function () use ($muette) {
|
||||||
|
sessionActive(['pc-a' => 'uuid-a'], ilYa: 60);
|
||||||
|
$s = surveillance(['uuid-a' => $muette]);
|
||||||
|
|
||||||
|
foreach (range(1, 4) as $_) {
|
||||||
|
$r = $s->surveiller();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($r['sondees'])->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une stack du stock dont une VM est muette 2 minutes est retirée du stock', function () use ($muette) {
|
||||||
|
$pretes = [
|
||||||
|
['lab' => '08-deux-lan-routeur', 'nom' => 'stock-a-e1', 'id' => 'h1', 'instances' => ['pc-a' => 's1', 'routeur' => 's2', 'pc-b' => 's3'], 'cree_a' => time() - 600, 'brut' => 'A'],
|
||||||
|
['lab' => '08-deux-lan-routeur', 'nom' => 'stock-b-e1', 'id' => 'h2', 'instances' => ['pc-a' => 's4', 'routeur' => 's5', 'pc-b' => 's6'], 'cree_a' => time() - 600, 'brut' => 'B'],
|
||||||
|
];
|
||||||
|
$s = surveillance(['s2' => $muette], $pretes, stock: fn ($st) => $st->shouldReceive('retirer')->once()->with('08-deux-lan-routeur', 'A')->andReturn(true));
|
||||||
|
|
||||||
|
expect($s->surveiller()['retirees_stock'])->toBe(0)
|
||||||
|
->and($s->surveiller()['retirees_stock'])->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('correcteur injoignable : aucune action', function () {
|
||||||
|
sessionActive(['pc-a' => 'uuid-a']);
|
||||||
|
$this->mock(ClientCorrecteur::class, fn ($c) => $c->shouldReceive('sante')->andThrow(new RuntimeException('Correcteur injoignable')));
|
||||||
|
$this->mock(NovaClient::class, fn ($n) => $n->shouldNotReceive('redemarrerForce', 'demarrer'));
|
||||||
|
$this->mock(StockChaud::class, fn ($s) => $s->shouldReceive('pretes')->andReturn([]));
|
||||||
|
|
||||||
|
expect(app(SurveillanceVm::class)->surveiller()['correcteur_injoignable'])->toBeTrue();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue