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

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\LabSession;
use App\Models\Seance;
use App\Services\Labs\StockChaud;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
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
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
$donnees = $request->validate([
'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([
'lab_id' => $donnees['lab_id'],
'statut' => Seance::SALLE_ATTENTE,
@ -30,12 +54,23 @@ class SeanceController extends Controller
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
{
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
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
* en tâche de fond (étape 10), le terminal apparaît une fois prête.
* Admettre un élève, c'est lancer son lab. Chemin rapide : une stack
* 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($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([
'etat' => LabSession::ADMIS,
'expire_a' => now()->addMinutes($dureeMinutes),
]);
if ($pris === 0) {
return redirect()->route('seances.attente', $seance);
}
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);
}
@ -89,12 +141,7 @@ class SeanceController extends Controller
public function attenteEleve(Seance $seance): View|RedirectResponse
{
$eleve = Auth::guard('eleve')->user();
abort_if($eleve->classe_id !== $seance->classe_id, 403);
$labSession = LabSession::where('seance_id', $seance->id)
->where('eleve_id', $eleve->id)
->first();
$labSession = $this->sessionDeLEleve($seance);
if ($labSession === null) {
return redirect()->route('eleve');
@ -102,7 +149,27 @@ class SeanceController extends Controller
return view('seances.attente-eleve', [
'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\Services\Labs\DefinitionLab;
use App\Services\OpenStack\GenerateurTemplateHeat;
use App\Services\OpenStack\FabriqueStack;
use App\Services\OpenStack\HeatClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -12,70 +12,86 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
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
* SeanceController::admettre — « admettre » un élève, c'est lancer son lab.
* Construit la stack d'un élève admis quand le stock chaud du lab est vide
* (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
{
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);
$nomStack = "lab-session-{$this->labSession->id}";
$this->onQueue('sessions');
}
['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([
'heat_stack_id' => $stack['id'],
'heat_stack_name' => $nomStack,
])->save();
$base = "lab-session-{$session->id}";
$fabrique->nettoyerEssais($base);
$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') {
Log::error("CreerStackLab : la stack {$nomStack} n'a pas abouti", $etat);
if ($session->fresh()->etat !== LabSession::ADMIS) {
$heat->supprimerStack($stack['nom'], $stack['id']);
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();
$session->attribuerStack($stack);
}
/**
* 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
public function failed(?Throwable $exception): void
{
for ($tentative = 0; $tentative < 60; $tentative++) {
$etat = $heat->etatStack($nom, $id);
$session = $this->labSession->fresh();
if (! str_ends_with($etat['statut'], '_IN_PROGRESS')) {
return $etat;
}
sleep(2);
if ($session === null) {
return;
}
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 function __construct(private readonly LabSession $labSession) {}
public function __construct(private readonly LabSession $labSession)
{
$this->onQueue('sessions');
}
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\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\DB;
class LabSession extends Model
{
@ -23,7 +25,11 @@ class LabSession extends Model
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 = [
'expire_a' => 'datetime',
@ -48,4 +54,38 @@ class LabSession extends Model
{
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';
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 = [
'prevue_a' => 'datetime',
'demarree_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
* format), pour que GenerateurTemplateHeat puisse en faire une stack.
* format) : topologie pour GenerateurTemplateHeat, vérifications pour la
* correction.
*/
class DefinitionLab
{
public readonly string $identifiant;
public readonly string $titre;
public readonly ?string $niveau;
public readonly ?int $dureeMin;
/** @var array<int, array{nom: string, cidr: string}> */
public readonly array $reseaux;
/** @var array<int, array{nom: string, image: string, flavor: string, reseaux: array<int, string>, preparation: ?string}> */
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
{
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";
if (! File::exists($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)
{
$this->identifiant = $donnees['id'];
$this->titre = $donnees['titre'];
$this->niveau = $donnees['niveau'] ?? null;
$this->dureeMin = $donnees['duree_min'] ?? null;
$this->reseaux = $donnees['reseaux'];
$this->machines = array_map(
fn (array $m) => [
@ -47,5 +79,18 @@ class DefinitionLab
],
$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
{
$reponse = $this->requete()->delete($this->url("/stacks/{$nom}/{$id}"));

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@
"php": "^8.3",
"bacon/bacon-qr-code": "^3.1",
"laravel/framework": "^13.17",
"laravel/horizon": "^5.50",
"laravel/tinker": "^3.0",
"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",
"This file is @generated automatically"
],
"content-hash": "4bbbdac2ebdd2568b9a8375272d3a7be",
"content-hash": "69a4c7e65a216e8a691794b9b63cfe39",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -1394,6 +1394,86 @@
},
"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",
"version": "v0.3.25",
@ -1452,6 +1532,62 @@
},
"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",
"version": "v2.1.0",

View file

@ -67,6 +67,10 @@ return [
'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

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 [
// En local (dev, tests Pest) : labs/ est un dossier voisin de portail/
// 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'),
// 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);
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) {
if (evenement.data instanceof ArrayBuffer) {
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>
</div>
</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">
<section class="lg:col-span-7" aria-labelledby="t-afaire">
<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">
@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>
<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())
<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
<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>

View file

@ -97,8 +97,11 @@
</section>
<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>
<p class="mt-2 font-semibold">Les élèves qui rejoignent patientent dans la salle d'attente jusqu'à ce que tu les admettes.</p>
<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. 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">
@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">
@ -106,8 +109,28 @@
<option value="{{ $lab->id }}">{{ $lab->titre }}</option>
@endforeach
</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>
@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>
</div>
@endsection

View file

@ -2,24 +2,27 @@
@section('titre', "Salle d'attente")
@php($etats = \App\Models\LabSession::class)
@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">
<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">
<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>
@elseif ($labSession->etat === \App\Models\LabSession::ADMIS)
<meta http-equiv="refresh" content="4">
@elseif ($labSession->etat === $etats::ADMIS)
<div class="py-20 text-center">
<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>
</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>
<div class="mt-4 flex gap-2" role="tablist">
@foreach ($labSession->machines as $i => $machine)
@ -31,12 +34,43 @@
<div id="terminal-{{ $machine->id }}" class="h-full"></div>
</div>
@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">
<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="{{ asset('js/console-reelle.js') }}"></script>
<script>
var monte = {};
document.querySelectorAll('.onglet-machine').forEach(function (bouton) {
bouton.addEventListener('click', function () {
var id = bouton.dataset.onglet;
@ -44,20 +78,39 @@
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)
fetch('{{ route('console.eleve', [$labSession, $machine]) }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
}).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
</script>
@else
<div class="py-20 text-center">
<h1 class="text-4xl font-extrabold tracking-tight">La préparation a échoué</h1>
<p class="mt-4 font-semibold">Préviens ton prof.</p>
<h1 class="text-4xl font-extrabold tracking-tight">Cette séance est terminée pour toi</h1>
<p class="mt-4 font-semibold">Tes machines ont été supprimées.</p>
</div>
@endif
</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

View file

@ -2,6 +2,8 @@
@section('titre', "Salle d'attente")
@php($etats = \App\Models\LabSession::class)
@section('contenu')
<meta http-equiv="refresh" content="10">
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
@ -14,20 +16,41 @@
@else
<ul class="mt-8 grid gap-3">
@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>
@if ($labSession->etat === \App\Models\LabSession::EN_ATTENTE)
<form action="{{ route('seances.admettre', [$seance, $labSession]) }}" method="post">
@csrf
<button type="submit" class="inline-flex h-10 items-center rounded-full bg-[#0b57d0] px-4 font-bold text-white">Admettre</button>
</form>
@else
<span class="flex items-center gap-3">
<span class="rounded-lg border border-[#dadce0] bg-[#e6f4ea] px-3 py-1 font-mono text-sm font-bold">Admis</span>
@if ($labSession->machines->isNotEmpty())
<span class="flex flex-wrap items-center gap-3">
@switch($labSession->etat)
@case($etats::EN_ATTENTE)
<form action="{{ route('seances.admettre', [$seance, $labSession]) }}" method="post">
@csrf
<button type="submit" class="inline-flex h-10 items-center rounded-full bg-[#0b57d0] px-4 font-bold text-white">Admettre</button>
</form>
@break
@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>
@endif
</span>
@break
@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
</li>
@endforeach

View file

@ -10,3 +10,9 @@ Artisan::command('inspire', function () {
// Étape 10 : ferme les sessions de lab dont l'expiration est passée.
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\ClasseController;
use App\Http\Controllers\ConsoleController;
use App\Http\Controllers\CorrectionController;
use App\Http\Controllers\EleveTableauDeBordController;
use App\Http\Controllers\InscriptionController;
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::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}/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');
});
@ -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}/seances', [SeanceController::class, 'store'])->name('mes-classes.seances.store');
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::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');

View file

@ -3,7 +3,7 @@
use App\Models\Eleve;
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([
'prenom' => 'Léa',
'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";