diff --git a/ansible/roles/portail/handlers/main.yml b/ansible/roles/portail/handlers/main.yml index 9b181f6..1769d4b 100644 --- a/ansible/roles/portail/handlers/main.yml +++ b/ansible/roles/portail/handlers/main.yml @@ -2,3 +2,13 @@ - name: recharger nginx ansible.builtin.shell: nginx -t && systemctl reload nginx changed_when: true + +- name: recharger systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: redémarrer le worker de file + ansible.builtin.systemd: + name: portail-worker + state: restarted + enabled: true diff --git a/ansible/roles/portail/tasks/03-code.yml b/ansible/roles/portail/tasks/03-code.yml index 44ad47a..69a4acc 100644 --- a/ansible/roles/portail/tasks/03-code.yml +++ b/ansible/roles/portail/tasks/03-code.yml @@ -44,6 +44,7 @@ - "--exclude=/public/hot" - "--exclude=/.git" register: portail_code_synchronise + notify: redémarrer le worker de file - name: installer les dépendances PHP (composer) ansible.builtin.command: @@ -81,18 +82,24 @@ portail_app_url: "http://{{ ansible_host }}" when: not portail_env_stat.stat.exists -# Variables ajoutées après le premier déploiement (étape 9) : mises à jour -# ligne par ligne, pour ne jamais retoucher le reste d'un .env déjà en place -# (APP_KEY en particulier, qui ne doit jamais changer sans le vouloir). -- name: lire l'application credential OpenStack du portail (étape 6) +# Variables ajoutées après le premier déploiement (étapes 9 et 10) : mises à +# jour ligne par ligne, pour ne jamais retoucher le reste d'un .env déjà en +# place (APP_KEY en particulier, qui ne doit jamais changer sans le vouloir). +# +# Authentification par mot de passe, pas par application credential : Heat +# refuse de créer un trust Keystone à partir d'un jeton d'application +# credential (« Delegated tokens cannot manage trusts », constaté le +# 2026-10-08) — voir portail/config/openstack.php et docs/journal.md. +- name: lire les identifiants OpenStack du portail (étape 6) ansible.builtin.slurp: src: /etc/kolla/clouds-paillasse.yaml register: portail_clouds_brut -- name: extraire l'id et le secret de l'application credential +- name: extraire le nom d'utilisateur et le mot de passe ansible.builtin.set_fact: - portail_os_application_credential_id: "{{ portail_clouds_contenu | regex_search('application_credential_id:\\s*\"?([^\"\\n]+)', '\\1') | first }}" - portail_os_application_credential_secret: "{{ portail_clouds_contenu | regex_search('application_credential_secret:\\s*\"?([^\"\\n]+)', '\\1') | first }}" + portail_os_username: "{{ portail_clouds_contenu | regex_search('username:\\s*\"?([^\"\\n]+)', '\\1') | first }}" + portail_os_password: "{{ portail_clouds_contenu | regex_search('password:\\s*\"?([^\"\\n]+)', '\\1') | first }}" + portail_os_project_name: "{{ portail_clouds_contenu | regex_search('project_name:\\s*\"?([^\"\\n]+)', '\\1') | first }}" vars: portail_clouds_contenu: "{{ portail_clouds_brut.content | b64decode }}" no_log: true @@ -105,9 +112,14 @@ loop: - { cle: "OS_AUTH_URL", valeur: "http://10.10.0.1:5000" } - { cle: "OS_NOVA_URL", valeur: "http://10.10.0.1:8774/v2.1" } - - { cle: "OS_APPLICATION_CREDENTIAL_ID", valeur: "{{ portail_os_application_credential_id }}" } - - { cle: "OS_APPLICATION_CREDENTIAL_SECRET", valeur: "{{ portail_os_application_credential_secret }}" } + - { cle: "OS_HEAT_URL", valeur: "http://10.10.0.1:8004/v1" } + - { cle: "OS_USERNAME", valeur: "{{ portail_os_username }}" } + - { cle: "OS_PASSWORD", valeur: "{{ portail_os_password }}" } + - { cle: "OS_PROJECT_NAME", valeur: "{{ portail_os_project_name }}" } + - { cle: "OS_APPLICATION_CREDENTIAL_ID", valeur: "" } + - { cle: "OS_APPLICATION_CREDENTIAL_SECRET", valeur: "" } - { cle: "REDIS_PREFIX", valeur: "" } + - { cle: "LABS_DOSSIER", valeur: "/opt/labs" } no_log: true register: portail_env_mis_a_jour diff --git a/ansible/roles/portail/tasks/04-labs.yml b/ansible/roles/portail/tasks/04-labs.yml new file mode 100644 index 0000000..5b37dc2 --- /dev/null +++ b/ansible/roles/portail/tasks/04-labs.yml @@ -0,0 +1,17 @@ +--- +# Étape 10 : labs/ est la copie de travail des lab.yaml, lue par +# App\Services\Labs\DefinitionLab pour construire les templates Heat. +- name: créer le dossier des labs + ansible.builtin.file: + path: /opt/labs + state: directory + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + mode: "0755" + +- name: synchroniser les labs + become: false + ansible.posix.synchronize: + src: "{{ playbook_dir }}/../labs/" + dest: /opt/labs/ + delete: true diff --git a/ansible/roles/portail/tasks/04-nginx.yml b/ansible/roles/portail/tasks/05-nginx.yml similarity index 100% rename from ansible/roles/portail/tasks/04-nginx.yml rename to ansible/roles/portail/tasks/05-nginx.yml diff --git a/ansible/roles/portail/tasks/06-queue-scheduler.yml b/ansible/roles/portail/tasks/06-queue-scheduler.yml new file mode 100644 index 0000000..9e9aad8 --- /dev/null +++ b/ansible/roles/portail/tasks/06-queue-scheduler.yml @@ -0,0 +1,27 @@ +--- +# Étape 10 : CreerStackLab et SupprimerStackLab sont des jobs Redis — il faut +# un worker pour les exécuter, et un cron pour l'expiration automatique. +- name: déployer le service systemd du worker de file + ansible.builtin.template: + src: portail-worker.service.j2 + dest: /etc/systemd/system/portail-worker.service + mode: "0644" + notify: + - recharger systemd + - redémarrer le worker de file + +- name: appliquer les changements du worker + ansible.builtin.meta: flush_handlers + +- name: activer et démarrer le worker de file + ansible.builtin.systemd: + name: portail-worker + enabled: true + state: started + +- name: planifier le scheduler Laravel (expiration des sessions de lab) + ansible.builtin.cron: + name: "scheduler Laravel (portail Syllabus)" + user: www-data + minute: "*" + job: "cd {{ portail_dossier_app }} && php artisan schedule:run >> /dev/null 2>&1" diff --git a/ansible/roles/portail/tasks/main.yml b/ansible/roles/portail/tasks/main.yml index c5a8168..a8828df 100644 --- a/ansible/roles/portail/tasks/main.yml +++ b/ansible/roles/portail/tasks/main.yml @@ -8,5 +8,11 @@ - name: code de l'application ansible.builtin.import_tasks: 03-code.yml +- name: labs (copie de travail des lab.yaml) + ansible.builtin.import_tasks: 04-labs.yml + - name: Nginx - ansible.builtin.import_tasks: 04-nginx.yml + ansible.builtin.import_tasks: 05-nginx.yml + +- name: file et planificateur (jobs Heat, expiration des sessions) + ansible.builtin.import_tasks: 06-queue-scheduler.yml diff --git a/ansible/roles/portail/templates/portail-worker.service.j2 b/ansible/roles/portail/templates/portail-worker.service.j2 new file mode 100644 index 0000000..c58179a --- /dev/null +++ b/ansible/roles/portail/templates/portail-worker.service.j2 @@ -0,0 +1,16 @@ +[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 diff --git a/docs/adr/0002-mot-de-passe-pour-heat.md b/docs/adr/0002-mot-de-passe-pour-heat.md new file mode 100644 index 0000000..1070827 --- /dev/null +++ b/docs/adr/0002-mot-de-passe-pour-heat.md @@ -0,0 +1,62 @@ +# ADR 0002 — Authentification par mot de passe pour le compte de service, pas par application credential + +- **Date** : 2026-10-08 +- **Statut** : accepté + +## Contexte + +À l'étape 6, le compte de service `paillasse-service` (utilisé par le portail +Laravel pour piloter OpenStack) avait été équipé d'une *application +credential* plutôt que d'un mot de passe classique — plus simple à révoquer +sans toucher au mot de passe principal du compte, portée limitée à un projet. +Repris sans changement à l'étape 9 pour les appels Nova (console série). + +À l'étape 10, en ajoutant un client Heat pour créer une stack par lab, la +création d'une stack de test a échoué avec `500 Internal Server Error`. Le +journal de `heat-engine` (`/var/lib/docker/volumes/kolla_logs/_data/heat/`, +rien dans `docker logs`) donnait la vraie cause : + +``` +keystoneauth1.exceptions.http.Forbidden: You are not authorized to perform +the requested action: Delegated tokens cannot manage trusts. +``` + +## Décision + +**Le compte de service s'authentifie par mot de passe, pas par application +credential.** + +Ce n'est pas un contournement : c'est une règle de sécurité Keystone, pas un +bug. Heat a besoin de créer un *trust* (Keystone délègue temporairement les +droits de l'appelant au moteur Heat, pour que celui-ci agisse en son nom +pendant toute la vie de la stack). Keystone refuse catégoriquement qu'un +jeton issu d'une application credential — un jeton déjà délégué une fois — +en délègue un second : ce serait une chaîne de délégation, un risque de +sécurité que Keystone élimine par construction, quelle que soit la +configuration. + +Vérifié empiriquement avant de changer quoi que ce soit : mot de passe +régénéré pour `paillasse-service`, fichier `clouds-test-password.yaml` +temporaire avec `auth_type: password`, `openstack stack create --wait` → +`CREATE_COMPLETE`. Confirmé avec Adam (choix : « mot de passe classique pour +`paillasse-service` ») avant d'appliquer en dur. + +Changements : + +- `/etc/kolla/clouds-paillasse.yaml` : `auth_type: password` (mot de passe + généré une fois, stocké root:root 600 sur le serveur, jamais dans Git). + L'application credential `portail-syllabus` a été supprimée. +- `KeystoneAuth` (nouveau) : un seul point d'authentification Laravel, + partagé par `NovaClient` (étape 9, adapté) et `HeatClient` (étape 10). +- Un seul mécanisme d'authentification pour toutes les opérations OpenStack + du portail — pas un mécanisme par service OpenStack appelé. + +## Conséquences + +- Le mot de passe de `paillasse-service` est un secret plus sensible qu'une + application credential (pas de portée limitée à un projet par nature, + révocation = changement de mot de passe plutôt que suppression d'une + credential) : à tourner si jamais exposé, comme n'importe quel secret. +- Toute future intégration OpenStack qui utiliserait des trusts (Octavia, + Magnum, autres templates Heat) doit réutiliser `KeystoneAuth`, pas + réintroduire une application credential. diff --git a/docs/journal.md b/docs/journal.md index 97bd684..0fc92ee 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -346,3 +346,35 @@ La vraie contrainte de cette étape, c'est qu'une console série ne supporte qu' **Suite** Étape 10 : liaison Laravel ↔ OpenStack (client HTTP ou `php-opencloud/openstack`) et modèle Heat du lab 08 — poser la vraie mécanique qu'on a seulement simulée à la main à cette étape (associer une VM à une session de lab). + +## 2026-10-08 — Étape 10 : liaison Laravel ↔ OpenStack et modèle Heat du lab 08 + +**Fait** + +- `GenerateurTemplateHeat` : traduit un `lab.yaml` (format paillasse-contenus : réseaux + machines, chaque machine listant les réseaux auxquels elle est reliée, dans l'ordre) en un vrai gabarit Heat (HOT) — un réseau/sous-réseau Neutron par réseau du lab (`port_security_enabled: false`, `enable_dhcp: false` : pas de pare-feu réseau ni de DHCP, les élèves configurent eux-mêmes, aucune route vers l'extérieur), un port + une instance Nova par machine, les ports attachés dans l'ordre déclaré (c'est cet ordre qui décide si une interface sera `ens3` ou `ens4` dans la VM), une sortie Heat par machine pour récupérer son identifiant une fois la stack créée. +- `HeatClient` (créer/lire/supprimer une stack) et `KeystoneAuth` (authentification Keystone partagée, jeton mis en cache 50 min) : voir [ADR 0002](adr/0002-mot-de-passe-pour-heat.md), la vraie découverte de cette étape. +- Une stack Heat par session de lab (par élève, par tentative), pas par classe : `CreerStackLab` (job Redis, déclenché à l'admission de l'élève) génère le gabarit, crée la stack, attend `CREATE_COMPLETE` (jusqu'à 2 minutes), enregistre une `LabSessionMachine` (nom, identifiant d'instance) par machine du lab. `SupprimerStackLab` (job Redis, déclenché à l'expiration) supprime la stack et ses VM, et classe la session en `expire` ou `terminee` selon le cas. +- `ExpirerSessionsLab` (commande Artisan `labs:expirer-sessions`), planifiée chaque minute via `Schedule::command()` dans `routes/console.php` + un cron système qui appelle `artisan schedule:run`. +- Les deux jobs ont besoin d'un worker : service systemd `portail-worker` (`queue:work`), déployé et redémarré par Ansible à chaque déploiement de code (sans ce redémarrage, un worker déjà lancé continue de faire tourner l'ancien code en mémoire). +- `labs/` : nouveau dossier à la racine du dépôt, un sous-dossier par lab, copié par Ansible vers `/opt/labs` sur le serveur (distinct de `paillasse-contenus/`, qui reste la source éditoriale ; `labs/` est la copie de travail que lit le portail). Lab 08 (deux réseaux locaux reliés par un routeur, 3 machines) copié en premier. +- Salle d'attente élève et console prof passées à plusieurs terminaux (un onglet par VM de la session), avec un préfixe de route supplémentaire (`{labSessionMachine}`) pour identifier quelle VM ouvrir. + +**Problèmes rencontrés et solution** + +1. **La vraie découverte de l'étape** : création de stack Heat refusée (`500`, trace dans `heat-engine.log` : « Delegated tokens cannot manage trusts »). Le compte de service du portail utilisait une application credential (choix de l'étape 6) ; Heat a besoin de créer un trust Keystone pour gérer le cycle de vie d'une stack, et Keystone interdit par construction à un jeton déjà délégué (une application credential en est un) d'en déléguer un second. Vérifié avec un compte à mot de passe classique avant de rien changer en dur ; confirmé avec Adam ; appliqué partout (voir ADR 0002). Un seul mécanisme d'authentification désormais pour tout ce que le portail demande à OpenStack. +2. `openstack user set --password` avec le cloud `kolla-admin` et `OS_PROJECT_NAME=paillasse` → `401` : même limitation que l'étape 6 (le compte `admin` n'a pas de rôle sur le projet `paillasse`, donc pas de jeton scopé à ce projet). Pas réinvestigué, contourné en utilisant directement le cloud `paillasse-service`. +3. `sudo -u www-data php artisan tinker` échouait (« Writing to directory /var/www/.config/psysh is not allowed ») : `www-data` n'a pas de `$HOME` inscriptible. Contourné avec `HOME=/tmp` à chaque appel. + +**Critère vérifié (sur le serveur réel)** + +- Classe, élève, séance de test créés ; admission de l'élève sur le lab 08 → `CreerStackLab` exécuté en 26 s → stack `CREATE_COMPLETE`, 3 VM créées. `ip link show` dans chaque VM confirme la topologie exacte du `lab.yaml` : le routeur a 2 interfaces (`ens3`, `ens4`), les deux postes (`pc-a`, `pc-b`) n'en ont qu'une chacun, sur des réseaux différents. +- Les 3 terminaux se sont ouverts côté élève, chacun relié à la bonne VM. +- Vérification allant au-delà du critère minimal : expiration forcée (`expire_a` mis dans le passé) → `labs:expirer-sessions` → `SupprimerStackLab` exécuté en 1 s → stack et 3 VM confirmées absentes (`openstack stack list` / `server list` vides) → session classée `expire`. Le cycle de vie complet (création, usage, expiration automatique) fonctionne de bout en bout sans intervention manuelle. + +**Comprendre** + +La leçon de cette étape dépasse Heat : dans Keystone, un jeton porte une histoire (« comment a-t-il été obtenu ? »), pas seulement une identité et des droits. Une application credential produit un jeton marqué comme délégué ; peu importe les droits du compte derrière, ce jeton ne pourra jamais en déléguer un second — c'est une protection contre les chaînes de délégation, pas un détail de configuration qu'on pourrait activer. Avant d'ajouter un service OpenStack qui gère un cycle de vie (Heat aujourd'hui ; Octavia, Magnum plus tard), il faut vérifier s'il a besoin d'un trust — et donc d'un compte à mot de passe, jamais d'une application credential. + +**Suite** + +Étape 11 : stock chaud de VM (files Redis) et correction automatique (agent invité QEMU). diff --git a/docs/runbooks/labs.md b/docs/runbooks/labs.md new file mode 100644 index 0000000..ee39979 --- /dev/null +++ b/docs/runbooks/labs.md @@ -0,0 +1,49 @@ +# Runbook — Labs et stacks Heat (étape 10) + +## Où sont les choses + +| Élément | Emplacement | +|---|---| +| 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 | +| Lecture d'un `lab.yaml` | `portail/app/Services/Labs/DefinitionLab.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)) | +| Appels Heat | `portail/app/Services/OpenStack/HeatClient.php` | +| Création de stack (à l'admission) | `portail/app/Jobs/CreerStackLab.php` | +| Suppression de stack (à l'expiration ou à la fin) | `portail/app/Jobs/SupprimerStackLab.php` | +| 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) | +| Worker qui exécute les jobs | service systemd `portail-worker` (`queue:work`) | + +## 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.). +2. Copier (ou faire relire) ce `lab.yaml` 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`). +4. Déployer (`ansible-playbook portail.yml`) : la tâche `04-labs.yml` synchronise `labs/` vers `/opt/labs` sur le serveur. +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. + +## Déclencher l'expiration manuellement + +``` +ssh paillasse "cd /var/www/portail && sudo -u www-data HOME=/tmp php8.3 artisan labs:expirer-sessions" +``` + +## Inspecter une stack en cours sur le serveur + +``` +sudo -i +export PATH=/opt/kolla-venv/bin:$PATH +export OS_CLIENT_CONFIG_FILE=/etc/kolla/clouds-paillasse.yaml +export OS_CLOUD=paillasse-service +openstack stack list +openstack stack show lab-session-{id} +openstack server list +``` + +## 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. +- **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é. +- `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. +- 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. diff --git a/labs/08-deux-lan-routeur/lab.yaml b/labs/08-deux-lan-routeur/lab.yaml new file mode 100644 index 0000000..a964a34 --- /dev/null +++ b/labs/08-deux-lan-routeur/lab.yaml @@ -0,0 +1,115 @@ +id: 08-deux-lan-routeur +titre: Deux LAN et un routeur +niveau: decouverte +duree_min: 45 +formations: [nsi-tle, bts-sio-sisr, bts-ciel-ir, but-rt, bac-pro-ciel, tssr] +competences: [adressage-ipv4, passerelle, routage-statique, ip-forwarding, diagnostic] + +reseaux: + - nom: lan1 + cidr: 192.168.1.0/24 + - nom: lan2 + cidr: 192.168.2.0/24 + +machines: + - nom: pc-a + image: paillasse-debian + flavor: eleve.petit + reseaux: [lan1] + - nom: routeur + image: paillasse-debian + flavor: eleve.petit + reseaux: [lan1, lan2] + - nom: pc-b + image: paillasse-debian + flavor: eleve.petit + reseaux: [lan2] + +consignes: + - "Repère le nom des interfaces de chaque machine avec `ip link`." + - "Attribue 192.168.1.10/24 à PC-A." + - "Attribue 192.168.1.1/24 et 192.168.2.1/24 aux deux interfaces du routeur." + - "Attribue 192.168.2.10/24 à PC-B." + - "Donne à PC-A et PC-B leur passerelle par défaut : l'adresse du routeur sur leur LAN." + - "Active le routage IP sur le routeur." + - "Vérifie que PC-A joint PC-B avec `ping`." + - "Bonus : rends la configuration permanente (elle doit survivre à un redémarrage)." + +verifications: + - id: ip-pc-a + titre: Adresse de PC-A + machine: pc-a + commande: "ip -4 -o addr | grep -q ' 192.168.1.10/24 '" + attendu: { code_retour: 0 } + points: 1 + message_echec: "PC-A n'a pas l'adresse 192.168.1.10/24." + + - id: ip-routeur-lan1 + titre: Adresse du routeur sur le LAN 1 + machine: routeur + commande: "ip -4 -o addr | grep -q ' 192.168.1.1/24 '" + attendu: { code_retour: 0 } + points: 1 + message_echec: "Le routeur n'a pas 192.168.1.1/24 sur son interface du LAN 1." + + - id: ip-routeur-lan2 + titre: Adresse du routeur sur le LAN 2 + machine: routeur + commande: "ip -4 -o addr | grep -q ' 192.168.2.1/24 '" + attendu: { code_retour: 0 } + points: 1 + message_echec: "Le routeur n'a pas 192.168.2.1/24 sur son interface du LAN 2." + + - id: ip-pc-b + titre: Adresse de PC-B + machine: pc-b + commande: "ip -4 -o addr | grep -q ' 192.168.2.10/24 '" + attendu: { code_retour: 0 } + points: 1 + message_echec: "PC-B n'a pas l'adresse 192.168.2.10/24." + + - id: passerelle-pc-a + titre: Passerelle de PC-A + machine: pc-a + commande: "ip route show default | grep -q 'via 192.168.1.1'" + attendu: { code_retour: 0 } + points: 1 + message_echec: "PC-A n'a pas de route par défaut via 192.168.1.1." + + - id: passerelle-pc-b + titre: Passerelle de PC-B + machine: pc-b + commande: "ip route show default | grep -q 'via 192.168.2.1'" + attendu: { code_retour: 0 } + points: 1 + message_echec: "PC-B n'a pas de route par défaut via 192.168.2.1." + + - id: forwarding + titre: Routage activé sur le routeur + machine: routeur + commande: "test \"$(cat /proc/sys/net/ipv4/ip_forward)\" = 1" + attendu: { code_retour: 0 } + points: 2 + message_echec: "Le routeur ne transmet pas les paquets : regarde net.ipv4.ip_forward." + + - id: ping-a-vers-b + titre: PC-A joint PC-B + machine: pc-a + commande: "ping -c 2 -W 1 192.168.2.10" + attendu: { code_retour: 0 } + points: 2 + message_echec: "PC-A ne joint pas PC-B. Vérifie les adresses, les passerelles et le routage, dans cet ordre." + + - id: bonus-permanent + titre: "Bonus : configuration permanente" + machine: routeur + commande: "grep -rqs 'ip_forward *= *1' /etc/sysctl.conf /etc/sysctl.d/ && grep -rqs '192.168.2.1' /etc/network/interfaces /etc/network/interfaces.d/ /etc/systemd/network/" + attendu: { code_retour: 0 } + points: 1 + message_echec: "La configuration du routeur disparaîtra au redémarrage (sysctl et fichier d'interfaces)." + +indices: + - "`ip addr add 192.168.1.10/24 dev ` puis `ip link set up`." + - "La passerelle s'ajoute avec `ip route add default via `." + - "Le routage se lit dans /proc/sys/net/ipv4/ip_forward et s'active avec `sysctl`." + - "Si le ping échoue, teste d'abord PC-A vers 192.168.1.1, puis 192.168.2.1, puis PC-B." diff --git a/portail/app/Console/Commands/ExpirerSessionsLab.php b/portail/app/Console/Commands/ExpirerSessionsLab.php new file mode 100644 index 0000000..c1aa09d --- /dev/null +++ b/portail/app/Console/Commands/ExpirerSessionsLab.php @@ -0,0 +1,31 @@ +where('expire_a', '<', now()) + ->get(); + + foreach ($sessions as $session) { + SupprimerStackLab::dispatch($session); + } + + $this->info("{$sessions->count()} session(s) de lab expirée(s), suppression lancée."); + } +} diff --git a/portail/app/Http/Controllers/ConsoleController.php b/portail/app/Http/Controllers/ConsoleController.php index 47656db..0f5a405 100644 --- a/portail/app/Http/Controllers/ConsoleController.php +++ b/portail/app/Http/Controllers/ConsoleController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\LabSession; +use App\Models\LabSessionMachine; use App\Models\Seance; use App\Services\OpenStack\JetonConsole; use Illuminate\Http\JsonResponse; @@ -11,34 +12,33 @@ use Illuminate\Support\Facades\Auth; /** * Émet des jetons de console série à usage court (étape 9) : le navigateur * ne reçoit jamais l'adresse de nova-serialproxy, seulement un jeton Redis - * valable 5 minutes. L'élève peut écrire dans sa propre VM ; le prof ne - * peut que regarder celle d'un élève de sa classe. + * valable 5 minutes. L'élève peut écrire dans ses propres VM ; le prof ne + * peut que regarder celles d'un élève de sa classe (étape 10 : une session + * de lab a plusieurs machines, voir LabSessionMachine). */ class ConsoleController extends Controller { public function __construct(private readonly JetonConsole $jetons) {} - public function pourEleve(LabSession $labSession): JsonResponse + public function pourEleve(LabSession $labSession, LabSessionMachine $labSessionMachine): JsonResponse { $eleve = Auth::guard('eleve')->user(); abort_if($labSession->eleve_id !== $eleve->id, 403); - abort_if($labSession->etat !== LabSession::ADMIS, 403, 'En attente d\'admission.'); - abort_if($labSession->stack_id === null, 404, 'Aucune VM associée à cette session.'); + abort_if($labSessionMachine->lab_session_id !== $labSession->id, 404); return response()->json([ - 'jeton' => $this->jetons->pourEcriture($labSession->stack_id), + 'jeton' => $this->jetons->pourEcriture($labSessionMachine->instance_id), ]); } - public function pourProf(Seance $seance, LabSession $labSession): JsonResponse + public function pourProf(Seance $seance, LabSession $labSession, LabSessionMachine $labSessionMachine): JsonResponse { abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403); abort_if($labSession->seance_id !== $seance->id, 404); - abort_if($labSession->etat !== LabSession::ADMIS, 403, 'Élève pas encore admis.'); - abort_if($labSession->stack_id === null, 404, 'Aucune VM associée à cette session.'); + abort_if($labSessionMachine->lab_session_id !== $labSession->id, 404); return response()->json([ - 'jeton' => $this->jetons->pourLecture($labSession->stack_id), + 'jeton' => $this->jetons->pourLecture($labSessionMachine->instance_id), ]); } } diff --git a/portail/app/Http/Controllers/SeanceController.php b/portail/app/Http/Controllers/SeanceController.php index a9181af..962f4c3 100644 --- a/portail/app/Http/Controllers/SeanceController.php +++ b/portail/app/Http/Controllers/SeanceController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Jobs\CreerStackLab; use App\Models\Classe; use App\Models\LabSession; use App\Models\Seance; @@ -34,7 +35,7 @@ class SeanceController extends Controller abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403); return view('seances.attente', [ - 'seance' => $seance->load(['classe', 'lab', 'labSessions.eleve']), + 'seance' => $seance->load(['classe', 'lab', 'labSessions.eleve', 'labSessions.machines']), ]); } @@ -45,16 +46,27 @@ class SeanceController extends Controller return view('seances.console-prof', [ 'seance' => $seance->load('lab'), - 'labSession' => $labSession->load('eleve'), + 'labSession' => $labSession->load(['eleve', 'machines']), ]); } + /** + * Admettre un élève, c'est lancer son lab : la stack Heat se construit + * en tâche de fond (étape 10), le terminal apparaît une fois prête. + */ public function admettre(Seance $seance, LabSession $labSession): RedirectResponse { abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403); abort_if($labSession->seance_id !== $seance->id, 404); - $labSession->update(['etat' => LabSession::ADMIS]); + $dureeMinutes = $labSession->lab->duree_min ?? 45; + + $labSession->update([ + 'etat' => LabSession::ADMIS, + 'expire_a' => now()->addMinutes($dureeMinutes), + ]); + + CreerStackLab::dispatch($labSession); return redirect()->route('seances.attente', $seance); } diff --git a/portail/app/Jobs/CreerStackLab.php b/portail/app/Jobs/CreerStackLab.php new file mode 100644 index 0000000..7f32ff0 --- /dev/null +++ b/portail/app/Jobs/CreerStackLab.php @@ -0,0 +1,81 @@ +labSession->lab->identifiant); + $nomStack = "lab-session-{$this->labSession->id}"; + + ['template' => $template] = $generateur->genererDepuis($lab, $nomStack); + + $stack = $heat->creerStack($nomStack, $template); + + $this->labSession->forceFill([ + 'heat_stack_id' => $stack['id'], + 'heat_stack_name' => $nomStack, + ])->save(); + + $etat = $this->attendreCompletion($heat, $nomStack, $stack['id']); + + if ($etat['statut'] !== 'CREATE_COMPLETE') { + Log::error("CreerStackLab : la stack {$nomStack} n'a pas abouti", $etat); + + return; + } + + foreach ($lab->machines as $machine) { + $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(); + } + + /** + * 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} + */ + private function attendreCompletion(HeatClient $heat, string $nom, string $id): array + { + for ($tentative = 0; $tentative < 60; $tentative++) { + $etat = $heat->etatStack($nom, $id); + + if (! str_ends_with($etat['statut'], '_IN_PROGRESS')) { + return $etat; + } + + sleep(2); + } + + return $etat; + } +} diff --git a/portail/app/Jobs/SupprimerStackLab.php b/portail/app/Jobs/SupprimerStackLab.php new file mode 100644 index 0000000..bdd52de --- /dev/null +++ b/portail/app/Jobs/SupprimerStackLab.php @@ -0,0 +1,37 @@ +labSession->heat_stack_name !== null) { + $heat->supprimerStack($this->labSession->heat_stack_name, $this->labSession->heat_stack_id); + } + + $nouvelEtat = $this->labSession->expire_a !== null && $this->labSession->expire_a->isPast() + ? LabSession::EXPIRE + : LabSession::TERMINE; + + $this->labSession->forceFill(['etat' => $nouvelEtat])->save(); + } +} diff --git a/portail/app/Models/LabSession.php b/portail/app/Models/LabSession.php index bbc2f69..f311e57 100644 --- a/portail/app/Models/LabSession.php +++ b/portail/app/Models/LabSession.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class LabSession extends Model { @@ -12,15 +13,17 @@ class LabSession extends Model public const EN_ATTENTE = 'en_attente'; + // Admis par le prof : CreerStackLab est dispatché, la stack Heat se construit. public const ADMIS = 'admis'; + // Stack CREATE_COMPLETE : les VM existent, les terminaux peuvent s'ouvrir. public const ACTIF = 'actif'; public const TERMINE = 'termine'; public const EXPIRE = 'expire'; - protected $fillable = ['seance_id', 'eleve_id', 'lab_id', 'stack_id', 'etat', 'expire_a']; + protected $fillable = ['seance_id', 'eleve_id', 'lab_id', 'heat_stack_id', 'heat_stack_name', 'etat', 'expire_a']; protected $casts = [ 'expire_a' => 'datetime', @@ -40,4 +43,9 @@ class LabSession extends Model { return $this->belongsTo(Lab::class); } + + public function machines(): HasMany + { + return $this->hasMany(LabSessionMachine::class); + } } diff --git a/portail/app/Models/LabSessionMachine.php b/portail/app/Models/LabSessionMachine.php new file mode 100644 index 0000000..7fdb24c --- /dev/null +++ b/portail/app/Models/LabSessionMachine.php @@ -0,0 +1,19 @@ +belongsTo(LabSession::class); + } +} diff --git a/portail/app/Services/Labs/DefinitionLab.php b/portail/app/Services/Labs/DefinitionLab.php new file mode 100644 index 0000000..bc00aab --- /dev/null +++ b/portail/app/Services/Labs/DefinitionLab.php @@ -0,0 +1,51 @@ + */ + public readonly array $reseaux; + + /** @var array, preparation: ?string}> */ + public readonly array $machines; + + public static function depuis(string $identifiantLab): self + { + $chemin = config('labs.dossier')."/{$identifiantLab}/lab.yaml"; + + if (! File::exists($chemin)) { + throw new RuntimeException("Lab introuvable : {$chemin}"); + } + + $donnees = Yaml::parseFile($chemin); + + return new self($donnees); + } + + private function __construct(array $donnees) + { + $this->identifiant = $donnees['id']; + $this->reseaux = $donnees['reseaux']; + $this->machines = array_map( + fn (array $m) => [ + 'nom' => $m['nom'], + 'image' => $m['image'], + 'flavor' => $m['flavor'], + 'reseaux' => $m['reseaux'], + 'preparation' => $m['preparation'] ?? null, + ], + $donnees['machines'], + ); + } +} diff --git a/portail/app/Services/OpenStack/GenerateurTemplateHeat.php b/portail/app/Services/OpenStack/GenerateurTemplateHeat.php new file mode 100644 index 0000000..d1c8bae --- /dev/null +++ b/portail/app/Services/OpenStack/GenerateurTemplateHeat.php @@ -0,0 +1,107 @@ +} + */ + public function genererDepuis(DefinitionLab $lab, string $prefixe): array + { + $resources = []; + $outputs = []; + + foreach ($lab->reseaux as $reseau) { + $cleReseau = self::cle($reseau['nom']); + + $resources["reseau_{$cleReseau}"] = [ + 'type' => 'OS::Neutron::Net', + 'properties' => [ + 'name' => "{$prefixe}-{$reseau['nom']}", + 'port_security_enabled' => false, + ], + ]; + + $resources["sousreseau_{$cleReseau}"] = [ + 'type' => 'OS::Neutron::Subnet', + 'properties' => [ + 'network' => ['get_resource' => "reseau_{$cleReseau}"], + 'cidr' => $reseau['cidr'], + 'enable_dhcp' => false, + ], + ]; + } + + $nomsMachines = []; + + foreach ($lab->machines as $machine) { + $cleMachine = self::cle($machine['nom']); + $nomsMachines[] = $machine['nom']; + $ports = []; + + foreach ($machine['reseaux'] as $nomReseau) { + $cleReseau = self::cle($nomReseau); + $clePort = "port_{$cleMachine}_{$cleReseau}"; + + $resources[$clePort] = [ + 'type' => 'OS::Neutron::Port', + 'properties' => [ + 'network' => ['get_resource' => "reseau_{$cleReseau}"], + 'port_security_enabled' => false, + 'fixed_ips' => [], + ], + ]; + + $ports[] = ['port' => ['get_resource' => $clePort]]; + } + + $resources["machine_{$cleMachine}"] = [ + 'type' => 'OS::Nova::Server', + 'properties' => [ + 'name' => "{$prefixe}-{$machine['nom']}", + 'image' => $machine['image'], + 'flavor' => $machine['flavor'], + 'networks' => $ports, + ], + ]; + + $outputs["{$cleMachine}_id"] = [ + 'description' => "Id de l'instance {$machine['nom']}", + 'value' => ['get_resource' => "machine_{$cleMachine}"], + ]; + } + + $template = [ + 'heat_template_version' => '2021-04-16', + 'description' => "Lab {$lab->identifiant} ({$prefixe})", + 'resources' => $resources, + 'outputs' => $outputs, + ]; + + return ['template' => $template, 'noms_machines' => $nomsMachines]; + } + + /** + * Les noms de ressources Heat ne peuvent pas contenir les caractères du + * nom du lab (ex: "pc-a" → "pc_a"). Utilisé aussi par CreerStackLab pour + * retrouver, dans les sorties de la stack, quelle clé correspond à + * quelle machine. + */ + public static function cle(string $nom): string + { + return str_replace('-', '_', $nom); + } +} diff --git a/portail/app/Services/OpenStack/HeatClient.php b/portail/app/Services/OpenStack/HeatClient.php new file mode 100644 index 0000000..4fc105b --- /dev/null +++ b/portail/app/Services/OpenStack/HeatClient.php @@ -0,0 +1,77 @@ +requete()->post($this->url('/stacks'), [ + 'stack_name' => $nom, + 'disable_rollback' => true, + 'template' => $template, + ]); + + if ($reponse->failed()) { + throw new RuntimeException("Heat a refusé la création de la stack {$nom} : ".$reponse->body()); + } + + return ['id' => $reponse->json('stack.id')]; + } + + /** + * @return array{statut: string, raison: ?string, sorties: array} + */ + public function etatStack(string $nom, string $id): array + { + $reponse = $this->requete()->get($this->url("/stacks/{$nom}/{$id}")); + + if ($reponse->failed()) { + throw new RuntimeException("Impossible de lire l'état de la stack {$nom} : ".$reponse->body()); + } + + $sorties = []; + foreach ($reponse->json('stack.outputs') ?? [] as $sortie) { + $sorties[$sortie['output_key']] = $sortie['output_value']; + } + + return [ + 'statut' => $reponse->json('stack.stack_status'), + 'raison' => $reponse->json('stack.stack_status_reason'), + 'sorties' => $sorties, + ]; + } + + public function supprimerStack(string $nom, string $id): void + { + $reponse = $this->requete()->delete($this->url("/stacks/{$nom}/{$id}")); + + // 404 : déjà supprimée (idempotent, pas une erreur pour nous). + if ($reponse->failed() && $reponse->status() !== 404) { + throw new RuntimeException("Heat a refusé la suppression de la stack {$nom} : ".$reponse->body()); + } + } + + private function requete() + { + return Http::withHeaders(['X-Auth-Token' => $this->auth->jeton()]); + } + + private function url(string $chemin): string + { + return config('openstack.heat_url').'/'.$this->auth->projetId().$chemin; + } +} diff --git a/portail/app/Services/OpenStack/KeystoneAuth.php b/portail/app/Services/OpenStack/KeystoneAuth.php new file mode 100644 index 0000000..1598aed --- /dev/null +++ b/portail/app/Services/OpenStack/KeystoneAuth.php @@ -0,0 +1,66 @@ +authentifier()['jeton']; + } + + public function projetId(): string + { + return $this->authentifier()['projet_id']; + } + + /** + * @return array{jeton: string, projet_id: string} + */ + private function authentifier(): array + { + return Cache::remember('openstack.authentification', now()->addMinutes(50), function () { + $reponse = Http::post(config('openstack.auth_url').'/v3/auth/tokens', [ + 'auth' => [ + 'identity' => [ + 'methods' => ['password'], + 'password' => [ + 'user' => [ + 'name' => config('openstack.username'), + 'domain' => ['id' => 'default'], + 'password' => config('openstack.password'), + ], + ], + ], + 'scope' => [ + 'project' => [ + 'name' => config('openstack.project_name'), + 'domain' => ['id' => 'default'], + ], + ], + ], + ]); + + if ($reponse->failed()) { + throw new RuntimeException('Authentification OpenStack refusée : '.$reponse->body()); + } + + return [ + 'jeton' => $reponse->header('X-Subject-Token'), + 'projet_id' => $reponse->json('token.project.id'), + ]; + }); + } +} diff --git a/portail/app/Services/OpenStack/NovaClient.php b/portail/app/Services/OpenStack/NovaClient.php index 8146852..f484126 100644 --- a/portail/app/Services/OpenStack/NovaClient.php +++ b/portail/app/Services/OpenStack/NovaClient.php @@ -2,21 +2,22 @@ namespace App\Services\OpenStack; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use RuntimeException; /** * Juste ce qu'il faut pour demander à Nova l'adresse de la console série * d'une VM (étape 9). La liaison Laravel ↔ OpenStack complète (Heat, labs) - * est l'étape 10 : on ne construit pas de client générique ici. + * est l'étape 10 : voir HeatClient. */ class NovaClient { + public function __construct(private readonly KeystoneAuth $auth) {} + public function consoleSerielle(string $instanceId): string { $reponse = Http::withHeaders([ - 'X-Auth-Token' => $this->jetonKeystone(), + 'X-Auth-Token' => $this->auth->jeton(), 'OpenStack-API-Version' => 'compute 2.99', ]) ->post(config('openstack.nova_url')."/servers/{$instanceId}/remote-consoles", [ @@ -29,31 +30,4 @@ class NovaClient return $reponse->json('remote_console.url'); } - - /** - * Jeton Keystone mis en cache (un jeton v3 dure 1h côté Keystone, on le - * garde un peu moins longtemps par prudence). - */ - private function jetonKeystone(): string - { - return Cache::remember('openstack.jeton_keystone', now()->addMinutes(50), function () { - $reponse = Http::post(config('openstack.auth_url').'/v3/auth/tokens', [ - 'auth' => [ - 'identity' => [ - 'methods' => ['application_credential'], - 'application_credential' => [ - 'id' => config('openstack.application_credential_id'), - 'secret' => config('openstack.application_credential_secret'), - ], - ], - ], - ]); - - if ($reponse->failed()) { - throw new RuntimeException('Authentification OpenStack refusée : '.$reponse->body()); - } - - return $reponse->header('X-Subject-Token'); - }); - } } diff --git a/portail/composer.json b/portail/composer.json index 637cbbb..6b09b00 100644 --- a/portail/composer.json +++ b/portail/composer.json @@ -9,7 +9,8 @@ "php": "^8.3", "bacon/bacon-qr-code": "^3.1", "laravel/framework": "^13.17", - "laravel/tinker": "^3.0" + "laravel/tinker": "^3.0", + "symfony/yaml": "^7.4" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/portail/composer.lock b/portail/composer.lock index a8d728a..433e6e7 100644 --- a/portail/composer.lock +++ b/portail/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "50c7a6013c4c05685d2a3b021614b26b", + "content-hash": "4bbbdac2ebdd2568b9a8375272d3a7be", "packages": [ { "name": "bacon/bacon-qr-code", @@ -6042,6 +6042,82 @@ ], "time": "2026-08-30T20:10:52+00:00" }, + { + "name": "symfony/yaml", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0222bf4dfaa1e3ceb8fff579635d655f2950d5a7", + "reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-26T06:31:10+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", diff --git a/portail/config/labs.php b/portail/config/labs.php new file mode 100644 index 0000000..331935f --- /dev/null +++ b/portail/config/labs.php @@ -0,0 +1,8 @@ + env('LABS_DOSSIER', dirname(base_path()).'/labs'), +]; diff --git a/portail/config/openstack.php b/portail/config/openstack.php index 816edc6..a6c45e7 100644 --- a/portail/config/openstack.php +++ b/portail/config/openstack.php @@ -1,13 +1,23 @@ env('OS_AUTH_URL', 'http://10.10.0.1:5000'), - 'application_credential_id' => env('OS_APPLICATION_CREDENTIAL_ID'), - 'application_credential_secret' => env('OS_APPLICATION_CREDENTIAL_SECRET'), + 'username' => env('OS_USERNAME', 'paillasse-service'), + 'password' => env('OS_PASSWORD'), + 'project_name' => env('OS_PROJECT_NAME', 'paillasse'), 'nova_url' => env('OS_NOVA_URL', 'http://10.10.0.1:8774/v2.1'), + 'heat_url' => env('OS_HEAT_URL', 'http://10.10.0.1:8004/v1'), ]; diff --git a/portail/database/migrations/2026_10_08_075332_update_lab_sessions_for_heat.php b/portail/database/migrations/2026_10_08_075332_update_lab_sessions_for_heat.php new file mode 100644 index 0000000..c448b89 --- /dev/null +++ b/portail/database/migrations/2026_10_08_075332_update_lab_sessions_for_heat.php @@ -0,0 +1,24 @@ +renameColumn('stack_id', 'heat_stack_id'); + $table->string('heat_stack_name')->nullable()->after('heat_stack_id'); + }); + } + + public function down(): void + { + Schema::table('lab_sessions', function (Blueprint $table) { + $table->dropColumn('heat_stack_name'); + $table->renameColumn('heat_stack_id', 'stack_id'); + }); + } +}; diff --git a/portail/database/migrations/2026_10_08_075333_create_lab_session_machines_table.php b/portail/database/migrations/2026_10_08_075333_create_lab_session_machines_table.php new file mode 100644 index 0000000..8abe11d --- /dev/null +++ b/portail/database/migrations/2026_10_08_075333_create_lab_session_machines_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('lab_session_id')->constrained()->cascadeOnDelete(); + $table->string('nom')->comment('ex: pc-a, routeur, pc-b — voir lab.yaml'); + $table->string('instance_id')->comment('id de la VM Nova, sortie de la stack Heat'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('lab_session_machines'); + } +}; diff --git a/portail/resources/views/seances/attente-eleve.blade.php b/portail/resources/views/seances/attente-eleve.blade.php index 4b65ff0..7e3bf74 100644 --- a/portail/resources/views/seances/attente-eleve.blade.php +++ b/portail/resources/views/seances/attente-eleve.blade.php @@ -6,32 +6,57 @@ @if ($labSession->etat === \App\Models\LabSession::EN_ATTENTE) @endif -
+

{{ $seance->lab->titre }}

@if ($labSession->etat === \App\Models\LabSession::EN_ATTENTE)

En attente que le prof t'admette…

Cette page se rafraîchit toute seule.

- @elseif ($labSession->stack_id) -

Ta console

-
+ @elseif ($labSession->etat === \App\Models\LabSession::ADMIS) + +
+

Préparation de tes VM…

+

Ça prend généralement moins d'une minute.

+
+ @elseif ($labSession->machines->isNotEmpty()) +

Tes machines

+
+ @foreach ($labSession->machines as $i => $machine) + + @endforeach +
+ @foreach ($labSession->machines as $i => $machine) + + @endforeach @else
-

Tu es admis·e !

-

Le lancement de ta VM arrive dans une prochaine étape du déploiement.

+

La préparation a échoué

+

Préviens ton prof.

@endif
diff --git a/portail/resources/views/seances/attente.blade.php b/portail/resources/views/seances/attente.blade.php index fe2d79d..f36d587 100644 --- a/portail/resources/views/seances/attente.blade.php +++ b/portail/resources/views/seances/attente.blade.php @@ -24,7 +24,7 @@ @else Admis - @if ($labSession->stack_id) + @if ($labSession->machines->isNotEmpty()) Voir sa console @endif diff --git a/portail/resources/views/seances/console-prof.blade.php b/portail/resources/views/seances/console-prof.blade.php index 9a90bdc..3ac361b 100644 --- a/portail/resources/views/seances/console-prof.blade.php +++ b/portail/resources/views/seances/console-prof.blade.php @@ -3,21 +3,44 @@ @section('titre', 'Console de ' . $labSession->eleve->nomAffiche()) @section('contenu') -
+

{{ $seance->lab->titre }} · lecture seule

Console de {{ $labSession->eleve->nomAffiche() }}

-
- - - - - + + @if ($labSession->machines->isEmpty()) +

Les VM ne sont pas encore prêtes.

+ @else +
+ @foreach ($labSession->machines as $i => $machine) + + @endforeach +
+ @foreach ($labSession->machines as $i => $machine) + + @endforeach + + + + + + @endif
@endsection diff --git a/portail/routes/console.php b/portail/routes/console.php index 3c9adf1..2e761ca 100644 --- a/portail/routes/console.php +++ b/portail/routes/console.php @@ -2,7 +2,11 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Étape 10 : ferme les sessions de lab dont l'expiration est passée. +Schedule::command('labs:expirer-sessions')->everyMinute(); diff --git a/portail/routes/web.php b/portail/routes/web.php index ccf42af..958d602 100644 --- a/portail/routes/web.php +++ b/portail/routes/web.php @@ -68,7 +68,7 @@ Route::middleware(['auth:eleve', 'session.unique:eleve'])->group(function () { Route::get('/eleve', [EleveTableauDeBordController::class, 'show'])->name('eleve'); 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::post('/lab-sessions/{labSession}/console', [ConsoleController::class, 'pourEleve'])->name('console.eleve'); + Route::post('/lab-sessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourEleve'])->name('console.eleve'); }); // --- Espace prof (protégé, session unique) --- @@ -82,5 +82,5 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () { Route::get('/seances/{seance}/attente', [SeanceController::class, 'attente'])->name('seances.attente'); 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::post('/seances/{seance}/labsessions/{labSession}/console', [ConsoleController::class, 'pourProf'])->name('console.prof'); + Route::post('/seances/{seance}/labsessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourProf'])->name('console.prof'); });