Archive des recits de session (jusqu'a fin aout 2026)
Historique detaille des sessions de developpement, sorti de
../PROGRESS.mdpour que celui-ci reste un point de situation court et lisible.Ce document n'est plus mis a jour. Il est conserve parce que ces recits expliquent pourquoi certaines decisions ont ete prises et comment des bugs difficiles ont ete diagnostiques — ce que ni le code ni
git logne racontent. Les enseignements qui restent valables en ont ete extraits vers../architecture/pieges.md: c'est ce fichier-la qu'il faut lire avant de coder, pas celui-ci.A consulter uniquement pour retrouver le contexte complet d'un diagnostic passe.
Ce qui a ete construit cette session (resume chronologique)
- Scaffolding : workspace Rust (control plane) + dashboard Next.js,
CRD
Workshopcomme source de verite declarative. - Devcontainer comme contrat : decision que l'environnement livre a
l'agent est defini par un
.devcontainer/devcontainer.jsonstandard, construit viaenvbuilder. - Convention OpenTelemetry imposee des le depart sur tous les binaires, Grafana explicitement mis en backlog.
- Firecracker/jailer : premiere version hand-rolled en HTTP-sur-socket
Unix, rejetee par l'utilisateur en faveur d'un SDK officiel —
migration vers
fctools. Bascule desudovers des capabilities Linux (setcap) sur le jailer apres avoir constate que leSudoProcessSpawnerdefctools(qui appellesudo -S -s) est incompatible avec une regle sudoers scopee finement. - Identite/secrets : Kanidm pour l'identite (federable), OpenBao pour
les secrets d'environnement, avec le principe explicite que seul
identity-proxymanipule jamais un secret de sortie — jamais l'agent. Pont OpenBao choisi comme auth Kubernetes (ServiceAccount du pod parent), pas federation JWT Kanidm → OpenBao (plus simple, deja standard). - Finalizer de nettoyage Kanidm/OpenBao a la suppression d'un Workshop ; suspend/resume explicitement exempte de ce nettoyage.
- Pipeline image-builder : devcontainer →
envbuilder→ image OCI poussee au registre →crane export→ tarball →mke2fs→rootfs.ext4→ PVC de cache content-addressed (cle = digest de l'image). Pivot majeur decouvert en testant reellement :envbuilderne produit pas de dossier d'export, il ecrase le filesystem de son propre conteneur — d'ou le passage par un registre OCI intermediaire plutot qu'un simple montage de volume. vm-supervisorreel dans le pod parent : conteneur privilegie, montagehostPathde/dev/kvm, boot Firecracker jaile depuis le rootfs du cache. Le blocage constate ("Operation not permitted" malgre permissions et capabilities correctes) venait du device cgroup controller de Kubernetes/containerd, pas d'un probleme de capabilities — resolu parprivileged: trueen attendant un device plugin KVM dedie.- Depot GitHub prive publie.
api-server: validation JWT reelle (RS256, JWKS charge au demarrage depuisATELIER_JWT_JWKS_URL, algorithme derive du JWK quandalgest absent) et endpoints CRUD + suspend/resume surWorkshopviakube::Api, avecowner_subjecttoujours derive du sujet JWT authentifie (jamais du corps de la requete, pour eviter qu'un client usurpe un proprietaire) et un404(pas403) pour un Workshop appartenant a quelqu'un d'autre (evite de confirmer son existence). Teste reellement (crates/api-server/tests/routes.rs) : vraie crypto (cle RSAopenssl, JWKS derive de cette cle), vraikube::Clientcontre kind, vrai routeur axum (appele viatower::ServiceExt::oneshot, pas de mock) — create/get/list/suspend/resume/delete et isolation entre deux sujets JWT distincts, tous verifies contre le cluster reel.api-servercontre un vrai flux OAuth2 Kanidm, puis coordinateur de port-forward : un Resource Server OAuth2 public a ete cree dans le Kanidm de dev (deploy/dev/kanidm/README.md, section dediee) et un script reutilisable (deploy/dev/kanidm/get-oauth2-token.sh) automatise le flux complet (PKCE S256,/oauth2/authorise+/permit+/oauth2/token) pour obtenir un vraiaccess_token. Faire tournerapi-servercontre ce flux reel (au lieu des JWT synthetiques des tests) a revele deux bugs invisibles jusque-la :Validation::new()dejsonwebtokenactivevalidate_audpar defaut, et aucune audience n'etait configuree — tous les vrais tokens etaient donc rejetes (InvalidAudience) alors que les JWT synthetiques (sansaud) passaient ; etreqwest/rustls ne fait pas confiance a la CA auto-signee du Kanidm de dev, ni au trust store systeme. Corriges via deux nouvelles variables d'environnement,ATELIER_JWT_AUDIENCE(obligatoire des qu'un issuer est configure) etATELIER_JWT_CA_PATH(optionnelle). Cote port-forward : nouvel endpointGET /v1/workshops/{name}/portforward(crates/api-server/src/portforward.rs) qui authentifie le client, verifie qu'il est proprietaire du Workshop, puis relaie sa connexion websocket vers l'endpoint/portforwarddenet-proxy(deja ecrit, voir composant "port-forward" ci-dessus) — sur le modelekubectl port-forward(net-proxy= kubelet,api-server= coordinateur). Teste de bout en bout (crates/api-server/tests/routes.rs) : vrai binairenet-proxy, vrai serveur TCP cible, vraiWorkshop+Podsur kind, vrai client websocket a traversapi-server.- Device plugin Kubernetes pour
/dev/kvm(crates/kvm-device-plugin) : implemente l'API kubelet v1beta1 (proto vendore,tonic/tonic-prost-build) pour sortirvm-supervisor/image-builderdeprivileged: true. Deploye en DaemonSet (deploy/dev/kvm-device-plugin/), teste contre kind reel a trois niveaux : (1)kubectl describe nodeliste bienatelier.dev/kvm: 32une fois le pluginRunninget enregistre aupres du kubelet ; (2) un pod non privilegie demandant cette ressource ouvre reellement/dev/kvm(pas juste visible,exec 3<>/dev/kvmreussit — preuve que le device cgroup est bien configure par le kubelet) ; (3) un vrai podvm-supervisoravec exactement la spec desormais generee parreconcile.rs(resources.limits.atelier.dev/kvm=1, capabilitiesNET_ADMIN/SYS_ADMIN/SYS_RESOURCE, aucunprivileged) boote reellement Firecracker (microVM running). Au passage, decouverte queNET_ADMINseul suffit a creer le TAP mais pas a spawner le process jailer (Operation not permitted) :SYS_ADMIN/SYS_RESOURCEmanquants, necessaires pour que les capabilities de fichier posees parsetcapsur le binairejailersoient elevables a l'exec (voir "Lecons retenues").image-builderbeneficie du meme mecanisme. - Dashboard (
dashboard/, Next.js 16 App Router) : pattern backend-for-frontend plutot qu'un client OAuth2 cote navigateur — le dashboard lui-meme est le client public PKCE, jamais le JS servi au navigateur./api/auth/logingenere le couple PKCE (lib/pkce.ts) et redirige vers${KANIDM_URL}/ui/oauth2(pas directement/oauth2/authorise, qui exige unAuthorization: Bearerdeja present — inatteignable pour un navigateur sans session, decouvert en testant reellement : 401 systematique sans ce header). Kanidm gere son propre login+consentement puis redirige vers/api/auth/callback, qui echange le code (/oauth2/token) et pose un cookie httpOnly (atelier_session, jamais lisible par le JS navigateur). Toutes les donnees (lib/api-server.ts) et mutations (app/actions.ts, Server Actions suspend/resume/delete/create) relaient ce token enAuthorization: Bearerversatelier-api-server, qui reste la seule couche a revalider reellement le JWT —proxy.ts(ex-middleware.ts, renomme dans Next 16) ne fait qu'une verification optimiste de presence du cookie. Client OAuth2 Kanidm existant (atelier) reutilise, une redirect_uri supplementaire ajoutee (add-redirect-url+enable-localhost-redirects, necessaire pour un client public qui redirige verslocalhost, voirdeploy/dev/kanidm/README.md). Verifie reellement de bout en bout :/api/auth/loginproduit une redirection Kanidm valide avec le boncode_challenge, le cote Kanidm du flux est scripte commeget-oauth2-token.sh(login reel + consentement + redirection avec un vraicode) puis rejoue contre/api/auth/callbackavec le cookie PKCE emis par le dashboard — echange de code reussi, cookie de session pose avec un vrai JWT Kanidm, page d'accueil affichant la vraie liste de Workshops (vide puis peuplee), creation reelle d'un Workshop viaPOST /v1/workshopsavec ce token, affiche dans la liste, supprime. Seule la partie "clic humain dans l'UI de login Kanidm" n'est pas automatisable ; tout le reste (echange de code, session, appels API) est verifie contre de la vraie infrastructure, pas simule.
Reseau kind ↔ registre : diagnostic complet, activation bloquee par un risque de securite
Investigation menee jusqu'au bout (cluster kind + registre + Kanidm reels, conformement au principe "pas de mocks" du projet) :
- Cause du trou reseau, confirmee : le conteneur
atelier-registry-devvit sur le reseau Dockerbridgepar defaut, le noeud kind sur le reseaukind— deux reseaux Docker distincts, aucune route entre eux. Fix verifie manuellement :docker network connect kind atelier-registry-dev --alias atelier-registry-devrend le registre joignable par IP depuis un pod kind ; unService/EndpointSliceKubernetes statique pointant sur cette IP donne un nom DNS stable en cluster (atelier-registry-dev:5000depuis le namespacedefault). Verifie avec un podcurljetable et via un vrai Jobimage-builder(clone + build envbuilder + push registre +crane export: tous reussis avec cette configuration). - Blocage decouvert en testant reellement, pas encore leve : le Job
image-builderechoue avant meme d'atteindre le registre (temp remount: bind mount ... operation not permitted) tant que son conteneur n'a pas de capacite de mount elevee. Cause : envbuilder remonte tous les points de montage existants (PVC de cache,emptyDird'outils, token ServiceAccount) apres avoir vide le systeme de fichiers du conteneur pour y extraire l'image cible — necessaire pour que ces volumes restent utilisables apres le wipe, pas seulement pour le token SA.privileged: true(comme dans ledocker runmanuel du README) leve le blocage ; une version plus etroite (securityContext.capabilities.add: [SYS_ADMIN]seul, toutes les autres capacites droppees) leve aussi le blocage en test manuel. - Pourquoi ce n'est pas active : meme reduit a
SYS_ADMINseul, cette capacite reste une des plus dangereuses qui existe (mount arbitraire, plusieurs techniques d'evasion de conteneur connues) — et ici elle s'appliquerait a un conteneur qui execute des instructions de build (RUN,postCreateCommand, etc.) issues du depot cible du Workshop, potentiellement non fiable, a la difference devm-supervisorqui n'execute que du code first-party (jailer/Firecracker) sousprivileged: true. Contrairement au blocage KVM devm-supervisor(device cgroup controller, pas de contournement plus etroit trouve), ici le compromis n'a pas ete tranche : accorder cette capacite a du code non fiable contredit le modele de securite du projet ("aucun acces direct au reste du cluster" pour ce qui execute du contenu externe), donc le code (RBACworkshops/statuspour un ServiceAccount dedieimage-builder,capabilities.add: [SYS_ADMIN]sur le Job) n'a pas ete merge en l'etat — revert delibere apres discussion. - Direction retenue : plutot que d'isoler le conteneur K8s du Job
(gVisor/Kata/NetworkPolicy), faire tourner
envbuildera l'interieur d'une microVM Firecracker jetable, en reutilisant le plumbing jailer/fctoolsdeja ecrit et valide pourvm-supervisor— voir section "Builder microVM" ci-dessous. Decision explicite : le rootfs de la builder VM elle-meme (notre propreDockerfile, contenu first-party) peut etre construit dans un environnement moins contraint (docker buildclassique) — la protection ne vise que le contenu du depot cible du Workshop, execute a l'interieur de la VM une fois demarree, pas ce rootfs. - Resolu : la microVM "builder" est desormais ecrite, validee et
branchee dans
image-builder/reconcile.rs— voir section "Builder microVM" ci-dessous, sous-section "Branchee dansimage-builder/reconcile.rs". Le Jobimage-buildern'a plus besoin deSYS_ADMIN.
Builder microVM : isoler envbuilder d'une microVM jetable
Composants nouveaux cette session (plan complet dans l'historique de conversation) :
crates/firecracker: le wrapperfctools(jailer, boot, snapshot/restore) precedemment prive devm-supervisor, extrait en lib partagee —vm-supervisoren depend desormais sans changement de comportement (meme test reel boot/snapshot/restore, toujours vert apres le refactor). Ajout d'un modulenetwork(nouveau) : cree un device TAP- un sous-reseau link-local
/30point-a-point entre l'hote et le guest — teste reellement (crates/firecracker/tests/network.rs, viaunshare --net --map-root-user, sans besoin de root reel pour ce test-la). crates/builder-vm-init: init minimal (PID 1 du guest, pas de systemd) qui monte/proc//sys, configureeth0avec l'IP link-local recue via lesboot_argsdu kernel (atelier.<clef>=<valeur>, pas de MMDS), lanceenvbuilderavecHTTP_PROXY/HTTPS_PROXYpointant surnet-proxy(pas d'acces reseau direct/NAT vers Internet — voir plus bas), puis eteint la VM. Compile, image Docker construite, convertie enrootfs.ext4bootable via le meme pipeline crane-export +mke2fsqueimage-builder(deja valide cette session) — voirdeploy/dev/builder-vm/README.md.- Reseau via
net-proxy, pas NAT brut : premiere version decrates/firecracker::networkposait un NAT (iptables MASQUERADE) vers la sortie normale du pod. Remis en cause en cours de session :net-proxy(deja "Fonctionnel", allowlist de domaines + tunnelCONNECT) doit rester le seul chemin de sortie reseau pour une microVM, agent ou builder — pas de raison d'en faire une exception pour cette derniere. Design revu : le guest n'a qu'un lien point-a-point versnet-proxy(directement joignable, pas de route par defaut necessaire), configure commeHTTP_PROXY/HTTPS_PROXYpourenvbuilder. Plus simple (zeroiptables/ip_forwardcote hote) et plus coherent avec le modele de securite existant. CAP_NET_ADMINfinalement obtenu, deux voies : la premiere tentative (setcap cap_net_admin+eipsur une copie deip, meme pattern quejailer) a echoue silencieusement — le process de l'agent tournait dans un contexte qui ignore les capabilities de fichier sur le vrai netns de la machine (confirme par unRTNETLINK answers: Operation not permittedmeme sur une simple modification de MTU delo, capability posee ou pas). Contournement qui fonctionne :docker run --privileged --device=/dev/net/tun --device=/dev/kvm— un conteneur Docker recoit un vrai netns isole avecCAP_NET_ADMINeffectif et une sortie Internet reelle (NAT Docker par defaut), ce qui manquait aunshare --netseul. C'est aussi l'environnement le plus proche de la cible reelle (podprivileged: true).- Piege glibc en cross-environnement : premiere execution dans ce
conteneur
rust:1-bookwormavec le depot hote monte en volume ratee en silence —cargo builda reutilise unatelier-net-proxydeja compile sur l'hote (Ubuntu 26.04, glibc 2.43) trouve danstarget/debug/partage via le montage, incompatible avec la glibc du conteneur (Debian bookworm, 2.36) :GLIBC_2.38' not found.cargone detecte pas ce genre d'incompatibilite (le fingerprint ne suit pas la version glibc de l'environnement) — unCARGO_TARGET_DIRdedie au conteneur est necessaire des qu'un repertoiretarget/hote est monte dedans. - Bug reel trouve et corrige en testant : les pipes stdout/stderr de
Firecracker devaient etre draines — sans lecteur cote hote, un pipe
Unix a un buffer fini (~64 Kio) ; une fois plein, l'ecriture du guest sur
sa console serie (
console=ttyS0) bloque indefiniment, gelant tout le guest bien avant qu'il atteigne le reseau.Vm::boot/Vm::restoreappellent desormaistake_pipes()et draine en continu stdout/stderr verstracing::debug!(crates/firecracker/src/vm.rs). Corrige un vrai bug latent (n'importe quelle VM produisant plus de 64 Kio de sortie console l'aurait touche), mais n'a pas resolu le blocage du boot complet de la builder VM — meme symptome observe apres le correctif (process de test a 99.9% CPU en continu, jamais de sortie deVm::boot_with_network, aucune requete n'atteint jamaisnet-proxy).
Blocage resolu : cinq causes racines, boot complet valide reellement
Le blocage total (process de test a 99.9% CPU, jamais de sortie de
Vm::boot_with_network, aucune requete n'atteint jamais net-proxy) evoque
en fin de session precedente n'avait aucun rapport avec envbuilder ni la
configuration reseau du guest. Diagnostic repris et pousse jusqu'au bout
cette session (isolation par gdb/strace sur le process de test, contre
un environnement docker run --privileged --network host — voir "Lecons
retenues" pour pourquoi cet environnement remplace un acces root reel).
Cinq causes racines independantes, trouvees et corrigees dans l'ordre ou
elles se sont revelees :
- Chemin de socket jail Firecracker trop long :
sockaddr_un.sun_pathest limite a 108 octets sur Linux ; les noms de jail/repertoire de travail choisis danscrates/firecracker/tests/builder_vm.rs(atelier-builder-vm-test-<pid>repete deux fois dans le chemin) produisaient un chemin de 110 caracteres.connect()echouait donc enENAMETOOLONGa chaque tentative — etfctools0.7.0-alpha.2 (Vm::start, boucleloop { if client.get(...).is_ok() { break } },src/vm/mod.rs:244) avale cette erreur dans une boucle qui ne cede jamais la main a l'executeur async, empechant meme son propre timeout de 5s de se declencher : 100% CPU, aucun message d'erreur exploitable. Confirme viagdb -p <pid> -batch -ex bt(pile figee danshyper_client_sockets::uri::unix) et un test de controle (tests/vm.rs, chemin de 96 caracteres) qui passait sans probleme dans le meme environnement. Corrige en raccourcissant les noms (bvm-<pid>). KANIKO_DIRabsent : leDockerfiledebuilder-vm-initfixeENV KANIKO_DIR=/.envbuilder(garde-fou interne d'envbuilder/Kaniko avant de vider le filesystem), mais cette metadonnee OCI n'est interpretee que par un runtime de conteneur — perdue lors de la conversion enrootfs.ext4brut (crane export+mke2fs), puisque ce guest n'a pas de runtime de conteneur, seulementatelier-builder-vm-initen PID 1.envbuilderrefusait donc de demarrer ("KANIKO_DIR is not set to /.envbuilder. Bailing!"). Corrige en passant la variable explicitement dansrun_envbuilder().- Rootfs trop petit : la marge de 512 Mo (README) suffisait pour la
base de l'image
builder-vm-initmais pas pour un vrai build devcontainer (paquetsapt/pip, ex:gccpour le devcontainer Python de test) —no space left on deviceen plein build. Marge passee a 4096 Mo. - Adresse de registre
localhostexemptee du proxy :envbuilder(client HTTP Go,golang.org/x/net/http/httpproxy) exclut inconditionnellementlocalhost/loopback du proxy configure viaHTTP_PROXY/HTTPS_PROXY, meme sansNO_PROXY— comportement cable en dur dans cette bibliotheque. AvecATELIER_TEST_REGISTRY_ADDR=localhost: 5000(cas courant en dev), le guest tentait une connexion directe vers "lui-meme" et echouait (pas de route par defaut). Corrige en construisant la reference d'image donnee au guest avec l'IP hote du lien point-a-point (network.host_ip), jamais litteralementlocalhost. reboot(RB_POWER_OFF)sans effet : cette microVM minimale n'a pas d'ACPI (pci=off), donc aucun handlerpm_power_offa invoquer — le noyau se contentait d'unhalt("reboot: System halted") sans que Firecracker detecte la fin de la VM (is_running()restait vrai indefiniment, alors qu'envbuilderavait bien clone, construit ET pousse l'image avec succes).reboot=k(deja present dans lesboot_args) demande au noyau d'utiliser un reset via le controleur clavier i8042 pour un reboot, pas un power-off — signal que Firecracker intercepte lui-meme comme fin de VM (pattern standard des inits minimaux Firecracker). Corrige en appelantreboot(RebootMode::RB_AUTOBOOT)plutot queRB_POWER_OFF.
Cycle complet valide reellement une fois les cinq corrections en place :
boot jaile + configuration reseau + envbuilder (clone du dépôt public
vscode-remote-try-python, build du devcontainer, push vers le registre de
dev via net-proxy) + extinction propre detectee par l'hote, crane
manifest confirmant que l'image attendue est bien presente
(cargo test -p atelier-firecracker --test builder_vm, 35s, voir
deploy/dev/builder-vm/README.md).
Branchee dans image-builder/reconcile.rs : boucle complete automatique
Composant desormais utilise reellement par le Job image-builder, plus
seulement valide en isolation :
crates/image-builder:build_and_push()(invocation directe d'envbuilder) remplace parbuild_via_microvm(), qui reprend le meme cycle boot/attente-extinction quetests/builder_vm.rs. Le rootfs de la microVM builder n'est plus un fichier pre-dimensionne fourni a la main : un rootfs de base (contenu minimal, marge reduite) est baque dans l'imageimage-builderelle-meme au build Docker (voir plus bas), puis copie et agrandi (truncate+resize2fs, marge par defaut 4096 Mio) a chaque build reel —resolve_builder_rootfs().ATELIER_BUILDER_VM_ROOTFS_PATHreste disponible pour court-circuiter ce mecanisme en test manuel.crates/image-builder/Dockerfile: reecrit en build multi-etapes. Le contenu guest de la microVM builder (identique acrates/builder-vm-init/Dockerfile) est construit dans un stage intermediaire puis aplati directement en rootfs.ext4 viaCOPY --from=<stage> /+mke2fs— sans passer par un registre OCI intermediaire nicrane export(contrairement a la procedure manuelle dedeploy/dev/builder-vm/README.md) : un stage Docker multi-etapes fait deja ce travail de aplatissement localement. Simplification decouverte en ecrivant ce Dockerfile, pas anticipee au depart.- Alias
registrydenet-proxy(crates/net-proxy::internal, troisieme alias interne apresidentity-proxy/mcp-gateway) : la microVM builder doit joindre le registre interne (ouenvbuilderpousse l'image), un detail d'implementation que l'utilisateur ne doit pas avoir a ajouter aWorkshop.spec.egress_allowlist(decision explicite, discutee en session : cette allowlist reste reservee a l'usage — package managers du devcontainer compris — que l'utilisateur choisit explicitement d'autoriser).ATELIER_REGISTRY_ALIAS_ADDR(cotenet-proxy) etATELIER_BUILDER_REGISTRY_ALIAS(coteimage-builder, cable par le controller) ferment la boucle. net-proxyconteneurise pour la premiere fois (crates/net-proxy/Dockerfile, n'existait pas avant cette session — jusqu'ici teste uniquement viacargo run/tests d'integration reels) et deploye comme sidecar natif du Jobimage-builder(initContaineravecrestartPolicy: Always, K8s >= 1.28/1.29, KEP-753) : un simplecontainers[]long-vivant empecherait le Job de jamais se terminer (unJobn'est marque termine que quand tous sescontainers[], pas ses sidecars natifs, ont fini). Verifie reellement contre kind (K8s 1.34).- RBAC dedie (
ensure_image_build_rbac:ServiceAccount+Role+RoleBindingpar Job, scopes au Workshop precis viaresourceNames) : bloque depuis la session precedente par le compromisSYS_ADMIN(voir plus haut, "Reseau kind ↔ registre") — plus necessaire puisqueenvbuilderne tourne plus dans le conteneur du Job. UnRole/RoleBindingorphelin issu d'un test manuel anterieur trainait sur le cluster de dev, non gere par le code (nettoye). - Valide de bout en bout contre un vrai cluster kind :
kubectl applyd'unWorkshopreel (vscode-remote-try-python,egressAllowlist: ["*"]) declenche tout le pipeline sans aucune intervention manuelle — Jobimage-builderComplete(1/1, ~100s),status.imageDigestrenseigne, le controller enchaine automatiquement sur le pod parent,vm-supervisorboote reellement la microVM avec le rootfs construit ("microVM running"). Premiere fois que cette boucle complete tourne sans peuplage manuel du PVC de cache. - Bugs trouves en testant ce chemin (distincts des cinq de la section precedente, specifiques a l'integration K8s) :
iproute2(ip) absent de l'image finaleimage-builder(present dans le stage guest, oublie dans le stage final) —Vm::boot_with_networkechoue a la creation du TAP avec une erreurNo such file or directorytrompeuse (ressemble a un probleme de binaire Firecracker/jailer, pas deip).- L'image
atelier-net-proxy:devrechargee dans kind AVANT l'ajout du code de l'aliasregistrya produit un echec silencieux et rapide (VM eteinte en 3s,crane exporten erreurMANIFEST_UNKNOWN) : toujours reconstruire ET recharger (kind load docker-image) une image avant de re-tester en cluster, une image perimee ne donne aucun signal explicite qu'elle est perimee. - Un
Workshopde test sansegressAllowlistbloque tout l'egress de la microVM builder (allowlist vide = tout refuse, comportement voulu) — piege facile en testant manuellement, pas un bug.
Canal de controle suspend/resume : snapshot Firecracker reel
Item 2 de la roadmap ("canal de controle vsock entre controller/
vm-supervisor") : le terme "vsock" du TODO d'origine etait trompeur —
AF_VSOCK est le canal guest<->hote a l'interieur d'un meme pod (deja
utilise par mcp-gateway, voir docs/architecture/network-security.md),
alors que controller et vm-supervisor sont deux process dans deux pods
distincts : un simple canal HTTP sur le reseau normal du cluster (IP de
pod) est le bon outil, pas vsock.
- Obstacle principal, resolu par construction plutot que par
contournement : l'API
fctools(VmSnapshot/Vm::restore) n'est pas concue pour survivre a un redemarrage complet du process —Vm::restoreprend&mut selfsur la VM source, dont leResourceSystemvivant fournit les ressourcesMoved(kernel/rootfs) a recopier dans le nouveau jail, etVmConfigurationDatane derive queSerialize(pasDeserialize) : rien de tout ca ne peut etre serialise puis recharge plus tard dans un tout autre process. Contournement :VmConfigurationDataest entierement determinee par les memes parametres qu'un boot normal (kernel, rootfs, vcpu/mem, boot_args, reseau), tous deja connus independamment de tout etat runtime —Vm::restore_persisted(nouveau,crates/firecracker/src/vm.rs) la reconstruit a l'identique plutot que de la deserialiser, exactement comme le ferait un nouveauVm::boot/Vm::boot_with_network, avec pour seule differenceVmConfiguration::RestoredFromSnapshota la place deVmConfiguration::New. Validite garantie par leFlatVirtualPathResolverdu jail (chemin virtuel base sur le nom de fichier, pas sur l'identite de la ressource) : la configuration serialisee vers Firecracker reference "/vmlinux.bin"/"/rootfs.ext4" quel que soit l'objetResourceinterne qui les a produits. Teste reellement en abandonnant completement la VM source (eteinte, jail detruit) avant de restaurer dans unVmflambant neuf (crates/firecracker/tests/vm.rs::snapshot_persist_and_restore_without_source_vm). crates/vm-supervisor: petit serveur HTTP (axum, port 8081 par defaut,ATELIER_VM_CONTROL_ADDR) exposePOST /snapshot— fige la VM (vm.snapshot()), publiesnapshot.state/snapshot.memsur le cache partage (ATELIER_VM_SNAPSHOT_DIR, ecriture atomique via fichiers.tmprename), renvoie un digest sha256 informatif, puis arrete proprement la VM et sort du process. Au demarrage, si ce repertoire contient deja un snapshot, restauration viaVm::restore_persistedplutot que boot direct — decouverte automatique, sans variable d'environnement supplementaire pour distinguer boot/resume. Premiere conteneurisation testee de bout en bout de ce canal : boot -> snapshot via l'API -> arret propre -> reprise dans un process totalement neuf, valide manuellement avant meme le passage par Kubernetes.crates/controller/src/reconcile.rs:ensure_suspendedappelle ce canal (request_snapshot, best-effort — un echec de connexion, un timeout ou une reponse non exploitable degradent silencieusement vers "suspension sans snapshot" plutot que de bloquer indefiniment la suspension demandee) avant de liberer le pod, et publiestatus.snapshotDigest.ensure_parent_podmonte desormais le cache en lecture-ecriture (necessaire pour quevm-supervisory publie) et passeATELIER_VM_SNAPSHOT_DIR(snapshot_cache_subdir, nouveau dansstorage.rs— scope par Workshop, pas content-addressed comme le cache d'images : un snapshot n'a pas besoin de dedup entre Workshops).- Premiere conteneurisation du
controller(crates/controller/Dockerfile, n'existait pas non plus avant cette session) : necessaire pour valider ce canal en conditions reelles — lecontroller, lance depuis le poste de dev (hors cluster, comme durant toute la session), ne peut pas joindre une IP de pod kind directement (reseau du CNI non route vers l'hote). Contourne en lancant le conteneurcontrolleravec--network container:<noeud-kind>(partage le netns du noeud, qui lui route bien vers les pods qu'il heberge) — meme categorie de contournement que l'usage de--network hostpour la microVM builder plus haut. Une fois ce partage de netns en place, la requetePOST /snapshotatteint bien le pod et le cycle complet fonctionne. - Valide de bout en bout contre kind reel : Workshop reel ->
Running->desiredState: Suspended->vm-supervisorrecoitPOST /snapshot, publiesnapshot.state/snapshot.memsur le PVC,status.snapshotDigestrenseigne, pod libere (phase: Suspended) ->desiredState: Running-> nouveau pod parent, log"restoring microVM from persisted snapshot"(pas"booting microVM") : preuve que la restauration cross-process, cross-pod, fonctionne reellement. - Note observee, pas un bug : pendant la periode de grace de suppression
du pod (delai par defaut ~30s), un reconcile peut encore voir le pod
(
Terminating) et retenterPOST /snapshotsur une VM deja eteinte par le premier appel reussi — echoue en 500, journalise enWARN, sans consequence :ensure_suspendedne remplacestatus.snapshotDigestque sur un appel reussi, jamais parNone.
Reseau de l'agent + net-proxy/identity-proxy dans le pod parent
Item 3 de la roadmap. Le mecanisme d'isolation reseau etait deja
integralement specifie dans docs/architecture/network-security.md (session
precedente) — cette partie de la session l'a implemente et valide.
Workshop.spec.identityInjectionRules(nouveau champ) : meme type (IdentityInjectionRule) defini une seule fois dansatelier_common::crdet reexporte parcrates/identity-proxy::rules(pub use ... as InjectionRule, methodesecret_cache_key()deplacee dans un trait d'extension puisque le type n'est plus local a ce crate) — le controller serialise le contenu du CR tel quel versATELIER_IDENTITY_INJECTION_RULES(JSON, camelCase des deux cotes). CRD regenere (crds/workshop.yaml) et reapplique sur kind.- TAP reseau pour la VM de l'agent (
crates/vm-supervisor/src/main.rs) :vm-supervisorcree desormais un TAP link-local (setup_link_local_tap, meme mecanisme que la microVM "builder", mais sans NAT) et pose les regles iptables deNetworkSetup::restrict_to_net_proxy(nouveau,crates/firecracker/src/network.rs) avant de booter. Contrairement a la microVM "builder" (init personnaliseatelier-builder-vm-init),vm-supervisorboote le devcontainer construit parimage-buildertel quel — impossible de lui faire executer du code de configuration reseau a nous. Solution : le parametre de boot noyau standardip=<guest>::<hote>: <masque>::eth0:off(autoconfiguration IP Linux,Documentation/admin-guide/ nfs/nfsroot.rst), qui configure l'interface et la route par defaut avant meme que l'init du guest ne demarre, sans aucune cooperation requise. Verifie reellement (RUST_LOG=atelier_firecracker=debug, console du guest) :IP-Config: Complete: device=eth0, ipaddr=169.254.0.2, mask=255.255.255.252, gw=169.254.0.1. Regles iptables verifiees a l'identique de la specification (iptables -S atelier-vm-<tap>:ACCEPTsur le portnet-proxy+ DNS:53, puisDROP). net-proxyetidentity-proxycomme conteneurs du pod parent (ensure_parent_pod,crates/controller/src/reconcile.rs) : contrairement au sidecar du Jobimage-builder(qui devait etre uninitContainerrestartPolicy: Alwayspour ne pas empecher le Job de se terminer), ici ce sont de simplescontainers[]— le Pod parent est cense tourner indefiniment, aucune contrainte de "completion" ne s'applique.ATELIER_VM_ADDR(net-proxy) est fixe a169.254.0.2: deterministe,vm-supervisorutilise toujours l'index de sous-reseau0(une seule microVM par pod). PremierDockerfiled'identity-proxy(n'existait pas non plus avant cette session).- Bug trouve en testant reellement (meme categorie que pour
image-builder) :iproute2/iptablesabsents de l'image Docker finale devm-supervisor(presents nulle part avant, puisque cette image ne faisait jusqu'ici jamais de reseau) —CrashLoopBackOffaveclancement de ip: No such file or directory. Corrige en les ajoutant acrates/vm-supervisor/Dockerfile. - Valide de bout en bout contre kind reel :
WorkshopavecegressAllowlist: ["*"]et une regleidentityInjectionRulesreelle -> pod parent3/3 Running-> logs confirmant chaque piece :vm-supervisor("microVM running"),net-proxy(identity_proxy_alias=true,identity_proxy_mandatory_hop=true),identity-proxy("regles d'injection chargees count=1"). - Explicitement hors scope de cette session (voir
docs/architecture/network-security.md, section mise a jour) : le TAP et le pare-feu donnent au guest un chemin possible versnet-proxy, mais rien a l'interieur du guest ne configure encoreHTTP_PROXY/HTTPS_PROXYpour s'en servir — un devcontainer construit sans le savoir tenterait une connexion directe, silencieusement bloquee par les regles iptables. Reste a injecter ces variables dans l'image construite parimage-builder(ex:/etc/environment), voir "Prochaines etapes".
mcp-gateway : premier serveur MCP reel
Dernier composant du tableau ci-dessus a sortir de "Non demarre". Design
cible documente de longue date (docs/ARCHITECTURE.md,
docs/architecture/network-security.md) : un point d'entree structure pour
que l'agent demande des reglages a l'atelier plutot que d'agir en direct.
- Decision d'architecture : HTTP/SSE via
net-proxy, pasvsock, pour ce lot. Le design documente presentevsockcomme transport primaire (deja cable cote design, jamais construit :crates/firecracker/src/vm.rsfixevsock_device: Nonesur toute VM) et l'alias HTTP denet-proxy(ATELIER_MCP_GATEWAY_ADDR, deja present danscrates/net-proxy/src/internal.rsmais jamais branche jusqu'ici) comme repli pour les clients qui prefèrent HTTP/SSE. Batirvsockcorrectement (device Firecracker, plumbing hote, cote guest) est un chantier de l'ampleur du TAP reseau deja fait pour l'agent — differe. La garantie de securite recherchee ("mcp-gateway jamais joint directement par la VM") ne depend pas de vsock : elle est deja assuree par le meme mecanisme que pouridentity-proxy(pare-feu TAP, la VM ne connait quenet-proxy). crates/common::openbao_client:login()/read_field()(authentification Kubernetes-auth OpenBao + lecture KV v2) extraits decrates/identity-proxy/src/secrets.rsvers un client partage — la policy provisionnee parcrates/controller/src/openbao.rs::ensure_workshop_rolecouvre deja toutsecret/data/workshops/<name>/*, doncmcp-gatewaypeut lire un sous-chemin different (workshops/<name>/mcp) avec le meme role, sans aucun changement de provisioning.identity-proxyrefactore pour consommer ce client, son cache/rafraichissement periodique restant local (specifique a l'injection continue de credentials HTTP).crates/net-proxy: allowlist mutable a chaud + endpoint d'admin loopback-only.EgressConfig.allowlist/DnsConfig.allowlistpassent deArc<Vec<String>>aArc<RwLock<Vec<String>>>(proxy.rs,dns.rs,main.rs). Nouveau moduleadmin.rs:POST /internal/allowlist/add, servi sur un second listener lie explicitement a127.0.0.1(ATELIER_NET_PROXY_ADMIN_ADDR, defaut127.0.0.1:9001) — jamais0.0.0.0, contrairement au port de controle du port-forward (ATELIER_NET_PROXY_CONTROL_ADDR) qui n'est protege que par les regles iptables du TAP. Verifie manuellement (conteneurs Docker reels, meme netns) : un appel externe sur le port publie de ce listener echoue (connexion refusee, le bind loopback n'est routable que depuis l'interieur du conteneur), alors qu'un appel depuis un conteneur partageant le netns (mcp-gateway) reussit. Pas de persistance : un ajout ne survit pas a un redemarrage denet-proxyni ne modifieWorkshop.spec.egress_allowlist.crates/mcp-gateway: SDK officielrmcp(meme choix de principe que pourfctools: SDK officiel plutot que hand-roll), transport streamable HTTP (StreamableHttpService+axum::Router::nest_service, featuretowerdermcp). Deux tools reels, actifs seulement si leur nom figure dansWorkshop.spec.tools(ATELIER_TOOLS, verifie au moment de l'appel, pas filtre detools/list) :request_credential { field }: litworkshops/<name>/mcpvia le client OpenBao partage, retourne le champ demande.request_egress { host }: appelle l'endpoint d'admin denet-proxy(ci-dessus) sur127.0.0.1:9001(meme pod).enable_simulator: non implemente — aucun simulateur n'existe encore (roadmap item 5, "mcp-gateway et le premier simulateur, candidat LocalStack" reste a faire).allowed_hostsdermcp(protection anti DNS-rebinding, restreint par defaut alocalhost/127.0.0.1) etendu explicitement amcp-gateway:net-proxyrelaie la requete de la VM telle quelle recue (crate::proxy::forward), Host header compris, potentiellementHost: mcp-gatewayplutot qu'une IP.crates/controller/src/reconcile.rs: conteneurmcp-gatewayajoute au pod parent (containers[], commenet-proxy/identity-proxy— tourne indefiniment), envATELIER_WORKSHOP_NAME,ATELIER_TOOLS(serialisation CSV deworkshop.spec.tools),ATELIER_NET_PROXY_ADMIN_ADDR,OPENBAO_ADDR(si configure). AliasATELIER_MCP_GATEWAY_ADDRenfin branche cote conteneurnet-proxy(le code le supportait deja, jamais utilise jusqu'ici).- Verifie reellement, sans mock (conteneurs Docker construits et
executes, pas seulement
cargo test) : request_egressbout en bout : un appel MCPtools/callreel (handshakeinitialize/notifications/initialized/tools/callviacurl, protocole streamable HTTP) contre un vrai conteneuratelier-mcp-gatewayelargit l'allowlist d'un vrai conteneuratelier-net-proxy(partage de netns Docker, meme scenario que deux conteneurs d'un pod) ; unCONNECTHTTPS ulterieur vers l'hote ajoute (crates.io) reussit alors un vrai handshake TLS complet a travers le tunnel, la ou il echouait avant l'appel.request_credentialbout en bout : provisioning reel d'un role OpenBao (workshop-mcpdemo, methode Kubernetes-auth, meme sequence quedeploy/dev/openbao/README.md) et d'un secret KV v2 (workshops/mcpdemo/mcp), interroge viamcp-gatewaylance en local avec un vrai token de ServiceAccount Kubernetes — la valeur exacte du secret est retournee par l'appel MCP.- Test d'integration controller etendu
(
apply_creates_owned_parent_pod_once_image_ready) : verifie contre kind reel que le pod parent genere porte bien les quatre conteneurs (vm-supervisor,net-proxy,identity-proxy,mcp-gateway). - ~~Reste a faire : verification bout-en-bout depuis l'interieur d'une vraie microVM agent~~ — fait cette session, voir "Verification MCP depuis l'interieur d'une vraie microVM agent" ci-dessous.
enable_simulator + premier simulateur (LocalStack) : fait, verifie de bout en bout
- Decision de design : alias
simulator"gate", pas un alias interne classique. Les alias existants (identity-proxy/mcp-gateway/registry,crates/net-proxy/src/internal.rs) sont fixes au demarrage et toujours joignables — corrects pour des composants de confiance structurels, mais un simulateur AWS local n'a pas vocation a etre joignable par defaut, seulement quand l'agent le demande explicitement. Nouvel etat mutable distinct (EgressConfig::simulator,Arc<RwLock<Option<(String, u16)>>>,crates/net-proxy/src/proxy.rs) : initialise aNone(donc l'hotesimulatorretombe sur l'allowlist normale, qui le refuse sauf*, et de toute faconsimulatorn'est pas un nom DNS reel) ; ne devientSomequ'apres un appel aPOST /internal/simulator/enable(nouveau,crates/net-proxy/src/admin.rs, meme serveur loopback-only querequest_egress). crates/mcp-gateway: nouveau toolenable_simulator(sans parametre), gate par"enable_simulator"dansWorkshop.spec.tools(meme convention que les autres tools) — relaie simplement l'appel vers l'endpoint d'admin ci-dessus.crates/controller/src/reconcile.rs: conteneursimulatorajoute au pod parent seulement siWorkshop.spec.toolscontientenable_simulator— image officiellelocalstack/localstack:3(pas de fork/rebuild maison), lie a127.0.0.1:4566(GATEWAY_LISTEN, "edge port" LocalStack qui sert la quasi-totalite des API AWS emulees sur ce seul port), donc structurellement injoignable par la VM (netns distincte) et par tout ce qui n'est pas dans le pod.net-proxyrecoitATELIER_SIMULATOR_ADDR=127.0.0.1:4566dans ce cas, sinon rien (pas de conteneur, pas d'adresse configuree :enable_simulatorechoue proprement avec un message explicite plutot que de planter).- Verifie de bout en bout, sans mock (conteneurs Docker reels,
atelier-net-proxy:dev+atelier-mcp-gateway:dev+localstack/localstack:3officiel, partage de netns Docker) : avantenable_simulator, une requete vianet-proxyvers l'hotesimulatorechoue (502, ne resout jamais reellement le nom "simulator") ; un vrai handshake MCP complet par HTTP (initialize/notifications/initialized/tools/call enable_simulator, meme protocole que pourrequest_egressdocumente plus haut) retourne"simulateur active"; la meme requete verssimulatora traversnet-proxyreussit alors (200, vraie reponse JSON de sante LocalStack listant les ~34 services AWS emules) — preuve que le gate fonctionne dans les deux sens et que le chemin complet agent -> mcp-gateway -> net-proxy -> LocalStack est reellement cable. - Limite assumee : verifie via
net-proxydirectement (curl a travers le proxy HTTP), pas encore depuis l'interieur d'une vraie microVM agent (meme limite que le reste de cette section) ; pas de test contre kind reel non plus (conteneurs Docker isoles, pas de pod K8s) — le cablagereconcile.rs(volume, conteneur conditionnel, env vars) est verifie parcargo check/compilation mais pas encore par un test d'integration contre un cluster reel.
Transport vsock natif : fait, verifie de bout en bout
Composant desormais construit (etait le seul morceau manquant du design
cible documente pour mcp-gateway) :
crates/firecracker::vm:VmConfig.vsock: Option<VsockConfig>(guest_cid,uds_relative_path) —build_configuration_datadeclare le socket "principal" comme ressourceProduced(meme mecanisme que les fichiers de snapshot, voirVm::snapshot) : chemin relatif au jail (/vsock.sock), Firecracker le cree lui-meme au boot, le chemin hote reel resulte de la resolution du jail.crates/vm-supervisor: device vsock toujours actif (ATELIER_VM_VSOCK_GUEST_CID, defaut3;ATELIER_VM_VSOCK_UDS_FILENAME, defautvsock.sock) — sans emplacement partage avecmcp-gateway, reste simplement inutilise, cout nul.crates/mcp-gateway: second transport actif en parallele du HTTP existant, memeGateway(ServerHandler) des deux cotes. Convention Firecracker pour les connexions initiees par le guest : ce process lie lui-meme unUnixListenera<uds_path>_<port>(ATELIER_MCP_GATEWAY_VSOCK_UDS_PATH,ATELIER_MCP_GATEWAY_VSOCK_PORTdefaut10000) — pas de transport HTTP a reimplementer :rmcpexposeServiceExt::servesur n'importe quelAsyncRead + AsyncWrite(rmcp::transport::async_rw, meme framing JSON-RPC delimite par newline que le style stdio MCP standard), untokio::net::UnixStreamconvient donc directement, une session MCP complete par connexion acceptee.crates/controller/src/reconcile.rs: nouveau volumeemptyDir"jailer" partage entre les conteneursvm-supervisoretmcp-gatewaydu pod parent, monte au meme chemin absolu (/srv/jailer,ATELIER_VM_CHROOT_BASE_DIRfixe explicitement plutot que de dependre du defaut binaire) dans les deux — necessaire puisque le socket vsock "principal" vit a l'interieur de l'arborescence du jail, pas a un chemin choisi arbitrairement.ATELIER_MCP_GATEWAY_VSOCK_UDS_PATHcalcule par le controller a partir des memes constantes (VM_JAIL_ID,VM_VSOCK_UDS_FILENAME) que celles passees avm-supervisor.- Piege trouve en testant reellement : le jailer insere le nom de
l'executable comme composant de chemin —
<chroot_base_dir>/<exec_file_name>/<jail_id>/root/(donc/srv/jailer/firecracker/atelier-vm/root/...), pas<chroot_base_dir>/<jail_id>/root/comme la seule lecture de la doc--chroot-base-dirle suggererait — corrige apres inspection reelle de l'arborescence produite (find /srv/jailer). - Verifie de bout en bout, sans mock (
demo/vsock-probe/, nouveau) : vraiatelier-vm-supervisor+ vraiatelier-mcp-gateway(binaires natifs, pas de conteneur Docker pour ce test-la — les deux process partagent directement/srv/jailersur le systeme de fichiers de l'environnement de test), devcontainer minimal (systemd +python3) dont un service ecrit un vrai client MCP a la main (stdlibsocketavecsocket.AF_VSOCK, disponible nativement sur Linux depuis Python 3.9) qui fait un handshake MCP complet (initialize->notifications/initialized->tools/list) contremcp-gatewayen passant uniquement parAF_VSOCK. Resultat lu apres coup dans lerootfs.ext4du guest (debugfs -R "cat ...", meme technique que pour la validation HTTP_PROXY) : les trois etapes reussissent,tools/listrenvoie bienrequest_credential/request_egress. Voirdemo/vsock-probe/README.md. - Limite assumee : ce test prouve que le transport fonctionne
(jailer+firecracker+vsock+rmcp s'assemblent correctement), pas qu'un
client MCP standard (Claude Code, etc.) choisirait ce chemin plutot que
HTTP aujourd'hui — rien ne l'annonce au guest (pas d'equivalent au
HTTP_PROXYinjecte dans/etc/environmentpour signaler ce transport). Egalement non teste : le comportement en conditions Kubernetes reelles (volumeemptyDirpartage entre conteneurs d'un vrai pod, teste ici en isolation avec deux process natifs partageant un repertoire local).
Verification MCP depuis l'interieur d'une vraie microVM agent : fait
Dernier point ouvert de la section mcp-gateway : tous les tests HTTP
precedents (request_egress, request_credential, enable_simulator)
etaient verifies via un client curl tournant sur l'hote, partageant le
netns Docker avec net-proxy — jamais depuis un guest reellement boote par
vm-supervisor, donc jamais a travers le pare-feu TAP de production.
demo/mcp-agent-probe/(nouveau) : devcontainer minimal (systemd +curl) dont le seul acces reseau est celui d'un devcontainer normal — aucun raccourci, contrairement ademo/vsock-probe/(pas de partage de netns/process avec l'hote).mcp_probe.shfait un vrai handshake MCP (initialize->notifications/initialized->tools/call request_egress) contrehttp://mcp-gateway/mcp, en s'appuyant uniquement surHTTP_PROXY(lu depuis/etc/environmentviaEnvironmentFile=systemd) — exactement le chemin qu'un vrai client MCP dans un devcontainer emprunterait./etc/environmentinjecte a la main avec le contenu exact produit parinject_net_proxy_config(deja verifie octet pour octet contre le vrai pipelineimage-builderplus tot dans cette session) : reproduit manuellement plutot que rejoue via le pipeline complet, pour eviter le blocage connu d'auth git sur un depot prive a l'epoque (depuis resolu autrement pourministack-workshop, voir plus bas — ce depot a demenage vers un depot public dedie).- Boot avec les vrais binaires
atelier-vm-supervisor(TAP +restrict_to_net_proxy, le pare-feu de production, pas une version allegee),atelier-net-proxy(aliasmcp-gatewayconfigure) etatelier-mcp-gateway(toolegressactive). - Verifie de bout en bout, avec preuve croisee (pas seulement "le guest
dit que ca a marche") : le guest recupere bien
HTTP_PROXY=http://169.254.0.1:3128(log de la sonde), les trois etapes du handshake MCP reussissent (log de la sonde, lu apres extinction viadebugfs), etnet-proxy(systeme tiers independant) journalise de son coteallowlist elargie a chaud (request_egress) host="example.com" count=1— la meme requete qui a transite par le pare-feu TAP a reellement modifie l'etat denet-proxy. Voirdemo/mcp-agent-probe/README.md.
Devcontainer de demo ministack-workshop : le boot Firecracker de l'agent exige systemd
Mise a jour : ce devcontainer de demo vit desormais dans un depot public dedie, github.com/PhilippeVienne/atelier-workspace (plus sous
demo/ministack-workshop/de ce depot) — unWorkshoppeut donc le cloner sans identifiants git, le blocage d'auth mentionne plus bas dans cette section est resolu par ce demenagement, pas par le mecanisme d'identifiants git construit coteimage-builder(toujours disponible pour de vrais depots prives).
Question ouverte depuis la conception de vm-supervisor (qui boote le
rootfs d'un devcontainer arbitraire sans init= personnalise,
contrairement a la microVM "builder") : le PID 1 par defaut d'une image
devcontainer standard demarre-t-il seulement, et fait-il tourner quoi que
ce soit tout seul ? Verifie reellement cette session avec
ministack-workshop (devcontainer combinant docker-in-docker,
ministack, Claude Code, code-server — voir son
README) :
- Rootfs construit a la main, meme procedure qu'
image-builder(export d'image Docker +mke2fs -F -t ext4 -d, cf.crates/image-builder/src/main.rs), boote directement avecatelier-firecracker, boot_args identiques acrates/vm-supervisor/src/main.rs(nouveau testcrates/firecracker/tests/agent_boot_smoke.rs, sur le modele detests/builder_vm.rs) : reproduit fidelement les conditions reelles sans passer par tout le pipeline K8s (Jobimage-builder, registre, controller), qui aurait ajoute des variables sans rapport avec la question posee (notamment l'auth git sur un depot prive). - Constat initial : le PID 1 par defaut ne demarre rien. Console du
guest (draine vers
tracing::debug!, seul canal de diagnostic sans vsock) : le noyau tente/sbin/init,/etc/init,/bin/init, ne trouve aucun des trois (l'imagemcr.microsoft.com/devcontainers/basen'a pas de systeme init installe, pensee pour tourner sous un runtime de conteneur classique, pas pour booter seule) et retombe sur un/bin/shnu comme PID 1 — rien ne demarre.postStartCommand(devcontainer.json) est un concept du CLIdevcontainer, jamais rejoue par le noyau : sans lui, aucun mecanisme ne lancedockerd/ministack/code-server. - Corrige :
systemd/systemd-sysvajoutes a l'image (.devcontainer/Dockerfiledu depotatelier-workspace), nos services demarres via deux unites systemd dediees (atelier-ministack.service/atelier-code-server.service,WantedBy=multi-user.target) plutot que viapostStartCommand, qui reste par ailleurs utile pour l'usagedevcontainer upclassique (Docker, testee au debut de cette session). - Piege trouve en corrigeant :
systemctl enableechoue silencieusement dans cette image (exit 0, aucun symlink cree) — un script factice interceptesystemctl/service("systemd is not running in this container due to its overhead"), place par l'image de base pour eviter que l'installation de paquets.debfournissant des unites systemd echoue pendant undocker buildclassique (sans PID 1 systemd). Les paquets eux-memes (ex:docker.service, installe par la featuredocker-in-docker) s'activent correctement malgre tout viadeb-systemd-helper(appele par leur scriptpostinst), qui manipule les symlinks directement sans passer par ce fauxsystemctl— repris a la main (ln -sdans/etc/systemd/system/multi-user.target.wants/) pour nos deux unites. - Resultat, verifie reellement : boot confirme (meme environnement que
les autres tests Firecracker de ce crate,
docker run --privileged --network hostavec/dev/kvm+/dev/net/tun, voir "Lecons retenues"),code-server:8080etministack:4566repondent tous les deux a une vraie connexion TCP depuis l'hote — dans une microVM bootee exactement comme le faitvm-supervisoren production (memes boot_args, aucune concession). - Portee de ce resultat : ce n'est pas seulement une bizarrerie de ce
devcontainer de demo — c'est la premiere verification reelle que
l'architecture "n'importe quel depot Dev Containers standard" (
docs/ARCHITECTURE.md) a une limite concrete non documentee jusqu'ici : un devcontainer sans systeme init installe ne fera jamais tourner sespostCreateCommand/postStartCommandune fois boote comme microVM (contrairement a l'usage normal via le CLIdevcontainer/VS Code). Implication pour tout futur Workshop : soit le devcontainer source installe lui-meme un systeme init et declare ses propres services demarres au boot (comme fait ici), soitimage-builder/vm-supervisordevront un jour injecter un mecanisme generique equivalent — non tranche, laisse ouvert. - Reste a faire : creer un vrai
WorkshopK8s pointant sur ce depot (bloque sur l'auth git a un depot prive, jamais testee jusqu'ici — les Workshops de test utilisaient des depots publics) pour valider la meme chose a travers le pipeline complet, pas seulement en isolation.
UI dashboard : gestion + "ouvrir VS Code"
Premiere page de detail par Workshop et pont HTTP+WebSocket pour ouvrir
code-server (port 8080 dans la microVM agent) directement depuis le
navigateur, au-dessus du protocole portforward existant (raw TCP/UDP
multiplexe, pas HTTP-aware).
crates/api-server/src/vscode.rs(nouveau) : ouvre un flux d'octets verscode-servervia le protocoleportforward(websocket versnet-proxy, canal 0 = donnees) — pont viatokio::io::duplex+ une tache de fond, pas deSink/Streama la main.hyper::client::conn::http1par-dessus (hyper_util::rt::TokioIo),.with_upgrades(). Requetes normales : relayees avec le prefixe/v1/workshops/{name}/vscoderetire (code-serversupporte nativement d'etre servi sous un sous-chemin arbitraire, documente officiellement parcoder/code-server— URLs relatives, prefixe strippe avant de l'atteindre). Upgrade WebSocket (canal "live" propre decode-server) :hyper::upgrade::oncapture cote requete entrante avant de la consommer, meme mecanisme cote reponse amont si101, puistokio::io::copy_bidirectional— relai brut, sans reinterpreter les frames (meme philosophie quenet-proxy::proxy::tunnelpourCONNECT). Helper partageresolve_running_pod_ipextrait dansroutes.rs(portforward.rsrefactore pour le reutiliser).- Verifie reellement, sans mock (
crates/api-server/tests/routes.rs, vrainet-proxy, vrai Workshop/Pod sur kind) : un test relaie unGETHTTP a travers tout le pont jusqu'a un vrai petit serveur de test, verifie que le prefixe est bien retire ; un second test verifie le chemin d'upgrade de bout en bout (client TCP brut envoyant une requeteUpgrade: websocket, reponse101reelle, octets echoes a travers tout le tunnel) — les deux stables sur plusieurs execution repetees, y compris en parallele l'un de l'autre (verrou de test partage sur les variables d'environnement globales que les deux mutent). - Dashboard : nouvelle page
app/workshops/[name]/page.tsx(statut, suspendre/reprendre/supprimer, lien "Ouvrir VS Code" en nouvel onglet siphase === "Running"), Route Handler catch-allapp/workshops/[name]/vscode/[[...path]]/route.ts(reverse-proxy same-origin fin, ajouteAuthorization: Bearercote serveur a partir du cookie de session existant — le navigateur ne voit jamais le token) pour tous les assets HTTP normaux decode-server. Preset "Demo ministack" surworkshops/new(pre-remplit le formulaire existant avec le depot de ce projet + le chemin du devcontainer de demo). dashboard/server.ts(nouveau, serveur Next custom — necessaire seulement pour le WebSocket "live" decode-server, qu'un Route Handler standard ne peut pas hijacker) : intercepte l'evenement Node'upgrade', lit le cookie de session directement dans les en-tetes (aucune API Next disponible hors contexte de requete), ouvre une connexion sortante avecwsversapi-server(Authorization: Bearerajoute cote serveur), relie les deux cotes.package.json:dev/startutilisent desormaistsx server.tsau lieu denext dev/next start.- Bug reel trouve en testant manuellement (pas par les tests
automatises) : la premiere version appelait
wss.handleUpgrade(qui envoie inconditionnellement un101au navigateur) avant de savoir si la connexion amont versapi-serverreussissait — un navigateur pouvait donc recevoir un101"reussi" alors que le canal restait mort en silence juste apres. Corrige : la connexion amont est ouverte et son evenementopenattendu avant d'appelerwss.handleUpgrade; un client WebSocket standard n'envoie de toute facon aucune trame avant d'avoir recu son propre101, rien n'est perdu a attendre. Egalement corrige au passage :socket.destroy()immediatement apressocket.write()peut tronquer l'ecriture avant qu'elle ne parte reellement (constate en pratique : le client ne recevait rien du tout sur le chemin d'erreur) —socket.end(data)a la place (ecrit puis ferme proprement une fois le buffer vide). - Limite assumee : verifie manuellement contre un Workshop de test
(Pod avec
podIPcontrole a la main, meme technique que les tests automatises) et un service de remplacement pourcode-server— pas encore contre un vraicode-serverreel ni un vraiWorkshopcomplet — desormais possible sans blocage d'auth git puisqueministack-workshopest un depot public (cf. section dediee plus haut), reste a le faire. Portcode-serverfixe a 8080 par convention (ATELIER_VSCODE_PORToverridable, surtout utile pour les tests), pas encore un champ du CRDWorkshop.
Terminal navigateur (ttyd) : premiere validation reelle du pont guest, six bugs
Objectif du lot : un terminal riche dans le navigateur pour chaque Workshop,
et reparer le bouton "Ouvrir VS Code" qui ne repondait plus. Les deux
partagent exactement le meme chemin (api-server -> portforward ->
net-proxy -> guest), c'est donc le lot qui a valide pour la premiere fois
ce pont contre de vrais services dans une vraie microVM — la section "UI
dashboard" ci-dessus annoncait cette verification comme restant a faire.
Elle a fait tomber six bugs reels, dont quatre bloquants qui se presentaient
tous au navigateur sous la meme forme indiscernable (1006, ou rien du tout).
crates/api-server:vscode_proxy_impldevientproxy_to_guest_port(port cible + prefixe d'URL en parametres) ;terminal.rs(nouveau) s'en sert pourttyd(port 7681,ATELIER_TERMINAL_PORT), qui embarque son propre client xterm.js.dashboard/lib/guest-proxy.ts(nouveau) : le Route Handler HTTP decode-serverextrait et partage avec le terminal.dashboard: bouton "Terminal ↗" + iframe sur la page de detail, meme conditioncanConnectque VS Code.
Les six bugs
- Regle
iptablesde retour de connexion manquante — la chaine dediee par TAP filtraitINPUTpar liste blanche de ports de destination, ce qui jetait silencieusement le retour (SYN-ACK) de toute connexion quenet-proxyinitie lui-meme vers le guest : le port de destination est alors un port ephemere cotenet-proxy, jamais dans la liste. Tout le mecanisme de port-forward (code-server,ttyd) restait bloque jusqu'auConnection timed out (os error 110)alors que le service ecoutait normalement dans le guest. Diagnostique sur infra reelle (beaucoup deSYN_SENTne passant jamaisESTABLISHEDdans/proc/net/tcpdu pod), corrige par un-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPTen premiere regle de la chaine. C'etait la cause du "rien ne repond jamais", pour VS Code comme pour le terminal. - Next.js fermait nos upgrades WebSocket — Next installe son propre
listener
upgradesur notre serveur custom des la premiere requete HTTP servie (getRequestHandler()appellesetupWebSocketHandler(), qui recupere le serveur viareq.socket.server,node_modules/next/dist/server/next.js). Son handler faitif (matchedOutput) return socket.end()(node_modules/next/dist/server/lib/router-server.js) — or nos chemins d'upgrade correspondent bien au Route Handler catch-allapp/workshops/[name]/(vscode|terminal)/[[...path]]/route.ts. Next fermait donc la socket du navigateur en parallele de notre handler. Deterministe, mais seulement apres la premiere requete HTTP du process : d'ou une flakiness apparente qui a coute cher (un clientwsnu contre un serveur fraichement redemarre reussissait 6/6, le meme test echouait 2/2 une fois une page chargee). Corrige en neutralisant l'auto-attachement et en deleguant aapp.getUpgradeHandler()(API publique) tout ce qui ne nous concerne pas — ce qui repare au passage le HMR de Next, que notre propre handler detruisait en boucle. ERR_CONTENT_DECODING_FAILED—fetch(undici) decompresse le corps selonContent-Encodingmais laisse cet en-tete, etContent-Length, intacts surresponse.headers; les relayer faisait tenter au navigateur de decompresser un corps deja en clair. Les deux sont desormais retires (code-serveretttydservent tous deux compresse).- Sous-protocole
ttynon relaye —ttydnegocie explicitementSec-WebSocket-Protocol: tty, et un navigateur ferme la connexion (1006) si le101ne confirme pas le sous-protocole demande. Relaye desormais dans les deux sens (handleProtocolssur uneWebSocketServernoServerpar requete, valeur reelle negociee par l'amont). - En-tete
Hostcode en dur sur127.0.0.1:8080— fonctionnait par coincidence pourcode-server, qui ecoute sur ce port, mais cassait silencieusement le pont versttyd. Reflete maintenant le port reellement cible. Workshop.spec.resourcesjamais cable vers la VM — trouve en creusant pourquoicode-serverrestait injoignable :vm-supervisorutilisait toujours 256 MiB / 1 vCPU quoi que declare le Workshop. Corrige danscrates/controller/src/reconcile.rs(memory_to_mib/cpu_to_vcpu_count+ 7 tests unitaires, commit57109e9).
Ajoute par-dessus, sur demande explicite ("il faut que le front soit immune a
ce genre de soucis") : refresh token OAuth2 transparent. Le JWT Kanidm
expire a 900s, et une session terminal/VS Code ouverte plus longtemps se
mettait a echouer en boucle silencieuse — encore un 1006, indiscernable
d'un vrai probleme reseau, et une vraie source de confusion pendant le debug.
getAccessToken() echange desormais le refresh token cote serveur des que
l'access token approche de son expiration, et SessionKeepAlive (monte dans
TopNav) ping /api/auth/refresh toutes les 4 min avec rattrapage sur
visibilitychange (les navigateurs bridant les timers d'un onglet en
arriere-plan). Les deux cookies restent httpOnly : aucun token n'atteint le
JS du navigateur.
Verification reelle (vrai navigateur, pas un client ws)
Le manque de test navigateur est precisement ce qui avait laisse passer le
bug 2 : un client ws Node.js reussissait, l'<iframe> du dashboard non.
Verifie avec Chromium pilote (cookies de session reels, vrai client ttyd) :
- Terminal connecte, prompt du shell recu, commande interactive executee
dans la microVM (
echo,uname -sr-> Linux 5.10.223,nproc-> 2,free -m-> ~2 GB, ce qui confirme au passage le bug 6 corrige : plus les 256 MiB / 1 vCPU codes en dur). code-server:.monaco-workbenchcharge, page Welcome affichee.- HMR de Next : une seule ouverture, zero erreur, plus de reconnexion en boucle.
Reste ouvert : quelques 404 en console cote code-server (workbench
fonctionnel malgre tout), non investigues. Le port de ttyd est fixe par
convention (ATELIER_TERMINAL_PORT overridable), pas encore un champ du CRD
Workshop — meme situation que ATELIER_VSCODE_PORT.
LLM Proxy : LiteLLM global (DeepSeek low-cost + Anthropic premium)
Objectif : reduire le cout d'inference de Claude Code (et de tout autre
agent) tournant dans les devcontainers en routant ses appels Anthropic
Messages API vers DeepSeek par defaut, avec un alias explicite vers le vrai
Anthropic Sonnet pour les taches complexes. Architecture proposee par
l'utilisateur, adaptee aux invariants existants (la microVM ne parle
jamais directement a un service, seulement a net-proxy ; ici, service
global du cluster, pas un sidecar par pod, decision explicite de
l'utilisateur — contrairement au sidecar simulator de mcp-gateway).
- Fait verifie, pas suppose (documentation officielle Claude Code et
LiteLLM) : Claude Code ne parle que le format Anthropic Messages API
(
/v1/messages), aucun support multi-fournisseur natif — un vrai proxy traducteur est necessaire pour DeepSeek/OpenAI/Grok.ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKENsont les variables standard pour pointer vers n'importe quel gateway compatible. LiteLLM (MIT,ghcr.io/berriai/litellm) est l'outil etabli de l'ecosysteme pour cette traduction — meme logique de reuse queministackpour AWS, pas de traducteur maison. deploy/dev/llm-proxy/(nouveau, meme niveau qu'deploy/dev/openbao/) : ConfigMap (config.yaml, lemodel_list—claude-3-5-sonnet-20241022etdeepseek-dev->deepseek/deepseek-chat,sonnet-premium-> le vraianthropic/claude-3-5-sonnet-20241022), Secret (DEEPSEEK_API_KEY,ANTHROPIC_API_KEY,LITELLM_MASTER_KEY), Deployment+Service (ghcr.io/berriai/litellm:main-stable, tag epingle).crates/net-proxy/src/internal.rs: 4e alias fixellm-proxy(ATELIER_LLM_PROXY_ADDR), meme mecanisme qu'identity-proxy/mcp-gateway/registry— toujours actif des que configure.crates/controller:ReconcileCtx.llm_proxy_addr/llm_proxy_auth_token(Option, meme convention "desactive si absent" qu'openbao). Cable inconditionnellement sur le conteneurnet-proxydu pod parent (pas gate parWorkshop.spec.tools, contrairement aenable_simulator— decision explicite : service global, toujours present). Le Jobimage-builderrecoitATELIER_LLM_PROXY_AUTH_TOKEN(necessaire au moment du build).crates/image-builder: extension deinject_net_proxy_config(meme fonction deja verifiee pourHTTP_PROXY) — ajouteANTHROPIC_BASE_URL=http://llm-proxy/ANTHROPIC_AUTH_TOKEN=<jeton>/ANTHROPIC_API_KEY=(vide, desactive toute cle locale) dans/etc/environmentquand le service est configure.- Bug reel trouve en testant, corrige :
net-proxyrelayait la requete recue de la VM verbatim (forme absolue,GET http://llm-proxy/... HTTP/1.1) vers les alias internes — fonctionnait avecmcp-gateway/identity-proxy(axum/hyper, tolerants aux deux formes), maisuvicorn(serveur ASGI de LiteLLM) ne sait pas parser une cible en forme absolue et repondait404sur tout. Corrige parhttp::to_origin_form(nouveau) : reecrit la ligne de requete en forme origine (methode + chemin, en-tetes inchanges) avant de relayer vers un alias interne/simulator— verbatim inchange pouridentity-proxyet l'egress normal, qui en ont explicitement besoin (voir leur propre commentaire). Aurait pu affecter n'importe quel futur alias non base sur axum/hyper, pas seulementllm-proxy. - Verifie reellement contre kind, sans mock : Deployment
Running(probes de sante LiteLLM passees sans le bug connuBerriAI/litellm#8795),curldirect sur le Service —/health/readiness200,/v1/modelsliste les 3 alias configures,POST /v1/messagesavecmodel: deepseek-devtraduit et route reellement jusqu'a DeepSeek (401 reel de DeepSeek avec une cle de test factice — preuve que la traduction de protocole et le routage fonctionnent de bout en bout, pas seulement que LiteLLM demarre). Puis a travers l'aliasnet-proxyreel (--proxy) : memes resultats, confirmant le cablage complet cote atelier. - Limites assumees (documentees dans
deploy/dev/llm-proxy/README.md) : un seul jeton partage par tous les Workshops (pas de cles virtuelles LiteLLM par Workshop dans ce lot) ; pas de prompt caching explicitement configure (important pour tenir un budget bas, a activer separement). - Reste a faire : verification de bout en bout avec une vraie cle
DeepSeek (non disponible dans cet environnement de developpement). Le
blocage "aucun
Workshopcomplet n'a ete boote avecministack-workshop/atelier-workspace" est desormais leve — voir section suivante.
net-proxy en passerelle transparente : plus besoin de HTTP_PROXY cote guest
En bootant pour la premiere fois un Workshop complet avec le devcontainer
de demo public (ministack-workshop/atelier-workspace : base
mcr.microsoft.com, features docker-in-docker+claude-code via
ghcr.io, systemd/code-server installes par apt-get), un vrai bug
racine a ete trouve, plus profond qu'un simple oubli de domaine dans
Workshop.spec.egress_allowlist :
- Symptome initial :
archive.ubuntu.comrefuse d'etre resolu (Temporary failure resolving 'archive.ubuntu.com') alors qu'il figure bien dans l'allowlist, et aucune trace (ni autorisee ni refusee) de cette requete dans les logsnet-proxy— la requete n'atteint jamaisnet-proxydu tout. - Cause racine, confirmee en rejouant le build avec
RUST_LOG=debug(console guest complete, drainee viatracing::debug!) : l'etapeRUN apt-getd'unDockerfile, executee parenvbuilderdans la microVM "builder", n'herite jamais deHTTP_PROXY/HTTPS_PROXY— contrairement au clone git et au push registre (crates/builder-vm-init), qui eux passent bien par ces variables et fonctionnent. Rustiner au cas par cas (ARG HTTP_PROXYdans chaqueDockerfile) aurait ete fragile et jamais garanti pour un devcontainer arbitraire fourni par l'utilisateur d'un Workshop. - Decision (discutee en session) : plutot que de continuer a exiger
qu'un outil a l'interieur du guest sache configurer un proxy explicite,
net-proxydevient une passerelle reseau transparente — interception au niveau paquet (iptables REDIRECT), lecture duHost:(HTTP) ou du SNI duClientHello(HTTPS, sans jamais dechiffrer — pas de MITM, refuse explicitement en session, la validation TLS de bout en bout reste intacte cote guest). Voirdocs/architecture/network-security.mdpour le design complet (deja mis a jour, section correspondante). - Composants touches :
crates/firecracker/src/network.rs(NetworkSetup::enable_transparent_gateway, nouvelle chainenatdediee,restrict_to_net_proxyconserve pour compatibilite) ;crates/net-proxy(deux nouveaux listeners — port HTTP transparent qui reutilisehandle_connectiontel quel, port TLS transparent avec le nouveau moduletls_sni.rs, aucune dependance TLS ajoutee) ;crates/vm-supervisor/crates/image-builder(appellent desormaisenable_transparent_gatewayau lieu derestrict_to_net_proxy/rien du tout) ;crates/builder-vm-init(une ligneip route add default via <host_ip>— la VM de l'agent avait deja cette route gratuitement via le parametre kernelip=, seule la VM "builder", notre propre bootstrap, ne l'avait pas) ;crates/controller/src/reconcile.rs(cablage des deux nouveaux ports, memes valeurs cotenet-proxyet cotevm-supervisor/image-builder) ; Dockerfilesimage-builder(iptablesajoute, manquant jusqu'ici — seuliproute2y etait). - Aucune nouvelle capacite Kubernetes accordee : la pose des regles
reste faite par le composant deja
NET_ADMIN(vm-supervisor/image-builder,firecracker_security_context()),net-proxycontinue de tourner sans capacite elevee — meme decoupage qu'avant, juste etendu. sysctl net.ipv4.ip_forwardtoujours a 0 :REDIRECTreecrit la destination du paquet vers l'interface d'entree avant la decision de routage, donc le paquet devient une livraison locale (cheminINPUT), jamais un transitFORWARD— la preoccupation documentee dansnetwork-security.md(sysctl global au netns, partage entre VMs concurrentes du meme pod) reste donc valide et n'est pas contournee.- Verifie reellement, sans mock, Dockerfile
atelier-workspacenon modifie : cargo test -p atelier-net-proxy(49 tests, dont 5 nouveaux pourtls_sni::parse_sniet 3 nouveaux tests d'integration transparents dansproxy.rs, sockets loopback reelles).cargo test -p atelier-firecracker --test networksousunshare --net --map-root-user(vraiCAP_NET_ADMIN, sanssudo) : nouveau testenables_transparent_redirect_without_touching_forward, contenu exact des reglesnatverifie viaiptables -t nat -S, et confirmation explicite queFORWARDne gagne jamais de regleACCEPT.- Bout en bout contre kind, en reconstruisant
atelier-net-proxy:dev/atelier-image-builder:dev/atelier-vm-supervisor:dev: rejouer le meme Workshop qui echouait avant (allowlist "dev" complete, voirdashboard/lib/dev-allowlist.ts) reussit desormais integralement —apt-get install systemd(HTTP transparent,archive.ubuntu.com/security.ubuntu.com), la feature Node.js (deb.nodesource.com, HTTPS transparent/SNI),docker-in-docker(packages.microsoft.com), le tout sans une seule variableHTTP_PROXYnecessaire cote guest.status.imageDigestpublie, pod parent4/4 Running, microVM agent demarree avec l'image construite (vm-supervisor: "microVM running"). - Allowlist "dev" enrichie au passage (
dashboard/lib/dev-allowlist.ts, prerempli dans le formulaire de creation de Workshop) : au-dela degithub.com, un devcontainer standard a aussi besoin demcr.microsoft.com/*.data.mcr.microsoft.com(image de base),ghcr.io/pkg-containers.githubusercontent.com(features),archive.ubuntu.com/security.ubuntu.com/ports.ubuntu.com/deb.debian.org(apt),download.docker.com/get.docker.com/registry-1.docker.io/auth.docker.io/production.cloudflare.docker.com(Docker Hub/Engine),registry.npmjs.org/pypi.org/files.pythonhosted.org(gestionnaires de paquets de langage),deb.nodesource.com(Node.js) etpackages.microsoft.com(docker-in-docker) — chacun trouve un par un en rejouant le build reel et en lisant les refus dans les logsnet-proxy. - Limite assumee : le sniffing SNI ne fonctionne que pour du TLS
classique avec SNI en clair (immense majorite du trafic reel) — ESNI/ECH
(SNI chiffre) ne serait pas filtrable par nom, non bloquant pour ce lot.
HTTP_PROXY/HTTPS_PROXYrestent injectes pour la VM builder en filet de securite dans un premier temps (double mecanisme), a retirer dans un lot ulterieur une fois confirme inutile.
Premiere execution complete du PM autonome : sept bugs, tous invisibles sans faire tourner la chaine entiere
Session du 2026-08-30. Objectif initial : derouler un vrai scenario de developpement (API de todo-list + UI, specifiee et implementee par le PM) et verifier au passage que les Workshops disposent bien d'un Claude Code adosse a LiteLLM/DeepSeek.
Aucun de ces sept defauts n'est detectable par un test unitaire ou par la
lecture du code : ils n'apparaissent qu'en faisant tourner, ensemble et pour
de vrai, controller + image-builder + microVM Firecracker + net-proxy
+ api-server/MCP + pm-engine. Le graphe LangGraph complet
(AnalyzeIssue -> ... -> AwaitHitlApproval) n'avait jamais ete execute de
bout en bout jusqu'ici — services/pm-engine/README.md le signalait
explicitement.
1. Port du serveur metadata jete par la chaine iptables (cause racine)
Le bug le plus couteux de la session, et le plus trompeur. Le port du
serveur metadata de net-proxy (3132) n'etait pas transmis a
NetworkSetup::enable_transparent_gateway : il n'etait donc jamais accepte
par la chaine filter dediee, dont la derniere regle est un DROP. Tout le
trafic guest -> 169.254.0.1:3132 etait silencieusement jete.
Consequence : atelier-fetch-session-auth.sh et
atelier-fetch-ssh-authorized-key.sh (depot atelier-workspace) ne
pouvaient JAMAIS aboutir. Ils epuisaient leur budget de retry puis
basculaient sur leur repli — mot de passe aleatoire connu de personne, pas
d'authorized_keys. Resultat : ttyd/code-server repondaient 401 et
sshd refusait toute connexion (Permission denied (publickey)), alors
meme que le Workshop atteignait la phase Running, que net-proxy servait
deja les bonnes valeurs et qu'OpenBao contenait bien le bon secret.
Deux details ont rendu ce bug tres difficile a lire :
DROP(et nonREJECT) : chaque tentative expirait sur le timeout decurlau lieu d'echouer franchement. Le symptome ressemblait donc a une lenteur de boot, pas a un blocage reseau.- Les deux scripts sortent en
exit 0que le fetch ait reussi ou echoue :systemctlaffichaitOK Finishedsur un repli, indiscernable d'un succes.
Diagnostic initial errone, a ne pas refaire : la premiere conclusion a
ete "le lien TAP met plusieurs minutes a monter, le budget de retry de 60s
est trop court". Deux commits ont ete pousses sur atelier-workspace pour
allonger ce budget (jusqu'a 20 min) — ils ne faisaient que retarder le
meme echec, ce qui a produit des boots apparents de 3, puis 12, puis 29+
minutes et a donne l'illusion d'une variance liee a la charge machine. Ces
commits ont ete annules (c2f6b41) une fois la vraie cause trouvee.
Ce qui a permis de trancher : mettre RUST_LOG=atelier_firecracker=debug
sur le conteneur vm-supervisor pour capturer la console serie du guest
(drain_console_pipes). C'est le seul moyen d'observer un guest dont ni
ttyd ni sshd ne sont accessibles — a reutiliser systematiquement pour
tout probleme de boot de microVM.
Mesure apres correction : Workshop Running en ~60 s (contre 12 a 29+
minutes, ou jamais), sshd accepte la cle du Workshop, ttyd repond 200.
2. OPENBAO_ADDR injecte dans les pods : adresse de l'hote, pas du cluster
OpenBaoConfig.addr servait a la fois aux appels du controller et a la
valeur injectee dans les pods Workshop. En developpement, le controller
tourne hors cluster et pointe sur un port-forward (127.0.0.1:8200) :
net-proxy, dans son pod, recevait donc 127.0.0.1:8200 et son login
Kubernetes-auth echouait indefiniment. Champ pod_addr distinct introduit
(ATELIER_OPENBAO_POD_ADDR), egal a addr par defaut — sans effet en
production ou le controller tourne dans le cluster.
Classe de bug a surveiller : toute valeur qu'un composant utilise pour
lui-meme ET propage a un pod doit etre dedoublee des lors que ce composant
peut tourner hors cluster. ATELIER_LLM_PROXY_ADDR souffre exactement du
meme probleme aujourd'hui (voir "Points ouverts").
3. Injection de commande shell dans DelegateToClaudeCode
Le prompt etait passe au shell du Workshop via json.dumps(prompt), qui
produit une chaine entre guillemets doubles — ou bash interprete
encore les backticks, $(...) et $VAR. Or ce prompt contient du texte
genere par un LLM a partir du corps du ticket, c'est-a-dire une entree
non fiable, et des chemins entoures de backticks.
Verifie empiriquement : avec json.dumps, bash executait reellement
api/ et web/ comme des commandes (api/: not found dans stderr) et
Claude Code recevait un prompt tronque ("Modifie et "). Au-dela du
dysfonctionnement, c'est une injection de commande dans la microVM depuis le
contenu d'un ticket.
Corrige avec shlex.quote (guillemets simples, rien n'est interprete).
services/pm-engine/tests/test_delegate_command_quoting.py execute la
commande produite dans un vrai bash, avec un faux claude sur le
PATH qui recopie son argument, et verifie qu'il arrive intact.
4. Workshops crees par le PM avec une allowlist egress VIDE
create_workshop (MCP) laisse egress_allowlist vide par defaut et
ProvisionWorkshop ne la renseignait pas : les Workshops du PM naissaient
donc incapables de construire leur image (net-proxy bloque l'acces au
registre de l'image de base, aux features, a apt/npm). Desormais fournie par
le PM (PM_ENGINE_WORKSHOP_EGRESS_ALLOWLIST) puisqu'elle depend entierement
du devcontainer du depot cible.
5. Aucune attente de disponibilite entre provisioning et delegation
create_workshop est asynchrone (il cree la ressource Kubernetes et rend la
main), mais le Workshop met ensuite ~1 min a construire son image puis a
booter sa microVM. DelegateToClaudeCode partait aussitot et echouait
toujours ("le Workshop n'a pas de pod parent actif"). ProvisionWorkshop
sonde desormais get_workshop_status jusqu'a Running.
Piege rencontre en corrigeant : une premiere version tenait UNE session
MCP ouverte pendant toute l'attente. Or atelier_mcp_session fige son jeton
OIDC a l'ouverture (invariant documente dans pm_engine.mcp_client) : au
bout de quelques minutes, l'API repondait ExpiredSignature. Chaque sondage
rouvre donc sa propre session courte.
6. Claude Code refuse son propre modele par defaut derriere LiteLLM
Claude Code choisit un modele par defaut dont le nom evolue a chaque version
du CLI (claude-opus-4-8[1m] constate ici). LiteLLM ne le connaissant pas,
Claude Code sortait en erreur sans ecrire le moindre fichier — et le PM
ouvrait donc des PR parfaitement vides (0 fichier modifie), sans que rien
dans le graphe ne signale l'anomalie.
Deux correctifs complementaires :
deploy/dev/llm-proxy/config.yamlgagne unmodel_name: "*"en repli (place APRES les alias explicites, LiteLLM privilegiant la correspondance exacte). Maintenir un alias par version du CLI serait a refaire indefiniment.- Le PM epingle desormais le modele (
--model,PM_ENGINE_CLAUDE_CODE_MODEL), ce qui releve de toute facon de lui (cout, reproductibilite) et non du CLI.
7. PlanParallelTasks retombait toujours sur une tache unique
Le modele encadre sa reponse JSON dans un bloc de code markdown
(```json) malgre la consigne "UNIQUEMENT du JSON", donc
json.loads echouait et le repli "une seule tache" se declenchait
systematiquement — annulant tout le decoupage en sous-taches paralleles, qui
est pourtant le coeur du noeud. Les delimiteurs sont retires avant parsing.
Ce qui a ete valide reellement
- Claude Code + LiteLLM/DeepSeek dans une microVM :
claude --model claude-3-5-sonnet-20241022 --print 'Cree hello.txt contenant BONJOUR'execute par SSH dans une vraie microVM Firecracker a bien cree le fichier avec exactementBONJOUR. Chaine complete verifiee : Claude Code ->ANTHROPIC_BASE_URL=http://llm-proxy-> aliasnet-proxy-> LiteLLM -> DeepSeek. exec_in_workshop(MCP), le pont PM -> microVM jamais teste jusqu'ici, execute reellement des commandes dans le guest et en remonte la sortie.- Le graphe LangGraph complet traverse tous ses noeuds sur un vrai
ticket Forgejo : analyse DeepSeek, decoupage en 3 sous-taches paralleles,
3 branches creees, 3 microVM Firecracker provisionnees, delegation, tests,
boucle de correction, PR ouverte, suspension des Workshops
(
SuspendWhileWaitingReviewlibere bien les pods parents) et revue HITL enregistree danspm_reviews.
Ce qui n'est pas atteint
La PR produite est vide. Le correctif du modele epingle (point 6) n'a
pas ete valide par un run complet : c'est l'etape suivante evidente pour
boucler la demo. Note connexe : rien dans le graphe ne verifie qu'une PR
contient effectivement des changements — un garde-fou (OpenPullRequest
echoue ou avertit si le diff est vide) eviterait de reproduire une journee
de diagnostic pour un symptome aussi silencieux.
Environnement de dev : ce qu'il faut savoir avant de recommencer
- Le registre OCI n'est pas cree par
local-stack.sh: le script faitdocker start atelier-registry-dev || true, ce qui echoue silencieusement si le conteneur n'a jamais existe. Le creer une fois :docker run -d --name atelier-registry-dev -p 5000:5000 registry:2puisdocker network connect kind atelier-registry-dev --alias atelier-registry-dev. kvm-device-pluginet Redis ne sont pas deployes parlocal-stack.sh(voir leurs README respectifs). Sans le premier, aucun Workshop ne peut booter.- Le controller lance hors cluster ne joint pas les pods : ajouter une
route
sudo ip route add 10.244.0.0/24 via <IP du node kind>, sans quoiguest_probene confirme jamais la phaseRunning. env.shsurvit a la recreation du cluster kind : le jeton admin Forgejo qu'il contient devient invalide en silence (l'utilisateur n'existe plus). Le supprimer avant de relancerlocal-stack.sh.- Une microVM ne peut sortir que sur 80 et 443 (le reste est
DROP). Pour qu'un guest clone depuis le Forgejo interne, exposer son Service sur le port 80 et utiliser son nom DNS cluster — le mecanisme d'alias git (ATELIER_GIT_HOST_SERVICE) sert a injecter des credentials, pas a ouvrir un port. - La feature devcontainer
nodetelecharge depuisnodejs.orgetiojs.org: a ajouter a l'allowlist (absents deDEV_EGRESS_ALLOWLIST).
Journal d'Avancement du Plan d'Action Global (Specs 00 à 05)
Section dédiée au suivi jalon par jalon de l'implémentation du plan d'action global (
docs/specs/PLAN-ACTION-GLOBAL.md). Chaque agent complétant une tâche doit ajouter une entrée datée avec sa preuve empirique (tests réels sans mocks, zéros warnings clippy/fmt).
Matrice d'Avancement des Jalons
| Jalon | Intitulé du Jalon | Statut | Progrès | Dernière mise à jour |
|---|---|---|---|---|
| M1 | Socle PostgreSQL, Découplage OIDC Universel & Nettoyage CRD | ⏳ À démarrer | 0/14 tâches | 2026-08-23 |
| M2 | Stockage S3 Hybride & Git 100% HTTPS | ⏳ En attente M1 | 0/7 tâches | 2026-08-23 |
| M3 | Passerelle d'Inférence IA LiteLLM & Budgets Stricts | ⏳ En attente M1 | 0/4 tâches | 2026-08-23 |
| M4 | Serveur MCP Externe Embarqué dans l'API Server | ⏳ En attente M1 | 0/5 tâches | 2026-08-23 |
| M5 | Moteur DevFactory & Project Manager Autonome (LangGraph) | ✅ Code complet | 15/15 tâches | 2026-08-24 |
| M6 | Chart Helm Monolithique & Documentation Administrateur | ⏳ En attente M1-M5 | 0/10 tâches | 2026-08-23 |
Entrées d'Historique par Tâche
2026-08-23 : Exécution non-root (vscode) et répertoire de travail (/workspaces/atelier-workspace) dans ttyd et code-server
- Problème :
ttydetcode-serverdémarraient sousrootdans/à l'intérieur de la microVM Firecracker. - Causes racines identifiées :
- Les services systemd du devcontainer (
atelier-terminal.service,atelier-code-server.service,atelier-ministack.service) ne déclaraient pasUser=vscode,Group=vscode,HOME=/home/vscode,USER=vscode. USER vscodeplacé au milieu duDockerfilebloquait les devcontainer features (docker-in-docker,claude-code) lors du buildenvbuilderpar manque de permissions root (apt-get).- Si
/workspaces/atelier-workspacen'était pas expressément matérialisé par un fichier (ex:README.mdou.keep), l'arborescence n'était pas incluse dans le rootfs ext4, ce qui faisait échouerWorkingDirectory=/workspaces/...au boot systemd (CHDIR failure). - Corrections apportées :
PhilippeVienne/atelier-workspace: configuration des services systemd (User=vscode,Group=vscode,HOME=/home/vscode,USER=vscode,ExecStart=/bin/bash -c "mkdir -p /workspaces/atelier-workspace && cd /workspaces/atelier-workspace && exec ...").crates/image-builder: ajout de l'étapeensure_workspace_directorygarantissant l'existence et les permissions1000:1000(vscode:vscode) de/workspaces/<repo>avant l'empaquetage ext4.- Preuve empirique : Test réel WebSocket contre
ttydsur microVM Firecracker active (ws-user-verify-parent) :Validation workspace complète :vscode ➜ /workspaces/atelier-workspace $ whoami; pwd; id vscode /workspaces/atelier-workspace uid=1000(vscode) gid=1000(vscode) groups=1000(vscode),997(docker)cargo fmt --all -- --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace(100% verts).### [YYYY-MM-DD HH:MM] Jalon X - Tâche X.Y.Z : <Titre de la tâche> - **Composant impacté** : `crates/...` ou `services/...` - **Modifications réalisées** : Résumé précis des structs, endpoints ou tables modifiés. - **Preuve empirique / Test exécuté** : Commande exacte exécutée et extrait de sortie attestant du succès (zéro mock). - **Statut** : ✅ Validé / Prêt pour la tâche suivante.
[2026-08-23 00:00] Jalon M1 - Tâches 1.1.1, 1.1.2, 1.1.3, 1.3.2, 1.3.3 : Nettoyage CRD Kanidm & budget LLM par Workshop
- Composant impacté :
crates/common/src/crd.rs,crates/controller(kanidm.rssupprimé,lib.rs,reconcile.rs,Cargo.toml),crates/controller/tests/reconcile.rs,crds/workshop.yaml. - Modifications réalisées :
- Retrait de
WorkshopStatus.kanidm_entity_id: ce champ n'était en réalité jamais lu ailleurs que pour être reporté d'un reconcile à l'autre (resolve_kanidm_entity/carry_forward_status), aucune injection réelle dans le pod ou la microVM ne s'appuyait dessus. - Suppression complète du module
crates/controller/src/kanidm.rs(provisioning de service account Kanidm), du champReconcileCtx.kanidm, de la dépendancekanidm_client, et de tout le filkanidm_entity_idà traversapply/ensure_suspended/ensure_image_build_job/ensure_parent_pod/carry_forward_status. - Ajout de
WorkshopResources.max_llm_budget_usd: Option<f64>(#[serde(rename_all = "camelCase")]ajouté àWorkshopResources, absent jusqu'ici) — champ pas encore consommé (réservé au Jalon M3, provisioning LiteLLM). - Régénération de
crds/workshop.yaml(cargo run -p atelier-controller --bin crdgen) et application réelle sur le cluster kind (kind-atelier-dev) :kanidmEntityIddisparu du schéma,maxLlmBudgetUsdprésent en camelCase. - Ajout de deux tests unitaires (
crates/common/src/crd.rs::tests) :generate_crd(génération du manifest sans panique, absence dekanidmEntityId, présence demaxLlmBudgetUsd) etworkshop_roundtrip_json_and_yaml(round-tripserde_json/serde_yamlsur unWorkshopcomplet). - Preuve empirique / Test exécuté :
kubectl apply -f crds/workshop.yaml # customresourcedefinition.apiextensions.k8s.io/workshops.atelier.dev configured cargo test -p atelier-common # 2 passed (generate_crd, workshop_roundtrip_json_and_yaml) cargo test -p atelier-controller # 7 unit + 4 integration passed contre le vrai cluster kind + vrai OpenBao (apply_provisions_openbao_role_when_configured inclus) cargo test --workspace # 100% vert cargo clippy --workspace --all-targets -- -D warnings # 0 warning cargo fmt --all -- --check # propre - Restant sur M1 (non traité ici, volontairement) : 1.3.1 (ajout
sqlx), 1.3.4 (generate_session_authOpenBao) et 1.3.5 (injection du mot de passe de session danscode-server/ttyd) nécessitent (a) un PostgreSQL de dev provisionné (aucun n'existe encore dansdeploy/dev/) et (b) une décision d'architecture : le pod parent (vm-supervisor) boote aujourd'hui le PID 1 du devcontainer tel quel, sans mécanisme pour transmettre une valeur par-Workshop au guest au boot (contrairement à la microVM "builder") — l'image rootfs est un cache partagé entre Workshops utilisant le même devcontainer, donc injecter le secret au moment du build (commeANTHROPIC_AUTH_TOKEN) le partagerait entre Workshops. Question posée à l'utilisateur avant de concevoir ce canal. - Statut : ✅ Validé pour les 5 tâches listées / le reste de M1 reste
[ ].
[2026-08-23 21:30] Jalon M1 - Tâches 1.3.4, 1.3.5 : mot de passe de session via endpoint metadata net-proxy
- Composant impacté :
crates/controller/src/openbao.rs(+Cargo.toml),crates/controller/src/reconcile.rs,crates/net-proxy/src/session_auth.rs(nouveau),crates/net-proxy/src/metadata.rs(nouveau),crates/net-proxy/src/main.rs,crates/net-proxy/tests/session_auth.rs(nouveau),crates/common/src/telemetry.rs(commentaire Kanidm résiduel corrigé). - Décision d'architecture (validée avec l'utilisateur avant implémentation) : le pod parent boote le PID 1 du devcontainer tel quel, sans canal pour transmettre une valeur par-Workshop au guest au démarrage, et le rootfs est un cache partagé entre Workshops utilisant le même devcontainer (injecter au moment du build partagerait le secret). Option retenue parmi 3 proposées : un endpoint metadata HTTP exposé par
net-proxysur l'adresse link-local du TAP (169.254.0.1, déjà l'unique route réseau du guest), plutôt que des kernel boot args. - Modifications réalisées :
openbao::ensure_session_auth(config, workshop_name): get-or-create idempotent d'un mot de passe aléatoire de 32 caractères soussecret/data/workshops/<name>/session_auth(champpassword), avec le token d'administration du controller. Appelé dansensure_parent_pod, best-effort (non bloquant).crates/net-proxy/src/session_auth.rs: cache en mémoire rafraîchi toutes les 5 min viaatelier_common::OpenBaoClient(même schéma quecrates/identity-proxy/src/secrets.rs) —net-proxyrelit lui-même le secret avec son propre login Kubernetes-auth (le rôleworkshop-<name>couvre déjà ce chemin), le controller ne transmet donc jamais le mot de passe en clair dans la spec du pod (pas dekubectl get pod -o yamlqui le révèle).crates/net-proxy/src/metadata.rs: routeur axumGET /session-auth, lié à0.0.0.0:3132(par défaut) comme le reste des ports côté guest —503tant que le secret n'est pas encore lu,200+ mot de passe en texte brut une fois disponible.reconcile.rs: ajout des envOPENBAO_ADDR/ATELIER_WORKSHOP_NAMEau conteneurnet-proxy.net-proxy/src/main.rs: dégrade proprement (avertissement, pas d'échec du process) siOPENBAO_ADDRest présent maisATELIER_WORKSHOP_NAMEabsent — évite qu'une config incohérente de cette fonctionnalité annexe fasse tomber toutnet-proxy(egress/DNS).- Preuve empirique / Test exécuté :
cargo test -p atelier-net-proxy --test session_auth # 1 passed : login Kubernetes-auth REEL (vrai ServiceAccount, vrai token projete via `kubectl create token`) + lecture du secret ecrit avec le token root, contre le vrai OpenBao (deploy/dev/openbao) cargo test -p atelier-net-proxy # 51 unitaires + 1 integration, tous verts (dont les 2 nouveaux tests de crate::metadata) cargo test -p atelier-controller --test reconcile # 4 integration passed contre le vrai cluster kind (apply_provisions_openbao_role_when_configured inclus) cargo test --workspace / clippy -D warnings / fmt --check # 100% vert - Incident rencontré et résolu en cours de route : un process
atelier-controllerdéjà lancé manuellement (build pré-nettoyage Kanidm) tournait en direct sur le cluster kind partagé et réconciliait les Workshops créés par les tests avec son ancien code, entrant en conflit 422 ("pod updates may not change fields...") avec le nouveau code du test. Résolu (accord utilisateur) en arrêtant puis relançant ce process avec le binaire à jour. - Reste, hors de ce dépôt : le devcontainer (repo séparé
PhilippeVienne/atelier-workspace) doit faire consommerGET http://169.254.0.1:3132/session-authpar ses servicesttyd/code-serverau démarrage (--credential atelier:<password>/--auth password) — ce dépôt se limite à rendre le secret disponible, pas à le pousser dans ces services. - Reste sur M1 : 1.2.6 (injection du header
Authorization: Basiccôtéapi-serverpour/vscode/*//terminal/*) nécessite de trancher commentapi-server(composant cluster-wide, pas un pod par Workshop) obtient un accès OpenBao scopé en lecture àsecret/data/workshops/*/session_auth— question à poser avant implémentation. 1.3.1/1.2.1/1.2.7-1.2.9/1.3.6/1.3.7 (sqlx/PostgreSQL) restent bloqués sur l'absence de PostgreSQL de dev. - Statut : ✅ Validé pour 1.3.4 et 1.3.5.
[2026-08-23 22:00] Jalon M1 - Tâche 1.0.1 : instance PostgreSQL de développement
- Composant impacté :
deploy/dev/postgres/dev-pod.yaml(nouveau),deploy/dev/postgres/README.md(nouveau). - Contexte : le Jalon M1 rend
DATABASE_URLobligatoire pourapi-server/controller(tâches 1.2.1, 1.2.7-1.2.9, 1.3.1, 1.3.6, 1.3.7), mais aucune tâche n'existait pour provisionner un PostgreSQL de dev — la seule instance prévue dans le plan est celle du Jalon M6 (Helm de production). Signalé par l'utilisateur, corrigé en ajoutant cette tâche 1.0.1. - Modifications réalisées : pod PostgreSQL 16 (image
pgvector/pgvector:pg16, même image que prévue pour le Jalon M6 afin d'éviter un changement d'image plus tard) déployé dans le cluster kind, sans persistance (emptyDir, même convention quedeploy/dev/kanidm/deploy/dev/openbao). Deux bases :atelier_apiserver(créée automatiquement viaPOSTGRES_DB) etatelier_controller(créée à la main, voir README). - Preuve empirique / Test exécuté :
kubectl apply -f deploy/dev/postgres/dev-pod.yaml kubectl wait --for=condition=Ready pod/atelier-postgres-dev --timeout=90s # pod/atelier-postgres-dev condition met kubectl exec atelier-postgres-dev -- psql -U atelier_admin -d postgres -c 'CREATE DATABASE atelier_controller;' # CREATE DATABASE kubectl port-forward svc/atelier-postgres-dev 5433:5432 & kubectl exec atelier-postgres-dev -- psql -U atelier_admin -d atelier_apiserver -c '\l' # liste bien atelier_apiserver + atelier_controller bash -c "cat < /dev/null > /dev/tcp/127.0.0.1/5433" && echo "port 5433 joignable" # port 5433 joignable - Statut : ✅ Validé. Débloque le démarrage des tâches
sqlx/PostgreSQL de M1, non encore attaquées.
[2026-08-23 22:30] Jalon M2 - Tâches 2.0.1 & 2.0.2 : instances S3 (RustFS) et Forgejo (Git HTTPS) de développement
- Composant impacté :
deploy/dev/s3/dev-pod.yaml(nouveau),deploy/dev/s3/README.md(nouveau),deploy/dev/forgejo/dev-pod.yaml(nouveau),deploy/dev/forgejo/README.md(nouveau). - Contexte : Pour respecter l'éthos du projet « Vérification Empirique sans Mocks » lors du développement du Jalon M2 (client S3 dans
api-serveret injection de token Git HTTPS dansidentity-proxy), deux composants d'infrastructure locale de test étaient requis dans le cluster Kind : un serveur S3 compatible S3 standard (RustFS) et une forge Git 100% HTTPS (Forgejo). - Modifications réalisées :
- Tâche 2.0.1 (S3 RustFS) : Pod
atelier-s3-devdéployé dans Kind (image officiellerustfs/rustfs:latestavecemptyDir, 100% Rust et conforme à l'éthos Atelier). UninitContainerinitialise automatiquement les 3 buckets requis (atelier-sessions,atelier-snapshots,forgejo-lfs-attachments) avec les permissions10001:10001(UID du processrustfs). Serviceatelier-s3-devexposant le port 9000 (S3 API). - Tâche 2.0.2 (Forgejo 100% HTTPS connecté à PostgreSQL) : Base
forgejocréée suratelier-postgres-dev. Podatelier-forgejo-devdéployé dans Kind (imagecodeberg.org/forgejo/forgejo:9connectée àatelier-postgres-dev:5432/forgejo, 121 tables initialisées). SSH désactivé d'office (DISABLE_SSH: true). Utilisateur administrateuratelier_admincréé via CLI, token d'accès PAT généré, création d'un dépôt privétest-repovalidée via API REST, puis cycle completgit clone➔git commit➔git push origin mainvalidé avec mise à jour effective dans PostgreSQL (repository.is_empty = false). - Preuve empirique / Test exécuté :
kubectl apply -f deploy/dev/s3/dev-pod.yaml kubectl wait --for=condition=Ready pod/atelier-s3-dev --timeout=60s # pod/atelier-s3-dev condition met kubectl logs atelier-s3-dev -c rustfs # Starting: /usr/bin/rustfs /data (Running on port 9000) kubectl exec atelier-s3-dev -c rustfs -- sh -c 'echo "test session payload" > /data/atelier-sessions/test-session.zst && cat /data/atelier-sessions/test-session.zst' # ecriture et lecture reussies en tant que rustfs (10001) kubectl exec atelier-postgres-dev -- psql -U atelier_admin -d postgres -c 'CREATE DATABASE forgejo;' # CREATE DATABASE kubectl apply -f deploy/dev/forgejo/dev-pod.yaml kubectl wait --for=condition=Ready pod/atelier-forgejo-dev --timeout=60s # pod/atelier-forgejo-dev condition met kubectl exec atelier-postgres-dev -- psql -U atelier_admin -d forgejo -c '\dt' # 121 tables creees par Forgejo dans PostgreSQL kubectl exec atelier-forgejo-dev -- su-exec 1000:1000 forgejo admin user create --username atelier_admin --password dev-only-not-for-production --email admin@atelier.local --admin # New user 'atelier_admin' has been successfully created! kubectl exec atelier-forgejo-dev -- su-exec 1000:1000 forgejo admin user generate-access-token --username atelier_admin --token-name dev-test-token --scopes all # Access token was successfully created: 5f04486ef2c4... kubectl exec atelier-forgejo-dev -- curl -s -X POST http://127.0.0.1:3000/api/v1/user/repos -H "Authorization: token 5f04486ef2c4..." -H "Content-Type: application/json" -d '{"name": "test-repo", "private": true, "auto_init": true}' # HTTP 201 Created ("id": 1, "name": "test-repo", "private": true) kubectl exec atelier-forgejo-dev -- sh -c 'git clone http://atelier_admin:5f04486ef2c4...@127.0.0.1:3000/atelier_admin/test-repo.git /tmp/clone-test && cd /tmp/clone-test && echo "commit" > f && git add f && git commit -m "feat: commit" && git push origin main' # main -> main OK kubectl exec atelier-postgres-dev -- psql -U atelier_admin -d forgejo -c 'SELECT id, name, is_empty FROM repository;' # 1 | test-repo | f - Statut : ✅ Validé pour 2.0.1 et 2.0.2. Débloque le développement réel des modules S3 (
storage.rs) et de l'interceptionidentity-proxydu Jalon M2.
[2026-08-23 22:45] Jalon M1 - Tâche 1.0.2 : PKI locale validable et instance Keycloak OIDC de développement
- Composant impacté :
deploy/dev/pki/init-pki.sh(nouveau),deploy/dev/pki/README.md(nouveau),deploy/dev/keycloak/realm-export.json(nouveau),deploy/dev/keycloak/dev-pod.yaml(nouveau),deploy/dev/keycloak/README.md(nouveau). - Contexte : Pour simplifier le dev local et respecter l'éthos du projet « Tests Réels sans Mocks », il était impératif de générer une PKI de développement validable (Root CA + certificats Multi-SAN) et de disposer d'une vraie instance Keycloak locale branchée sur notre PostgreSQL partagé (
atelier-postgres-dev:5432/keycloak) pour valider les flux OIDC JWT (JWKS dynamique, claims, PKCE) dès le Jalon M1. - Modifications réalisées :
- PKI Dev Locale : Script
deploy/dev/pki/init-pki.shgénérant une Root CA (atelier-ca.crt, valide 10 ans) et un certificat Multi-SAN (server.crt) couvrant tous les domaines dev (*.atelier.local,auth.atelier.local,git.atelier.local,app.atelier.local,api.atelier.local,localhost,127.0.0.1). Synchronisation automatique des Secrets Kubernetesatelier-dev-caetatelier-dev-tlsdans le cluster Kind. - Keycloak Dev (PostgreSQL) : Base
keycloakcréée suratelier-postgres-dev. Podatelier-keycloak-devdéployé dans Kind (imagequay.io/keycloak/keycloak:26.1en modestart-dev). Realmatelierimporté automatiquement avec les clients OIDCatelier-dashboard(PKCE S256 public) etatelier-api(Bearer-only), et deux utilisateurs de test (atelier-adminetatelier-test-user). - Preuve empirique / Test exécuté :
./deploy/dev/pki/init-pki.sh # Root CA generee + Certificat Multi-SAN cree + Secrets synchronises dans Kind openssl verify -CAfile deploy/dev/pki/ca/atelier-ca.crt deploy/dev/pki/certs/server.crt # deploy/dev/pki/certs/server.crt: OK kubectl exec atelier-postgres-dev -- psql -U atelier_admin -d postgres -c 'CREATE DATABASE keycloak;' # CREATE DATABASE kubectl apply -f deploy/dev/keycloak/dev-pod.yaml kubectl wait --for=condition=Ready pod/atelier-keycloak-dev --timeout=90s # pod/atelier-keycloak-dev condition met kubectl exec atelier-forgejo-dev -- curl -s http://atelier-keycloak-dev:8080/realms/atelier/.well-known/openid-configuration # Document OIDC RFC 8414 valide retourne kubectl exec atelier-forgejo-dev -- curl -s -X POST http://atelier-keycloak-dev:8080/realms/atelier/protocol/openid-connect/token -d "client_id=atelier-dashboard" -d "grant_type=password" -d "username=atelier-test-user" -d "password=dev-only-not-for-production" -d "scope=openid email profile" # JWT Access Token + ID Token + Refresh Token obtenus avec succes - Statut : ✅ Validé pour 1.0.2. L'authentification OIDC de dev est désormais 100% basée sur Keycloak et une PKI validable.
[2026-08-23 22:30] Jalon M1 - Tâches 1.2.1, 1.2.7, 1.2.8, 1.2.9, 1.2.10 : sqlx, migrations et healthchecks dans api-server
- Composant impacté :
crates/api-server/Cargo.toml,crates/api-server/src/main.rs,crates/api-server/src/routes.rs,crates/api-server/migrations/20260824000000_init_apiserver.sql(nouveau),crates/api-server/tests/routes.rs,deploy/dev/postgres/(rôleatelier_appajouté). - Modifications réalisées :
- Ajout des dépendances
sqlx(0.8, featurespostgres/uuid/chrono/json/macros/migrate),aws-sdk-s3,aws-config,base64(les deux derniers pas encore consommés, réservés au Jalon M2). main.rs:DATABASE_URLobligatoire au démarrage (erreur explicite sinon),PgPoolOptions(10 connexions max),sqlx::migrate!("./migrations")exécutées au boot avant de servir du trafic.AppState(routes.rs) : ajout dedb_pool: sqlx::PgPooletopenbao_addr: Option<String>(uniquement pour la sonde readiness, pas de lecture de secrets).- Deux nouveaux endpoints hors authentification (
/health/liveness,/health/readiness) : le premier toujours200si le process tourne, le secondSELECT 1réel sur PostgreSQL +GET /v1/sys/healthsur OpenBao si configuré,503sinon. - Migration
20260824000000_init_apiserver.sql: tablessession_logs/audit_events, RLS (ENABLE+FORCE ROW LEVEL SECURITY) isolant parowner_subjectviacurrent_setting('app.current_tenant'). - Découverte empirique importante :
atelier_admin(le rôlePOSTGRES_USERde l'image officielle) est superutilisateur — un superutilisateur (ou tout rôleBYPASSRLS) ignore silencieusement RLS même avecFORCE ROW LEVEL SECURITY(comportement standard PostgreSQL, pas un bug de la migration). Vérifié en pratique : deux lignes de tenants différents insérées, toutes deux visibles viaatelier_adminmalgréSET app.current_tenant. Corrigé en ajoutant un second rôleatelier_app(non-superutilisateur,NOBYPASSRLS), provisionné automatiquement au premier démarrage du pod (ConfigMapmontée sur/docker-entrypoint-initdb.d/) — avec ce rôle, la même expérience isole correctement les tenants (lecture ET écriture,WITH CHECKbloque bien unINSERTcross-tenant avec une erreur RLS réelle). Voirdeploy/dev/postgres/README.mdpour la démonstration complète et la limite actuelle (les migrations et l'app tournent encore avecatelier_adminen M1, la séparation des rôles pour l'app runtime reste à faire quand du code consommera réellement ces tables). - Incident rencontré et résolu en cours de route : disque hôte plein à 100 % en plein
cargo test --workspace(dépendancesaws-sdk-s3/aws-configvolumineuses ajoutées) — bloquant, indépendant de ce changement (target/du dépôt seul pesait 102 Go, plus~135Gde Téléchargements, volumes Docker, etc.). Résolu par l'utilisateur (libération d'espace), tests rejoués avec succès ensuite. - Preuve empirique / Test exécuté :
cargo test -p atelier-api-server --test routes # 5 passed, dont le nouveau health_endpoints_respond_without_auth (vrai PgPool, vraies migrations) cargo test --workspace / clippy -D warnings / fmt --check # 100% vert kubectl exec atelier-postgres-dev -- psql -U atelier_admin -d atelier_apiserver -c '\dt' # session_logs, audit_events, _sqlx_migrations kubectl exec atelier-postgres-dev -- psql ... "SELECT relrowsecurity, relforcerowsecurity FROM pg_class ..." # t | t pour les deux tables # Demonstration RLS reelle : atelier_admin voit les deux tenants (RLS ignoree, superutilisateur), # atelier_app n'en voit qu'un (RLS appliquee) et un INSERT cross-tenant leve # "new row violates row-level security policy" - Statut : ✅ Validé pour les 5 tâches listées.
[2026-08-23 23:00] Jalon M1 - Tâches 1.3.1, 1.3.6, 1.3.7 : sqlx et healthcheck dans controller, et correction d'une régression réelle sur les pods parents
- Composant impacté :
crates/controller/Cargo.toml,crates/controller/src/main.rs,crates/controller/src/health.rs(nouveau),crates/controller/src/lib.rs,crates/controller/src/reconcile.rs,crates/controller/migrations/20260824000000_init_controller.sql(nouveau). - Modifications réalisées :
- Ajout de
sqlx(mêmes features queapi-server) etaxum(nouveau serveur HTTP dédié aux sondes). main.rs:DATABASE_URLobligatoire,PgPool, migrations exécutées au boot, puis un serveurGET /health/ready(0.0.0.0:8081par défaut,ATELIER_CONTROLLER_HEALTH_ADDRpour le surcharger) vérifiant l'API Kubernetes (apiserver_version()), PostgreSQL (SELECT 1) et OpenBao si configuré — même logique que/health/readinesscôtéapi-server.- Migration
20260824000000_init_controller.sql:rootfs_cache_index(index du cache rootfs content-addressed) etworkshop_reconciliation_history(historique des transitions de phase) — schéma seul, pas encore consommé parreconcile.rs. - Régression réelle découverte et corrigée en cours de route (avant tout dégât) : en relançant le controller à jour contre le cluster kind partagé (qui héberge de vrais Workshops créés par l'utilisateur, ex.
my-new-demoavec une microVM active depuis 2h+), le tout premier reconcile de chaque Workshop existant échouait en boucle avec une erreur 422 Kubernetes ("field is immutable") — cause :ensure_parent_pod/ensure_image_build_jobre-appliquent inconditionnellement (Server-Side Apply) la spec complète du pod/Job à chaque reconcile, alors quespec.containers[*].env(Pod) etspec.template(Job) sont immuables une fois l'objet créé. Ce défaut préexistait (latent, jamais déclenché tant qu'aucun changement de spec n'était introduit) ; le commit précédent (ajout deATELIER_WORKSHOP_NAME/OPENBAO_ADDRau conteneurnet-proxy) l'a rendu visible pour la première fois. - Première approche envisagée (supprimer puis recréer le pod en cas de conflit) rejetée avant déploiement après vérification manuelle :
my-new-demo-parentest un pod réel avec une microVM restaurée depuis snapshot et une session utilisateur active — le recréer aurait interrompu ce travail sans nécessité. - Fix retenu : ne plus jamais re-patcher un Pod ou un Job déjà existant (
jobs.get_opt/pods.get_optd'abord,patchseulement si absent) — une mise à jour de spec ne prend effet qu'au prochain cycle naturel de recréation (suspend/resume pour le pod parent, jamais pour un Job terminé). Aucune perte de fonctionnalité : rien ne dépendait d'un re-patch réussi sur un objet déjà existant. - Vérifié réellement : redéploiement du controller corrigé sur le cluster partagé, tous les Workshops existants (
my-new-demo,ws-user-fix-test,test, etc.) reconcilient désormais sans la moindre erreur en boucle,my-new-demo-parentconserve soncreationTimestampd'origine (non recréé, session non interrompue). - Preuve empirique / Test exécuté :
cargo test -p atelier-controller # 7 unit + 4 integration passed (dont apply_creates_owned_parent_pod_once_image_ready, qui exerce le chemin "pod deja existant") cargo test --workspace / clippy -D warnings / fmt --check # 100% vert curl http://127.0.0.1:8081/health/ready # 200 # Sur le cluster partage, apres redeploiement : 0 occurrence de "error"/"failed" dans les logs # du controller sur un cycle complet de reconciliation de tous les Workshops existants ; # my-new-demo-parent conserve son creationTimestamp d'origine (pod non recree). - Statut : ✅ Validé pour les 3 tâches listées, plus une régression corrigée avant tout impact utilisateur constaté.
[2026-08-23 22:15] Jalon M1 - Tâches 1.4.1, 1.4.2, 1.4.3 : dashboard découplé de Kanidm, flux OIDC générique (Keycloak)
- Composant impacté :
dashboard/lib/config.ts,dashboard/lib/session.ts,dashboard/app/api/auth/{login,callback}/route.ts,dashboard/app/login/page.tsx,dashboard/server.ts,dashboard/package.json,dashboard/README.md. - Modifications réalisées :
KANIDM_URL/ATELIER_KANIDM_URLrenommés enOIDC_ISSUER_URL/ATELIER_OIDC_ISSUER_URL— cette base pointe maintenant sur l'URL du realm (http://127.0.0.1:8080/realms/atelieren dev), pas la racine du serveur comme pour Kanidm.- Les chemins Kanidm-spécifiques (
/ui/oauth2,/oauth2/token) remplacés par les chemins OIDC standards de Keycloak, mais rendus configurables séparément plutôt que résolus via/.well-known/openid-configuration: nouvelles variablesATELIER_OIDC_AUTHORIZE_PATH(défaut/protocol/openid-connect/auth) etATELIER_OIDC_TOKEN_PATH(défaut/protocol/openid-connect/token), combinées àOIDC_ISSUER_URLvia deux nouvelles fonctionsoidcAuthorizeUrl()/oidcTokenUrl()dansconfig.ts. - Décision technique notable : ces deux fonctions concatènent les chaînes (
${base}${path}) plutôt que d'utilisernew URL(path, base)—OIDC_ISSUER_URLcontient déjà un chemin non-vide (/realms/atelier), etURL(path, base)avec unpathcommençant par/remplace le chemin de la base au lieu de l'y ajouter, ce qui aurait silencieusement perdu le segment/realms/atelieret cassé toute résolution contre Keycloak. - Choix de l'option (a) du plan (chemins configurables) plutôt que (b) (découverte OIDC dynamique) : évite un appel réseau + cache supplémentaire pour un flux qui n'a besoin que de deux endpoints stables ; un fournisseur OIDC non-Keycloak n'a qu'à surcharger les deux variables de chemin.
session.ts(refreshAccessToken()) etcallback/route.tsutilisent tous deuxoidcTokenUrl(): un seul point de vérité pour le chemin token, plus de duplication Kanidm-spécifique.ATELIER_OAUTH2_CLIENT_IDpar défaut passé deatelieràatelier-dashboard(client public PKCE réellement pré-configuré dansdeploy/dev/keycloak/realm-export.json).package.json(NODE_EXTRA_CA_CERTSdu scriptdev) etREADME.md: chemin CA basculé surdeploy/dev/pki/ca/atelier-ca.crt(PKI locale multi-services,deploy/dev/pki/README.md) — plus dedeploy/dev/kanidm/data/ca.pem.- Wording UI (
app/login/page.tsx) et commentaires (server.ts,session.ts) désormais génériques OIDC au lieu de "Kanidm". - Preuve empirique / Test exécuté (contre le vrai Keycloak de dev, pas de mock) :
Limite rencontrée : le serveur
kubectl port-forward svc/atelier-keycloak-dev 8090:8080 & # 8080 local deja occupe par un autre service curl http://127.0.0.1:8090/realms/atelier/.well-known/openid-configuration # 200, confirme les endpoints attendus # Flux complet simule via curl, memes endpoints/parametres que login/callback route.ts : # 1) GET /realms/atelier/protocol/openid-connect/auth avec PKCE S256 -> 200, vraie page de login Keycloak # 2) POST du formulaire de login (atelier-test-user) -> 302 avec un vrai `code` d'autorisation # 3) POST /realms/atelier/protocol/openid-connect/token (grant_type=authorization_code, code_verifier) -> 200, access_token + refresh_token # 4) POST /realms/atelier/protocol/openid-connect/token (grant_type=refresh_token) -> 200, nouveau access_token + refresh_token en rotation (exactement le mecanisme de refreshAccessToken()) cd dashboard && npm run build # succes (TypeScript strict, Next 16 App Router) cd dashboard && npm run lint # succes, 0 warningnext dev(server.ts, custom pour le WebSocket) refuse de démarrer une seconde instance sur le même répertoire (verrou Next), et une instance déjà lancée par une session précédente tournait sur le port par défaut sans accès au Keycloak forwardé sur 8090 — la validation du flux HTTP réel a donc été faite via des appelscurldirects reproduisant fidèlement les requêtes émises parlogin/route.ts/callback/route.ts/session.ts(mêmes URLs, mêmes paramètres, même grammaire de requête), plutôt qu'un clic navigateur bout-en-bout sur l'app elle-même. Le rechargement à chaud (HMR) de l'instance déjà active a par ailleurs confirmé que la nouvelle URL d'autorisation générée parlogin/route.tspréservait bien le segment/realms/atelier. - Statut : ✅ Validé pour les 3 tâches listées.
[2026-08-23 22:50] Hors plan initial - Ingress Traefik de dev, mapper d'audience Keycloak et bug nextUrl.origin du dashboard
- Contexte : en testant réellement le flux de login dans un vrai navigateur (suite aux tâches 1.4.1-1.4.3), l'utilisateur a rencontré une cascade de bugs d'intégration non couverts par le plan initial — consignés ici avec leur résolution, et une nouvelle tâche d'infrastructure ajoutée au plan (1.0.3) en conséquence.
- Composant impacté :
deploy/dev/traefik/(nouveau),deploy/dev/keycloak/realm-export.json(mapper d'audience),dashboard/lib/config.ts,dashboard/app/api/auth/{login,callback}/route.ts,dashboard/README.md. - Problèmes rencontrés et corrigés, dans l'ordre :
- Collision de port 8080 : le dashboard redirigeait vers Keycloak sur
127.0.0.1:8080, mais ce port était déjà occupé paratelier-api-server(port figé danscrates/api-server/src/main.rs) — le READMEdeploy/dev/keycloakdocumentait par erreur le même port pour son port-forward. Point de départ d'une réflexion plus large : plutôt que de re-numéroter les ports un par un, mise en place d'un ingress unique. - Ingress Traefik de dev (
deploy/dev/traefik/) : routage par en-têteHostvers 4 domaines (auth./git./app./api.atelier.local), remplaçant les port-forwards individuels. Keycloak et Forgejo sont joints via leurServiceClusterIPin-cluster ;atelier-api-serveret le dashboard (pas encore conteneurisés) sont exposés via unServicesans sélecteur + unEndpointsmanuel pointant sur172.19.0.1(gateway du réseau Dockerkind, joignable depuis n'importe quel pod — vérifié réellement avec un podcurlimages/curljetable). Traefik tourne enhostNetwork: true(pas unServiceNodePort, qui n'aurait pas pu utiliser le port 80 standard sans élargir--service-node-port-rangecôté API server) : lie directement le port 80 sur l'IP du node kind (172.19.0.2), joignable telle quelle depuis l'hôte (réseau Docker en pont standard sur Linux). OptionLoadBalancer/MetalLB envisagée et écartée par l'utilisateur (composant supplémentaire non nécessaire pour un seul node de dev). Scriptdeploy/dev/traefik/update-hosts.shpour automatiser la mise à jour de/etc/hosts(impossible depuis un Job Kubernetes : le cluster kind tourne lui-même dans un conteneur Docker isolé du système de fichiers de la vraie machine hôte). atelier-api-servertournait sans aucune variable JWT configurée (AuthState::Disabled), rejetant systématiquement toute requête avec 401 quel que soit le token — relancé avecATELIER_JWT_ISSUER/ATELIER_JWT_JWKS_URL/ATELIER_JWT_AUDIENCEpointant sur le Keycloak réel via Traefik.- Aucun token Keycloak ne portait de claim
aud(vérifié en décodant un vrai JWT) : sans mapper d'audience explicite,ATELIER_JWT_AUDIENCEétait structurellement invalidable, quelle que soit sa valeur. Ajouté en direct sur l'instance Keycloak vivante viakcadm.sh(oidc-audience-mappersur le clientatelier-dashboard,included.client.audience=atelier-api) puis répercuté dansrealm-export.jsonpour survivre à une recréation du realm. - Bug réel (pas cosmétique) trouvé côté dashboard :
request.nextUrl.origin, dans le serveur custom (server.ts,next({ dev })sanshostnameexplicite), ignore l'en-têteHostréellement reçu et retombe toujours surhttp://localhost:3000— vérifié en envoyantHost: app.atelier.localdirectement au process Node sur le port 3000 (pas seulement via Traefik). Conséquence concrète : le cookie PKCE (atelier_oauth_pkce) posé surapp.atelier.localau moment de/api/auth/loginn'était jamais renvoyé par le navigateur puisqueredirect_uriramenait sur le domainelocalhost:3000, différent — l'échange du code échouait ("session de connexion expirée"). Corrigé par une nouvelle fonctionrequestOrigin()(dashboard/lib/config.ts) qui lit l'en-têteHostdirectement plutôt quenextUrl.origin, utilisée danslogin/route.tsetcallback/route.ts. - Preuve empirique / Test exécuté : flux complet simulé via
curlavec un fichier de cookies partagé, reproduisant exactement un navigateur réel —GET /api/auth/login(pose le cookie PKCE surapp.atelier.local) →GETde la vraie page de login Keycloak →POSTdu formulaire avec les vrais identifiants de test → redirection avec un vrai code d'autorisation →GET /api/auth/callback(échange réel du code, cookie PKCE bien reçu cette fois) →atelier_sessionposé avec un vrai JWT (aud: atelier-apiconfirmé en décodant le token) →GET /renvoie200avec le contenu Workshop, sans "JWT invalide".curl -H "Host: <domaine>" http://172.19.0.2:80/confirme les 4 routes Traefik (auth→302,git→200,api→401 authentifié requis,app→307 vers/login). - Reste à faire (hors scope de cette session) :
atelier-api-serveret le dashboard ne sont pas encore conteneurisés (d'où le contournementEndpointsmanuel vers la gateway Docker) — les intégrer proprement au cluster (Deployment + Service) simplifieraitdeploy/dev/traefik/ingresses.yamlen supprimant ce contournement. - Statut : ✅ Résolu et vérifié de bout en bout. Nouvelle tâche 1.0.3 ajoutée au plan pour tracer l'ingress Traefik.
[2026-08-23 23:05] Jalon M1 - Tâches 1.2.2, 1.2.3, 1.2.4, 1.2.5, 1.2.6 : JWKS dynamique, claims OIDC et Basic Auth VS Code/Terminal
- Composant impacté :
crates/api-server/src/auth.rs,crates/api-server/src/vscode.rs,crates/api-server/src/session_auth.rs(nouveau),crates/api-server/src/routes.rs,crates/api-server/src/main.rs,crates/common/src/openbao_client.rs,crates/controller/src/openbao.rs,crates/controller/src/main.rs. - Contexte : tâches réalisées par un agent dédié lancé en arrière-plan (voir demande de l'utilisateur "démarre autant d'agents en parallèle") puis interrompu par l'utilisateur avant la fin. Reprises et terminées par cette session : un import manquant (
use base64::Engine;) empêchait la compilation des tests, corrigé, puis toute la suite de tests revérifiée réellement avant tout commit. - Modifications réalisées :
auth.rs: documentation généralisée (OIDC standard RFC 7517/7636, plus de mention Kanidm-spécifique) ; JWKS mis en cache dans unArc<RwLock<JwkSet>>, rafraîchi toutes les 10 min par une tâche de fond, plus un refetch immédiat si un JWT présente unkidabsent du cache (rotation de clés côté fournisseur) ;Claimsétendue (sub,preferred_username,email,groups) et injectée entière dans les extensions Axum (Extension<Claims>), pas seulement le sujet.- Basic Auth VS Code/Terminal (1.2.6) :
api-serverest cluster-wide (une seule instance pour tous les Workshops), donc incompatible avec le rôle OpenBaoworkshop-<name>scopé à un seul Workshop qu'utilisentidentity-proxy/mcp-gateway/net-proxy. Ajout d'un rôle OpenBao dédiéatelier-api-server, provisionné une seule fois au démarrage du controller (ensure_api_server_role, policyreadseule sursecret/{data,metadata}/workshops/+/session_auth, wildcard sur un seul segment de chemin — aucun autre secret de Workshop n'est accessible).crates/common/src/openbao_client.rs::OpenBaoClientgénéralisé (from_env_with_role+read_field_for) pour permettre un rôle fixe distinct du nom de Workshop, sans casser les appelants existants scopés à un seul Workshop. Nouveaucrate::session_auth::SessionAuthClient(cache du token client OpenBao, retry après un login frais si le premier essai échoue) injecte l'en-têteAuthorization: Basicdansproxy_to_guest_port(vscode.rs, réutilisé parterminal.rs) — dégradé silencieusement (relai sans injection) si OpenBao n'est pas configuré ou si le secret est absent. - Incident rencontré et résolu en cours de route : le nouveau test
vscode_proxy_injects_real_session_auth_basic_headeréchouait de façon intermittente (400 Bad Request, "le Workshop n'a pas de pod parent actif") — diagnostiqué comme une interférence du controller réel tournant en direct sur le cluster partagé (il réconciliait le Workshop de test entre lepatch_statusdu test et la requête HTTP, écrasant le statut manuellement posé). Confirmé en arrêtant le controller (test passe systématiquement) puis en le relançant avec le code à jour (aucune régression,my-new-demo-parentnon affecté). Pas un bug de l'implémentation. - Preuve empirique / Test exécuté :
cargo test -p atelier-api-server # 6 passed (dont vscode_proxy_injects_real_session_auth_basic_header, vrai OpenBao) cargo test -p atelier-controller # 7 unit + 5 integration passed (dont ensure_api_server_role_reads_any_workshop_session_auth_but_nothing_else) cargo test --workspace / clippy -D warnings / fmt --check # 100% vert (controller live arrêté pendant la vérification finale, relancé ensuite sans erreur) - Statut : ✅ Validé pour les 5 tâches listées.
[2026-08-23 23:15] Validation du DoD du Jalon M1
- Contexte : toutes les tâches individuelles de M1 (1.0.1-1.0.3, 1.1.1-1.1.3, 1.2.1-1.2.10, 1.3.1-1.3.7, 1.4.1-1.4.3) étaient déjà
[x], mais le récapitulatif "Definition of Done" du jalon (section dédiée dedocs/specs/PLAN-ACTION-GLOBAL.md) n'avait jamais été revérifié point par point ni coché. - Vérifications effectuées :
- PostgreSQL :
DATABASE_URLobligatoire et migrations réelles au boot d'api-serveretcontroller— confirmé. - Kanidm :
grep -rn kanidm crates/api-server crates/controller(code etCargo.toml) ne retourne plus rien — confirmé. - Healthchecks :
/health/liveness,/health/readiness(api-server) et/health/ready(controller) répondent réellement. cargo test --workspace/clippy -D warnings/fmt --check: 100% vert, revérifié avec le controller live arrêté puis relancé (élimine l'interférence de reconciliation déjà documentée).- Basic Auth VS Code/
ttyd: chaîne complète côté ce dépôt fonctionnelle et testée (controller → OpenBao → net-proxy → api-server), mais incomplète en pratique : le devcontainer (repo séparéatelier-workspace) ne consomme pas encore l'endpoint metadata pour configurer le Basic Auth dettyd/code-server— vérifié en cherchant toute référence àsession-auth/169.254.0.1:3132/credential atelierdans les clones locaux disponibles de ce repo (/tmp/atelier-workspace-new,/tmp/claude-1000/atelier-workspace) : aucune trouvée. Marqué[~](partiel) dans le DoD plutôt que[x], pour ne pas déclarer M1 clos à tort sur ce point précis. - Statut : ⚠️ DoD de M1 validé à 5/6 items complets ; le 6ème (Basic Auth guest) nécessite une intervention dans le dépôt
atelier-workspace, hors du périmètre de cette session.
[2026-08-24] Jalon M2 - Tâches 2.1.1, 2.1.2, 2.1.3, 2.1.4 : client S3 (storage.rs), archivage et rejeu de session en streaming
- Composant impacté :
crates/api-server/src/storage.rs(nouveau module),crates/api-server/src/lib.rs,crates/api-server/Cargo.toml,crates/api-server/tests/storage.rs(nouveau test d'intégration). - Contexte : premier des deux agents parallèles verrouillés sur la section 5.1 du plan (le second,
[-/claude-code/sess-6f3eef77-d], travaille surcrates/controller/crates/net-proxypour l'injection Git HTTPS — aucun fichier en commun). - Modifications réalisées :
- 2.1.1 : trait
StorageBackend(#[async_trait::async_trait], dyn-compatible pour permettre une future implémentation alternative — principe de substitutabilité du projet) avecupload_stream/download_stream/delete_objectsur un type uniqueBoxedAsyncRead = Pin<Box<dyn AsyncRead + Send + Sync>>.S3StorageBackendimplémente ce trait au-dessus d'aws-sdk-s3, client construit explicitement viaaws_sdk_s3::config::Builder(endpoint_url,Credentials::newstatiques,force_path_style) plutôt que via la découverte AWS standard (IMDS/profils), qui ne s'applique pas à un endpoint personnalisé type RustFS/MinIO. - 2.1.2 :
storage::config_from_envsuit exactement la convention deopenbao::config_from_env/TrustedIssuer::from_env:S3_ENDPOINTabsent →Ok(None)(stockage désactivé) ; présent →S3_REGION/S3_BUCKET_SESSIONS/S3_BUCKET_SNAPSHOTS/AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYdeviennent obligatoires (erreur explicite sinon),S3_FORCE_PATH_STYLEoptionnel (falsepar défaut). - 2.1.3 :
S3StorageBackend::upload_session_archive(workshop_name, session_id, source: impl AsyncRead)compressesourceen zstd en streaming viaasync-compression(ZstdEncoderopérant directement sur unAsyncRead, jamais de chargement intégral en mémoire), sous la clé conventionnelleworkshops/<workshop_name>/sessions/<session_id>.zst(préfixe par Workshop pour un listing/nettoyage ciblé sans connaître à l'avance lessession_id). - 2.1.4 :
S3StorageBackend::get_session_stream(s3_key)récupère l'objet et le décompresse en streaming (ZstdDecoder), renvoie unAsyncReadconsommable progressivement par l'appelant (pas deVec<u8>chargé entièrement). - Choix de conception notable, découvert en testant réellement contre RustFS : un
put_objectavec un corps HTTP en streaming de taille inconnue (SdkBody::from_body_1_xsur un fluxhttp_body) échoue systématiquement avec « Only request bodies with a known size can be aws-chunked encoded » — la taille compressée d'une session en cours de rejeu n'est jamais connue à l'avance.upload_streamest donc implémenté via un televersement multipart S3 (create_multipart_upload/upload_partpar blocs de 8 Mio/complete_multipart_upload), la seule méthode compatible avec un corps de taille totale inconnue (chaque part a une taille individuelle connue) ; abandon (abort_multipart_upload) sur toute erreur en cours de route, et repli sur unput_objectclassique à corps vide pour un flux source vide (un multipart upload ne peut pas se compléter sans au moins une part). - Dépendances ajoutées à
crates/api-server/Cargo.toml:async-compression(zstd streaming,tokio+zstdfeatures — préférée à la cratezstdnue pour éviter de gérer nous-mêmes un thread bloquant autour de libzstd),bytes,async-trait,sha2(dev, cohérent aveccrates/vm-supervisor/crates/image-builder) ; activation de la featurert-tokiosuraws-sdk-s3(déjà présente comme dépendance non consommée) — nécessaire à la fois pour l'implémentation par défaut du sleep/retry async du client (sinon panique au premier appel réseau) et pourByteStream::into_async_read(rejeu en streaming deget_object). - Preuve empirique / Test exécuté (contre le vrai serveur RustFS de dev, pas de mock) :
kubectl port-forward svc/atelier-s3-dev 9000:9000 & export S3_ENDPOINT=http://127.0.0.1:9000 S3_REGION=us-east-1 \ AWS_ACCESS_KEY_ID=atelier-rustfs-access-key AWS_SECRET_ACCESS_KEY=atelier-rustfs-secret-key \ S3_BUCKET_SESSIONS=atelier-sessions S3_BUCKET_SNAPSHOTS=atelier-snapshots S3_FORCE_PATH_STYLE=true cargo test -p atelier-api-server --test storage # test upload_and_replay_session_archive_preserves_integrity ... ok # Contenu déterministe (LCG à graine fixe, pas de générateur aléatoire) d'environ 5 Mo : # upload_session_archive -> get_session_stream -> lecture complète jusqu'à EOF -> SHA-256 identique avant/après. unset S3_ENDPOINT # vérifie le gate (pas de panic, juste "test ignore") cargo test -p atelier-api-server --test storage # ok, 1 passed (skip effectif) cargo test -p atelier-api-server # 6 (routes.rs, vrai cluster/OpenBao) + 1 (storage.rs, vrai RustFS) passed cargo fmt --all -- --check # silencieux cargo clippy --workspace --all-targets -- -D warnings # 0 warning - Statut : ✅ Validé pour les 4 tâches listées (2.1.1 à 2.1.4).
[2026-08-24] Jalon M6 - Tâches 6.0.1, 6.0.2 : local-stack.sh réécrit (retrait de Kanidm, orchestration complète), teardown-stack.sh
- Composant impacté :
deploy/dev/local-stack.sh(réécrit),deploy/dev/teardown-stack.sh(nouveau). - Contexte : le script
local-stack.shdatait d'avant la migration Kanidm → Keycloak et d'avant l'ajout de PostgreSQL, S3, Forgejo, la PKI locale et l'ingress Traefik — tous déployés manuellement au fil de sessions précédentes (voir entrées ci-dessus) mais jamais intégrés à l'orchestration. local-stack.sh: retire toute référence à Kanidm (conteneur Docker, service account, token API) ; orchestre désormais dans l'ordre de dépendance CRD Workshop → PKI locale (deploy/dev/pki/init-pki.sh, idempotent) → OpenBao (inchangé) → PostgreSQL (pod + création idempotente des basesatelier_controller/keycloak/forgejo, la baseatelier_apiserverétant déjà créée automatiquement) → Keycloak (realm importé via ConfigMap) → S3 (RustFS) → Forgejo (admin + token API générés une seule fois, réutilisés ensuite viaenv.sh) → Traefik (ingress + Ingress applicatifs, après Keycloak/Forgejo dont il référence les Service) → registre OCI + build/kind loaddes images:dev(inchangé) → LLM Proxy optionnel (inchangé) → Redis (non disponible, message explicite renvoyant au Jalon M5 plutôt qu'un échec silencieux ou une infra inventée). Port-forwards host démarrés/réutilisés pour OpenBao (8200), PostgreSQL (5433) et S3 (9000).env.shrégénéré avecATELIER_DATABASE_URL_{API_SERVER,CONTROLLER}(une base par composant),ATELIER_OIDC_ISSUER_URL/ATELIER_JWT_{ISSUER,JWKS_URL,AUDIENCE}pointant sur Keycloak via l'ingress Traefik (auth.atelier.local, cohérent avecdashboard/lib/config.tsetcrates/api-server/src/auth.rs), variables S3 et Forgejo. Message final listant les étapes manuelles non automatisables :sudo deploy/dev/traefik/update-hosts.sh(nécessite un mot de passe, ne peut pas tourner dans le script) et confiance optionnelle à la CA locale (SSL_CERT_FILE/NODE_EXTRA_CA_CERTS).- Bug réel trouvé et corrigé en testant : le pod Forgejo du cluster tournait depuis des heures avec une base
forgejoinexistante (jamais créée manuellement, cf. tâche originale) — confirmé parkubectl logs(pq: database "forgejo" does not existen boucle sur les tâches cron). Le script détecte ce cas (grep du message d'erreur dans les logs juste après lekubectl wait) et redémarre le pod pour qu'il exécute ses migrations une fois la base créée. Deuxième bug trouvé en testant :forgejo admin user createéchouait silencieusement juste après ce redémarrage (pod au statutReadyk8s alors qu'aucune readiness probe applicative n'est définie — le serveur web met encore quelques secondes à finir ses migrations) — corrigé par une boucle de nouvelle tentative (jusqu'à 30 x 1s) au lieu d'un|| truemasquant l'échec ; la condition de réutilisation du token dansenv.sha aussi été corrigée pour ne jamais considérer une valeur vide comme "déjà généré". teardown-stack.sh(nouveau) : symétrique delocal-stack.sh,kubectl delete -fsur exactement les mêmes fichiers manifest (jamais un sélecteur de label large ni--all) pour Traefik, LLM Proxy, Forgejo, S3, Keycloak, PostgreSQL, OpenBao, plus les secrets TLS/CA de la PKI et les port-forwards locaux. Ne touche jamaiscrds/workshop.yaml(supprimerait tous les Workshops du cluster) ni les images Docker/kindatelier-*:dev(encore utilisées par les pods Workshop réels en cours d'exécution). Le registre OCI est arrêté (docker stop) mais pas supprimé, pour conserver les images déjà poussées. Garde-fou explicite : n'exécute aucune suppression sansCONFIRM=yes, avec un message d'avertissement sur l'impact (casse la session dev active en cours, sans toucher aux pods Workshop eux-mêmes).- Preuve empirique / Test exécuté (contre le vrai cluster
kind-atelier-dev, cluster partagé avec de vrais Workshops actifs —my-new-demo-parentnotamment, jamais touché) :./deploy/dev/local-stack.sh # run complet, tous les composants déjà déployés détectés et réutilisés sans effet de bord # → a révélé et corrigé le bug Forgejo/base manquante en conditions réelles (voir ci-dessus) ./deploy/dev/local-stack.sh # deuxième run : "administrateur + token déjà générés, réutilisé" — idempotence confirmée cat deploy/dev/local-stack/env.sh # aucune référence à Kanidm, DATABASE_URL/ATELIER_OIDC_ISSUER_URL/ATELIER_JWT_*/S3_*/ATELIER_FORGEJO_* présents curl -H "Host: auth.atelier.local" http://172.19.0.2:80/realms/atelier/.well-known/openid-configuration # → 200, issuer "http://auth.atelier.local/realms/atelier" (cohérent avec ATELIER_JWT_ISSUER généré) curl -H "Host: git.atelier.local" http://172.19.0.2:80/ # 200 curl -H "Host: git.atelier.local" -H "Authorization: token $ATELIER_FORGEJO_ADMIN_TOKEN" http://172.19.0.2:80/api/v1/user # → 200, {"login":"atelier_admin","is_admin":true,...} : le token généré par le script authentifie réellement # my-new-demo-parent (vrai Workshop) et les process locaux déjà lancés # (npm run dev, atelier-controller, atelier-api-server) vérifiés intacts # avant/après chaque run. kubectl delete -f <chaque manifest cible> --dry-run=client -o name # confirme que teardown-stack.sh ne resout que les ressources dev attendues (aucune CRD, aucun Workshop) ./deploy/dev/teardown-stack.sh # sans CONFIRM=yes : refuse et affiche l'avertissement, exit 1 — comportement voulu, PAS exécuté avec CONFIRM=yes sur ce cluster partagé (aurait cassé la session dev active d'OpenBao/PostgreSQL/Keycloak) - Point non testé en exécution réelle, par prudence :
CONFIRM=yes ./deploy/dev/teardown-stack.shn'a pas été exécuté pour de vrai contrekind-atelier-dev(cluster partagé avec une session dev active et de vrais Workshops) — la suppression d'OpenBao/PostgreSQL/Keycloak aurait immédiatement cassé les process locaux déjà en cours (atelier-controller/atelier-api-server) et potentiellement la chaîne d'authentification de Workshops réels actifs. Le script a été relu très attentivement et chaque résolution de ressource vérifiée viakubectl delete --dry-run=client(voir ci-dessus) ; l'exécution réelle du chemin de suppression reste à faire sur un cluster kind jetable dédié. - Statut : ✅ Validé pour 6.0.1 (testé réellement, bug Forgejo trouvé et corrigé en conditions réelles). ⚠️ 6.0.2 : script écrit et relu attentivement, résolution des ressources vérifiée, mais suppression réelle non exécutée par prudence (cluster partagé).
[2026-08-24] Jalon M2 - Tâches 2.2.1, 2.2.2, 2.2.3 : Git HTTPS via identity-proxy (injection automatique de PAT, alias git.atelier.internal)
- Composants impactés :
crates/common/src/crd.rs(+GIT_ALIAS_HOST),crates/controller/src/git_identity.rs(nouveau),crates/controller/src/reconcile.rs,crates/net-proxy/src/internal.rs,crates/net-proxy/src/main.rs, et — bug réel trouvé en testant, hors périmètre initial mais nécessaire pour que le test empirique passe —crates/identity-proxy/src/proxy.rsetcrates/identity-proxy/src/http.rs. - 2.2.1 (chemin OpenBao) : réutilisation délibérée du chemin déjà utilisé par
crates/image-builder/src/main.rs::resolve_git_credentials(secret/data/workshops/<name>/git, champsusername/password), pas d'un second chemingit_tokencomme suggéré initialement par le plan. Justification documentée danscrates/controller/src/git_identity.rs: un même PAT Forgejo/GitHub/GitLab donne généralement accès aux mêmes dépôts pour cloner le devcontainer au build (image-builder) et pour que l'agent clone/pousse au runtime (identity-proxy) — deux moments et deux composants différents, mais un seul secret à provisionner côté utilisateur. - 2.2.2 (règle d'injection calculée) : nouveau module
crates/controller/src/git_identity.rs.config_from_env()litATELIER_GIT_HOST_SERVICE(nom du Service Kubernetes de la forge, défaut désactivé si absent — même convention queopenbao/llm_proxy_addr),ATELIER_GIT_HOST_SERVICE_NAMESPACE(défautdefault),ATELIER_GIT_HOST_PORT(défaut3000),ATELIER_GIT_INJECTION_HEADER/ATELIER_GIT_INJECTION_PREFIX(défautsAuthorization/token, override pour GitLab viaPRIVATE-TOKEN/vide).resolve_cluster_ip()lit le ClusterIP du Service via l'API Kubernetes (Api<Service>::get), jamais une résolution DNS classique — le controller peut tourner hors du cluster en dev (voir les autres entrées de ce journal), sans accès au DNS interne, mais toujours avec accès à l'API Kubernetes. Dansensure_parent_pod(reconcile.rs), la règle calculée (host: git.atelier.internal,secretPath: git,field: password) est ajoutée à la volée à la liste sérialisée versATELIER_IDENTITY_INJECTION_RULES, jamais écrite dansWorkshop.speclui-même (qui reste déclaratif) ; le ClusterIP résolu est posé enPod.spec.hostAliases(nouveau champ, mécanisme Kubernetes natif qui écrit dans/etc/hostsde tous les conteneurs du pod). Best-effort et non bloquant comme le reste du provisioning OpenBao : un échec de résolution désactive juste la fonctionnalité pour ce cycle. - 2.2.3 (alias net-proxy) : nouvel alias fixe
git.atelier.internal(constante partagéeatelier_common::GIT_ALIAS_HOST, reprend délibérément la même valeur queFORGEJO__server__ROOT_URLdéjà configuré dansdeploy/dev/forgejo/dev-pod.yaml— pas une coïncidence). Configurable viaATELIER_GIT_ALIAS_ADDR, pointant vers l'adresse locale d'identity-proxy, même mécanisme que les 4 alias existants (identity-proxy/mcp-gateway/registry/llm-proxy). Vérification documentée avant d'écrire du code (voir le commentaire de tête decrates/net-proxy/src/internal.rs) : le chaînage générique déjà en place (ATELIER_IDENTITY_PROXY_ADDR, qui fait transiter tout l'egress déjà autorisé paridentity-proxy) s'applique après que l'allowlist ait tranché — il n'aurait donc pas suffi à rendregit.atelier.internaljoignable sans que l'utilisateur l'ajoute explicitement àWorkshop.spec.egress_allowlist. L'alias dédié, lui, bypass l'allowlist entièrement, exactement ce que demandait 2.2.3. Un seul alias retenu (pas de secondforgejo.atelier.internal: un seul forge Git par Workshop dans ce MVP, pas de valeur ajoutée à en distinguer deux). - Bug réel trouvé et corrigé en testant un vrai
git clonede bout en bout (identity-proxy, hors du périmètre de fichiers initialement prévu pour cette tâche mais nécessaire pour que le test empirique demandé par le plan passe réellement — répertoire explicitement inclus dans le protocole multi-agents de cette session) :crates/identity-proxy/src/proxy.rs::forward()ne relayait la requête avec injection de credential que pour la toute première requête HTTP d'une connexion — toute requête suivante sur la même connexion keep-alive n'était plus qu'un flux d'octets recopié en aveugle (copy_bidirectional), sans jamais repasser par l'injection. Or le protocole HTTP "smart" de Git enchaîne systématiquement deux requêtes sur la même connexion (GET .../info/refs?service=git-upload-packpuisPOST .../git-upload-pack) : la première recevait bien le headerAuthorization, la seconde non, faisant échouer Forgejo en401 Unauthorizedsur la deuxième —git cloneéchouait avecfatal: could not read Username, alors qu'un simplecurl(une seule requête) fonctionnait. Corrigé en transformantforward()en boucle qui réinjecte le credential sur chaque requête, en s'appuyant sur un parsing minimal du framing HTTP (Content-Length/Transfer-Encoding: chunked, nouvelles fonctionscrates/identity-proxy/src/http.rs::{read_response_head, content_length, is_chunked, copy_exact, copy_chunked_body}) pour savoir où s'arrête chaque requête/réponse et pouvoir relire la suivante ; si le framing d'une réponse est inconnu (corps jusqu'à fermeture, pas deContent-Length/chunked), on retombe sur l'ancien comportement (copy_bidirectionaljusqu'à fermeture), correct tant qu'il ne reste plus qu'une requête sur la connexion. - Test réel exécuté (
cargo test -p atelier-net-proxy --test git_identity, nouveau) : bout en bout contre le vrai Forgejo de dev (kubectl port-forward svc/atelier-forgejo-dev --address 0.0.0.0, nécessaire pour être joignable depuis un conteneur Docker) et un vrai OpenBao (ServiceAccount + rôle Kubernetes-auth provisionnés commecrates/net-proxy/tests/session_auth.rs) : - Génère un vrai PAT Forgejo (
forgejo admin user generate-access-token) et crée un vrai dépôt privé frais. - Écrit le PAT dans OpenBao sous
secret/data/workshops/<name>/git(même convention que 2.2.1). - Lance
identity-proxy(imageatelier-identity-proxy:devreconstruite avec le correctif ci-dessus) dans un conteneur Docker avec--add-host git.atelier.internal:<passerelle bridge>— équivalent Docker exact dePod.spec.hostAliasesposé par le controller en production (voir 2.2.2), pour rester fidèle au mécanisme réel sans modifieridentity-proxypour les besoins du test. - Lance
net-proxy(process natif) avecATELIER_GIT_ALIAS_ADDRpointant vers ce conteneur, et sans aucune allowlist egress configurée — preuve directe que seul l'alias interne rendgit.atelier.internaljoignable. git clone http://git.atelier.internal:.../atelier_admin/<repo>.gitviahttp_proxy=http://127.0.0.1:<net-proxy>: succès réel, contenu du dépôt (README.mdde l'auto_init) vérifié sur disque.- Avant le correctif identity-proxy : échec reproductible (
fatal: could not read Username). Après : clone systématiquement réussi (plusieurs runs). - Test réel complémentaire (
cargo test -p atelier-controller --test reconcile apply_wires_the_git_identity_injection_rule_when_configured, nouveau) : contre le vrai cluster, avecReconcileCtx::git_identityconfiguré pointant sur le vrai Serviceatelier-forgejo-dev— vérifie queapply()(a) résout le vrai ClusterIP via l'API Kubernetes, (b) le pose enhostAliasessur le pod parent réellement créé, (c) ajoute la règle d'injection calculée àATELIER_IDENTITY_INJECTION_RULESdu conteneuridentity-proxy, (d) câbleATELIER_GIT_ALIAS_ADDRsurnet-proxy, et (e) ne modifie jamaisWorkshop.speclui-même. - Commandes exécutées et résultats :
cargo fmt --all -- --check # silencieux cargo clippy --workspace --all-targets -- -D warnings # 0 warning (workspace entier, y compris le travail en cours de l'autre agent sur storage.rs) cargo test -p atelier-controller -p atelier-net-proxy -p atelier-identity-proxy # 9 (controller lib) + 6 (controller/tests/reconcile.rs, vrai cluster) + 8 (identity-proxy) + 52 (net-proxy lib) # + 1 (net-proxy/tests/git_identity.rs, vrai Forgejo+Docker+OpenBao+K8s) + 1 (net-proxy/tests/session_auth.rs, vrai OpenBao+K8s) passed, 0 failed - Vérification de non-régression sur le cluster partagé (consigne de sécurité de cette session) :
atelier-controllerréel (./target/debug/atelier-controller, PID préexistant) arrêté puis relancé avec le binaire recompilé (mêmes variables d'environnement,git_identitynon activé pour ce run réel — fonctionnalité opt-in,ATELIER_GIT_HOST_SERVICEabsent) : logs de reconciliation examinés pour tous les Workshops existants (my-new-demo,test,ws-user-fix-test, etc.) sans aucune erreur ;my-new-demo-parent(vrai Workshop actif, 4/4 conteneurs) toujoursRunningaprès redémarrage,status.phasetoujoursRunning. - Décision de conception — un seul chemin OpenBao, un seul alias : voir 2.2.1/2.2.3 ci-dessus ; documentée en détail (avec justification) dans les commentaires de tête de
crates/controller/src/git_identity.rsetcrates/net-proxy/src/internal.rs. - Statut : ✅ Validé pour les 3 tâches (2.2.1 à 2.2.3), avec un bug pré-existant réel corrigé dans
identity-proxysans lequel le test empirique demandé par le plan (git cloneréel réussi) ne pouvait pas passer.
[2026-08-24 07:45] Jalon M2 - DoD : archivage S3 du terminal, branchement de storage.rs dans crate::vscode/crate::terminal
- Contexte : après la fusion des trois agents parallèles (2.1.x storage, 2.2.x git-identity, 6.0.x local-stack), le DoD du Jalon M2 avait deux lignes encore honnêtement
[ ]: le modulecrates/api-server/src/storage.rs(2.1.1-2.1.4) existait et était testé isolément, mais n'était appelé nulle part dans le reste du crate (vérifié pargrep) — aucune session terminal/VS Code réelle n'était jamais archivée. Aucune spec dédiée (contrairement à Keycloak/Forgejo/LiteLLM) ne précisait le périmètre exact de cet enregistrement : décision produit prise avec l'utilisateur avant d'écrire du code (voir question posée). - Décision retenue (validée par l'utilisateur) : enregistrer uniquement le terminal (
ttyd), pascode-server. Justification : le tunnelcode-serverne transporte que le protocole HTTP/WebSocket interne de l'éditeur (assets, LSP...), sans sémantique de rejeu exploitable une fois archivé, contrairement à la sortie d'un terminal (conventionasciinema: seule la sortie serveur→client est enregistrée, jamais la saisie utilisateur). - Implémentation :
- Nouveau module
crates/api-server/src/session_recorder.rs(SessionRecording) :start(storage, workshop_name)génère unsession_id(UUID v4), ouvre untokio::io::duplexinterne dont la moitié lecture est immédiatement consommée parS3StorageBackend::upload_session_archivedans une tâchetokio::spawn(jamais de session bufferisée entièrement en mémoire, y compris pour une session de plusieurs heures) ;write_chunk()pousse les octets au fil de l'eau, best-effort (un échec d'archivage ne doit jamais interrompre la session terminal elle-même) ;finish()ferme le tuyau et attend la fin réelle de l'upload en arrière-plan (au lieu d'un simpledrop), pour que la fin du tunnel WebSocket corresponde à un archivage réellement complet (ou en échec, déjà journalisé). crates/api-server/src/vscode.rs::proxy_to_guest_port: les 8 paramètres positionnels (dont le nouveaurecord_session: bool) ont déclenchéclippy::too_many_arguments— regroupés dans un structGuestProxyTarget. La copie bidirectionnelle du tunnel WebSocket (tokio::io::copy_bidirectional) a été remplacée parcopy_bidirectional_with_recording, une boucletokio::select!manuelle qui duplique (« tee ») uniquement la direction serveur→client vers leSessionRecordingle cas échéant, puis appellefinish()avant de retourner — comportement strictement identique àcopy_bidirectionalquandrecordingestNone(donc pour tous les tunnelscode-server).crate::terminalappelleproxy_to_guest_portavecrecord_session: true;crate::vscodeavecrecord_session: false.AppState.storage: Option<Arc<S3StorageBackend>>(nouveau champ, construit dansmain.rsviaS3StorageBackend::from_env()—NonesiS3_ENDPOINTabsent, fonctionnalité alors simplement désactivée, même convention quesession_auth).- Test réel exécuté (
crates/api-server/tests/session_recorder.rs, nouveau) : contre le vrai RustFS de dev — écrit plusieurs blocs sur unSessionRecording, appellefinish(), puis relit l'archive viaS3StorageBackend::get_session_stream(reconstruite avecsession_recorder.session_id(), exposé pour cet usage) et vérifie l'égalité byte-à-byte avec ce qui a été écrit. - Commandes exécutées et résultats :
cargo fmt --all -- --check # silencieux cargo clippy --workspace --all-targets -- -D warnings # 0 warning cargo test -p atelier-api-server # 9 (routes, controller live arrêté pour éliminer l'interférence de réconciliation déjà documentée) # + 1 (session_recorder.rs, vrai RustFS) + 1 (storage.rs, vrai RustFS) passed, 0 failed - Vérification de non-régression sur le cluster partagé :
atelier-controllerréel arrêté le temps de la vérification (élimine la course connue avecvscode_proxy_injects_real_session_auth_basic_header, confirmée en isolant le test — échoue avec le controller actif, passe sans), puis recompilé et relancé : logs de réconciliation de tous les Workshops réels examinés sans erreur,my-new-demo(Workshop réel actif, podmy-new-demo-parent4/4) toujoursRunningaprès redémarrage. - Statut : ✅ DoD du Jalon M2 entièrement clos (3/3 lignes
[x]).
[2026-08-24 01:30] Jalon M5 - Tâches 5.0.1, 5.1.1, 5.1.2, 5.3.1, 5.3.2, 5.3.3 : Redis dev, scaffolding services/pm-engine, base atelier_pm + RLS + checkpointer LangGraph
- Composant impacté :
deploy/dev/redis/dev-pod.yaml(nouveau),deploy/dev/redis/README.md(nouveau),services/pm-engine/(nouveau :pyproject.toml,Dockerfile,README.md,pm_engine/main.py,pm_engine/checkpointer.py,tests/,migrations/20260824000000_init_pm_engine.sql). - 5.0.1 — Redis dev (Streams) : Pod
atelier-redis-dev(redis:7.4-alpine,emptyDir, meme convention quedeploy/dev/postgres/deploy/dev/s3) deploye reellement surkind-atelier-dev. Les Streams sont une structure native de Redis (pas de module a activer) : cycle at-least-once complet verifie a la main (XADD->XLEN=1 ->XGROUP CREATE pm-engine-workers->XREADGROUP->XPENDING=1 message en attente ->XACK->XPENDING=0). Detail dansdeploy/dev/redis/README.md. - 5.0.2 — LiteLLM dev + modele d'embedding leger : BLOQUE, non traité, documenté ci-dessous plutôt que forcé.
- Au moment d'aborder cette tâche,
deploy/dev/llm-proxy/était en cours de déploiement/redéploiement en temps réel par l'agent parallèle dédié à M3 (kubectl get podsmontraitatelier-llm-proxy-<hash>enTerminatingà côté d'un nouveau pod de 24 secondes, plus un nouveau serviceatelier-llm-proxy-dbinédit) — collision quasi certaine si ce fichier avait été édité au même instant. - Conformément à la consigne de cette session en cas de conflit probable : aucune modification appliquée à
deploy/dev/llm-proxy/config.yamlnidev-deployment.yaml. Patch proposé ci-dessous, à appliquer par la prochaine session une fois M3 stabilisé (vérifiergit log -- deploy/dev/llm-proxy/avant d'appliquer, la structure du fichier a pu évoluer depuis) :--- a/deploy/dev/llm-proxy/config.yaml +++ b/deploy/dev/llm-proxy/config.yaml @@ - model_name: sonnet-premium litellm_params: model: anthropic/claude-3-5-sonnet-20241022 api_key: os.environ/ANTHROPIC_API_KEY + + # Modele d'embedding local, sans cle payante (Jalon M5, tache 5.0.2) : + # valide services/pm-engine (project_memories/pgvector) en dev sans + # dependre d'une cle OpenAI facturee. LiteLLM route ce nom vers un + # backend HuggingFace local (le conteneur telecharge le modele au + # premier appel ; prevoir un volume/cache persistant si le pod est + # recree souvent). Dimension native 384 != VECTOR(1536) de + # `project_memories` (calibree sur text-embedding-3-small) : n'ecrit + # PAS directement dans cette colonne sans adaptation (re-projection ou + # migration de colonne dediee aux tests dev) -- limite assumee, a + # trancher avant d'utiliser ce modele pour peupler la table reelle. + - model_name: embedding-dev-local + litellm_params: + model: huggingface/sentence-transformers/all-MiniLM-L6-v2 - Non vérifié empiriquement (LiteLLM pas stable au moment du passage) : à revalider avec
curl -X POST .../v1/embeddings -d '{"model":"embedding-dev-local","input":"test"}'une fois appliqué. - 5.1.1/5.1.2 — Scaffolding
services/pm-engine:pyproject.toml(Python ≥3.12, FastAPI/LangGraph/langgraph-checkpoint-postgres/psycopg[binary]/Redis/AsyncPG/Pydantic/HTTPX),pm_engine/main.pyavec un unique endpoint/health(aucune machine d'états LangGraph — tâches 5.2.x hors périmètre, dépendantes du serveur MCP M4 pas encore construit).Dockerfilemulti-stage (builderavecuv, image finalepython:3.12-slimnon-root, ~205 Mo). - 5.3.1/5.3.2 — Base
atelier_pm+ RLS :CREATE DATABASE atelier_pm+CREATE EXTENSION vectorsur l'instance PostgreSQL de dev réelle. Tableproject_memories(VECTOR(1536), alignée surtext-embedding-3-small) avec indexivfflat/vector_cosine_opset RLS (ENABLE+FORCE ROW LEVEL SECURITY, policy surcurrent_setting('app.current_tenant')). Nouveau rôle non-superutilisateuratelier_pm_app(NOBYPASSRLS), même convention queatelier_appdocumentée dansdeploy/dev/postgres/README.md— piège rencontré et documenté dans la migration :ALTER DEFAULT PRIVILEGES ... GRANT ... ON TABLESne couvre pas la séquence implicite deBIGSERIAL(nextval()échoue avec "permission denied for sequence" malgré le GRANT sur la table) ; il faut aussiGRANT USAGE, SELECT ON SEQUENCES. - 5.3.3 — Checkpointer
AsyncPostgresSaver:pm_engine/checkpointer.py(context managerbuild_checkpointer(database_url), appelle.setup()puis retourne l'instance). Nécessitait une dépendance non déclarée parlanggraph-checkpoint-postgres(psycopg[binary], sinonImportError: no pq wrapper available), ajoutée àpyproject.toml. - Preuve empirique / Test exécuté (aucun mock, contre le cluster kind partagé et l'instance PostgreSQL réelle) :
kubectl exec atelier-redis-dev -- redis-cli XADD/XLEN/XGROUP CREATE/XREADGROUP/XPENDING/XACK # cycle at-least-once complet, voir deploy/dev/redis/README.md cd services/pm-engine && uv venv .venv --python 3.12 && uv pip install -e ".[dev]" --python .venv/bin/python # installation reelle, 0 erreur .venv/bin/uvicorn pm_engine.main:app --port 8100 & curl http://127.0.0.1:8100/health # {"status":"ok"} docker build -t atelier-pm-engine:dev . # succes, image finale 205MB docker run -d -p 18100:8100 atelier-pm-engine:dev curl http://127.0.0.1:18100/health # {"status":"ok"} (via le conteneur, pas juste le venv local) .venv/bin/pytest -q # 2 passed (test_health.py, test_checkpointer.py contre DATABASE_URL_PM reel via le port-forward 5433 deja actif) kubectl exec atelier-postgres-dev -- psql -U atelier_admin -d postgres -c 'CREATE DATABASE atelier_pm;' kubectl exec -i atelier-postgres-dev -- psql -U atelier_admin -d atelier_pm < services/pm-engine/migrations/20260824000000_init_pm_engine.sql # succes # RLS verifiee avec deux tenants et le role non-superutilisateur atelier_pm_app (jamais atelier_admin) : PGPASSWORD=... kubectl exec -i atelier-postgres-dev -- psql -U atelier_pm_app -d atelier_pm -c "SET app.current_tenant='alice'; SELECT ... FROM project_memories;" # -> 1 ligne (alice uniquement) PGPASSWORD=... kubectl exec -i atelier-postgres-dev -- psql -U atelier_pm_app -d atelier_pm -c "SET app.current_tenant='bob'; SELECT ... FROM project_memories;" # -> 1 ligne (bob uniquement) kubectl exec atelier-postgres-dev -- psql -U atelier_admin -d atelier_pm -c "SET app.current_tenant='alice'; SELECT ... FROM project_memories;" # -> 2 lignes (superutilisateur, RLS ignoree - coherent avec la decouverte deja documentee pour atelier_apiserver) # AsyncPostgresSaver.setup() cree reellement ses tables, verifie apres le test pytest : kubectl exec atelier-postgres-dev -- psql -U atelier_admin -d atelier_pm -c '\dt' # checkpoints, checkpoint_writes, checkpoint_blobs, checkpoint_migrations, project_memories # Etat du cluster verifie intact apres coup : kubectl get pods | grep -E 'my-new-demo-parent|atelier-redis-dev|atelier-postgres-dev' # tous Running, my-new-demo-parent non affecte - Statut : ✅ Validé pour 5.0.1, 5.1.1, 5.1.2, 5.3.1, 5.3.2, 5.3.3. ⚠️ 5.0.2 non réalisé (bloqué par un déploiement M3 concurrent en cours au moment du passage) — patch proposé mais non appliqué, à reprendre par la session suivante.
[2026-08-24 08:34] Interruption de session - État des tâches M3 et M6 en cours, passage de relais
Session sess-6f3eef77 interrompue à la demande de l'utilisateur. Deux tâches restent verrouillées [-/...] dans docs/specs/PLAN-ACTION-GLOBAL.md, dans des états différents — détail ci-dessous pour la session suivante.
M3 (tâches 3.1.1 à 3.2.1, verrou [-/claude-code/sess-6f3eef77-f]) : code terminé et testé, mais PAS ENCORE fusionné dans main.
- Le travail a été fait par un agent en arrière-plan dans un worktree git isolé, avec deux commits réels et vérifiés (diff relu intégralement par la session parente avant tentative de fusion) : 8eac102 (feat(controller): Virtual Keys LiteLLM isolees par Workshop (Jalon M3)) et 5303a19 (docs(progress): consigner une race observee entre le controller live et cargo test --workspace), sur la branche worktree-agent-a8d2ccd747d91380d (encore présente localement, git worktree list la montre sous .claude/worktrees/agent-a8d2ccd747d91380d).
- Tentative de fusion abandonnée volontairement (git merge --abort), pour deux raisons concurrentes :
1. Conflit de fusion réel dans crates/controller/src/reconcile.rs et crates/controller/tests/reconcile.rs : la branche de l'agent avait divergé de main avant la fusion de l'agent Git-HTTPS (744a665, tâches 2.2.x), qui touche aussi ensure_parent_pod/ReconcileCtx dans les mêmes zones du fichier — nécessite une résolution manuelle attentive (pas un simple conflit de contenu dupliqué comme pour M5), pas traitée avant l'arrêt demandé.
2. Pendant la tentative de fusion, un processus tiers (une autre session/agent travaillant sur ce même dépôt partagé, cf. protocole multi-agents d'AGENTS.md) a exécuté un git pull/rebase/push réel sur origin/main en parallèle — main est passé de d93173f à b35cbbf (Add CodeQL analysis workflow configuration, en passant par 069baca chore: update documentation qui a rebasé/aplati la fusion M5 faite plus tôt dans cette session). Ceci a rendu l'état de fusion en cours incohérent (conflits résolus contre un HEAD qui n'était plus le bon) : git merge --abort a ramené le dépôt à un état propre correspondant au nouveau HEAD réel (b35cbbf), sans perte du travail de l'agent M3 (préservé intact sur sa branche).
- Pour reprendre : git merge worktree-agent-a8d2ccd747d91380d (ou git rebase main de cette branche puis fast-forward) depuis l'état actuel de main, résoudre le conflit réel dans reconcile.rs/tests/reconcile.rs (les deux fonctionnalités — Git HTTPS identity_injection_rules et Virtual Key LiteLLM identity_injection_rules — doivent coexister : les deux ajoutent une règle à la même liste, la fusion doit conserver les deux push), puis revalider cargo test --workspace/clippy/fmt avant de committer. Les tâches 3.1.1-3.2.1 et le DoD de M3 ne doivent être cochés [x] qu'après cette fusion réelle et cette revalidation — PAS encore fait, le verrou [-/claude-code/sess-6f3eef77-f] reste donc intentionnellement en place dans le plan.
M6 (tâches 6.2.1 à 6.5.2, verrou [-/claude-code/sess-6f3eef77-h]) : agent encore en cours d'exécution au moment de l'arrêt, aucun résultat rapporté.
- Agent en arrière-plan (worktree .claude/worktrees/agent-a1b58166d0b22db1f, branche worktree-agent-a1b58166d0b22db1f) lancé pour construire le chart Helm complet (charts/atelier/) et le guide administrateur. Aucune notification de complétion reçue avant l'arrêt de session — état d'avancement réel inconnu (peut être toujours en cours, ou terminé sans que la notification ait encore été traitée). La session suivante doit vérifier l'état de cet agent/worktree avant de relancer quoi que ce soit sur ce périmètre, pour éviter un travail dupliqué.
Vérification finale avant interruption : my-new-demo/my-new-demo-parent (Workshop réel actif) toujours Running, cluster intact.
[2026-08-24 09:00] Jalon M3 - Tâches 3.1.1 à 3.2.1 : Virtual Keys LiteLLM par Workshop, budgets stricts et révocation
- Composant impacté :
crates/controller/src/litellm.rs(nouveau),crates/controller/src/openbao.rs(ensure_llm_virtual_key_secret),crates/controller/src/reconcile.rs,crates/controller/src/lib.rs,crates/controller/tests/litellm.rs(nouveau),crates/controller/tests/reconcile.rs,deploy/dev/llm-proxy/config.yaml,deploy/dev/llm-proxy/dev-deployment.yaml. - Contexte : jusqu'ici,
ANTHROPIC_AUTH_TOKENétait un jeton statique unique (ATELIER_LLM_PROXY_AUTH_TOKEN), baked une seule fois dans/etc/environmentau moment du build de l'image (crates/image-builder::inject_net_proxy_config), partagé par tous les Workshops (limite documentée dansdeploy/dev/llm-proxy/README.md). Ce jalon introduit une Virtual Key LiteLLM isolée par Workshop, avec budget plafonné (WorkshopSpec.resources.maxLlmBudgetUsd) et TTL court, régénérée à chaud à chaque (re)création du pod parent. - Décision de conception documentée (écart assumé vis-à-vis du libellé littéral de 3.1.3, "injecter dans
/etc/environment") : réécrire/etc/environmentdu guest à la reprise post-suspension est impossible sans rebuild d'image (seulimage-builder, exécuté une fois au build, y écrit réellement —vm-supervisorboote le rootfs tel quel, sans init capable de recevoir des paramètres au boot). Modifiernet-proxy/identity-proxypour ouvrir un nouveau canal (à la manière decrates/net-proxy/src/metadata.rspoursession_auth) était par ailleurs hors du périmètre assigné à cet agent (ces deux crates sont explicitement exclues). Solution retenue, à coût d'implémentation nul sur ces deux crates : réutilisation telle quelle du mécanisme générique déjà en place pour l'injection de credentials Git (Workshop.spec.identity_injection_rules,crates/identity-proxy/src/rules.rs/proxy.rs) — la Virtual Key est écrite dans OpenBao (secret/workshops/<name>/llm_key, champvalue), une règle d'injectionAuthorization: Bearer <value>sur l'hôte internellm-proxyest ajoutée à la volée àATELIER_IDENTITY_INJECTION_RULES.identity-proxy, sur le chemin de sortie, REMPLACE alors l'en-têteAuthorizationstatique baked au build par la vraie Virtual Key de ce Workshop (with_injected_headerremplace, ne duplique pas — comportement déjà testé danscrates/identity-proxy/src/http.rs). L'agent dans la microVM n'a jamais connaissance de la vraie clé, exactement comme pour le credential Git. Conséquence assumée : cette isolation nécessite OpenBao EN PLUS de LiteLLM — sans OpenBao, aucun canal de livraison n'existe et le controller se contente de logguer l'échec (le jeton statique partagé historique reste actif, zéro régression). Justification complète documentée en tête decrates/controller/src/litellm.rs. - Génération de la Virtual Key : uniquement au moment où le pod parent va être créé (
pod_will_be_created, calculé viapods.get_opt().is_none()avant construction de la spec) — jamais à chaque reconcile d'un pod déjà en place, dont l'envest de toute façon immuable une fois créé. Couvre naturellement le provisioning initial ET la reprise post-suspension (ensure_suspendedsupprime le pod, la reconciliation suivante le recrée via ce même chemin). - Clé éphémère de build (3.1.4) : alias dédié
atelier-build-<name>(distinct deatelier-wks-<name>du pod parent), généré une seule fois à la création du Jobimage-builder(même gardejob_already_exists), injecté dans/etc/environmentdu build comme avant. Révoquée dès que le Job atteint un état terminal (succeeded/failed) — limite assumée et documentée : siimage-builderpatchestatus.imageDigestavant que le controller ne revoie ce Job comme terminé lors d'un cycle suivant, la révocation explicite peut être manquée pour ce Workshop ; un TTL court dédié (BUILD_VIRTUAL_KEY_TTL= 30 min, plus court que le TTL runtime de 2h) sert de filet de sécurité. - Révocation à la suppression (3.2.1) :
cleanup()(exécuté par le handlerEvent::Cleanupdu finalizeratelier.dev/cleanup) appelle désormaislitellm::LiteLlmClient::delete_virtual_key(&litellm::workshop_key_alias(name))avant de libérer le rôle OpenBao.cleanup()rendupub(commeapply) pour être testable directement sans démarrer unControllercomplet, même convention que les tests OpenBao existants. - Infrastructure de dev déployée (absente au début de cette session) : un vrai LiteLLM (
ghcr.io/berriai/litellm:main-stable) déployé surkind-atelier-devviadeploy/dev/llm-proxy/dev-deployment.yaml, qui déploie désormais AUSSI une instance Postgres DÉDIÉE (atelier-llm-proxy-db, pod+Service séparés) :/key/generate//key/deleteexigent une base Postgres côté LiteLLM (constaté en pratique :500 "DB not connected"sans elle) — une instance dédiée plutôt qu'une base supplémentaire suratelier-postgres-dev(partagée parapi-serverET un Workshop réel actif sur ce cluster), pour zéro risque d'interférence avec cette instance partagée. - Adaptation du test réel en l'absence de clés de provider payantes : ni DeepSeek ni Anthropic réels disponibles dans cet environnement. Ajout d'un modèle de test dédié dans
deploy/dev/llm-proxy/config.yaml(atelier-budget-test) utilisant la fonctionnalité nativemock_responsede LiteLLM (aucun appel HTTP sortant vers un fournisseur réel, jamais de coût facturé), combinée àmodel_info.input_cost_per_token/output_cost_per_tokenexplicites pour porter un coût non nul par appel (LiteLLM n'a pas de tarif par défaut pour un nom de modèle arbitraire). LiteLLM lui-même calcule et enforce le budget de la Virtual Key exactement comme il le ferait avec un vrai modèle payant — aucune simulation côté test Rust. - Preuve empirique / Séquence réellement observée contre le vrai LiteLLM déployé (
crates/controller/tests/litellm.rs::generates_enforces_budget_and_revokes_a_real_virtual_key) :POST /key/generate {key_alias, duration:"2h", max_budget:1.0} -> 200, "key":"sk-..." POST /chat/completions (modele atelier-budget-test, cle ci-dessus) -> 200 (sous le budget) # cout enregistre de maniere asynchrone cote LiteLLM (constate en pratique, boucle de relecture /key/info) GET /key/info?key=sk-... -> spend: 15.0 (> budget 1.0) POST /chat/completions (meme cle) -> 429 "Budget has been exceeded! ..." POST /key/delete {key_aliases:[alias]} -> 200 {"deleted_keys":[alias]} POST /chat/completions (meme cle) -> 401 Authentication Error POST /key/delete {key_aliases:[alias]} (deuxieme appel) -> 404 "No keys found" (traite comme succes, idempotence) - Preuve empirique supplémentaire (
crates/controller/tests/reconcile.rs::apply_wires_the_llm_virtual_key_injection_rule_when_configured, contre un vrai OpenBao ET un vrai LiteLLM) :apply()écrit une vraie Virtual Key danssecret/workshops/<name>/llm_key(vérifié en relisant directement OpenBao), câble la règle d'injectionhost: "llm-proxy"sur un vrai Pod créé (vérifié en relisantspec.containers[identity-proxy].env), la clé existe côté LiteLLM (/key/info→ 200) ; puiscleanup()la révoque effectivement (/key/infoaprès suppression → non-200). - Incident rencontré et résolu en cours de route (même nature que celui déjà documenté au Jalon M1) : le premier essai de ce nouveau test échouait systématiquement avec des règles d'injection vides côté serveur alors que le code en mémoire les calculait correctement — diagnostiqué comme une interférence du vrai controller déjà en cours d'exécution sur ce cluster partagé (
Api::all, il réconciliait aussi les Workshops de test créés directement par le test, avec SON PROPREReconcileCtxnon configuré pour LiteLLM, écrasant via Server-Side Apply — même field manageratelier-controller— la spec fraîchement posée par le test). Confirmé en arrêtant ce controller (le test passe alors systématiquement), reconstruit puis relancé avec la nouvelle version et les variables LiteLLM positionnées. - Preuve empirique / Tests exécutés :
Contrôleur live réel arrêté (
export ATELIER_LLM_PROXY_ADDR=127.0.0.1:4000 export ATELIER_LLM_PROXY_AUTH_TOKEN=<LITELLM_MASTER_KEY du Secret atelier-llm-proxy-dev> export OPENBAO_ADDR=http://127.0.0.1:8200 export OPENBAO_TOKEN=root cargo test -p atelier-controller # 17 passed (10 unitaires + litellm.rs + 6 reconcile.rs), 0 failed cargo test --workspace # 92 passed (sans OPENBAO_ADDR/ATELIER_LLM_PROXY_ADDR, silencieusement ignores), 0 failed cargo fmt --all -- --check # silencieux cargo clippy --workspace --all-targets -- -D warnings # silencieuxpkill), reconstruit depuis cette session, relancé avec les mêmes variables d'environnement que l'instance précédente (capturées via/proc/<pid>/environ) PLUSATELIER_LLM_PROXY_ADDR/ATELIER_LLM_PROXY_AUTH_TOKEN:my-new-demo-parenttoujours4/4 Running, aucun redémarrage (le nouveau code ne recrée jamais un pod parent déjà existant — gardepod_will_be_created), zéro erreur touchant un Workshop réel préexistant sur toute la durée de cette session (voir limite (4) ci-dessous pour la seule catégorie d'erreurs observée, confinée aux Workshops éphémères créés parcargo test --workspacelui-même). - Limites assumées : (1) isolation par Workshop nécessite OpenBao + LiteLLM tous deux configurés (dégradation gracieuse vers le jeton statique partagé sinon) ; (2) révocation de la clé de build non garantie si le controller manque la fenêtre où le Job est visible comme terminé (TTL court en filet de sécurité) ; (3)
generate_virtual_keyn'est pas idempotent côté LiteLLM (un appel avec un alias déjà utilisé crée une clé supplémentaire) — mitigé par la gardepod_will_be_created/job_already_exists, jamais appelé pour un pod/Job déjà existant ; (4) constaté empiriquement : en relançantcargo test --workspaceune seconde fois APRÈS avoir redémarré le controller live (donc avec un vrai controller qui réconcilie aussi, en concurrence, les Workshops éphémères que les tests créent/suppriment très rapidement), le controller live a authentiquement tenté de générer une Virtual Key pour un Workshop de test dont le pod parent était en cours de création par un cycle de reconciliation précédent du MÊME controller — LiteLLM a rejeté le second appel (400 "Key with alias ... already exists"), journalisé enERRORmais non bloquant (ensure_parent_podcontinue sans isolation pour ce cycle). Confiné aux Workshops de test à durée de vie de quelques secondes ; sans impact surmy-new-demoni sur un Workshop utilisateur réel (créé une seule fois, jamais recréé/re-patché en rafale par du code externe). Reproduit et confirmé isolément :curl -X POST /key/generateavec unkey_aliasdéjà pris renvoie exactement cette erreur400. Leçon retenue pour la suite : ne pas relancer la suite de tests d'intégration pendant qu'un controller live tourne sur le même cluster (redémarrer les tests avec le controller arrêté, comme fait pour la validation principale ci-dessus) — capturé ici pour la prochaine session. - Statut : ✅ Validé pour les tâches 3.1.1 à 3.2.1 et le DoD du Jalon M3.
[2026-08-24 20:30] Fusion effective de la branche M3 (worktree-agent-a8d2ccd747d91380d) dans main
- Contexte : reprise directe de la tâche laissée en suspens par l'entrée "Interruption de session" ci-dessus. Le code M3 (commits
8eac102/5303a19) était terminé et testé mais jamais fusionné,mainayant avancé entre-temps. - Résolution du conflit réel dans
crates/controller/src/reconcile.rs/tests/reconcile.rs: les deux fonctionnalités qui divergaient (injection Git HTTPS de M2 et Virtual Keys LiteLLM de M3, toutes deux basées surWorkshop.spec.identity_injection_rules/ReconcileCtx) ont été fusionnées manuellement pour coexister —ReconcileCtxporte désormais à la foisgit_identityetlitellm, etensure_parent_podcalculeeffective_identity_injection_rulesen poussant d'abord la règle Git (si configurée) puis la règle LiteLLM (sipod_will_be_createdet OpenBao+LiteLLM configurés).crates/controller/tests/reconcile.rs: un site de construction deReconcileCtx(testgenerates_enforces_budget_and_revokes_a_real_virtual_key) avait été oublié côté branche M3 et ne compilait plus après fusion (champgit_identitymanquant) — corrigé (git_identity: None). docs/PROGRESS.md/docs/specs/PLAN-ACTION-GLOBAL.md: conflits de fusion (append concurrent d'entrées de journal/de cases à cocher) résolus en conservant les deux contributions dans l'ordre chronologique, sans perte de contenu.- Preuve empirique :
cargo fmt --all -- --check # silencieux cargo clippy --workspace --all-targets -- -D warnings # silencieux (apres correction du site ReconcileCtx manquant) kubectl port-forward svc/atelier-postgres-dev 5433:5432 & # requis, aucun port-forward actif au demarrage de cette session cargo test --workspace # 100% vert, 0 failed (tous crates, y compris atelier-controller/atelier-api-server) - Vérification de non-régression : un
atelier-controllerréel tournait déjà (processusroot, PID 9383, démarré 20:15 — propriété différente des sessions précédentes, contexte inconnu) ; sans accès root pour l'arrêter/le relancer avec le binaire fusionné, cette session n'a pas redémarré le controller live (le code fusionné n'est donc pas encore actif sur le cluster réel — à faire par la prochaine session ayant les droits nécessaires).my-new-demo-parenta été trouvé0/4 UnexpectedAdmissionError(kubectl describe pod: "Allocate failed due to no healthy devices present ... atelier.dev/kvm", événement vieux de 13 min au moment du constat) — problème préexistant et sans rapport avec cette fusion (aucun code n'a été déployé/redémarré par cette session avant ce constat) : signalé ici pour la session suivante, probable flakiness du DaemonSetatelier-kvm-device-plugin(7 redémarrages en 4j20h) plutôt qu'une régression introduite par M3. - Nettoyage : worktree
.claude/worktrees/agent-a8d2ccd747d91380det brancheworktree-agent-a8d2ccd747d91380dsupprimés après fusion réussie. Commit de fusion :926997csurmain. - Statut : ✅ M3 fusionné dans
main,[-/claude-code/sess-6f3eef77-f]remplacé par[x]dansdocs/specs/PLAN-ACTION-GLOBAL.md. ⚠️ Point ouvert signalé : redémarrage du controller live à faire, et podmy-new-demo-parentà investiguer (device plugin KVM), tous deux hors périmètre de cette session.
[2026-08-24 21:10] Jalon M6 - Chart Helm charts/atelier + tache 6.4.2 (NeedsRestartForUpgrade) : fusionne dans main
Reprise de la tache M6 laissee en suspens (verrou [-/claude-code/sess-6f3eef77-h], tout le travail non commite dans le worktree .claude/worktrees/agent-a1b58166d0b22db1f).
6.4.2 (detection reelle, absente au moment de l'interruption) : le champ WorkshopStatus.upgrade_state existait deja (schema CRD + carry-forward) mais rien ne le positionnait jamais (grep -rn "NeedsRestartForUpgrade" crates/ ne remontait que la declaration). Implemente dans crates/controller/src/reconcile.rs::ensure_parent_pod :
- pod_spec_template_hash(spec: Option<&PodSpec>) : hash deterministe (DefaultHasher sur la serialisation JSON du PodSpec), calcule AVANT toute mutation des metadonnees du pod (pour ne jamais dependre de lui-meme).
- Le hash du template desire est ecrit dans l'annotation atelier.dev/template-hash a chaque creation de pod.
- A chaque reconcile d'un pod deja existant, ce hash est compare a celui porte par l'annotation en place : divergence -> status.upgrade_state = Some(NeedsRestartForUpgrade), sans jamais recreer/supprimer le pod (contrainte deja documentee dans ce fichier : un pod parent peut heberger une microVM active). Meme hash (ou pod fraichement cree) -> None.
- Deux tests unitaires (reconcile::template_hash_tests) + un test d'integration reel contre le cluster kind partage (apply_flags_needs_restart_for_upgrade_without_recreating_pod, nouveau dans crates/controller/tests/reconcile.rs) : cree un Workshop, laisse le pod parent se creer (verifie upgrade_state == None), patch directement l'annotation du pod en place pour simuler un controller plus ancien, relance apply(), verifie upgrade_state == Some(NeedsRestartForUpgrade) et que pod.metadata.uid n'a pas change (jamais de recreation forcee).
Validation du chart Helm par deploiement reel (namespace dedie atelier-helm-test sur kind-atelier-dev, isole de my-new-demo ; nettoye avec helm uninstall+kubectl delete namespace en fin de session) :
helm lint charts/atelier # 0 erreur
helm template atelier charts/atelier -f charts/atelier/values-test.yaml # rendu valide
kubectl apply --dry-run=client -f <rendu> # tous les manifests acceptes par l'API server
helm lint/helm template seuls) :
1. Keycloak refusait de demarrer ("Key material not provided to setup HTTPS") : start (mode production) exige du materiel TLS local si --http-enabled n'est pas explicitement pose, meme derriere un Ingress qui termine deja le TLS. Corrige (templates/infra/keycloak-deployment.yaml, ajout de --http-enabled=true).
2. sslmode=require (valeur par defaut, pensee pour une base geree externe type RDS) incompatible avec le PostgreSQL embarque par ce chart (aucun certificat TLS configure sur le StatefulSet) : cassait LiteLLM/Prisma au demarrage ("server does not support TLS"). Nouveau helper atelier.postgresSslMode (templates/_helpers.tpl) : force disable uniquement quand le Postgres embarque est utilise ET que l'utilisateur a laisse la valeur par defaut — un choix explicite reste respecte.
3. litellm/keycloak/rustfs ne redemarraient jamais quand le contenu de leur Secret changeait (envFrom: secretRef, jamais suivi par Kubernetes lui-meme) : un helm upgrade qui change un mot de passe Postgres laissait le pod tourner indefiniment avec l'ancienne valeur deja injectee. Corrige par des annotations checksum/* sur les 3 templates de pods concernes (pattern Helm standard).
4. keycloak-init-job dependait de curl, absent de l'image officielle keycloak/keycloak (base minimale) : la boucle d'attente ne demarrait jamais. Remplace par une boucle directement sur kcadm.sh config credentials (verifie a la fois la disponibilite HTTP et que l'authentification admin fonctionne).
5. db-migrate-job (raw psql -f) dupliquait, de facon non idempotente, les migrations que sqlx::migrate! applique deja au demarrage de api-server/controller (verifie dans le code : les deux binaires appellent sqlx::migrate!("./migrations")) : un deuxieme helm upgrade le rejouait entierement et echouait systematiquement ("relation ... already exists"), bloquant en cascade tous les hooks de poids superieur (keycloak-init/openbao-init/s3-init ne s'executaient plus du tout). Desactive par defaut (initJobs.dbMigrate.enabled: false), rationale documentee dans values.yaml et docs/admin-guide.md — a reactiver uniquement apres reecriture avec un outil qui suit reellement l'etat d'application (sqlx-cli migrate run).
Resultat apres corrections (meme namespace de test) : db-init, keycloak-init, openbao-init, s3-init tous Completed ; keycloak, litellm, rustfs, forgejo, openbao, postgresql, redis, pm-engine, controller tous Running/Ready. Seul api-server restait CrashLoopBackOff, root-cause identifiee comme non liee au chart : imagePullPolicy: Never + image locale atelier-api-server:dev construite le 2026-08-23 (avant les evolutions de routes.rs du 2026-08-24, ou /health/readiness exigeait encore une authentification) — confirme par docker image inspect --format '{{.Created}}'. Le code source ACTUEL de crates/api-server/src/routes.rs expose deja ces routes sans authentification. Documente dans docs/admin-guide.md, section "Limites Connues".
Verification de non-regression : my-new-demo-parent (Workshop reel, default) trouve Failed/0/4 UnexpectedAdmissionError (atelier.dev/kvm device plugin) AVANT toute action de cette session — probleme deja documente dans l'entree M3 ci-dessus, sans rapport avec ce travail. Namespace de test atelier-helm-test entierement isole (jamais de ressource default touchee hormis des pods tmp-curl* de diagnostic, supprimes).
Commandes de validation finale (sur main, apres fusion) :
cargo fmt --all -- --check # silencieux
cargo clippy --workspace --all-targets -- -D warnings # 0 warning
cargo test --workspace # 100% vert (controller live arrete, port-forward Postgres actif)
helm lint charts/atelier # 0 erreur
helm template atelier charts/atelier -f charts/atelier/values-test.yaml # rendu valide
Fusion : conflit reel dans crates/common/src/lib.rs (liste d'exports pub use crd::{...}), crates/controller/src/reconcile.rs (imports), crates/controller/tests/reconcile.rs (deux tests distincts alignes par erreur par le diff — le test M2 apply_wires_the_git_identity_injection_rule_when_configured et le nouveau test M6 apply_flags_needs_restart_for_upgrade_without_recreating_pod conserves tous les deux, en entier) et mkdocs.yml (section RFC de main + entree admin-guide.md du worktree, combinees). Worktree .claude/worktrees/agent-a1b58166d0b22db1f et branche associee supprimes apres fusion reussie.
Statut : ✅ Jalon M6 fusionne dans main, tache 6.4.2 completee et testee (code + chart absents lors de l'interruption). [-/claude-code/sess-6f3eef77-h] remplace par [x] pour 6.2.1-6.5.2 dans docs/specs/PLAN-ACTION-GLOBAL.md. ⚠️ DoD partiellement coche : Ingress/TLS reel et scripts local-stack.sh/teardown-stack.sh restent [ ] (jamais valides avec un certificat cert-manager reel ni revises dans cette session) — voir le DoD M6 pour le detail exact.
[2026-08-24 21:45] Jalon M4 - Serveur MCP externe (/v1/mcp) : outils lifecycle livres, exec_in_workshop hors perimetre (decision produit)
Contexte : reprise du Jalon M4 (aucun travail anterieur, premiere session dessus). Avant d'ecrire du code, verification que exec_in_workshop (tache 4.2.3) et le confinement de securite (4.2.4) supposent des mecanismes absents du code actuel :
- exec_in_workshop necessite un canal "envoie cette commande au guest, recois stdout/stderr/exit code" entre api-server et la microVM — le seul canal existant vers le guest est le tunnel HTTP/WebSocket interactif vers ttyd (crate::terminal), pas une RPC d'execution de commande. Aucune trace de ce mecanisme dans vm-supervisor/mcp-gateway.
- Le confinement de securite (Egress Lockdown + snapshot d'urgence sur anomalie reseau) suppose une detection d'anomalie dans net-proxy qui n'existe pas non plus (grep -rn "lockdown\|anomal" crates/ : aucun resultat).
Decision prise avec l'utilisateur (question posee explicitement) : scoper cette session sur 4.1.1-4.1.4 + 4.2.1 (serveur MCP + outils lifecycle), laisser 4.2.2-4.2.4 non commences plutot que d'improviser une conception non validee pour un canal d'execution guest inexistant.
Implementation (crates/api-server/src/mcp_server.rs, nouveau module) :
- SDK officiel rmcp 3.1.4 (deja utilise par crates/mcp-gateway), reutilise plutot que d'ecrire le JSON-RPC MCP a la main. Adaptation de transport documentee : la spec d'origine (2024-11-05, GET /sse + POST /messages separes) n'est plus implementee par ce SDK — seul le transport Streamable HTTP courant (un seul endpoint /v1/mcp, GET pour le flux SSE, POST pour les appels) est disponible cote serveur, et c'est ce que parlent les clients MCP actuels (dont Claude Desktop/Cursor). GET /v1/mcp/ws (WebSocket brut) n'a pas ete implemente : le bridge JSON-RPC <-> WebSocket est faisable (rmcp expose sink_stream::SinkStreamTransport pour ca) mais propager l'identite JWT jusqu'aux handlers d'outils sans le mecanisme http::request::Parts qu'utilise Streamable HTTP demandait une exploration plus poussee des internals de rmcp (GetExtensions/extensions_mut() sur les messages JSON-RPC) — juge hors budget de cette session, laisse [ ].
- Fast-Fail (4.1.2) : ensure_state_creating_dependencies_reachable sonde LiteLLM (GET /health/liveliness) et OpenBao (GET /v1/sys/health) avant create_workshop, uniquement si CONFIGURES (AppState.litellm_addr/openbao_addr, meme convention "optionnel si absent" que le reste du projet). Adaptation necessaire : la spec demandait un HTTP 503, structurellement impossible a renvoyer une fois a l'interieur d'un appel d'outil MCP reussi au niveau transport (Streamable HTTP repond toujours 200, le JSON-RPC porte sa propre erreur) — renvoie donc une erreur JSON-RPC explicite a la place.
- Auth (4.1.4) : /v1/mcp monte dans le meme Router protected que le reste de l'API (crate::routes::router), derriere require_auth — aucune logique d'auth propre a ce module. L'identite JWT (AuthenticatedUser) est relue a chaque appel d'outil via http::request::Parts (mecanisme documente par rmcp::transport::streamable_http_server::tower::StreamableHttpService, section "Accessing HTTP request data from tool handlers").
- 6 outils lifecycle (4.2.1) : create_workshop, list_workshops, get_workshop_status, suspend_workshop, resume_workshop, delete_workshop — reutilisent directement crate::routes::{workshops_api, ensure_owner, validate_name, patch_desired_state} (rendus pub(crate)), memes regles de visibilite que la route REST equivalente (404 generique, pas 403, pour ne jamais confirmer l'existence d'un Workshop a un tiers).
- Piege rencontre : atelier_common::{DevcontainerSource, WorkshopResources} derivent JsonSchema de schemars 0.8 (via kube-derive), incompatible avec schemars 1.x qu'exige la macro #[tool] de rmcp 3.1.4 (deux versions majeures de la meme crate dans le graphe de dependances, erreur de compilation explicite). Les params de create_workshop sont donc des champs a plat (devcontainer_repo, cpu, memory...), reconstruits manuellement en types atelier_common a l'interieur du handler.
- Autre piege : StreamableHttpClientTransportConfig.auth_header ne doit PAS inclure le prefixe Bearer — le SDK l'ajoute lui-meme via reqwest::RequestBuilder::bearer_auth. Un double-prefixage ("Bearer {jwt}") produit un en-tete Authorization: Bearer Bearer <jwt> que le serveur rejette (decode_header echoue) — corrige apres un premier echec de test reel.
- Autre piege : deux versions majeures de reqwest coexistent dans le graphe (0.12 pour le reste du workspace, 0.13 vendorise par rmcp pour son client Streamable HTTP) — StreamableHttpClient n'est implemente QUE pour le reqwest::Client de rmcp. Resolu par un alias de dependance (reqwest013 = { package = "reqwest", version = "0.13" }, dev-dependency uniquement) plutot que de faire converger tout le workspace.
Tests reels executes (crates/api-server/tests/mcp.rs, nouveau — vrai client MCP rmcp en transport Streamable HTTP reqwest, contre un vrai routeur Axum servi sur un port TCP reel, vrai kube::Client contre kind-atelier-dev, vraie crypto JWT) :
cargo test -p atelier-api-server --test mcp
# mcp_lifecycle_tools_drive_a_real_workshop ... ok
# (tools/list annonce les 6 outils ; create_workshop -> verifie via l'API
# Kubernetes directement, pas seulement la reponse MCP ; list_workshops
# -> get_workshop_status -> suspend_workshop (desiredState=Suspended)
# -> resume_workshop (desiredState=Running) -> delete_workshop)
# mcp_tools_enforce_ownership_isolation ... ok
# (un second utilisateur JWT ne peut pas lire get_workshop_status du
# Workshop d'un autre : erreur JSON-RPC generique "workshop introuvable",
# jamais une confirmation d'existence)
# mcp_create_workshop_fast_fails_when_litellm_unreachable ... ok
# (port TCP local reellement ferme, pas un mock : create_workshop refuse
# et aucun Workshop n'est cree)
cargo fmt --all -- --check # silencieux
cargo clippy --workspace --all-targets -- -D warnings # 0 warning
cargo test --workspace # 100% vert (controller live arrete, port-forward Postgres actif)
Statut : ✅ Taches 4.1.1-4.1.4 et 4.2.1 validees (avec les adaptations de transport documentees ci-dessus et dans crates/api-server/src/mcp_server.rs). [-/claude-code/sess-c7a1e9-m4] remplace par [x] dans docs/specs/PLAN-ACTION-GLOBAL.md. ⚠️ 4.2.2 (migration exec_commands), 4.2.3 (exec_in_workshop) et 4.2.4 (confinement de securite) restent [ ], decision produit deliberee : necessitent la conception d'un nouveau canal d'execution de commande host->guest et d'une detection d'anomalie reseau, tous deux absents du code actuel — a traiter dans une session dediee avec sa propre conception validee, pas improvises ici. DoD M4 : 2/3 lignes [x] (pilotage MCP du cycle de vie verifie ; entree PROGRESS ; resilience exec_in_workshop non applicable, fonctionnalite non livree).
[2026-08-24 22:20] Jalon M4 (suite) - exec_in_workshop via un canal SSH dedie (taches 4.2.2-4.2.3)
Decision de conception validee avec l'utilisateur (question posee explicitement, cf. session precedente) : construire un nouveau canal SSH dedie (cle Ed25519 par Workshop) plutot que de piloter le terminal ttyd existant ou d'inventer un protocole maison — l'utilisateur a demande de committer/pusher directement les changements necessaires dans le depot separe atelier-workspace (acces push confirme, compte GitHub deja authentifie avec le scope repo).
Guest (atelier-workspace, commit 1d26735, pousse sur main) :
- openssh-server ajoute au Dockerfile, cle publique uniquement (atelier-sshd.conf : PasswordAuthentication no, PermitRootLogin no, AllowUsers vscode).
- atelier-fetch-ssh-authorized-key.sh (meme schema exact que atelier-fetch-session-auth.sh) va chercher la cle publique aupres de net-proxy (GET http://169.254.0.1:3132/ssh-authorized-key, jusqu'a 30 tentatives) et l'installe dans ~vscode/.ssh/authorized_keys — echec apres tous les essais : le repertoire reste vide, sshd demarre quand meme mais reste inaccessible (fail-closed, jamais ouvert sans cle).
- ssh.service (paquet openssh-server) ordonne apres atelier-ssh-authorized-key.service (drop-in Requires=/After=) — verifie empiriquement par un boot reel (Docker + systemd PID 1, meme methode que les autres services de ce depot) : ssh.service demarre bien apres l'echec/succes du script de recuperation de cle, jamais avant.
- Piege reel decouvert et corrige pendant le test de boot : ssh.service porte WantedBy=multi-user.target dans son unite mais n'etait PAS active par defaut dans cette image de base (systemctl etant factice, deb-systemd-helper ne l'a pas symlinke automatiquement, contrairement a l'hypothese initiale du commentaire du Dockerfile) — symlink ajoute explicitement, meme methode que les unites propres a ce depot.
Controller (crates/controller/src/openbao.rs::ensure_ssh_key) : idempotent-preservant (meme convention que ensure_session_auth), genere une paire Ed25519 (crate ssh-key, format OpenSSH natif) stockee dans secret/workshops/<name>/ssh_key (champs privateKey/publicKey). Policy cluster-wide atelier-api-server (ensure_api_server_role) etendue pour couvrir ssh_key en plus de session_auth (lecture seule, meme wildcard + par Workshop). Appele dans ensure_parent_pod, a cote de ensure_session_auth.
net-proxy (crate::ssh_authorized_key) : meme cache/rafraichissement periodique (5 min) que crate::session_auth, nouvel endpoint metadata GET /ssh-authorized-key (503 tant que non disponible).
api-server (crate::exec, nouveau module) :
- spawn() : INSERT immediat dans exec_commands (statut Running), retourne execution_id, execute en arriere-plan (tokio::spawn) — jamais bloquant pour l'appelant MCP.
- Canal : crate::vscode::open_forwarded_tcp_stream (rendu pub(crate), deja utilise par ttyd/code-server) jusqu'au port SSH du guest, puis client SSH reel (crate russh, backend crypto ring — pas aws-lc-rs, pour rester sur le meme backend que rustls::crypto::ring deja utilise ailleurs). Pas de verification de la cle hote SSH (check_server_key toujours Ok(true)) : documente comme un choix deliberer, le canal transitant deja exclusivement par le tunnel portforward interne au pod.
- stdout/stderr (donnees SSH standard + "extended data" canal 1) appendes chunk par chunk dans exec_commands (jamais bufferises entierement en memoire), sous une transaction SET LOCAL app.current_tenant (RLS) a chaque ecriture — y compris depuis cette tache de fond sans contexte de requete HTTP.
- GET /v1/workshops/{name}/exec/{id}/stream (SSE, crate::exec::stream_handler) : relit le buffer complet a chaque (re)connexion (pas seulement les octets manques, plus simple et tout aussi correct pour une reconnexion peu frequente), puis sonde la ligne toutes les 300ms jusqu'a status != "Running".
- Outil MCP exec_in_workshop (crate::mcp_server) : meme Fast-Fail (LiteLLM/OpenBao) que create_workshop (la spec liste explicitement exec_in_workshop comme "creatrice d'etat").
Pieges de dependances rencontres et resolus (tous constates a la compilation, pas devines) :
- atelier_common::{DevcontainerSource, WorkshopResources} derivent schemars 0.8 (via kube-derive), incompatible avec schemars 1.x — deja documente dans la session precedente pour create_workshop.
- Deux versions majeures de rand_core (0.6/0.9/0.10) et de getrandom (0.2/0.3/0.4) coexistent dans le graphe : ssh-key (vendorisee par russh 0.63) exige precisement rand_core 0.10, qui n'expose plus OsRng directement (delegue a getrandom::SysRng, lui-meme fallible) — resolu avec rand_core::UnwrapErr(getrandom::SysRng) (feature sys_rng de getrandom) uniquement dans les tests (le controller, avec ssh-key 0.6.7, plus ancien, n'a pas ce probleme et utilise rand::rngs::OsRng classique).
Tests reels executes :
# Guest : boot reel (Docker + systemd PID 1) verifie avant de committer/pusher
# -> voir commentaire du commit 1d26735 dans atelier-workspace
# Controller : vraie instance OpenBao (kubectl exec ... bao auth enable kubernetes,
# necessaire car reinitialise depuis la derniere session sur ce cluster partage)
OPENBAO_ADDR=http://127.0.0.1:8200 OPENBAO_TOKEN=root cargo test -p atelier-controller --test reconcile
# ensure_ssh_key_generates_a_valid_openssh_keypair_and_preserves_it ... ok
# (vraie paire Ed25519 reparsee/validee, idempotence verifiee sur un deuxieme appel)
# ensure_api_server_role_reads_any_workshop_session_auth_but_nothing_else ... ok
# (etendu a ssh_key : le role cluster-wide lit bien privateKey de n'importe quel
# Workshop, toujours refuse tout secret hors session_auth/ssh_key)
# api-server : vrai binaire net-proxy + vrai serveur SSH (russh::server, protocole
# SSH reel cote serveur aussi, substitue au vrai sshd Firecracker pour un test
# rapide/portable) + vrai PostgreSQL
cargo test -p atelier-api-server --test exec
# exec_in_workshop_runs_a_real_command_over_ssh_and_buffers_the_result ... ok
# (authentification par cle reussie, stdout ET stderr transportes separement,
# exit code 7 recupere, tout bufferise dans exec_commands via RLS)
cargo fmt --all -- --check # silencieux
cargo clippy --workspace --all-targets -- -D warnings # 0 warning
cargo test --workspace # 100% vert (controller live arrete, port-forward Postgres+OpenBao actifs)
Non teste de bout en bout avec une vraie microVM Firecracker : le cluster kind partage n'a pas de atelier-controller reel en cours d'execution au moment de cette session (aurait fallu le redemarrer avec le code fusionne + orchestrer un vrai build d'image incluant les changements atelier-workspace) — juge hors budget raisonnable pour cette session au vu de la validation deja obtenue a chaque couche (guest, controller/OpenBao, net-proxy, client SSH). A confirmer par une session future disposant d'un controller live.
Statut : ✅ Taches 4.2.2 et 4.2.3 validees avec l'adaptation de canal documentee (SSH plutot que WebSocket/vsock, decision utilisateur). [-/claude-code/sess-c7a1e9-m4] remplace par [x]. DoD M4 : 3/3 lignes [x] desormais (le pilotage MCP du cycle de vie ET exec_in_workshop sont verifies). ⚠️ 4.2.4 (confinement de securite automatique) reste [ ], deliberement hors perimetre — aucune detection d'anomalie reseau n'existe dans net-proxy aujourd'hui, necessiterait sa propre conception (heuristiques de detection, nouveau statut Workshop, alerte Dashboard).
[2026-08-24 22:50] Jalon M5 (suite) - tache 5.0.2 : embedding dev via Ollama plutot que Hugging Face
Le patch propose par la session precedente (huggingface/sentence-transformers/all-MiniLM-L6-v2, API d'inference hebergee de Hugging Face) a ete teste reellement une fois deploy/dev/llm-proxy/ stabilise (M3 fusionne) : echec, Hugging Face exige desormais une authentification meme pour un modele public (litellm.AuthenticationError, page de login HTML retournee par l'API). Contraire a l'objectif explicite de cette tache ("sans cle payante bloquante").
Solution retenue : deploy/dev/ollama/ (nouveau, meme convention que deploy/dev/redis/deploy/dev/postgres — Pod + Service, emptyDir, pas de persistance), modele all-minilm (meme famille que sentence-transformers/all-MiniLM-L6-v2, ~45 Mo, telecharge une fois via kubectl exec ... ollama pull all-minilm, meme convention d'etape ponctuelle post-deploiement que l'activation OpenBao). deploy/dev/llm-proxy/config.yaml : embedding-dev-local route maintenant vers ollama/all-minilm (api_base: http://atelier-ollama-dev:11434), 100% local, aucune cle, aucun appel reseau externe apres le telechargement initial.
Test reel execute (contre le cluster kind partage, LiteLLM redeploye avec la nouvelle configuration) :
kubectl apply -f deploy/dev/ollama/dev-pod.yaml
kubectl exec atelier-ollama-dev -- ollama pull all-minilm # succes, ~45 Mo
kubectl create configmap atelier-llm-proxy-config --from-file=config.yaml=deploy/dev/llm-proxy/config.yaml
kubectl rollout restart deployment/atelier-llm-proxy && kubectl rollout status ...
curl -X POST http://localhost:4000/v1/embeddings \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-d '{"model":"embedding-dev-local","input":"test"}'
# -> vecteur reel de dimension 384 (coherent avec all-MiniLM-L6-v2), aucune erreur
Limite assumee documentee (deploy/dev/ollama/README.md) : dimension native 384, differente de VECTOR(1536) de project_memories (calibree sur text-embedding-3-small) — ne pas ecrire directement dans cette colonne avec ce modele sans adaptation (re-projection ou colonne dediee aux tests dev), meme limite deja documentee par la session precedente pour l'option Hugging Face.
Statut : ✅ Tache 5.0.2 validee, [-/claude-code/sess-c7a1e9-m5] remplace par [x].
[2026-08-24 23:20] Jalon M5 (suite) - Adaptateurs Git multi-forges + consommateur Redis Streams (taches 5.4.1-5.4.3)
services/pm-engine/pm_engine/git_providers/ (nouveau sous-package) :
- base.py : BaseGitProvider (ABC, 5 methodes async : get_issue, post_comment, create_branch, create_pr, merge_pr) + dataclasses Issue/PullRequest — contrat identique quelle que soit la forge, conforme au principe de substituabilite (docs/specs/00-architecture-principles-substitutability.md).
- forgejo.py/github.py/gitlab.py : implementations httpx.AsyncClient directes (pas de SDK tiers, meme convention que le reste du projet cote Rust — appels REST bruts). Piege reel rencontre et documente : l'API GitHub renvoie 401 sur un en-tete Authorization: Bearer VIDE (different de l'absence totale de l'en-tete, qui est autorisee pour les lectures publiques) — corrige en omettant completement l'en-tete quand token est une chaine vide plutot que de l'envoyer vide.
services/pm-engine/pm_engine/redis_consumer.py (nouveau) : RedisStreamConsumer — ensure_group (idempotent, mkstream=True), read (XREADGROUP sur >), claim_stale_messages (XAUTOCLAIM, delai d'inactivite minimal configurable), ack (XACK), et run_forever (boucle : reprend d'abord les messages abandonnes, puis lit les nouveaux ; un message n'est acquitte qu'apres execution reussie du handler — une exception le laisse dans le PEL pour reprise, jamais acquitte a tort).
Tests reels executes (aucun mock — Forgejo/Redis de dev reels, API publiques GitHub/GitLab reelles) :
# Forgejo : cycle complet contre l'instance de dev reelle (jeton genere
# pour ce test : `forgejo admin user generate-access-token ... --scopes all`)
FORGEJO_URL=http://127.0.0.1:3000 FORGEJO_TOKEN=... pytest tests/git_providers/test_forgejo.py
# test_forgejo_provider_full_issue_to_merged_pr_lifecycle ... PASSED
# (depot jetable cree via l'API admin, issue reelle -> get_issue ->
# post_comment -> create_branch -> commit reel sur la branche (API
# "contents", necessaire pour que Forgejo accepte d'ouvrir une PR :
# "no changes between head and base" sinon) -> create_pr -> merge_pr,
# depot supprime en fin de test)
# GitHub/GitLab : lecture contre les vraies API publiques (pas de jeton
# d'ecriture disponible dans cet environnement pour un vrai depot)
pytest tests/git_providers/test_github.py tests/git_providers/test_gitlab.py
# get_issue sur octocat/Hello-World#1 et gitlab-org/gitlab-runner#1 ... PASSED
# Redis Streams : instance de dev reelle (deploy/dev/redis)
REDIS_URL=redis://127.0.0.1:6379/0 pytest tests/test_redis_consumer.py
# test_consumer_reads_and_acks_a_real_message ... PASSED
# test_consumer_reclaims_a_message_never_acked_by_a_dead_consumer ... PASSED
# (simule un crash : un consommateur lit un message sans jamais
# l'acquitter, un second le reprend via XAUTOCLAIM et l'acquitte)
# test_run_forever_processes_messages_and_stops_on_cancellation ... PASSED
pytest # 8 passed (aucune regression sur test_checkpointer.py/test_health.py)
Statut : ✅ Taches 5.4.1-5.4.3 validees. [-/claude-code/sess-c7a1e9-m5] remplace par [x].
[2026-08-25 00:15] Jalon M5 (suite) - Machine d'etats LangGraph complete (taches 5.2.1-5.2.2)
Modules crees (services/pm-engine/pm_engine/) : state.py (PMWorkflowState), deps.py (PmEngineDeps, injection de dependances par noeud), oidc.py (OidcTokenProvider, grant client_credentials), mcp_client.py (client MCP officiel, SDK mcp, transport Streamable HTTP), exec_client.py (consomme le flux SSE de exec_in_workshop jusqu'a completion), llm_client.py (REST direct vers LiteLLM), nodes.py (les 11 noeuds), graph.py (cablage StateGraph).
Identite de service atelier-pm-bot : le PM pilote Atelier en tant qu'identite propre (pas au nom de l'utilisateur qui a ouvert le ticket) — nouveau client OIDC confidentiel avec compte de service ajoute a deploy/dev/keycloak/realm-export.json (client_credentials, mapper d'audience atelier-api identique a atelier-dashboard), cree sur l'instance Keycloak de dev reelle via l'API admin et verifie (jeton reel obtenu, aud contient bien atelier-api).
Validation empirique de la chaine MCP complete : un vrai atelier-api-server (Jalon M4) a ete demarre localement pour cette session, connecte au vrai Keycloak/PostgreSQL/OpenBao/cluster kind de dev (ATELIER_JWT_ISSUER/JWKS_URL/AUDIENCE pointant vers Keycloak) — pm_engine.mcp_client pilote reellement create_workshop/list_workshops/delete_workshop via ce process, avec le jeton atelier-pm-bot.
Piege reel rencontre : le SDK MCP officiel (mcp 2.1.0) exige precisement son propre client HTTP (httpx2.AsyncClient, une dependance distincte de httpx) pour streamable_http_client — passer un httpx.AsyncClient classique echoue au typage/execution. Documente dans pm_engine/mcp_client.py.
Adaptation "5.0.2 x 5.3.2" : project_memories.embedding est un VECTOR(1536) (calibre sur text-embedding-3-small), mais le modele d'embedding local (all-minilm/Ollama, tache 5.0.2) produit des vecteurs de 384 dimensions — limite deja documentee comme non resolue par les sessions precedentes. Resolue ici par IndexKnowledge : complete le vecteur a zero jusqu'a 1536 dimensions (_pad_embedding) plutot que de changer le schema de cette table dev — mathematiquement, un padding a zero uniforme sur toutes les lignes ne modifie NI le produit scalaire NI la norme des vecteurs d'origine, donc la similarite cosinus (metrique de l'index ivfflat) entre deux embeddings ainsi completes reste identique a celle des vecteurs 384-dim d'origine.
Modele LiteLLM de test ajoute (deploy/dev/llm-proxy/config.yaml, atelier-plan-test) : DEEPSEEK_API_KEY/ANTHROPIC_API_KEY ne sont que des valeurs de bouchon dans cet environnement (DeepseekException - ... api key ... is invalid, verifie empiriquement) — aucune vraie cle payante n'est disponible pour tester le chemin "heureux" (reponse JSON valide) de PlanParallelTasks. mock_response (fonctionnalite LiteLLM native, deja utilisee au Jalon M3 pour les tests de budget) reste un vrai aller-retour LiteLLM, juste sans fournisseur payant reel — pas un mock introduit par cette session.
Tests reels executes (25 tests, aucun mock de code applicatif — seulement des mock_response LiteLLM natifs pour eviter tout cout) :
pytest -v
# git_providers/test_forgejo.py, test_github.py, test_gitlab.py ......... 3 passed (deja existants)
# test_checkpointer.py, test_health.py ................................... 2 passed (deja existants)
# test_redis_consumer.py (3 tests) ........................................ deja existants
# test_llm_client.py::test_chat_returns_the_mock_response_content ........ PASSED
# test_llm_client.py::test_embed_returns_a_real_384_dim_vector ............ PASSED
# test_oidc.py::test_get_token_returns_a_real_valid_jwt_and_caches_it ..... PASSED (Keycloak reel)
# test_mcp_client.py::test_mcp_session_drives_a_real_workshop_lifecycle .. PASSED
# (vrai atelier-api-server local + vrai Keycloak : tools/list, create_workshop,
# list_workshops, delete_workshop, verifie via un vrai Workshop CRD cree/supprime)
# test_graph.py (2 tests) : compilation du graphe complet (11 noeuds) + AutoCorrectionLoop
# test_hitl_interrupt.py::test_await_hitl_approval_interrupts_and_resumes_with_the_real_checkpointer
# PASSED — reprise verifiee sur un OBJET graph totalement recree (simule un
# crash/redemarrage de worker), l'etat complet (dont pr_url, pose AVANT
# l'interruption) est bien restaure depuis PostgreSQL, pas seulement la decision
# test_nodes.py (10 tests) : route_after_tests/route_after_hitl (purs),
# AnalyzeIssue (vrai Forgejo + vrai LLM), PlanParallelTasks (vrai JSON LLM),
# ProvisionWorkshop+SuspendWhileWaitingReview (vrai MCP, vrai Workshop CRD),
# OpenPullRequest+MergeAndClose (cycle complet contre Forgejo reel),
# IndexKnowledge (vrai Ollama + vraie ecriture RLS dans atelier_pm)
# 25 passed
Non teste : DelegateToClaudeCode/RunDevcontainerTests de bout en bout avec une vraie microVM Firecracker — necessiterait un atelier-controller actif + une image devcontainer construite + un boot reel, hors budget de cette session (le meme constat que pour exec_in_workshop lui-meme au Jalon M4). La construction de l'appel MCP (exec_in_workshop) et l'attente du resultat via SSE (pm_engine.exec_client) sont neanmoins du code reel, pas un stub — seul le "dernier kilometre" (guest reellement demarre) n'a pas pu etre exerce.
Statut : ✅ Taches 5.2.1-5.2.2 validees pour leur perimetre testable dans cet environnement. [-/claude-code/sess-c7a1e9-m5] remplace par [x]. DoD M5 : 1/4 ligne [x] (entree PROGRESS) — les 3 autres necessitent soit une vraie microVM live (hors budget), soit le Dashboard (tache 5.5.x, hors perimetre convenu pour cette session).
[2026-08-24 --:--] Jalon M5 - Tâches 5.5.1, 5.5.2 : Dashboard PM Chat SSE & Interface HITL
- Composant impacté :
dashboard/app/pm/(page,pm-chat.tsx,pm-reviews.tsx),dashboard/app/api/pm/(route handlerschat/route.ts,reviews/route.ts,reviews/[threadId]/decision/route.ts),dashboard/lib/pm-engine.ts,dashboard/app/actions.ts. - Constat à la reprise (kiro/sess-m5-dashboard) : l'implémentation avait été intégralement réalisée par
claude-code/sess-c7a1e9-m5avant l'interruption faute de crédits — seuls le marquage[x]dansPLAN-ACTION-GLOBAL.mdet l'entréePROGRESS.mdmanquaient. - Fonctionnalités livrées :
- 5.5.1 — Chat SSE interactif :
POST /api/pm/chat(Route Handler Next.js 16), relaye le flux SSE depm-engine POST /chaten ajoutant le tokenhttpOnlycôté serveur (BFF pattern — le navigateur ne voit jamais le JWT). Composant clientpm-chat.tsxconsomme le streaming viafetch+ lecture manuelle duReadableStream(pasEventSource, incompatible avec les requêtes POST). - 5.5.2 — Approbation HITL :
GET /api/pm/reviews(liste des PRs en attente d'approbation),POST /api/pm/reviews/[threadId]/decision(approuve/rejette, reprend le graphe LangGraph viapm-engine). Composant clientpm-reviews.tsxavec mise à jour optimiste (retire immédiatement la ligne de la liste sans attendrerevalidatePath). Server ActiondecideReviewActiondansapp/actions.tspour le même flux via formulaire. - Architecture retenue : page unique
/pm(pas/projects/[id]/pm/— pas de notion de "projet" distincte d'un dépôt Git dans ce Dashboard, documenté en tête du fichierpage.tsx). Le champ "dépôt" est saisi par l'utilisateur dans l'interface de chat et passé dans le body JSON. - Preuve empirique / Test exécuté : Build TypeScript + Next.js 16 (Turbopack) sans aucune erreur ni warning.
- Statut : ✅ Validé. Tâches 5.5.1 et 5.5.2 marquées
[x]dansPLAN-ACTION-GLOBAL.md.
Feuille de route telle qu'elle se presentait fin aout 2026
Conservee ici pour les notes residuelles attachees aux etapes deja terminees (limites connues, points restes ouverts dans leur voisinage). L'etat courant de la feuille de route est dans
../PROGRESS.md.
- ~~Brancher la microVM "builder" dans
image-builder/reconcile.rs~~ — fait cette session, voir "Builder microVM" ci-dessus. Le pipeline completimage-builder(microVM) → cache →vm-supervisortourne automatiquement de bout en bout, verifie contre kind reel, sans peuplage manuel du PVC. Reste ouvert dans ce voisinage : le registre interne (ctx.registry_addr) n'est joignable par la microVM builder que via l'aliasregistrydenet-proxy, pas encore par la VM de l'agent (n'a pas de reseau du tout aujourd'hui, voir point 3). - ~~Canal de controle entre
controller/vm-supervisorpour quesuspenddeclenche un vraisnapshot/createavant liberation du pod~~ — fait cette session, voir "Canal de controle suspend/resume" ci-dessus. Canal HTTP (pasvsock, terme initial trompeur — voir cette section) ;status.snapshotDigestreellement renseigne, restauration cross-process/ cross-pod validee contre kind. Reste ouvert : lecontrollerlui-meme ne tourne pas encore dans le cluster (premierDockerfileecrit cette session, mais uniquement pour ce test manuel — pas encore de Deployment/RBAC cluster-wide dedies), donc ce canal n'est valide qu'en conditions de test, pas encore en deploiement reel. - ~~
net-proxy/identity-proxycomme conteneurs du pod parent + TAP reseau pour la VM de l'agent~~ — fait cette session, voir "Reseau de l'agent + net-proxy/identity-proxy dans le pod parent" ci-dessus. Le TODO dedocs/ARCHITECTURE.md(l'agent parle-t-il aidentity-proxyen direct ou seulement vianet-proxy) etait deja tranche pardocs/architecture/network-security.md(session precedente) : jamais en direct, uniquement vianet-proxy. ~~Reste ouvert, distinct : faire en sorte que le devcontainer construit utilise reellement ce chemin~~ — fait cette session : plutot que de chercher un mecanisme coteenvbuilder(aucun hook natif trouve, et de toute facon inutilisable ici — l'export brutcrane export+mke2fsperd deja toute metadonnee OCIENV, voir "Lecons retenues"),image-builder::inject_net_proxy_config(nouveau,crates/image-builder/src/main.rs) ecrit directement/etc/environment(complete le fichier existant,HTTP_PROXY/HTTPS_PROXY/variantes minuscules +NO_PROXYvers169.254.0.1:3128) et/etc/resolv.conf(nameserver 169.254.0.1, remplace un eventuel symlinksystemd-resolved) dans l'arborescence exportee, entreexport_image_filesystemetpackage_ext4— meme adresse fixe de lien point-a-point et meme port quevm-supervisor/controller(constante3128, cf.crates/controller/src/reconcile.rs)./etc/environmentest le bon point d'injection pour un guest sans init personnalise (boote le PID 1 du devcontainer tel quel) : lu parpam_envpour toute session de login (SSH/terminal interactif — code-server, Claude Code). Verifie contre le vrai pipelineimage-builder(pas seulementcargo check) : conteneurs Docker reelsatelier-image-builder:dev+atelier-net-proxy:dev(partage du netns hote, meme registre:5000reel), vrai devcontainer (vscode-remote-try-python) construit par la microVM builder viaenvbuilder, push registre reel,crane export+ injection +mke2fsreels. Contenu durootfs.ext4resultant inspecte directement (debugfs -R "cat ...", sans montage) :/etc/environmentet/etc/resolv.conf(fichier regulier, pas un symlink residuel) portent bien le contenu attendu. Usage reel par un guest boot, verifie ensuite (demo/net-proxy-probe/, nouveau) : devcontainer minimal (systemd +curl+ service qui fait un vraiCONNECTHTTPS versexample.com) boote avec le vrai binaireatelier-vm-supervisor(pas une reimplementation de test), a cote d'un vraiatelier-net-proxy, meme protocole A/B qu'ailleurs dans cette section : rootfs identique avec/sans les deux fichiers injectes. Preuve observee dans les logs du vrainet-proxy(pas dans un mock) : variante avec injection ->egress autorise ... host="example.com" port=443 method=CONNECT allowed=true(le guest a bien luHTTPS_PROXYet l'a utilise) ; variante sans injection -> aucune entree de log pendant toute la fenetre du run (la tentative directe decurlest silencieusement rejetee parrestrict_to_net_proxy, jamais unCONNECTn'atteintnet-proxy). Voirdemo/net-proxy-probe/README.mdpour le protocole complet et la limite restante (sonde TCP directe sur le port du guest non fiable dans cet environnement, non bloquant vu que la preuve par les logsnet-proxysuffit). - ~~
api-server: valider contre un vrai flux OAuth2 Kanidm, et role de coordinateur de port-forward~~ — fait cette session, voir point 11 du resume chronologique ci-dessus. Resource Server OAuth2 reel configure dans Kanidm de dev, deux bugs reels trouves et corriges (ATELIER_JWT_AUDIENCE,ATELIER_JWT_CA_PATH), endpoint/v1/workshops/{name}/portforwardecrit et teste de bout en bout. - ~~
mcp-gateway~~ — base faite cette session, voir section dediee ci-dessus : serveur MCP reel (request_credential,request_egress) via HTTP/SSE. Reste ouvert : le premier simulateur (candidat toujours LocalStack pour AWS) et le toolenable_simulatorassocie, le transportvsocknatif, et une verification depuis l'interieur d'une vraie microVM agent (pas encore d'agent MCP dans le devcontainer de test). - ~~Device plugin Kubernetes pour
/dev/kvm, afin de sortir du podprivileged: true~~ — fait cette session, voir point 12 du resume chronologique ci-dessus.vm-supervisoretimage-buildertournent desormais sansprivileged: true, verifie contre kind reel (boot Firecracker reussi dans un pod portant uniquementNET_ADMIN/SYS_ADMIN/SYS_RESOURCE+ la ressourceatelier.dev/kvm). - Offload/reload du cache d'images vers S3 (prevu des la conception, explicitement differe).
- Stack d'observabilite complet : collector OTLP + backend de stockage + Grafana.
- ~~Devcontainer de demo
ministack-workshop~~ : boot Firecracker reel verifie, ~~UI dashboard dediee~~ construite cette session (voir section dediee "UI dashboard" ci-dessus : page de gestion par Workshop, pont HTTP+WSapi-server->code-server, preset de creation). ~~Reste ouvert : creer un vraiWorkshopK8s pointant suratelier-workspace(depot desormais public, plus de blocage d'auth git) pour la premiere validation reellement complete du pont "Ouvrir VS Code" de bout en bout~~ — fait, voir "Terminal navigateur (ttyd)" ci-dessus :code-serveretttydverifies dans un vrai navigateur contre un vrai Workshop, six bugs reels corriges sur le chemin. Reste ouvert : quelques404en console cotecode-server, non investigues. - ~~LLM Proxy~~ — base faite cette session (LiteLLM global, DeepSeek
par defaut + Anthropic premium), voir section dediee ci-dessus. Reste
ouvert : verification avec une vraie cle DeepSeek et depuis l'interieur
d'une vraie microVM agent ; cles virtuelles LiteLLM par Workshop (pas
de scoping/isolation de budget dans ce lot) ; prompt caching non
configure ; OpenAI/Grok non couverts (DeepSeek/Anthropic uniquement
pour l'instant, le
model_listLiteLLM est extensible sans changement de code cote atelier).