Spécification Technique : Sessions pour Apprenants (besoins d'un produit de formation)
Statut : Proposé (rédigé avant implémentation, conformément à la démarche déjà suivie pour les specs 08 à 13 et 18) Principe cadre : Conforme à
00-architecture-principles-substitutability.md. Complète18-reutilisation-images-par-source.md, dont elle reprend deux questions ouvertes (egress de build, clone de l'espace de travail). Date : 2026-10-06 Auteur : Équipe Atelier
1. Constat, vérifié empiriquement
Atelier a été essayé comme plan d'exécution d'un autre produit, Mentor, une plateforme de formation : chaque apprenant·e y reçoit un environnement (un dossier devcontainer) dans lequel un serveur lance des commandes et vérifie le résultat (code de sortie, sortie standard, fichiers). Deux passages ont eu lieu le 2026-10-06 sur kind-atelier-dev (controller et api-server lancés depuis les sources, commit 9eece24, noyau invité 5.10.223). Un troisième passage, le 2026-10-09, a vérifié dans l'invité le dimensionnement du disque (§3.1, tâche 14.1) et mis au jour le constat 16. Le détail, les commandes et les journaux sont dans le dépôt de Mentor : doc/atelier-lab-validation.md.
Ce qui marche. Des commandes lancées par exec_in_workshop tournent sous l'uid 1000 dans une microVM, sans capacité, sans sudo, sans accès aux autres Workshops, au nœud ni aux services du cluster. Au second passage, cinq environnements de Mentor ont démarré sans modification et 27 labos sur 34 y ont été rejoués, étape par étape (vérifications en échec, solution, vérifications réussies) :
| Environnement (invité) | Atteint Running |
Labos réussis | Étapes réussies |
|---|---|---|---|
git-basics (1 CPU, 512 Mio) |
oui | 7 sur 7 | 31 sur 31 |
python (1 CPU, 512 Mio) |
oui | 6 sur 6 | 31 sur 31 |
aws-cloud-practitioner (1 CPU, 512 Mio, émulateur AWS local) |
oui | 12 sur 12 | 59 sur 59 |
docker-hello (2 CPU, 2 Gio, Docker dans l'invité) |
oui | 1 sur 4 | 8 sur 19 |
docker-advanced (2 CPU, 2 Gio, Docker dans l'invité) |
oui | 1 sur 5 | 10 sur 26 |
Ce qui a dû être contourné ou reste bloquant. Chaque ligne est un constat, avec sa source : observé (une commande l'a montré) ou lu (dans le code, sans exécution).
| # | Constat | Source |
|---|---|---|
| 1 | Le disque d'une session vaut « image + 512 Mio » : 276 à 369 Mio libres selon l'image. resources.disk existe dans la CRD (crates/common/src/crd.rs) mais le controller ne le lit pas. Les deux cours Docker échouent surtout là-dessus (une image postgres ne se dépaquette pas). |
observé (dd s'arrête à 352 Mio) ; lu pour resources.disk |
| 2 | Trois ports sont pris dans l'invité par les services injectés : 8080 (code-server), 7681 (ttyd), 2222 (sshd), codés dans crates/image-builder/src/main.rs. Un labo qui publie un service sur 8080 échoue (failed to bind host port 0.0.0.0:8080). |
observé |
| 3 | Une image qui contient systemd sans /sbin/init (cas de toute image Debian où openssh-server est installé sans systemd-sysv) ne démarre jamais : le Workshop reste en Provisioning, sans erreur nulle part. |
observé (plus de 10 minutes) |
| 4 | Sur une image sans systemd, ttyd et code-server tournent en root ; avec systemd, sous l'uid 1000. |
observé (ps dans l'invité) |
| 5 | Les scripts de démarrage injectés ont besoin de curl et de bash dans l'image ; rien ne le vérifie au build. |
lu (inject_sshd, inject_terminal_and_ide) |
| 6 | L'exec est taillé pour un agent : compte fixe vscode (crates/api-server/src/exec.rs), dossier de départ fixe, pas d'environnement, entrée standard jamais fermée (cat reste bloqué), plafond de durée global (20 minutes) et non par appel, sortie non bornée, lecture du résultat par sondage toutes les 300 ms. |
observé, et lu pour les constantes |
| 7 | Résultat d'exec : la sortie d'erreur est perdue quand elle arrive dans le même intervalle de sondage que la sortie standard ; une sortie contenant un octet nul revient vide ; une commande tuée par un signal revient sans code de sortie, statut Completed ; un processus d'arrière-plan qui garde la sortie ouverte tient l'exec ouvert au-delà du plafond (602 s constatées). |
observé |
| 8 | Le dépôt source entier est cloné dans l'image (ensure_workspace_clone), sous /workspaces/<dépôt>, lisible par la personne dans la session, et c'est là que s'ouvrent l'exec et VS Code. Pour un produit de formation, le dépôt contient les solutions. |
observé |
| 9 | Une seule liste d'egress sert au build et à l'exécution. Mettre spec.egressAllowlist à [] sur un Workshop en marche n'a aucun effet ; après une suspension puis une reprise (environ 60 s), tout est bien coupé. |
observé |
| 10 | Les champs remoteUser, workspaceFolder, containerEnv, postStartCommand et hostRequirements du devcontainer.json sont ignorés. Le dossier de travail déclaré par l'image appartient à root. |
observé |
| 11 | Le noyau invité n'a ni CONFIG_NF_TABLES ni CONFIG_IP_NF_RAW : Docker dans l'invité ne démarre qu'avec les iptables historiques et DOCKER_INSECURE_NO_IPTABLES_RAW=1. Manquent aussi CONFIG_NETFILTER_XT_MARK, CONFIG_NETFILTER_XT_MATCH_COMMENT, CONFIG_IP_SET, CONFIG_IP_VS, CONFIG_VXLAN (non essayés). |
observé pour les deux premiers |
| 12 | La console de l'invité n'est journalisée qu'au niveau debug et rien ne permet de la demander pour un Workshop : les échecs de Docker n'ont pu être diagnostiqués qu'avec un patch local. | observé |
| 13 | Le cloisonnement s'arrête à l'API : tout est dans l'espace de noms default, sans NetworkPolicy, et GET /v1/workshops rend les Workshops de tous les groupes, spec comprise, à qui porte le rôle admin. Le port de redirection d'un Workshop a répondu à une requête HTTP venue d'un pod sans rapport ; l'ouverture d'un tunnel par ce chemin n'a pas été essayée. |
observé |
| 14 | L'api-server écoute sur 0.0.0.0:8080, codé en dur (crates/api-server/src/main.rs) : il ne démarre pas sur une machine où ce port est pris. |
observé |
| 15 | Après une reprise, l'horloge de l'invité retarde d'environ 46 s (déjà relevé par la spec 18, §5). | observé |
| 16 | Une mise en veille perd tout ce qui a été écrit sur le disque racine. À la reprise, la mémoire vient de l'instantané mais le disque est recopié depuis le cache d'images (restore_persisted, crates/firecracker/src/vm.rs). Un fichier de 200 Mio écrit et synchronisé avant la suspension se relit correctement tant qu'il est dans le cache de pages de l'invité, puis comme des zéros en lecture directe ; une fois le cache évincé, ls rend Bad message et le noyau journalise EXT4-fs error … Directory block failed checksum. Le système de fichiers reste monté en écriture. Indépendant de la taille du disque. |
observé (troisième passage, 2026-10-09) |
Mesures utiles : image prête → Running et reprise, 15,5 à 16,6 s (12 mesures) ; un aller-retour d'exec, 0,34 s en médiane sur 25, dont 0,03 s pour l'appel et le reste pour le sondage ; dix exec de sleep 1 en parallèle, 1,3 s ; mémoire du superviseur et de la VM, 268 à 379 Mo pour un invité de 768 Mio, 969 Mo pour 2 Gio avec Docker.
2. Objectifs / non-objectifs
Dans le périmètre : ce qui sépare aujourd'hui « un bac à sable pour un agent » d'« une session pour une personne dont un serveur vérifie le travail ».
- Une session a la taille de disque qu'elle demande.
- Une image qui ne peut pas démarrer échoue au build, avec un message.
- Les services injectés ne tournent jamais en root et ne confisquent pas de port courant.
- Un appel « exécuter une commande » rend un résultat fidèle et borné, sans que l'appelant ait à envelopper chaque commande.
- Le contenu du dépôt source n'entre dans l'invité que si on le demande.
- Le réseau du build et celui de l'exécution se règlent séparément.
Hors périmètre : la réutilisation des images (spec 18) ; le démarrage depuis un snapshot modèle (spec 18, §5) ; le découpage d'un socle commun à plusieurs produits, que cette spec prépare sans le décider.
3. Décisions proposées
3.1. Disque de session dimensionné
Le controller lit resources.disk et le superviseur donne à l'invité un système de fichiers inscriptible de cette taille, au lieu de « image + 512 Mio ». La valeur est plafonnée par une limite de cluster. Si la couche inscriptible par VM de la spec 18 (§5) est retenue, sa taille est ce quota ; sinon, l'image ext4 est agrandie à la création du Workshop.
3.2. Un invité qui démarre, ou un build qui échoue
À la fin du build, image-builder vérifie ce dont le démarrage dépend et échoue avec un message qui nomme le manque :
- systemd présent et
/sbin/initabsent : erreur (aujourd'hui, non-démarrage silencieux) ; bashoucurlabsents : erreur, ou injection d'un binaire statique.
Le refus est remonté (tâche 14.12) : image-builder sort avec le code 65 (IMAGE_BUILD_REFUSED_EXIT_CODE) et écrit le message dans /dev/termination-log ; la podFailurePolicy du Job de build arrête les nouvelles tentatives pour ce code ; le controller lit le message du conteneur et l'inscrit dans status.conditions["ImageBuildFailed"]. Les autres échecs de build gardent leurs deux nouvelles tentatives, et leur message (fin du journal, FallbackToLogsOnError) est remonté de la même façon. Constaté : une image alpine:3 est refusée en environ 35 s, avec bash et curl nommés, au lieu de trois builds en 3 min 17 s.
L'init propre à Atelier (images sans systemd) lance ttyd et code-server sous le compte de la session, jamais en root.
3.3. Ports et services injectés
- Les ports de
sshd,ttydetcode-serverquittent les valeurs courantes (8080 en premier lieu) pour une plage réservée et documentée. - L'injection du terminal web et de l'IDE devient optionnelle par Workshop : un appelant qui n'utilise que l'exec n'a pas à les porter.
Réalisé par la tâche 14.4 :
- Plage réservée 61000 à 61099, au-dessus des ports éphémères de Linux :
sshdsur 61022,code-serversur 61080,ttydsur 61081. Le reste des ports appartient à l'environnement. spec.guestServices {terminal, ide}, vrais par défaut ;sshdest toujours installé. Le choix est figé dans l'image au build. L'API REST l'accepte à la création (guestServices), l'outil MCP aussi (install_terminal,install_ide), et le formulaire de création du tableau de bord par deux cases à cocher.status.guestPorts, inscrit parimage-builderdans le même patch queimageDigest: la sonde du controller et les ponts de l'api-server y lisent les ports. Un statut qui n'en dit rien désigne une image d'avant cette tâche, sur 2222, 7681 et 8080 : les Workshops existants ne changent pas. Un pont vers un service non installé répond 404.
Vérifié dans l'invité le 2026-10-09 (septième et huitième passages) : 8080, 7681 et 2222 fermés, un serveur de l'invité répond sur 8080 ; les deux cours Docker de Mentor passent en entier (19 étapes sur 19 et 27 sur 27, contre 12 et 23) ; sans terminal ni IDE, l'image pèse 430 Mio de moins, l'invité utilise 41 Mo au repos contre 83, et docker-hello y passe aussi 19 sur 19 ; suspension et reprise conservent guestPorts. Un défaut trouvé au premier de ces passages est corrigé : le compte de session n'était préparé que par l'installation du terminal, et un Workshop sans terminal ni IDE refusait chaque exec.
Restent ouverts : la sonde de readiness ne vérifie que la bannière SSH, pas qu'une authentification aboutit ; le repli sur les ports historiques n'a été vérifié que par tests unitaires, faute d'un Workshop construit avec l'ancien image-builder ; le ssh.service d'une image qui installe openssh-server écoute toujours sur 22, ce qui ne gêne pas mais n'est pas le canal d'Atelier.
3.4. Un appel « exécuter une commande »
Un appel de l'API (REST, à côté de l'outil MCP) dont les paramètres sont : la commande, le compte, le dossier de travail, l'environnement, une durée maximale par appel, une taille maximale de sortie. Il ferme l'entrée standard, attend la fin de la commande et rend en une réponse : le code de sortie ou le signal, la sortie standard et la sortie d'erreur séparées et intactes (octets nuls compris), et un indicateur de troncature. Il ne reste pas ouvert quand un processus d'arrière-plan garde un descripteur hérité.
Les défauts du constat 7 sont à corriger aussi pour l'outil MCP existant, indépendamment de ce nouvel appel.
Réalisé par la tâche 14.6 : POST /v1/workshops/{name}/exec (crates/api-server/src/run.rs).
| Champ de la requête | Rôle | Défaut |
|---|---|---|
command |
commande, interprétée par sh -c |
requis |
workingDir |
dossier de travail ; s'il est donné et absent, la commande n'est pas lancée (code 126) | le workspace du Workshop |
env |
variables d'environnement ajoutées à celles de la session | aucune |
timeoutSeconds |
délai de la commande, plafonné par ATELIER_EXEC_CEILING_SECS |
60 |
maxOutputBytes |
octets gardés par flux de sortie, plafonnés à 8 Mio | 1 Mio |
La réponse porte exitCode ou signal, timedOut, stdoutBase64 et stderrBase64 (les octets tels quels, nuls compris), stdoutTruncated, stderrTruncated et durationMs. Une commande qui échoue, dépasse son délai ou meurt d'un signal est une réponse 200 : c'est un résultat. Seul un échec du canal (invité injoignable, clé refusée) est une erreur de l'API.
Vérifié d'abord contre un vrai sshd OpenSSH lancé sans privilège sur la machine de test, avec un vrai shell (crates/api-server/tests/run.rs), puis dans l'invité le 2026-10-10 (neuvième et dixième passages), à travers l'API, le tunnel de net-proxy et le sshd injecté, sur trois images dont une sans systemd :
- flux séparés, octets nuls, signaux
TERM,KILLetSEGV, délai (y compris une commande qui ignoreTERM, et le processus est bien tué dans l'invité), entrée standard fermée, dossier et variables non interprétés, troncature ; sleep 15 & echo lancerevient en 0,34 s : un processus d'arrière-plan ne retient pas la réponse ;- 0,159 s par appel en médiane, contre 0,344 s par l'outil MCP ;
- le cours
git-basicsde Mentor rejoué par cet appel seul, avec les commandes que le serveur Mentor envoie : 31 étapes sur 31 (27 sur 27 et 19 sur 19 pour deux autres cours) ; - jusqu'à 100 appels simultanés sur un même Workshop répondent tous. Au-delà, l'invité ne peut plus créer de processus : les appels en trop reçoivent un
502en 10 s, quelques-uns un200dont la commande a échoué surfork, et aucun ne reste sans réponse.
Ce dernier point a demandé deux corrections, trouvées par ces passages : l'ouverture de la session SSH n'avait pas de délai, et le sshd injecté gardait le MaxStartups par défaut d'OpenSSH, qui écarte des connexions dès qu'il y en a dix en attente. À travers le tunnel, ce rejet ne fermait pas la connexion vue par l'api-server : l'appel attendait sans fin, sans une ligne de journal.
Tâche 14.5, outil MCP exec_in_workshop (crates/api-server/src/exec.rs) : le flux SSE n'avance que du flux émis (la sortie d'erreur n'est plus perdue quand elle arrive avec la sortie standard) ; les tampons exec_commands sont en BYTEA (PostgreSQL refusait l'octet nul dans un TEXT et la sortie revenait vide) ; un signal est enregistré (colonne signal, statut Killed, champ signal de l'événement status) et un dépassement du plafond donne le statut Timeout ; l'entrée standard est fermée ; l'appel rend la main 300 ms après le statut de sortie même si un processus d'arrière-plan garde la sortie ouverte. Les événements SSE restent du texte : un octet invalide en UTF-8 y devient U+FFFD, seul le tampon est fidèle.
Restent ouverts :
- le compte ne se choisit pas par appel : la commande tourne sous le compte de session du
sshdinjecté (tâche 14.7, qui appliqueremoteUser). Conséquence observée :HOMEvaut/home/vscodeetUSERvautvscodealors queidrend le compte de l'image (même uid) ; l'appelant qui en dépend doit passerHOMEdansenv; - sur une image où le compte
vscodea un autre dossier personnel (docker-hello), les variables de/etc/environmentn'arrivent pas dans l'environnement de la commande. Cause non établie ; piste :~/.ssh/environmentécrit sous/home/vscode; - un invité saturé rend des
200dont la commande a échoué surfork(codes 125 ou 2) : rien ne les distingue d'un échec ordinaire de la commande ; - un champ inconnu dans la requête est refusé en
422, pas en400; - chaque appel ouvre sa connexion SSH ; une session réutilisée par Workshop réduirait la latence et la charge de l'invité ;
- l'appel n'est pas inscrit dans l'historique
exec_commands, seulement journalisé ; les défauts de l'outil MCP du constat 7 sont corrigés par la tâche 14.5 (voir plus bas) ; - non essayé : une image dont
timeoutvient de busybox.
3.5. Le dépôt source dans l'invité
ensure_workspace_clone devient optionnel (spec.source.cloneWorkspace, vrai par défaut pour ne rien changer aux usages actuels). À faux, rien du dépôt n'entre dans l'image hors ce que le build y a copié, et l'exec comme VS Code s'ouvrent sur le dossier de travail déclaré.
Réalisé par la tâche 14.7 (première moitié) : le champ est spec.devcontainer.cloneWorkspace (et non spec.source, qui n'existe pas). À faux, image-builder ne clone pas et n'installe pas le service de rafraîchissement ; il crée /workspaces/<dépôt> vide, au compte de la session.
Tâche 14.7, seconde moitié partielle : status.session (remoteUser, workspaceFolder, containerEnv) est écrit par image-builder avec le digest et appliqué à chaque exec. Un chemin avec variable (${...}) ou un nom de compte douteux est ignoré. postStartCommand et hostRequirements ne sont pas appliqués.
Les champs du devcontainer.json qui décrivent la session sont appliqués : remoteUser (au lieu du compte fixe vscode), workspaceFolder (créé et donné à ce compte), containerEnv, postStartCommand, et hostRequirements comme valeurs par défaut de resources.
3.6. Egress de build et egress d'exécution
Réalisé par la tâche 14.8 : spec.buildEgressAllowlist pour le build, spec.egressAllowlist pour l'exécution, relue à chaud par net-proxy depuis une ConfigMap (60 à 70 s mesurées, par kubelet). Conception d'origine :
Deux listes : celle du build, portée par l'image (elle rejoint la question ouverte de la spec 18, §4), et celle de l'exécution, portée par le Workshop. Une modification de la seconde s'applique à un Workshop en marche sans suspension. Constaté au passage : une liste étroite suffit au build (hôtes de Docker Hub, deb.debian.org, download.docker.com, PyPI), * n'est pas nécessaire.
3.7. Noyau invité, console, écoute de l'API
- Reconstruire le noyau invité avec
CONFIG_NF_TABLESetCONFIG_IP_NF_RAW(et évaluer les autres options du constat 11). - Exposer la console de l'invité par Workshop, sur demande.
- Rendre l'adresse d'écoute de l'api-server configurable.
Réalisé en partie par la tâche 14.9 : deploy/dev/firecracker/kernel/build.sh construit le noyau 5.10.223 dans Docker à partir de la configuration de référence de Firecracker et de atelier.config ; ATELIER_API_LISTEN_ADDR règle l'écoute de l'api-server (défaut 0.0.0.0:8080) ; l'annotation atelier.dev/console-log: "true", lue à la création du pod parent, journalise la console de l'invité au niveau info. Le critère « Docker démarre sans réglage particulier » reste à prouver avec une image qui ne pose pas DOCKER_INSECURE_NO_IPTABLES_RAW.
3.8. Le disque survit à la mise en veille
Un instantané ne vaut que avec le disque qu'il décrit (constat 16). snapshot_and_publish (crates/vm-supervisor/src/main.rs) enregistre le disque racine de la microVM avec snapshot.state et snapshot.mem, et restore_persisted repart de cette copie, jamais du cache d'images. Sans cela, toute reprise d'un Workshop qui a écrit sur son disque le corrompt en silence : c'était le défaut le plus grave relevé par ces essais, et il rendait aussi dangereux le contournement du constat 9 (suspendre et reprendre pour appliquer une liste d'egress).
Réalisé par la tâche 14.11, décrit dans architecture/snapshot-restore.md :
- le disque est copié vers
snapshot.rootfspendant que la microVM est figée, en conservant les trous du fichier ; - la reprise remet ce disque dans le jail sans le vérifier ni l'agrandir ;
- un instantané sans son disque (pris avant cette tâche, ou dont la publication a été interrompue) n'est plus repris : le Workshop redémarre à froid, ce que le superviseur journalise.
Vérifié dans l'invité le 2026-10-09 (quatrième passage), sur un Workshop de 4 Gio et sur un Workshop Docker de 6 Gio, deux suspensions de suite : fichiers de 200 Mio relus à l'identique en lecture directe une fois le cache de pages évincé, répertoires et petits fichiers intacts, même boot_id et processus d'arrière-plan toujours vivants, conteneurs Docker toujours en marche, aucune erreur ext4. snapshot.rootfs occupe ce que l'invité a écrit (1,2 Gio pour 4 Gio apparents, 2,1 Gio pour 6). Les fichiers sont dans le cache 6 à 21 s après la demande ; la reprise prend 16 à 18 s.
Délai de la suspension (tâche 14.13). snapshot_and_publish ne répond au controller qu'après le téléversement vers S3, soit 25 à 52 s après la demande pour ces Workshops, alors que request_snapshot n'attendait que 30 s : le controller journalisait « suspension sans snapshot », status.snapshotDigest restait vide et le pod pouvait être supprimé en plein téléversement. Le défaut existait déjà pour une mémoire de plusieurs Gio, le disque l'a rendu systématique. Le délai est désormais de 300 s, réglable (ATELIER_SNAPSHOT_TIMEOUT_SECS, valeur workshops.snapshotTimeoutSeconds du chart), et le controller ne redemande plus d'instantané à un pod en cours d'arrêt. L'ordre de publication, local comme S3, garantit qu'une interruption laisse un instantané incomplet et ignoré, jamais un mélange. Vérifié le 2026-10-09 (cinquième passage) : aucun avertissement sur trois suspensions avec S3, snapshotDigest renseigné, reprise depuis S3 seul avec des données justes sur deux générations ; avec un délai forcé à 5 s, le cache local reste complet et S3 incomplet, et une reprise sans cache local redémarre à froid proprement.
Relevé pendant ces passages, et laissé à des tâches à part :
Runningannoncé avant la fin de la reprise (tâche 14.14, corrigé). Deux fois sur trois lors d'une reprise depuis S3, le Workshop passaitRunning4 à 25 s avant que la microVM n'existe, et le premier exec échouait (connexion SSH echouee: Disconnected) : la sonde du controller tenait le silence denet-proxypour un port ouvert, or personne ne refuse une connexion vers une microVM qui n'existe pas encore.guest_port_answers(crates/controller/src/guest_probe.rs) exige désormais des octets reçus de l'invité : la bannière SSH sur le port d'exec, une réponse HTTP sur celui du terminal. Vérifié le 2026-10-09 (sixième passage) : premier exec réussi 13 fois sur 13 dès le passage àRunning(démarrage à froid, reprise depuis le cache, reprise depuis S3),Runningjamais avant lemicroVM runningdu superviseur, image sans systemd comprise ; 15 s au démarrage à froid, comme avant. Une microVM pas encore prête au passage de la sonde attend la réconciliation suivante, jusqu'à 15 s plus tard.- Stockage S3 des instantanés (tâche 14.15, corrigé : zstd sous
<nom>.zst, objets effacés avec le Workshop,snapshotDigestvidé sans instantané). Constat d'origine : Le disque y est envoyé à sa taille apparente (6,2 Gio pour 2,1 Gio écrits), ce qui fait l'essentiel de la durée d'une suspension ; les objets d'un Workshop supprimé ne sont pas effacés (33 Go laissés par ces essais) ; après un dépassement de délai,status.snapshotDigestgarde l'empreinte de la suspension précédente ; trois suspensions simultanées ont pris 139 à 199 s chacune, ce qui entame le délai de 300 s.
4. Risques et questions ouvertes
- Cloisonnement entre organisations (constat 13). Un espace de noms et une
NetworkPolicypar groupe propriétaire, et une liste filtrée par groupe y compris pour le rôleadmin, changent le modèle de déploiement : à instruire à part, avec le découpage d'un socle. - Canal d'exec. L'exec passe par SSH avec une clé lue dans OpenBao, et les sessions MCP expirent en quelques minutes : un appelant qui vérifie des dizaines d'étapes doit se réauthentifier. Un canal vsock vers un agent de l'invité réglerait aussi l'entrée standard et les descripteurs hérités ; à comparer au coût de maintenir deux chemins.
- Compatibilité. Changer les ports injectés (§3.3) et le compte de session (§3.5) touche les Workshops existants et le tableau de bord : prévoir des valeurs par défaut inchangées et une bascule par Workshop.
- Durée de vie. Rien n'arrête un Workshop inactif. Un appelant peut piloter la suspension et la suppression ; l'horloge de l'invité après reprise (constat 15), elle, ne peut être corrigée que par Atelier.
4.1. Instruction des trois questions ouvertes (tâche 14.10)
Établie le 2026-10-26 par lecture du code et des essais de la tâche 14.5 à 14.8. Rien n'est implémenté ici : chaque point se termine par une recommandation et la tâche qui en découlerait (voir le plan, 14.16 à 14.18).
Cloisonnement entre organisations.
- Faits. Tout vit dans l'espace de noms
default(ATELIER_NAMESPACE). LesNetworkPolicyn'existent que pour les campagnes (tâche 12.1). L'autorisation par nom de Workshop suit le groupe (ensure_owner,crates/api-server/src/routes.rs), la liste suit autre chose :list_workshopsne rend que les Workshops dontowner_subjectest le sujet appelant, et tous ceux de l'instance,speccomprise, pour le rôleadmin. Un collègue du même groupe peut donc ouvrir un Workshop par son nom sans le voir dans la liste. Le locataire de l'historique d'exec est déjà le groupe (workshop_tenant). - Options. (a) Garder un seul espace de noms et resserrer : liste par groupe, y compris pour
admin(qui verrait tout seulement via une route d'administration explicite), plus uneNetworkPolicypar défaut qui ferme l'entrée des pods de Workshop sauf depuis le plan de contrôle. (b) Un espace de noms par groupe : le CRD est déjà namespacé, maisensure_image_cache_pvccrée un cache par espace de noms, ce qui détruit le partage d'images que vise la spec 18 et multiplie le stockage ; le controller devrait surveiller tous les espaces de noms et l'api-server choisir l'espace par groupe. (c) Une installation par organisation : isolation maximale, coût d'exploitation maximal. - Réalisé (tâche 14.16, option a). Liste par groupe pour tous, route
GET /v1/admin/workshopspour l'administration, NetworkPolicy du chart (workshops.networkPolicy.enabled). Mesuré dans le cluster : un pod étranger n'atteint plus nivm-supervisor(8081) ninet-proxy(3128) d'un pod de Workshop. - Recommandation. (a) maintenant, c'est petit et ferme le constat 13 sans toucher au modèle de déploiement. (b) seulement si une exigence contractuelle l'impose, et à décider avec la spec 18 §4 puisque les deux se contredisent sur le cache. Ne pas offrir (c) comme fonction du produit.
- À mesurer. Qu'un pod sans rapport ne joint plus le port de redirection d'un Workshop après la
NetworkPolicy, et qu'une requête de tunnel complète (non essayée jusqu'ici) est bien refusée.
Canal d'exec.
- Faits. L'appel REST de la tâche 14.6 règle déjà les deux griefs d'origine : il s'authentifie par le jeton OIDC ordinaire (renouvelable par le client) et non par une session MCP ; entrée standard fermée et processus d'arrière-plan sont réglés par SSH lui-même (
exec timeout,</dev/null, délai après le statut de sortie, tâches 14.5 et 14.6). Coût mesuré : 0,159 s par appel en médiane (REST) contre 0,344 s (MCP), 100 appels simultanés tous servis. La clé SSH est relue dans OpenBao à chaque appel. Un canal vsock existe déjà, mais dans l'autre sens : l'invité appelle l'hôte (mcp-gateway). Un canal de l'hôte vers l'invité demanderait un agent de plus dans l'invité, son protocole, et son installation dans toute image, y compris sans systemd et sans glibc (voir le piège sur musl). - Recommandation. Garder SSH. Un agent vsock ne retirerait plus de défaut observé et ajouterait un second chemin à maintenir. Rouvrir la question seulement si une mesure montre un besoin que SSH ne couvre pas : latence cumulée d'un cours de plusieurs dizaines d'étapes, ou charge de
sshdau-delà de 100 appels simultanés (où l'invité manque de processus avant que SSH ne soit en cause). - À mesurer. La durée d'un cours entier de bout en bout par l'appel REST (31 étapes sur 31 sont passées, la durée totale n'a pas été consignée), et le coût d'une session SSH réutilisée par Workshop contre une connexion par appel.
Durée de vie.
- Faits. Rien n'arrête un Workshop inactif : ni le controller, ni l'api-server n'ont de délai d'inactivité ni de durée maximale.
guest-initenvoie un battement toutes les 15 s, mais c'est un signe de vie de l'invité, pas une activité de la personne ; et seules les images sans systemd l'envoient. Après une reprise, l'horloge de l'invité retarde d'environ 46 s (constat 15) ; rien dans le code ne la remet à l'heure. Le battement part par POST sans lire la réponse. - Réalisé pour l'horloge (tâche 14.18).
GET /timesur le serveur de métadonnées denet-proxy, service systemdatelier-sync-clocket équivalent dansatelier-guest-init. Mesuré : 113 s de retard après une suspension d'une minute, corrigé en moins de 5 s. L'inactivité et la durée de vie restent à faire (tâche 14.17). - Options. Pour l'inactivité : l'api-server inscrit un horodatage d'activité (annotation
atelier.dev/last-activity, au plus une écriture par minute) à chaque exec, redirection de port et pont d'IDE ; le controller suspend un Workshop dont l'horodatage dépassespec.idleTimeoutSeconds, et supprime un Workshop suspendu depuis plus despec.maxSuspendedSeconds. Les deux valent « jamais » par défaut, pour ne rien changer aux usages actuels. Pour l'horloge : renvoyer l'heure de l'hôte dans la réponse au battement et laisserguest-init(root) la corriger au-delà de 2 s d'écart ; ou la corriger à la reprise parvm-supervisorsi un canal vers l'invité existe. - Recommandation. Les deux délais, valeur par défaut « jamais », dans cet ordre : d'abord l'horodatage d'activité (rien ne s'arrête sans lui), puis les délais. L'horloge à part, par la réponse au battement ; elle ne couvre pas les images avec systemd, qui n'envoient pas de battement, donc prévoir
systemd-timesyncdou équivalent pour elles et le vérifier. -
Risque. Suspendre un Workshop dont un processus d'arrière-plan travaille (un cours qui lance un build long) perd du travail si l'activité n'est mesurée que sur les appels d'API : décider si un processus actif de l'invité compte comme activité avant de livrer la suspension automatique.
-
Ce qui n'a pas été essayé : une session interactive réelle de terminal ou de VS Code, un grand nombre de Workshops simultanés, un dépôt privé, un build avec une liste d'egress réellement vide, une installation par le chart Helm.