Paillasse/docs/journal.md
Adam Belkacemi 777dd1cbc8 ajoute la recherche plein texte dans les cours et labs (bloc C)
Elasticsearch interne (jamais exposé, sécurité x-pack désactivée,
cohérent avec le reste des services internes de ce serveur), tas JVM
fixé à 512 Mo plutôt que le calcul automatique (jusqu'à 50 % de la
RAM système, désastreux sur ce serveur à 31 Go réels). Appels HTTP
directs depuis Laravel, pas de bibliothèque cliente pour un besoin
aussi simple. Index reconstruit après chaque synchronisation du
catalogue, page de recherche commune aux profs et aux élèves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-14 13:24:13 +02:00

1276 lines
162 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Journal de bord — Syllabus (ex-Paillasse)
## 2026-10-07 — Étape 1 : reconnaissance du serveur
**Fait**
- Déblocage de l'accès SSH : la clé dédiée `~/.ssh/id_ed25519_claude` avait une passphrase sans invite interactive disponible dans Claude Code. Adam a retiré la passphrase lui-même (`ssh-keygen -p -f ~/.ssh/id_ed25519_claude`, nouvelle passphrase vide).
- ⚠️ Incident mineur : au premier essai, Adam a collé l'ancienne passphrase directement dans la conversation avec Claude Code au lieu de la taper dans le prompt du terminal. Elle n'a pas été utilisée ni répétée, et elle est devenue sans objet dès que la passphrase de la clé a été retirée. **Point d'attention pour la suite : ne jamais coller de secret dans le chat, toujours dans le terminal.**
- Alias `paillasse` créé dans `~/.ssh/config` (`163.172.4.195`, utilisateur `adam`, `IdentityFile ~/.ssh/id_ed25519_claude`, `IdentitiesOnly yes`).
- Reconnaissance en lecture seule via SSH : OS, noyau, CPU, virtualisation, RAM, disques/RAID, réseau, état actuel de SSH et du pare-feu, paquets de durcissement déjà présents ou non.
- Vérification dans la documentation officielle OpenStack (support matrix + page des séries) de la version de Kolla-Ansible à utiliser : **2025.2 (Flamingo)**, seule série récente qui supporte encore Debian 12 bookworm et qui est encore maintenue.
- Écriture de `docs/inventaire-serveur.md`.
**Commandes clés**
```bash
ssh -i ~/.ssh/id_ed25519_claude -o IdentitiesOnly=yes adam@163.172.4.195 ...
ssh paillasse "lscpu; ls -la /dev/kvm; cat /proc/mdstat; ip -br a; sudo nft list ruleset; sudo sshd -T"
```
**Problèmes rencontrés et solution**
- Connexion SSH refusée (`Permission denied (publickey,password)`) : la clé privée avait une passphrase, et l'environnement Claude Code n'a pas de terminal interactif pour la saisir (`ssh_askpass` absent). Solution : Adam a retiré la passphrase lui-même avec `ssh-keygen -p`.
**Comprendre**
Avant de changer quoi que ce soit, il fallait savoir précisément sur quoi on allait travailler : est-ce que la virtualisation matérielle marche vraiment (sans ça, pas d'OpenStack utilisable), le RAID est-il sain, combien de RAM reste disponible, et surtout quelle version de Kolla-Ansible est compatible avec Debian 12. C'est important car OpenStack évolue vite : la version la plus récente (2026.1) a justement arrêté de supporter Debian 12, donc l'installer aveuglément aurait bloqué tout le projet dès l'étape 5. On a aussi remarqué que le pare-feu est aujourd'hui totalement ouvert et que root peut encore se connecter par clé : ce sont exactement les deux points que l'étape 2 (durcissement) va corriger. Rien n'a été modifié sur le serveur à cette étape, uniquement des commandes de lecture.
**Suite**
Étape 2 : durcissement SSH + pare-feu nftables + fail2ban/chrony/unattended-upgrades, via un rôle Ansible depuis le Mac. C'est une étape qui touche SSH et le réseau : je vais détailler la commande avant de l'exécuter, prévoir un retour arrière automatique (anti-coupure), et ne redémarrer/couper rien sans l'accord explicite d'Adam pour les points couverts par la règle 3 du prompt de déploiement.
## 2026-10-07 — Étape 2 : durcissement du serveur
**Fait**
- Rôle Ansible `durcissement` (`ansible/roles/durcissement/`) + playbook `ansible/durcissement.yml`, appliqués après accord explicite d'Adam.
- Pare-feu nftables : tout refusé en entrée sauf loopback, trafic établi, ICMP, et les ports 22/80/443.
- SSH : `PermitRootLogin no`, `PasswordAuthentication no` (connexion par clé uniquement), via un drop-in `/etc/ssh/sshd_config.d/99-durcissement.conf`.
- `fail2ban` (jail `sshd`, backend `nftables` car pas d'`iptables` sur l'image), `chrony` (remplace `systemd-timesyncd`), `unattended-upgrades` (sécurité auto, **redémarrage automatique explicitement désactivé** — un reboot reste une décision d'Adam).
- Filet de sécurité anti-coupure : timer `systemd-run` de 5 minutes armé avant toute modification du pare-feu/SSH, script de retour à l'état précédent (`/usr/local/sbin/durcissement-rollback.sh`), désarmé seulement après vérification réussie depuis une connexion SSH toute neuve + `nmap`.
- Vérifié deux fois l'idempotence (rejouer le playbook ne change rien d'inattendu).
**Commandes clés**
```bash
cd ansible && script -q /dev/null ansible-playbook durcissement.yml -i inventory.ini
ssh paillasse "sudo systemctl list-timers | grep durcissement" # filet de sécurité
nmap -Pn -T4 --open 163.172.4.195
```
**Problèmes rencontrés et solution**
1. Bug dans le rôle : le `notify` pour recharger `sshd` manquait sur la tâche de dépôt de la config SSH → `sshd` jamais rechargé au 1er essai → le filet de sécurité s'est déclenché (normal, rien de cassé) → corrigé en ajoutant le `notify`.
2. Le test de vérification `nmap -p-` (65535 ports) était beaucoup trop lent face à une politique `drop` (chaque port fermé attend un timeout) → dépassait les 5 minutes du filet de sécurité → 2ᵉ déclenchement automatique du rollback (normal, rien de cassé) → corrigé en limitant le scan aux ports par défaut + `-T4` (4,5 s au lieu de plusieurs minutes).
3. **Incident réel : auto-bannissement par fail2ban.** Les tentatives volontairement ratées (mot de passe, root) de nos propres tests de vérification, répétées sur plusieurs runs rapprochés, ont dépassé le seuil `maxretry = 5` de fail2ban : l'IP du Mac d'Adam s'est fait bannir par son propre pare-feu. Résolu via la console web Scaleway (hors bande) avec `fail2ban-client set sshd unbanip`. Post-mortem complet : `docs/postmortems/2026-10-07-fail2ban-auto-ban.md`. Correctif : `maxretry` relevé à 10.
**Critère vérifié**
- Connexion par clé : OK. Mot de passe : refusé (`Permission denied (publickey)`, sans mention de `password` dans les méthodes offertes). Root : refusé. `nmap -Pn -T4 --open 163.172.4.195` depuis le Mac : seul le port 22 est ouvert (80/443 autorisés par le pare-feu mais rien n'écoute encore dessus — normal, le portail arrive à l'étape 8).
**Comprendre**
Durcir un serveur, c'est fondamentalement risqué pour l'accès qu'on a déjà : chaque changement de pare-feu ou de SSH peut potentiellement nous enfermer dehors. D'où le filet de sécurité (un minuteur qui annule tout automatiquement si on ne confirme pas que ça marche dans les 5 minutes) : il s'est déclenché deux fois pendant la mise au point, et les deux fois c'était le bon comportement — un bug ailleurs, pas un défaut du filet. L'incident fail2ban est différent et plus subtil : ce n'est pas le pare-feu qui nous a bloqués, c'est un service de protection qu'on vient d'installer qui a réagi à nos propres tests comme à une attaque. C'est un vrai piège classique en administration système, qui vaut la peine d'être raconté en entretien : on protège un système contre tout le monde, y compris soi-même si on ne fait pas attention au volume de ses propres tentatives.
**Suite**
Étape 3 : sauvegardes vers Dedibackup (archives `tar.zst` chiffrées `age`, rotation 7 quotidiennes + 4 hebdomadaires, timer systemd).
## 2026-10-07 — Étape 3 : sauvegardes vers Dedibackup
**Fait**
- Rôle Ansible `sauvegardes` (`ansible/roles/sauvegardes/`) + playbook `ansible/sauvegardes.yml`. Pas de risque réseau/pare-feu/SSH ici, donc appliqué directement sans attendre d'accord explicite (règle 3).
- Paire de clés **age** générée sur le serveur (`/root/.age/cle-sauvegardes.txt`, `600`) : la clé privée ne quitte jamais le serveur.
- Script `/usr/local/sbin/sauvegarde-dedibackup.sh` : archive `/etc`, `/home`, `/root` en `tar.zst`, chiffre en flux avec `age`, envoie en FTP vers Dedibackup avec `lftp`, duplique en hebdomadaire le dimanche, applique la rotation (7 quotidiennes + 4 hebdomadaires, suppression du surplus sur Dedibackup).
- Timer systemd `sauvegarde-dedibackup.timer` : tous les jours à 03:15 (±5 min).
- `docs/runbooks/sauvegardes.md` écrit (déclenchement manuel, liste, restauration, rappel sur la clé age).
**Commandes clés**
```bash
cd ansible && script -q /dev/null ansible-playbook sauvegardes.yml -i inventory.ini
ssh paillasse "sudo systemctl start sauvegarde-dedibackup.service"
# restauration testée : voir docs/runbooks/sauvegardes.md
```
**Problèmes rencontrés et solution**
- 1er envoi FTP : `530 Bad credentials`. Diagnostic sans jamais afficher les identifiants (longueur et forme des variables seulement). Cause probable : valeurs non protégées par des guillemets dans `/root/.dedibackup`, risque de troncature silencieuse par `bash` si le mot de passe contient `#`, `$` ou une espace. **Adam a corrigé le fichier lui-même** (jamais Claude, le fichier contient un secret). Après correction, l'envoi a réussi.
- Extraction de test avec un chemin `./etc/...` : échec (`Not found in archive`), car `tar` retire le `/` initial à la création (`etc/...`, sans `./`). Corrigé dans le runbook.
- `etc/os-release` n'a pas pu être lu après extraction : c'est un lien symbolique vers `../usr/lib/os-release`, hors du périmètre sauvegardé (`/etc`, `/home`, `/root` seulement) — comportement normal, pas un bug.
**Critère vérifié**
- Sauvegarde envoyée : `quotidienne-20261007.tar.zst.age`, confirmée présente sur Dedibackup (`lftp cls`).
- Fichier restauré avec succès dans `/tmp` : `etc/hostname` extrait et relu, contenu conforme (`sd-124443`). Dossier de test nettoyé après coup.
**Comprendre**
Chiffrer avant d'envoyer (plutôt que de compter sur un transport chiffré) a un avantage : Dedibackup n'a accès qu'à un FTP en clair, mais comme le contenu est déjà chiffré par `age` avant de partir, intercepter le transfert ne révèle rien d'utile. La contrepartie, c'est qu'il ne faut jamais perdre la clé privée `age` : sans elle, toutes les sauvegardes passées deviennent illisibles pour toujours, même si elles sont bien là sur Dedibackup. D'où le rappel à Adam de garder une copie de cette clé ailleurs que sur le serveur.
**⚠️ Reste à faire par Adam** : copier `/root/.age/cle-sauvegardes.txt` en dehors du serveur (gestionnaire de mots de passe, clé USB chiffrée…) — voir `docs/runbooks/sauvegardes.md`.
**Suite**
Étape 4 : réseau pour OpenStack (`dummy0`, paire veth, NAT). Touche le réseau → accord explicite d'Adam obligatoire avant d'appliquer, filet anti-coupure, et redémarrage du serveur seulement avec son accord.
## 2026-10-07 — Étape 4 : réseau pour OpenStack
**Fait**
- Rôle Ansible `reseau-openstack` + playbook `ansible/reseau-openstack.yml` (qui rejoue aussi `durcissement` pour le pare-feu et son filet anti-coupure). Appliqué après accord explicite d'Adam, puis redémarrage avec son accord.
- `dummy0` : 10.10.0.1/24, futur `network_interface` / `kolla_internal_vip_address` (Kolla sans HAProxy).
- Paire veth : `veth-ext-ovs` (sans IP, futur `neutron_external_interface`) ↔ `veth-ext-gw` (172.24.4.1/24, passerelle du réseau externe OpenStack).
- Persistance par `systemd-networkd`, limité à ces trois interfaces : `eno1` reste sous `ifupdown`, inchangé.
- `net.ipv4.ip_forward=1` (`/etc/sysctl.d/99-reseau-openstack.conf`).
- nftables : NAT masquerade de 172.24.4.0/24 vers `eno1` (`/etc/nftables.d/10-nat-openstack.conf`, inclus depuis `/etc/nftables.conf`). Chaîne `forward` resserrée à la demande d'Adam : seulement `veth-ext-gw → eno1` depuis 172.24.4.0/24, et les réponses `eno1 → veth-ext-gw` (`established,related`). Tout le reste est refusé ; rien n'entre depuis Internet vers les réseaux internes.
**Règles nftables finales (relues après redémarrage)**
```
chain forward { policy drop;
iifname "veth-ext-gw" oifname "eno1" ip saddr 172.24.4.0/24 accept
iifname "eno1" oifname "veth-ext-gw" ct state established,related accept
}
table ip nat { chain postrouting {
ip saddr 172.24.4.0/24 oifname "eno1" masquerade
} }
```
**Problèmes rencontrés et solution**
- 1er lancement : `Destination directory /etc/nftables.d does not exist`. Le dossier était créé par `durcissement`, qui tourne après `reseau-openstack`. Échec survenu avant toute modification du pare-feu. Corrigé en créant aussi le dossier dans `reseau-openstack`.
- Un lancement a été bloqué par le garde-fou de Claude Code parce que la règle `forward` avait été modifiée après l'accord d'Adam sans lui être remontrée. On a fait une simulation `--check --diff`, Adam a validé le diff, puis on a appliqué.
- Table `f2b-table` de fail2ban absente après rechargement du pare-feu et après redémarrage : c'est normal, fail2ban 1.0 ne la crée qu'au premier bannissement. Point encore à vérifier : `flush ruleset` à chaque rechargement efface une table de bannissement déjà créée. fail2ban devrait la recréer au bannissement suivant (vérification automatique de ses actions), mais ce n'est pas encore testé.
**Critère vérifié (après redémarrage à 11:37, SSH revenu à 11:39)**
- SSH répond, `dummy0` et la paire veth sont présents avec leurs IP, `ip_forward=1`.
- Internet joignable depuis le serveur (ping 9.9.9.9, résolution de `deb.debian.org`), route par défaut inchangée.
- Services actifs : ssh, nftables, fail2ban, chrony, systemd-networkd, timer de sauvegarde. RAID `[UU]` sur `md0` et `md1`.
- `nmap` depuis le Mac : seul le port 22 est ouvert.
**Comprendre**
OpenStack a besoin de deux réseaux en plus de l'accès public : un réseau interne pour que ses services se parlent, et un réseau « externe » pour que les routeurs virtuels puissent sortir. Sur un serveur qui n'a qu'une seule IP publique, mettre Neutron directement sur `eno1` reviendrait à lui confier notre seule porte d'entrée. On a donc créé des interfaces virtuelles : `dummy0`, une carte réseau factice qui porte une adresse privée stable pour les services, et une paire veth, qui fonctionne comme un câble virtuel dont un bout ira à Neutron et l'autre sert de passerelle. Le NAT fait sortir ce réseau externe vers Internet en se faisant passer pour l'IP publique. Le pare-feu n'autorise que ce sens-là, plus les réponses, si bien que personne ne peut entrer depuis Internet vers les réseaux internes. Le redémarrage prouve que tout cela est persistant, et pas seulement appliqué en mémoire.
**Suite**
Étape 5 : Kolla-Ansible 2025.2 tout-en-un. `bootstrap-servers` peut toucher au pare-feu et à Docker : vérifier avant, rejouer `durcissement` après, et demander l'accord d'Adam pour ce qui touche au pare-feu.
## 2026-10-07 — Étape 5 : OpenStack avec Kolla-Ansible 2025.2
**Fait**
- Rôle Ansible `kolla` + playbook `ansible/kolla.yml` : dépendances système, venv `/opt/kolla-venv` (Kolla-Ansible `stable/2025.2`, version `21.3.1.dev4`, ansible-core 2.19, client `openstack` 8.2.0, SDK `docker` et `dbus-python`), `/etc/kolla/globals.yml` et `all-in-one` copiés depuis le dépôt (`kolla/`), `passwords.yml` généré par `kolla-genpwd` **sur le serveur uniquement** (`600 root:root`, jamais dans Git), `install-deps`.
- `kolla/globals.yml` : images Debian bookworm (tag vérifié sur quay.io), API sur `dummy0`, réseau externe `veth-ext-ovs`, OVN, sans HAProxy, keepalived, ProxySQL, Horizon ni Cinder ; avec Heat, console série, KVM, Prometheus, Grafana. `disable_firewall: false`. Docker sans iptables ni ip6tables.
- Avant de lancer `bootstrap-servers`, j'ai lu son code installé localement pour savoir s'il touchait au pare-feu. Sur Debian, il ne fait qu'essayer de désactiver `ufw` (absent) et ne touche ni à nftables ni à l'heure.
- `bootstrap-servers`, `prechecks`, `deploy` (lancé comme service `systemd-run` pour survivre à une coupure SSH, 22 min), `post-deploy`.
- ADR `docs/adr/0001-kolla-2025-2-sur-debian-12.md`, runbook `docs/runbooks/openstack.md`.
- Correction fail2ban validée par Adam à l'étape 4 : le handler `recharger nftables` relance aussi fail2ban (handler `listen`), et une option `-e durcissement_forcer_rechargement=true` force un rechargement du pare-feu.
**Problèmes rencontrés et solution**
1. `install-deps` : `No such file or directory: 'ansible-galaxy'` → le `bin` du venv doit être dans le `PATH`.
2. **`bootstrap-servers` a modifié le pare-feu** (arrêt et accord d'Adam). Docker 29 a créé des tables `ip6 nat` et `ip6 filter` malgré `iptables: false` : depuis Docker 27, l'IPv6 a son propre réglage. Aucun impact sur la sécurité, car notre chaîne `forward` en `drop` l'emporte. Correction : `docker_custom_config: {ip6tables: false}`, puis un rechargement forcé du pare-feu sous filet anti-coupure. J'ai vérifié qu'un redémarrage de Docker ne recrée plus les tables.
3. `prechecks` : `No module named 'docker'` → Kolla utilise le Python du venv ; `docker` et `dbus-python` ajoutés au venv.
4. **1er `deploy` en échec** : MariaDB `Bind on TCP/IP port. Got error: 98: Address already in use`. ProxySQL écoute sur la VIP:3306, et sans HAProxy la VIP est l'IP du nœud, donc il occupait le port de MariaDB. Correction : `enable_proxysql: "no"`. Le conteneur et le volume `proxysql` ont été supprimés avec l'accord d'Adam. Le volume `proxysql_socket` est encore présent (non validé, à supprimer avec son accord). Au 2ᵉ `deploy`, MariaDB a démarré sans qu'il faille réinitialiser son volume.
**Critère vérifié**
- `openstack service list` : keystone, glance, nova, neutron, placement, heat, heat-cfn.
- `openstack compute service list` : nova-scheduler, nova-conductor, nova-compute `up`. Agents OVN (Controller Gateway, Metadata) vivants.
- `nmap` depuis le Mac sur les ports OpenStack (5000, 8774, 8775, 8778, 9292, 9696, 6080-6083, 3000, 9090, 9091, 9093, 9100, 3306, 5672, 15672, 11211, 6641, 6642, 16509…) + 1-1024 : **seul 22 est ouvert**.
- Pare-feu identique à la référence ; seule `f2b-table` (fail2ban) en plus.
- Correction fail2ban testée : bannissement de l'adresse de test `192.0.2.10` (plage réservée à la documentation), rechargement forcé du pare-feu ; `f2b-table` recréée et le bannissement toujours en place. Adresse débannie ensuite.
- 46 conteneurs ; RAM utilisée 11 Gio sur 31 (19 Gio disponibles) avant toute VM.
**Dettes notées**
- ~~Les bases OVN (`ovsdb-server`, 6641/6642) écoutent sur `0.0.0.0`~~ corrigé le 2026-10-07 (voir plus bas).
- Kolla est installé depuis une branche, pas un tag : figer la version pour la reproductibilité (ADR 0001).
**Correctifs du 2026-10-07 (suite), demandés par Adam**
- Volume Docker résiduel `proxysql_socket` supprimé (accord d'Adam), après `proxysql_socket` lui-même supprimé lors du correctif ProxySQL.
- **Écoute OVN restreinte à `10.10.0.1`.** Cause : `ansible/roles/ovn-db/tasks/bootstrap-db.yml` de Kolla fait `ovn-nbctl/ovn-sbctl set-connection ptcp:PORT:0.0.0.0` en dur à la création des bases, sans tenir compte de `api_interface`. Les ports de cluster (6643/6644) ne sont pas concernés : ils respectent déjà `api_interface_address`. Le pare-feu bloquait déjà ces ports depuis Internet (vérifié à l'étape 5), donc pas de faille réelle, mais la bonne pratique de défense en profondeur ne coûte rien à appliquer.
- Correctif codé dans `ansible/roles/kolla/tasks/corrections.yml` (idempotent : relit l'écoute actuelle avant de la changer, ignoré si OpenStack n'est pas encore déployé), importé à la fin de `ansible/roles/kolla/tasks/main.yml`, tag `corrections`.
- Vérifié : `ss -tlnp` montre les 4 ports OVN (6641-6644) sur `10.10.0.1` uniquement ; un second passage du playbook ne rapporte aucun changement (idempotence confirmée) ; les agents OVN et les services Nova restent `up` après coup.
**Comprendre**
Kolla-Ansible déploie OpenStack sous forme de conteneurs Docker : chaque service (Keystone, Nova, Neutron…) tourne dans son propre conteneur, configuré par Ansible à partir d'un seul fichier, `globals.yml`. Les deux incidents de cette étape viennent de valeurs par défaut prévues pour un vrai cluster de plusieurs serveurs. Docker crée des règles de pare-feu IPv6 pour ses réseaux, et ProxySQL suppose une adresse virtuelle distincte de celle du serveur. Sur un seul nœud, ces mécanismes ne servent à rien et se gênent entre eux. La leçon : lire le code de l'outil avant de le lancer, et comparer le pare-feu avant et après, plutôt que de faire confiance aux options. C'est ce qui a permis de repérer les tables Docker en quelques secondes.
**Suite**
Étape 6 : projet `paillasse`, utilisateur de service avec application credentials, image `paillasse-debian` (virt-customize), flavor `eleve.petit`, première VM et console série par WebSocket.
## 2026-10-07 — Étape 6 : première VM et console série
**Fait**
- Rôle Ansible `images` + playbook `ansible/images.yml` : installe `libguestfs-tools`, dépose `openstack/images/construire-paillasse-debian.sh` et `paillasse-contenus/07-sql/lycee.sql` sur le serveur.
- `paillasse-contenus/07-sql/lycee.sql` créé : tables `classes`, `eleves`, `notes` avec données d'exemple. Volontairement sans la vue `moyennes`, la table `clubs` ni l'utilisateur `prof` : c'est le travail demandé à l'élève.
- Projet OpenStack `paillasse`, utilisateur de service `paillasse-service` (mot de passe généré aléatoirement, jamais affiché, jeté après usage), *application credential* `portail-syllabus` (rôle `member`) enregistrée dans `/etc/kolla/clouds-paillasse.yaml` (`600 root:root`, jamais dans Git — `.gitignore` élargi à `clouds-*.yaml` et `*application-credential*.json`).
- Image `paillasse-debian` construite avec `virt-customize` depuis `debian-12-genericcloud-amd64.qcow2` officiel (somme SHA512 vérifiée) : `qemu-guest-agent`, getty sur `ttyS0`, utilisateur `eleve` (sudo, mot de passe `eleve`), outils réseau, `nginx`/`mariadb-server`/`frr` installés mais désactivés, `/opt/labs/07/lycee.sql`, `/opt/paquets/tree_*.deb`, **sans cloud-init** (retiré : inutile et seulement source de lenteur sur des réseaux de lab sans DHCP ni métadonnées). Importée dans Glance avec `hw_qemu_guest_agent=yes`, rendue `public` (sinon invisible aux autres projets).
- Flavor `eleve.petit` : 1 vCPU, 768 Mo, 5 Go.
- VM de test `vm-test-console` démarrée sur un réseau de test (`reseau-test`, sans DHCP, port security désactivée — même style que les futurs réseaux de lab), en ~25 s.
- Console série récupérée via `openstack console url show --serial`, testée avec un petit script Python (`openstack/images/test-console-serie.py`, module `websocket-client`) : connexion `eleve`/`eleve`, commande exécutée.
**Écart avec `paillasse-contenus/README.md`**
Le README liste `tree` parmi les outils préinstallés, mais le lab 02 demande explicitement à l'élève de l'installer lui-même depuis `/opt/paquets/` (sans Internet dans les VM). J'ai tranché en faveur du lab, qui a une vérification automatique dessus : `tree` n'est **pas** préinstallé, son `.deb` est dans `/opt/paquets/`. À signaler à Adam : `README.md` est à corriger (retirer `tree` de la liste des outils), ou le lab à revoir si c'était voulu.
**Problèmes rencontrés et solution**
1. `openstack user create --password-prompt` : `No terminal detected`, aucun TTY dans ce contexte. Remplacé par `--password` avec un mot de passe généré par `openssl rand`, jamais affiché ni réutilisé (l'authentification définitive passe par l'application credential).
2. `openstack application credential create -f json` : `KeyError: 'id'`. Le client renvoie des clés en majuscule (`ID`, `Secret`...), pas en minuscule. Corrigé.
3. `openstack server create --project paillasse` : `--project` n'existe pas sur cette sous-commande (la portée vient du token). Résolu en utilisant directement le cloud `paillasse-service` (application credential), déjà scopé au bon projet.
4. Image invisible pour `paillasse-service` : créée avec visibilité `shared` (nécessite une acceptation explicite par projet). Passée en `public`, cohérent avec un usage par tous les établissements plus tard.
5. Console série : la première tentative du script de test a échoué (`socket is already closed`) en envoyant des trames texte — nova-serialproxy attend des trames **binaires** (sous-protocole `binary`). Corrigé avec `ws.send_binary()`.
6. La console série est un flux continu, pas une session par connexion : un essai de login interrompu laisse le prompt au milieu d'une saisie pour la connexion suivante. Un script de test doit lire l'état actuel avant d'envoyer quoi que ce soit, pas supposer qu'il part d'un prompt frais.
**Critère vérifié**
- VM créée et `ACTIVE` en ~25 s.
- Script connecté au WebSocket de la console série : prompt `localhost login:` affiché, connexion réussie, commande `whoami && hostname && echo ---OK---` exécutée avec la sortie attendue (`eleve`, `localhost`, `---OK---`).
**Comprendre**
Une image de VM, ce n'est pas juste un système d'exploitation : c'est tout ce dont les labs ont besoin pour fonctionner sans accès Internet, préparé à l'avance une fois pour toutes. `virt-customize` permet de modifier une image disque sans jamais la démarrer, en manipulant directement son système de fichiers. Les *application credentials* sont le bon réflexe pour un compte de service : contrairement à un mot de passe, elles sont limitées à un projet et un rôle précis, et peuvent être révoquées sans toucher au compte lui-même. La console série, elle, prouve qu'on peut atteindre une VM même si son réseau est complètement cassé ou absent — exactement ce que demande CLAUDE.md pour l'espace de lab : « elle doit marcher même si l'élève casse le réseau de sa VM ».
**Nettoyage (accord d'Adam)**
- Correction de `paillasse-contenus/README.md` : `tree` retiré de la liste des outils préinstallés (confirmé par Adam : le lab 02 garde son intérêt pédagogique).
- VM `vm-test-console` et réseau `reseau-test` supprimés (accord d'Adam) : ils n'avaient servi qu'à la preuve de fonctionnement de la console série.
**Suite**
Étape 7 : supervision (Prometheus, Grafana, Uptime Kuma) et alertes, accessibles par tunnel SSH en attendant un nom de domaine.
## 2026-10-07 — Étape 7 : supervision et alertes
**Fait**
- Prometheus, Alertmanager, Grafana, node_exporter, cAdvisor, openstack-exporter, libvirt/mysqld/memcached-exporters étaient déjà déployés par Kolla depuis l'étape 5 (`enable_prometheus`/`enable_grafana: "yes"`), tous liés uniquement à `10.10.0.1` — jamais exposés sur `eno1` (déjà vérifié par nmap à l'étape 5, re-confirmé ici).
- Rôle Ansible `supervision` + playbook `ansible/supervision.yml` : vérifie la présence et les permissions (`600`) de `/root/.alertes-webhook` sans jamais afficher son contenu, déploie le relais Alertmanager → ntfy (`/usr/local/sbin/alertmanager-ntfy.py`, service systemd, `127.0.0.1:9099` uniquement), et Uptime Kuma (conteneur Docker, `127.0.0.1:3001`, volume nommé).
- `kolla/config/prometheus/alertes.rules` : 4 règles (`DisqueSature` > 80 %, `MemoireSaturee` > 90 %, `ServiceOpenStackArrete` via les métriques `openstack_nova_agent_state`/`openstack_neutron_agent_state`, `SauvegardeAncienne` > 26 h).
- `kolla/config/prometheus/prometheus-alertmanager.yml` : remplace le gabarit Kolla par défaut (receveur sans destination) par un webhook vers le relais local. Déposé via le mécanisme standard de Kolla (`node_custom_config/prometheus/...`), pas de template Jinja Kolla à casser.
- Collecteur "textfile" activé sur node_exporter (`prometheus_node_exporter_cmdline_extras`, `prometheus_node_exporter_extra_volumes` dans `globals.yml`) : `sauvegarde-dedibackup.sh` y écrit, de façon atomique, l'horodatage de la dernière sauvegarde réussie à chaque exécution.
**Problèmes rencontrés et solution**
- **Oubli répété (2 fois) : modifier un fichier du dépôt (`globals.yml`, le script de sauvegarde) sans le repousser sur le serveur avant de relancer Kolla.** Dans les deux cas, `kolla-ansible reconfigure` a tourné sans erreur mais sur l'ancienne configuration — aucun message ne le signale. Corrigé en relançant le playbook Ansible concerné avant `reconfigure`. Noté dans `docs/runbooks/supervision.md` comme piège à ne pas refaire.
- Mesure du mot de passe Alertmanager/Prometheus pour les tests `curl` : évité `python3-yaml` (absent du Python système) au profit d'un simple `awk` sur le format `clef: valeur` de `passwords.yml` ; jamais affiché, seulement sa longueur vérifiée une fois pour confirmer qu'il avait bien été lu.
**Critère vérifié**
- Alerte de test injectée via l'API d'Alertmanager (`POST /api/v2/alerts`), sans toucher à rien de réel sur le serveur. **Confirmé par Adam : notification ntfy bien reçue.** Testé aussi en contournant Alertmanager (requête directe au relais) : `HTTP 200`.
- Métrique de sauvegarde vérifiée de bout en bout : fichier textfile → `node_exporter` (`node_textfile_scrape_error 0`) → Prometheus (`/api/v1/query` renvoie la valeur).
- Règles d'alerte confirmées chargées dans Prometheus (`/api/v1/rules` : les 4 règles listées).
**Dette assumée, à trancher avec Adam**
Pas d'alerte « serveur injoignable » : toute la chaîne de supervision tourne sur le serveur qu'elle surveille, donc si le serveur tombe complètement, personne n'est prévenu par ce mécanisme. L'architecture d'origine (CLAUDE.md) prévoyait une sonde hors du serveur (Hetzner CX23) ; ce déploiement n'a qu'un seul serveur. Documenté dans `docs/runbooks/supervision.md`, pas construit : décision à prendre par Adam (qui commande et paie lui-même toute ressource supplémentaire).
**Comprendre**
La bonne nouvelle de cette étape, c'est que Kolla avait déjà fait le plus gros : Prometheus, Grafana et une demi-douzaine d'exportateurs tournaient depuis l'étape 5 sans qu'on ait rien demandé de spécial, juste en activant deux options. Le travail réel a surtout été de les relier à une vraie notification (Alertmanager ne sait parler qu'un format webhook générique, pas le format de ntfy, d'où le petit relais) et d'ajouter une alerte qui n'existe dans aucun exportateur standard : l'âge de la dernière sauvegarde. Le collecteur « textfile » de node_exporter est fait exactement pour ça — transformer n'importe quel script maison en métrique Prometheus, juste en écrivant un fichier texte à un endroit précis. La vraie leçon de l'étape, à retenir pour la suite : modifier un fichier dans le dépôt ne change rien tant qu'il n'est pas repoussé sur le serveur — une évidence une fois dite, mais qui s'oublie vite quand on va vite.
**Suite**
Étape 8 : portail Laravel (Blade, Vite, MariaDB/Redis pour l'app, modèles, connexion prof/élève, salle d'attente). Le dossier `design/` doit exister : à vérifier avant de commencer.
## 2026-10-07 — Étape 8 : portail Laravel
**Fait**
- Projet Laravel 13 créé dans `portail/` (développé en local sur le Mac : PHP 8.4/Composer/Node 22 y étaient déjà installés), plateforme Composer forcée à PHP 8.3 pour que les dépendances restent installables sur le serveur (PHP 8.3, via Sury). Pest 4 (Pest 5 exige PHP 8.4). `concurrently` retiré du starter (2 vulnérabilités critiques via `shell-quote`, et en réalité inutilisé : `php artisan dev` de Laravel 13 gère déjà le lancement parallèle nativement).
- Vues, routes, JS, CSS, images de `design/` repris tels quels dans `portail/resources` et `portail/public`. Toutes les pages vitrine (une cinquantaine) fonctionnent sans rien construire de plus.
- Modèles + migrations : `Etablissement`, `User` (+ `etablissement_id`, `jeton_session_actif`), `Classe` (code unique auto-généré), `Eleve` (identifiant unique, `code_secret` haché, `jeton_qr` à usage unique, `jeton_session_actif`), `Lab`, `Seance`, `LabSession`.
- Deux guards d'authentification (`config/auth.php`) : `web` (profs, email/mot de passe) et `eleve` (identifiant/code secret, sans e-mail — minimisation des données mineurs). `App\Models\Eleve` authentifiable via le trait `Illuminate\Auth\Authenticatable` et `getAuthPasswordName()`.
- **Session unique par compte** : trait `GereSessionUnique` (un jeton en base, recréé à chaque connexion) + middleware `VerifieSessionUnique` (`session.unique:web`/`session.unique:eleve`), qui déconnecte et prévient si le jeton de la session en cours ne correspond plus à celui, plus récent, enregistré en base.
- Connexion (`/connexion`, un seul écran, deux formulaires), inscription (`/inscription` : crée établissement + prof + première classe en une transaction), gestion de classe (`/mes-classes/...` : ajout rapide, import CSV, génération identifiant + code à 6 caractères + QR à usage unique via `bacon/bacon-qr-code` en SVG, fiches imprimables), salle d'attente de séance (`/seances/...`, rafraîchissement automatique, le prof admet).
- Service `GenerateurCompteEleve` : identifiant `prenom.initiale` (suffixe numérique si homonyme), code secret à 6 caractères sans caractères ambigus (`0/O`, `1/I/L`).
- 11 tests Pest (connexion prof, connexion élève, session unique × 2, création de classe × 3) : tous passent, base SQLite en mémoire.
- Déploiement : rôle Ansible `portail` (PHP 8.3 via Sury, Nginx, Composer avec vérification de signature officielle, Node 22 LTS via NodeSource, MariaDB+Redis de l'app en Docker Compose sur `127.0.0.1:3307`/`127.0.0.1:6380` — jamais sur `10.10.0.1`, réservé à Kolla), synchronisation du code par `rsync`, `.env` de production généré une seule fois (secrets jamais dans Git).
**Deux décisions de design tranchées par Adam**
1. Champ mot de passe ajouté au formulaire d'inscription (absent de la maquette) : sans lui, la connexion prof décrite dans le prompt n'aurait pas pu fonctionner sans serveur mail.
2. Écrans sans maquette (gestion des élèves, salle d'attente) : design minimal sobre, mêmes jetons visuels que le reste du site, pas de recherche d'effet « wow ».
**Problèmes rencontrés et solution**
1. `@auth` (sans argument) dans le layout partagé retournait `true` pour un **élève** connecté, affichant le menu du prof. Cause : le middleware `auth:eleve` appelle `Auth::shouldUse('eleve')`, ce qui change le *guard par défaut* pour le reste de la requête — `@auth` nu teste alors ce guard-là, pas forcément `web`. Corrigé en précisant partout `@auth('web')` / `auth('eleve')`.
2. Déploiement : j'ai modifié `kolla/globals.yml` et le script de sauvegarde dans le dépôt sans les repousser sur le serveur avant de relancer les outils correspondants (déjà rencontré à l'étape 7 — même piège, maintenant documenté deux fois).
3. `php artisan key:generate` échouait : lancé avant `composer install`, donc avant que `vendor/autoload.php` existe. Réordonné : code → composer install → clé/`.env` → npm → migrations.
4. `php artisan view:cache` échouait (« View path not found ») : `storage/framework/views` avait été exclu du `rsync` mais n'était créé par rien d'autre. Plus largement, **`storage/` et `bootstrap/cache/` ne doivent jamais être synchronisés depuis le poste de dev** (ce sont de l'état et du cache runtime, pas du code — un `rsync --delete` y effacerait un jour les travaux déposés par les élèves dans `storage/app/`). Corrigé : leur structure est créée une fois sur le serveur, propriété `www-data` dès le départ, et ils sont exclus en bloc du `rsync` (pas juste leurs sous-dossiers).
5. **Un handler Ansible qui notifie un autre handler ne s'est pas propagé avant la fin du play** : Nginx a continué de servir sa page par défaut malgré un déploiement sans erreur apparente. Corrigé en fusionnant les deux handlers (`nginx -t` puis `systemctl reload nginx`) en un seul. Détecté uniquement parce que le critère de fin a été vérifié sur le serveur réel, pas seulement sur le code local.
6. Vérification Playwright visuelle (demandée par le prompt) non faite : l'extension Chrome n'était pas connectée dans cette session. Vérification faite autrement : les mêmes fichiers Blade que `design/` sont repris sans changement de structure, et le flux complet a été vérifié par capture HTML (`curl`) côté connexion, tableau de bord et gestion de classe.
**Critère vérifié (sur le serveur réel, pas seulement en local)**
- 11/11 tests Pest passent (en local, avant déploiement).
- Un élève fictif (`lea.m`) s'est connecté sur `http://163.172.4.195/connexion` et a vu son tableau de bord (« Salut Léa », nom de sa classe) — données de test supprimées après coup (accord d'Adam).
- `nft list tables` après déploiement : toujours seulement `inet filter` + `ip nat` (+ `f2b-table`) — Nginx/PHP-FPM n'ont rien ajouté au pare-feu.
**Comprendre**
Cette étape a un piège intéressant pour la suite : Laravel a deux systèmes de comptes (profs et élèves), et un mécanisme Laravel conçu pour un seul système (« quel est l'utilisateur connecté ? ») suppose souvent qu'il n'y en a qu'un — le bug du menu qui affichait « Mes classes » à un élève connecté vient exactement de là. Chaque fois qu'on ajoutera un rôle (établissement, formateur, opérateur), il faudra se souvenir d'être explicite sur le guard, partout. L'autre leçon, plus générale : un déploiement qui se termine « sans erreur » ne veut pas dire que le résultat est correct — c'est en testant le vrai critère de fin sur le serveur réel (pas juste en relisant les logs Ansible) qu'on a trouvé le bug du handler Nginx.
**Suite**
Étape 9 : proxy console Go et xterm.js (jeton Redis à usage unique, relais WebSocket vers `nova-serialproxy`, lecture seule pour le prof).
## 2026-10-08 — Étape 9 : proxy console Go et xterm.js
**Fait**
- Vérifié avant d'écrire du code : l'appel Nova exact (`POST /servers/{id}/remote-consoles`) et le format d'authentification Keystone par *application credential* (`POST /v3/auth/tokens`, méthode `application_credential`), par observation directe (`openstack --debug`) et un test `curl` réel — pas de mémoire.
- Go 1.27.1 installé localement (Homebrew) pour développer `proxy-console/` sur le Mac, comme pour Laravel à l'étape 8. Déployé en compilant **sur le serveur** à partir du code source (pas de binaire committé), Go 1.27.1 installé là-bas depuis l'archive officielle (somme SHA256 vérifiée via le flux JSON officiel de go.dev, pas depuis un résumé de page).
- Service Go (`proxy-console/`, utilisateur système dédié sans shell, durci avec `ProtectSystem=strict`) : un `Hub` par VM (`instance_id`) maintient une seule connexion amont vers `nova-serialproxy` (elle n'en accepte qu'une), et duplique ce qu'il reçoit vers tous les navigateurs abonnés à cette VM. Seul l'abonné en rôle `ecriture` peut faire passer ses frappes ; les autres sont ignorées côté serveur.
- Côté Laravel : `NovaClient` (jeton Keystone mis en cache ~50 min), `JetonConsole` (écrit `console:{jeton}` dans Redis, 5 minutes, format lu par le proxy Go), `ConsoleController` (vérifie que l'élève est bien propriétaire de sa session de lab et admis ; que le prof est bien propriétaire de la séance).
- Nginx : `location /console/` relayée en WebSocket vers `127.0.0.1:8081`.
- Front : nouveau script `console-reelle.js` (vraie connexion WebSocket, xterm.js) — distinct de `paillasse-terminal.js` qui est un faux shell simulé pour la vitrine, explicitement prévu pour être remplacé en production. Intégré dans la salle d'attente élève (une fois admis, si une VM est associée) et dans une nouvelle page prof en lecture seule.
**Problèmes rencontrés et solution**
1. **VM de test créée dans le mauvais projet OpenStack** (`admin` plutôt que `paillasse`) : Nova renvoyait `404 Instance could not be found` à l'*application credential* du portail (scopée à `paillasse`), pas une erreur d'authentification. Recréée avec le cloud `paillasse-service`, comme à l'étape 6.
2. `.env` de production existait déjà depuis l'étape 8 et n'est jamais régénéré : les nouvelles variables `OS_*` devaient être ajoutées sans y retoucher le reste (`APP_KEY` en particulier). Ajouté via des tâches `lineinfile` idempotentes, séparées du template de première génération.
3. `go build`, lancé avec `$HOME` pointé sur le dossier de l'app, y crée `.cache/` et `go/pkg/mod/` appartenant à `root` : le `rsync` suivant (sans `become`) ne pouvait plus les supprimer. Exclus du `rsync`.
4. Premier test manuel de la console : « Invalid WebSocket Header ». Le script de test demandait le sous-protocole `binary`, nécessaire pour parler *directement* à `nova-serialproxy` (étape 6) mais pas à notre propre proxy, qui ne le renvoie pas — un vrai navigateur ne le demande pas non plus. Corrigé dans le script de test ; rien à changer côté proxy ni côté `console-reelle.js`.
**Critère vérifié**
- Une élève fictive a tapé `ip a` dans sa VM et vu le résultat, via le chemin complet navigateur → Nginx → proxy Go → `nova-serialproxy`.
- Un autre élève n'a pas pu obtenir de jeton pour cette session de lab (`403`).
- Vérification allant au-delà du critère minimal : prof et élève connectés en même temps sur la même VM — le prof voit ce que tape l'élève (duplication du flux), et sa propre tentative d'écrire n'apparaît jamais dans le flux renvoyé par la VM (protection côté serveur, pas seulement côté interface).
**Comprendre**
La vraie contrainte de cette étape, c'est qu'une console série ne supporte qu'un seul lecteur à la fois — c'est une caractéristique de QEMU/libvirt, pas un choix arbitraire. Tout l'intérêt du proxy Go est d'être le seul à parler à cette connexion fragile et unique, et de se comporter comme un standard téléphonique pour tout le monde d'autre : il décroche une fois, et fait écouter qui il veut sur la même ligne. C'est aussi pour ça que le jeton que porte le navigateur ne doit jamais révéler l'adresse réelle de la console : s'il fallait donner cette adresse directement à chaque élève, rien n'empêcherait d'en ouvrir une deuxième connexion et de faire planter la première.
**Suite**
Étape 10 : liaison Laravel ↔ OpenStack (client HTTP ou `php-opencloud/openstack`) et modèle Heat du lab 08 — poser la vraie mécanique qu'on a seulement simulée à la main à cette étape (associer une VM à une session de lab).
## 2026-10-08 — Étape 10 : liaison Laravel ↔ OpenStack et modèle Heat du lab 08
**Fait**
- `GenerateurTemplateHeat` : traduit un `lab.yaml` (format paillasse-contenus : réseaux + machines, chaque machine listant les réseaux auxquels elle est reliée, dans l'ordre) en un vrai gabarit Heat (HOT) — un réseau/sous-réseau Neutron par réseau du lab (`port_security_enabled: false`, `enable_dhcp: false` : pas de pare-feu réseau ni de DHCP, les élèves configurent eux-mêmes, aucune route vers l'extérieur), un port + une instance Nova par machine, les ports attachés dans l'ordre déclaré (c'est cet ordre qui décide si une interface sera `ens3` ou `ens4` dans la VM), une sortie Heat par machine pour récupérer son identifiant une fois la stack créée.
- `HeatClient` (créer/lire/supprimer une stack) et `KeystoneAuth` (authentification Keystone partagée, jeton mis en cache 50 min) : voir [ADR 0002](adr/0002-mot-de-passe-pour-heat.md), la vraie découverte de cette étape.
- Une stack Heat par session de lab (par élève, par tentative), pas par classe : `CreerStackLab` (job Redis, déclenché à l'admission de l'élève) génère le gabarit, crée la stack, attend `CREATE_COMPLETE` (jusqu'à 2 minutes), enregistre une `LabSessionMachine` (nom, identifiant d'instance) par machine du lab. `SupprimerStackLab` (job Redis, déclenché à l'expiration) supprime la stack et ses VM, et classe la session en `expire` ou `terminee` selon le cas.
- `ExpirerSessionsLab` (commande Artisan `labs:expirer-sessions`), planifiée chaque minute via `Schedule::command()` dans `routes/console.php` + un cron système qui appelle `artisan schedule:run`.
- Les deux jobs ont besoin d'un worker : service systemd `portail-worker` (`queue:work`), déployé et redémarré par Ansible à chaque déploiement de code (sans ce redémarrage, un worker déjà lancé continue de faire tourner l'ancien code en mémoire).
- `labs/` : nouveau dossier à la racine du dépôt, un sous-dossier par lab, copié par Ansible vers `/opt/labs` sur le serveur (distinct de `paillasse-contenus/`, qui reste la source éditoriale ; `labs/` est la copie de travail que lit le portail). Lab 08 (deux réseaux locaux reliés par un routeur, 3 machines) copié en premier.
- Salle d'attente élève et console prof passées à plusieurs terminaux (un onglet par VM de la session), avec un préfixe de route supplémentaire (`{labSessionMachine}`) pour identifier quelle VM ouvrir.
**Problèmes rencontrés et solution**
1. **La vraie découverte de l'étape** : création de stack Heat refusée (`500`, trace dans `heat-engine.log` : « Delegated tokens cannot manage trusts »). Le compte de service du portail utilisait une application credential (choix de l'étape 6) ; Heat a besoin de créer un trust Keystone pour gérer le cycle de vie d'une stack, et Keystone interdit par construction à un jeton déjà délégué (une application credential en est un) d'en déléguer un second. Vérifié avec un compte à mot de passe classique avant de rien changer en dur ; confirmé avec Adam ; appliqué partout (voir ADR 0002). Un seul mécanisme d'authentification désormais pour tout ce que le portail demande à OpenStack.
2. `openstack user set --password` avec le cloud `kolla-admin` et `OS_PROJECT_NAME=paillasse` → `401` : même limitation que l'étape 6 (le compte `admin` n'a pas de rôle sur le projet `paillasse`, donc pas de jeton scopé à ce projet). Pas réinvestigué, contourné en utilisant directement le cloud `paillasse-service`.
3. `sudo -u www-data php artisan tinker` échouait (« Writing to directory /var/www/.config/psysh is not allowed ») : `www-data` n'a pas de `$HOME` inscriptible. Contourné avec `HOME=/tmp` à chaque appel.
**Critère vérifié (sur le serveur réel)**
- Classe, élève, séance de test créés ; admission de l'élève sur le lab 08 → `CreerStackLab` exécuté en 26 s → stack `CREATE_COMPLETE`, 3 VM créées. `ip link show` dans chaque VM confirme la topologie exacte du `lab.yaml` : le routeur a 2 interfaces (`ens3`, `ens4`), les deux postes (`pc-a`, `pc-b`) n'en ont qu'une chacun, sur des réseaux différents.
- Les 3 terminaux se sont ouverts côté élève, chacun relié à la bonne VM.
- Vérification allant au-delà du critère minimal : expiration forcée (`expire_a` mis dans le passé) → `labs:expirer-sessions` → `SupprimerStackLab` exécuté en 1 s → stack et 3 VM confirmées absentes (`openstack stack list` / `server list` vides) → session classée `expire`. Le cycle de vie complet (création, usage, expiration automatique) fonctionne de bout en bout sans intervention manuelle.
**Comprendre**
La leçon de cette étape dépasse Heat : dans Keystone, un jeton porte une histoire (« comment a-t-il été obtenu ? »), pas seulement une identité et des droits. Une application credential produit un jeton marqué comme délégué ; peu importe les droits du compte derrière, ce jeton ne pourra jamais en déléguer un second — c'est une protection contre les chaînes de délégation, pas un détail de configuration qu'on pourrait activer. Avant d'ajouter un service OpenStack qui gère un cycle de vie (Heat aujourd'hui ; Octavia, Magnum plus tard), il faut vérifier s'il a besoin d'un trust — et donc d'un compte à mot de passe, jamais d'une application credential.
**Suite**
Étape 11 : stock chaud de VM (files Redis) et correction automatique (agent invité QEMU).
## 2026-10-08 — Entre les étapes 10 et 11 : questions d'Adam sur le compte de service
**Fait**
- `--unrestricted` testé sur le serveur réel : une application credential `--unrestricted` échoue avec la même erreur Keystone que la restreinte (`Delegated tokens cannot manage trusts`). Le mot de passe reste la seule option pour Heat. Ajouté à l'ADR 0002.
- Contrôle du compte `paillasse-service` : seul rôle `member` sur `paillasse` (`reader` en découle, rôle impliqué), mot de passe de 32 octets aléatoires, `clouds-paillasse.yaml` en `600 root:root`. Il manquait le runbook de rotation : écrit (`docs/runbooks/compte-service-openstack.md`) et testé par une vraie rotation. Le classifieur de permissions a bloqué la fin de la rotation (écriture de secrets) : Adam a lancé lui-même le playbook et l'effacement du cache, puis j'ai vérifié l'authentification sans afficher de secret.
- Défaut corrigé : un changement de `.env` par Ansible ne redémarrait pas le worker de file, qui aurait gardé l'ancien mot de passe en mémoire.
**Incident (de ma part)**
En créant l'application credential de test `--unrestricted`, j'ai demandé une sortie `-f json` qui a affiché son secret en clair, puis je l'ai écrit dans un fichier temporaire. La credential et le fichier ont été supprimés juste après le test : le secret est révoqué et sans valeur. Règle pour la suite : toute commande qui crée un secret doit l'envoyer directement dans un fichier `600` sur le serveur, sans jamais l'afficher, même pour un test.
## 2026-10-08 — Étape 11 : stock chaud et correction automatique
**Fait**
- **Fiabilité des créations** (demande d'Adam) : `FabriqueStack`, commune aux sessions et au stock. Un essai raté supprime sa stack et lève une exception ; Laravel relance le job 2 fois (après 15 puis 45 s), chaque essai sous son propre nom (`-e1`, `-e2`, `-e3`). Avant un essai, les restes des précédents sont supprimés (cas d'un processus tué). Après le 3ᵉ échec : session en `echec`, raison visible du prof, bouton « Réessayer ». L'admission est protégée contre le double clic par une mise à jour SQL conditionnelle.
- **Stock chaud** : une liste Redis de stacks prêtes par lab, prise atomique par un script Lua, cible = 1 stack par lab + élèves des séances qui commencent dans 10 min ou déjà ouvertes, plafond de capacité, ménage des stacks orphelines par comparaison avec Heat, disjoncteur après 3 échecs. Séances planifiables (date et heure, heure de Paris convertie en UTC) : le stock monte au nombre d'élèves 10 min avant. Détails et alternatives : ADR 0003.
- **Laravel Horizon 5.50** remplace le `queue:work` unique : superviseurs `sessions` (4 processus) et `stock` (2), tableau de bord fermé. Timeouts emboîtés : job 280 s < superviseur 300 s < `retry_after` 330 s (avant : 90 s, un job long aurait été exécuté deux fois).
- **Correcteur** (Go, choix d'Adam) : service systemd root, groupe `www-data`, socket Unix `0660`, identité du client contrôlée par `SO_PEERCRED`. N'accepte que `{instance, lab, verification}` (champs en plus refusés), lit la commande dans le `lab.yaml`, vérifie dans les métadonnées libvirt que la VM appartient au projet `paillasse` et correspond à la machine visée, exécute par `guest-exec` via `docker exec nova_libvirt virsh` (10 s max). Ne renvoie jamais la sortie de la commande, seulement réussi/échoué et un code d'erreur. Installation de Go sortie dans un rôle `go` partagé avec le proxy console.
- **Côté élève** : bouton « Vérifier mon travail », note, détail par vérification avec le `message_echec` du lab (ou un message technique si la VM ne répond pas). La page d'attente interroge l'état chaque seconde au lieu de se recharger toutes les 5 s. Côté prof : note dans la salle d'attente.
- 7 nouveaux tests Pest (18 au total), 4 tests Go.
**Calcul de la capacité (`LABS_CAPACITE_VM` = 15)**
- Mesuré sans VM : 15,5 Go utilisés par Kolla et le portail sur 32 Go, 16,5 Go disponibles.
- Mesuré avec les 3 VM d'une stack de stock au repos : 260 à 290 Mo réels par VM (sur 768 Mo alloués).
- Pire cas retenu (chaque VM utilise toute sa RAM) : (16,5 Go − 2 Go de marge pour l'hôte) / (768 Mo + ≈ 200 Mo de surcoût QEMU) ≈ 15 VM. Au repos, le serveur en tiendrait trois fois plus : décider d'un surengagement (et de KSM) sera le sujet de l'étape 13, mesures en charge à l'appui. Stock par défaut : 1 stack par lab (3 VM pour le lab 08, 20 % du plafond).
**Commandes clés**
```
cd ansible && script -q /dev/null ansible-playbook correcteur.yml -i inventory.ini # portail + correcteur
ssh paillasse "cd /var/www/portail && sudo -u www-data HOME=/tmp php8.3 artisan labs:remplir-stock"
ssh paillasse "journalctl -u correcteur -f"
```
**Problèmes rencontrés et solution**
1. **La première touche frappée coupait la console** (défaut de l'étape 9) : `nova-serialproxy` ferme la connexion à la première trame WebSocket texte, et xterm.js envoie du texte. Le test de l'étape 9 envoyait du binaire et ne l'avait pas vu. Le proxy envoie désormais tout en binaire. Post-mortem : `docs/postmortems/2026-10-08-console-trames-texte.md`.
2. **Aucun bouton pour qu'un élève rejoigne une séance** (défaut de l'étape 8, masqué aux étapes 9 et 10 où la session était créée en base) : ajouté au tableau de bord élève.
3. **« Se déconnecter » renvoyait 419** : les deux formulaires du layout n'avaient pas de `@csrf`. Corrigé, et un test vérifie la présence du jeton (Laravel désactive le contrôle CSRF pendant les tests, il fallait tester le HTML).
4. Une VM du stock démarrée depuis longtemps n'affiche rien sur sa console série : option `reveil` dans `console-reelle.js`, qui envoie un retour chariot à la connexion (côté élève seulement).
5. Les séances n'avaient pas d'heure de début : colonne `prevue_a` et formulaire de planification.
6. Larastan (PHPStan niveau 6, exigé par le CLAUDE.md) n'est pas installé depuis l'étape 8 : dette notée, pas traitée ici.
7. Durée : l'étape a dépassé 1 h 30 (trois défauts d'étapes précédentes trouvés et corrigés en route), sans blocage.
**Critère vérifié (sur le serveur réel, `portail/tests/Reels/parcours-lab.py`, joué comme deux navigateurs)**
```
1. Admission → invite de connexion : 4.06 s (OK, critère < 5 s)
machines ouvertes : pc-a, routeur, pc-b
2. Sans routage activé : « Pas encore réussi » 6/10 (vérification en 5.8 s)
- Le routeur ne transmet pas les paquets : regarde net.ipv4.ip_forward.
- PC-A ne joint pas PC-B. Vérifie les adresses, les passerelles et le routage, dans cet ordre.
- La configuration du routeur disparaîtra au redémarrage (sysctl et fichier d'interfaces).
3. Routage activé : « Réussi » 10/10 (vérification en 4.7 s)
```
- Une seule mesure du temps d'ouverture, et pessimiste (le script récupère les 3 consoles l'une après l'autre, le navigateur en parallèle). Le p95 sur une série reste à établir à l'étape 13.
- Échec provoqué (lab temporaire dont la stack ne peut pas se construire) : `heat-engine.log` montre `lab-session-5-e1`, `-e2`, `-e3` chacune `CREATE FAILED` puis `DELETE COMPLETE` ; session en `echec` 75 s après l'admission, aucune stack restante. Le stock du même lab : disjoncteur ouvert après les échecs, plus aucune commande.
- Correcteur interrogé directement : vérification légitime exécutée ; machine non conforme, uuid hors projet, chemin `../../etc`, champ `commande` ajouté et vérification inconnue tous refusés ; un utilisateur autre que `www-data` ou root ne peut pas ouvrir le socket.
- Données, lab temporaire et fichiers de test supprimés (accord d'Adam). État final : une stack de stock pour le lab 08, rien d'autre.
**Comprendre**
Le stock chaud transforme une attente de 30 s en une attente de 4 s en faisant le travail lent *avant* que quelqu'un en ait besoin — comme un restaurant qui prépare ses fonds de sauce le matin. La difficulté n'est pas de construire d'avance, c'est de ne jamais donner deux fois la même chose ni de perdre ce qu'on a construit : d'où le script Lua (Redis exécute « retirer de la liste et marquer comme en cours d'attribution » d'un seul bloc) et le ménage qui compare Redis à Heat. Côté correction, la règle de sécurité est simple et non négociable : le portail désigne une vérification par son nom, il n'envoie jamais de commande ; le correcteur, seul à toucher les VM, ne fait confiance qu'au `lab.yaml` et vérifie même que la VM désignée est bien celle du lab. Enfin, cette étape montre l'intérêt d'un test qui imite vraiment l'utilisateur : trois défauts des étapes précédentes, invisibles pour les tests « techniques », sont apparus dès qu'un script a joué le parcours comme un navigateur.
**Suite**
Étape 12 : domaine, HTTPS et sécurité. **Arrêt obligatoire avant de commencer** : Adam doit me donner le domaine (il l'achète et configure le DNS lui-même).
## 2026-10-09 — Correctif : Uptime Kuma injoignable par le tunnel SSH
**Symptôme** (signalé par Adam) : conteneur `uptime-kuma` « healthy », mais rien n'écoute sur `127.0.0.1:3001` ; le tunnel SSH donne « connexion refusée ».
**Cause** : depuis l'étape 5, Docker tourne avec `"bridge": "none"` et `"iptables": false` pour ne jamais toucher au pare-feu. Le conteneur était rattaché au pont par défaut, qui n'existe pas : pas de réseau, et le `ports: 127.0.0.1:3001:3001` du rôle n'a jamais été publié (`docker inspect` : ports demandés présents, ports effectifs vides). Le contrôle de santé de l'image interroge le localhost du conteneur, d'où le « healthy » trompeur. Le défaut date de l'étape 7 : sa vérification avait porté sur l'état du conteneur, pas sur l'écoute côté hôte.
**Correctif** (rôle `supervision`) : `network_mode: host` et écoute forcée sur `127.0.0.1` par `UPTIME_KUMA_HOST` / `UPTIME_KUMA_PORT` (noms vérifiés dans `server/config.js` de l'image). Plus de publication Docker, ni NAT ni règle de pare-feu. Le rôle vérifie désormais à chaque passage que le port écoute et uniquement sur `127.0.0.1` (échec du playbook sinon). Données conservées (volume `uptime-kuma-data`).
**Vérifié** : `ss -tlnp` → `LISTEN 127.0.0.1:3001 users:(("node",...))` et rien d'autre ; `curl http://127.0.0.1:3001/` → 302 (redirection vers la page d'accueil de Kuma) ; port 3001 fermé sur l'IP publique depuis le Mac ; tables nftables inchangées ; second passage du playbook `changed=0`.
**Comprendre** : « healthy » veut dire que le conteneur répond *à lui-même*, pas qu'il est joignable de l'extérieur. Le seul test qui prouve qu'un service est accessible, c'est de s'y connecter par le chemin réel (ici : écoute sur l'hôte, puis tunnel). En réseau hôte, le conteneur partage la pile réseau du serveur : l'adresse d'écoute devient la seule barrière, d'où la vérification automatique ajoutée au rôle.
## 2026-10-09 — Étape 12 : domaine, HTTPS et sécurité
**Fait**
- DNS vérifié : `syllabuslab.fr` et `www` → `163.172.4.195`, pas d'AAAA ni de CAA.
- **Certificat Let's Encrypt** (certbot 2.1 du dépôt Debian, `certonly` avec l'authentificateur nginx, sans e-mail) : `syllabuslab.fr` + `www`, valable jusqu'au 2027-01-07. Le rôle `portail` sert d'abord le site en HTTP avec les bons noms, obtient le certificat, puis passe le vhost en HTTPS ; aux passages suivants, certbot ne fait rien tant que le certificat est valide. Renouvellement par `certbot.timer`, rechargement de Nginx en crochet.
- **Alerte d'expiration** : sans e-mail Let's Encrypt, c'est Prometheus qui prévient (métrique publiée chaque jour par le collecteur textfile, règles `CertificatExpireBientot` à moins de 14 jours et `CertificatNonMesure`).
- **TLS** : recommandations Mozilla v6.0 « intermediate », lues à la source (TLS 1.2/1.3, suites ECDHE), sans `X25519MLKEM768` (OpenSSL 3.0) ni OCSP stapling (abandonné par Let's Encrypt). HTTP/2.
- **Redirections** : HTTP → HTTPS, `www` → domaine principal, IP → domaine.
- **En-têtes** (Nginx) : HSTS 2 ans `includeSubDomains`, `nosniff`, `X-Frame-Options DENY`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `server_tokens off`. Cookie de session `secure; httponly; samesite=lax`, `APP_URL` en HTTPS.
- **CSP en deux niveaux** (Laravel, ADR 0004) : stricte avec nonce sur l'application, adaptée sur la vitrine. xterm.js est maintenant servi par le portail (Vite) au lieu de jsDelivr. Point de collecte `/csp-rapport`.
- **Exposition** (choix d'Adam) : Grafana et Uptime Kuma restent accessibles par tunnel SSH seulement. Page de statut publique prévue sur `status.syllabuslab.fr`, filtrée par Nginx (seuls la page de statut et son API en lecture passent). Elle s'activera toute seule au passage du rôle quand le DNS existera.
- `docs/dette.md` créé : la dette éparpillée dans le journal est regroupée, avec la demande d'Adam (figer l'image Uptime Kuma et la mettre à jour par Ansible).
- 5 nouveaux tests Pest (23 au total) : CSP stricte et adaptée, nonce sur chaque script, rapport sans CSRF, toutes les pages publiques en 200.
**Problèmes rencontrés et solution**
1. **Faille XSS dans la page de classe** (étape 8) : `onsubmit="return confirm('… {{ $eleve->prenom }} …')"`. Blade échappe l'apostrophe en `&#039;`, que le navigateur décode avant d'exécuter le JavaScript : un prénom importé par CSV comme `x');alert(1);//` s'exécutait chez le prof. Corrigé (attribut `data-confirmer` + écouteur), et la CSP stricte bloquerait désormais ce genre de script.
2. **Page `/cours` en erreur 500 depuis l'étape 8** : la maquette `design/` elle-même a perdu la boucle et les données des cursus lors de sa refonte (3 `@foreach` pour 4 `@endforeach`), et `config/cours.php` n'avait pas été copié. Rétabli d'après `design/_backup-ludique` (sans toucher à `design/`). Le journal de l'étape 8 affirmait que « toutes les pages vitrine fonctionnent » : faux pour celle-ci. Un test ouvre maintenant toutes les pages publiques ; vérifié qu'il échoue sur l'ancienne version.
3. **NodeSource relancé en root à chaque déploiement** : la condition `creates` visait `nodesource.list`, alors que le script crée `nodesource.sources`. Corrigé ; remplacement par un dépôt déclaratif noté en dette.
4. Le scan depuis le Mac montrait 21, 554 et 1723 ouverts : c'est la box d'Adam (même résultat vers `192.0.2.1`, adresse inexistante). Côté serveur, rien n'écoute et nftables n'accepte que 22/80/443.
5. Vérification dans un vrai navigateur impossible (extension Chrome non connectée) : la CSP est surveillée par ses rapports ; parcours manuel demandé à Adam.
6. Ma commande de contrôle des en-têtes a affiché la valeur d'un cookie de session anonyme (ouvert par `curl`, sans connexion, donc sans valeur) ; le filtre de masquage visait un mauvais nom de cookie.
**Critère vérifié**
```
subject=CN = syllabuslab.fr ; issuer=Let's Encrypt (YE2) ; notAfter=Jan 7 07:21:22 2027 GMT
https://syllabuslab.fr -> HTTP 200, TLS vérifié: 0 (0 = OK), HTTP/2
http://163.172.4.195/ -> 301 https://syllabuslab.fr/ ; https://www.syllabuslab.fr/cours -> 301 https://syllabuslab.fr/cours
TLS 1.1 : refusé ; TLSv1.3, TLS_AES_256_GCM_SHA384
certbot renew --dry-run : all simulated renewals succeeded
Prometheus : CertificatExpireBientot:ok, CertificatNonMesure:ok ; jours restants : 90.0
```
Parcours complet par `https://syllabuslab.fr`, consoles en `wss://` :
```
1. Admission → invite de connexion : 4.35 s (OK, critère < 5 s)
2. Sans routage activé : « Pas encore réussi » 6/10, avec les messages du lab
3. Routage activé : « Réussi » 10/10
```
- Ports : depuis Internet, seuls 22, 80 et 443 répondent pour le serveur ; les ports internes (Grafana, Prometheus, Kuma, API OpenStack, proxy console, MariaDB, Redis) sont tous filtrés.
- VM d'élèves : aucune IP flottante, aucun routeur Neutron dans le projet ; depuis `pc-a`, Internet et l'hôte sont injoignables (« Network is unreachable »).
**Comprendre**
HTTPS ne se résume pas au cadenas : le certificat prouve l'identité du site, HSTS interdit au navigateur de revenir en HTTP, et la CSP décide quels scripts ont le droit de s'exécuter. Cette dernière est la vraie protection contre l'injection de code : même si un prénom piégé arrivait dans une page, le navigateur refuserait de l'exécuter faute du nonce, ce jeton aléatoire que seul le serveur connaît pour chaque page. La faille trouvée dans la page de classe montre pourquoi : l'échappement de Blade protège le HTML, mais pas du JavaScript placé dans un attribut — deux langages imbriqués, deux règles d'échappement. Enfin, le renouvellement automatique n'est fiable que s'il est surveillé : d'où l'alerte Prometheus, puisque plus personne n'enverra d'e-mail avant l'expiration.
**Suite**
- Adam : enregistrement DNS `status`, page de statut `syllabus` dans Kuma, sonde du portail passée en `https://syllabuslab.fr`, parcours manuel dans un navigateur (pour la CSP).
- Étape 13 : tests de capacité et de résistance.
## 2026-10-09 — Étape 12 (suite) : site de statut activé
- DNS `status` d'abord créé sous `status.syllabuslab.fr.syllabuslab.fr` (le champ « Sous-domaine » d'OVH n'attend que `status`) ; corrigé par Adam. Une sonde nommée « syllabus » avait été créée à la place de la page de statut : Kuma 1.23 ne répond alors jamais à l'API de la page (d'où `proxy_read_timeout 15s` côté Nginx). Runbook `https.md` précisé.
- Le rôle a détecté le nom, étendu le certificat (`DNS:status.syllabuslab.fr, DNS:syllabuslab.fr, DNS:www.syllabuslab.fr`) et activé le site filtré.
- Vérifié depuis le Mac : `/` → 302 vers `/status/syllabus` ; `/dashboard`, `/manage-status-page`, `/settings/general`, `/metrics`, `/socket.io/…`, `/api/push/…` → 404 ; `POST /api/status-page/syllabus` → 403 ; HTTP → 301 vers HTTPS. `/status/syllabus` → 404 renvoyé par Kuma lui-même (page pas encore créée), pas par le filtre.
- Reste (Adam) : créer la page de statut `syllabus` dans Kuma, supprimer la sonde en double « syllabus ».
- Page de statut créée par Adam (slug `syllabus`, publiée). Vérifié depuis le Mac par `https://status.syllabuslab.fr` : page, API, pulsations, scripts, CSS et icône en 200. Seul `/docker-latest-warning.js` est bloqué (404) : script que Kuma injecte dans ses images « historiques » `latest`/`1`, actif seulement sur `/setup`, inutile au public — et argument de plus pour figer l'image (dette).
## 2026-10-09 — Étape 13 : tests de capacité et de résistance
**Fait**
- **Constats avant de tester** (valeurs par défaut de Nova lues dans son code installé, pas de mémoire) : `reserved_host_memory_mb = 512` (Nova se croyait libre de distribuer 31,5 Go alors que Kolla en occupe 15,5 : il aurait accepté ~41 VM et l'hôte aurait manqué de mémoire avant tout refus), `resume_guests_state_on_host_boot = false` (après un redémarrage, toutes les VM — dont le stock chaud — seraient restées éteintes), quota du projet à 10 instances (défaut OpenStack : une classe aurait été bloquée bien avant la capacité réelle).
- **Surcharge Nova** versionnée (`kolla/config/nova/nova-compute.conf`, déposée par le rôle `kolla`) : reprise des VM au démarrage, réserve mémoire. **Rôle `openstack-projet`** : gabarit `eleve.petit` et quota (40/40/30 Go) rejouables.
- **Test de capacité** (`openstack/tests/capacite.py`, en service systemd) : 20 VM lancées en même temps, puis repos, CPU à 100 %, mémoire chargée. Résultats et décisions : `docs/capacite.md`. **Capacité retenue par Adam : 22 VM** (Nova et portail alignés ; Placement confirme 16 993 Mo distribuables = 22 VM).
- **Sauvegarde complétée** : la base du portail (`mariadb-dump --single-transaction`), son `.env` (APP_KEY) et `storage/app` n'étaient **pas sauvegardés** (la sauvegarde date de l'étape 3, le portail de l'étape 8). Le mot de passe MariaDB est lu dans le conteneur, jamais sur la ligne de commande de l'hôte.
- **Test de restauration** (`/usr/local/sbin/syllabus-test-restauration`, chaque lundi 04:30, alerte `RestaurationNonTestee` au-delà de 8 jours) : télécharge, déchiffre, restaure la base dans un conteneur jetable, fait tourner le code du portail contre la copie, compare avec la production.
- **Redémarrage du serveur** (accord d'Adam) avec relevé d'état avant/après (`/root/etat-serveur.sh`).
- Unité systemd `kolla-proxysql-container` en échec depuis le 7 octobre (ProxySQL désactivé à l'étape 5, unité restée activée) : retirée par le rôle `kolla` (accord d'Adam), rangée dans `/root/unites-retirees/`. `systemctl --failed` : 0.
**Problèmes rencontrés et solution**
1. Le playbook `kolla` a perdu sa connexion SSH pendant une tâche longue (« Shared connection closed ») : ni bannissement fail2ban ni panne ; relancé, il est allé au bout. Les `kolla-ansible reconfigure` sont désormais lancés en service systemd (`systemd-run`), insensibles à une coupure SSH.
2. **Mesure du temps d'ouverture trompeuse** : après le redémarrage, le parcours a donné 5,51 s (critère < 5 s). Décomposition : le script demandait les 3 jetons de console l'un après l'autre (≈ 1,1 s chacun) alors que le navigateur les demande en parallèle. Script corrigé ; 3 mesures réelles fidèles au navigateur : 2,80 s, 2,85 s, 2,85 s. Les 4,06 et 4,35 s des étapes 11 et 12 étaient donc pessimistes aussi.
3. Ma boucle d'attente du retour des services ne sortait jamais (sous zsh, `set -- $s` ne découpe pas la chaîne) : les valeurs affichées montraient pourtant le retour complet ; boucle arrêtée, relevé fait à la main. Mon premier `diff` avant/après a échoué sur des droits (`sudo` ne s'applique pas aux substitutions de processus) : comparaison faite ligne à ligne.
4. Premier « retour SSH » après la demande de redémarrage : faux positif (connexion établie juste avant l'arrêt effectif, uptime de 2 jours). Attente refaite sur l'uptime (< 10 min).
5. Le projet `paillasse`, le compte de service et le gabarit avaient été créés à la main à l'étape 6 sans être codifiés (règle « tout est du code ») : gabarit et quota codifiés ; projet et compte notés en dette. La base d'OpenStack elle-même n'est pas sauvegardée : dette prioritaire.
**Critère vérifié**
- **Chiffres de capacité écrits** : `docs/capacite.md`. 20/20 VM démarrées sans erreur (prêtes en 53 s médiane, 75 s p95) ; ≈ 250 Mo par VM au repos, ≈ 600 Mo active ; 20 vCPU à 100 % → hôte à 89 %, commande de correction en 0,6 s.
- **Restauration réussie** :
```
quotidienne-20261009.tar.zst.age (108M)
APP_KEY restaurée identique à la production (valeur non affichée)
migrations dans la copie : 14 appliquées, 0 en attente
17 tables : copie = production pour chacune
== RESTAURATION RÉUSSIE
```
- **Tout est revenu seul après le redémarrage** (19:50 → SSH 19:53 → services Kolla et VM avant 20:01) : services, 46 unités Kolla, 49 conteneurs (0 en mauvaise santé), pare-feu, écoutes, réseau OpenStack, Nova/Neutron, 3 VM du stock relancées et dont l'agent invité répond, correcteur, Horizon, HTTPS, page de statut — identiques à l'état d'avant. Seule différence attendue : `f2b-table` (créée au premier bannissement). Parcours complet rejoué ensuite : « Pas encore réussi » 6/10 puis « Réussi » 10/10.
**Comprendre**
Un serveur qui « marche » n'est pas un serveur qui *revient* : trois valeurs par défaut raisonnables pour un cloud générique (VM éteintes après redémarrage, réserve mémoire minime, quota de 10 instances) auraient fait échouer une vraie classe, et aucune ne se voyait en usage normal. On ne les trouve qu'en provoquant la situation : charger le serveur, le redémarrer, restaurer une sauvegarde. C'est aussi vrai des sauvegardes : celle-ci « marchait » depuis l'étape 3, mais ne contenait pas la base du portail — seule une vraie restauration l'a montré. Enfin, une mesure dépend de la manière de mesurer : le même chemin a donné 5,5 s ou 2,8 s selon que le script imitait ou non le navigateur.
**Suite**
Étape 14 : pages légales et RGPD.
## 2026-10-09 — Étape 14 : pages légales et RGPD
**Fait**
- **Purge RGPD** : bouton « Archiver la classe » côté prof (plus de séance possible, date de suppression affichée, désarchivage possible d'ici là) et commande `classes:purger-archivees`, chaque nuit : une classe archivée depuis 90 jours est supprimée avec, en cascade, ses élèves, séances, sessions, machines et corrections. Le compte du prof reste ; le journal ne garde ni nom ni prénom.
- **Pages légales réécrites d'après le système réel** : `/legal` (mentions légales : Esquisse Formation, SIREN et adresse à compléter par Adam ; hébergeur vérifié à la source, mentions légales de Scaleway ; CGU) et `/confidentialite` (données réellement traitées, finalités et bases légales, durées vérifiées sur le serveur, hébergement et tiers, sécurité, cookies, droits, sous-traitance). `/statut` redirige vers la page de statut.
- **Plus aucun envoi d'adresse IP chez Google** : Archivo et IBM Plex Mono sont téléchargées à la compilation et servies par le portail (`Vite::fonts()`), au lieu de Google Fonts, chargé jusque-là sur **toutes** les pages, espace élève compris. xterm.js et CodeMirror servis depuis `public/vendor/` (copiés des paquets npm à la compilation) au lieu de jsDelivr.
- **Journaux** : Laravel passe d'un fichier unique jamais purgé à un fichier par jour gardé 14 jours (comme Nginx).
- **Interdits du prompt** : aucun logo Marianne, ministère, drapeau de l'UE ni « logo RGPD » sur le site. Bandeau « Aperçu d'une fonction en préparation — données fictives » sur les 32 pages d'aperçu (choix d'Adam) ; vrais établissements des tableaux de bord remplacés par des noms fictifs ; « 20 cursus » corrigé en « 6 cursus » ; « 99 cours » présenté comme un programme en cours de publication ; « Conforme au RGPD » relié à `/confidentialite` ; « Hébergé en France · Paris » ramené à « Hébergé en France » (commune du datacenter non vérifiable sur le site de Scaleway).
- 8 nouveaux tests Pest (33 au total) ; runbook `docs/runbooks/rgpd.md`.
**Problèmes rencontrés et solution**
1. **La page `/legal` du design décrivait un système qui n'existe pas** : « aucun traceur tiers » et « les données ne quittent pas l'UE » alors que Google Fonts était chargé partout, un « mode examen » qui enregistre les changements d'onglet (non construit), « temps passé, réponses aux QCM » (non collectés). Réécrite affirmation par affirmation.
2. **Faux clients et chiffres inventés** dans les pages d'aperçu (« Lycée Vauban Brest », « 608 élèves »…). Mon premier test cherchait nom et ville collés et a laissé passer les tableaux où ils sont dans deux cellules (« Lycée Vauban | Brest ») : test réécrit pour chercher chaque nom seul, sur les 32 aperçus — il a ensuite trouvé `/paiement` et les dernières lignes de `/academie`.
3. **La CSP de l'étape 12 cassait probablement l'exécution de code sur `/ide`** (Pyodide doit télécharger ses fichiers et compiler du WebAssembly) : `connect-src` et `'wasm-unsafe-eval'` ajoutés pour la vitrine. Non vérifié dans un navigateur (extension non connectée).
4. Deux erreurs dans mes tests : `toContain($texte, $message)` de Pest traite le second argument comme un autre texte à trouver ; le test « toutes les pages en 200 » ne prévoyait pas la redirection voulue de `/statut`.
5. Tinker lancé avec un fichier est resté bloqué en attente de saisie : remplacé par des tests Pest permanents (aucune police externe sur aucune page ; aucune ressource externe sur les pages de l'application).
**Critère vérifié**
- Pages en ligne : `/legal` 200, `/confidentialite` 200, `/statut` → 302 vers `https://status.syllabuslab.fr/` ; page de statut publique opérationnelle (étape 12).
- **La purge fonctionne, prouvée par Pest** (`PurgeRgpdTest`, 5 tests) : classe archivée depuis 91 jours supprimée avec élèves, séances, sessions, machines et corrections, compte du prof conservé ; classes archivées depuis 89 jours et classes actives épargnées ; seules les classes concernées touchées ; archivage par le prof, refus pour un autre prof. En production : `classes:purger-archivees` → « 0 classe(s) purgée(s) » (aucune classe archivée), planifiée à 02:30.
- `/connexion` ne charge plus que des ressources de `syllabuslab.fr` ; aucune mention de Google Fonts sur l'accueil ; 0 violation CSP depuis le déploiement.
**Comprendre**
Une politique de confidentialité n'est pas un texte juridique à recopier : c'est une description exacte de ce que fait le système, et elle engage. Celle du design promettait « aucun traceur tiers » pendant que chaque page, y compris celles des élèves mineurs, envoyait l'adresse IP du visiteur à Google pour charger une police. Le bon réflexe est de partir de la réalité (que collecte-t-on ? où ? combien de temps ? qui d'autre la voit ?), de la vérifier sur le serveur, puis de corriger le système quand la réalité n'est pas défendable — ici, servir les polices nous-mêmes — plutôt que d'adapter le texte. La purge suit la même logique : une durée de conservation écrite n'a de valeur que si un programme l'applique, et un test prouve qu'il l'applique.
**Suite**
- Adam : compléter forme juridique, SIREN, adresse du siège (et DPO s'il y en a un) dans `/legal` et `/confidentialite` ; vérifier `/ide` dans un navigateur.
- Étape 15 : visio de classe — seulement après validation des étapes 1 à 11 (le prompt l'exige).
## 2026-10-09 — Étape 14 (suite) : retours du test d'Adam dans son navigateur
Adam a ouvert `/ide` et transmis la console du navigateur, plus sa fiche INPI.
- **`/ide` n'a jamais fonctionné en production** : « Uncaught SyntaxError: Unexpected token '.' ». Dans la maquette `design/` elle-même, des `!!x` (conversion en booléen) sont devenus `.x` (4 occurrences, seulement dans `/ide`, vérifié par une recherche sur tous les scripts). Corrigé dans le portail (pas dans `design/`). **Nouveau test Pest** : chaque script en ligne de chaque page publique est analysé par `node --check` ; vérifié qu'il échoue sur l'ancienne version.
- **Aperçu HTML de `/ide` hors de son bac à sable** : `sandbox="allow-scripts allow-same-origin"` faisait tourner le code de l'élève avec l'origine `syllabuslab.fr`. Passé à `allow-scripts` seul ; la vérification automatique s'exécute dans l'aperçu et renvoie ses résultats par `postMessage` (délai de 3 s si la page de l'élève ne répond pas).
- **Régression introduite à l'étape 14** : `public/css/syllabus-theme.css` importait Mulish (police principale de tout le site) depuis Google par `@import`. Mon test ne regardait que le HTML ; la CSP durcie bloquait l'appel, donc plus de fuite d'IP, mais le site s'affichait avec une police de remplacement. Mulish ajoutée aux polices servies par le portail, `@import` retiré, test étendu aux feuilles de style de `public/css`.
- **Déploiement en échec passé inaperçu** : `npm run build`, lancé en root sur le serveur, a créé `public/vendor/` au nom de root ; le `rsync` suivant (compte `adam`) n'a pas pu y écrire. Mon filtre de sortie n'affichait ni l'échec ni le récapitulatif : je l'ai pris pour un succès, et c'est la vérification en production qui a montré que rien n'avait changé. `public/vendor/` exclu du `rsync` (même piège qu'à l'étape 9). Règle pour la suite : toujours lire la ligne `PLAY RECAP` (et le code de sortie) avant de conclure.
- **Mentions légales complétées** d'après la fiche INPI : Adam Belkacemi, entrepreneur individuel (EI), nom commercial Esquisse Formation, SIREN 838 093 268, SIRET 838 093 268 00087, 30 rue Lorenzaccio, 38100 Grenoble. La date de naissance de la fiche n'est pas reprise. Restent à compléter : l'article « Disponibilité et responsabilité » des CGU, et un éventuel délégué à la protection des données.
Vérifié en production : `/ide` 1 script en ligne, 0 erreur de syntaxe, `sandbox="allow-scripts"` ; Mulish déclarée par le portail ; aucun `@import` vers Google ; `/vendor/xterm/xterm.js` et `/vendor/codemirror/codemirror.js` en 200. 35 tests Pest.
## 2026-10-09 — Accès des élèves : rejoindre la classe avec son code, fiches en lot
**Question d'Adam** : comment l'élève obtient-il un accès après sa création ? Faut-il un e-mail automatique pour créer un mot de passe ?
**Réponse et décision** : pas d'e-mail. Le CLAUDE.md l'interdit pour les mineurs (minimisation) et range l'invitation par e-mail en V2 (pour les majeurs) ; le serveur n'a pas non plus de service d'envoi (il faudrait un compte chez un prestataire, ouvert par Adam). Le parcours existant (identifiant + code secret + QR à usage unique, sur une fiche imprimable) restait valable, avec deux défauts : la fiche ne s'affichait qu'une fois, et le code de classe affiché ne servait à rien. Adam n'ayant pas répondu au choix proposé, j'ai construit les deux options sans dépendance extérieure :
- **Rejoindre avec le code de classe** (façon n°1 du CLAUDE.md) : `/rejoindre` (code + prénom + nom facultatif, limité à 10 envois par minute) → demande visible sur la page de la classe (rafraîchie toute seule quand une demande arrive) → le prof accepte → le navigateur de l'élève affiche **une seule fois** son identifiant et son code, et le connecte. Le code en clair n'existe que dans la demande (chiffré par `APP_KEY`), supprimée à l'affichage ; une demande non traitée est supprimée au bout d'une heure (`model:prune`, tous les quarts d'heure). Seul le navigateur qui a envoyé la demande peut la suivre (jeton en session, empreinte SHA-256 en base). Classe archivée ou code faux : refus. Le prof projette le code ou un QR qui pré-remplit le code.
- **Fiches en lot** : cases à cocher dans la liste des élèves, « Nouvelles fiches pour la sélection » (nouveaux codes et QR, anciens codes invalidés).
- Lien « Rejoins ta classe avec son code » sur la page de connexion ; politique de confidentialité mise à jour (nouvelle donnée : la demande, au plus une heure).
- 6 tests Pest (41 au total) ; vérifié en production : `/rejoindre` 200 avec CSP stricte, code faux refusé avec message, table créée, nettoyage planifié.
**Clé des sauvegardes** : je ne peux ni ne dois voir la copie d'Adam. Procédure de preuve ajoutée au runbook des sauvegardes : un message chiffré sur le serveur avec la clé publique, que la copie doit déchiffrer sur le Mac. Fichier de test déposé sur le Bureau d'Adam. Corrigé aussi la commande de copie du runbook (un `scp` d'un fichier de root échouait).
## 2026-10-12 — Étape 15 : visio de classe
Démarrée après validation des étapes 1 à 11 par Adam. Décisions : [ADR 0005](adr/0005-visio-jitsi.md).
**Fait**
- **Jitsi Meet auto-hébergé** (docker-jitsi-meet `stable-11248`, version vérifiée sur le dépôt officiel, images figées), rôle Ansible `visio` : configuration d'après le `docker-compose.yml` officiel, JVB sur le réseau de l'hôte, secrets générés une fois sur le serveur, contrôle au déploiement que Jicofo voit un JVB opérationnel.
- **Pare-feu** (deux accords explicites d'Adam, filet anti-coupure de 5 min, vérification SSH depuis le Mac, règles de NAT d'OpenStack intactes) : UDP 10000 ouvert ; pont `br-visio` autorisé en `forward` pour les échanges entre conteneurs.
- **Accès par jeton JWT** signé par le portail (HS256, une salle imprévisible par séance, nom non modifiable : prénom et initiale pour les élèves) ; pas d'invités. **Prof seul modérateur** par le module natif `token_affiliation` (vérifié dans l'image : `context.user.moderator`).
- **Pages prof et élève** reprises de `design/resources/views/visio.blade.php` : « En direct » et chrono, « Couper tous les micros », « Terminer la visio », mains levées (premier arrivé en haut, « Donner la parole »), salle d'attente (élèves présents tant que la visio est fermée, « Tout admettre » l'ouvre), chat des élèves désactivé par défaut avec interrupteur ; côté élève : statut, micro, caméra, « Lever la main », « Quitter ». La grille factice est remplacée par la vraie visio (IFrame API, `external_api.js` servi par `meet`).
- **Main levée synchronisée** : l'événement Jitsi `raiseHandUpdated` met à jour l'état du portail (cache Redis, verrou) ; quand le prof donne la parole (`askToUnmute`, qui autorise le micro sous modération d'après la documentation officielle), le portail baisse la main.
- `meet.syllabuslab.fr` ajouté au certificat ; site intégrable dans le seul portail (`frame-ancestors`) ; `Permissions-Policy` du portail : caméra, micro et partage d'écran délégués à `meet` seulement ; CSP stricte complétée (`frame-src` et `script-src` vers `meet`).
- **Capacité ramenée à 20 VM** (Jitsi ≈ 710 Mo après une visio à trois, plafonds 1 Go + 512 Mo) : Nova et portail alignés.
- 6 tests Pest de la visio (47 au total) ; test réel en navigateurs `portail/tests/Reels/visio-e2e.mjs` (Playwright 1.64, Chromium, caméra et micro simulés) ; politique de confidentialité complétée (visio sans enregistrement).
**Problèmes rencontrés et solution**
1. Conteneurs Jitsi en boucle : leurs volumes doivent appartenir à l'uid 1000 (ils tournent sous cet utilisateur).
2. **Jicofo ne joignait pas Prosody** (« Connect timed out ») : le module `br_netfilter` fait passer le trafic entre conteneurs d'un même réseau Docker par la chaîne `forward`, dont la politique est `drop`. Jamais vu avant parce que tous les autres conteneurs ne sont joints que depuis l'hôte. Règle `br-visio` (accord d'Adam).
3. DNS de `meet` absent au départ : test complet d'abord joué en résolvant `meet` vers le serveur dans Chromium (`--host-resolver-rules`, certificat du portail accepté), puis rejoué en conditions réelles une fois le DNS créé par Adam et le certificat étendu.
4. Tinker resté ouvert en attente de saisie sur le serveur, bloquant la suite : lancé désormais avec `< /dev/null` (runbook).
5. **Un élève pouvait ouvrir son micro sans la parole** — trouvé par le test en navigateurs. En observant les messages XMPP envoyés par chaque navigateur, aucun client n'approuvait les élèves : c'est **Jicofo**, avec son authentification active par défaut, qui donnait le rôle de propriétaire à tout utilisateur authentifié, élèves compris (ils échappaient donc aussi à la modération et pouvaient modérer les autres). `JICOFO_ENABLE_AUTH=0` : Prosody vérifie toujours chaque jeton, `token_affiliation` seul attribue les rôles. Vérifié dans les journaux : élèves `PARTICIPANT`, liste des autorisés = le prof, puis l'élève à qui il donne la parole.
**Critère vérifié (vrai DNS, vrai certificat, trois navigateurs + un intrus)**
```
prof : visio ouverte, Jitsi chargé
prof : · 3 en ligne (le prof et les deux élèves)
prof : main levée reçue de « Lea M. »
Yanis tente d'ouvrir son micro sans la parole → « En sourdine »
Léa : sa main est baissée (parole donnée)
Léa ouvre son micro → « Tu as la parole »
intrus sans jeton : le prof voit toujours « · 3 en ligne » ; écran de l'intrus : « … Authentication required … »
CRITÈRE DE L’ÉTAPE 15 : RÉUSSI
```
Données et fichiers de test supprimés (accord d'Adam).
**Comprendre**
Dans une visio, l'image et le son ne sont que la partie visible : la sécurité tient dans *qui a quel rôle*. Ici, trois composants pouvaient en décider — Prosody (le serveur de messagerie), le module `token_affiliation` (qui lit le jeton signé par le portail) et Jicofo (l'organisateur des conférences) — et le moins attendu, Jicofo, accordait silencieusement le rôle de propriétaire à tout le monde. Aucune lecture de configuration ne l'aurait montré : il a fallu un test qui joue la situation réelle (un élève tente de parler sans y être autorisé) puis observer les messages échangés pour remonter à la cause. L'autre leçon est réseau : un pare-feu « tout refuser » protège aussi contre ses propres conteneurs, et une règle doit alors être la plus étroite possible (ici, un seul pont, dans les deux sens).
**Suite**
Étape 16 : nettoyage.
## 2026-10-12 — Étape 16 : nettoyage, et fin de mission
**Fait**
- Relevé de `/etc/sudoers.d/90-adam` : `adam ALL=(ALL) NOPASSWD:ALL`. `adam` est aussi dans le groupe `sudo` (`%sudo ALL=(ALL:ALL) ALL`) et a un mot de passe défini : retirer le fichier laisserait sudo, avec mot de passe. Conséquences présentées : `ansible-playbook -K`, plus aucune action root possible pour Claude Code seul, services automatiques non concernés (systemd, pas sudo).
- **Décision d'Adam : garder le fichier jusqu'à la fin du bloc A** (self-healing et montée de version demandent encore beaucoup de travail root). Noté en dette avec la procédure de retrait ; rien n'a été modifié sur le serveur.
- Restes de tests sur le serveur (`/root/capacite-20261009`, `etat-*.txt`, `nft-avant-visio.txt`, `/root/unites-retirees/`) conservés : ce sont des preuves citées dans le journal (« ne supprime rien »).
- `docs/bilan.md` écrit : preuve de chaque critère, temps par étape (fenêtres entre commits), capacité (20 VM), risques, ce qu'Adam doit faire, prochaines étapes du bloc A.
**État vérifié en fin de mission** : 0 unité en échec, 53 conteneurs sains, site et page de statut en 200, certificat jusqu'au 2027-01-10, test de restauration automatique du 2026-10-12 04:30 réussi.
**Comprendre**
Un accès « sudo sans mot de passe » est pratique pour automatiser, mais il transforme toute clé SSH du compte en clé root. Ici, c'est précisément la clé de Claude Code. Le garder pendant le bloc A est un compromis assumé, pas un oubli : il est écrit dans la dette avec sa date de fin et la commande de retrait.
## 2026-10-12 — Bloc A : sauvegarde des bases d'OpenStack
Adam lance la suite du bloc A **sans montée de version d'OpenStack** : la 2025.2 est stable et reste en place.
**Fait**
- La sauvegarde quotidienne exporte maintenant toutes les bases de Kolla (Keystone, Nova, Neutron, Glance, Heat, Placement, Grafana) avec `mariadb-dump --single-transaction` : ≈ 30 Mo, sans verrou ni redémarrage. Le mot de passe root de Kolla est lu dans `passwords.yml` et passé à `mariadb` par l'entrée standard (`--defaults-extra-file=/dev/stdin`) : jamais en argument, donc invisible dans `ps`.
- Le test de restauration hebdomadaire restaure aussi `openstack.sql` dans un conteneur jetable `mariadb:11.4` (même version que Kolla) et compare : bases, nombre de tables, lignes des tables stables.
- Procédure de sinistre OpenStack écrite dans `docs/runbooks/sauvegardes.md` (non jouée : il faudrait un second serveur).
- Ligne retirée de `docs/dette.md`.
**Pourquoi pas `mariabackup`, l'outil de Kolla** : il faut activer `enable_mariabackup` puis relancer `reconfigure` sur MariaDB, qui porte tout OpenStack, pour obtenir une copie physique de 2,5 Go. Un export logique de 30 Mo entre dans l'archive chiffrée existante et se restaure dans n'importe quel MariaDB 11.4. Le prix : la restauration est plus lente qu'une copie physique, ce qui ne compte pas à cette taille.
**Critère vérifié**
```
glance 16/16 grafana 113/113 heat 16/16 keystone 49/49 neutron 135/135
nova 111/111 nova_api 32/32 nova_cell0 111/111 placement 13/13
keystone.project : copie 7, production 7
keystone.user : copie 8, production 8
glance.images : copie 1, production 1
nova_api.flavors : copie 1, production 1
projet paillasse dans la copie : 1
== RESTAURATION RÉUSSIE (quotidienne-20261012.tar.zst.age)
```
**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).
## 2026-10-12 — Alerte Prometheus pour le self-healing, et Larastan (dette de l'étape 8)
**Alerte Prometheus**
- Trois règles ajoutées (`kolla/config/prometheus/alertes.rules`) : `VmPanneGenerale` (critique, 5 min), `VmIrreparable` (avertissement, 5 min), `VmSurveillanceArretee` (si `labs:surveiller-vm` ne publie plus sa métrique, comme `CertificatNonMesure`).
- Déployées par `kolla-ansible reconfigure --tags prometheus` (service systemd, survit à une coupure SSH) : correctif en route, le premier essai a échoué faute du `PATH` du venv Kolla dans l'environnement du service — même piège que l'étape 5 (« install-deps : No such file or directory 'ansible-galaxy' »), corrigé en ajoutant `PATH=/opt/kolla-venv/bin:...` à `systemd-run`.
- Vérifié : les 3 règles chargées (`/api/v1/rules`, toutes `health: ok`) ; `VmIrreparable` injectée via l'API d'Alertmanager (comme à l'étape 7), reçue en « active ». Confirmation de la notification ntfy par Adam à faire.
- Au passage : le nom d'utilisateur de l'API Prometheus/Alertmanager n'est pas `prometheus` mais `admin` (lu dans la config réelle du conteneur, `prometheus.yml`) — à corriger partout où le runbook `supervision.md` dirait le contraire.
**Larastan (dette de l'étape 8, jamais traitée depuis)**
- `larastan/larastan` ^3.0 installé, `phpstan.neon` (niveau 6, `app/` seulement — les tests ont leur propre style Pest, pas une exigence du CLAUDE.md).
- 72 erreurs au premier passage, 0 à la fin, sans aucune suppression (`@phpstan-ignore`, baseline, `@var`) : chaque défaut corrigé à la racine.
- **33 generics manquants** sur les relations Eloquent (`BelongsTo<Related, $this>`, etc.) et `HasFactory<...>` : mécanique, mais a mis en évidence 3 modèles (`LabSession`, `LabSessionMachine`, `Seance`) qui utilisaient `HasFactory` sans qu'aucune factory n'existe pour eux — trait mort, retiré.
- **14 `property.notFound`/`method.notFound`** : conséquence directe des generics manquants (une relation sans type précis rend tous ses modèles liés `Model` générique aux yeux de PHPStan) ; disparues une fois les generics posés.
- **Vrai bug trouvé** (`StockChaud::estUtilisable`) : la signature `array{nom: string, id: string}` ne déclarait pas la clé `instances`, pourtant testée par le même `isset()` — PHPStan jugeait ce test « toujours vrai » sur les deux clés déclarées et « sur une clé inexistante » sur la troisième. La vraie origine du doute (une valeur décodée du JSON de Redis, donc jamais garantie par le type) a été redonnée à la signature (`array<string, mixed>|null`), avec un contrôle de type explicite sur `nom`/`id` : les `isset()` redeviennent un vrai rempart, pas une façade.
- **`Redis::eval()`/`Redis::lrem()` mal typés par la façade** : la classe que la façade Laravel donne à lire à PHPStan (`Illuminate\Redis\Connections\Connection`, abstraite) porte elle-même `@mixin \Redis` (l'extension native), jamais la classe réellement choisie à l'exécution (`PhpRedisConnection`, qui réordonne les arguments) — un angle mort documenté de Laravel/Larastan, pas un bug de Syllabus. Contourné en appelant `Redis::command('eval'|'lrem', [...])` directement (méthode réelle, pas un mixin), avec les arguments dans l'ordre que `PhpRedisConnection` leur donnerait — même effet, correctement typé.
- Une fermeture de `CreerStackLab` retournait le booléen de `->save()` là où `FabriqueStack::fabriquer()` attendait une fermeture `void` : changée en bloc explicite.
- Vérifié : `./vendor/bin/phpstan analyse` → 0 erreur ; Pint → aucun changement ; 54 tests Pest toujours au vert.
- Dette retirée.
**Comprendre**
La plupart des 72 erreurs venaient d'une seule cause répétée dix fois (des relations Eloquent sans generics), pas de dix bugs différents — las de ça, régler la cause réelle (les generics) a fait disparaître les trois quarts du travail d'un coup. Les erreurs qui restaient ensuite étaient plus intéressantes : deux ne venaient pas du code de Syllabus mais d'une imprécision des annotations de Laravel lui-même sur sa façade Redis, et une révélait un vrai défaut (une signature de type qui en disait moins que ce que le code vérifiait vraiment). La règle du prompt (« ne jamais juste faire taire l'outil ») a forcé à distinguer ces trois cas plutôt qu'à tout traiter de la même façon.
## 2026-10-12 — Deux dettes (en attendant le DNS du TURN) : code de classe et image Uptime Kuma
**Code de classe sans caractère ambigu**
- `App\Support\CodeLisible` : alphabet sans 0/O ni 1/I/L (`ABCDEFGHJKMNPQRSTUVWXYZ23456789`), repris de `GenerateurCompteEleve::genererCodeSecret` (qui l'avait en privé) pour que `Classe::genererCodeUnique` l'utilise aussi — jusqu'ici il tirait dans `Str::random(6)` (alphabet complet, ambigu). Scripts de test réels (`tests/Reels/preparer-*.php`) alignés. 1 test Pest (55 au total) : 100 codes de classe générés, tous conformes à l'alphabet.
- Dette retirée.
**Image Uptime Kuma figée par digest**
- `louislam/uptime-kuma:1` est un tag flottant : son contenu peut changer sans préavis à n'importe quel redéploiement. Vérifié sur l'API Docker Hub que le digest du tag `:1` actuellement déployé (`sha256:70233f4a...`, version 1.23.17) correspond bien à ce qui tourne sur le serveur (`docker inspect` → même `RepoDigests`) : figé tel quel dans `supervision_image_uptime_kuma` (`ansible/roles/supervision/defaults/main.yml`), au lieu du tag.
- **Nuance pour la suite** : ce digest est celui du tag `:1`, pas celui du tag versionné `1.23.17` (une image différente, reconstruite séparément — vérifié : digests différents). La vraie « migration vers un tag versionné » demandée par Adam demande donc un second pas (re-pull, revérifier la page de statut et les sondes) : dette réduite, pas fermée.
- Déployé (`supervision.yml`, `changed=1`) et vérifié : même version (1.23.17), même base de données (`kuma.db`, sondes conservées), toujours sur `127.0.0.1:3001` seulement. Procédure de mise à jour future écrite dans `docs/runbooks/supervision.md`.
**Comprendre**
Les deux corrections de ce tour-ci illustrent une même idée : une dette notée n'est réglée que quand on peut dire exactement ce qui a changé et le prouver. Pour le code de classe, prouver, c'est un test qui génère une centaine de codes et vérifie qu'aucun caractère ambigu n'y figure jamais — pas seulement relire le code une fois. Pour Uptime Kuma, prouver, c'est avoir comparé le digest réellement déployé à celui que l'API de Docker Hub donne pour le tag flottant, plutôt que de supposer qu'ils coïncident : une fois comparés, il est apparu qu'ils coïncidaient aujourd'hui, mais que « corriger » et « migrer vers une version propre » sont deux choses différentes — la première protège contre les surprises futures, la seconde reste à faire.
**Suite**
Toujours en attente du DNS `turn.syllabuslab.fr` (ADR 0006) pour le relais TURN de la visio.
## 2026-10-12 — Vrai bug trouvé par Adam : un prof connecté ne voyait que la démo marketing
**Signalé par Adam** : « un prof qui se connecte avec ses identifiants ne peut pas accéder à son cockpit ou son tableau de prof ? il peut juste programmer et inviter élève... il peut pas lancer une visio, il peut rien et pourtant c'est lui l'admin ».
**Cause trouvée** : le gros menu « Produit » du site (repris de `design/` à l'étape 8, « Piloter la classe » : Tableau de bord prof, Visio de classe, Espace élève…) pointe vers `/ressource?outil=...`, qui mène aux pages de démonstration marketing (`/cockpit`, `/visio`…) — des pages fictives, clairement annoncées comme telles par leur bandeau (« Aperçu d'une fonction en préparation… fictifs »), construites pour un visiteur qui n'a pas encore de compte. Ce menu est servi par `layouts.vitrine`, **le même layout que les vraies pages du prof** (`/mes-classes`, la salle d'attente…) : connecté ou pas, un prof voyait donc toujours les mêmes liens vers les démos, jamais vers sa vraie page. En cliquant sur « Tableau de bord prof » en pensant retrouver son espace, il tombait sur une page fictive sans aucun bouton réel — exactement le symptôme décrit.
**Correctif** (`resources/views/layouts/vitrine.blade.php`, 3 liens) : « Tableau de bord prof » et « Visio de classe » pointent vers `/mes-classes` (et le texte devient « Mes classes ») quand `auth('web')->check()` ; « Espace élève » pointe vers `/eleve` (texte « Mon espace ») quand `auth('eleve')->check()`. Pour un visiteur non connecté, strictement aucun changement : mêmes liens vers les démos qu'avant.
**Critère vérifié sur le serveur réel** : un vrai prof créé puis connecté par `/connexion/prof` (pas par tinker) → `/mes-classes` affiche « Mes classes » dans le menu, et la page ne contient plus aucun lien `?outil=cockpit`. 1 nouveau test Pest (56 au total) : un prof connecté voit « Mes classes », jamais « Tableau de bord prof » ni de lien vers la démo.
**Comprendre**
Reprendre une maquette « telle quelle » (la règle de l'étape 8) a du sens pour la partie vitrine, qui s'adresse à un visiteur ; mais un même fichier de layout servait les deux publics à la fois, vitrine et application, sans jamais se demander qui regardait. La bonne clé n'était pas de reconstruire le menu, juste de lui apprendre à se taire sur les démos pour quelqu'un qui a déjà un compte — trois liens corrigés, pas toute la page. C'est aussi un bon rappel que « toutes les pages marchent » (dit à l'étape 8) voulait dire « s'affichent sans erreur », pas « mènent au bon endroit une fois connecté » : deux vérifications différentes, et seule un vrai parcours de connexion (pas un tinker) pouvait montrer celle-ci.
## 2026-10-12 — Chantier « vrai cockpit », étape 1 : tableau de bord réel
Après avoir montré que `/mes-classes` fonctionnait (voir plus haut), Adam a précisé le vrai besoin : il voulait le **style et la mise en page** de la démo `/cockpit`, mais branché sur ses vraies données. Quatre widgets de la démo (QCM, devoirs maison, observations, surveillance d'examen) n'ont pas de fonction réelle derrière : décidé avec Adam un ordre de construction (tableau de bord d'abord, puis une fonction à la fois), en commençant par ce qui existe déjà vraiment.
**Fait**
- `ClasseController::show()` calcule maintenant, en plus de ce qu'il chargeait déjà : la séance la plus pertinente de la classe (en cours avant salle d'attente, la plus récente d'abord), ses sessions de lab avec élève/dernière correction/machines, un taux de réussite moyen (sur les dernières corrections existantes, absent s'il n'y en a aucune — jamais un faux 0 %), le nombre de mains levées si une visio est ouverte, et la liste des autres classes du prof (pour le sélecteur).
- `VisioController::nombreMainsLevees()` (nouveau, public) : lit le même cache que la page de visio, pour que le tableau de bord puisse afficher un vrai chiffre sans dupliquer le format de la clé.
- `resources/views/mes-classes/show.blade.php` réécrite dans la mise en page de `/cockpit` (barre latérale avec sélecteur de classe, cartes d'indicateurs, tableau des élèves de la séance en cours) : **chaque chiffre vient d'une vraie table ou d'un vrai cache**. Aucune carte pour les fonctions non construites. Tout ce qui existait déjà (rejoindre, fiches, ajout/import d'élève, régénération de code, planification/archivage) est conservé, juste réorganisé.
- 3 nouveaux tests Pest (59 au total) : classe vide (aucune carte inventée), séance en cours avec élèves réels/état/correction, étanchéité entre classes d'un même prof.
**Vérifié sur le serveur réel** (scénario de test jetable, supprimé ensuite) : connexion par le vrai formulaire → `/mes-classes/{id}` en 200, avec la vraie classe, le vrai élève, le vrai état (« Réussi », 10/10), le vrai ratio (1/1 dans la séance), le vrai taux (100 %), et le lien console — aucune erreur.
**Comprendre**
Le piège de ce chantier n'était pas technique mais une tentation facile : recopier `/cockpit` presque à l'identique en remplaçant juste un ou deux chiffres aurait été plus rapide, et personne n'aurait rien vu tout de suite. Mais une carte « Moyenne QCM : 13,4 » sans aucun QCM derrière, ou une carte « DM rendus » sans aucun devoir, ce n'est plus une démo marketing honnête (où le bandeau d'aperçu le dit) — c'est un mensonge dans l'outil que le prof utilise vraiment pour piloter sa classe. La règle « pas de KPI inventé » du CLAUDE.md vise la vitrine ; elle compte encore plus ici.
**Suite**
Chantier « vrai cockpit », étape 2 : Quiz flash (prochaine fonction à construire pour de vrai).
## 2026-10-12 — Chantier « vrai cockpit », étape 1 (suite) : plus près de la démo, toujours sans rien inventer
Adam a demandé d'aller plus loin dans la fidélité à `/cockpit`. Trois ajouts, tous à partir de données déjà réelles :
**Fait**
- **Avancement du lab** : une barre par vérification du lab (équivalent réel de la « répartition par étape » de la démo), comptée sur les vraies dernières corrections de la séance — titres et ordre lus dans le `lab.yaml` (`DefinitionLab`), jamais les vérifications bonus (elles ajoutent des points, elles ne font pas partie du parcours normal).
- **Panneau « Mains levées »** : la même file que la page de visio (premier arrivé en haut), avec la **vraie action** « Donner la parole » (`seances.visio.parole`) directement depuis le tableau de bord — pas une liste en lecture seule.
- **Progression de la séance** (barre latérale) : temps écoulé rapporté à la durée estimée du lab (`Lab::duree_min`) — absente si l'une des deux manque, jamais une estimation inventée.
- `VisioController::mainsLevees()` (nouveau, public, à côté de `nombreMainsLevees()`) : la liste complète (nom, depuis), pas seulement le compte.
- Le calcul de l'avancement a buté deux fois sur l'analyse statique (Larastan) : `Correction::$details` n'avait pas de type précis (ajouté en `@property-read`), et repasser par une requête SQL séparée pour les corrections (au lieu de réutiliser le chargement déjà fait) perdait le type des modèles pour l'outil. Les deux corrigés à la racine, pas contournés.
- 2 nouveaux tests Pest (61 au total) : avancement qui exclut bien les bonus, mains levées avec la vraie action.
**Vérifié sur le serveur réel** (scénario à deux élèves, corrections différentes, main levée en cache — supprimé ensuite) : avancement affiché par vérification (2/2 puis 1/2), mains levées avec « Yanis » et le lien exact vers `seances.visio.parole`, barre de progression du temps. Classe réelle d'Adam vérifiée intacte après coup (1 classe, 2 élèves).
**Suite**
Chantier « vrai cockpit », étape 2 : Quiz flash.
## 2026-10-13 — ADR 0006 appliqué : relais TURN sur 443 pour la visio
DNS `turn.syllabuslab.fr` créé par Adam (après un premier essai avec le même piège qu'avec `status` à l'étape 12 : champ « Sous-domaine » rempli avec le nom complet au lieu du seul sous-domaine — corrigé).
**Fait**
- **Certificat étendu** au nom `turn.syllabuslab.fr` (même mécanisme que `status`/`meet` : détection DNS, `--expand`), sans toucher à la structure de Nginx — port 80 seulement à ce stade.
- **coturn** (rôle `turn`, nouveau, paquet système — pas Docker, pour éviter les soucis déjà vus avec Kuma/Jitsi) : TLS seulement (`no-udp`, `no-tcp`), sur `127.0.0.1:5349`. Secret partagé généré une fois (`/etc/turnserver-secret`), copie lisible du certificat régénérée à chaque renouvellement (`/etc/letsencrypt/renewal-hooks/deploy/90-turn-coturn.sh`).
- **Partage du port 443** (rôle `portail`, `07-turn-stream.yml`) : un bloc `stream` Nginx (module `libnginx-mod-stream`, installé) lit le nom demandé dans la poignée de main TLS (SNI, jamais déchiffrée) et route `turn.syllabuslab.fr` vers coturn, tout le reste vers les vhosts HTTPS existants — désormais sur `127.0.0.1:8443` au lieu du port public. **Aucun nouveau port dans nftables.**
- **Prosody** (rôle `visio`) : `TURNS_HOST`/`TURNS_PORT`/`TURN_TTL` dans `visio.env`, `TURN_CREDENTIALS` (même secret que coturn) dans `secrets.env` — lu nativement par `prosody.cfg.lua` (confirmé dans le fichier source de l'image, pas deviné), aucun plugin personnalisé.
- **Appliqué avec le filet habituel** : sauvegarde des deux fichiers Nginx touchés, `nginx -t` avant tout rechargement (déjà intégré au handler), restauration automatique programmée à 5 minutes, désarmée après vérification réussie (site, meet, status, pare-feu).
**Deux incidents, traités tout de suite (post-mortem complet : `docs/postmortems/2026-10-13-secret-turn-affiche.md`)**
1. J'ai affiché par erreur le secret partagé TURN en clair en vérifiant la configuration générée de Prosody. Traité comme compromis immédiatement (même règle que le 2026-10-08) : le classificateur de permissions m'a refusé la régénération, Adam a lancé la rotation lui-même.
2. En reprenant après la rotation, j'ai lancé plusieurs `ansible-playbook` concurrents vers le même serveur sans vérifier que le précédent tournait encore ; les tuer a fait planter un worker d'un autre (ils partagent la connexion SSH). Rien de cassé — vérifié l'état réel avant de relancer une seule fois, jusqu'au bout.
**Critère vérifié**
```
nmap -Pn -T4 --open 163.172.4.195 : 22, 80, 443 seulement (les autres « ouverts » occasionnels
— 21, 554, 1723 — viennent d'un équipement de l'hébergeur, confirmé par les compteurs nftables
à zéro sur ces ports, même repéré à l'étape 12)
turnutils_uclient -t -S -p 443 -W <secret> turn.syllabuslab.fr : poignée de main TLS 1.2/1.3
réussie, réponse binaire STUN/TURN (jamais ce qu'un serveur HTTP renverrait)
Empreintes sha256 du secret identiques des deux côtés (coturn, Prosody) — jamais le secret lui-même
syllabuslab.fr / meet / status : toujours 200/302 après la restructuration de Nginx
```
Reste à faire avant le pilote (noté dans le runbook) : un vrai test avec un navigateur dont l'UDP est bloqué (vérifier la candidate ICE `relay` dans les statistiques WebRTC), et rejouer `visio-e2e.mjs`.
**Comprendre**
Le choix qui a le plus simplifié ce chantier n'était pas technique mais de méthode : chaque étape risquée (étendre le certificat, installer coturn, router le port 443) a été faite et vérifiée séparément, dans un ordre qui limite le risque à chaque fois — rien d'irréversible n'a été tenté avant que l'étape précédente soit prouvée saine. Le routage par nom lui-même repose sur une idée simple une fois comprise : une poignée de main TLS commence par annoncer son destinataire en clair (le SNI), avant tout chiffrement — on peut donc aiguiller sans jamais ouvrir l'enveloppe. Les deux incidents de cette session rappellent la même leçon que celle du 8 octobre, appliquée à deux situations différentes : vérifier l'état avant d'agir coûte quelques secondes ; ne pas le faire peut coûter beaucoup plus.
**Suite**
Dette retirée. Reste dans `docs/dette.md` : le test de bascule TURN en navigateur réel, et l'image Jitsi/Kuma à faire évoluer vers un tag versionné. Chantier « vrai cockpit » : Quiz flash, toujours en attente.
## 2026-10-13 — Chantier « vrai cockpit », étape 2 : Quiz flash
Deuxième fonction réelle du chantier (après le tableau de bord). Deux décisions d'Adam avant de coder : formats QCM **et** Vrai/Faux dès la version 1 ; le quiz se lance **indépendamment** d'une séance de lab (pas besoin de code à rejoindre : l'élève est déjà un compte réel de sa classe, le quiz apparaît sur son tableau de bord).
**Fait**
- 5 tables (`quizzes`, `questions_quiz`, `choix_questions_quiz`, `sessions_quiz`, `reponses_quiz`) et leurs modèles. Une banque de questions par classe (`Quiz`), réutilisable ; un lancement en direct (`SessionQuiz`) avance question par question, se termine seule après la dernière.
- Prof : `/mes-classes/{classe}/quiz` (créer un quiz, ajouter des questions une par une — même esprit que « ajouter un élève »), panneau de contrôle en direct (`/sessions-quiz/{session}`) avec décompte des réponses en temps réel (qui a répondu, combien de bonnes réponses), bouton « Suivante »/« Terminer ».
- Élève : rien à rejoindre. Une zone sur `/eleve`, cachée par défaut, interrogée toutes les 2 s (`/quiz-flash/etat`) ; apparaît dès qu'un quiz est lancé pour sa classe, avec la question et un formulaire. Une réponse par question (contrainte unique en base, vérifiée aussi côté application) : pas de changement d'avis, pas de double clic compté deux fois.
- Menu : les liens « Quiz flash » de la vitrine (mégamenu et pied de page) pointent désormais vers `/mes-classes` pour un prof connecté, au lieu de la démo — même correctif que pour le tableau de bord.
- 16 tests Pest (72 au total), dont un qui rend les vraies pages et vérifie leurs scripts avec Node (même piège que `/ide` à l'étape 14, évité ici dès la construction) et un qui vérifie que la purge RGPD d'une classe supprime bien ses quiz, questions, choix, sessions et réponses.
**Un vrai bug de migration, trouvé par les tests avant le serveur réel**
`constrained()` devine le nom de la table à partir du nom de la colonne, en la mettant au pluriel anglais standard. Nos tables ont des noms différents de cette convention (`questions_quiz`, pas `question_quizzes` ; `sessions_quiz`, pas `session_quizzes` ; `choix_questions_quiz`, pas `choix_question_quizzes`) : trois clés étrangères pointaient vers des tables qui n'existaient pas. Corrigé en précisant le nom de chaque table explicitement (`constrained('questions_quiz')`, etc.) — seul `quiz_id` → `quizzes` correspondait déjà à la convention (vérifié avec `Str::plural('quiz')`, pas supposé).
**Critère vérifié sur le serveur réel** (scénario à 2 élèves, nettoyé ensuite) : un prof a créé un quiz (1 QCM, 1 vrai/faux), l'a lancé ; un élève a répondu juste (Paris), l'autre faux (Lyon) ; le panneau du prof a montré en direct « 2 ont répondu, 1 correcte » ; une deuxième réponse du même élève a été refusée (409, « Déjà répondu ») ; passage à la question suivante, l'élève l'a vue sans son ancien « déjà répondu » ; fin du quiz, l'élève ne voit plus rien.
**Comprendre**
Le bug de migration est un bon rappel que les conventions de Laravel ne devinent pas, elles supposent — et l'hypothèse casse silencieusement dès qu'on s'éloigne un peu du nom de table standard (ici, un ordre « pluriel-puis-singulier » choisi pour que les noms de table se lisent bien en français). Heureusement, les tests Pest l'ont trouvé avant même d'atteindre le serveur : la bonne nouvelle de ce chantier n'est pas qu'il n'y a pas eu de bug, mais que chaque bug a été trouvé au bon moment — jamais après coup.
**Suite**
Chantier « vrai cockpit », étape 3 : Devoirs maison (prochaine fonction).
## 2026-10-13 — Chantier « vrai cockpit » : banque de quiz
Demande d'Adam en continuant Quiz flash : « c'est dommage qu'on ne puisse pas puiser dans une banque de quiz ! » — cohérent avec la bibliothèque déjà prévue au CLAUDE.md (« Dupliquer et adapter »), étendue ici au quiz.
**Fait**
- `Quiz.partage` (booléen) : un prof partage un de ses quiz, visible par tous les autres profs dans `/banque-quiz` (établissement d'origine affiché, jamais les notes des élèves).
- `Quiz::dupliquerDans(Classe)` : copie indépendante (questions et choix compris, jamais les sessions ni les réponses) dans une classe du prof qui duplique. La copie n'est jamais partagée d'office : modifier la copie ne touche jamais l'original, et réciproquement.
- Pas de nouvelle table : partager ne change pas le propriétaire du quiz d'origine, dupliquer en crée toujours une copie à part.
- 3 nouveaux tests Pest (75 au total) : partage puis duplication avec vérification que la copie est une vraie copie (identifiants différents) et non liée ; refus de dupliquer un quiz non partagé ou vers la classe d'un autre ; seul le propriétaire peut partager/retirer.
- Dette notée : un quiz partagé disparaît de la banque si sa classe d'origine est purgée (RGPD, 90 jours) — pas de propriétaire indépendant d'une classe pour l'instant, acceptable tant que ça ne gêne pas en usage réel.
**Vérifié sur le serveur réel** (deux profs d'établissements différents, scénario jetable supprimé ensuite) : le prof A partage son quiz « Capitale du Japon » ; le prof B le voit dans la banque avec le nom de l'établissement d'origine, le duplique dans sa propre classe ; la copie a un identifiant différent, le même contenu (question et choix), `partage` à faux ; l'original du prof A n'a pas bougé.
**Comprendre**
La bonne nouvelle de cette fonction, c'est qu'elle n'a presque rien coûté en complexité : un seul booléen et une méthode de copie, parce que le modèle de données du quiz (questions, choix, séparés de leur lancement en direct) était déjà pensé pour qu'un quiz soit un objet autonome, réutilisable. C'est souvent le signe qu'une étape précédente a été bien conçue : la suivante devient presque gratuite.
**Suite**
Chantier « vrai cockpit », étape 3 : Devoirs maison.
## 2026-10-13 — Chantier « vrai cockpit », étape 3 : Devoirs maison
Deux décisions d'Adam avant de coder : rendu en texte libre (pas de dépôt de fichier, qui demanderait le stockage S3 prévu au bloc B, pas commencé) ; notation sur 20 **et** suivi « rendu / pas rendu » ensemble.
**Fait**
- `Devoir` (titre, consigne, échéance) par classe ; `RenduDevoir` (texte, note sur 20 facultative, horodatage), un seul par élève et par devoir — un nouvel envoi remplace le précédent, pas d'historique. Rendre n'est plus possible après l'échéance.
- Prof : `/mes-classes/{classe}/devoirs` (créer, voir qui a rendu et qui non, lire chaque réponse, noter).
- Élève : une vraie section sur `/eleve` (devoirs des 7 derniers jours, à rendre ou déjà rendus), et une page par devoir pour lire la consigne, rendre ou modifier son rendu, voir sa note une fois donnée.
- Menu : le lien démo « Devoirs maison » du mégamenu pointe vers `/mes-classes` pour un prof connecté — même correctif que pour le tableau de bord, le quiz et la banque.
- 7 nouveaux tests Pest (82 au total) : parcours complet, modification du rendu avant l'échéance (un seul en base), refus après l'échéance, étanchéité entre classes (élève et prof), purge RGPD qui emporte devoirs et rendus.
**Vérifié sur le serveur réel** (scénario jetable, supprimé ensuite) : un prof a créé un devoir avec une échéance à 48 h ; l'élève l'a vu apparaître sur son tableau de bord, a rendu une réponse ; le prof a vu le rendu immédiatement, l'a noté 16/20 ; l'élève a vu sa note. Aucune unité en échec après coup.
**Suite**
Avec Quiz flash, la banque de quiz et les devoirs maison, trois des quatre fonctions fictives du tableau de bord démo sont maintenant réelles. Reste : Observations (puis Surveillance d'examen, la plus sensible — suivi de mineurs à cadrer avec Adam avant de coder).
## 2026-10-13 — Peupler la banque de quiz avec du vrai contenu
Avant de continuer le chantier « vrai cockpit », demande d'Adam : la banque de quiz ne doit pas rester vide, elle doit puiser dans le contenu pédagogique déjà écrit (cours, TD, labs), rangée par thème comme la bibliothèque (`/bibliotheque`).
**Fait**
- Un fichier `quiz.gift` (format GIFT, déjà utilisé par le lab 08) écrit pour chacune des séquences 01 à 07 (01-shell-droits, 02-reseau-paquets-ip, 03-web-client-serveur, 04-processus, 05-routage-ospf, 06-chiffrement-https, 07-sql) — 6 à 7 questions QCM ou vrai/faux chacun, relues pour être justes (pas de question piège ni de réponse approximative).
- `App\Services\Quiz\LecteurGift` : lit un `quiz.gift` en questions structurées. Ignore avec un avertissement le type numérique du GIFT (`{#N}`, présent dans le lab 08) : non supporté par le schéma de questions_quiz, pas question d'inventer une réponse pour le faire rentrer.
- `App\Services\Quiz\BanqueOfficielle` : un établissement, un prof et une classe « Banque officielle » créés une seule fois (`firstOrCreate`), jamais archivés — propriétaire stable des quiz officiels, pour ne pas reproduire la dette du quiz partagé qui disparaît si sa classe d'origine est purgée (RGPD, 90 jours).
- `quiz:synchroniser-banque` (même esprit que `labs:synchroniser`) : lit `paillasse-contenus/`, crée ou met à jour un quiz par séquence (jamais supprimé), remplace ses questions à chaque passage (lui seul les écrit, aucun prof ne peut les éditer). Colonne `theme` ajoutée à `quizzes` (LIN, NET, SRV, SEC, BDD, selon `config/contenus.php`) pour le groupement.
- `paillasse-contenus/` synchronisé sur le serveur (`/opt/paillasse-contenus`, nouvelle tâche Ansible), comme `labs/` mais pour le contenu éditorial source entier, pas seulement les lab.yaml déployés.
- `/banque-quiz` regroupée par thème (badge + nom, comme `/bibliotheque`), les quiz partagés par des profs (sans thème) à la fin.
- 2 nouveaux tests Pest (84 au total) : création idempotente depuis un `quiz.gift` de test, pas de doublon à une deuxième synchronisation.
**Vérifié sur le serveur réel** : après déploiement, 8 quiz officiels créés (un par séquence, le lab 08 avec son avertissement attendu sur la question numérique ignorée). Scénario avec un prof jetable (supprimé ensuite) : `/banque-quiz` affiche bien 5 sections par thème (LIN ×2, NET ×3, SRV ×1, SEC ×1, BDD ×1) puis « Partagés par des profs » ; dupliqué le quiz officiel « Un serveur web et son client » (6 questions, 16 choix) dans la classe du prof jetable, copie identique et indépendante créée avec `theme` à nul. Le quiz réel d'Adam (« dfdffd ») et sa classe n'ont pas été touchés.
**Comprendre**
Le choix qui évite le plus de dette ici : ne pas réutiliser le mécanisme « partager » existant pour le contenu officiel (qui dépendrait d'une classe de prof, purgeable), mais lui donner un propriétaire à part qui n'est jamais archivé. Le prix à payer est une petite classe technique invisible dans l'usage normal — acceptable parce que documentée et jamais visible d'un vrai prof.
**Suite**
Reprendre le chantier « vrai cockpit » : Observations.
## 2026-10-13/14 — Séparer la vitrine de l'application
Demande d'Adam : une fois connecté (élève ou prof), on gardait le header vitrine (Fonctionnalités, Cours, Tarifs) — ça n'a pas de sens, comme sur Facebook il faut basculer dans une vraie interface d'application. Avant de coder, j'ai montré la liste des 10 entrées de navigation demandées (5 élève, 5 prof) : la plupart n'avaient pas de page réelle derrière (seulement `/mes-classes` et la banque de quiz existaient). Adam a choisi de construire une vraie version minimale de chaque page manquante plutôt qu'un simple renvoi « bientôt disponible ».
**Fait**
- `layouts/app` : barre du haut (logo, menu avatar — Mon profil, Paramètres, Aide, Signaler un problème, Se déconnecter), barre latérale (5 entrées par rôle + Mes raccourcis), zone centrale, bulle de chat Messenger. Mobile : barre d'onglets en bas (4 + Plus), chat plein écran. Thème clair/sombre et taille du texte réglables, appliqués via des attributs `data-theme`/`data-taille` sur `<html>`.
- Nouvelles pages réelles, aucune donnée inventée :
- Élève : **Ma classe** (annonces du prof, camarades en prénom + initiale, qui est en ligne dans les 3 dernières minutes, lien ENT si connu) ; **Emploi du temps** (séances + échéances de devoirs réunies, pas d'examens tant qu'aucun modèle n'existe) ; **Mes devoirs** (devoirs maison, TP disponibles via une séance ouverte, quiz en cours — avec badge) ; **Mes cours** (uniquement les cours.md réels des labs déjà pratiqués par la classe — un seul existe aujourd'hui) ; **Mes notes** (devoirs notés et corrections de labs, groupées par compétence du lab.yaml, jamais lue jusqu'ici).
- Prof : **Emploi du temps** et **Séances en direct** (vue transverse à toutes les classes, plus besoin d'ouvrir chaque classe) ; **Bibliothèque** (labs + banque de quiz réels, distincte de la vitrine `/bibliotheque`) ; **Résultats** (corrections et nouvelles **Observations** — notes rapides sur un élève pour le conseil de classe, dernière fonction fictive du tableau de bord démo, jamais montrées à l'élève). Annonces et Observations se gèrent depuis le cockpit de la classe (`/mes-classes/{classe}`).
- Chat Messenger élève ↔ prof de sa classe, jamais entre élèves (structurellement impossible : aucune route élève-élève n'existe) ; le prof peut couper le chat de sa classe ; historique conservé, purgé avec la classe (RGPD).
- Mon profil, Paramètres, Signaler un problème (stocké, relu par Adam), Mes raccourcis (pages épinglées).
- `/eleve` vidée des faux contenus (« À faire », « Ma progression », « Mes QCM » — des données inventées qui restaient depuis avant le chantier « vrai cockpit ») : ne reste que le quiz flash réel et des liens vers les 5 nouvelles pages.
- Nouvelles tables : `annonces`, `observations`, `raccourcis`, `signalements`, `fils_conversation`, `messages_chat`, `cours` ; colonnes ajoutées à `users`/`eleves` (préférences, dernière activité), `labs` (compétences, enfin lues du lab.yaml), `classes` (chat_actif), `etablissements` (url_ent).
- `cours:synchroniser` (même esprit que `labs:synchroniser` et `quiz:synchroniser-banque`) : lit les `cours.md` de `paillasse-contenus/`.
- 9 nouveaux tests Pest (93 au total) : étanchéité des barres par rôle, impossibilité pour un élève d'atteindre les routes de chat du prof, deux élèves de classes différentes ne partagent jamais de fil, JavaScript valide des nouvelles pages, toutes les nouvelles pages se chargent sans erreur.
**Bug trouvé et corrigé en vérifiant en réel** (pas en local) : le compteur de messages non lus du chat ne redescendait jamais à zéro — `FilConversation` n'avait pas `lu_par_prof_a`/`lu_par_eleve_a` dans son `$fillable`, donc `update()` les ignorait silencieusement, sans erreur. Trouvé en testant un vrai aller-retour élève ↔ prof sur le serveur, pas par les tests locaux (qui ne vérifiaient pas la valeur du compteur). Corrigé, et le test de non-régression ajouté aurait aussi révélé un second problème plus subtil : marquer « lu » au moment d'envoyer son propre message n'a pas de sens (le filtre par expéditeur l'exclut déjà) et créait un risque d'égalité de seconde avec un message suivant — retiré des deux côtés.
**Vérifié sur le serveur réel** (un prof et un élève jetables, classe supprimée ensuite) : navigation cloisonnée par rôle (l'élève ne voit jamais les entrées prof et réciproquement), vitrine toujours accessible et intacte sans connexion, chat élève → prof → élève avec compteurs corrects après le correctif, coupure du chat par le prof effective immédiatement, annonce publiée visible dans Ma classe, observation ajoutée visible dans Résultats. La banque officielle et le quiz réel d'Adam n'ont pas bougé.
**Comprendre**
La leçon technique : les données injectées par un `View::composer` sur un layout ne sont visibles que dans le corps du layout lui-même (barre latérale, barre du haut) — pas dans le contenu de la page qui l'étend (`@section('contenu')`), parce que ce contenu est déjà rendu, en chaîne de caractères, avant que le composeur du layout ne s'exécute. Deux pages (`eleve.blade.php`, `aide-app.blade.php`) utilisaient à tort une variable du composeur dans leur propre contenu ; passées explicitement par leur contrôleur à la place.
**Suite**
Reprendre le chantier « vrai cockpit » : Observations étant maintenant construites, il ne reste que Surveillance d'examen — à cadrer avec Adam avant de coder (RGPD, mineurs).
## 2026-10-14 — Coquille : thème sombre et menu avatar
Retour d'Adam après avoir vu la coquille : du texte clair illisible par endroits, et le menu avatar (Mon profil, Paramètres…) beaucoup trop grand.
**Trouvé en lisant le CSS** (pas besoin d'un navigateur pour celui-ci) :
- `syllabus-theme.css`, écrit pour la vitrine avant que le thème sombre n'existe, force `html, body` et l'en-tête (`body > header`) en blanc avec des couleurs codées en dur et `!important` — trois fois dans le fichier. Mes règles de thème sombre dans `app-shell.css` n'avaient pas ce poids : le texte passait bien en clair (lui vient d'une variable), mais le fond restait blanc. D'où le texte clair illisible : ce n'est pas un bug séparé, c'est la même cause.
- Les icônes du menu avatar n'avaient aucune taille en CSS. Un `<svg>` sans largeur ni hauteur se rend par défaut à 300×150 px dans le navigateur : c'est ça qui gonflait chaque ligne du menu. Même oubli sur l'icône des raccourcis dans la barre latérale.
**Fait** : des règles plus précises et avec `!important`, chargées après, redonnent la main aux pages de l'application pour le fond de `html`/`body`/l'en-tête en thème sombre ; taille explicite (16 à 22 px selon l'endroit) sur toutes les icônes qui n'en avaient pas ; menu avatar resserré (220 px, moins de marge intérieure).
**Vérifié** : le CSS corrigé est bien servi par le serveur ; un prof jetable qui passe en thème sombre dans Paramètres obtient bien `data-theme="sombre"` sur la page. Je n'ai pas pu vérifier le rendu dans un vrai navigateur (extension Chrome non connectée cette fois) — à confirmer visuellement.
**Dette non résolue** (déjà notée) : les pages migrées avant la coquille (quiz, devoirs, mes-classes) gardent des couleurs codées en dur par endroits et ne suivront pas parfaitement le thème sombre.
**Suite**
Adam vérifie visuellement (thème sombre, menu avatar, mobile) et me dit si autre chose cloche. Sinon : Surveillance d'examen.
## 2026-10-14 — Surveillance d'examen (chantier « vrai cockpit », dernière étape)
Adam confirme que le correctif du thème sombre et du menu avatar est bon, sans navigateur disponible pour le vérifier moi-même cette fois. Avant de coder la dernière fonction fictive du tableau de bord démo, j'ai demandé à Adam ce qui est acceptable de suivre pendant un examen (élèves parfois mineurs). Son choix : suivi chronométré + détection de changement d'onglet + tentative de plein écran — en étant honnête sur la limite : un navigateur ne permet pas de vraiment empêcher l'élève de sortir, seulement de le signaler.
**Fait**
- Une séance peut être lancée en « mode examen » avec une durée dédiée (pas celle du lab), choisie par le prof au moment de lancer. Réutilise le mécanisme d'expiration déjà en place (`labs:expirer-sessions`, `expire_a` sur `LabSession`) plutôt que d'en construire un nouveau : l'admission en mode examen pose juste `expire_a` sur la durée de l'examen au lieu de la durée du lab.
- `evenements_examen` : un type (`changement_onglet` ou `sortie_plein_ecran`) et un horodatage, rien d'autre — pas de capture d'écran, pas de webcam (minimisation, CLAUDE.md). Le navigateur de l'élève écoute `visibilitychange` et `fullscreenchange` et signale au serveur ; un bouton propose le plein écran, jamais forcé.
- Le prof voit, dans le cockpit de sa classe et dans la salle d'attente : un badge « Mode examen · durée », le nombre de sorties par élève, le temps restant.
- Purge RGPD : les événements d'examen partent avec la classe (vérifié par un test).
- 7 nouveaux tests Pest (100 au total) : durée d'examen appliquée à l'admission, un élève ne peut signaler que sur sa propre session, aucun événement possible hors mode examen, type invalide refusé, purge RGPD, JavaScript valide des deux nouveaux scripts (barre prof, page élève).
**Comprendre**
Le choix qui a le plus simplifié ce chantier : ne pas inventer un nouveau mécanisme de fin de session pour l'examen. `expire_a` et `labs:expirer-sessions` existaient déjà pour une autre raison (libérer les VM d'un TP normal) ; l'examen ne fait qu'y mettre une durée différente. Même vérification de temps restant côté élève (JS, toutes les 10 s) que côté prof — pas de nouveau canal de communication.
**Vérifié sur le serveur réel** (prof et élève jetables, sans VM réelle créée — session de lab montée directement en base, comme pour vérifier Quiz flash, pour ne pas consommer de capacité OpenStack juste pour tester) : le prof voit bien « Mode examen · 45 min » et la colonne Sorties dans le cockpit de sa classe ; l'élève, sur sa page de lab actif, voit le bandeau temps restant ; un événement `changement_onglet` posté par l'élève apparaît aussitôt chez le prof (passé de 0 à 1, en rouge) ; un type d'événement invalide est refusé (422) ; un élève ne peut pas poster sur la session d'un autre (403, déjà couvert par les tests). Classe de test supprimée, rien touché chez Adam.
**Suite**
Chantier « vrai cockpit » terminé : les quatre fonctions fictives du tableau de bord démo (quiz, devoirs, observations, examen) sont maintenant réelles. Reprendre le plan du bloc A (CLAUDE.md, section 2) là où il en était.
## 2026-10-14 — Deux points de dette « avant le pilote » réglés
Avant de passer à la semaine 8 (pilote avec une vraie classe), j'ai repris la liste des points de dette marqués « avant le pilote ».
**Alerte Prometheus du self-healing : fausse dette.** La ligne « pas d'alerte dédiée » datait du 2026-10-12, mais `VmPanneGenerale`, `VmIrreparable` et `VmSurveillanceArretee` avaient été ajoutées le jour même dans un commit suivant (`d47e835`) sans que la ligne de dette soit retirée. Vérifié en interrogeant l'API Prometheus réelle (`/api/v1/rules`, authentifiée) : les trois règles sont chargées et en bonne santé (`health: ok`). Ligne retirée de `docs/dette.md` — rien à construire, juste un oubli de ménage.
**Image Uptime Kuma : digest corrigé.** Le digest figé était celui publié sous le tag flottant `:1`, qui embarque encore un script d'avertissement « dépôt obsolète » sans impact réel (jamais atteint, visible seulement sur `/setup`). Vérifié sur l'API Docker Hub que le tag versionné `1.23.17` a un digest de manifeste différent (`3d632903...` contre `70233f4a...`) : même version logicielle, image reconstruite autrement. Mis à jour `supervision_image_uptime_kuma`, redéployé (`ansible-playbook supervision.yml`) : conteneur recréé avec le nouveau digest, toujours en écoute sur 127.0.0.1 seulement, base de données des sondes conservée (volume Docker, taille inchangée). Dette retirée, runbook mis à jour.
**Comprendre**
La dette qui n'existe plus a coûté plus cher à découvrir (interroger Prometheus, retrouver l'authentification) qu'à corriger quand elle existait vraiment : un rappel pour vérifier l'état réel avant de croire une ligne de `dette.md`, dans un sens comme dans l'autre.
**Suite**
Reste « avant le pilote » : les mentions légales (CGU, article disponibilité/responsabilité — j'ai besoin de l'avis d'Adam, pas une décision à prendre seul), et le test TURN-443 avec UDP bloqué (rejouer `visio-e2e.mjs`). Puis semaine 8 du bloc A : pilote avec une vraie classe.
## 2026-10-14 — CGU : article disponibilité et responsabilité complété ; TURN-443 laissé en l'état
Deux décisions d'Adam pour finir la liste « avant le pilote ».
**TURN-443** : le test complet (navigateur avec UDP bloqué, candidate ICE `relay`) reste à faire mais n'est pas bloquant pour un premier pilote si les réseaux testés ne filtrent pas l'UDP plus que d'habitude — la vérification sans navigateur (relais joignable via `turnutils_uclient`, rejouée aujourd'hui, toujours bonne) suffit pour l'instant. Formalisé en ligne de dette plutôt que laissé seulement dans le journal.
**Mentions légales** : complété l'article 6 des CGU (`resources/views/legal.blade.php`) avec une exclusion de responsabilité classique — dommages directs seulement, dommages indirects (perte de données, de temps, manque à gagner) et force majeure exclus, sans plafond chiffré (pas encore d'établissement payant). Le délégué à la protection des données reste à renseigner seulement s'il en est désigné un jour (pas obligatoire pour un pilote à cette échelle).
**Suite**
Les 4 points « avant le pilote » sont traités (2 résolus, 2 sciemment reportés avec l'accord d'Adam). Semaine 8 du bloc A : pilote avec une vraie classe — à organiser avec Adam (ce n'est pas une tâche de code).
## 2026-10-14 — Bloc B, premier chantier : l'API publique (/api/v1)
Le pilote (semaine 8) attend qu'Adam l'organise ; il demande de continuer en attendant. Premier chantier du bloc B (poste kDrive) listé au CLAUDE.md : une API publique. Avant de coder, j'ai demandé ce qu'elle doit exposer en premier — choix d'Adam : le catalogue en lecture seule (labs, banque de quiz officielle, cours), zéro donnée d'élève.
**Fait**
- Laravel Sanctum installé : un jeton Bearer par développeur, créé par un prof depuis `/jetons-api` (le jeton en clair n'est montré qu'une fois, même règle que le code secret d'un élève). Jeton à portée (`ability` Sanctum) `catalogue:lire` seulement — pas d'accès par défaut à autre chose si l'API grandit un jour.
- `routes/api.php`, préfixe `/v1` : `GET /labs`, `/labs/{identifiant}`, `/quiz`, `/quiz/{id}`, `/cours`, `/cours/{identifiant}`. Les quiz exposés sont seulement ceux de la banque officielle (partagés **et** avec un thème) : jamais les questions ni les réponses, seulement les métadonnées (titre, thème, nombre de questions) — un quiz partagé par un prof entre collègues reste interne à l'application.
- Quota par jeton (pas par compte ni par IP) dans Redis : un limiteur nommé `api` (`AppServiceProvider`), 60 requêtes/minute, clé = l'identifiant du jeton Sanctum. `CACHE_STORE=redis` était déjà la config de production (Horizon, sessions) : rien à ajouter côté infra.
- Documentation : spécification OpenAPI 3.0 (`public/openapi/v1.yaml`) et une page publique `/api/docs` (exemples `curl`, réponse type).
- Désactivé la route `/sanctum/csrf-cookie` (`config/sanctum.php`, `routes => false`) : elle n'a pas de sens ici, l'API est en jeton Bearer seulement, jamais en cookie de SPA — sa présence faisait d'ailleurs échouer le test qui vérifie que toute page publique répond 200 (elle répond 204, une page sans contenu, ce qui est correct mais pas un code que ce test attendait).
- 9 nouveaux tests Pest (109 au total) : pas de jeton → 401, jeton sans la bonne portée → 403, jeton révoqué → 401, contenu correct de chaque point d'accès, un quiz privé ou sans thème renvoie 404, les en-têtes de limite de débit sont présents, un prof crée/voit une fois/révoque son jeton, ne peut pas révoquer celui d'un autre prof.
**Comprendre**
Le choix qui limite le plus le risque ici : exposer seulement ce qui est déjà pensé comme public ou quasi-public dans l'application (le catalogue), jamais une donnée d'élève, même en lecture. Ça permet de vraiment montrer le savoir-faire attendu pour le poste kDrive (jeton, versionnage, quotas, documentation, tests) sans ouvrir de nouvelle surface RGPD à surveiller.
**Vérifié sur le serveur réel** (prof jetable, supprimé ensuite) : jeton créé depuis `/jetons-api`, affiché une seule fois ; `GET /api/v1/labs` sans jeton → 401, avec jeton → 200 avec les en-têtes `X-RateLimit-*` (60/59) et les vraies compétences du lab.yaml ; `/api/v1/cours` liste sans le contenu, `/api/v1/cours/{id}` avec ; `/api/v1/quiz` renvoie les 8 quiz de la banque officielle avec leur thème, jamais leurs questions ; lab inconnu → 404 ; jeton révoqué depuis la page → 401 immédiatement. `/api/docs` et `/openapi/v1.yaml` répondent. Rien touché chez Adam.
**Suite**
Prochain chantier du bloc B : cache Redis sur les lectures lourdes, avec mesure avant/après dans Grafana.
## 2026-10-14 — Cache Redis sur les lectures lourdes (bloc B)
Deuxième chantier du bloc B : cache sur le catalogue (labs, banque de quiz officielle, cours) et sur la liste des classes du tableau de bord prof — relue à chaque chargement de page et, pendant une séance en cours, toutes les 15 secondes par le rafraîchissement automatique.
**Fait**
- `App\Services\Catalogue\CacheCatalogue` (labs, quiz officiels, cours) et `App\Services\CacheTableauDeBordProf` (classes d'un prof, avec le nombre d'élèves) : jamais d'expiration, invalidation explicite au bon moment plutôt qu'une durée de vie devinée.
- Catalogue : invalidé par les trois commandes de synchronisation (`labs:synchroniser`, `quiz:synchroniser-banque`, `cours:synchroniser`), déjà rejouées à chaque déploiement.
- Tableau de bord prof : invalidé à l'archivage, au désarchivage, et par la purge RGPD (`classes:purger-archivees`).
- Branché sur l'API publique (`/api/v1/labs`, `/quiz`, `/cours`), `/bibliotheque-app`, et le cockpit d'une classe (`/mes-classes/{classe}`, formulaire de séance).
**Bug trouvé en vérifiant en réel, pas en local** : mettre en cache directement des modèles Eloquent hydratés plante à la relecture avec « incomplete class » — `unserialize()` reconstruit un objet PHP, et selon le moment où il est relu, l'autoloader n'a pas forcément encore chargé sa classe. Les tests locaux ne l'ont pas vu : `CACHE_STORE=array` en test ne sérialise jamais rien, contrairement à Redis en production. Corrigé en mettant en cache les attributs bruts (`getAttributes()`, un tableau de valeurs scalaires, aucun risque de ce genre) et en reconstruisant de vrais modèles à la lecture avec `Model::hydrate()` — aucun changement nécessaire côté vues ou ressources API, qui continuent de recevoir de vraies collections Eloquent.
**Mesuré sur le serveur réel** (avant/après, par `DB::listen()` — l'équivalent de ce que Grafana afficherait via les métriques MariaDB déjà exposées, juste lu directement plutôt que dans l'interface, faute d'extension Chrome connectée cette fois) :
- `Lab::orderBy(...)->get()` direct, 5 appels : **5 requêtes SQL**.
- `CacheCatalogue::labs()`, 5 appels : **1 requête SQL** (le premier appel, un cache miss ; les 4 suivants sont servis par Redis). Même résultat pour `quizOfficiels()` et `cours()`, et pour `CacheTableauDeBordProf::classes()`.
- Invalidation vérifiée : une valeur sentinelle posée sur chaque clé disparaît bien après avoir rejoué la commande de synchronisation correspondante.
- Vérification HTTP complète (prof jetable, supprimé ensuite) : `/mes-classes`, le formulaire de séance de `/mes-classes/{classe}` (liste des labs), `/bibliotheque-app` et `/api/v1/labs` affichent tous les bonnes données, réellement mises en cache.
**Comprendre**
La leçon : un test local qui passe ne prouve rien sur un comportement qui dépend du driver de cache réellement utilisé. `CACHE_STORE=array` en test est rapide et pratique, mais invisible aux bugs de sérialisation — seule une vérification contre le vrai Redis de production les révèle. Pour ce genre de risque (sérialisation, comportement spécifique à un driver), le test local ne remplace pas la vérification en réel : il la complète.
**Suite**
Prochain chantier du bloc B : optimisation MariaDB (index, `EXPLAIN`, requêtes N+1, jeu de données de charge).
## 2026-10-14 — Optimisation MariaDB (bloc B)
Dernier chantier annoncé du bloc B pour l'instant : index vérifiés par `EXPLAIN`, requêtes N+1 supprimées, sur un vrai jeu de données de charge — jamais deviné, jamais sur la base de production.
**Fait**
- `php artisan charge:generer` : nouvelle commande, refuse de tourner si `APP_ENV=production`. Remplit une base avec des classes, élèves, séances, sessions de lab, devoirs et rendus, en insertions par lot (pas un `Hash::make()` par élève — 20 000 hachages individuels auraient pris des minutes). Lancée en local contre un conteneur MariaDB 11 jetable (même version qu'en production), jamais contre le serveur réel : 20 000 lignes de test géneraient les vrais profs pendant la mesure, pour un bénéfice qui ne demande pas d'y toucher.
- Jeu généré : 500 classes, 20 000 élèves, 1 500 séances, 30 000 sessions de lab, 1 000 devoirs, 13 000 rendus — l'échelle de référence du CLAUDE.md.
- `EXPLAIN` sur les requêtes qui filtrent par statut/état/archivage a confirmé deux vrais manques (et écarté une fausse piste) :
- `lab_sessions.etat` (lu chaque minute par `labs:expirer-sessions` et la surveillance du self-healing) : parcours complet de la table, 30 056 lignes examinées. Avec l'index : 1 161 lignes (-96 %).
- `classes.archivee_a` (lu chaque jour par la purge RGPD) : 500 lignes examinées sans index, 1 avec.
- `devoirs.echeance` : pas touché. L'index existant sur `classe_id` suffisait déjà (2 lignes examinées, la requête filtre d'abord par classe) — `EXPLAIN` a évité d'ajouter un index inutile.
- N+1 supprimé dans `ResultatsController` (« Résultats », un prof avec plusieurs classes) : une requête de corrections et une d'observations étaient lancées par classe. Remplacé par une requête de chaque pour **toutes** les classes du prof, regroupées en mémoire — 4 requêtes au total, qu'il y ait 1 classe ou 50.
- 2 nouveaux tests Pest (117 au total) : le nombre de requêtes de `/resultats` ne bouge pas avec 1 ou 5 classes (compté via `DB::listen()`, pas deviné) ; les données restent correctement associées à la bonne classe après le regroupement en mémoire.
**Comprendre**
Deux fois dans ce chantier, mesurer plutôt que deviner a changé la décision : `devoirs.echeance` avait l'air d'un candidat évident à un index composite, `EXPLAIN` a montré que non. À l'inverse, `lab_sessions.etat` n'avait rien d'alarmant en lisant juste le code — c'est le volume réel (30 000 lignes, après des mois d'usage) qui rend le manque d'index coûteux, invisible sur les quelques lignes qu'on a aujourd'hui en production.
**Vérifié sur le serveur réel** : les deux index existent bien sur la vraie base (`SHOW INDEX`) ; un prof jetable avec deux classes (une avec une correction et une observation, l'autre vide) voit `/resultats` afficher les bons chiffres pour la bonne classe (« 1 correction(s) · 90 % de réussite », l'observation au bon endroit, la classe vide à 0). Classe et compte de test supprimés ensuite.
**Suite**
Dernier point du bloc B : stockage S3 des travaux d'élèves (Hetzner Object Storage, avec versioning).
## 2026-10-14 — Stockage S3 des devoirs maison (bloc B, dernier chantier)
Dernier point du bloc B annoncé par le CLAUDE.md : dépôt de fichier pour les devoirs maison, en plus de la réponse en texte libre déjà existante, stocké sur Hetzner Object Storage (jamais sur le disque du serveur).
**Fait**
- Migration : `reponse` devient facultative sur `rendus_devoirs`, trois colonnes ajoutées (`fichier_chemin`, `fichier_nom_original`, `fichier_taille_octets`) — un rendu a désormais au moins l'un des deux, texte ou fichier.
- `App\Services\Stockage\StockageTravauxEleves` : stocke/supprime sur le disque `s3` (`league/flysystem-aws-s3-v3`), lien de téléchargement temporaire (5 min), jamais le fichier servi en clair par le portail. `disponible()` renvoie `false` tant que `AWS_BUCKET` n'est pas renseigné — le bouton de dépôt reste alors simplement masqué côté élève (CLAUDE.md : pas de fonction qui a l'air de marcher sans vraiment marcher).
- `DevoirEleveController::rendre()` : validation « l'un ou l'autre » (`required_without`), 10 Mo max, extensions limitées (pdf, doc(x), odt, txt, images, zip), 503 si un fichier est envoyé alors que le disque n'est pas configuré.
- Bug trouvé et corrigé avant même de tester (relecture de mon propre code) : la première version ne supprimait l'ancien fichier d'Object Storage que par comparaison de chemins, ce qui aurait effacé un fichier déjà déposé à chaque renvoi en texte seul. Corrigé : la suppression de l'ancien fichier n'a lieu que si un *nouveau* fichier est vraiment envoyé dans la requête.
- Téléchargement côté prof : `DevoirController::telechargerRendu`, redirige vers un lien signé temporaire, réservé au prof propriétaire de la classe.
- Purge RGPD (`classes:purger-archivees`) étendue : les fichiers d'Object Storage des devoirs de la classe sont supprimés avant la suppression en base (la cascade en base ne connaît pas Object Storage).
- Ansible (`roles/portail/tasks/03-code.yml`) : bloc conditionnel qui lit `/etc/syllabus/s3-paillasse.yaml` s'il existe et écrit les variables `AWS_*` dans `.env` — fichier à créer par Adam lui-même sur le serveur (jamais par Claude, CLAUDE.md « commandes et dépenses »), rien ne change tant qu'il n'existe pas. Runbook dédié : `docs/runbooks/stockage-s3.md`.
- 9 tests Pest (`Storage::fake('s3')`) : dépôt masqué/refusé sans bucket configuré, fichier seul sans texte, validation (ni l'un ni l'autre, taille, extension), remplacement supprime l'ancien mais un renvoi texte seul le conserve, téléchargement réservé au prof propriétaire, affichage côté prof, suppression lors de la purge RGPD. 125 tests au total, tous verts ; Pint et Larastan (niveau 6) sans erreur sur tout le projet.
**Comprendre**
Le dernier bug (suppression accidentelle d'un fichier conservé) est le genre d'erreur qu'un test aurait aussi bien pu laisser passer si je ne l'avais écrit qu'après coup en pensant au cas « remplacement » et en oubliant le cas « renvoi texte seul » : relire sa propre logique de décision (« dans quels cas est-ce qu'on supprime ? ») avant d'écrire le test, pas seulement après, aide à voir le cas manquant.
**Vérifié en réel** : déployé sur le serveur, migration appliquée, bloc Ansible S3 confirmé sans effet tant que `/etc/syllabus/s3-paillasse.yaml` n'existe pas (aucune variable `AWS_` dans `.env`), `StockageTravauxEleves::disponible()` renvoie bien `false` en production (vérifié par tinker, compte jetable supprimé ensuite). Le dépôt réel contre un vrai bucket Hetzner ne pourra être vérifié qu'une fois le bucket créé par Adam (hors de portée de Claude) — couvert pour l'instant par les tests contre un disque S3 factice.
**Suite**
Le bloc B du CLAUDE.md est maintenant complet (API publique, cache Redis, optimisation MariaDB, stockage S3). La suite reste au choix d'Adam : créer le bucket S3 (runbook ci-dessus), avancer sur le bloc C, ou organiser le pilote de la semaine 8 du bloc A.
## 2026-10-14 — Ceph, démo mono-serveur (bloc C)
Premier chantier du bloc C : comprendre et opérer Ceph, en démo sur le serveur actuel avant le vrai cluster au 2ᵉ serveur (CLAUDE.md).
**Découverte avant de commencer**
En vérifiant l'état réel du serveur avant de toucher au stockage (réflexe de prudence, pas une étape prévue) : **31 Go de RAM réels et 230 Go en RAID 1, pas 64 Go / 2×500 Go** comme écrit dans le CLAUDE.md — et seulement ~580 Mo « libres » au sens strict, bien que ~6-7 Go restent « disponibles » (mémoire + cache récupérable, la vraie mesure qui compte). Un seul serveur fait tourner OpenStack (Kolla, MariaDB Galera, Nova/qemu pour les VM d'élèves) et le portail Laravel ensemble, pas deux serveurs séparés comme le schéma d'architecture le suggère. Remonté à Adam avant d'installer quoi que ce soit (Ceph consomme facilement 2-3 Go pour mon+mgr+OSD) ; décision d'Adam : démo quand même, a minima, sous surveillance de la RAM à chaque étape — faite sans incident, la RAM « disponible » est restée stable entre 6 et 7 Go du début à la fin.
**Fait**
- `cephadm` installé depuis les dépôts Debian (pas de `curl | bash`), cluster amorcé avec `--ssh-user adam` (notre durcissement SSH interdit le login root, y compris pour cephadm qui veut gérer même l'hôte local par SSH), sans dashboard ni pile de supervision intégrée (économie de RAM, Grafana/Prometheus existent déjà côté CX23).
- Bug trouvé et contourné : `ceph-volume lvm batch` plante (`IndexError`) sur un périphérique loop, classé à tort comme « déjà LVM » — reproduit à l'identique sur Pacific (image par défaut du paquet Debian) et sur Reef (après avoir ajouté le dépôt APT officiel Ceph pour avoir un `cephadm` de version assortie). La vraie cause n'était pas la version mais le *mode* : `ceph orch daemon add osd <hôte>:<périphérique> raw` (mode brut, sans LVM par-dessus, en argument séparé — la syntaxe `périphérique,method=raw` est refusée) évite entièrement le code de classification fautif.
- 1 OSD de 20 Go sur un fichier épars (`/var/lib/ceph-osd-demo/osd0.img`, aucun disque libre sur ce serveur), rendu permanent au redémarrage par un service systemd dédié (suivi dans `infra/ceph/`).
- Vérifié avec de vraies données : écriture et relecture d'un objet (`rados put`/`get`), contenu identique, pool de test ensuite supprimé. Cluster laissé dans l'état `HEALTH_WARN` attendu (1 seul OSD, pas de réplication possible) — pas une alerte à traiter.
- Détail complet des choix et contournements : [ADR 0007](adr/0007-ceph-demo-mono-serveur.md), procédures : `docs/runbooks/ceph.md`.
**Comprendre**
Deux leçons distinctes : d'abord, ne jamais faire confiance à la documentation d'architecture sans vérifier l'état réel avant une action qui consomme des ressources critiques — le CLAUDE.md décrivait un serveur deux fois plus gros que la réalité, et je ne l'aurais pas su sans lancer `free -h`/`lsblk` par réflexe avant Ceph plutôt qu'après un incident. Ensuite, un contournement de bug peut avoir l'air d'être « la mauvaise version du logiciel » alors que c'est autre chose : changer d'image Ceph (Pacific → Reef) n'a rien résolu, parce que le vrai problème était le choix entre deux modes de création d'OSD (`lvm` vs `raw`), pas la version — la bonne piste est venue de relire l'aide de la commande (`ceph orch daemon add osd --help`) plutôt que de continuer à changer des variables au hasard.
**Suite**
Prochain chantier du bloc C, annoncé par Adam : WebDAV, puis ElasticSearch.
## 2026-10-14 — WebDAV de l'élève (bloc C)
Deuxième chantier du bloc C : dossier personnel de l'élève, monté comme
lecteur réseau. Deux bugs réels trouvés et corrigés en vérifiant en vrai sur
le serveur, détaillés dans [ADR 0008](adr/0008-webdav-eleve-auth-request.md).
**Fait**
- `rclone serve webdav` (20 Mo de RAM) sert le dossier local de chaque élève (`storage/app/webdav/<id>/`), accessible en `https://syllabuslab.fr/webdav/` avec les identifiants de connexion habituels.
- L'authentification est déléguée au portail via `auth_request` de Nginx (`WebdavAuthController`, sous-requête interne, jamais appelable depuis l'extérieur) — pas de nouveau système de comptes.
- Page « Mes fichiers » (menu du compte, élève seulement) : explique comment monter le lecteur sous Windows/macOS/Linux.
- Dossier WebDAV de l'élève supprimé automatiquement lors de la purge RGPD d'une classe archivée (comme pour le stockage S3 des devoirs).
**Bug grave trouvé en testant en réel (pas en local)**
Première version : un `rewrite` injectait le sous-dossier de l'élève
(posé par `auth_request_set` après vérification des identifiants) dans le
chemin transmis à rclone. Testé avec deux comptes élèves jetables : le
fichier déposé par le premier atterrissait à la racine du dossier WebDAV
commun, et **le second élève pouvait le lire en devinant juste son nom** —
un élève aurait pu accéder aux fichiers d'un autre. Cause : `rewrite`
s'exécute dans une phase Nginx antérieure à celle où `auth_request_set` pose
sa variable, qui était donc toujours vide au moment du `rewrite`, même
écrite après dans le fichier. Corrigé avec une `location` à regex nommée et
`proxy_pass` construisant l'URL directement (phase plus tardive, la
variable y est déjà posée) — revérifié avec trois comptes élèves jetables,
isolation stricte confirmée (404 propre, jamais le contenu d'un autre),
comptes supprimés ensuite.
Deuxième bug, plus bénin : le cache de répertoires de rclone (distinct du
cache de contenu de fichier, déjà désactivé) ne voyait pas un dossier élève
tout juste créé par Laravel — 404 pendant que le cache n'avait pas encore
rafraîchi sa liste. Corrigé en réduisant ce cache à 1 seconde (volume
d'élèves trop faible pour que ça coûte quoi que ce soit).
**Comprendre**
Aucun test Pest n'aurait pu voir ni l'un ni l'autre bug : les tests
appellent `WebdavAuthController` directement, sans jamais passer par Nginx
ni par rclone — la pile complète (Nginx → auth_request → PHP-FPM → rclone →
disque) n'existe qu'en production. Pour ce genre de chantier (plusieurs
briques d'infra qui communiquent entre elles), la vérification en réel avec
de vraies requêtes HTTP sur deux comptes différents n'est pas une étape de
confort après coup : c'est la seule façon de voir ce bug-là. La leçon
« rewrite s'exécute avant auth_request_set » est aussi le genre de piège
d'ordre des phases Nginx qui ne saute pas aux yeux en relisant le fichier
de haut en bas — une bonne question à savoir expliquer en entretien SRE.
**Suite**
Prochain chantier du bloc C : ElasticSearch.
## 2026-10-14 — Recherche plein texte, Elasticsearch (bloc C)
Troisième chantier du bloc C : recherche plein texte dans les cours et les
labs. Détail des choix : [ADR 0009](adr/0009-elasticsearch-interne-sans-securite.md).
**Fait**
- Elasticsearch 8.19 installé depuis le dépôt officiel (clé GPG vérifiée), instance strictement interne : écoute `127.0.0.1` seulement, sécurité x-pack désactivée (cohérent avec le reste du serveur — l'API OpenStack interne et le MariaDB/Redis du portail n'ont pas de TLS entre services non plus, l'isolation vient du pare-feu), tas JVM limité à 512 Mo (le calcul automatique d'Elasticsearch, jusqu'à 50 % de la RAM système, aurait pu réserver ~15 Go sur ce serveur à 31 Go réels).
- `App\Services\Recherche\IndexRecherche` : appels HTTP directs (pas de bibliothèque cliente, le besoin se limite à indexer et chercher), analyseur `french` d'Elasticsearch sur les champs texte, `disponible()` même principe que le stockage S3 (la page affiche juste « pas disponible » tant que ce n'est pas configuré, jamais une erreur).
- Index reconstruit automatiquement après `labs:synchroniser` et `cours:synchroniser` — pas de mise à jour incrémentale, le catalogue est trop petit pour que ça coûte quoi que ce soit.
- Page « Recherche » (menu du compte, profs et élèves), route commune aux deux guards (`auth:eleve,web`, déjà utilisée par « Mes cours »/« Paramètres »).
- 5 tests Pest (`Http::fake()`, jamais contre un vrai Elasticsearch) : indisponibilité propre, accès par les deux guards, surlignage affiché avec le bon lien vers la page du cours, reconstruction de l'index envoie bien un document par lab/cours, et n'envoie *aucun* appel réseau quand Elasticsearch n'est pas configuré.
**Vérifié en réel** : index reconstruit sur le serveur depuis les vraies données (2 documents : le lab 08 et son vrai `cours.md`), recherche du mot « routage » retrouve les deux avec le bon score, page `/recherche` testée avec un compte élève jetable (connexion réelle par cookies de session, pas juste un appel direct au contrôleur) — le mot recherché apparaît bien surligné (`<em>routage</em>`) et le lien vers le cours fonctionne. RAM disponible du serveur restée stable (~6 Go) pendant tout le chantier. Compte de test supprimé ensuite.
**Comprendre**
Le réflexe acquis avec Ceph et WebDAV (vérifier l'état réel avant d'agir, mesurer plutôt que deviner) a évité ici une vraie bêtise : Elasticsearch calcule par défaut un tas JVM jusqu'à 50 % de la RAM système, ce qui aurait été catastrophique sur ce serveur sans même un message d'erreur — juste un service qui prend toute la RAM disponible. Fixer explicitement le tas avant le premier démarrage, plutôt qu'après avoir constaté le problème, est le genre de précaution qui ne coûte rien et qui évite un incident.
**Suite**
Bloc C restant : WebDAV et Ceph faits, Elasticsearch fait. ElasticSearch avait été écarté du MCP développeurs par Adam (pas pour l'instant). Prochains chantiers confirmés par Adam : LTI Moodle 1.3, puis SSO ENT (Keycloak) — normalement des étapes V2/V3 du produit, avancées à la demande explicite d'Adam.