Plan d'Action Global d'Implémentation
Statut : Plan Cadre Opérationnel & Feuille de Route d'Ingénierie Dernière compression : 2026-09-05 — les jalons M1 à M6 (117 tâches, toutes
[x]) ont été sortis versdocs/archive/PLAN-ACTION-M1-M6.md(même raison que l'archivage dedocs/PROGRESS.mdfin août 2026 : une tâche validée n'a plus besoin de son détail ligne à ligne dans le document courant, seulement dansgit loget l'archive). Ce document ne garde que ce qui reste utile pour démarrer la prochaine tâche : le protocole de traçabilité, les principes transversaux, la carte des dépendances, la Matrice Récapitulative des jalons clos, et les jalons en cours ou à venir. Protocole de Transition Multi-Agents & États de Tâche : Ce plan est conçu pour être exécuté de manière asynchrone, interrompu à tout moment et repris sans friction par n'importe quel autre agent IA (Claude Code, Gemini CLI, Antigravity). Règle de Traçabilité Obligatoire & États[ ]/[-/<agent>/<session_id>]/[x]: 1.[ ]: Tâche en attente / non démarrée. 2.[-/<agent_family>/<session_id>]: Tâche en cours d'exécution par un LLM / Agent identifié (ex:[-/claude-code/sess-4a8b]ou[-/antigravity/c192a786]). Permet de savoir précisément qui travaille sur la tâche et si la session est toujours active. 3.[x]: Tâche terminée et validée empiriquement par tests réels sans mocks et journalisée dansdocs/PROGRESS.md.
Sommaire
- Protocole de Transmission & Traçabilité Multi-Agents
- Principes Directeurs & Definition of Done (DoD) Transversale
- Cartographie des Dépendances & Matrice d'Impact Globale
- Jalons Clos (M1 à M6) — Matrice Récapitulative
- Jalon 7 (M7) : Stack d'Observabilité (Traces, Métriques, Logs)
- Jalon 8 (M8) : Offload S3 du Cache d'Images et des Snapshots
- Jalon 9 (M9) : Expérience Dev (CLI, Pont IDE), Simulateurs in-VM & HITL
- Jalon 11 (M11) : Souveraineté, Air-Gap & Inférence GPU Locale
- Jalon 12 (M12) : Workflows Multi-Workshops & Orchestration d'Équipes d'Agents
- Jalon 13 (M13) : Réutilisation des Images par Source
- Jalon 14 (M14) : Sessions pour Apprenants
1. Protocole de Transmission & Traçabilité Multi-Agents
Afin de permettre une collaboration fluide entre différents agents IA ou sessions interrompues :
📋 Instructions Strictes pour l'Agent Exécuteur :
- Vérification Initiale & Verrouillage Nominatif (
[-/<family>/<id>]) : - Inspecter
git status,docs/PROGRESS.mdet ce documentPLAN-ACTION-GLOBAL.md. - Vérifier qu'aucune tâche antérieure n'est laissée en cours
[-/...]ou non validée[ ]. - Dès qu'un agent prend en charge une tâche
[ ], il DOIT IMMÉDIATEMENT positionner le marqueur nominatif[-/<agent_family>/<session_id>](ex:[-/antigravity/c192a786]ou[-/claude-code/sess-xyz]) sur la tâche dansPLAN-ACTION-GLOBAL.md. - Validation d'une tâche (
[x]) : - Exécuter impérativement les tests unitaires et de linter (
cargo test,cargo clippy,cargo fmtoupytest). - Remplacer le marqueur
[-/...]par[x](indiquant formellement le travail terminé) dans ce documentPLAN-ACTION-GLOBAL.md. - Consigner ce qui doit réellement l'être dans
docs/PROGRESS.md/docs/architecture/pieges.md(voirAGENTS.md, section « Mise à jour de la Documentation ») — pas d'entrée narrative datée par tâche. - Interruption / Passage de relais :
- Si une session s'arrête en cours de tâche, laisser le marqueur
[-/<family>/<id>]et documenter dansdocs/PROGRESS.mdl'état exact d'avancement pour que l'agent suivant sache exactement d'où repartir.
2. Principes Directeurs & Definition of Done (DoD) Transversale
Conformément à AGENTS.md et 00-architecture-principles-substitutability.md :
🛡️ Exigences de Qualité Transversales
- Zero
unsafedans tout le code Rust de production (crates/*/src/). - Formatage & Linting Stricts :
cargo fmt --all -- --checkest 100% propre.cargo clippy --workspace --all-targets -- -D warningsne retourne aucun avertissement.- Vérification Empirique sans Mocks (Infrastructure Dev Réelle) :
- Tous les tests d'intégration s'exécutent contre de vrais conteneurs / pods locaux (PostgreSQL réel sous Kind port 5433, S3/MinIO réel, Forgejo réel, OpenBao réel, LiteLLM réel, Redis réel, cluster Kind réel avec microVMs Firecracker).
- Zéro mock factice remplaçant les composants réseau ou de stockage.
- Documentation Vivante :
- Mise à jour systématique de
docs/PROGRESS.mdavec preuves d'exécution. - Dashboard Next.js 16 :
- Validation de la compilation TypeScript et du build (
npm run build).
3. Cartographie des Dépendances & Matrice d'Impact Globale
graph TD
subgraph Layer1["1. Contrats Partagés"]
CRD["crates/common/src/crd.rs\n(Suppression Kanidm, ajout maxLlmBudgetUsd)"]
SQL_SCHEMA["Schémas SQL & Migrations\n(PostgreSQL 16 + pgvector)"]
end
subgraph Layer2["2. Control Plane Rust"]
API["crates/api-server\n(OIDC, sqlx, S3, /v1/mcp, Basic Auth OpenBao, Health)"]
CTRL["crates/controller\n(sqlx, LiteLLM keys, HTTPS Git, Session Auth Vault, Health)"]
IDP["crates/identity-proxy\n(Injection PAT HTTPS)"]
end
subgraph Layer3["3. Infrastructure & IA"]
KC["Keycloak / OIDC Provider"]
FJ["Forgejo / GitHub / GitLab"]
LLM["LiteLLM (Virtual Keys)"]
OB["OpenBao / Vault (Secrets & Session Auth)"]
S3["RustFS / GCS / Azure / AWS (S3)"]
REDIS["Redis (Streams)"]
end
subgraph Layer4["4. DevFactory & Dashboard"]
PM["services/pm-engine\n(Python, LangGraph, pgvector)"]
DASH["dashboard/\n(Next.js 16, OIDC, Ask PM, VS Code, Terminal)"]
end
subgraph Layer5["5. Packaging Helm & Dev Scripts"]
HELM["charts/atelier\n(Monolithique, 4 Ingress, BYO, Cloud IAM)"]
DEV_STACK["deploy/dev/local-stack.sh & teardown-stack.sh"]
DOC["docs/admin-guide.md\n(Runbook d'exploitation)"]
end
CRD --> CTRL
CRD --> API
SQL_SCHEMA --> API
SQL_SCHEMA --> CTRL
SQL_SCHEMA --> PM
KC --> API
KC --> DASH
FJ --> PM
LLM --> CTRL
LLM --> PM
OB --> CTRL
OB --> API
S3 --> API
S3 --> FJ
REDIS --> PM
CTRL --> IDP
API --> DASH
PM --> API
PM --> DASH
API --> HELM
CTRL --> HELM
PM --> HELM
HELM --> DOC
DEV_STACK --> HELM
4. Jalons Clos (M1 à M6) — Matrice Récapitulative
Détail tâche par tâche (fichiers exacts, garde-fous, sous-tâches) dans
docs/archive/PLAN-ACTION-M1-M6.md.
| Jalon | Intitulé | Livrables & Composants Clés | Critère de Validation Empirique (Go / No-Go) |
|---|---|---|---|
| M1 | Socle DB, OIDC, Basic Auth & Health | crates/common, crates/api-server, crates/controller, dashboard/ |
cargo test --workspace passe avec vrai Postgres & OIDC, Basic Auth OpenBao et sondes /health opérationnelles. |
| M2 | S3 & Git HTTPS (Dev Pods) | crates/api-server/src/storage.rs, crates/identity-proxy, deploy/dev/{s3,forgejo} |
Upload de session S3 réussi, clone Git HTTPS privé réussi contre Forgejo dev. |
| M3 | LiteLLM & Budgets | crates/controller/src/litellm.rs, crates/common/src/crd.rs |
Virtual Key créée avec TTL court renouvelé à chaud post-resume, blocage 429 au dépassement de quota. |
| M4 | Serveur MCP Externe | crates/api-server/src/mcp_*.rs |
Client Claude Desktop connecté sur /v1/mcp, streaming exec_in_workshop bufferisé dans Postgres. |
| M5 | DevFactory PM Engine | services/pm-engine, dashboard/, deploy/dev/redis |
Workflow LangGraph complet (issue ➔ sous-branches ➔ auto-correction ➔ git-sync ➔ snapshot S3 ➔ merge). |
| M6 | Helm & Admin Doc | charts/atelier/, deploy/dev/*-stack.sh, docs/admin-guide.md |
helm install 100% opérationnel sur Kind avec 4 Ingress, identités Cloud, scripts dev et hooks validés — inclut la configuration des modèles LiteLLM par un admin (docs/specs/11-admin-litellm-model-config.md). |
5. Jalon 7 (M7) : Stack d'Observabilité (Traces, Métriques, Logs)
- Spécification :
12-observabilite.md - Constat :
crates/common/src/telemetry.rsexporte déjà des traces OTLP siOTEL_EXPORTER_OTLP_ENDPOINTest positionnée — elle ne l'est nulle part (chart, dev). Pire, vérifié empiriquement : même en la positionnant vers un vrai collecteur, zéro trace n'est produite, aucune route/boucle n'étant instrumentée par un span (TraceLayer/#[instrument]absents partout). - Fichiers :
crates/common/src/telemetry.rs,crates/api-server/src/routes.rs,crates/controller/src/reconcile.rs,charts/atelier/templates/infra/observability-deployment.yaml(nouveau),charts/atelier/values.yaml,deploy/dev/local-stack.sh. - [x] 7.1 : Nouveau composant chart
observability(grafana/otel-lgtm, un seul pod — voir spec §3 pour la justification et les mesures empiriques de démarrage/mémoire),.Values.observability.enabled/.resources. Déployé réellement surkind-atelier-dev: podRunningen 37s,otel-lgtm:0.32.1épinglé (même digest que:latestau moment de la vérification).helm lint/helm templatepassent. - [x] 7.2 :
OTEL_EXPORTER_OTLP_ENDPOINTcâblée sur les Deploymentsapi-server/controllerdu chart quandobservability.enabled.deploy/dev/local-stack.shdéploie inconditionnellementdeploy/dev/otel/dev-deployment.yaml(remplace l'anciencollector-config.yaml, orphelin depuisa51b1c9, jamais câblé) et ouvre un port-forward local (4317) — vérifié contre le cluster de dev réel (Service→pod atteignable,ncréussi).helm lint/helm template/shellcheckpropres. - [x] 7.3 :
tower_http::TraceLayersurroutes::router(api-server) — un span par requête HTTP, méthode/chemin/statut en attributs. Piège trouvé en vérifiant :TraceLayercrée son span par défaut àDEBUG, filtré par l'EnvFilterpar défaut detelemetry::init("info") — sans.level(Level::INFO)explicite, aucune trace n'était exportée malgréTraceLayeren place. Vérifié bout en bout via le chemin réel (Service Kubernetes + port-forward 4317, cluster de dev,RUST_LOGnon défini) : les requêtes HTTP produisent bien des tracesservice.name=atelier-api-serverdans Tempo.cargo test/clippy/fmtpropres. - [x] 7.4 : Déjà fait —
crates/controller/src/reconcile.rsporte#[tracing::instrument(skip_all, ...)]sur 5 fonctions depuis le commita51b1c9(2026-08-18), dont deux avecfields(workshop = %workshop.name_any()). Découvert en cours de rédaction de la spec (une recherche trop stricte l'avait manqué) puis reverifié bout en bout :atelier-controllerlocal +OTEL_EXPORTER_OTLP_ENDPOINTvers ungrafana/otel-lgtmréel + réconciliation réelle contre le cluster de dev → 5 tracesservice.name=atelier-controllerretrouvées dans Tempo. Aucune implémentation nécessaire, seul le câblage de 7.2 manquait. - [x] 7.5 :
telemetry.rsgagne unMeterProvider(opentelemetry_otlp::MetricExporter+with_periodic_exporter; featuremetricsdéjà en défaut chezopentelemetry_sdk/-otlp, aucune dépendance nouvelle). Nouveaucrates/api-server/src/http_metrics.rs: compteur de requêtes + histogramme de latence, posés via.route_layer()pour queMatchedPathsoit résolu (évite la cardinalité non bornée du chemin brut). Vérifié contre une instance réelle degrafana/otel-lgtm:http_server_request_count_total/http_server_duration_milliseconds_countvisibles dans Prometheus avec les bons labels.cargo test/clippy/fmtpropres. M7 (Observabilité) entièrement clos.
6. Jalon 8 (M8) : Offload S3 du Cache d'Images et des Snapshots
- Spécification :
13-image-cache-offload.md - Constat : le
TODOdeimage-builder::publish_to_cachen'était pas qu'une optimisation théorique — découvert en creusant ce chantier : le disque de la machine de dev était à 96 % d'utilisation,local-path-provisionerne fait respecter AUCUN quota (400 Go réels contre un PVC nominal20Gi), et surtoutcrates/controller/src/reconcile.rs::cleanup()ne supprime JAMAIS le snapshot d'unWorkshopsupprimé (103 Go de snapshots orphelins mesurés). Pansement déjà appliqué cette session (contenu du PVC vidé manuellement, disque revenu à 50 %) — pas une solution durable. - Fichiers :
crates/controller/src/reconcile.rs(cleanup),crates/controller/src/storage.rs,crates/image-builder/src/main.rs(publish_to_cache),crates/vm-supervisor/src/main.rs,crates/api-server/src/storage.rs→ à déplacer verscrates/common/src/storage.rs(spec §3.3),charts/atelier/values.yaml(S3_BUCKET_IMAGE_CACHE). - [x] 8.1 : Correctif prioritaire (bug, pas une optimisation) —
cleanup()lance un Job éphémère (busybox,ttlSecondsAfterFinished=300) qui monte le PVC de cache et supprimesnapshots/<ns>_<name>du Workshop supprimé — le controller lui-même ne monte jamais ce PVC. Vérifié de bout en bout contre le cluster de dev réel : faux snapshot créé, Workshop supprimé, JobCompleteen 6s, répertoire confirmé absent après coup.cargo test/clippy/fmtpropres. - [x] 8.2 :
StorageBackend/S3StorageBackenddéplacés verscrates/common/src/storage.rs(spec §3.3) ;api-serverimporte désormais ce type depuisatelier_common(dépendancesaws-sdk-s3/async-compression/bytes/async-trait/aws-configdéplacées ou retirées en conséquence,aws-confign'était déjà plus utilisé nulle part). Tests d'intégration déplacés (crates/common/tests/storage.rs) et re-vérifiés réellement contre RustFS (cargo test -p atelier-common --test storage+cargo test -p atelier-api-server --test session_recorder, les deux passent contre un vrai bucket).cargo test/clippy/fmtpropres sur tout le workspace. - [x] 8.3 : Nouvelle variable
S3_BUCKET_IMAGE_CACHE(spec §3.2, optionnelle — contrairement àS3_BUCKET_SESSIONS/SNAPSHOTS, seulimage-builderen a besoin) ;image-buildertéléverse vers S3 aprèspublish_to_cachelocal (best-effort, non bloquant). Correction découverte en implémentant : le digest n'est connu qu'APRÈS la construction complète (sha256_filesur lerootfs.ext4fini) — aucune vérification préalable ne peut donc éviter un rebuildenvbuilder, contrairement à ce qu'affirmait la première rédaction de la spec (corrigée, voir spec §3). Le gain réel : survie de l'artefact à une éviction locale ultérieure (8.5), pas une évitation de rebuild.ReconcileCtxgagnes3/s3_pod_endpoint(controller ne s'en sert pas lui-même, retransmet au Job — piège trouvé en vérifiant : sanss3_pod_endpoint, un controller de dev hors cluster transmettait son PROPRES3_ENDPOINTen127.0.0.1:9000, injoignable depuis le Job ; même correctif quellm_proxy_pod_addr/OpenBaoConfig::pod_addr). Câblé danscontroller-deployment.yaml(RustFS embarqué ET S3 externe) etdeploy/dev/local-stack.sh. Vérifié de bout en bout contre le cluster de dev réel : vrai Workshop créé, Jobimage-buildinspecté,S3_ENDPOINTcorrect des deux côtés (127.0.0.1:9000côté controller, DNS in-cluster côté Job) ; test d'intégrationupload_image_cache_file_is_retrievable_with_the_conventional_keypasse contre RustFS réel.cargo test/clippy/fmt/helm lint/shellcheckpropres. - [x] 8.4 :
vm-supervisortéléverse ses fichiers de snapshot versS3_BUCKET_SNAPSHOTSaprès publication locale (snapshot_and_publish, best-effort) et les retélécharge si absents localement (évincés) avantVm::restore_persisted— téléchargement partiel (un seul des deux fichiers) traité comme un échec complet, les deux sont supprimés pour retomber proprement sur le boot à froid. Nouvelles méthodesS3StorageBackend::upload_snapshot_file/download_snapshot_to_file(crates/common), clé S3 = même préfixe que le répertoire local (storage::snapshot_cache_subdir), transmis par lecontrollerviaATELIER_VM_SNAPSHOT_S3_PREFIX—vm-supervisorn'a besoin de connaître ninsninamelui-même. Même câblagectx.s3/s3_pod_endpointque 8.3 (déjà vérifié). Nouveau test d'intégration réel (upload_snapshot_file_survives_local_eviction_via_download, cycle upload→suppression locale→téléchargement→vérification de contenu) passe contre RustFS réel.cargo test/clippy/fmtpropres. - [x] 8.5 : Passe périodique (
crates/controller/src/eviction.rs, boucletokio::spawnindépendante de la réconciliation, toutes les 15 min) qui crée un Job éphémère (minio/mc) montant le PVC de cache, plafond configurable (.Values.imageCache.evictionThresholdGb/ATELIER_IMAGE_CACHE_EVICTION_THRESHOLD_GB), désactivée siS3_BUCKET_IMAGE_CACHEn'est pas configuré. Piège trouvé en vérifiant : l'imageminio/mcn'a NIfindNIawk(quasi-distroless) — le script initial (basé surfind -printf/awkcommes3-init-job.yaml) aurait échoué silencieusement en prod. Réécrit avec uniquementstat -c '%Y %n' | sort -n(tri par date) et un accumulateur shell pur (pas d'awk), vérifié ligne par ligne dans un pod jetable contre le cluster de dev réel avant de porter le script dans le code Rust. Scénario de sécurité vérifié explicitement : une entrée ancienne mais absente de S3 est bien préservée malgré son ancienneté, une entrée plus récente mais confirmée sur S3 est évincée à sa place. Job réellement créé et exécuté via le vrai binairecontroller(Completeen 4s).cargo test/clippy/fmt,helm lint/template,shellcheckpropres. M8 (Offload S3 du Cache d'Images et des Snapshots) entièrement clos.
7. Jalon 9 (M9) : Expérience Dev (CLI, Pont IDE), Simulateurs in-VM & HITL
- Spécification :
14-devex-cli-simulateurs-hitl.md - Constat : L'accès aux microVMs Atelier nécessite aujourd'hui le Dashboard web ou
kubectl. Par ailleurs, les agents de code in-VM manquent de dépendances d'appoint (Postgres, S3 LocalStack, stubs API) sans accès Internet direct, et les opérations sensibles (extension allowlist, PRs critiques) nécessitent une boucle de décision humaine ("Human-in-the-Loop"). - Fichiers :
crates/cli(nouveau),crates/common/src/crd.rs(simulators),crates/controller/src/reconcile.rs,crates/mcp-gateway/src/,crates/api-server/src/approvals.rs(nouveau),dashboard/app/approvals/. - [x] 9.1 : Nouveau crate
crates/cli— gestion multi-environnementsatelier context add/use/list(support d'un cluster local Kind ou distant EKS/GKE/bare-metal), flux d'authatelier auth login(Device Authorization Grant RFC 8628, decouverte OIDC generique), stockage des jetons dans le trousseau OS (keyring, backendlinux-native/keyutils), et commandesatelier workshops list/create/status/stop/resume/delete. Verifie de bout en bout contre de la vraie infra : nouveau client Keycloakatelier-cli(Device Authorization Grant active) ajoute adeploy/dev/keycloak/realm-export.jsonet cree via l'API admin sur l'instance de dev deja en cours ; login reel via le navigateur (utilisateuratelier-test-user) jusqu'a stockage du jeton dans le trousseau ;atelier-api-serverreel lance en local, cycle completcreate→list→status→stop→resume→deleteexecute contre lui, Workshop confirme absent dekubectl get workshopapres coup.cargo check/clippy/fmtpropres. Point ouvert non bloquant : le backendkeyringchoisi (linux-native) n'est pas cross-platform (voir commentaire danscrates/cli/Cargo.toml) — a revisiter si portage macOS/Windows requis. - [x] 9.2 : Pont Tunnels dans la CLI —
crates/cli/src/tunnel.rsreimplemente cote client le sous-protocole websocketportforward.k8s.iodeja parle parcrates/api-server/src/portforward.rs/crates/net-proxy/src/portforward.rs(canal2*idonnees /2*i+1erreurs, un octet de prefixe par message).atelier port-forward <id> <local>:<remote>(mode ecoute, une session par connexion TCP acceptee) etatelier port-forward --stdio <id> <port>(relais stdin/stdout, utilisable commeProxyCommandSSH) ;atelier ssh <id>etatelier code <id>deleguent a un clientssh/IDE systeme reel via ceProxyCommand(spec §3.7),codeecrivant un bloc dedie et delimite dans~/.ssh/config. Verifie contre le vrai binaireatelier-net-proxy(pas un mock du protocole) : nouveau testcrates/cli/tests/portforward_wire.rs, lance le vraiatelier-net-proxyen sous-processus pointe sur un serveur TCP reel, confirme qu'un message envoye sur le canal 0 revient bien transforme par la cible.cargo test/clippy/fmtpropres. Non verifie (hors de portee sans un Workshop entierement demarre — image-builder + boot Firecracker) :atelier ssh/atelier codede bout en bout contre un vrai sshd in-VM, a faire des qu'un Workshop de test tourne reellement. - [x] 9.3 : Extension du CRD
WorkshopSpecavecsimulators: Vec<SimulatorSpec>(name,type: postgres|localstack|wiremock,env) — additif et independant du mecanisme historiquetools/enable_simulator(un seul LocalStack, gate par MCP, deja existant et non touche). Lecontroller(crates/controller/src/reconcile.rs::simulator_container) ajoute un conteneur sidecar par entree (image + port par defaut selon le type,POSTGRES_PASSWORDpar defaut si absente deenv) et transmetATELIER_SIMULATORS=nom=127.0.0.1:port,...au conteneurnet-proxy.crates/net-proxy/src/internal.rs::InternalRoutes::add_simulatorsresout<name>.atelier.internal(DNS ET routage HTTP/TLS transparent, meme mecanisme generique quegit.atelier.internal) sans toucher a l'allowlist egress.api-server(routes::create_workshop) relaie le champ depuis la requete de creation. Verifie de bout en bout : CRD regenere et applique sur le cluster de dev reel (crdgen), nouveau test d'integrationapply_creates_simulator_sidecar_containers_when_declared(contre kind reel, sans dependre du pipeline complet image-builder/Firecracker — patch direct destatus.imageDigest, meme pattern que les tests existants) confirmant les conteneurspostgres/mockcrees avec les bonnes images/env etATELIER_SIMULATORScorrectement forme surnet-proxy; nouveaux tests unitairesinternal.rs(resolution reelle par le code net-proxy, pas un mock).cargo test/clippy/fmtpropres sur tout le workspace (cargo test -p atelier-controller --test reconcile: 11/11 ; regression pre-existante et non liee identifiee dansatelier-api-server::routes::vscode_proxy_injects_real_session_auth_basic_header, confirmee en echec identique sur HEAD avant ce changement viagit stash). Non fait dans cette tache (hors perimetre 9.3, laisse a une session suivante) : activation dynamique a chaud (request_simulator, tache 9.4) et exposition du champsimulatorsdansatelier workshops createde la CLI. - [x] 9.4 : Outil MCP
request_simulatordanscrates/mcp-gateway(crates/mcp-gateway/src/gateway.rs) — patcheWorkshop.spec.simulators(ajoute une entree non pre-declaree) via unkube::Clienten config "in-cluster" (jeton de service account monte automatiquement). Contrainte reelle assumee et documentee : contrairement au libelle "a chaud" de la spec, un pod Kubernetes ne peut pas recevoir de nouveau conteneur sans redemarrage — le tool le dit explicitement dans sa description et sa reponse (disponible au prochain cycle stop/resume, meme mecanismeWorkshopUpgradeState::NeedsRestartForUpgradeque pour un upgrade Helm, deja existant). Nouveau RBAC cote controller :crates/controller/src/reconcile.rs::ensure_parent_pod_workshop_rbac, Role+RoleBindingget+patchsurworkshopsscopes au SEUL Workshop du pod viaresourceNames(jamaisworkshops/status, jamais un acces plus large), poses par le memeensure_parent_podque le reste du pod (donc a chaque reconcile, comme le reste du RBAC de ce fichier). Verifie contre le vrai RBAC du cluster (pas une relecture de l'objetRole) : nouveau testparent_pod_service_account_can_only_patch_its_own_workshop, emet un vrai jeton via l'APITokenRequestpour le ServiceAccount du pod parent d'un Workshop A, construit un secondkube::Clientreellement authentifie avec, confirme qu'il peut lire+patcher A (patch despec.simulatorsverifie applique) mais recoit un vrai403 Forbiddendu serveur API sur un Workshop B distinct du meme namespace.cargo test/clippy/fmtpropres sur tout le workspace, 12/12 suratelier-controller --test reconcile(regression incluse). Un probleme d'environnement (microVM builder crashant en ~3s durant cette session, cause non determinee — piste : etat KVM residuel) a empeche une verification de bout en bout via un Workshop reellement demarre avecmcp-gatewayjoignable en HTTP ; contourne par un test cible sur le mecanisme RBAC/patch reel plutot que par un mock. - [x] 9.5 : Socle HITL dans
api-server— nouvelle tablehitl_requests(migration20260905000000_hitl_requests.sql, RLS partenant= groupe proprietaire comme le reste de cette base depuis20260831000000_tenant_is_the_group.sql, categorie/statut en TEXT+CHECK plutot qu'ENUM Postgres, meme convention queexec_commands.status) et nouveau modulecrates/api-server/src/approvals.rs:POST/GET /v1/workshops/{name}/approvals(creation, liste) etPOST /v1/approvals/{id}/decision(approuve/rejette). TTL 15 min fail-closed : toute demandePENDINGexpiree est basculeeEXPIRED(jamais decidee) des qu'on la rencontre en lecture ou en decision, sans tache de fond dediee. Deuxieme predicat RLSapp.is_admin(positionne seulement siClaims::has_role("admin")) pour qu'un administrateur de l'instance puisse decider une demande d'un groupe dont il n'est pas membre, meme logique quelist_workshops. Verifie de bout en bout contre l'infra reelle : vrai Workshop cree via l'API, cycle complet create→list→decide execute avec un vrai JWT (atelier-test-user, groupeatelier-demo) — creation, listage, decision APPROVED confirmes, re-decision d'une demande deja tranchee refusee (400) ; bypass admin cross-tenant confirme avec un second JWT reel (atelier-admin, groupeatelier-core, roleadmin) decidant une demande du groupeatelier-demo.cargo check/clippy/fmtpropres. Non fait dans cette tache, laisse a une suite : cablage d'un appelant machine (mcp-gateway/pm-engine) vers ces endpoints (pas de canal d'authentification machine-a-machine defini pour l'instant — les endpoints sont utilisables par tout sujet authentifie proprietaire du Workshop, comme le reste de l'API) ; verification explicite du cas "sujet non-admin et non-membre du groupe" contre un troisieme utilisateur reel (bloque par un souci de configuration Keycloak sans rapport avec cette tache — "Account is not fully set up" — la logique de refus decoule neanmoins directement de l'algebre booleenne de la policy RLS, dont les deux autres branches sont verifiees). - [x] 9.6 : Interface HITL — Dashboard (
dashboard/lib/api-server.ts: types+fonctionslistApprovals/decideApproval;dashboard/app/actions.ts:decideApprovalAction, nommage distinct dedecideReviewActiondeja existant pour les revues PM Engine — deux mecanismes independants ;dashboard/app/workshops/[name]/approvals.tsx, composant client sur le meme patron quecredentials.tsx; bandeau d'alerte sticky sur la page Workshop quand des demandesPENDINGexistent, section "Approbations" avec boutons Approuver/Rejeter et historique repliable) et CLI (atelier approvals list/approve/reject,crates/cli/src/api.rs/commands/approvals.rs). Verifie de bout en bout contre l'infra reelle :npm run build/tsc --noEmitpropres ; UI exercee dans un vrai navigateur (login OIDC reel, Workshop et demande HITL reels crees via l'API) — bandeau affiche, clic "Approuver" reel confirme (bandeau disparait, demande basculee dans l'historique avec le statut "Approuvée") ; CLI exercee en reel (nouveau login device flow,atelier approvals list/approve/rejectexecutes contre de vraies demandes, statuts confirmes apres coup).cargo check/clippy/fmtpropres. - [x] 9.7 : Intégrations ChatOps (Slack uniquement — Teams non fait, voir ci-dessous) — nouveau
crates/api-server/src/chatops.rs:notify_slackposte un payload Block Kit (bouton "Approuver"/"Rejeter",value= UUID de la demande) versATELIER_SLACK_WEBHOOK_URLa chaquecreate_approval(best-effort, jamais bloquant) ;POST /v1/webhooks/slack/interactions(hors du groupe de routes protegees par JWT OIDC — Slack n'en presente pas) verifie la signature HMAC-SHA256 (v0:{timestamp}:{body},ATELIER_SLACK_SIGNING_SECRET, comparaison en temps constant) puis appellecrate::approvals::apply_decision(logique de decision extraite et partagee avecdecide_approvalREST). Limite assumee et documentee : une signature HMAC valide prouve seulement que la requete vient de l'app Slack configuree, jamais de quel groupe l'utilisateur Slack est membre — traite donc toute decision recue comme un bypass admin, meme mecanisme que l'admin REST. Verification partielle, honnetement bornee (pas de workspace Slack reel dans cet environnement) : signature HMAC verifiee par round-trip auto-coherent (signe puis verifie avec le meme code, PAS contre un vecteur officiel Slack — aucun acces reseau disponible pour le confirmer aupres de la documentation Slack, corrige apres qu'un premier essai de vecteur "officiel" recopie de memoire se soit revele faux au test) ; nouveaucrates/api-server/tests/chatops.rs: vrai routeur Axum + vraie base Postgres (RLS), un clic "Approuver" signe correctement bascule reellement une lignehitl_requestsenAPPROVED(verifie par relecture SQL directe), une signature invalide est refusee (401) sans rien changer en base, etnotify_slackenvoie un vrai POST HTTP verifie recu par un serveur TCP de test local avec le Block Kit attendu (UUID + boutons).cargo test/clippy/fmtpropres, aucune regression (atelier-api-server --test routes5/6, meme echec preexistant et non lie deja identifie en 9.5). Non fait : integration Teams (Adaptive Cards), et — comme deja signale pour 9.5 — aucun appelant machine (mcp-gateway/pm-engine) ne declenche encore reellement une demande HITL en conditions de production. M9 (Experience Dev CLI/Pont IDE, Simulateurs in-VM, HITL) entierement clos. - [x] 9.8 : Moteur Serveur Single-Node dans la CLI (
crates/cli/src/commands/server.rs) —atelier server doctor/install/status/upgrade/uninstall, meme etapes exactes quescripts/install.sh(k3s sans Traefik, Helm, ingress-nginx, cert-manager, ClusterIssuer Let's Encrypt, identifiants generes une seule fois puis relus,helm upgrade --installdu chartcharts/atelier), avec spinnersindicatif.doctorverifie reellement l'acces KVM par l'ioctlKVM_GET_API_VERSION— pas seulement l'existence de/dev/kvm— viakvm-ioctls(rust-vmm, wrapper sur : aucununsafecote atelier, regle du projet respectee), plus RAM (/proc/meminfo), ports 80/443 (vraie liaison TCP, distinguePermissionDenieddeAddrInUse) et presence desystemd. Verifie en reel (lecture seule, sans danger) :atelier server doctorexecute sur la machine de dev, confirme un vrai KVM (version API 12), 61,4 Go de RAM, et refuse correctement les ports 80/443 sans privilege root. Non verifie (delibere, evite par securite — voir doc de tete du module) :install/upgrade/uninstallde bout en bout, car cela installerait/desinstallerait reellement k3s sur la machine hote (systeme, pas reversible facilement, en conflit avec le clusterkindde dev deja en place) — implementation portee fidelement du script shell existant (memes commandes externes), a valider contre un vrai serveur/VM avant mise en production. Tentative de verifierdoctoren root viasudo:sudolui-meme segfault dans cet environnement sandboxe (confirme avecsudo echo hi, sans rapport avec ce code) — non resolu, non bloquant pour cette tache.cargo check/clippy/fmtpropres,atelier server --help/install --helpverifies. Script bootstrapcurl -fsSL https://get.atelier.dev \| sh: NON fait (necessite un domaine/hebergement publics hors de portee de cette session). - [x] 9.9 : Serveur MCP Local pour agents desktop (
crates/cli/src/commands/mcp.rs,crates/cli/src/mcp_client.rs) —atelier mcp serve [--context <nom>]ouvre une connexion CLIENTE reelle (rmcp, transport Streamable HTTP) vers le serveur MCP externe DEJA expose parapi-server(/v1/mcp,crates/api-server/src/mcp_server.rs) avec le jeton OIDC du contexte, puis sert sur stdio les outilsatelier_create_sandbox/atelier_list_sandboxes/atelier_exec_in_sandbox/atelier_read_file/atelier_write_file/atelier_git_diff/atelier_suspend/atelier_resume— chacun un simple relai (create_workshop,list_workshops,exec_in_workshop...), zero nouvelle capacite cote serveur :read_file/write_file/git_diffsont des raccourciscat/redirection-base64/git diffexecutes via le seul mecanisme d'execution reellement expose (exec_in_workshop+ flux SSE/v1/workshops/{name}/exec/{id}/stream, parse a la main cote CLI).atelier mcp install-config --target claude-desktop|cursor [--context <nom>]ecrit/fusionne le blocmcpServers.atelierdansclaude_desktop_config.json/.cursor/mcp.json. Verifie de bout en bout contre l'infra reelle : nouveau testcrates/cli/tests/mcp_serve.rsqui lance le VRAI binaireateliercompile en sous-processus (rmcp::transport::child_process::TokioChildProcess, exactement le mecanisme utilise par Claude Desktop/Cursor), parle MCP stdio avec un vrai clientrmcp, confirme les 8 outilsatelier_*annonces et un appel reelatelier_list_sandboxesrelaye avec succes vers unatelier-api-serverreel (nouveau login OIDC device flow pour ce test) ;install-configexecute reellement pour les deux cibles dans un$HOMEjetable, JSON verifie manuellement. Incoherence trouvee et corrigee en verifiant :install-configecrivait--context <nom>maismcp serven'acceptait pas cet argument (McpCommand::Servesans champs) — ajoute--contextaserve(crate::commands::auth::ensure_access_token_for_named_context, nouvelle fonction).cargo test/clippy/fmtpropres sur tout le workspace.
8. Jalon 11 (M11) : Souveraineté, Air-Gap & Inférence GPU Locale
- Spécification :
15-souverainete-airgap-inference-gpu.md - Constat : les environnements d'entreprise régulés (banque, défense, santé, OIV) exigent une inférence LLM 100% locale (vLLM sur GPU), la reconnaissance des CAs internes (PKI d'entreprise) par
net-proxyet les outils in-VM, et un packaging déconnecté (air-gap) des images conteneurs. Rien de tout cela n'existe aujourd'hui :net-proxyne valide que la chaîne TLS publique par défaut,charts/ateliern'a pas de composantgpu.vllm, et il n'existe aucun script d'export/import d'images. - Fichiers :
charts/atelier/values.yaml,charts/atelier/templates/(nouveaux templatesnet-proxyCA /litellmvLLM),crates/net-proxy/src/(truststore),crates/image-builder/,crates/guest-init/(injection CA rootfs),crates/cli/src/commands/server.rs(détection GPU),scripts/(nouveauairgap-bundle.sh). - [x] 11.1 : Support de
tls.customCaBundledanscharts/atelier(ConfigMap<release>-ca-bundle,charts/atelier/templates/infra/ca-bundle-configmap.yaml). Correction faite en vérifiant : la spec initiale visaitnet-proxy(§3.2), maiscrates/net-proxy/src/proxy.rs/tls_sni.rsprouvent que ce composant ne termine JAMAIS TLS sur son chemin de relais (CONNECT= tunnelTcpStreambrut ; mode transparent = lecture du seul SNI en clair) — une CA d'entreprise n'y "inspecterait" donc rien sans une réécriture MITM complète, hors périmètre. Recentré sur le point réel où TLS est terminé : nouveau mécanisme génériqueatelier_common::tls_client::client_builder_trusting_extra_ca(crates/common/src/tls_client.rs, généralise l'ATELIER_JWT_CA_PATHpré-existant d'api-server), câblé automatiquement côtéapi-server(ATELIER_JWT_CA_PATHpositionnée sur le fichier monté quandtls.customCaBundleest renseignée et non déjà surchargée parapiServer.env) — couvre le cas réelkeycloak.external.enabledavec IdP d'entreprise derrière une CA privée. Piège trouvé en implémentant :reqwest::Certificate::from_pem(backendrustls-tls-native-roots) accepte silencieusement un PEM invalide (octets arbitraires, ou corps base64 corrompu) sans jamais renvoyer d'erreur, ni au parsing ni àClientBuilder::build()— validé désormais explicitement viarustls_pemfile::certsavant transmission àreqwest, avec 5 tests unitaires réels (dont 2 régressions sur ce piège, certificat auto-signé généré par un vraiopensslpour le cas valide).helm lint/helm templatevérifiés avec un CA bundle réel etkeycloak.external.enabled:ConfigMaprendue correctement, montage +ATELIER_JWT_CA_PATHpositionnés, override explicite viaapiServer.envrespecté (pas d'écrasement).cargo test -p atelier-common/clippy/fmtpropres sur tout le workspace. Non fait dans cette tâche : aucun autrereqwest::Clientdu workspace n'a aujourd'hui de vrai besoin de cette CA (les autres clients HTTPS sortants observés sont soit en clair intra-cluster —openbao.rs,litellm.rs— soit inexistants), doncapi-server/JWKS reste l'unique consommateur réel du mécanisme pour l'instant ; pas de test d'intégration bout-en-bout contre un vrai serveur HTTPS (seule la construction du client est vérifiée empiriquement, pas un handshake réel). - [x] 11.2 : Injection de la CA d'entreprise (
tls.customCaBundle, tâche 11.1) au Jobimage-builderet dans le rootfs produit (crates/controller/src/reconcile.rs::ensure_image_build_job,crates/image-builder/src/main.rs) — pascrates/guest-init(fallback init sans rapport avec la confiance TLS, correction de la première rédaction de la spec 15). Le controller retransmetATELIER_CA_BUNDLE_CONFIGMAP(positionné par le chart,charts/atelier/templates/core/controller-deployment.yaml) en volumeConfigMapmonté sur le Job (/etc/atelier/ca/ca.crt,ATELIER_CA_BUNDLE_PATH). Correction faite en implémentant : la spec envisageait d'invoquerupdate-ca-certificates— abandonné (binaire pas forcément présent dans l'image cible,chrootfragile) au profit d'un ajout direct au bundle système déjà présent (append_pem_to_bundle_file, TOUJOURS un ajout, jamais un remplacement — remplacer casserait tout accès HTTPS public pour les mécanismes qui font confiance à ce seul fichier). Deux consommateurs :trust_enterprise_ca_bundle_for_this_process(magasin du conteneurimage-builderlui-même, pour son propregit clone— jamais relayé/déchiffré parnet-proxy, voir la correction de la tâche 11.1) etinject_enterprise_ca_bundle(rootfs produit : bundle système combiné pourgit/curl/pip/cargo, plusNODE_EXTRA_CA_CERTSdédié pour Node/npm qui ne consulte jamais le magasin système). 7 tests unitaires réels suratelier-image-builder(dont une régression trouvée et corrigée :inject_enterprise_ca_bundlene créait pasetc/avant d'écrireetc/environmentsur un rootfs minimal) + 1 nouveau test d'intégration réel contre le cluster kind de dev (apply_wires_ca_bundle_volume_into_image_build_job_when_configured, 13/13 suratelier-controller --test reconcile, un flake sans rapport confirmé en isolation).cargo test/clippy/fmtpropres sur tout le workspace,helm lintpropre. Non fait, limites assumées : seules les images de base Debian/Ubuntu bénéficient de la couverture complète (RHEL/Fedora jamais testé, hors périmètre) ;envbuilder/la microVM builder (crates/builder-vm-init) qui résout le devcontainer.json et exécute les features AVANT l'export du rootfs n'est PAS couverte — un devcontainer dont une feature a besoin d'un miroir npm/pip d'entreprise au moment du build échouerait encore, chantier distinct laissé à une suite. - [x] 11.3 : Composant Helm
gpu.vllm—StatefulSet(image officiellevllm/vllm-openai, cache HuggingFace survolumeClaimTemplatespour survivre aux redémarrages) +Service,charts/atelier/templates/infra/vllm-{statefulset,service}.yaml, gardés par.Values.gpu.enabled. Correction faite en implémentant : la spec envisageait unlitellm-configmap.yaml— ce fichier n'existe pas et n'a jamais existé dans ce chart,litellm-deployment.yamlpositionneSTORE_MODEL_IN_DB=Trueet TOUS les modèles sont gérés dynamiquement via/model/new(spec 11). Nouveaucharts/atelier/templates/jobs/litellm-vllm-model-init-job.yaml(hookpost-install,post-upgrade,curlimages/curl) enregistre donc le backend vLLM comme routemodel_name: "*"(wildcard, même convention quedeploy/dev/llm-proxy/config.yaml) via cette même API, idempotent sansjq(détection par présence deapi_basedansGET /model/info, nécessaire carmodel_namen'est pas garanti unique côté LiteLLM). Piège trouvé et vérifié empiriquement contre l'instance LiteLLM réelle du cluster de dev (atelier-llm-proxy,STORE_MODEL_IN_DB=Truedéjà actif) : la réponse dePOST /model/newchiffrelitellm_params.api_base(illisible), maisGET /model/infole renvoie en clair — confirmé par un Job jetable réel (création200,api_baseretrouvé en clair, second passage détecté "déjà présent", entrée de test supprimée après coup, aucun résidu laissé).helm lint/helm template(rendu vérifié avecgpu.enabled: true) etshellcheck(koalaman/shellcheck:stableen conteneur, script du Job) propres. Non vérifié : inférence réelle de bout en bout — cette machine a un GPU physique (RTX 3060) mais ninvidia-container-toolkitni un device plugin Kubernetesnvidia.com/gpun'y sont installés (docker run --gpus alléchoue explicitement), les installer aurait été un changement système invasif hors périmètre sur une machine de dev partagée ; leStatefulSetvLLM lui-même resteraitPendingfaute de cette ressource allouable.cargo fmt/clippy(aucun code Rust touché, workspace revérifié propre) sur tout le workspace. - [x] 11.4 : Détection GPU réelle (
crates/cli/src/commands/server.rs::detect_nvidia_gpu) — interroge le pilote vianvidia-smi(NVML, même principe que la vérification KVM déjà existante viakvm_ioctls, pasls /dev/nvidia*) ET vérifienvidia-container-runtimedans lePATH(sans lui, k3s ne peut exposer aucun GPU aux pods). Intégré àdoctor(diagnostic non bloquant) et au nouveau flag--enable-gpud'atelier server install(crates/cli/src/main.rs), qui rend ce même diagnostic BLOQUANT (échec explicite avant toute modification système) et positionnegpu.enabled: true/gpu.resources.limits."nvidia.com/gpu": 1dans les values Helm générées. Vérifié en réel sur cette machine de dev (GPU physique NVIDIA RTX 3060 présent) :atelier server doctordétecte correctementNVIDIA GeForce RTX 3060, 8192 MiBet signale à raison l'absence denvidia-container-runtime(non installé ici) en avertissement actionnable non bloquant.cargo check/clippy/fmtpropres sur tout le workspace. Non vérifié :install --enable-gpude bout en bout (installerait réellement k3s sur cette machine partagée, même limite déjà documentée en 9.8) — seule la fonction de détection est vérifiée en réel, son câblage dansinstallpar lecture de code et compilation. - [x] 11.5 : Script
scripts/airgap-bundle.shd'export/import d'images conteneurs (export [--with-gpu] [output-dir]/import --registry <registre>), viacrane pull/crane push— correction faite en implémentant :skopeo/docker save(première rédaction de la spec) abandonnés au profit decrane, déjà vendu et utilisé ailleurs dans ce dépôt (deploy/dev/crane/crane,crates/image-builder), aucune nouvelle dépendance externe. Liste des images corrigée par rapport àdeploy/terraform/aws/mirror-images.sh(en retard survalues.yaml: omettaitobservability/RustFS/gpu/litellmVllmModelInit, confondaitpostgres:16-alpine— clientpsqldes Jobs d'init — avecpgvector/pgvector:pg16, l'image réelle de la base). Piège trouvé et corrigé en testant réellement :do_importdéclaraitwork_direnlocalavec untrap ... EXITqui s'exécute APRÈS le retour de la fonction (variable alors hors de portée,variable sans liaisonsousset -u— import réussi mais message d'erreur final trompeur), corrigé en retirantlocal. Vérifié de bout en bout contre de vraies images et un vrai registre : export réel (curlimages/curl:8.11.0,ghcr.io/philippevienne/atelier-net-proxy:latest, archive ~45 Mo), import contre un vrairegistry:2local (docker run, port éphémère, supprimé après coup) confirmé parGET /v2/_catalog(les deux images présentes,atelier-net-proxyre-taguédevcomme attendu).shellcheck(koalaman/shellcheck:stable) propre. Corrige aussi la spec 15 §3.3 :global.imageRegistryn'existe pas dans ce chart (aucun template ne le lit) — documenté avec les deux mécanismes réels (ATELIER_COMPONENT_IMAGE_REGISTRYpour les 5 composants injectés par le controller,<composant>.image.repositoryindividuel pour tout le reste). Non fait, laissé à une suite : ajouter un véritableglobal.imageRegistrycohérent à toutes les sections du chart (amélioration légitime, hors périmètre de cette tâche). M11 (Souveraineté, Air-Gap & Inférence GPU Locale) entièrement clos.
9. Jalon 12 (M12) : Workflows Multi-Workshops & Orchestration d'Équipes d'Agents
- Spécification :
16-escouades-multi-agents-swarms-mesh.md - Constat : jusqu'ici, un Workshop isole un unique agent dans une unique microVM — aucun mécanisme ne permet à plusieurs Workshops spécialisés (backend/frontend/QA) de collaborer sur un même chantier via des contrats explicites plutôt qu'un dialogue LLM-à-LLM non structuré. Rien de tout cela n'existe :
WorkshopSpecn'a ni port exporté, ni cible inter-Workshop, ni identifiant de campagne ; aucun Service Kubernetes n'est créé par Workshop (l'unique canal réseau vers un port applicatif du guest est le relais WebSocketportforward.k8s.io-like denet-proxy, jamais un port direct sur l'IP du pod). - Fichiers :
crates/common/src/crd.rs,crates/controller/src/reconcile.rs,crates/net-proxy/src/(internal.rs, nouveauingress.rs),crates/identity-proxy/,services/pm-engine/,dashboard/app/campaigns/. - [x] 12.1 : Export de services, validation nominative des cibles ("Zero Wildcard") et
NetworkPolicypar campagne.WorkshopSpecgagneexported_services: Vec<ExportedService>(name,port),allowed_internal_targets: Vec<String>etcampaign_id: Option<String>(crates/common/src/crd.rs). Découverte de conception faite en implémentant : un port applicatif du guest n'existe QUE dans le netns de la microVM — rien n'écoute sur l'IP du pod pour lui (voircrates/controller/src/guest_probe.rs, "pas de port exposé directement sur l'IP du pod" — seul le relais WebSocket multiplexé/portforwarddenet-proxy, réservé au chemin externe api-server→Workshop, y accède). Un Service Kubernetes pointant vers l'IP du guest (hors netns du pod, injoignable depuis un AUTRE pod) est donc impossible : nouveaucrates/net-proxy/src/ingress.rs, relais TCP simple (tokio::io::copy_bidirectional, PAS le protocole WebSocket — inutile entre deux composants du même pod) qui écoute sur0.0.0.0:<port>et forwarde versvm_addr:<port>(le Service Kubernetes cible donc CE net-proxy, pas la microVM).crates/controller/src/reconcile.rs::ensure_exported_servicecrée un Service<workshop>-<service>par entrée (réappliqué à chaque reconcile, sans la contrainte d'immuabilité du pod) ;resolve_allowed_internal_targetsrésout chaque cible<service>.<workshop-cible>.atelier.internal:<port>en ClusterIP RÉEL du Service correspondant (même raisonnement quegit_identity::resolve_cluster_ip: le controller peut tourner hors cluster, best-effort si le Workshop cible n'est pas encore provisionné) et le transmet ànet-proxyviaATELIER_ALLOWED_INTERNAL_TARGETS;crates/net-proxy/src/internal.rsgagne une tablesquadséparée desimulators(même mécanisme textuel, "Zero Wildcard" par construction — recherche de clé exacte, jamais un suffixe).campaign_network_policygénère, seulement sicampaign_idest renseigné, uneNetworkPolicyd'ingress restreignant le pod parent aux seuls pods portant les MÊMES labelsatelier.dev/campaign-id/atelier.dev/owner-group(posés sur le pod UNIQUEMENT sicampaign_idest renseigné — unmatchLabelsvide sélectionnerait sinon tout le namespace). Vérifié de bout en bout contre le cluster kind de dev réel : nouveau testapply_wires_exported_services_squad_targets_and_campaign_network_policy, DEUX vrais Workshops créés (backend exportantapi:8080, frontend autorisé à le joindre), confirme le Service backend créé avec le bon sélecteur/port, la résolution en ClusterIP réel (pas l'alias) transmise au net-proxy du frontend,ATELIER_EXPORTED_SERVICES=api=8080transmise au net-proxy du backend, les labels de campagne posés, et laNetworkPolicygénérée avec le bon sélecteur de peer (14/14 suratelier-controller --test reconcile).crates/net-proxy/src/ingress.rs: 2 tests réels avec de vrais sockets TCP (relais bidirectionnel bout-en-bout, et fermeture propre si le port guest est fermé) ;internal.rs: 5 nouveaux tests (résolution par alias, "Zero Wildcard" sur une cible non listée, coexistence avec un simulateur homonyme). CRD régénéré (crdgen) et réappliqué sur le cluster de dev réel (kubectl apply, confirmé viakubectl get crd) — découverte au passage :crds/workshop.yaml/charts/atelier/templates/crds/workshop.yamlétaient déjà périmés AVANT cette tâche (ne portaient même passimulators, tâche 9.3, alors que le CRD réellement appliqué au cluster l'avait) — corrigés au passage (hygiène, hors périmètre strict de 12.1 mais peu coûteux).cargo test/clippy/fmtpropres sur tout le workspace,helm lintpropre. Non fait dans cette tâche, laissé à une suite : exposition viaapi-server/CLI (phasage explicite, hors périmètre —pm-engine, tâche 12.3, positionnera ces champs directement via le CRD) ;identity-proxy/X-Atelier-Squad-Token(tâche 12.2, authentification applicative de session — laNetworkPolicyde cette tâche reste une défense en profondeur au niveau paquet, pas le mécanisme d'autorisation primaire). - [x] 12.2 : Authentification de session inter-workshops. Correction faite en implémentant : pas
identity-proxyni un en-tête HTTPX-Atelier-Squad-Token(un service exporté n'est pas nécessairement HTTP) — nouveaucrates/common/src/squad_token.rs(jeton<workshop_name>|<expiry_unix>|<hex_hmac>, HMAC-SHA256, TTL 15 min, comparaison en temps constant) émis comme PREMIÈRE LIGNE textuelle parcrates/net-proxy/src/proxy.rs(avant tout octet relayé, y compris à chaque reconnexion) et vérifié parcrates/net-proxy/src/ingress.rs(ferme la connexion AVANT tout relais vers le guest si absent/invalide/expiré) — valide pour n'importe quel protocole applicatif. Le controller dérive une clé PAR CAMPAGNE (derive_campaign_key) à partir d'un secret GLOBAL (ATELIER_SQUAD_TOKEN_SIGNING_KEY, nouveau Secret HelmsquadToken.signingKey, vide par défaut = désactivé) et la transmet à CHAQUE Workshop de la campagne viaATELIER_SQUAD_TOKEN_KEY— jamais le secret global lui-même, jamais à un Workshop hors campagne. Nouvelle méthodeInternalRoutes::resolve_squad(distincte deresolve()) pour que seules les cibles inter-Workshops déclenchent l'émission d'un jeton. Vérifié de bout en bout contre le cluster kind de dev réel : testapply_wires_exported_services_squad_targets_and_campaign_network_policyétendu, confirme que les DEUX Workshops d'une même campagne reçoivent la MÊME clé dérivée (14/14 suratelier-controller --test reconcile).crates/net-proxy/src/ingress.rs: 3 nouveaux tests réels (jeton valide laisse passer, jeton forgé avec une mauvaise clé ferme la connexion AVANT tout relais vers le guest, absence de jeton finit par expirer après le vrai délai de 5s) ;crates/common/src/squad_token.rs: 5 tests réels (émission/vérification, campagnes distinctes ne se vérifient jamais l'une l'autre, expiration réelle avec un vraisleep, falsification du nom rejetée, entrées malformées jamais de panique) ;internal.rs: 1 nouveau test (resolve_squadne matche que la table squad).cargo test/clippy/fmtpropres sur tout le workspace,helm lint/helm templatepropres (Secret rendu, variable positionnée). Non fait, laissé à une suite : câblage effectif parpm-engine(tâche 12.3) qui positionnera réellementcampaign_idlors de l'orchestration multi-Workshops. - [x] 12.3 : Workflow LangGraph multi-étapes avec passage d'artefacts (OpenAPI). Correction faite en implémentant : pas de nouveau graphe —
pm-engineorchestrait déjà des sous-tâches multi-Workshops en parallèle depuis le Jalon M5 (PlanParallelTasks/SubTask/DelegateToOpencode, un mécanisme de fusion Git après coup). Réutilisé :SubTask(services/pm-engine/pm_engine/state.py) gagne trois champs optionnels —service_port/contract_path(sous-tâche PRODUCTRICE) etdepends_on(CONSOMMATRICE) — le prompt du planificateur les documente et_plan_is_credible(nodes.py) valide déterministiquement quedepends_onréférence toujours une sous-tâche DÉJÀ VUE plus haut dans le plan (delegate_to_opencodetraite les sous-tâches dans l'ordre, sans fan-out). Nouvelle méthodeBaseGitProvider.get_file_content(services/pm-engine/pm_engine/git_providers/, implémentée pour Forgejo/GitHub/GitLab — trois API "Contents" différentes) :delegate_to_opencodel'utilise pour injecter le contrat de la productrice comme contexte immuable dans le prompt de la consommatrice, avant de la déléguer.provision_workshopcalculeexportedServices/allowedInternalTargets/campaignId(nouvelles fonctions pures_squad_campaign_id/_squad_workshop_params) et les transmet àcreate_workshop— dont les trois champs correspondants, explicitement NON exposés côté Rust depuis la tâche 12.1 ("réservés à pm-engine, tâche 12.3"), sont désormais exposés par cette tâche (crates/api-server/src/routes.rs::CreateWorkshopRequestetcrates/api-server/src/mcp_server.rs::CreateWorkshopParams— ce dernier via un type miroirExportedServiceParam, piège trouvé en implémentant :atelier_common::ExportedServicedériveschemars0.8 (CRD Kubernetes) quand#[tool_router]/rmcpexige la 1.x, deux versions coexistant dans l'arbre de dépendances — un type ne peut implémenter les deuxJsonSchemaa la fois, d'où le miroir local plutôt qu'une réutilisation directe, même raison pour laquelleSimulatorSpecn'y apparaissait déjà pas). Vérifié de bout en bout contre l'infra réelle : nouveau test Rustcreate_workshop_wires_squad_fields_into_the_crd(crates/api-server/tests/routes.rs, contre le cluster kind de dev réel, 7/7 sur--test routes) confirmant que les trois champs atteignent bien le CRD via la route REST ;get_file_contentvérifiée en réel pour Forgejo (contre l'instance de dev réelle, dépôt jetable, 6/6 surtest_forgejo.py) et pour GitHub/GitLab (contre leurs API publiques réelles, dépôts publics connus, 4/4 et 4/4) ; 4 nouveaux tests réels sur_plan_is_credible/depends_onet 3 sur_squad_workshop_params(calcul pur, 52/52 surtest_nodes.py+test_plan_guards.py, 14 tests pré-existants ignorés faute deKEYCLOAK_PM_BOT_SECRET/LITELLM_MASTER_KEYconfigurés dans cet environnement — limite déjà documentée en tête detest_nodes.py, sans rapport avec cette tâche).cargo test/clippy/fmtpropres sur tout le workspace Rust ;ruff checkpropre côté Python (le formatageruff formatn'est pas un gate déjà en vigueur dans ce service, contrairement àcargo fmtcôté Rust — non touché pour éviter un diff massif sans rapport). Non fait, laissé à une suite : câblage de bout en boutprovision_workshop/delegate_to_opencodecontre un runpm-engineréel complet (nécessite l'infra Keycloak/LiteLLM/Forgejo intégralement configurée, hors de portée de cet environnement — seule la logique pure et le câblage Rust sont vérifiés en réel) ; tâches 12.4 (consolidation Git multi-branches), 12.5 (budget LLM parent) et 12.6 (interface Dashboard). - [x] 12.4 : Pipeline de consolidation Git multi-branches et validation QA. Découverte faite en vérifiant (même situation que la spec 14 vis-à-vis du Jalon M9) : cette tâche décrit un mécanisme DÉJÀ ENTIÈREMENT implémenté dans
pm-enginedepuis le Jalon M5, avant même la rédaction de la spec 16 — aucun nouveau code écrit pour cette tâche.plan_parallel_tasksattribue déjà une branche par sous-tâche (feature/<issue>-<task_id>) ;integrate_sub_tasks(pm_engine/nodes.py) fusionne déjà toutes les branches de sous-tâches dans celle de la première (git merge --no-editréel, exécuté dans le Workshop de cette première sous-tâche, conflits propagés sans jamais bloquer tout le run) ;run_devcontainer_testsexécute déjà la suite de tests sur CET ensemble intégré (pas une sous-tâche isolée) ;open_pull_requestouvre déjà UNE SEULE PR avec résumé consolidé (état des tests, conflits d'intégration éventuels) ;AwaitHitlApproval/MergeAndCloseempêchent déjà toute fusion surmainsans décision humaine explicite (tâche 9.6). Seule différence avec la première rédaction de la spec 16 §3.3 : pas de Workshop QA physiquement distinct — réutilisation du Workshop d'intégration, jugée suffisante et sans duplication d'environnement inutile. Corrigé dans la spec 16 (§3.3, "déjà entièrement implémentée depuis le Jalon M5"). - [x] 12.5 : Clé de budget parent partagée dans LiteLLM. Correction faite en implémentant : LiteLLM n'a aucune notion de clé hiérarchique — le mécanisme natif équivalent est une Team LiteLLM (
POST /team/new), un objet distinct porteur d'unmax_budgetPARTAGÉ par toutes les Virtual Keys qui lui sont rattachées (team_idsurPOST /key/generate), vérifié empiriquement contre l'instance LiteLLM réelle du cluster de dev. Nouvelles méthodescrates/controller/src/litellm.rs::ensure_team/generate_virtual_key_in_team/delete_team,campaign_team_id(préfixée pour ne jamais collisionner).ensure_parent_podappelleensure_teamdès queWorkshop.spec.campaign_idest renseigné, avant de générer la Virtual Key du Workshop avec ceteam_id. Idempotence gérée explicitement (contrairement àgenerate_virtual_key, non idempotent par conception) : plusieurs Workshops de la même campagne appellent chacunensure_teamindépendamment — vérifié empiriquement quePOST /team/newsur unteam_iddéjà existant renvoie400avec un message précis ("already exists", pas un409standard), seule façon fiable de distinguer "déjà créée" d'un autre échec ;POST /team/deletesur unteam_idabsent renvoie404, même convention quedelete_virtual_key. Vérifié de bout en bout contre l'infra réelle : nouveaucrates/controller/tests/litellm.rs(2 tests contre l'instance LiteLLM de dev réelle, idempotence deensure_teamet partage réel de Team entre deux clés) ; nouveau testapply_wires_two_workshops_of_the_same_campaign_into_the_same_litellm_team(crates/controller/tests/reconcile.rs, DEUX vrais Workshops de la même campagne, contre OpenBao+LiteLLM+cluster kind réels, confirme via/key/info//team/infoque les deux Virtual Keys partagent la même Team — piège trouvé et corrigé en testant : la réponse/team/infoportekeysà la racine, pas imbriqué sousteam_info.team_info.keyscomme la première version du test le supposait). 15/15 suratelier-controller --test reconcileavec OpenBao/LiteLLM réels configurés (2 échecs pré-existants sans rapport confirmés identiques sur HEAD viagit stash— backend Kubernetes-auth d'OpenBao non activé dans cette session, environnement, pas régression).cargo test/clippy/fmtpropres sur tout le workspace. Non fait, limite assumée et documentée dans le code : le plafond de la Team est celui du PREMIER Workshop de la campagne réconcilié (pas de champ CRD dédié à un budget de campagne indépendant) ; la Team n'est jamais supprimée par le finalizer d'un Workshop (aucun Workshop n'étant "le dernier" de façon fiable, rien ne représente la campagne elle-même) — accumulation de Teams orphelines sans impact fonctionnel, nettoyage périodique à faire si le volume devient gênant. M12 (Workflows Multi-Workshops & Orchestration d'Équipes d'Agents) : 12.1 à 12.5 clos, seule 12.6 (interface Dashboard) reste ouverte. - [x] 12.6 : Interface de suivi de campagne multi-agents sur le Dashboard. Aucun nouvel endpoint api-server nécessaire :
WorkshopSpecportait déjàcampaignId/exportedServices/allowedInternalTargetscôté CRD depuis 12.1/12.3, seuls absents du côté TypeScript du Dashboard (dashboard/lib/api-server.ts) — ajout des trois champs (ExportedServiceétendu àWorkshopSpec) et nouvelle pagedashboard/app/campaigns/page.tsxqui regroupe les Workshops retournés parlistWorkshops()(déjà filtré parowner_subjectcôté api-server) parcampaignId, affichant pour chacun sonPhaseBadge, ses services exposés (Expose : <nom>:<port>) et ses cibles inter-Workshops (Joint : <cible>, transformée en lien direct vers le Workshop cible quand il appartient à la même campagne — parsing du format<service>.<workshop>.atelier.internal:<port>documenté danscrates/net-proxy/src/internal.rs). Lien "Campagnes" ajouté àtop-nav.tsx. Vérifié de bout en bout contre l'infra réelle :atelier-api-servercompilé et lancé en local contre le cluster kind de dev (Postgres/OpenBao/LiteLLM/S3 via port-forward réels), dashboardnext devpointé dessus, connexion OIDC réelle via Keycloak de dev (atelier-test-user), deux vrais objets CRDWorkshopcréés dans le cluster (campaignId: pm-99, l'un avecexportedServices, l'autre avecallowedInternalTargetspointant vers le premier) — piège trouvé en testant : le filtreowner_subjectde l'api-server (routes.rs::list_workshops) compare ausubJWT (un UUID Keycloak), pas au nom d'utilisateur ; mes premiers objets de démo (ownerSubject: "demo") restaient invisibles pouratelier-test-user, corrigé en relisant le vraisubvia un password grant OIDC réel. Après correction,/campaignsaffiche bien la section "pm-99" (1/2 en cours d'exécution), les deux Workshops avec leurs badges de phase (Running/BuildingImage) et le lien croisé "Joint :" — capture d'écran prise dans le navigateur réel.npx tsc --noEmitetnpm run buildpropres. Objets CRD de démo supprimés après vérification. M12 (Workflows Multi-Workshops & Orchestration d'Équipes d'Agents) entièrement clos (12.1 à 12.6).
10. Jalon 13 (M13) : Réutilisation des Images par Source
- Spécification :
18-reutilisation-images-par-source.md - Constat : mesuré sur
kind-atelier-dev, un second Workshop du même dépôt et de la même révision met 106 s à atteindreRunning, dont 90 s à reconstruire une image qui existe déjà. Le controller ne regarde questatus.imageDigestdu Workshop courant, l'image et le cache de couches sont nommés d'après le Workshop, et le cache est indexé par l'empreinte du fichier produit, qui diffère d'un build à l'autre (déjà relevé par la spec 13, §3). - Fichiers :
crates/common/src/crd.rs(nouvelle ressourceWorkshopImage),crds/,crates/controller/src/reconcile.rs(ensure_image_build_job),crates/controller/src/eviction.rs,crates/image-builder/src/main.rs,crates/builder-vm-init/src/main.rs,charts/atelier/. - [ ] 13.1 : Artefacts de registre nommés par la clé de source (
<registre>/atelier-images/<clé>) etENVBUILDER_CACHE_REPOcommun (spec §3.3). Critère : le build d'une seconde révision d'un même dépôt réutilise les couches de la première, constaté dans les journaux d'envbuilder. - [ ] 13.2 : Calcul de la clé de source (spec §3.1), révision résolue en commit avant toute recherche, clé consignée dans le statut du Workshop.
- [ ] 13.3 : Ressource
WorkshopImageet réconciliation par clé (spec §3.2) : le Job de build appartient à l'image. Critère : un second Workshop de la même source passe dePendingàProvisioningsans Job de build ; dix Workshops créés ensemble ne lancent qu'un seul build. - [ ] 13.4 : Trancher et implémenter les questions ouvertes de la spec §4 (révisions mobiles, egress de build, dépôts privés, build en échec).
- [ ] 13.5 : Éviction tenant compte des
WorkshopImage(spec §3.2), sans régression des garanties de la spec 13. - [ ] 13.6 : Remesurer avec le même essai que le constat et consigner les résultats dans la spec.
11. Jalon 14 (M14) : Sessions pour Apprenants
- Spécification :
19-sessions-pour-apprenants.md - Constat : essayé comme plan d'exécution d'une plateforme de formation, Atelier fait tourner et vérifier des labos dans ses microVM (27 labos sur 34 rejoués sans modification des environnements), mais le disque de session ne se règle pas, trois ports courants sont pris dans l'invité, une image avec systemd sans
/sbin/initne démarre jamais sans le dire, et l'exec, pensé pour un agent, rend un résultat incomplet à un serveur qui vérifie. - Fichiers :
crates/controller/src/reconcile.rs,crates/firecracker/src/vm.rs,crates/image-builder/src/main.rs,crates/guest-init/,crates/api-server/src/exec.rs,crates/api-server/src/main.rs,crates/net-proxy/,crates/common/src/crd.rs,charts/atelier/. - [x] 14.1 :
resources.diskappliqué à l'invité, plafonné par une limite de cluster (spec §3.1). Critère :dfdans l'invité montre la taille demandée ; undds'arrête à cette taille. Vérifié dans l'invité le 2026-10-09 : sansresources.disk, 1412 Mio comme avant ; avec4Gi,dfaffiche 4,0 Gio et undds'arrête à 3776 Mio sur 3997 (ext4 réserve 5 %) ;8Giavec un plafond de 3072 Mio donne 3,0 Gio ;64Milaisse le disque à la taille de l'image, avec l'avertissement. Coût non mesurable de l'extérieur (15,6 s sans, 15,9 s avec) ; environ 3 s de plus à la reprise, où le redimensionnement est rejoué. - [x] 14.2 : Vérifications de fin de build (spec §3.2). Critère : une image avec systemd sans
/sbin/init, ou sanscurl, échoue au build avec un message qui nomme le manque. Fait danscheck_boot_prerequisites(crates/image-builder), qui exige aussibashetenv. Vérifié par tests unitaires et sur deux rootfs réels exportés (une image avecsystemd-sysvetcurl, acceptée ;debian:trixie-slim+openssh-server, refusée pour les deux manques). Non rejoué par un build complet en cluster. L'injection d'un binaire statique à la place de l'erreur n'a pas été retenue. - [ ] 14.3 : Services injectés jamais en root sous l'init d'Atelier (spec §3.2). Critère :
psdans un invité sans systemd montrettydetcode-serversous le compte de la session. Code écrit le 2026-10-26, tâche laissée ouverte faute de preuve dans l'invité :atelier-guest-initlance le rafraîchissement du workspace,ttydetcode-serversous le comptevscode(uid lu dans/etc/passwd, repli sur 1000, groupes supplémentaires vidés par la bibliothèque standard) ; seulsshdreste en root. Tests unitaires de la lecture depasswd. Non vu :psdans un invité sans systemd. Les images de Mentor ont toutes systemd (ce chemin n'est pas pris), et une image Alpine ne démarre pas du tout :atelier-guest-initest lié dynamiquement à glibc, le noyau retombe sur/bin/sh(console vue dans l'invité) ; lecode-serverembarqué (Node, glibc) n'y tournerait pas non plus. À reprendre avec une image glibc sans systemd. - [x] 14.4 : Ports injectés hors des valeurs courantes, terminal et IDE optionnels par Workshop (spec §3.3).
sshd,code-serveretttydécoutent sur 61022, 61080 et 61081 (plage réservée 61000-61099) ;spec.guestServicesretire le terminal ou l'IDE ;image-builderinscrit les ports dansstatus.guestPorts, que lisent le controller et l'api-server, avec repli sur les ports historiques pour les Workshops existants. Vérifié dans l'invité : un service écoute sur 8080, et les deux cours Docker de Mentor passent en entier, y compris sans terminal ni IDE. - [x] 14.5 : Corriger le résultat d'exec (spec §3.4, constat 7) : sortie d'erreur conservée, octets nuls, code ou signal, entrée standard fermée, fin de l'appel malgré un processus d'arrière-plan. Fait dans
crates/api-server/src/exec.rs(outil MCP) et la migration20260915000000(tampons enBYTEA, colonnesignal). Vérifié le 2026-10-26 dans une microVM (Workshopgit-basicsde Mentor, sans terminal ni IDE), par l'outil MCP et le flux SSE : sortie standard et sortie d'erreur reçues ensemble, octet nul conservé,catqui rend la main,sleep 30 & echo lancerendu en 0,65 s,exit 3etexit 7rendus tels quels, signauxSEGVetKILLrapportés (statutKilled). Le premier passage a montré unexitCode 139sans signal : l'enveloppetimeouttournait sous un shell intermédiaire, corrigée parexec. Migration appliquée sur le PostgreSQL du cluster, tests d'intégration contre un vraisshd. Non vu : le statutTimeout(plafond de 1200 s), ni un invité saturé. - [x] 14.6 : Appel « exécuter une commande » de l'API REST (spec §3.4) :
POST /v1/workshops/{name}/exec, avec dossier, environnement, durée et taille de sortie par appel, et une réponse unique (code ou signal, sorties séparées en base64, troncature, dépassement de délai). Vérifié contre un vraisshdOpenSSH sur la machine de test puis dans l'invité, sur trois images :git-basicsde Mentor y passe 31 étapes sur 31 par cet appel seul, et 100 appels simultanés répondent tous. Le choix du compte par appel est reporté à la tâche 14.7. - [ ] 14.7 : Clone de l'espace de travail optionnel et champs de session du
devcontainer.jsonappliqués (spec §3.5). Première moitié faite le 2026-10-26 :spec.devcontainer.cloneWorkspace(vrai par défaut ; champ du CRD, du chart et deDevcontainerSource, transmis àimage-builderparATELIER_CLONE_WORKSPACE). À faux, ni clone ni service de rafraîchissement : le dossier de travail est créé vide (.keep) et donné au compte de la session. Vérifié dans le cluster surgit-basicsde Mentor :falsedonne un dossier vide, l'exec s'y ouvre etgity répond « not a git repository » ;truedonne les 11 entrées du dépôt, comme avant. Reste : applique Seconde moitié partielle (2026-10-26) :image-builderlit ledevcontainer.json(JSONC) et inscritremoteUser,workspaceFolderetcontainerEnvdansstatus.session; l'api-server les applique à l'exec REST et à l'outil MCP (containerEnvsous l'environnement de l'appel,USER,LOGNAMEetHOMEdu compte annonce, dossier déclaré quand le dépôt n'est pas cloné, créé et donné au compte de la session). Vérifié dans le cluster surgit-basicsde Mentor sans clone :pwd=/workspace(propriétaireapprenant),USER=apprenant HOME=/home/apprenant(avant :vscode,/home/vscode),EDITOR=nanopar défaut etvimquand l'appel le donne ; même résultat par l'outil MCP. Reste :postStartCommandethostRequirements(valeurs par défaut deresources, à lire côté controller) ; le compte restevscodeen dur poursshdet les services (SSH_USER,ensure_vscode_user), donc le choix du compte par appel d'exec n'est pas fait. - [x] 14.8 : Egress de build distinct de l'egress d'exécution, modifiable sans suspension (spec §3.6 ; à mener avec la tâche 13.4). Fait (2026-10-26) :
spec.buildEgressAllowlist(absent : le build reprendegressAllowlist, comme avant) ; l'egress d'exécution est publié à chaque réconciliation dans la ConfigMap<workshop>-egress, montée dansnet-proxy, qui la relit toutes les 5 s (ATELIER_EGRESS_ALLOWLIST_FILE) en gardant les ajouts à chaud derequest_egress. Vérifié dans le cluster surgit-basicsde Mentor, build avec une liste et exécution avec une autre (example.comseul) :example.orgbloqué, ajouté àspec.egressAllowlistil passe en 67 s, retiré avec la liste vide tout est coupé en 60 s, le Workshop restantRunning(délai de propagation de kubelet). Réserve : un Workshop dont le pod existait avant ce changement n'a pas le montage et garde l'ancien comportement jusqu'à sa prochaine suspension. Les questions de la tâche 13.4 (egress de build partagé entre images) restent ouvertes. - [ ] 14.9 : Noyau invité avec
nf_tableset la tableraw, console de l'invité exposée, adresse d'écoute de l'api-server configurable (spec §3.7). Critère : Docker démarre dans l'invité sans réglage particulier. Fait en partie (2026-10-26), tâche laissée ouverte faute de preuve sur le critère : noyau 5.10.223 reconstruit dans Docker (deploy/dev/firecracker/kernel/,build.sh) avecNF_TABLES,IP_NF_RAW,IP6_NF_RAW,XT_MARK,XT_MATCH_COMMENT,IP_SET,IP_VS,VXLAN, toutes retenues parolddefconfig;ATELIER_API_LISTEN_ADDRpour l'api-server ; console de l'invité au niveau info danskubectl logs <workshop>-parent -c vm-supervisorpour un Workshop annotéatelier.dev/console-log=true(531 lignes vues). Vérifié : le coursdocker-hellode Mentor démarre sur le nouveau noyau,dockerd29.9.2 tourne. Non vérifié : Docker sans réglage particulier. L'image de Mentor pose elle-mêmeDOCKER_INSECURE_NO_IPTABLES_RAW=1(/etc/systemd/system/docker.service.d/mentor.conf) et l'utilisateur de session n'a pas root pour le retirer ni pour lire les tables. À reprendre avec une image Docker sans ce réglage. Le noyau n'est pas encore dans les images de production (les Dockerfile copientassets/vmlinux.bin, non versionné). - [x] 14.10 : Instruire les questions ouvertes de la spec §4 (cloisonnement par organisation, canal d'exec, durée de vie). Instruites le 2026-10-26 dans la spec 19 §4.1 (faits tirés du code, options, recommandation) : cloisonnement par resserrement dans un seul espace de noms plutôt que par groupe, SSH conservé comme canal d'exec, délais d'inactivité et de durée en « jamais » par défaut. Aucune implémentation ; les suites sont les tâches 14.16 à 14.18.
- [x] 14.16 : Resserrer le cloisonnement dans un seul espace de noms (spec 19 §4.1) : liste filtrée par groupe y compris pour
admin(route d'administration explicite pour tout voir),NetworkPolicypar défaut qui ferme l'entrée des pods de Workshop sauf depuis le plan de contrôle. Critère : un pod sans rapport ne joint plus le port de redirection d'un Workshop, tunnel complet refusé compris. Fait (2026-10-27) :GET /v1/workshopset l'outil MCP de liste suivent le groupe (owner_group),admincompris ;GET /v1/admin/workshops(rôleadmin, journalisé) rend tout l'instance ; le chart pose la NetworkPolicy<release>-workshop-ingress(workshops.networkPolicy.enabled, vrai par défaut) qui ne laisse entrer dans un pod de Workshop que le controller et l'api-server. Vérifié : test d'intégration de la liste (collègue du groupe, administrateur d'une autre organisation, route d'administration refusée sans le rôle) ; dans le cluster de dev, avec la politique rendue parhelm template, un pod quelconque est bloqué sur le port de contrôle devm-supervisor(8081) et sur celui denet-proxy(3128) alors qu'un pod portant les étiquettes de l'api-server les joint. Non vérifié : le chart installé en entier avec un plan de contrôle dans le cluster, et la tentative de tunnel complète depuis un pod étranger. Attention en dev : un controller ou un api-server hors du cluster ne correspond à aucunpodSelector, la politique les couperait (la stack de dev ne l'installe pas). - [ ] 14.17 : Activité et durée de vie (spec 19 §4.1) : horodatage d'activité écrit par l'api-server,
spec.idleTimeoutSecondsetspec.maxSuspendedSeconds(défaut « jamais ») appliqués par le controller. Décider d'abord si un processus actif de l'invité compte comme activité. - [x] 14.18 : Horloge de l'invité après reprise (constat 15, spec 19 §4.1) : l'heure de l'hôte dans la réponse au battement de
guest-init, corrigée au-delà de 2 s d'écart ; solution équivalente pour les images avec systemd. Critère : écart inférieur à 2 s dix secondes après une reprise. Fait (2026-10-28) :net-proxysert l'heure de l'hôte surGET /time(serveur de métadonnées,169.254.0.1:3132) ;image-builderinjecte le service systemdatelier-sync-clock(script bash, remise à l'heure au-delà de 2 s d'écart, vérification toutes les 5 s) ;atelier-guest-initfait la même chose pour les images sans systemd (clock_settime). Vérifié dans le cluster surgit-basicsde Mentor (systemd) : après une suspension d'une minute, l'invité retardait de 113 s à la sortie de reprise (16 s après la demande) et l'écart tombe à 0,3 s (délai de la mesure) 2 s plus tard, sans plus bouger. Non vu : le cheminatelier-guest-init, faute d'une image glibc sans systemd (même blocage que 14.3) ; il n'est couvert que par des tests unitaires de l'analyse de la réponse et du seuil. Un Workshop construit avant ce changement n'a pas le service avant sa prochaine reconstruction d'image. - [x] 14.11 : Conserver le disque à la mise en veille (spec §1, constat 16, et §3.8). Critère : un fichier écrit sur le disque racine puis synchronisé avant une suspension se relit à l'identique après la reprise, cache de pages vidé (
dd iflag=direct), etdmesgne signale aucune erreur ext4. - [x] 14.12 : Faire remonter le refus de build de la tâche 14.2 (spec §3.2) : message dans le journal de terminaison du conteneur et dans
status.conditionsdu Workshop, et pas de nouvelle tentative pour un refus déterministe (aujourd'hui trois builds complets, 3 min 17 s, et un message visible seulement dans les journaux du Job). Fait :image-buildersort en code 65 et écrit la raison dans/dev/termination-logpour un refus (BuildRefused), le Job de build porte unepodFailurePolicyqui l'arrête sans nouvelle tentative, et le controller inscrit le message dansstatus.conditions["ImageBuildFailed"]. Le Workshop ne passe plusFailedau premier pod en erreur mais quand le Job est terminé en échec. Vérifié le 2026-10-26 dans le cluster suralpine:3(dépôtget2knowio/deacon) : un seul pod, Job arrêté pourPodFailurePolicy, condition lisible sur le Workshop, environ 35 s au lieu de 3 min 17 s. Non vérifié : un échec transitoire (les nouvelles tentatives restent à 2), et un Workshop dont le Job a été créé avant ce changement (lespec.templated'un Job est immuable, il garde l'ancien comportement). - [x] 14.13 : La suspension répond dans le délai du controller (spec §3.8).
request_snapshotattendATELIER_SNAPSHOT_TIMEOUT_SECS(300 s par défaut,workshops.snapshotTimeoutSecondsdu chart) au lieu de 30 s,ensure_suspendedne redemande plus d'instantané à un pod en cours d'arrêt, l'empreinte est calculée par blocs. Vérifié dans le cluster avec S3 :snapshotDigestrenseigné sur des Workshops de 4 et 6 Gio, trois objets sur S3 (l'état en dernier), reprise depuis S3 seul sur deux générations, et démarrage à froid propre quand l'offload a été interrompu. - [x] 14.14 : Ne pas annoncer
Runningavant que l'invité réponde (spec §3.8).guest_port_answersremplaceguest_tcp_port_open, qui tenait le silence pour un port ouvert : il faut la bannière SSH sur le port d'exec et une réponse HTTP sur celui du terminal. Vérifié dans le cluster : premier exec réussi 13 fois sur 13 dès le passage àRunning, dont 5 reprises depuis S3, et une image sans systemd. - [x] 14.15 : Stockage S3 des instantanés (spec §3.8) : envoyer le disque sans ses trous, effacer les objets d'un Workshop supprimé, vider
snapshotDigestquand la suspension n'a pas produit d'instantané. Fait : les fichiers d'instantané partent en zstd sous<nom>.zst(repli sur l'ancienne clé à la lecture) et sont réécrits creux à la reprise ;delete_snapshot_prefixvide S3 à la suppression du Workshop ;snapshotDigestest vidé quand la suspension n'a pas produit d'instantané ;download_snapshot_to_filecrée le dossier absent. Vérifié le 2026-10-26 sur un disque de 4 Gio : 411 Mio (disque), 251 Mio (mémoire) et 1,9 Kio (état) envoyés, suspension en 60 s, fichier de 200 Mio relu à l'identique avec mêmeboot_idaprès reprise locale puis après reprise depuis S3 seul, 3 objets avant la suppression du Workshop et 0 après. Non vérifié en réel : le vidage desnapshotDigest(couvert par lecture du code seulement), et le temps de trois suspensions simultanées.