ajoute le stock chaud, la correction automatique et les nouveaux essais de création de stack

Stock de stacks prêtes par lab dans Redis (prise atomique en Lua, ménage
par comparaison avec Heat, disjoncteur), files gérées par Horizon, séances
planifiables. Correcteur Go sur l'hyperviseur : socket Unix, commandes lues
dans le lab.yaml uniquement, exécution par l'agent invité QEMU. Une création
de stack ratée est supprimée et réessayée deux fois, puis la session passe
en échec, visible du prof.

Corrige aussi trois défauts des étapes précédentes : la console se coupait
à la première frappe d'un navigateur (trames texte), l'élève ne pouvait pas
rejoindre une séance, la déconnexion n'avait pas de jeton CSRF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-08 21:52:16 +02:00
parent ca2f6f3b7a
commit 80b6c0722d
71 changed files with 2959 additions and 170 deletions

3
.gitignore vendored
View file

@ -8,3 +8,6 @@ clouds-*.yaml
admin-openrc.sh admin-openrc.sh
*-openrc.sh *-openrc.sh
*application-credential*.json *application-credential*.json
# Caches Python des scripts de test
__pycache__/

11
ansible/correcteur.yml Normal file
View file

@ -0,0 +1,11 @@
---
# Étape 11 du déploiement Syllabus : service de correction des labs.
# Rejoue aussi `portail` : il dépose les lab.yaml dans /opt/labs, que lit
# le correcteur.
- name: Portail et correcteur
hosts: syllabus
become: true
gather_facts: true
roles:
- portail
- correcteur

View file

@ -0,0 +1,4 @@
---
correcteur_dossier: /opt/correcteur
correcteur_socket: /run/correcteur/correcteur.sock
correcteur_dossier_labs: /opt/labs

View file

@ -0,0 +1,10 @@
---
- name: recharger systemd
ansible.builtin.systemd:
daemon_reload: true
- name: redémarrer correcteur
ansible.builtin.systemd:
name: correcteur
state: restarted
enabled: true

View file

@ -0,0 +1,3 @@
---
dependencies:
- role: go

View file

@ -0,0 +1,79 @@
---
# Étape 11 : service de correction sur l'hyperviseur. Il tourne en root :
# il lui faut `docker exec nova_libvirt virsh` (agent invité QEMU).
- name: créer le dossier du code source
ansible.builtin.file:
path: "{{ correcteur_dossier }}"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: synchroniser le code source
become: false
ansible.posix.synchronize:
src: "{{ playbook_dir }}/../correcteur/"
dest: "{{ correcteur_dossier }}/"
delete: true
rsync_opts:
- "--exclude=/correcteur"
- "--exclude=/correcteur-linux-amd64"
notify: redémarrer correcteur
# Cache et modules Go dans /root, pas dans le dossier synchronisé : sinon ils
# appartiendraient à root et bloqueraient le rsync suivant (piège de l'étape 9).
- name: compiler le binaire
ansible.builtin.command:
cmd: /usr/local/bin/go build -o /usr/local/sbin/correcteur .
args:
chdir: "{{ correcteur_dossier }}"
environment:
HOME: /root
GOFLAGS: -mod=readonly
changed_when: true
notify: redémarrer correcteur
- name: lire l'id du projet OpenStack des labs
ansible.builtin.shell:
cmd: >-
OS_CLIENT_CONFIG_FILE=/etc/kolla/clouds-paillasse.yaml OS_CLOUD=paillasse-service
/opt/kolla-venv/bin/openstack token issue -f value -c project_id
register: correcteur_projet
changed_when: false
- name: lire l'uid de www-data (seul client autorisé)
ansible.builtin.getent:
database: passwd
key: www-data
- name: déployer le fichier d'environnement du service
ansible.builtin.copy:
dest: /etc/correcteur.env
owner: root
group: root
mode: "0644"
content: |
# Géré par Ansible (rôle correcteur). Aucun secret ici.
CORRECTEUR_SOCKET={{ correcteur_socket }}
CORRECTEUR_LABS={{ correcteur_dossier_labs }}
CORRECTEUR_PROJET={{ correcteur_projet.stdout | trim }}
CORRECTEUR_UID_CLIENT={{ ansible_facts.getent_passwd['www-data'][1] }}
notify: redémarrer correcteur
- name: déployer le service systemd
ansible.builtin.template:
src: correcteur.service.j2
dest: /etc/systemd/system/correcteur.service
mode: "0644"
notify:
- recharger systemd
- redémarrer correcteur
- name: appliquer les changements
ansible.builtin.meta: flush_handlers
- name: vérifier que le service tourne
ansible.builtin.systemd:
name: correcteur
enabled: true
state: started

View file

@ -0,0 +1,31 @@
[Unit]
Description=Correcteur des labs Syllabus (étape 11)
# Géré par Ansible (rôle correcteur) — ne pas modifier à la main.
After=docker.service
Requires=docker.service
[Service]
Type=simple
# root pour joindre le socket Docker ; groupe www-data pour que le socket
# Unix créé (0660) ne soit joignable que par root et le portail.
User=root
Group=www-data
EnvironmentFile=/etc/correcteur.env
Environment=HOME=/run/correcteur
ExecStart=/usr/local/sbin/correcteur
Restart=on-failure
RestartSec=2
RuntimeDirectory=correcteur
RuntimeDirectoryMode=0750
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,4 @@
---
# Partagé par proxy-console et correcteur, compilés sur le serveur.
go_version: "1.27.1"
go_sha256: "63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445"

View file

@ -1,14 +1,14 @@
--- ---
- name: télécharger Go {{ proxy_console_go_version }} (archive officielle, somme vérifiée) - name: télécharger Go {{ go_version }} (archive officielle, somme vérifiée)
ansible.builtin.get_url: ansible.builtin.get_url:
url: "https://go.dev/dl/go{{ proxy_console_go_version }}.linux-amd64.tar.gz" url: "https://go.dev/dl/go{{ go_version }}.linux-amd64.tar.gz"
dest: "/tmp/go{{ proxy_console_go_version }}.linux-amd64.tar.gz" dest: "/tmp/go{{ go_version }}.linux-amd64.tar.gz"
checksum: "sha256:{{ proxy_console_go_sha256 }}" checksum: "sha256:{{ go_sha256 }}"
mode: "0644" mode: "0644"
- name: vérifier si Go est déjà installé dans cette version - name: vérifier si Go est déjà installé dans cette version
ansible.builtin.command: /usr/local/go/bin/go version ansible.builtin.command: /usr/local/go/bin/go version
register: proxy_console_go_deja_installe register: go_deja_installe
failed_when: false failed_when: false
changed_when: false changed_when: false
@ -16,11 +16,11 @@
ansible.builtin.file: ansible.builtin.file:
path: /usr/local/go path: /usr/local/go
state: absent state: absent
when: proxy_console_go_version not in (proxy_console_go_deja_installe.stdout | default('')) when: go_version not in (go_deja_installe.stdout | default(''))
- name: extraire Go dans /usr/local - name: extraire Go dans /usr/local
ansible.builtin.unarchive: ansible.builtin.unarchive:
src: "/tmp/go{{ proxy_console_go_version }}.linux-amd64.tar.gz" src: "/tmp/go{{ go_version }}.linux-amd64.tar.gz"
dest: /usr/local dest: /usr/local
remote_src: true remote_src: true
creates: /usr/local/go/bin/go creates: /usr/local/go/bin/go

View file

@ -7,8 +7,10 @@
ansible.builtin.systemd: ansible.builtin.systemd:
daemon_reload: true daemon_reload: true
- name: redémarrer le worker de file # Horizon garde le code et la configuration en mémoire : à redémarrer après
# chaque déploiement ou changement de .env.
- name: redémarrer horizon
ansible.builtin.systemd: ansible.builtin.systemd:
name: portail-worker name: portail-horizon
state: restarted state: restarted
enabled: true enabled: true

View file

@ -44,7 +44,7 @@
- "--exclude=/public/hot" - "--exclude=/public/hot"
- "--exclude=/.git" - "--exclude=/.git"
register: portail_code_synchronise register: portail_code_synchronise
notify: redémarrer le worker de file notify: redémarrer horizon
- name: installer les dépendances PHP (composer) - name: installer les dépendances PHP (composer)
ansible.builtin.command: ansible.builtin.command:
@ -120,11 +120,13 @@
- { cle: "OS_APPLICATION_CREDENTIAL_SECRET", valeur: "" } - { cle: "OS_APPLICATION_CREDENTIAL_SECRET", valeur: "" }
- { cle: "REDIS_PREFIX", valeur: "" } - { cle: "REDIS_PREFIX", valeur: "" }
- { cle: "LABS_DOSSIER", valeur: "/opt/labs" } - { cle: "LABS_DOSSIER", valeur: "/opt/labs" }
# > timeout des superviseurs Horizon (300 s, config/horizon.php)
- { cle: "REDIS_QUEUE_RETRY_AFTER", valeur: "330" }
no_log: true no_log: true
register: portail_env_mis_a_jour register: portail_env_mis_a_jour
# queue:work charge la configuration une seule fois au démarrage : sans # Horizon charge la configuration une seule fois au démarrage : sans
# redémarrage, il garderait l'ancien mot de passe après une rotation. # redémarrage, il garderait l'ancien mot de passe après une rotation.
notify: redémarrer le worker de file notify: redémarrer horizon
- name: installer les dépendances JS (npm) - name: installer les dépendances JS (npm)
ansible.builtin.command: ansible.builtin.command:

View file

@ -15,3 +15,14 @@
src: "{{ playbook_dir }}/../labs/" src: "{{ playbook_dir }}/../labs/"
dest: /opt/labs/ dest: /opt/labs/
delete: true delete: true
# Le catalogue (table labs) suit les lab.yaml déposés. En www-data : un
# artisan lancé en root peut créer un storage/logs/laravel.log que PHP-FPM
# ne pourrait plus écrire.
- name: synchroniser le catalogue des labs
ansible.builtin.command:
cmd: runuser -u www-data -- php artisan labs:synchroniser
args:
chdir: "{{ portail_dossier_app }}"
register: portail_labs_synchronises
changed_when: "'créé' in portail_labs_synchronises.stdout"

View file

@ -1,25 +1,38 @@
--- ---
# Étape 10 : CreerStackLab et SupprimerStackLab sont des jobs Redis — il faut # Étape 11 : Laravel Horizon remplace le simple `queue:work` de l'étape 10.
# un worker pour les exécuter, et un cron pour l'expiration automatique. # Deux files (sessions, stock), plusieurs processus chacune : config/horizon.php.
- name: déployer le service systemd du worker de file - name: arrêter l'ancien worker de l'étape 10
ansible.builtin.systemd:
name: portail-worker
state: stopped
enabled: false
failed_when: false
- name: supprimer l'unité de l'ancien worker
ansible.builtin.file:
path: /etc/systemd/system/portail-worker.service
state: absent
notify: recharger systemd
- name: déployer le service systemd de Horizon
ansible.builtin.template: ansible.builtin.template:
src: portail-worker.service.j2 src: portail-horizon.service.j2
dest: /etc/systemd/system/portail-worker.service dest: /etc/systemd/system/portail-horizon.service
mode: "0644" mode: "0644"
notify: notify:
- recharger systemd - recharger systemd
- redémarrer le worker de file - redémarrer horizon
- name: appliquer les changements du worker - name: appliquer les changements
ansible.builtin.meta: flush_handlers ansible.builtin.meta: flush_handlers
- name: activer et démarrer le worker de file - name: activer et démarrer Horizon
ansible.builtin.systemd: ansible.builtin.systemd:
name: portail-worker name: portail-horizon
enabled: true enabled: true
state: started state: started
- name: planifier le scheduler Laravel (expiration des sessions de lab) - name: planifier le scheduler Laravel (expiration, stock chaud)
ansible.builtin.cron: ansible.builtin.cron:
name: "scheduler Laravel (portail Syllabus)" name: "scheduler Laravel (portail Syllabus)"
user: www-data user: www-data

View file

@ -0,0 +1,20 @@
[Unit]
Description=Laravel Horizon du portail Syllabus (files sessions et stock — étape 11)
# Géré par Ansible (rôle portail) — ne pas modifier à la main.
After=network.target docker.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory={{ portail_dossier_app }}
ExecStart=/usr/bin/php{{ portail_php_version }} artisan horizon
Restart=always
RestartSec=3
# Sur SIGTERM, Horizon laisse finir les jobs en cours (jusqu'à 300 s) :
# systemd doit attendre au moins autant avant de tuer.
TimeoutStopSec=320
KillSignal=SIGTERM
[Install]
WantedBy=multi-user.target

View file

@ -1,16 +0,0 @@
[Unit]
Description=Worker de file du portail Syllabus (CreerStackLab, SupprimerStackLab — étape 10)
# Géré par Ansible (rôle portail) — ne pas modifier à la main.
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory={{ portail_dossier_app }}
ExecStart=/usr/bin/php{{ portail_php_version }} artisan queue:work --sleep=1 --tries=1 --max-time=3600
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View file

@ -1,6 +1,4 @@
--- ---
proxy_console_go_version: "1.27.1"
proxy_console_go_sha256: "63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445"
proxy_console_dossier: /opt/proxy-console proxy_console_dossier: /opt/proxy-console
proxy_console_ecoute: "127.0.0.1:8081" proxy_console_ecoute: "127.0.0.1:8081"
proxy_console_redis: "127.0.0.1:6380" proxy_console_redis: "127.0.0.1:6380"

View file

@ -0,0 +1,3 @@
---
dependencies:
- role: go

View file

@ -1,6 +1,3 @@
--- ---
- name: installer Go
ansible.builtin.import_tasks: 01-go.yml
- name: déployer et démarrer le service - name: déployer et démarrer le service
ansible.builtin.import_tasks: 02-service.yml ansible.builtin.import_tasks: 02-service.yml

2
correcteur/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/correcteur
/correcteur-linux-amd64

5
correcteur/go.mod Normal file
View file

@ -0,0 +1,5 @@
module correcteur
go 1.27.1
require gopkg.in/yaml.v3 v3.0.1

4
correcteur/go.sum Normal file
View file

@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

71
correcteur/lab.go Normal file
View file

@ -0,0 +1,71 @@
package main
import (
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
type attendu struct {
CodeRetour int `yaml:"code_retour"`
SortieContient string `yaml:"sortie_contient"`
}
func (a attendu) satisfait(code int, sortie string) bool {
if code != a.CodeRetour {
return false
}
return a.SortieContient == "" || strings.Contains(sortie, a.SortieContient)
}
type verification struct {
ID string `yaml:"id"`
Machine string `yaml:"machine"`
Commande string `yaml:"commande"`
Attendu attendu `yaml:"attendu"`
}
type lab struct {
Machines []struct {
Nom string `yaml:"nom"`
} `yaml:"machines"`
Verifications []verification `yaml:"verifications"`
}
// L'identifiant a déjà été validé (^[a-z0-9][a-z0-9-]*$) : pas de « .. »
// possible dans le chemin.
func chargerLab(dossier, id string) (*lab, error) {
contenu, err := os.ReadFile(filepath.Join(dossier, id, "lab.yaml"))
if err != nil {
return nil, err
}
var l lab
if err := yaml.Unmarshal(contenu, &l); err != nil {
return nil, err
}
return &l, nil
}
func (l *lab) verification(id string) (verification, bool) {
for _, v := range l.Verifications {
if v.ID == id {
return v, true
}
}
return verification{}, false
}
// Le portail nomme chaque VM « {préfixe}-{machine} » (GenerateurTemplateHeat).
// On retrouve la machine par le suffixe le plus long : avec des machines
// « a » et « pc-a », le nom « …-pc-a » désigne bien pc-a, pas a.
func (l *lab) machineDuNom(nomInstance string) string {
trouvee := ""
for _, m := range l.Machines {
if strings.HasSuffix(nomInstance, "-"+m.Nom) && len(m.Nom) > len(trouvee) {
trouvee = m.Nom
}
}
return trouvee
}

55
correcteur/lab_test.go Normal file
View file

@ -0,0 +1,55 @@
package main
import "testing"
func TestLab08(t *testing.T) {
l, err := chargerLab("../labs", "08-deux-lan-routeur")
if err != nil {
t.Fatal(err)
}
v, ok := l.verification("forwarding")
if !ok || v.Machine != "routeur" || v.Attendu.CodeRetour != 0 || v.Commande == "" {
t.Fatalf("vérification forwarding mal lue : %+v", v)
}
if _, ok := l.verification("inexistante"); ok {
t.Fatal("une vérification inconnue ne doit pas être trouvée")
}
}
func TestMachineDuNom(t *testing.T) {
l := &lab{}
for _, n := range []string{"a", "pc-a", "routeur"} {
l.Machines = append(l.Machines, struct {
Nom string `yaml:"nom"`
}{n})
}
cas := map[string]string{
"lab-session-3-e1-pc-a": "pc-a",
"stock-08-deux-lan-routeur-x-e2-a": "a",
"lab-session-3-e1-routeur": "routeur",
"vm-sans-rapport": "",
}
for nom, attendu := range cas {
if got := l.machineDuNom(nom); got != attendu {
t.Errorf("machineDuNom(%q) = %q, attendu %q", nom, got, attendu)
}
}
}
func TestAttendu(t *testing.T) {
a := attendu{CodeRetour: 0, SortieContient: "ok"}
if !a.satisfait(0, "tout est ok") || a.satisfait(1, "ok") || a.satisfait(0, "non") {
t.Fatal("règle code_retour + sortie_contient mal appliquée")
}
}
func TestValidationIdentifiants(t *testing.T) {
for _, mauvais := range []string{"../etc", "08/../..", "A", "", "lab;rm"} {
if reIdent.MatchString(mauvais) {
t.Errorf("%q ne doit pas être accepté", mauvais)
}
}
if reInstance.MatchString("not-a-uuid") || !reInstance.MatchString("8c8d5447-80fe-40da-ae65-812e077941f8") {
t.Fatal("validation d'uuid incorrecte")
}
}

113
correcteur/libvirt.go Normal file
View file

@ -0,0 +1,113 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"os/exec"
"time"
)
var errDelaiDepasse = errors.New("délai dépassé")
// virsh lancé dans le conteneur nova_libvirt de Kolla. Les arguments sont
// passés tels quels (pas de shell) : rien n'est interprété sur l'hôte.
func virsh(ctx context.Context, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "docker", append([]string{"exec", "nova_libvirt", "virsh"}, args...)...)
sortie, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return nil, fmt.Errorf("%w : %s", err, ee.Stderr)
}
return nil, err
}
return sortie, nil
}
// Métadonnées que Nova écrit dans la définition libvirt de chaque instance
// (espace de noms http://openstack.org/xmlns/libvirt/nova/…).
type domaine struct {
Metadata struct {
Instance struct {
Nom string `xml:"name"`
Owner struct {
Projet struct {
UUID string `xml:"uuid,attr"`
} `xml:"project"`
} `xml:"owner"`
} `xml:"instance"`
} `xml:"metadata"`
}
func (d domaine) nom() string { return d.Metadata.Instance.Nom }
func (d domaine) projet() string { return d.Metadata.Instance.Owner.Projet.UUID }
func lireDomaine(ctx context.Context, instance string) (domaine, error) {
var d domaine
brut, err := virsh(ctx, "dumpxml", instance)
if err != nil {
return d, err
}
err = xml.Unmarshal(brut, &d)
return d, err
}
// Lance /bin/sh -c <commande> dans la VM (guest-exec), puis interroge
// guest-exec-status jusqu'à la fin du processus.
func executer(ctx context.Context, instance, commande string) (int, string, error) {
lancement, _ := json.Marshal(map[string]any{
"execute": "guest-exec",
"arguments": map[string]any{
"path": "/bin/sh",
"arg": []string{"-c", commande},
"capture-output": true,
},
})
brut, err := virsh(ctx, "qemu-agent-command", instance, string(lancement), "--timeout", "5")
if err != nil {
return 0, "", err
}
var pid struct {
Return struct {
PID int `json:"pid"`
} `json:"return"`
}
if err := json.Unmarshal(brut, &pid); err != nil {
return 0, "", err
}
statut, _ := json.Marshal(map[string]any{"execute": "guest-exec-status", "arguments": map[string]int{"pid": pid.Return.PID}})
for {
select {
case <-ctx.Done():
return 0, "", errDelaiDepasse
case <-time.After(200 * time.Millisecond):
}
brut, err := virsh(ctx, "qemu-agent-command", instance, string(statut), "--timeout", "5")
if err != nil {
if ctx.Err() != nil {
return 0, "", errDelaiDepasse
}
return 0, "", err
}
var r struct {
Return struct {
Exited bool `json:"exited"`
ExitCode int `json:"exitcode"`
OutData string `json:"out-data"`
} `json:"return"`
}
if err := json.Unmarshal(brut, &r); err != nil {
return 0, "", err
}
if r.Return.Exited {
sortie, _ := base64.StdEncoding.DecodeString(r.Return.OutData)
return r.Return.ExitCode, string(sortie), nil
}
}
}

180
correcteur/main.go Normal file
View file

@ -0,0 +1,180 @@
// Correcteur Syllabus (étape 11) : exécute dans les VM des élèves les
// vérifications déclarées dans le lab.yaml, par l'agent invité QEMU.
//
// Il écoute sur un socket Unix accessible au seul groupe www-data (le
// portail) et n'accepte que trois identifiants : instance, lab,
// vérification. La commande exécutée est toujours lue dans le lab.yaml,
// jamais reçue du portail.
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log"
"net"
"os"
"regexp"
"strconv"
"time"
)
const (
tailleMaxRequete = 1024
delaiConnexion = 20 * time.Second
// README du pack de contenus : 10 s maximum par commande.
delaiVerification = 10 * time.Second
maxSimultanees = 8
)
var (
reInstance = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
reIdent = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
)
type requete struct {
Instance string `json:"instance"`
Lab string `json:"lab"`
Verification string `json:"verification"`
}
type reponse struct {
Reussi bool `json:"reussi"`
Erreur string `json:"erreur,omitempty"`
}
// Codes d'erreur renvoyés au portail (jamais la sortie de la commande).
const (
errRequete = "requete_invalide"
errLab = "lab_inconnu"
errVerification = "verification_inconnue"
errMachine = "machine_non_conforme"
errAgent = "agent_injoignable"
errDelai = "delai_depasse"
)
type config struct {
socket string
dossierLabs string
projet string
uidClient int
}
func main() {
cfg := config{
socket: envOu("CORRECTEUR_SOCKET", "/run/correcteur/correcteur.sock"),
dossierLabs: envOu("CORRECTEUR_LABS", "/opt/labs"),
projet: os.Getenv("CORRECTEUR_PROJET"),
}
uid, err := strconv.Atoi(os.Getenv("CORRECTEUR_UID_CLIENT"))
if err != nil || cfg.projet == "" {
log.Fatal("CORRECTEUR_PROJET et CORRECTEUR_UID_CLIENT sont obligatoires")
}
cfg.uidClient = uid
_ = os.Remove(cfg.socket)
ecoute, err := net.Listen("unix", cfg.socket)
if err != nil {
log.Fatalf("écoute impossible sur %s : %v", cfg.socket, err)
}
// Le groupe du processus est www-data (unité systemd) : 0660 réserve le
// socket à root et au portail.
if err := os.Chmod(cfg.socket, 0o660); err != nil {
log.Fatalf("chmod du socket : %v", err)
}
log.Printf("correcteur prêt sur %s (labs : %s)", cfg.socket, cfg.dossierLabs)
places := make(chan struct{}, maxSimultanees)
for {
conn, err := ecoute.Accept()
if err != nil {
log.Printf("accept : %v", err)
continue
}
places <- struct{}{}
go func() {
defer func() { <-places }()
traiter(cfg, conn.(*net.UnixConn))
}()
}
}
func traiter(cfg config, conn *net.UnixConn) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(delaiConnexion))
debut := time.Now()
// Défense en profondeur : en plus des droits du socket, on vérifie
// l'identité du processus à l'autre bout (SO_PEERCRED).
uid, err := uidPair(conn)
if err != nil || (uid != cfg.uidClient && uid != 0) {
log.Printf("connexion refusée (uid %d, %v)", uid, err)
return
}
var req requete
ligne, err := bufio.NewReader(io.LimitReader(conn, tailleMaxRequete)).ReadBytes('\n')
if err == nil {
dec := json.NewDecoder(bytes.NewReader(ligne))
dec.DisallowUnknownFields()
err = dec.Decode(&req)
}
var rep reponse
if err != nil || !reInstance.MatchString(req.Instance) || !reIdent.MatchString(req.Lab) || !reIdent.MatchString(req.Verification) {
rep = reponse{Erreur: errRequete}
} else {
rep = verifier(cfg, req)
}
sortie, _ := json.Marshal(rep)
_, _ = conn.Write(append(sortie, '\n'))
log.Printf("instance=%s lab=%s verification=%s reussi=%t erreur=%q duree=%s",
req.Instance, req.Lab, req.Verification, rep.Reussi, rep.Erreur, time.Since(debut).Round(time.Millisecond))
}
func verifier(cfg config, req requete) reponse {
lab, err := chargerLab(cfg.dossierLabs, req.Lab)
if err != nil {
return reponse{Erreur: errLab}
}
v, ok := lab.verification(req.Verification)
if !ok {
return reponse{Erreur: errVerification}
}
ctx, annuler := context.WithTimeout(context.Background(), delaiVerification+5*time.Second)
defer annuler()
dom, err := lireDomaine(ctx, req.Instance)
if err != nil {
log.Printf("dumpxml %s : %v", req.Instance, err)
return reponse{Erreur: errMachine}
}
if dom.projet() != cfg.projet || lab.machineDuNom(dom.nom()) != v.Machine {
log.Printf("instance %s refusée : projet %q, nom %q, machine attendue %q", req.Instance, dom.projet(), dom.nom(), v.Machine)
return reponse{Erreur: errMachine}
}
ctxExec, annulerExec := context.WithTimeout(ctx, delaiVerification)
defer annulerExec()
code, sortieCmd, err := executer(ctxExec, req.Instance, v.Commande)
switch {
case errors.Is(err, errDelaiDepasse):
return reponse{Erreur: errDelai}
case err != nil:
log.Printf("guest-exec %s : %v", req.Instance, err)
return reponse{Erreur: errAgent}
}
return reponse{Reussi: v.Attendu.satisfait(code, sortieCmd)}
}
func envOu(cle, defaut string) string {
if v := os.Getenv(cle); v != "" {
return v
}
return defaut
}

View file

@ -0,0 +1,14 @@
//go:build !linux
package main
import (
"errors"
"net"
)
// Le correcteur ne tourne que sur l'hyperviseur Linux ; ce fichier permet
// seulement de compiler et tester le reste sur le Mac.
func uidPair(*net.UnixConn) (int, error) {
return -1, errors.New("SO_PEERCRED indisponible hors Linux")
}

View file

@ -0,0 +1,24 @@
package main
import (
"net"
"syscall"
)
func uidPair(conn *net.UnixConn) (int, error) {
brut, err := conn.SyscallConn()
if err != nil {
return -1, err
}
var cred *syscall.Ucred
var errCred error
if err := brut.Control(func(fd uintptr) {
cred, errCred = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
}); err != nil {
return -1, err
}
if errCred != nil {
return -1, errCred
}
return int(cred.Uid), nil
}

View file

@ -0,0 +1,60 @@
# ADR 0003 — Stock chaud dans Redis, Heat pour le ménage, files gérées par Horizon
- **Date** : 2026-10-08
- **Statut** : accepté
## Contexte
Étape 11 : un lab pris dans le stock doit s'ouvrir en moins de 5 s, alors
que Heat met 20 à 40 s à construire une stack. Il faut donc des stacks
construites d'avance, données à un élève au moment où le prof l'admet, sans
jamais donner la même stack à deux élèves, ni perdre une stack (une VM qui
tourne pour rien coûte de la RAM, rare sur ce serveur de 32 Go).
## Décisions
1. **Une liste Redis par lab** (`stock:pretes:{lab}`) contient les stacks
prêtes. La prise est un script Lua (`LPOP` + `ZADD stock:attribuees`)
exécuté d'un bloc par Redis : une stack est toujours soit dans la liste,
soit marquée « en cours d'attribution », jamais nulle part.
2. **Redis fait foi pour ce qui est prêt ; Heat sert au ménage.** Chaque
minute, `labs:remplir-stock` liste les stacks `stock-…` dans Heat et
supprime celles que plus rien ne référence (ni la liste, ni une
attribution, ni une session en base, ni une construction en cours), après
10 min de marge. Une stack orpheline n'est jamais « récupérée » dans le
stock : la supprimer est plus sûr que risquer de la donner deux fois.
3. **Cible du stock** = taille par défaut (1 stack par lab) + élèves des
séances qui commencent dans les 10 min ou déjà ouvertes et pas encore
servis, dans la limite de `capacite_vm` (15 VM, voir journal de l'étape 11).
4. **Fiabilité commune** (`FabriqueStack`) pour le stock et les sessions :
3 essais, chaque essai porte son nom (`-e1`, `-e2`, `-e3`) et supprime sa
stack ratée ; avant un essai, les restes des précédents sont supprimés.
Disjoncteur sur le stock : 3 échecs définitifs de suite sur un lab →
plus de commande pour lui pendant 30 min.
5. **Laravel Horizon** remplace le `queue:work` unique de l'étape 10. Deux
superviseurs à nombre fixe de processus : `sessions` (4) et `stock` (2).
Un élève n'attend jamais derrière le remplissage du stock, et plusieurs
stacks se construisent en parallèle (chaque job bloque un processus
pendant qu'il attend Heat). Horizon était déjà prévu dans l'architecture
du CLAUDE.md ; le besoin réel apparaît ici. Tableau de bord `/horizon`
fermé à tous en production.
## Alternatives écartées
- **Une table MariaDB pour le stock** : possible, mais la prise atomique
demande un verrou de ligne (`SELECT … FOR UPDATE SKIP LOCKED`) ; la liste
Redis + Lua est plus simple, et le prompt de déploiement la demande.
- **Récupérer les stacks orphelines au lieu de les supprimer** : économise
une construction, mais ouvre une fenêtre où la même stack pourrait être
dans la liste deux fois. Refusé.
- **Plusieurs unités systemd `queue:work`** : faisable, mais sans métriques
ni gestion propre de plusieurs files ; Horizon fait les deux.
## Conséquences
- Les VM du stock tournent en permanence (≈ 270 Mo réels chacune au repos).
- Si Redis est vidé, le stock est perdu mais pas les VM : le ménage les
supprime 10 min plus tard, et le stock se reconstruit.
- `retry_after` de la file Redis (330 s) doit rester supérieur au timeout
des superviseurs Horizon (300 s), lui-même supérieur au timeout des jobs
(280 s).

View file

@ -378,3 +378,75 @@ La leçon de cette étape dépasse Heat : dans Keystone, un jeton porte une hist
**Suite** **Suite**
Étape 11 : stock chaud de VM (files Redis) et correction automatique (agent invité QEMU). É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).

View file

@ -0,0 +1,54 @@
# Post-mortem — la première touche frappée coupait la console de l'élève
- **Date** : 2026-10-08 (défaut introduit à l'étape 9, découvert à l'étape 11)
- **Durée** : défaut latent depuis l'étape 9 ; corrigé dans l'heure suivant sa découverte
- **Gravité** : élevée si elle avait atteint une classe — aucun élève n'aurait pu taper une seule commande dans un vrai navigateur. Impact réel nul : aucun utilisateur.
- **Impact** : avec xterm.js, la première frappe de l'élève faisait fermer la connexion à la VM ; la console restait affichée mais muette.
## Résumé
`nova-serialproxy` (sous-protocole WebSocket `binary`) ferme sa connexion
dès qu'il reçoit une trame **texte**. Or xterm.js, dans le navigateur, envoie
les frappes en trames texte (`ws.send(chaîne)`), et le proxy Go les relayait
vers l'amont avec leur type d'origine.
Le test de l'étape 9 (`proxy-console/test-manuel.py`) envoyait des trames
**binaires** (`send_binary`) : il passait, et le critère « une élève tape
`ip a` et voit le résultat » a été déclaré vérifié sans qu'aucun vrai
navigateur n'ait tapé une touche.
## Chronologie
1. Étape 9 : proxy écrit et testé avec un script qui envoie du binaire. Critère validé.
2. Étape 11 : le script de parcours (`portail/tests/Reels/parcours-lab.py`) imite xterm.js et envoie `"\r"` en trame texte pour réveiller la console : aucune invite `login:`.
3. Journaux : `proxy-console` → « connexion amont fermée » 2 s après l'ouverture, à l'instant de la trame texte ; `nova-serialproxy` ne dit rien de plus.
4. Diagnostic par comparaison directe (`/tmp/diag-trames.py`) : trame texte → rien puis connexion perdue.
5. Correctif : `proxy-console/hub.go`, les frappes partent toujours en `websocket.BinaryMessage` vers l'amont. Redéployé ; texte et binaire font apparaître `login:`.
## Cause racine
Le test de l'étape 9 ne reproduisait pas le vrai client : il prenait un
raccourci (binaire) que le navigateur ne prend pas. La différence entre
« le chemin fonctionne » et « le chemin fonctionne avec le vrai client »
n'était pas testée.
## Ce qui a bien fonctionné
- Écrire le test de l'étape 11 comme un vrai navigateur (connexion, CSRF, jeton de console, trames texte) a fait remonter le défaut avant tout pilote.
- Les journaux du proxy, horodatés à la seconde, ont permis de relier la fermeture à la trame en quelques minutes.
## Actions correctives
- [x] Le proxy normalise : toute frappe part en binaire vers `nova-serialproxy`, quel que soit le client.
- [x] `parcours-lab.py` envoie des trames texte, comme xterm.js : il protège désormais contre ce défaut.
- [ ] Étape 12 : vérifier une fois le parcours dans un vrai navigateur (Playwright), la vérification visuelle de l'étape 8 n'ayant pas pu être faite.
## Comprendre
Un test qui passe ne prouve que ce qu'il teste vraiment. Le script de
l'étape 9 parlait au proxy « comme un outil », pas « comme un navigateur » :
il envoyait des octets bruts, alors qu'un navigateur envoie du texte. Les deux
arrivent au même proxy, mais `nova-serialproxy` les traite différemment. La
leçon générale : pour valider un critère utilisateur, le test doit imiter le
vrai client jusque dans les détails qui paraissent anodins — ici, le type de
trame WebSocket.

View file

@ -1,49 +1,93 @@
# Runbook — Labs et stacks Heat (étape 10) # Runbook — Labs, stock chaud et correction (étapes 10 et 11)
## Où sont les choses ## Où sont les choses
| Élément | Emplacement | | Élément | Emplacement |
|---|---| |---|---|
| Source éditoriale des labs | `paillasse-contenus/` (dépôt de contenu, pas de code) | | Source éditoriale des labs | `paillasse-contenus/` (dépôt de contenu, pas de code) |
| Copie de travail lue par le portail | `labs/` à la racine de ce dépôt → synchronisée vers `/opt/labs` sur le serveur | | Copie de travail lue par le portail et le correcteur | `labs/` à la racine de ce dépôt → synchronisée vers `/opt/labs` sur le serveur |
| Catalogue (table `labs`) | `php artisan labs:synchroniser`, lancé par Ansible après chaque synchronisation de `labs/` |
| Lecture d'un `lab.yaml` | `portail/app/Services/Labs/DefinitionLab.php` | | Lecture d'un `lab.yaml` | `portail/app/Services/Labs/DefinitionLab.php` |
| Traduction lab → gabarit Heat | `portail/app/Services/OpenStack/GenerateurTemplateHeat.php` | | Traduction lab → gabarit Heat | `portail/app/Services/OpenStack/GenerateurTemplateHeat.php` |
| Authentification OpenStack partagée | `portail/app/Services/OpenStack/KeystoneAuth.php` (mot de passe, pas application credential — voir [ADR 0002](../adr/0002-mot-de-passe-pour-heat.md)) | | Construction d'une stack (3 essais) | `portail/app/Services/OpenStack/FabriqueStack.php` |
| Appels Heat | `portail/app/Services/OpenStack/HeatClient.php` | | Authentification OpenStack | `portail/app/Services/OpenStack/KeystoneAuth.php` (mot de passe — [ADR 0002](../adr/0002-mot-de-passe-pour-heat.md), rotation : [compte-service-openstack.md](compte-service-openstack.md)) |
| Création de stack (à l'admission) | `portail/app/Jobs/CreerStackLab.php` | | Stock chaud | `portail/app/Services/Labs/StockChaud.php`, job `CreerStackStock`, commande `labs:remplir-stock` chaque minute — [ADR 0003](../adr/0003-stock-chaud-et-horizon.md) |
| Suppression de stack (à l'expiration ou à la fin) | `portail/app/Jobs/SupprimerStackLab.php` | | Admission (stock ou construction) | `SeanceController::admettre` → `CreerStackLab` si le stock est vide |
| Expiration automatique | commande `labs:expirer-sessions` (`portail/app/Console/Commands/ExpirerSessionsLab.php`), planifiée chaque minute (`routes/console.php` + cron `www-data` sur le serveur) | | Suppression et expiration | `SupprimerStackLab`, commande `labs:expirer-sessions` chaque minute |
| Worker qui exécute les jobs | service systemd `portail-worker` (`queue:work`) | | Files de jobs | Laravel Horizon, service systemd `portail-horizon` : superviseurs `sessions` (4 processus) et `stock` (2), `config/horizon.php` |
| Correcteur | service systemd `correcteur` (Go, `correcteur/`, binaire `/usr/local/sbin/correcteur`), socket `/run/correcteur/correcteur.sock` (`0660 root:www-data`) |
| Correction côté portail | `ClientCorrecteur`, `CorrigerSession`, table `corrections` |
| Tests du parcours réel | `portail/tests/Reels/` (préparation + parcours prof/élève) |
## États d'une session de lab
`en_attente` → (prof : Admettre) → `actif` tout de suite si le stock a une
stack, sinon `admis` puis `actif` quand `CreerStackLab` a fini → `expire` ou
`termine`. Après 3 essais ratés : `echec`, la raison est affichée au prof
qui peut cliquer « Réessayer » (retour à `admis`).
## Ajouter un nouveau lab ## Ajouter un nouveau lab
1. Le lab existe déjà en tant que contenu dans `paillasse-contenus/{id}/lab.yaml`, avec un format `reseaux: [{nom, cidr}]` et `machines: [{nom, image, flavor, reseaux: [...]}]` (l'ordre des réseaux d'une machine décide de l'ordre des interfaces : la première listée sera `ens3`, la suivante `ens4`, etc.). 1. Le lab existe dans `paillasse-contenus/{id}/lab.yaml`. L'ordre des réseaux d'une machine décide de l'ordre des interfaces (`ens3`, `ens4`…). Les vérifications dont l'id commence par `bonus-` ne comptent pas pour « réussi ».
2. Copier (ou faire relire) ce `lab.yaml` dans `labs/{id}/lab.yaml` de ce dépôt. 2. Le copier dans `labs/{id}/lab.yaml` de ce dépôt.
3. Vérifier que les images et flavors nommés existent déjà dans OpenStack (`openstack image list`, `openstack flavor list`). 3. Vérifier que les images et flavors existent (`openstack image list`, `openstack flavor list`) et que l'image a `hw_qemu_guest_agent=yes` (sinon le correcteur répond `agent_injoignable`).
4. Déployer (`ansible-playbook portail.yml`) : la tâche `04-labs.yml` synchronise `labs/` vers `/opt/labs` sur le serveur. 4. Déployer : `cd ansible && script -q /dev/null ansible-playbook correcteur.yml -i inventory.ini` (portail + correcteur). Le lab entre au catalogue, et son stock se remplit dans la minute.
5. Tester : admettre un élève de test sur ce lab, vérifier que la stack passe en `CREATE_COMPLETE` et que chaque terminal s'ouvre. 5. Tester le parcours : `portail/tests/Reels/` (voir l'en-tête des deux scripts), puis supprimer les données de test.
## Déclencher l'expiration manuellement ## Stock chaud
``` ```
ssh paillasse "cd /var/www/portail && sudo -u www-data HOME=/tmp php8.3 artisan labs:expirer-sessions" ssh paillasse "cd /var/www/portail && sudo -u www-data HOME=/tmp php8.3 artisan labs:remplir-stock"
``` ```
## Inspecter une stack en cours sur le serveur Affiche, par lab : cible, prêtes, en construction, commandées, retirées,
disjoncteur. Réglages (`.env` du portail, sinon valeurs de `config/labs.php`) :
- `LABS_CAPACITE_VM` (15) : plafond de VM `eleve.petit`, sessions et stock compris ;
- `LABS_STOCK_TAILLE` (1) : stacks prêtes par lab hors séance.
Disjoncteur ouvert (« oui ») : 3 échecs définitifs de suite sur ce lab. Lire
`storage/logs/laravel.log` (`CreerStackStock`), corriger la cause (image,
flavor, quota…), puis le réarmer sans attendre ses 30 min :
```
sudo -u www-data HOME=/tmp php8.3 artisan tinker --execute='Illuminate\Support\Facades\Redis::del("stock:echecs:{lab}");'
```
Vider le stock d'un lab (les stacks seront supprimées par le ménage) : passer
`LABS_STOCK_TAILLE=0` puis attendre une minute, ou supprimer la clé
`stock:pretes:{lab}` (le ménage supprime alors les stacks orphelines après 10 min).
## Correcteur
```
systemctl status correcteur
journalctl -u correcteur -f # une ligne par vérification : instance, lab, id, résultat, durée
```
Il n'accepte que `{"instance": uuid, "lab": id, "verification": id}`, refuse
tout champ en plus, et n'exécute que la commande du `lab.yaml`, en root dans
la VM, par `guest-exec` (10 s maximum). Avant d'exécuter, il vérifie dans les
métadonnées libvirt que la VM appartient au projet `paillasse` et porte le
nom de la machine visée. Codes d'erreur : `requete_invalide`, `lab_inconnu`,
`verification_inconnue`, `machine_non_conforme`, `agent_injoignable`,
`delai_depasse`.
## Inspecter les stacks sur le serveur
``` ```
sudo -i sudo -i
export PATH=/opt/kolla-venv/bin:$PATH export PATH=/opt/kolla-venv/bin:$PATH
export OS_CLIENT_CONFIG_FILE=/etc/kolla/clouds-paillasse.yaml export OS_CLIENT_CONFIG_FILE=/etc/kolla/clouds-paillasse.yaml OS_CLOUD=paillasse-service
export OS_CLOUD=paillasse-service openstack stack list # lab-session-{id}-e{n} : sessions ; stock-{lab}-{xxxxxx}-e{n} : stock
openstack stack list
openstack stack show lab-session-{id}
openstack server list openstack server list
``` ```
## Pièges rencontrés ## Pièges rencontrés
- **Heat refuse de créer une stack avec une application credential** (`Delegated tokens cannot manage trusts`) : Heat a besoin de créer un trust Keystone, qu'un jeton déjà délégué ne peut jamais déléguer à son tour. Le compte `paillasse-service` s'authentifie par mot de passe pour cette raison — voir [ADR 0002](../adr/0002-mot-de-passe-pour-heat.md). Ne jamais revenir à une application credential pour ce compte. - **Heat refuse une application credential** (`Delegated tokens cannot manage trusts`), même `--unrestricted` : voir [ADR 0002](../adr/0002-mot-de-passe-pour-heat.md).
- **Après un déploiement de code, le worker doit redémarrer** (`notify: redémarrer le worker de file` sur la tâche de synchronisation) : un `queue:work` déjà lancé garde l'ancien code en mémoire tant qu'il n'est pas relancé. - **Horizon garde le code et la configuration en mémoire** : le rôle Ansible le redémarre après chaque synchronisation de code ou changement de `.env`.
- `sudo -u www-data php artisan ...` échoue sans `HOME=/tmp` (« Writing to directory /var/www/.config/psysh is not allowed ») : `www-data` n'a pas de `$HOME` inscriptible sur ce serveur. - **Timeouts emboîtés** : job (280 s) < superviseur Horizon (300 s) < `REDIS_QUEUE_RETRY_AFTER` (330 s). Sinon Redis redonne un job encore en cours à un autre processus, et deux stacks sont construites.
- Une stack Heat appartient à une session de lab (un élève, une tentative), jamais à une classe : ne pas chercher à réutiliser une stack entre deux élèves ou deux tentatives. - **Console muette après la première touche** : `nova-serialproxy` ferme la connexion à la première trame WebSocket texte. Le proxy Go envoie tout en binaire — voir [post-mortem du 2026-10-08](../postmortems/2026-10-08-console-trames-texte.md).
- `sudo -u www-data php artisan ...` exige `HOME=/tmp` (pas de `$HOME` inscriptible pour `www-data`).
- Une stack appartient à une session (un élève, une tentative) ; une stack du stock n'y retourne jamais après avoir été donnée.

View file

@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use App\Services\Labs\StockChaud;
use Illuminate\Console\Command;
class RemplirStock extends Command
{
protected $signature = 'labs:remplir-stock';
protected $description = 'Ajuste le stock chaud de chaque lab à sa cible et supprime les stacks orphelines';
public function handle(StockChaud $stock): void
{
$lignes = [];
foreach ($stock->remplir() as $lab => $etat) {
$lignes[] = [$lab, $etat['cible'], $etat['pretes'], $etat['en_cours'], $etat['commandees'], $etat['retirees'], $etat['disjoncte'] ? 'oui' : 'non'];
}
$this->table(['Lab', 'Cible', 'Prêtes', 'En cours', 'Commandées', 'Retirées', 'Disjoncteur'], $lignes);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Models\Lab;
use App\Services\Labs\DefinitionLab;
use Illuminate\Console\Command;
/**
* Le catalogue (table labs) suit les lab.yaml présents dans labs/ : lancé
* par Ansible après chaque déploiement. Ne supprime jamais un lab : des
* séances passées peuvent le référencer.
*/
class SynchroniserLabs extends Command
{
protected $signature = 'labs:synchroniser';
protected $description = 'Crée ou met à jour le catalogue des labs depuis les lab.yaml';
public function handle(): void
{
foreach (DefinitionLab::identifiantsDisponibles() as $identifiant) {
$definition = DefinitionLab::depuis($identifiant);
$lab = Lab::updateOrCreate(
['identifiant' => $definition->identifiant],
['titre' => $definition->titre, 'niveau' => $definition->niveau, 'duree_min' => $definition->dureeMin],
);
$this->line(($lab->wasRecentlyCreated ? 'créé' : 'à jour')." : {$identifiant}");
}
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Models\LabSession;
use App\Services\Labs\CorrigerSession;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class CorrectionController extends Controller
{
public function corriger(LabSession $labSession, CorrigerSession $correction): RedirectResponse
{
abort_if($labSession->eleve_id !== Auth::guard('eleve')->id(), 403);
$retour = redirect()->route('seances.eleve.attente', $labSession->seance_id);
if ($labSession->etat !== LabSession::ACTIF) {
return $retour;
}
try {
$correction->corriger($labSession);
} catch (RuntimeException $e) {
Log::error("Correction impossible pour la session {$labSession->id}", ['raison' => $e->getMessage()]);
return $retour->with('correction_indisponible', true);
}
return $retour;
}
}

View file

@ -6,21 +6,45 @@ use App\Jobs\CreerStackLab;
use App\Models\Classe; use App\Models\Classe;
use App\Models\LabSession; use App\Models\LabSession;
use App\Models\Seance; use App\Models\Seance;
use App\Services\Labs\StockChaud;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\View\View; use Illuminate\View\View;
class SeanceController extends Controller class SeanceController extends Controller
{ {
/**
* Sans date : la salle d'attente s'ouvre tout de suite. Avec une date :
* séance planifiée, le stock chaud du lab se remplit 10 minutes avant.
*/
public function store(Request $request, Classe $classe): RedirectResponse public function store(Request $request, Classe $classe): RedirectResponse
{ {
abort_if($classe->user_id !== Auth::guard('web')->id(), 403); abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
$donnees = $request->validate([ $donnees = $request->validate([
'lab_id' => ['required', 'exists:labs,id'], 'lab_id' => ['required', 'exists:labs,id'],
'prevue_a' => ['nullable', 'date_format:Y-m-d\TH:i'],
]); ]);
if (! empty($donnees['prevue_a'])) {
$prevueA = Carbon::createFromFormat('Y-m-d\TH:i', $donnees['prevue_a'], config('app.fuseau_utilisateurs'))->utc();
if ($prevueA->isPast()) {
return back()->withErrors(['prevue_a' => 'Choisis une date à venir.'])->withInput();
}
$classe->seances()->create([
'lab_id' => $donnees['lab_id'],
'statut' => Seance::PLANIFIEE,
'prevue_a' => $prevueA,
]);
return redirect()->route('mes-classes.show', $classe)->with('seance_planifiee', true);
}
$seance = $classe->seances()->create([ $seance = $classe->seances()->create([
'lab_id' => $donnees['lab_id'], 'lab_id' => $donnees['lab_id'],
'statut' => Seance::SALLE_ATTENTE, 'statut' => Seance::SALLE_ATTENTE,
@ -30,12 +54,23 @@ class SeanceController extends Controller
return redirect()->route('seances.attente', $seance); return redirect()->route('seances.attente', $seance);
} }
public function ouvrir(Seance $seance): RedirectResponse
{
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
if ($seance->statut === Seance::PLANIFIEE) {
$seance->update(['statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now()]);
}
return redirect()->route('seances.attente', $seance);
}
public function attente(Seance $seance): View public function attente(Seance $seance): View
{ {
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403); abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
return view('seances.attente', [ return view('seances.attente', [
'seance' => $seance->load(['classe', 'lab', 'labSessions.eleve', 'labSessions.machines']), 'seance' => $seance->load(['classe', 'lab', 'labSessions.eleve', 'labSessions.machines', 'labSessions.derniereCorrection']),
]); ]);
} }
@ -51,22 +86,39 @@ class SeanceController extends Controller
} }
/** /**
* Admettre un élève, c'est lancer son lab : la stack Heat se construit * Admettre un élève, c'est lancer son lab. Chemin rapide : une stack
* en tâche de fond (étape 10), le terminal apparaît une fois prête. * prête est prise dans le stock chaud et attribuée tout de suite.
* Chemin lent (stock vide) : CreerStackLab la construit (20 à 40 s).
* Aussi utilisé pour relancer une session en échec.
*/ */
public function admettre(Seance $seance, LabSession $labSession): RedirectResponse public function admettre(Seance $seance, LabSession $labSession, StockChaud $stock): RedirectResponse
{ {
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403); abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
abort_if($labSession->seance_id !== $seance->id, 404); abort_if($labSession->seance_id !== $seance->id, 404);
$dureeMinutes = $labSession->lab->duree_min ?? 45; // Mise à jour conditionnelle en une seule requête SQL : un double clic
// (ou deux onglets) ne peut pas lancer deux stacks pour le même élève.
$pris = LabSession::whereKey($labSession->id)
->whereIn('etat', [LabSession::EN_ATTENTE, LabSession::ECHEC])
->update([
'etat' => LabSession::ADMIS,
'raison_echec' => null,
'expire_a' => now()->addMinutes($labSession->lab->duree_min ?? 45),
]);
$labSession->update([ if ($pris === 0) {
'etat' => LabSession::ADMIS, return redirect()->route('seances.attente', $seance);
'expire_a' => now()->addMinutes($dureeMinutes), }
]);
CreerStackLab::dispatch($labSession); $labSession->refresh();
$pret = $stock->prendre($labSession->lab->identifiant);
if ($pret !== null) {
$labSession->attribuerStack($pret);
$stock->confirmerAttribution($pret);
} else {
CreerStackLab::dispatch($labSession);
}
return redirect()->route('seances.attente', $seance); return redirect()->route('seances.attente', $seance);
} }
@ -89,12 +141,7 @@ class SeanceController extends Controller
public function attenteEleve(Seance $seance): View|RedirectResponse public function attenteEleve(Seance $seance): View|RedirectResponse
{ {
$eleve = Auth::guard('eleve')->user(); $labSession = $this->sessionDeLEleve($seance);
abort_if($eleve->classe_id !== $seance->classe_id, 403);
$labSession = LabSession::where('seance_id', $seance->id)
->where('eleve_id', $eleve->id)
->first();
if ($labSession === null) { if ($labSession === null) {
return redirect()->route('eleve'); return redirect()->route('eleve');
@ -102,7 +149,27 @@ class SeanceController extends Controller
return view('seances.attente-eleve', [ return view('seances.attente-eleve', [
'seance' => $seance->load('lab'), 'seance' => $seance->load('lab'),
'labSession' => $labSession, 'labSession' => $labSession->load(['machines', 'derniereCorrection']),
]); ]);
} }
/**
* Interrogé chaque seconde par la page d'attente de l'élève : la page se
* recharge dès que l'état change (au lieu d'un rafraîchissement toutes
* les 5 s, incompatible avec un lab ouvert en moins de 5 s).
*/
public function etatEleve(Seance $seance): JsonResponse
{
return response()->json(['etat' => $this->sessionDeLEleve($seance)?->etat]);
}
private function sessionDeLEleve(Seance $seance): ?LabSession
{
$eleve = Auth::guard('eleve')->user();
abort_if($eleve->classe_id !== $seance->classe_id, 403);
return LabSession::where('seance_id', $seance->id)
->where('eleve_id', $eleve->id)
->first();
}
} }

View file

@ -4,7 +4,7 @@ namespace App\Jobs;
use App\Models\LabSession; use App\Models\LabSession;
use App\Services\Labs\DefinitionLab; use App\Services\Labs\DefinitionLab;
use App\Services\OpenStack\GenerateurTemplateHeat; use App\Services\OpenStack\FabriqueStack;
use App\Services\OpenStack\HeatClient; use App\Services\OpenStack\HeatClient;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -12,70 +12,86 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
/** /**
* Lance la stack Heat d'un lab pour un élève admis (étape 10). Dispatché par * Construit la stack d'un élève admis quand le stock chaud du lab est vide
* SeanceController::admettre — « admettre » un élève, c'est lancer son lab. * (chemin lent : 20 à 40 s). Un essai raté supprime sa stack et lève une
* exception : Laravel relance le job (2 nouveaux essais, après 15 puis 45 s).
* Après le 3ᵉ échec, failed() passe la session en « echec », visible du prof.
*/ */
class CreerStackLab implements ShouldQueue class CreerStackLab implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1; public int $tries = 3;
public function __construct(private readonly LabSession $labSession) {} /** @var array<int, int> */
public array $backoff = [15, 45];
public function handle(GenerateurTemplateHeat $generateur, HeatClient $heat): void // Doit rester sous le timeout du superviseur Horizon (300 s), lui-même
// sous retry_after (330 s, voir .env) : sinon un job encore en cours
// serait redonné à un autre processus.
public int $timeout = 280;
public function __construct(private readonly LabSession $labSession)
{ {
$lab = DefinitionLab::depuis($this->labSession->lab->identifiant); $this->onQueue('sessions');
$nomStack = "lab-session-{$this->labSession->id}"; }
['template' => $template] = $generateur->genererDepuis($lab, $nomStack); public function handle(FabriqueStack $fabrique, HeatClient $heat): void
{
$session = $this->labSession->fresh();
$stack = $heat->creerStack($nomStack, $template); // Expirée ou relancée autrement entre-temps : plus rien à construire.
if ($session === null || $session->etat !== LabSession::ADMIS) {
return;
}
$this->labSession->forceFill([ $base = "lab-session-{$session->id}";
'heat_stack_id' => $stack['id'], $fabrique->nettoyerEssais($base);
'heat_stack_name' => $nomStack,
])->save();
$etat = $this->attendreCompletion($heat, $nomStack, $stack['id']); $stack = $fabrique->fabriquer(
DefinitionLab::depuis($session->lab->identifiant),
"{$base}-e{$this->attempts()}",
fn (string $nom, string $id) => $session->forceFill(['heat_stack_name' => $nom, 'heat_stack_id' => $id])->save(),
);
if ($etat['statut'] !== 'CREATE_COMPLETE') { if ($session->fresh()->etat !== LabSession::ADMIS) {
Log::error("CreerStackLab : la stack {$nomStack} n'a pas abouti", $etat); $heat->supprimerStack($stack['nom'], $stack['id']);
return; return;
} }
foreach ($lab->machines as $machine) { $session->attribuerStack($stack);
$cle = GenerateurTemplateHeat::cle($machine['nom']);
$this->labSession->machines()->create([
'nom' => $machine['nom'],
'instance_id' => $etat['sorties']["{$cle}_id"],
]);
}
$this->labSession->forceFill(['etat' => LabSession::ACTIF])->save();
} }
/** public function failed(?Throwable $exception): void
* Heat crée une stack de cette taille (réseaux + quelques VM) en
* général en moins d'une minute : on attend jusqu'à 2 minutes.
*
* @return array{statut: string, raison: ?string, sorties: array<string, mixed>}
*/
private function attendreCompletion(HeatClient $heat, string $nom, string $id): array
{ {
for ($tentative = 0; $tentative < 60; $tentative++) { $session = $this->labSession->fresh();
$etat = $heat->etatStack($nom, $id);
if (! str_ends_with($etat['statut'], '_IN_PROGRESS')) { if ($session === null) {
return $etat; return;
}
sleep(2);
} }
return $etat; $session->forceFill([
'etat' => LabSession::ECHEC,
'raison_echec' => Str::limit($exception?->getMessage() ?? 'raison inconnue', 250),
'heat_stack_name' => null,
'heat_stack_id' => null,
])->save();
Log::error("CreerStackLab : session {$session->id} en échec après {$this->tries} essais", [
'raison' => $exception?->getMessage(),
]);
// Le dernier essai a normalement supprimé sa stack ; pas si son
// processus a été tué (timeout) : on repasse derrière.
try {
app(FabriqueStack::class)->nettoyerEssais("lab-session-{$session->id}");
} catch (Throwable $e) {
Log::error("CreerStackLab : nettoyage impossible pour la session {$session->id}", ['raison' => $e->getMessage()]);
}
} }
} }

View file

@ -0,0 +1,67 @@
<?php
namespace App\Jobs;
use App\Services\Labs\DefinitionLab;
use App\Services\Labs\StockChaud;
use App\Services\OpenStack\FabriqueStack;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Construit une stack pour le stock chaud d'un lab (commandée par
* StockChaud::remplir). Mêmes règles de fiabilité que CreerStackLab :
* 3 essais, chaque essai raté supprime sa stack. Seule une stack
* CREATE_COMPLETE entre dans le stock.
*/
class CreerStackStock implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [15, 45];
public int $timeout = 280;
public function __construct(
private readonly string $lab,
private readonly string $base,
) {
$this->onQueue('stock');
}
public function handle(FabriqueStack $fabrique, StockChaud $stock): void
{
$fabrique->nettoyerEssais($this->base);
$pret = $fabrique->fabriquer(DefinitionLab::depuis($this->lab), "{$this->base}-e{$this->attempts()}");
$stock->deposer($this->lab, $pret);
$stock->commandeTerminee($this->lab, $this->base);
$stock->reinitialiserEchecs($this->lab);
}
public function failed(?Throwable $exception): void
{
$stock = app(StockChaud::class);
$stock->commandeTerminee($this->lab, $this->base);
$stock->compterEchec($this->lab);
Log::error("CreerStackStock : {$this->base} en échec après {$this->tries} essais", [
'raison' => $exception?->getMessage(),
]);
try {
app(FabriqueStack::class)->nettoyerEssais($this->base);
} catch (Throwable $e) {
Log::error("CreerStackStock : nettoyage impossible pour {$this->base}", ['raison' => $e->getMessage()]);
}
}
}

View file

@ -20,7 +20,10 @@ class SupprimerStackLab implements ShouldQueue
public int $tries = 3; public int $tries = 3;
public function __construct(private readonly LabSession $labSession) {} public function __construct(private readonly LabSession $labSession)
{
$this->onQueue('sessions');
}
public function handle(HeatClient $heat): void public function handle(HeatClient $heat): void
{ {

View file

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Une passe de vérification d'une session de lab (bouton « Vérifier mon
* travail »). details : une entrée par vérification du lab.yaml, avec
* id, titre, points, reussi et message (message_echec du lab si échouée).
*/
class Correction extends Model
{
protected $fillable = ['lab_session_id', 'reussi', 'note', 'note_max', 'details'];
protected $casts = [
'reussi' => 'boolean',
'details' => 'array',
];
public function labSession(): BelongsTo
{
return $this->belongsTo(LabSession::class);
}
}

View file

@ -6,6 +6,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\DB;
class LabSession extends Model class LabSession extends Model
{ {
@ -23,7 +25,11 @@ class LabSession extends Model
public const EXPIRE = 'expire'; public const EXPIRE = 'expire';
protected $fillable = ['seance_id', 'eleve_id', 'lab_id', 'heat_stack_id', 'heat_stack_name', 'etat', 'expire_a']; // Trois créations de stack ratées de suite (voir CreerStackLab) : le prof
// le voit dans la salle d'attente et peut relancer.
public const ECHEC = 'echec';
protected $fillable = ['seance_id', 'eleve_id', 'lab_id', 'heat_stack_id', 'heat_stack_name', 'etat', 'raison_echec', 'expire_a'];
protected $casts = [ protected $casts = [
'expire_a' => 'datetime', 'expire_a' => 'datetime',
@ -48,4 +54,38 @@ class LabSession extends Model
{ {
return $this->hasMany(LabSessionMachine::class); return $this->hasMany(LabSessionMachine::class);
} }
/**
* Relie une stack prête à cette session : venue du stock chaud
* (SeanceController::admettre) ou construite à la demande (CreerStackLab).
*
* @param array{nom: string, id: string, instances: array<string, string>} $stack
*/
public function attribuerStack(array $stack): void
{
DB::transaction(function () use ($stack) {
$this->machines()->delete();
foreach ($stack['instances'] as $nom => $instanceId) {
$this->machines()->create(['nom' => $nom, 'instance_id' => $instanceId]);
}
$this->forceFill([
'heat_stack_name' => $stack['nom'],
'heat_stack_id' => $stack['id'],
'etat' => self::ACTIF,
'raison_echec' => null,
])->save();
});
}
public function corrections(): HasMany
{
return $this->hasMany(Correction::class);
}
public function derniereCorrection(): HasOne
{
return $this->hasOne(Correction::class)->latestOfMany();
}
} }

View file

@ -19,9 +19,10 @@ class Seance extends Model
public const TERMINEE = 'terminee'; public const TERMINEE = 'terminee';
protected $fillable = ['classe_id', 'lab_id', 'statut', 'demarree_a', 'terminee_a']; protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a'];
protected $casts = [ protected $casts = [
'prevue_a' => 'datetime',
'demarree_a' => 'datetime', 'demarree_a' => 'datetime',
'terminee_a' => 'datetime', 'terminee_a' => 'datetime',
]; ];

View file

@ -0,0 +1,25 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
}
// Tableau de bord /horizon fermé à tout le monde en production : il
// expose le contenu des jobs. À ouvrir pour le rôle opérateur, plus tard.
protected function gate(): void
{
Gate::define('viewHorizon', fn ($user = null) => false);
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Services\Labs;
use RuntimeException;
/**
* Parle au service correcteur (Go, sur l'hyperviseur) par son socket Unix.
* Le portail n'envoie que trois identifiants : jamais une commande. Les
* commandes sont lues dans le lab.yaml par le correcteur lui-même.
*/
class ClientCorrecteur
{
/**
* @return array{reussi: bool, erreur: ?string}
*/
public function verifier(string $instanceId, string $lab, string $verification): array
{
$flux = @stream_socket_client('unix://'.config('labs.correcteur.socket'), $codeErreur, $messageErreur, 2);
if ($flux === false) {
throw new RuntimeException("Correcteur injoignable : {$messageErreur}");
}
// 10 s par vérification côté correcteur, plus une marge.
stream_set_timeout($flux, 15);
fwrite($flux, json_encode(['instance' => $instanceId, 'lab' => $lab, 'verification' => $verification])."\n");
$ligne = fgets($flux);
fclose($flux);
$reponse = is_string($ligne) ? json_decode($ligne, true) : null;
if (! is_array($reponse) || ! is_bool($reponse['reussi'] ?? null)) {
throw new RuntimeException('Réponse illisible du correcteur');
}
return ['reussi' => $reponse['reussi'], 'erreur' => $reponse['erreur'] ?? null];
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Services\Labs;
use App\Models\Correction;
use App\Models\LabSession;
/**
* Passe toutes les vérifications du lab sur les VM d'une session et
* enregistre la note. Le lab est « réussi » quand toutes les vérifications
* hors bonus passent ; les bonus (id « bonus-… ») ajoutent des points
* au-delà de la note maximale.
*/
class CorrigerSession
{
// Erreurs techniques du correcteur : l'élève n'y est pour rien, le
// message doit le lui dire plutôt que de lui faire chercher une erreur.
private const MESSAGES_TECHNIQUES = [
'agent_injoignable' => 'La machine ne répond pas à la vérification. Attends quelques secondes après un redémarrage, puis réessaie.',
'delai_depasse' => 'La vérification a pris plus de 10 secondes sur la machine.',
];
public function __construct(private readonly ClientCorrecteur $client) {}
public function corriger(LabSession $session): Correction
{
$lab = DefinitionLab::depuis($session->lab->identifiant);
$instances = $session->machines()->pluck('instance_id', 'nom');
$details = [];
$note = 0;
$noteMax = 0;
$reussi = true;
foreach ($lab->verifications as $verification) {
$resultat = isset($instances[$verification['machine']])
? $this->client->verifier($instances[$verification['machine']], $lab->identifiant, $verification['id'])
: ['reussi' => false, 'erreur' => 'machine_absente'];
if (! $verification['bonus']) {
$noteMax += $verification['points'];
$reussi = $reussi && $resultat['reussi'];
}
if ($resultat['reussi']) {
$note += $verification['points'];
}
$details[] = [
'id' => $verification['id'],
'titre' => $verification['titre'],
'points' => $verification['points'],
'bonus' => $verification['bonus'],
'reussi' => $resultat['reussi'],
'message' => $resultat['reussi'] ? null : (self::MESSAGES_TECHNIQUES[$resultat['erreur']] ?? $verification['message_echec']),
];
}
return $session->corrections()->create([
'reussi' => $reussi,
'note' => $note,
'note_max' => $noteMax,
'details' => $details,
]);
}
}

View file

@ -8,34 +8,66 @@ use Symfony\Component\Yaml\Yaml;
/** /**
* Lit le lab.yaml d'un lab (voir paillasse-contenus/README.md pour le * Lit le lab.yaml d'un lab (voir paillasse-contenus/README.md pour le
* format), pour que GenerateurTemplateHeat puisse en faire une stack. * format) : topologie pour GenerateurTemplateHeat, vérifications pour la
* correction.
*/ */
class DefinitionLab class DefinitionLab
{ {
public readonly string $identifiant; public readonly string $identifiant;
public readonly string $titre;
public readonly ?string $niveau;
public readonly ?int $dureeMin;
/** @var array<int, array{nom: string, cidr: string}> */ /** @var array<int, array{nom: string, cidr: string}> */
public readonly array $reseaux; public readonly array $reseaux;
/** @var array<int, array{nom: string, image: string, flavor: string, reseaux: array<int, string>, preparation: ?string}> */ /** @var array<int, array{nom: string, image: string, flavor: string, reseaux: array<int, string>, preparation: ?string}> */
public readonly array $machines; public readonly array $machines;
/** @var array<int, array{id: string, titre: string, machine: string, points: int, message_echec: string, bonus: bool}> */
public readonly array $verifications;
public static function depuis(string $identifiantLab): self public static function depuis(string $identifiantLab): self
{ {
if (! preg_match('/^[a-z0-9][a-z0-9-]*$/', $identifiantLab)) {
throw new RuntimeException("Identifiant de lab invalide : {$identifiantLab}");
}
$chemin = config('labs.dossier')."/{$identifiantLab}/lab.yaml"; $chemin = config('labs.dossier')."/{$identifiantLab}/lab.yaml";
if (! File::exists($chemin)) { if (! File::exists($chemin)) {
throw new RuntimeException("Lab introuvable : {$chemin}"); throw new RuntimeException("Lab introuvable : {$chemin}");
} }
$donnees = Yaml::parseFile($chemin); return new self(Yaml::parseFile($chemin));
}
return new self($donnees); /**
* @return array<int, string>
*/
public static function identifiantsDisponibles(): array
{
return collect(File::directories(config('labs.dossier')))
->map(fn (string $dossier) => basename($dossier))
->filter(fn (string $id) => File::exists(config('labs.dossier')."/{$id}/lab.yaml"))
->values()
->all();
}
public function nombreMachines(): int
{
return count($this->machines);
} }
private function __construct(array $donnees) private function __construct(array $donnees)
{ {
$this->identifiant = $donnees['id']; $this->identifiant = $donnees['id'];
$this->titre = $donnees['titre'];
$this->niveau = $donnees['niveau'] ?? null;
$this->dureeMin = $donnees['duree_min'] ?? null;
$this->reseaux = $donnees['reseaux']; $this->reseaux = $donnees['reseaux'];
$this->machines = array_map( $this->machines = array_map(
fn (array $m) => [ fn (array $m) => [
@ -47,5 +79,18 @@ class DefinitionLab
], ],
$donnees['machines'], $donnees['machines'],
); );
// Les commandes et le résultat attendu ne sont lus que par le
// correcteur, sur l'hyperviseur : le portail n'en a pas besoin.
$this->verifications = array_map(
fn (array $v) => [
'id' => $v['id'],
'titre' => $v['titre'],
'machine' => $v['machine'],
'points' => (int) ($v['points'] ?? 1),
'message_echec' => $v['message_echec'] ?? 'Vérification non réussie.',
'bonus' => str_starts_with($v['id'], 'bonus-'),
],
$donnees['verifications'] ?? [],
);
} }
} }

View file

@ -0,0 +1,361 @@
<?php
namespace App\Services\Labs;
use App\Jobs\CreerStackStock;
use App\Models\Lab;
use App\Models\LabSession;
use App\Models\LabSessionMachine;
use App\Models\Seance;
use App\Services\OpenStack\HeatClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
use Throwable;
/**
* Stock chaud (étape 11) : pour chaque lab, une liste Redis de stacks déjà
* construites, prêtes à être données à un élève sans attendre Heat.
*
* Clés Redis :
* - stock:pretes:{lab} liste (JSON d'une stack prête), FIFO ;
* - stock:commandes:{lab} zset base de nom => horodatage : stacks en construction ;
* - stock:attribuees zset JSON => horodatage : stacks sorties du stock, pas
* encore enregistrées en base (fenêtre de quelques ms) ;
* - stock:echecs:{lab} compteur d'échecs consécutifs (disjoncteur, 30 min).
*
* Redis fait foi pour ce qui est prêt ; Heat sert à retrouver les fuites
* (stacks « stock-… » que plus rien ne référence), supprimées par menage().
*/
class StockChaud
{
private const ATTRIBUEES = 'stock:attribuees';
// Une commande plus vieille que ça a perdu son job (processus tué, Redis
// vidé…) : on cesse de la compter comme « en construction ».
private const COMMANDE_PERIMEE_SECONDES = 900;
// Une stack inconnue n'est supprimée qu'après ce délai : marge pour les
// opérations en vol entre Redis, la base et Heat.
private const AGE_MIN_MENAGE_SECONDES = 600;
// Après 3 échecs définitifs de suite sur un lab, on arrête de commander
// pour lui pendant 30 min, au lieu de recréer en boucle une stack vouée
// à l'échec (image absente, quota…).
private const SEUIL_DISJONCTEUR = 3;
private const DUREE_DISJONCTEUR_SECONDES = 1800;
public function __construct(private readonly HeatClient $heat) {}
/**
* Sort une stack prête du stock, ou null si le stock du lab est vide.
* LPOP et ZADD dans un seul script Lua : Redis l'exécute d'un bloc, donc
* à aucun moment la stack n'est ni dans la liste ni dans « attribuees »
* — le ménage ne peut jamais la prendre pour une fuite.
*
* @return array{nom: string, id: string, instances: array<string, string>, brut: string}|null
*/
public function prendre(string $lab): ?array
{
$script = <<<'LUA'
local item = redis.call('LPOP', KEYS[1])
if item then redis.call('ZADD', KEYS[2], ARGV[1], item) end
return item
LUA;
for ($essai = 0; $essai < 3; $essai++) {
$brut = Redis::eval($script, 2, self::clePretes($lab), self::ATTRIBUEES, time());
if (! is_string($brut)) {
return null;
}
$stack = json_decode($brut, true);
if ($this->estUtilisable($stack)) {
return $stack + ['brut' => $brut];
}
Log::warning("Stock chaud : stack {$stack['nom']} inutilisable, supprimée");
$this->supprimerSansErreur($stack['nom'], $stack['id']);
Redis::zrem(self::ATTRIBUEES, $brut);
}
return null;
}
/**
* @param array{brut: string} $stack
*/
public function confirmerAttribution(array $stack): void
{
Redis::zrem(self::ATTRIBUEES, $stack['brut']);
}
/**
* @param array{nom: string, id: string, instances: array<string, string>} $stack
*/
public function deposer(string $lab, array $stack): void
{
Redis::rpush(self::clePretes($lab), json_encode([
'nom' => $stack['nom'],
'id' => $stack['id'],
'instances' => $stack['instances'],
'cree_a' => time(),
]));
}
public function commandeTerminee(string $lab, string $base): void
{
Redis::zrem(self::cleCommandes($lab), $base);
}
public function compterEchec(string $lab): void
{
$cle = "stock:echecs:{$lab}";
Redis::incr($cle);
Redis::expire($cle, self::DUREE_DISJONCTEUR_SECONDES);
}
public function reinitialiserEchecs(string $lab): void
{
Redis::del("stock:echecs:{$lab}");
}
/**
* Appelé chaque minute (labs:remplir-stock) : ajuste chaque stock à sa
* cible, dans la limite de la capacité, puis fait le ménage.
*
* @return array<string, array{cible: int, pretes: int, en_cours: int, commandees: int, retirees: int, disjoncte: bool}>
*/
public function remplir(): array
{
$labs = $this->labsDisponibles();
$vmLibres = (int) config('labs.capacite_vm') - $this->vmOccupees($labs);
$rapport = [];
$cibles = [];
foreach ($labs as $identifiant => $definition) {
$cibles[$identifiant] = $this->cible($identifiant);
}
// Les labs dont une séance approche sont servis en premier.
arsort($cibles);
foreach ($cibles as $identifiant => $cible) {
$machines = $labs[$identifiant]->nombreMachines();
$pretes = (int) Redis::llen(self::clePretes($identifiant));
$enCours = $this->enCours($identifiant);
$disjoncte = (int) Redis::get("stock:echecs:{$identifiant}") >= self::SEUIL_DISJONCTEUR;
$commandees = 0;
$retirees = 0;
if ($pretes + $enCours > $cible) {
$retirees = $this->reduire($identifiant, min($pretes, $pretes + $enCours - $cible));
} elseif (! $disjoncte) {
$possibles = intdiv(max(0, $vmLibres), $machines);
$commandees = min($cible - $pretes - $enCours, $possibles);
for ($i = 0; $i < $commandees; $i++) {
$this->commander($identifiant);
}
$vmLibres -= $commandees * $machines;
}
$rapport[$identifiant] = compact('cible', 'pretes', 'commandees', 'retirees', 'disjoncte') + ['en_cours' => $enCours];
}
$this->menage();
return $rapport;
}
/**
* Cible = taille de base + élèves qui vont avoir besoin d'une stack :
* ceux des séances qui commencent dans les 10 minutes, et ceux des
* séances ouvertes qui n'ont pas encore été admis (ou dont le lancement
* a échoué et sera relancé).
*/
public function cible(string $lab): int
{
$avance = (int) config('labs.stock.avance_minutes');
$besoin = 0;
$seances = Seance::query()
->whereHas('lab', fn ($q) => $q->where('identifiant', $lab))
->where(function ($q) use ($avance) {
$q->where(fn ($q) => $q->where('statut', Seance::PLANIFIEE)
->whereBetween('prevue_a', [now()->subHours(2), now()->addMinutes($avance)]))
->orWhereIn('statut', [Seance::SALLE_ATTENTE, Seance::EN_COURS]);
})
->withCount(['labSessions as servies_count' => fn ($q) => $q->whereIn('etat', [
LabSession::ADMIS, LabSession::ACTIF, LabSession::TERMINE, LabSession::EXPIRE,
])])
->with('classe')
->get();
foreach ($seances as $seance) {
$besoin += max(0, $seance->classe->eleves()->count() - $seance->servies_count);
}
return (int) config('labs.stock.taille_par_defaut') + $besoin;
}
public function enCours(string $lab): int
{
$cle = self::cleCommandes($lab);
Redis::zremrangebyscore($cle, '-inf', (string) (time() - self::COMMANDE_PERIMEE_SECONDES));
return (int) Redis::zcard($cle);
}
/**
* Supprime les stacks « stock-… » que plus rien ne référence : ni la
* liste des prêtes, ni une attribution en cours, ni une session en base,
* ni une commande en construction.
*/
public function menage(): void
{
$connues = [];
$bases = [];
foreach ($this->labsDisponibles() as $identifiant => $definition) {
foreach (Redis::lrange(self::clePretes($identifiant), 0, -1) as $brut) {
$connues[json_decode($brut, true)['nom']] = true;
}
foreach (Redis::zrange(self::cleCommandes($identifiant), 0, -1) as $base) {
$bases[$base] = true;
}
}
// Attributions vieilles de plus de 5 min : la requête qui les a
// faites est terminée depuis longtemps (ou a planté) — on les oublie.
Redis::zremrangebyscore(self::ATTRIBUEES, '-inf', (string) (time() - 300));
foreach (Redis::zrange(self::ATTRIBUEES, 0, -1) as $brut) {
$connues[json_decode($brut, true)['nom']] = true;
}
foreach (LabSession::whereNotNull('heat_stack_name')->where('heat_stack_name', 'like', 'stock-%')->pluck('heat_stack_name') as $nom) {
$connues[$nom] = true;
}
foreach ($this->heat->listerStacks() as $stack) {
if (! str_starts_with($stack['nom'], 'stock-') || $stack['statut'] === 'DELETE_IN_PROGRESS') {
continue;
}
$base = preg_replace('/-e\d+$/', '', $stack['nom']);
$age = time() - Carbon::parse($stack['cree_a'])->getTimestamp();
if (isset($connues[$stack['nom']]) || isset($bases[$base]) || $age < self::AGE_MIN_MENAGE_SECONDES) {
continue;
}
Log::warning("Stock chaud : stack orpheline {$stack['nom']} ({$stack['statut']}) supprimée");
$this->supprimerSansErreur($stack['nom'], $stack['id']);
}
}
public static function clePretes(string $lab): string
{
return "stock:pretes:{$lab}";
}
private static function cleCommandes(string $lab): string
{
return "stock:commandes:{$lab}";
}
private function commander(string $lab): void
{
$base = "stock-{$lab}-".Str::lower(Str::random(6));
Redis::zadd(self::cleCommandes($lab), time(), $base);
CreerStackStock::dispatch($lab, $base);
}
private function reduire(string $lab, int $nombre): int
{
$retirees = 0;
for ($i = 0; $i < $nombre; $i++) {
$brut = Redis::lpop(self::clePretes($lab));
if (! is_string($brut)) {
break;
}
$stack = json_decode($brut, true);
$this->supprimerSansErreur($stack['nom'], $stack['id']);
$retirees++;
}
return $retirees;
}
/**
* VM réservées : sessions d'élèves en cours (ou en construction) et
* stocks (prêtes et en construction), tous labs confondus.
*
* @param array<string, DefinitionLab> $labs
*/
private function vmOccupees(array $labs): int
{
$vm = LabSessionMachine::whereHas('labSession', fn ($q) => $q->where('etat', LabSession::ACTIF))->count();
$admises = LabSession::where('etat', LabSession::ADMIS)->with('lab')->get();
foreach ($admises as $session) {
$vm += isset($labs[$session->lab->identifiant]) ? $labs[$session->lab->identifiant]->nombreMachines() : 1;
}
foreach ($labs as $identifiant => $definition) {
$stacks = (int) Redis::llen(self::clePretes($identifiant)) + $this->enCours($identifiant);
$vm += $stacks * $definition->nombreMachines();
}
return $vm;
}
/**
* Labs du catalogue (table labs) dont le lab.yaml est présent.
*
* @return array<string, DefinitionLab>
*/
private function labsDisponibles(): array
{
$presents = DefinitionLab::identifiantsDisponibles();
$labs = [];
foreach (Lab::whereIn('identifiant', $presents)->pluck('identifiant') as $identifiant) {
$labs[$identifiant] = DefinitionLab::depuis($identifiant);
}
return $labs;
}
/**
* @param array{nom: string, id: string}|null $stack
*/
private function estUtilisable(?array $stack): bool
{
if ($stack === null || ! isset($stack['nom'], $stack['id'], $stack['instances'])) {
return false;
}
try {
return $this->heat->etatStack($stack['nom'], $stack['id'])['statut'] === 'CREATE_COMPLETE';
} catch (Throwable) {
return false;
}
}
private function supprimerSansErreur(string $nom, string $id): void
{
try {
$this->heat->supprimerStack($nom, $id);
} catch (Throwable $e) {
Log::error("Stock chaud : suppression de {$nom} impossible", ['raison' => $e->getMessage()]);
}
}
}

View file

@ -0,0 +1,88 @@
<?php
namespace App\Services\OpenStack;
use App\Services\Labs\DefinitionLab;
use RuntimeException;
/**
* Crée la stack Heat d'un lab et attend qu'elle soit prête — partagé par les
* sessions d'élèves (CreerStackLab) et le stock chaud (CreerStackStock).
*
* Chaque essai porte son propre nom ({base}-e{n}) : une stack ratée met un
* moment à disparaître, et Heat refuse deux stacks du même nom. Avant un
* nouvel essai, nettoyerEssais() supprime les restes des précédents (y
* compris ceux d'un processus tué en plein travail).
*/
class FabriqueStack
{
// Heat construit un lab (réseaux + quelques VM) en 20 à 40 s : au-delà
// de 3 minutes, l'essai est considéré comme raté.
private const ATTENTE_MAX_SECONDES = 180;
public function __construct(
private readonly HeatClient $heat,
private readonly GenerateurTemplateHeat $generateur,
) {}
public function nettoyerEssais(string $base): void
{
foreach ($this->heat->listerStacks() as $stack) {
if (str_starts_with($stack['nom'], "{$base}-e") && $stack['statut'] !== 'DELETE_IN_PROGRESS') {
$this->heat->supprimerStack($stack['nom'], $stack['id']);
}
}
}
/**
* @param (callable(string, string): void)|null $apresCreation appelé dès que Heat a accepté la stack (nom, id)
* @return array{nom: string, id: string, instances: array<string, string>}
*
* @throws RuntimeException si Heat refuse la stack ou si elle n'aboutit pas (elle est alors supprimée)
*/
public function fabriquer(DefinitionLab $lab, string $nom, ?callable $apresCreation = null): array
{
['template' => $template] = $this->generateur->genererDepuis($lab, $nom);
$stack = $this->heat->creerStack($nom, $template);
if ($apresCreation !== null) {
$apresCreation($nom, $stack['id']);
}
$etat = $this->attendre($nom, $stack['id']);
if ($etat['statut'] !== 'CREATE_COMPLETE') {
$this->heat->supprimerStack($nom, $stack['id']);
throw new RuntimeException("La stack {$nom} n'a pas abouti : {$etat['statut']} ({$etat['raison']})");
}
$instances = [];
foreach ($lab->machines as $machine) {
$instances[$machine['nom']] = $etat['sorties'][GenerateurTemplateHeat::cle($machine['nom']).'_id'];
}
return ['nom' => $nom, 'id' => $stack['id'], 'instances' => $instances];
}
/**
* @return array{statut: string, raison: ?string, sorties: array<string, mixed>}
*/
private function attendre(string $nom, string $id): array
{
$limite = time() + self::ATTENTE_MAX_SECONDES;
do {
$etat = $this->heat->etatStack($nom, $id);
if (! str_ends_with($etat['statut'], '_IN_PROGRESS')) {
return $etat;
}
sleep(2);
} while (time() < $limite);
return ['statut' => 'DELAI_DEPASSE', 'raison' => 'plus de '.self::ATTENTE_MAX_SECONDES.' s', 'sorties' => []];
}
}

View file

@ -55,6 +55,27 @@ class HeatClient
]; ];
} }
/**
* Toutes les stacks du projet (quelques dizaines au plus : pas de pagination).
*
* @return array<int, array{nom: string, id: string, statut: string, cree_a: string}>
*/
public function listerStacks(): array
{
$reponse = $this->requete()->get($this->url('/stacks'));
if ($reponse->failed()) {
throw new RuntimeException('Impossible de lister les stacks : '.$reponse->body());
}
return array_map(fn (array $s) => [
'nom' => $s['stack_name'],
'id' => $s['id'],
'statut' => $s['stack_status'],
'cree_a' => $s['creation_time'],
], $reponse->json('stacks') ?? []);
}
public function supprimerStack(string $nom, string $id): void public function supprimerStack(string $nom, string $id): void
{ {
$reponse = $this->requete()->delete($this->url("/stacks/{$nom}/{$id}")); $reponse = $this->requete()->delete($this->url("/stacks/{$nom}/{$id}"));

View file

@ -17,9 +17,9 @@ class NovaClient
public function consoleSerielle(string $instanceId): string public function consoleSerielle(string $instanceId): string
{ {
$reponse = Http::withHeaders([ $reponse = Http::withHeaders([
'X-Auth-Token' => $this->auth->jeton(), 'X-Auth-Token' => $this->auth->jeton(),
'OpenStack-API-Version' => 'compute 2.99', 'OpenStack-API-Version' => 'compute 2.99',
]) ])
->post(config('openstack.nova_url')."/servers/{$instanceId}/remote-consoles", [ ->post(config('openstack.nova_url')."/servers/{$instanceId}/remote-consoles", [
'remote_console' => ['protocol' => 'serial', 'type' => 'serial'], 'remote_console' => ['protocol' => 'serial', 'type' => 'serial'],
]); ]);

View file

@ -1,5 +1,6 @@
<?php <?php
use App\Http\Middleware\VerifieSessionUnique;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@ -13,7 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([ $middleware->alias([
'session.unique' => \App\Http\Middleware\VerifieSessionUnique::class, 'session.unique' => VerifieSessionUnique::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

View file

@ -1,7 +1,9 @@
<?php <?php
use App\Providers\AppServiceProvider; use App\Providers\AppServiceProvider;
use App\Providers\HorizonServiceProvider;
return [ return [
AppServiceProvider::class, AppServiceProvider::class,
HorizonServiceProvider::class,
]; ];

View file

@ -9,6 +9,7 @@
"php": "^8.3", "php": "^8.3",
"bacon/bacon-qr-code": "^3.1", "bacon/bacon-qr-code": "^3.1",
"laravel/framework": "^13.17", "laravel/framework": "^13.17",
"laravel/horizon": "^5.50",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"symfony/yaml": "^7.4" "symfony/yaml": "^7.4"
}, },

138
portail/composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "4bbbdac2ebdd2568b9a8375272d3a7be", "content-hash": "69a4c7e65a216e8a691794b9b63cfe39",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -1394,6 +1394,86 @@
}, },
"time": "2026-10-06T13:52:37+00:00" "time": "2026-10-06T13:52:37+00:00"
}, },
{
"name": "laravel/horizon",
"version": "v5.50.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/horizon.git",
"reference": "009177cc3d0829c839c8ab573dd17d158fc0b71e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/horizon/zipball/009177cc3d0829c839c8ab573dd17d158fc0b71e",
"reference": "009177cc3d0829c839c8ab573dd17d158fc0b71e",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-pcntl": "*",
"ext-posix": "*",
"illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0",
"illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0",
"laravel/sentinel": "^1.0",
"nesbot/carbon": "^2.17|^3.0",
"php": "^8.0",
"ramsey/uuid": "^4.0",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/error-handler": "^6.0|^7.0|^8.0",
"symfony/polyfill-php83": "^1.28",
"symfony/process": "^6.0|^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^7.56|^8.37|^9.16|^10.9|^11.0",
"phpstan/phpstan": "^1.10|^2.0",
"predis/predis": "^1.1|^2.0|^3.0"
},
"suggest": {
"ext-redis": "Required to use the Redis PHP driver.",
"predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Horizon": "Laravel\\Horizon\\Horizon"
},
"providers": [
"Laravel\\Horizon\\HorizonServiceProvider"
]
},
"branch-alias": {
"dev-master": "6.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Horizon\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Dashboard and code-driven configuration for Laravel queues.",
"keywords": [
"laravel",
"queue"
],
"support": {
"issues": "https://github.com/laravel/horizon/issues",
"source": "https://github.com/laravel/horizon/tree/v5.50.0"
},
"time": "2026-09-15T13:59:54+00:00"
},
{ {
"name": "laravel/prompts", "name": "laravel/prompts",
"version": "v0.3.25", "version": "v0.3.25",
@ -1452,6 +1532,62 @@
}, },
"time": "2026-10-06T02:25:49+00:00" "time": "2026-10-06T02:25:49+00:00"
}, },
{
"name": "laravel/sentinel",
"version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sentinel.git",
"reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sentinel/zipball/972d9885d9d14312a118e9565c4e6ecc5e751ea1",
"reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0"
},
"require-dev": {
"laravel/pint": "^1.27",
"orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0",
"phpstan/phpstan": "^2.1.33"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sentinel\\SentinelServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sentinel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Mior Muhammad Zaki",
"email": "mior@laravel.com"
}
],
"support": {
"source": "https://github.com/laravel/sentinel/tree/v1.1.0"
},
"time": "2026-03-24T14:03:38+00:00"
},
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
"version": "v2.1.0", "version": "v2.1.0",

View file

@ -67,6 +67,10 @@ return [
'timezone' => 'UTC', 'timezone' => 'UTC',
// Les dates restent stockées en UTC ; celles que saisissent ou lisent les
// profs (séances planifiées) sont en heure de Paris.
'fuseau_utilisateurs' => 'Europe/Paris',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Locale Configuration | Application Locale Configuration

269
portail/config/horizon.php Normal file
View file

@ -0,0 +1,269 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Name
|--------------------------------------------------------------------------
|
| This name appears in notifications and in the Horizon UI. Unique names
| can be useful while running multiple instances of Horizon within an
| application, allowing you to identify the Horizon you're viewing.
|
*/
'name' => env('HORIZON_NAME'),
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => env('HORIZON_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Horizon Path
|--------------------------------------------------------------------------
|
| This is the URI path where Horizon will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('HORIZON_PATH', 'horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
|--------------------------------------------------------------------------
|
| This is the name of the Redis connection where Horizon will store the
| meta information required for it to function. It includes the list
| of supervisors, failed jobs, job metrics, and other information.
|
*/
'use' => 'default',
/*
|--------------------------------------------------------------------------
| Horizon Redis Prefix
|--------------------------------------------------------------------------
|
| This prefix will be used when storing all Horizon data in Redis. You
| may modify the prefix when you are running multiple installations
| of Horizon on the same server so that they don't have problems.
|
*/
'prefix' => env(
'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
),
/*
|--------------------------------------------------------------------------
| Horizon Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will get attached onto each Horizon route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:default' => 60,
],
/*
|--------------------------------------------------------------------------
| Job Trimming Times
|--------------------------------------------------------------------------
|
| Here you can configure for how long (in minutes) you desire Horizon to
| persist the recent and failed jobs. Typically, recent jobs are kept
| for one hour while all failed jobs are stored for an entire week.
|
*/
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
/*
|--------------------------------------------------------------------------
| Silenced Jobs
|--------------------------------------------------------------------------
|
| Silencing a job will instruct Horizon to not place the job in the list
| of completed jobs within the Horizon dashboard. This setting may be
| used to fully remove any noisy jobs from the completed jobs list.
|
*/
'silenced' => [
// App\Jobs\ExampleJob::class,
],
'silenced_tags' => [
// 'notifications',
],
/*
|--------------------------------------------------------------------------
| Metrics
|--------------------------------------------------------------------------
|
| Here you can configure how many snapshots should be kept to display in
| the metrics graph. This will get used in combination with Horizon's
| `horizon:snapshot` schedule to define how long to retain metrics.
|
*/
'metrics' => [
'trim_snapshots' => [
'job' => 24,
'queue' => 24,
],
],
/*
|--------------------------------------------------------------------------
| Fast Termination
|--------------------------------------------------------------------------
|
| When this option is enabled, Horizon's "terminate" command will not
| wait on all of the workers to terminate unless the --wait option
| is provided. Fast termination can shorten deployment delay by
| allowing a new instance of Horizon to start while the last
| instance will continue to terminate each of its workers.
|
*/
'fast_termination' => false,
/*
|--------------------------------------------------------------------------
| Memory Limit (MB)
|--------------------------------------------------------------------------
|
| This value describes the maximum amount of memory the Horizon master
| supervisor may consume before it is terminated and restarted. For
| configuring these limits on your workers, see the next section.
|
*/
'memory_limit' => 64,
/*
|--------------------------------------------------------------------------
| Queue Worker Configuration
|--------------------------------------------------------------------------
|
| Here you may define the queue worker settings used by your application
| in all environments. These supervisors and settings handle all your
| queued jobs and will be provisioned by Horizon during deployment.
|
*/
// Deux superviseurs séparés : une session d'élève ne doit jamais attendre
// derrière le remplissage du stock. Nombre de processus fixe (balance
// 'off', min = max) : chaque job de création de stack bloque un
// processus pendant qu'il attend Heat, et Heat construit les stacks en
// parallèle. Timeout < retry_after de la connexion redis (config/queue.php),
// sinon Redis redonnerait un job encore en cours à un autre processus.
'defaults' => [
'sessions' => [
'connection' => 'redis',
'queue' => ['sessions', 'default'],
'balance' => 'off',
'minProcesses' => 4,
'maxProcesses' => 4,
'maxTime' => 3600,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 300,
'nice' => 0,
],
'stock' => [
'connection' => 'redis',
'queue' => ['stock'],
'balance' => 'off',
'minProcesses' => 2,
'maxProcesses' => 2,
'maxTime' => 3600,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 300,
'nice' => 5,
],
],
'environments' => [
'production' => [
'sessions' => [],
'stock' => [],
],
'local' => [
'sessions' => ['minProcesses' => 1, 'maxProcesses' => 1],
'stock' => ['minProcesses' => 1, 'maxProcesses' => 1],
],
],
/*
|--------------------------------------------------------------------------
| File Watcher Configuration
|--------------------------------------------------------------------------
|
| The following list of directories and files will be watched when using
| the `horizon:listen` command. Whenever any directories or files are
| changed, Horizon will automatically restart to apply all changes.
|
*/
'watch' => [
'app',
'bootstrap',
'config/**/*.php',
'database/**/*.php',
'public/**/*.php',
'resources/**/*.php',
'routes',
'composer.lock',
'composer.json',
'.env',
],
];

View file

@ -3,6 +3,23 @@
return [ return [
// En local (dev, tests Pest) : labs/ est un dossier voisin de portail/ // En local (dev, tests Pest) : labs/ est un dossier voisin de portail/
// dans le dépôt. En production, synchronisé à part par Ansible — voir // dans le dépôt. En production, synchronisé à part par Ansible — voir
// ansible/roles/portail/tasks/05-labs.yml. // ansible/roles/portail/tasks/04-labs.yml.
'dossier' => env('LABS_DOSSIER', dirname(base_path()).'/labs'), 'dossier' => env('LABS_DOSSIER', dirname(base_path()).'/labs'),
// Nombre de VM eleve.petit (768 Mo) que le serveur peut faire tourner en
// même temps, sessions d'élèves et stock compris. Calcul et mesures :
// docs/journal.md, étape 11. Le stock chaud ne dépasse jamais ce plafond.
'capacite_vm' => (int) env('LABS_CAPACITE_VM', 15),
'stock' => [
// Stacks prêtes gardées en permanence pour chaque lab du catalogue,
// hors séance. Le stock monte au nombre d'élèves de la classe
// avance_minutes avant une séance planifiée.
'taille_par_defaut' => (int) env('LABS_STOCK_TAILLE', 1),
'avance_minutes' => 10,
],
'correcteur' => [
'socket' => env('CORRECTEUR_SOCKET', '/run/correcteur/correcteur.sock'),
],
]; ];

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('lab_sessions', function (Blueprint $table) {
$table->string('raison_echec')->nullable()->after('etat');
});
Schema::table('seances', function (Blueprint $table) {
$table->timestamp('prevue_a')->nullable()->after('statut');
$table->index(['statut', 'prevue_a']);
});
}
public function down(): void
{
Schema::table('seances', function (Blueprint $table) {
$table->dropIndex(['statut', 'prevue_a']);
$table->dropColumn('prevue_a');
});
Schema::table('lab_sessions', function (Blueprint $table) {
$table->dropColumn('raison_echec');
});
}
};

View file

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('corrections', function (Blueprint $table) {
$table->id();
$table->foreignId('lab_session_id')->constrained()->cascadeOnDelete();
$table->boolean('reussi');
$table->unsignedSmallInteger('note');
$table->unsignedSmallInteger('note_max');
$table->json('details');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('corrections');
}
};

View file

@ -33,7 +33,13 @@
var ws = new WebSocket(protocole + location.host + '/console/' + o.jeton); var ws = new WebSocket(protocole + location.host + '/console/' + o.jeton);
ws.binaryType = 'arraybuffer'; ws.binaryType = 'arraybuffer';
ws.onopen = function () { term.write('\x1b[2mConnecté.\x1b[0m\r\n'); }; ws.onopen = function () {
term.write('\x1b[2mConnecté.\x1b[0m\r\n');
// VM du stock chaud démarrée depuis longtemps : sa console série
// n'affiche plus rien jusqu'à la prochaine touche. Un retour chariot
// fait réapparaître l'invite de connexion.
if (o.reveil && !lectureSeule) ws.send('\r');
};
ws.onmessage = function (evenement) { ws.onmessage = function (evenement) {
if (evenement.data instanceof ArrayBuffer) { if (evenement.data instanceof ArrayBuffer) {
term.write(new Uint8Array(evenement.data)); term.write(new Uint8Array(evenement.data));

View file

@ -15,6 +15,23 @@
<a href="{{ route('labs.essai') }}" class="inline-flex items-center gap-4 border border-[#e3e5e8] rounded-2xl bg-black px-6 py-4 text-lg font-extrabold text-white transition-transform hover:bg-[#ec3013].5.5 active:shadow-none">Reprendre mon TP <span class="font-mono" aria-hidden="true">→</span></a> <a href="{{ route('labs.essai') }}" class="inline-flex items-center gap-4 border border-[#e3e5e8] rounded-2xl bg-black px-6 py-4 text-lg font-extrabold text-white transition-transform hover:bg-[#ec3013].5.5 active:shadow-none">Reprendre mon TP <span class="font-mono" aria-hidden="true">→</span></a>
</div> </div>
</section> </section>
@php($seancesOuvertes = $eleve->classe->seances()->whereIn('statut', [\App\Models\Seance::SALLE_ATTENTE, \App\Models\Seance::EN_COURS])->with('lab')->get())
@if ($seancesOuvertes->isNotEmpty())
<section class="mx-auto max-w-7xl px-5 pt-10 lg:px-10" aria-labelledby="t-seance-ouverte">
<h2 id="t-seance-ouverte" class="text-2xl font-extrabold">Séance en cours</h2>
<ul class="mt-4 grid gap-3">
@foreach ($seancesOuvertes as $seance)
<li class="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-[#e3e5e8] bg-[#e6f4ea] p-5">
<span class="text-xl font-extrabold">{{ $seance->lab->titre }}</span>
<form action="{{ route('seances.rejoindre', $seance) }}" method="post">
@csrf
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white">Rejoindre la séance</button>
</form>
</li>
@endforeach
</ul>
</section>
@endif
<div class="mx-auto grid max-w-7xl gap-12 px-5 py-14 lg:grid-cols-12 lg:px-10"> <div class="mx-auto grid max-w-7xl gap-12 px-5 py-14 lg:grid-cols-12 lg:px-10">
<section class="lg:col-span-7" aria-labelledby="t-afaire"> <section class="lg:col-span-7" aria-labelledby="t-afaire">
<h2 id="t-afaire" class="text-4xl font-extrabold tracking-tight">À faire</h2> <h2 id="t-afaire" class="text-4xl font-extrabold tracking-tight">À faire</h2>

View file

@ -32,10 +32,10 @@
<div style="display:flex;align-items:center;gap:20px"> <div style="display:flex;align-items:center;gap:20px">
@auth('web') @auth('web')
<a href="{{ route('mes-classes.index') }}" style="font-size:15px;font-weight:600;color:#0b1220;text-decoration:none" class="hover:text-[#0b57d0]">Mes classes</a> <a href="{{ route('mes-classes.index') }}" style="font-size:15px;font-weight:600;color:#0b1220;text-decoration:none" class="hover:text-[#0b57d0]">Mes classes</a>
<form action="{{ route('deconnexion') }}" method="post" style="margin:0"><button type="submit" style="display:inline-flex;align-items:center;height:40px;padding:0 20px;border:0;border-radius:999px;background:#0b57d0;color:#fff;font-size:15px;font-weight:700;cursor:pointer">Se déconnecter</button></form> <form action="{{ route('deconnexion') }}" method="post" style="margin:0">@csrf<button type="submit" style="display:inline-flex;align-items:center;height:40px;padding:0 20px;border:0;border-radius:999px;background:#0b57d0;color:#fff;font-size:15px;font-weight:700;cursor:pointer">Se déconnecter</button></form>
@elseif (auth('eleve')->check()) @elseif (auth('eleve')->check())
<a href="{{ route('eleve') }}" style="font-size:15px;font-weight:600;color:#0b1220;text-decoration:none" class="hover:text-[#0b57d0]">Mon espace</a> <a href="{{ route('eleve') }}" style="font-size:15px;font-weight:600;color:#0b1220;text-decoration:none" class="hover:text-[#0b57d0]">Mon espace</a>
<form action="{{ route('deconnexion') }}" method="post" style="margin:0"><button type="submit" style="display:inline-flex;align-items:center;height:40px;padding:0 20px;border:0;border-radius:999px;background:#0b57d0;color:#fff;font-size:15px;font-weight:700;cursor:pointer">Se déconnecter</button></form> <form action="{{ route('deconnexion') }}" method="post" style="margin:0">@csrf<button type="submit" style="display:inline-flex;align-items:center;height:40px;padding:0 20px;border:0;border-radius:999px;background:#0b57d0;color:#fff;font-size:15px;font-weight:700;cursor:pointer">Se déconnecter</button></form>
@else @else
<a href="{{ route('login') }}" style="font-size:15px;font-weight:600;color:#0b1220;text-decoration:none" class="hover:text-[#0b57d0]">Se connecter</a> <a href="{{ route('login') }}" style="font-size:15px;font-weight:600;color:#0b1220;text-decoration:none" class="hover:text-[#0b57d0]">Se connecter</a>
<a href="{{ route('register') }}" style="display:inline-flex;align-items:center;height:40px;padding:0 20px;border-radius:999px;background:#0b57d0;color:#fff;font-size:15px;font-weight:700;text-decoration:none">Créer une classe</a> <a href="{{ route('register') }}" style="display:inline-flex;align-items:center;height:40px;padding:0 20px;border-radius:999px;background:#0b57d0;color:#fff;font-size:15px;font-weight:700;text-decoration:none">Créer une classe</a>

View file

@ -97,8 +97,11 @@
</section> </section>
<section class="mt-10 border border-[#e3e5e8] rounded-2xl bg-white p-6" aria-labelledby="t-seance"> <section class="mt-10 border border-[#e3e5e8] rounded-2xl bg-white p-6" aria-labelledby="t-seance">
<h2 id="t-seance" class="text-2xl font-extrabold">Lancer une séance</h2> <h2 id="t-seance" class="text-2xl font-extrabold">Lancer ou planifier une séance</h2>
<p class="mt-2 font-semibold">Les élèves qui rejoignent patientent dans la salle d'attente jusqu'à ce que tu les admettes.</p> <p class="mt-2 font-semibold">Les élèves qui rejoignent patientent dans la salle d'attente jusqu'à ce que tu les admettes. Si tu planifies la séance, les machines de toute la classe sont préparées 10 minutes avant : elles s'ouvrent alors en quelques secondes.</p>
@if (session('seance_planifiee'))
<p class="mt-4 rounded-2xl border border-[#e3e5e8] bg-[#e6f4ea] p-4 font-semibold">Séance planifiée.</p>
@endif
<form action="{{ route('mes-classes.seances.store', $classe) }}" method="post" class="mt-4 flex flex-wrap items-end gap-3"> <form action="{{ route('mes-classes.seances.store', $classe) }}" method="post" class="mt-4 flex flex-wrap items-end gap-3">
@csrf @csrf
<label class="grid gap-1 font-bold">Lab<select name="lab_id" required class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold"> <label class="grid gap-1 font-bold">Lab<select name="lab_id" required class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold">
@ -106,8 +109,28 @@
<option value="{{ $lab->id }}">{{ $lab->titre }}</option> <option value="{{ $lab->id }}">{{ $lab->titre }}</option>
@endforeach @endforeach
</select></label> </select></label>
<button type="submit" class="inline-flex h-11 items-center justify-center rounded-full bg-black px-5 font-bold text-white">Ouvrir la salle d'attente</button> <label class="grid gap-1 font-bold">Date et heure (facultatif)<input type="datetime-local" name="prevue_a" value="{{ old('prevue_a') }}" class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold"></label>
<button type="submit" class="inline-flex h-11 items-center justify-center rounded-full bg-black px-5 font-bold text-white">Ouvrir ou planifier</button>
</form> </form>
@error('prevue_a')
<p class="mt-2 font-semibold text-[#A63D2A]">{{ $message }}</p>
@enderror
@php($planifiees = $classe->seances()->where('statut', \App\Models\Seance::PLANIFIEE)->with('lab')->orderBy('prevue_a')->get())
@if ($planifiees->isNotEmpty())
<h3 class="mt-6 text-lg font-extrabold">Séances planifiées</h3>
<ul class="mt-2 grid gap-2">
@foreach ($planifiees as $planifiee)
<li class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#e3e5e8] p-3">
<span><span class="font-mono font-bold">{{ $planifiee->prevue_a->timezone(config('app.fuseau_utilisateurs'))->translatedFormat('d/m/Y H:i') }}</span> · <span class="font-bold">{{ $planifiee->lab->titre }}</span></span>
<form action="{{ route('seances.ouvrir', $planifiee) }}" method="post">
@csrf
<button type="submit" class="font-bold underline underline-offset-4">Ouvrir la salle d'attente</button>
</form>
</li>
@endforeach
</ul>
@endif
</section> </section>
</div> </div>
@endsection @endsection

View file

@ -2,24 +2,27 @@
@section('titre', "Salle d'attente") @section('titre', "Salle d'attente")
@php($etats = \App\Models\LabSession::class)
@section('contenu') @section('contenu')
@if ($labSession->etat === \App\Models\LabSession::EN_ATTENTE)
<meta http-equiv="refresh" content="5">
@endif
<div class="mx-auto max-w-5xl px-5 py-14 lg:px-10"> <div class="mx-auto max-w-5xl px-5 py-14 lg:px-10">
<p class="font-mono text-sm font-semibold">{{ $seance->lab->titre }}</p> <p class="font-mono text-sm font-semibold">{{ $seance->lab->titre }}</p>
@if ($labSession->etat === \App\Models\LabSession::EN_ATTENTE) @if ($labSession->etat === $etats::EN_ATTENTE)
<div class="py-20 text-center"> <div class="py-20 text-center">
<h1 class="text-4xl font-extrabold tracking-tight">En attente que le prof t'admette…</h1> <h1 class="text-4xl font-extrabold tracking-tight">En attente que le prof t'admette…</h1>
<p class="mt-4 font-semibold">Cette page se rafraîchit toute seule.</p> <p class="mt-4 font-semibold">Tes machines s'ouvriront ici dès qu'il t'aura admis.</p>
</div> </div>
@elseif ($labSession->etat === \App\Models\LabSession::ADMIS) @elseif ($labSession->etat === $etats::ADMIS)
<meta http-equiv="refresh" content="4">
<div class="py-20 text-center"> <div class="py-20 text-center">
<h1 class="text-4xl font-extrabold tracking-tight">Préparation de tes VM…</h1> <h1 class="text-4xl font-extrabold tracking-tight">Préparation de tes VM…</h1>
<p class="mt-4 font-semibold">Ça prend généralement moins d'une minute.</p> <p class="mt-4 font-semibold">Ça prend généralement moins d'une minute.</p>
</div> </div>
@elseif ($labSession->machines->isNotEmpty()) @elseif ($labSession->etat === $etats::ECHEC)
<div class="py-20 text-center">
<h1 class="text-4xl font-extrabold tracking-tight">Tes machines n'ont pas pu être préparées</h1>
<p class="mt-4 font-semibold">Ton prof le voit et peut relancer. Cette page se mettra à jour toute seule.</p>
</div>
@elseif ($labSession->etat === $etats::ACTIF && $labSession->machines->isNotEmpty())
<h1 class="mt-2 text-3xl font-extrabold tracking-tight">Tes machines</h1> <h1 class="mt-2 text-3xl font-extrabold tracking-tight">Tes machines</h1>
<div class="mt-4 flex gap-2" role="tablist"> <div class="mt-4 flex gap-2" role="tablist">
@foreach ($labSession->machines as $i => $machine) @foreach ($labSession->machines as $i => $machine)
@ -31,12 +34,43 @@
<div id="terminal-{{ $machine->id }}" class="h-full"></div> <div id="terminal-{{ $machine->id }}" class="h-full"></div>
</div> </div>
@endforeach @endforeach
<section class="mt-6 rounded-2xl border border-[#e3e5e8] bg-white p-6" aria-labelledby="t-verification">
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 id="t-verification" class="text-2xl font-extrabold">Vérification</h2>
<form action="{{ route('lab-sessions.corriger', $labSession) }}" method="post" id="form-verifier">
@csrf
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white">Vérifier mon travail</button>
</form>
</div>
@if (session('correction_indisponible'))
<p class="mt-4 rounded-xl bg-[#fce8e6] p-3 font-semibold">La vérification est indisponible pour le moment. Réessaie dans une minute.</p>
@endif
@if ($correction = $labSession->derniereCorrection)
<p class="mt-4 text-lg font-extrabold">
{{ $correction->reussi ? 'Réussi' : 'Pas encore réussi' }}
<span class="font-mono">· {{ $correction->note }}/{{ $correction->note_max }}</span>
</p>
<ul class="mt-3 grid gap-2">
@foreach ($correction->details as $detail)
<li class="rounded-xl border border-[#e3e5e8] p-3">
<span class="font-mono font-bold">{{ $detail['reussi'] ? '✓' : '✗' }}</span>
<span class="font-bold">{{ $detail['titre'] }}</span>
<span class="font-mono text-sm">({{ $detail['points'] }} pt{{ $detail['points'] > 1 ? 's' : '' }})</span>
@if ($detail['message'])
<p class="mt-1 font-semibold text-[#A63D2A]">{{ $detail['message'] }}</p>
@endif
</li>
@endforeach
</ul>
@endif
</section>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="{{ asset('js/console-reelle.js') }}"></script> <script src="{{ asset('js/console-reelle.js') }}"></script>
<script> <script>
var monte = {};
document.querySelectorAll('.onglet-machine').forEach(function (bouton) { document.querySelectorAll('.onglet-machine').forEach(function (bouton) {
bouton.addEventListener('click', function () { bouton.addEventListener('click', function () {
var id = bouton.dataset.onglet; var id = bouton.dataset.onglet;
@ -44,20 +78,39 @@
document.querySelectorAll('[data-panneau]').forEach(function (p) { p.hidden = p.dataset.panneau !== id; }); document.querySelectorAll('[data-panneau]').forEach(function (p) { p.hidden = p.dataset.panneau !== id; });
}); });
}); });
document.getElementById('form-verifier').addEventListener('submit', function (e) {
var bouton = e.target.querySelector('button');
bouton.disabled = true;
bouton.textContent = 'Vérification en cours…';
});
@foreach ($labSession->machines as $machine) @foreach ($labSession->machines as $machine)
fetch('{{ route('console.eleve', [$labSession, $machine]) }}', { fetch('{{ route('console.eleve', [$labSession, $machine]) }}', {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }, headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
}).then(function (r) { return r.json(); }).then(function (d) { }).then(function (r) { return r.json(); }).then(function (d) {
window.SyllabusConsoleReelle.mount(document.getElementById('terminal-{{ $machine->id }}'), { jeton: d.jeton, lectureSeule: false }); window.SyllabusConsoleReelle.mount(document.getElementById('terminal-{{ $machine->id }}'), { jeton: d.jeton, lectureSeule: false, reveil: true });
}); });
@endforeach @endforeach
</script> </script>
@else @else
<div class="py-20 text-center"> <div class="py-20 text-center">
<h1 class="text-4xl font-extrabold tracking-tight">La préparation a échoué</h1> <h1 class="text-4xl font-extrabold tracking-tight">Cette séance est terminée pour toi</h1>
<p class="mt-4 font-semibold">Préviens ton prof.</p> <p class="mt-4 font-semibold">Tes machines ont été supprimées.</p>
</div> </div>
@endif @endif
</div> </div>
@if (in_array($labSession->etat, [$etats::EN_ATTENTE, $etats::ADMIS, $etats::ECHEC], true))
<script>
(function () {
var etatAffiche = @json($labSession->etat);
setInterval(function () {
fetch('{{ route('seances.eleve.etat', $seance) }}', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (d) { if (d.etat !== etatAffiche) location.reload(); })
.catch(function () {});
}, 1000);
})();
</script>
@endif
@endsection @endsection

View file

@ -2,6 +2,8 @@
@section('titre', "Salle d'attente") @section('titre', "Salle d'attente")
@php($etats = \App\Models\LabSession::class)
@section('contenu') @section('contenu')
<meta http-equiv="refresh" content="10"> <meta http-equiv="refresh" content="10">
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10"> <div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
@ -14,20 +16,41 @@
@else @else
<ul class="mt-8 grid gap-3"> <ul class="mt-8 grid gap-3">
@foreach ($seance->labSessions as $labSession) @foreach ($seance->labSessions as $labSession)
<li class="flex items-center justify-between gap-4 border border-[#e3e5e8] rounded-2xl bg-white p-4"> <li class="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-[#e3e5e8] bg-white p-4">
<span class="font-extrabold">{{ $labSession->eleve->nomAffiche() }}</span> <span class="font-extrabold">{{ $labSession->eleve->nomAffiche() }}</span>
@if ($labSession->etat === \App\Models\LabSession::EN_ATTENTE) <span class="flex flex-wrap items-center gap-3">
<form action="{{ route('seances.admettre', [$seance, $labSession]) }}" method="post"> @switch($labSession->etat)
@csrf @case($etats::EN_ATTENTE)
<button type="submit" class="inline-flex h-10 items-center rounded-full bg-[#0b57d0] px-4 font-bold text-white">Admettre</button> <form action="{{ route('seances.admettre', [$seance, $labSession]) }}" method="post">
</form> @csrf
@else <button type="submit" class="inline-flex h-10 items-center rounded-full bg-[#0b57d0] px-4 font-bold text-white">Admettre</button>
<span class="flex items-center gap-3"> </form>
<span class="rounded-lg border border-[#dadce0] bg-[#e6f4ea] px-3 py-1 font-mono text-sm font-bold">Admis</span> @break
@if ($labSession->machines->isNotEmpty()) @case($etats::ADMIS)
<span class="rounded-lg border border-[#dadce0] bg-[#fef7e0] px-3 py-1 font-mono text-sm font-bold">Préparation des VM…</span>
@break
@case($etats::ACTIF)
<span class="rounded-lg border border-[#dadce0] bg-[#e6f4ea] px-3 py-1 font-mono text-sm font-bold">Admis</span>
@if ($labSession->derniereCorrection)
<span class="font-mono text-sm font-bold">
{{ $labSession->derniereCorrection->reussi ? 'Réussi' : 'En cours' }} · {{ $labSession->derniereCorrection->note }}/{{ $labSession->derniereCorrection->note_max }}
</span>
@endif
<a href="{{ route('seances.console.prof.page', [$seance, $labSession]) }}" class="font-bold underline underline-offset-4">Voir sa console</a> <a href="{{ route('seances.console.prof.page', [$seance, $labSession]) }}" class="font-bold underline underline-offset-4">Voir sa console</a>
@endif @break
</span> @case($etats::ECHEC)
<span class="rounded-lg border border-[#A63D2A] bg-[#fce8e6] px-3 py-1 font-mono text-sm font-bold" title="{{ $labSession->raison_echec }}">Échec du lancement (3 essais)</span>
<form action="{{ route('seances.admettre', [$seance, $labSession]) }}" method="post">
@csrf
<button type="submit" class="inline-flex h-10 items-center rounded-full bg-black px-4 font-bold text-white">Réessayer</button>
</form>
@break
@default
<span class="rounded-lg border border-[#dadce0] px-3 py-1 font-mono text-sm font-bold">Terminé</span>
@endswitch
</span>
@if ($labSession->etat === $etats::ECHEC && $labSession->raison_echec)
<p class="w-full font-mono text-xs text-[#5f6368]">{{ $labSession->raison_echec }}</p>
@endif @endif
</li> </li>
@endforeach @endforeach

View file

@ -10,3 +10,9 @@ Artisan::command('inspire', function () {
// Étape 10 : ferme les sessions de lab dont l'expiration est passée. // Étape 10 : ferme les sessions de lab dont l'expiration est passée.
Schedule::command('labs:expirer-sessions')->everyMinute(); Schedule::command('labs:expirer-sessions')->everyMinute();
// Étape 11 : stock chaud ajusté à sa cible, stacks orphelines supprimées.
Schedule::command('labs:remplir-stock')->everyMinute()->withoutOverlapping(5);
// Horizon : instantanés des métriques (débit, temps d'attente des files).
Schedule::command('horizon:snapshot')->everyFiveMinutes();

View file

@ -3,6 +3,7 @@
use App\Http\Controllers\Auth\ConnexionController; use App\Http\Controllers\Auth\ConnexionController;
use App\Http\Controllers\ClasseController; use App\Http\Controllers\ClasseController;
use App\Http\Controllers\ConsoleController; use App\Http\Controllers\ConsoleController;
use App\Http\Controllers\CorrectionController;
use App\Http\Controllers\EleveTableauDeBordController; use App\Http\Controllers\EleveTableauDeBordController;
use App\Http\Controllers\InscriptionController; use App\Http\Controllers\InscriptionController;
use App\Http\Controllers\SeanceController; use App\Http\Controllers\SeanceController;
@ -68,6 +69,10 @@ Route::middleware(['auth:eleve', 'session.unique:eleve'])->group(function () {
Route::get('/eleve', [EleveTableauDeBordController::class, 'show'])->name('eleve'); Route::get('/eleve', [EleveTableauDeBordController::class, 'show'])->name('eleve');
Route::post('/seances/{seance}/rejoindre', [SeanceController::class, 'rejoindre'])->name('seances.rejoindre'); Route::post('/seances/{seance}/rejoindre', [SeanceController::class, 'rejoindre'])->name('seances.rejoindre');
Route::get('/seances/{seance}/attente-eleve', [SeanceController::class, 'attenteEleve'])->name('seances.eleve.attente'); Route::get('/seances/{seance}/attente-eleve', [SeanceController::class, 'attenteEleve'])->name('seances.eleve.attente');
Route::get('/seances/{seance}/etat-eleve', [SeanceController::class, 'etatEleve'])->name('seances.eleve.etat');
Route::post('/lab-sessions/{labSession}/corriger', [CorrectionController::class, 'corriger'])
->middleware('throttle:6,1')
->name('lab-sessions.corriger');
Route::post('/lab-sessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourEleve'])->name('console.eleve'); Route::post('/lab-sessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourEleve'])->name('console.eleve');
}); });
@ -80,6 +85,7 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () {
Route::post('/mes-classes/{classe}/eleves/{eleve}/regenerer-code', [ClasseController::class, 'regenererCode'])->name('mes-classes.eleves.regenererCode'); Route::post('/mes-classes/{classe}/eleves/{eleve}/regenerer-code', [ClasseController::class, 'regenererCode'])->name('mes-classes.eleves.regenererCode');
Route::post('/mes-classes/{classe}/seances', [SeanceController::class, 'store'])->name('mes-classes.seances.store'); Route::post('/mes-classes/{classe}/seances', [SeanceController::class, 'store'])->name('mes-classes.seances.store');
Route::get('/seances/{seance}/attente', [SeanceController::class, 'attente'])->name('seances.attente'); Route::get('/seances/{seance}/attente', [SeanceController::class, 'attente'])->name('seances.attente');
Route::post('/seances/{seance}/ouvrir', [SeanceController::class, 'ouvrir'])->name('seances.ouvrir');
Route::post('/seances/{seance}/admettre/{labSession}', [SeanceController::class, 'admettre'])->name('seances.admettre'); Route::post('/seances/{seance}/admettre/{labSession}', [SeanceController::class, 'admettre'])->name('seances.admettre');
Route::get('/seances/{seance}/labsessions/{labSession}/console', [SeanceController::class, 'consoleProf'])->name('seances.console.prof.page'); Route::get('/seances/{seance}/labsessions/{labSession}/console', [SeanceController::class, 'consoleProf'])->name('seances.console.prof.page');
Route::post('/seances/{seance}/labsessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourProf'])->name('console.prof'); Route::post('/seances/{seance}/labsessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourProf'])->name('console.prof');

View file

@ -3,7 +3,7 @@
use App\Models\Eleve; use App\Models\Eleve;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
test("un élève se connecte avec son identifiant et son code secret, et voit son tableau de bord", function () { test('un élève se connecte avec son identifiant et son code secret, et voit son tableau de bord', function () {
$eleve = Eleve::factory()->create([ $eleve = Eleve::factory()->create([
'prenom' => 'Léa', 'prenom' => 'Léa',
'identifiant' => 'lea.m', 'identifiant' => 'lea.m',

View file

@ -0,0 +1,145 @@
<?php
use App\Jobs\CreerStackLab;
use App\Models\Classe;
use App\Models\Eleve;
use App\Models\Lab;
use App\Models\LabSession;
use App\Models\Seance;
use App\Services\Labs\ClientCorrecteur;
use App\Services\Labs\CorrigerSession;
use App\Services\Labs\StockChaud;
use App\Services\OpenStack\FabriqueStack;
use Illuminate\Support\Facades\Queue;
function sessionEnAttente(string $etat = LabSession::EN_ATTENTE): LabSession
{
$classe = Classe::factory()->create();
$lab = Lab::factory()->create(['identifiant' => '08-deux-lan-routeur']);
$seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => $lab->id, 'statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now()]);
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
return LabSession::create(['seance_id' => $seance->id, 'eleve_id' => $eleve->id, 'lab_id' => $lab->id, 'etat' => $etat]);
}
function admettre($test, LabSession $session)
{
$prof = $session->seance->classe->prof;
$jeton = $prof->demarreNouvelleSession();
return $test->withSession(['jeton_session_web' => $jeton])
->actingAs($prof, 'web')
->post(route('seances.admettre', [$session->seance, $session]));
}
$stackPrete = [
'nom' => 'stock-08-deux-lan-routeur-abc123-e1',
'id' => 'id-heat',
'instances' => ['pc-a' => 'uuid-a', 'routeur' => 'uuid-r', 'pc-b' => 'uuid-b'],
'brut' => '{}',
];
test('une stack du stock chaud est attribuée tout de suite, sans job', function () use ($stackPrete) {
Queue::fake();
$this->mock(StockChaud::class, function ($stock) use ($stackPrete) {
$stock->shouldReceive('prendre')->once()->with('08-deux-lan-routeur')->andReturn($stackPrete);
$stock->shouldReceive('confirmerAttribution')->once();
});
$session = sessionEnAttente();
admettre($this, $session)->assertRedirect();
$session->refresh();
expect($session->etat)->toBe(LabSession::ACTIF)
->and($session->heat_stack_name)->toBe($stackPrete['nom'])
->and($session->machines()->pluck('instance_id', 'nom')->all())->toBe($stackPrete['instances']);
Queue::assertNothingPushed();
});
test('stock vide : la stack est construite par CreerStackLab, une seule fois malgré un double clic', function () {
Queue::fake();
$this->mock(StockChaud::class, fn ($stock) => $stock->shouldReceive('prendre')->once()->andReturn(null));
$session = sessionEnAttente();
admettre($this, $session);
admettre($this, $session);
expect($session->refresh()->etat)->toBe(LabSession::ADMIS);
Queue::assertPushedOn('sessions', CreerStackLab::class);
Queue::assertPushed(CreerStackLab::class, 1);
});
test('après le dernier essai raté, la session passe en échec avec sa raison', function () {
$this->mock(FabriqueStack::class, fn ($f) => $f->shouldReceive('nettoyerEssais')->once()->with(Mockery::pattern('/^lab-session-\d+$/')));
$session = sessionEnAttente(LabSession::ADMIS);
(new CreerStackLab($session))->failed(new RuntimeException('CREATE_FAILED (quota)'));
$session->refresh();
expect($session->etat)->toBe(LabSession::ECHEC)
->and($session->raison_echec)->toBe('CREATE_FAILED (quota)')
->and($session->heat_stack_name)->toBeNull();
});
test('le prof peut relancer une session en échec', function () {
Queue::fake();
$this->mock(StockChaud::class, fn ($stock) => $stock->shouldReceive('prendre')->andReturn(null));
$session = sessionEnAttente(LabSession::ECHEC);
$session->update(['raison_echec' => 'ancienne raison']);
admettre($this, $session);
expect($session->refresh()->etat)->toBe(LabSession::ADMIS)
->and($session->raison_echec)->toBeNull();
Queue::assertPushed(CreerStackLab::class);
});
test('la correction est réussie quand toutes les vérifications hors bonus passent', function () {
$session = sessionEnAttente(LabSession::ACTIF);
foreach (['pc-a', 'routeur', 'pc-b'] as $nom) {
$session->machines()->create(['nom' => $nom, 'instance_id' => "uuid-{$nom}"]);
}
$this->mock(ClientCorrecteur::class, fn ($c) => $c->shouldReceive('verifier')
->andReturnUsing(fn ($instance, $lab, $verification) => ['reussi' => $verification !== 'bonus-permanent', 'erreur' => null]));
$correction = app(CorrigerSession::class)->corriger($session);
expect($correction->reussi)->toBeTrue()
->and($correction->note)->toBe(10)
->and($correction->note_max)->toBe(10);
});
test("une vérification ratée donne « échoué » avec le message du lab, une panne d'agent un message technique", function () {
$session = sessionEnAttente(LabSession::ACTIF);
foreach (['pc-a', 'routeur', 'pc-b'] as $nom) {
$session->machines()->create(['nom' => $nom, 'instance_id' => "uuid-{$nom}"]);
}
$this->mock(ClientCorrecteur::class, fn ($c) => $c->shouldReceive('verifier')
->andReturnUsing(fn ($instance, $lab, $verification) => match ($verification) {
'forwarding' => ['reussi' => false, 'erreur' => null],
'ip-pc-b' => ['reussi' => false, 'erreur' => 'agent_injoignable'],
default => ['reussi' => true, 'erreur' => null],
}));
$correction = app(CorrigerSession::class)->corriger($session);
$details = collect($correction->details)->keyBy('id');
expect($correction->reussi)->toBeFalse()
->and($correction->note)->toBe(8)
->and($details['forwarding']['message'])->toContain('net.ipv4.ip_forward')
->and($details['ip-pc-b']['message'])->toContain('ne répond pas');
});
test("l'élève voit la séance ouverte de sa classe et peut la rejoindre ; la déconnexion porte un jeton CSRF", function () {
$session = sessionEnAttente();
$eleve = $session->eleve;
$session->delete();
$jeton = $eleve->demarreNouvelleSession();
$page = $this->withSession(['jeton_session_eleve' => $jeton])->actingAs($eleve, 'eleve')->get(route('eleve'));
$page->assertOk()
->assertSee('Rejoindre la séance')
->assertSee(route('seances.rejoindre', $session->seance_id), false);
expect(preg_match('#action="'.preg_quote(route('deconnexion'), '#').'" method="post"[^>]*><input type="hidden" name="_token"#', $page->getContent()))->toBe(1);
});

View file

@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Parcours réel de l'étape 11, joué comme deux navigateurs (prof et élève) :
1. l'élève rejoint la séance, le prof l'admet : chronomètre jusqu'à ce que
l'invite de connexion de la VM s'affiche dans la console de l'élève ;
2. l'élève configure le lab 08 par les consoles série, sans activer le
routage : « Vérifier mon travail » doit donner « échoué » avec un message ;
3. il active le routage : la vérification doit donner « réussi ».
Usage (sur le serveur, après preparer-parcours.php) :
sudo -u www-data python3 tests/Reels/parcours-lab.py
"""
import json
import re
import threading
import time
import requests
import websocket
BASE = "http://127.0.0.1"
ids = json.load(open("/tmp/parcours-identifiants.json"))
seance = ids["seance_id"]
def jeton_csrf(session, chemin):
page = session.get(BASE + chemin).text
return re.search(r'name="_token" value="([^"]+)"', page).group(1)
def connecter(chemin_formulaire, champs):
s = requests.Session()
champs["_token"] = jeton_csrf(s, "/connexion")
r = s.post(BASE + chemin_formulaire, data=champs, allow_redirects=False)
assert r.status_code == 302, f"connexion refusée ({r.status_code})"
return s
eleve = connecter("/connexion/eleve", {"identifiant": ids["eleve_identifiant"], "code": ids["eleve_code"]})
prof = connecter("/connexion/prof", {"email": ids["prof_email"], "password": ids["prof_mot_de_passe"]})
# L'élève rejoint : sa page d'attente interroge l'état chaque seconde.
eleve.post(f"{BASE}/seances/{seance}/rejoindre", data={"_token": jeton_csrf(eleve, "/eleve")})
page_prof = prof.get(f"{BASE}/seances/{seance}/attente").text
url_admettre = re.search(r'action="(http[^"]+/admettre/\d+)"', page_prof).group(1).replace("http://localhost", BASE)
url_admettre = re.sub(r"^https?://[^/]+", BASE, url_admettre)
etat_change = threading.Event()
def interroger():
while not etat_change.is_set():
if eleve.get(f"{BASE}/seances/{seance}/etat-eleve").json()["etat"] != "en_attente":
etat_change.set()
return
time.sleep(1)
threading.Thread(target=interroger, daemon=True).start()
time.sleep(1.5)
# --- 1. Chronomètre : clic « Admettre » → invite de connexion visible ---
t0 = time.time()
prof.post(url_admettre, data={"_token": re.search(r'name="_token" value="([^"]+)"', page_prof).group(1)})
etat_change.wait(60)
page = eleve.get(f"{BASE}/seances/{seance}/attente-eleve").text
csrf = re.search(r"'X-CSRF-TOKEN': '([^']+)'", page).group(1)
consoles = {}
for url, nom in zip(re.findall(r"fetch\('(http[^']+/console)'", page), re.findall(r'data-onglet="\d+"[^>]*>([^<]+)<', page)):
url = re.sub(r"^https?://[^/]+", BASE, url)
jeton = eleve.post(url, headers={"X-CSRF-TOKEN": csrf}).json()["jeton"]
consoles[nom] = websocket.create_connection(f"ws://127.0.0.1/console/{jeton}", timeout=10)
def lire_jusqua(ws, motif, delai=15):
fin, sortie = time.time() + delai, b""
while time.time() < fin:
ws.settimeout(max(0.1, fin - time.time()))
try:
morceau = ws.recv()
except websocket.WebSocketTimeoutException:
break
sortie += morceau if isinstance(morceau, bytes) else morceau.encode()
if re.search(motif, sortie):
return sortie
raise TimeoutError(f"motif {motif!r} absent : {sortie[-200:]!r}")
premiere = next(iter(consoles.values()))
premiere.send("\r") # comme l'option « reveil » de console-reelle.js
lire_jusqua(premiere, rb"login:")
duree = time.time() - t0
print(f"1. Admission → invite de connexion : {duree:.2f} s ({'OK' if duree < 5 else 'TROP LENT'}, critère < 5 s)")
print(f" machines ouvertes : {', '.join(consoles)}")
# --- 2. L'élève configure son lab par les consoles série ---
def taper(ws, commande, attendre=rb"\$ $"):
ws.send(commande + "\r")
return lire_jusqua(ws, attendre)
for nom, ws in consoles.items():
if ws is not premiere:
ws.send("\r")
lire_jusqua(ws, rb"login:")
taper(ws, "eleve", rb"[Pp]assword:")
taper(ws, "eleve")
if b"password for" in taper(ws, "sudo -v", rb"(\$ $|password for eleve:)"):
taper(ws, "eleve")
reglages = {
"pc-a": ["sudo ip addr add 192.168.1.10/24 dev ens3", "sudo ip link set ens3 up", "sudo ip route add default via 192.168.1.1"],
"routeur": ["sudo ip addr add 192.168.1.1/24 dev ens3", "sudo ip addr add 192.168.2.1/24 dev ens4", "sudo ip link set ens3 up", "sudo ip link set ens4 up"],
"pc-b": ["sudo ip addr add 192.168.2.10/24 dev ens3", "sudo ip link set ens3 up", "sudo ip route add default via 192.168.2.1"],
}
for nom, commandes in reglages.items():
for commande in commandes:
taper(consoles[nom], commande)
time.sleep(2)
def verifier():
page = eleve.get(f"{BASE}/seances/{seance}/attente-eleve").text
jeton = re.search(r'name="_token" value="([^"]+)"', page).group(1)
session_id = re.search(r"/lab-sessions/(\d+)/corriger", page).group(1)
t = time.time()
page = eleve.post(f"{BASE}/lab-sessions/{session_id}/corriger", data={"_token": jeton}).text
verdict = re.search(r'text-lg font-extrabold">\s*([^<]+?)\s*<span class="font-mono">· (\d+/\d+)', page)
messages = re.findall(r'text-\[#A63D2A\]">([^<]+)<', page)
return verdict.group(1), verdict.group(2), [m.replace("&#039;", "'") for m in messages], time.time() - t
resultat, note, messages, d = verifier()
print(f"2. Sans routage activé : « {resultat} » {note} (vérification en {d:.1f} s)")
for m in messages:
print(f" - {m}")
# --- 3. Le routage activé, le lab doit être réussi ---
taper(consoles["routeur"], "sudo sysctl -w net.ipv4.ip_forward=1")
time.sleep(1)
resultat, note, messages, d = verifier()
print(f"3. Routage activé : « {resultat} » {note} (vérification en {d:.1f} s)")
for m in messages:
print(f" - {m}")
for ws in consoles.values():
ws.close()

View file

@ -0,0 +1,41 @@
<?php
// Données de test du parcours réel (étape 11), à lancer sur le serveur :
// sudo -u www-data HOME=/tmp php8.3 artisan tinker tests/Reels/preparer-parcours.php
// Les identifiants générés vont dans un fichier 600 lu par parcours-lab.py ;
// ils ne sont jamais affichés. Supprimer ces données après le test.
use App\Models\Classe;
use App\Models\Eleve;
use App\Models\Etablissement;
use App\Models\Lab;
use App\Models\Seance;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
$motDePasse = Str::password(24, symbols: false);
$code = Str::upper(Str::random(6));
$etablissement = Etablissement::create(['nom' => 'Lycée Test Étape 11', 'ville' => 'Test']);
$prof = User::create(['name' => 'Prof Test 11', 'email' => 'prof11@syllabus.test', 'password' => $motDePasse, 'etablissement_id' => $etablissement->id]);
$classe = Classe::create(['etablissement_id' => $etablissement->id, 'user_id' => $prof->id, 'nom' => 'Classe Test 11', 'formation' => 'Test']);
Eleve::create(['classe_id' => $classe->id, 'prenom' => 'Testeur', 'identifiant' => 'testeur11', 'code_secret' => Hash::make($code), 'jeton_qr' => Str::random(32)]);
$seance = Seance::create([
'classe_id' => $classe->id,
'lab_id' => Lab::where('identifiant', '08-deux-lan-routeur')->value('id'),
'statut' => Seance::SALLE_ATTENTE,
'demarree_a' => now(),
]);
$fichier = '/tmp/parcours-identifiants.json';
file_put_contents($fichier, json_encode([
'prof_email' => $prof->email,
'prof_mot_de_passe' => $motDePasse,
'eleve_identifiant' => 'testeur11',
'eleve_code' => $code,
'seance_id' => $seance->id,
]));
chmod($fichier, 0600);
echo "données de test prêtes (séance {$seance->id})\n";

View file

@ -124,7 +124,7 @@ func (c *Client) pompeClient() {
defer c.conn.Close() defer c.conn.Close()
for { for {
messageType, donnees, err := c.conn.ReadMessage() _, donnees, err := c.conn.ReadMessage()
if err != nil { if err != nil {
return return
} }
@ -133,8 +133,11 @@ func (c *Client) pompeClient() {
continue continue
} }
// Toujours en binaire vers l'amont : xterm.js envoie des trames texte,
// et nova-serialproxy (sous-protocole « binary ») ferme la connexion
// à la première trame texte reçue (constaté le 2026-10-08, étape 11).
c.hub.mu.Lock() c.hub.mu.Lock()
err = c.hub.upstream.WriteMessage(messageType, donnees) err = c.hub.upstream.WriteMessage(websocket.BinaryMessage, donnees)
c.hub.mu.Unlock() c.hub.mu.Unlock()
if err != nil { if err != nil {
return return