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'],
]);