Aspire : orchestrer la pile GenAI réelle du cluster
Ce notebook démontre l’axe Aspire de l’Epic #10473 — The Unexpected AI Stack: C#/.NET (grain #10857). Il prolonge le grain #10838 (notebook 01-Aspire-Orchestration-GenAi.ipynb, qui orchestrait un service) : ici, l’AppHost déclare la pile telle qu’elle existe vraiment — plusieurs machines, plusieurs rôles, deux typologies de ressources — et le notebook la traverse par des appels réels.
Ce qui est démontré, de bout en bout :
Un AppHost C# unique qui modèle la pile réelle : endpoints externes (ComfyUI, vLLM) + un conteneur orchestrable (whisper-api).
Deux instances simultanées (aspire run --isolated depuis deux répertoires), sans collision de ports ni de conteneurs.
aspire describe / aspire logs sur des ressources réelles.
Des appels traversants : une complétion LLM sur le vLLM de la flotte (qwen3.6-35b-a3b), et un appel authentifié à ComfyUI (génération Qwen-Image).
Trois exercices pour s’approprier le diagnostic et l’extension du modèle.
Comment lire ce notebook : chaque section suit le même rythme — une cellule de code qui agit sur la pile, puis sa lecture. Les valeurs citées dans les lectures (ports, PIDs, jetons) sont celles des sorties committées : si vous ré-exécutez, elles changent — comportement attendu, les ports --isolated sont éphémères par conception — mais la structure des résultats reste identique.
Contexte : la pile GenAI réelle, multi-machines
Contrairement au grain #10838, qui orchestrait un service local isolé, la pile de production couvre plusieurs machines du cluster :
Service
Machine
Endpoint
Rôle
Statut dans l’AppHost
comfyui-qwen
po-2023
http://127.0.0.1:8188
génération d’images Qwen (ComfyUI 0.25)
externe (référencé)
vllm
ai-01
http://192.168.0.47:5002
LLM qwen3.6-35b-a3b, endpoint OpenAI-compatible
externe (référencé)
whisper-api
locale
port fixe 8190 (compose)
transcription ASR faster-whisper
conteneur orchestré
Deux typologies, et c’est un choix d’architecture, pas une commodité :
Les services lourds sont des singletons GPU. ComfyUI occupe ~14 Go de VRAM sur la RTX 3090 ; le vLLM de ai-01 sert toute la flotte. Les dupliquer « par worktree » serait gaspiller la seule ressource rare (la GPU) et risquer l’OOM. Aspire les déclare donc comme chaînes de connexion : visibles dans le dashboard, consommables par le code, jamais recréés.
Le service léger est orchestrable. whisper-api (lazy-load : le modèle ne se charge qu’au premier appel) se duplique sans coût — c’est lui qui matérialise l’isolation --isolated.
Ligne de parité : à la main, un agent qui travaille dans un worktree doit connaître les ports de chacun, gérer les collisions, et documenter la pile dans un README. Avec Aspire, le modèle de ressources EST la documentation exécutable : aspire describe répond.
Cette asymétrie se lit aussi dans les effets de bord : arrêter l’AppHost ne touche que ce qu’il a créé (les conteneurs orchestrés), jamais les singletons référencés — les autres machines de la flotte ne voient même pas passer votre expérimentation. C’est ce qui autorise un notebook « réel » sans risque : la pile de production reste hors de portée des effets de bord du grain.
1. L’AppHost : la pile entière en un fichier C
L’AppHost (GenAiStackReel.AppHost/apphost.cs) utilise le SDK file-based #:sdk Aspire.AppHost.Sdk@13.4.6 (pas de .csproj). Trois déclarations à repérer à la lecture : les deux ConnectionStrings (comfyui, vllm) passés par configuration — la surcharge à deux arguments de AddConnectionString interprète le second comme un nom de variable d’environnement, pas comme la valeur — et le AddContainer du grain #10838, reconduit à l’identique.
Aucun secret ne vit dans ce fichier : le conteneur orchestré tourne avec AUTH_ENABLED=false, et les clés des endpoints externes ne sont présentées qu’au moment de l’appel (sections 4), lues au runtime.
using System.IO;using System.Net.Http;using System.Text;using System.Text.RegularExpressions;using System.Threading.Tasks;// Amorcage : chemins partages + helpers d'execution (CLI Aspire, docker) et nettoyage ANSI.// Un `static class` persiste entre les cellules du notebook (.NET Interactive).publicstaticclass AspireShell {publicstaticreadonlystring AspireCmd = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),".dotnet","tools","aspire.cmd");// CLI Aspire (dotnet tool global)publicstaticreadonlystring RepoRoot =FindRepoRoot(Directory.GetCurrentDirectory());publicstaticreadonlystring NbDir = Path.Combine(RepoRoot,"MyIA.AI.Notebooks","GenAI","Aspire");publicstaticreadonlystring DirWt1 = Path.Combine(NbDir,"GenAiStackReel.AppHost");publicstaticreadonlystring DirWt2 = Path.Combine(NbDir,"GenAiStackReel.AppHost-wt2");publicstaticreadonlystring GenAiEnvFile = Path.Combine(RepoRoot,"MyIA.AI.Notebooks","GenAI",".env");publicstaticstringFindRepoRoot(string start){var dir =newDirectoryInfo(start);while(dir !=null&&!Directory.Exists(Path.Combine(dir.FullName,"docker-configurations"))) dir = dir.Parent;return dir?.FullName??thrownewDirectoryNotFoundException("racine du depot introuvable");}// Execute une commande (cmd.exe) et capture stdout+stderr, avec duree de vie// bornee : lecture ASYNCHRONE (un ReadToEnd synchrone avant WaitForExit// bloquerait indefiniment sur un processus qui ne se termine pas).publicstaticstringRun(string workDir,string cmd,string args,int timeoutMs =180_000){var psi =new System.Diagnostics.ProcessStartInfo{ FileName ="cmd.exe", Arguments = $"/c \"{cmd}\" {args}", WorkingDirectory = workDir, RedirectStandardOutput =true, RedirectStandardError =true, UseShellExecute =false, CreateNoWindow =true};var p = System.Diagnostics.Process.Start(psi)!;var sb =newStringBuilder(); p.OutputDataReceived+=(_, e)=>{if(e.Data!=null)lock(sb) sb.AppendLine(e.Data);}; p.ErrorDataReceived+=(_, e)=>{if(e.Data!=null)lock(sb) sb.AppendLine(e.Data);}; p.BeginOutputReadLine(); p.BeginErrorReadLine();if(!p.WaitForExit(timeoutMs)){try{ p.Kill();}catch{}}lock(sb)return sb.ToString();}publicstaticstringAspire(string workDir,string args)=>Run(workDir, AspireCmd, args);// Capture une commande qui FLUXE sa sortie (ex. aspire logs) pendant une fenetre// bornee, puis la termine : on garde ce qui a deja ete emis.publicstaticstringRunCapture(string workDir,string cmd,string args,int windowMs =10_000){var psi =new System.Diagnostics.ProcessStartInfo{ FileName ="cmd.exe", Arguments = $"/c \"{cmd}\" {args}", WorkingDirectory = workDir, RedirectStandardOutput =true, RedirectStandardError =true, UseShellExecute =false, CreateNoWindow =true};var p = System.Diagnostics.Process.Start(psi)!;var sb =newStringBuilder(); p.OutputDataReceived+=(_, e)=>{if(e.Data!=null)lock(sb) sb.AppendLine(e.Data);}; p.ErrorDataReceived+=(_, e)=>{if(e.Data!=null)lock(sb) sb.AppendLine(e.Data);}; p.BeginOutputReadLine(); p.BeginErrorReadLine();if(!p.WaitForExit(windowMs)){try{ p.Kill();}catch{}}lock(sb)return sb.ToString();}// Le CLI emet des sequences ANSI (couleurs SGR, hyperliens OSC-8) meme// redirige : on retire les enveloppes pour des sorties lisibles.publicstaticstringClean(string s){if(string.IsNullOrEmpty(s))return s ??""; s = Regex.Replace(s, @"\x1b\[[0-9;]*m","");// couleurs SGR s = Regex.Replace(s, @"\x1b\]8;[^\x1b]*\x1b\\","");// hyperlien OSC-8 (terminaison ST) s = Regex.Replace(s, @"\x1b\]8;[^\x07]*\x07","");// hyperlien OSC-8 (terminaison BEL)// Issue #11725 : neutralise les chemins absolus Windows qui survivent// aux strips ANSI/OSC-8 (D:\dev\..., C:\Users\..., file:///C:/...). s = Regex.Replace(s, @"(?<![A-Za-z])[A-Za-z]:\\[\S]+","<chemin-local>"); s = Regex.Replace(s, @"(?<![A-Za-z])[A-Za-z]:/[\S]+","<chemin-local>");return s;}publicstaticstringDescribe(string workDir,string resource)=>Clean(Run(workDir, AspireCmd, $"describe {resource}",60_000));// Poll jusqu'a ce que la ressource soit Healthy (conteneur whisper-api).publicstatic async Task<string>WaitHealthyAsync(string workDir,string resource,int maxTries =40){string snapshot ="";for(var i =0; i < maxTries; i++){ snapshot =Describe(workDir, resource);if(snapshot.Contains("Healthy"))break; await Task.Delay(5000);}return snapshot;}}// Issue #11725 (genai) : Path.GetFileName pour ne pas imprimer de chemin// machine (D:\Dev\..., C:\Users\...) dans la sortie du notebook.Console.WriteLine($"AppHost (wt1) : {Path.GetFileName(AspireShell.DirWt1)}");Console.WriteLine($"AppHost (wt2) : {Path.GetFileName(AspireShell.DirWt2)}");Console.WriteLine($"CLI aspire : {Path.GetFileName(AspireShell.AspireCmd)}");Console.WriteLine($".env GenAI : {Path.GetFileName(AspireShell.GenAiEnvFile)} (present : {File.Exists(AspireShell.GenAiEnvFile)})");Console.WriteLine($"apphost.cs wt1 present : {File.Exists(Path.Combine(AspireShell.DirWt1, "apphost.cs"))}");Console.WriteLine($"apphost.cs wt2 present : {File.Exists(Path.Combine(AspireShell.DirWt2, "apphost.cs"))}");Console.WriteLine($"version CLI aspire : {AspireShell.Clean(AspireShell.Aspire(Directory.GetCurrentDirectory(), "--version")).Trim()}");Console.WriteLine();Console.WriteLine("--- apphost.cs (wt1) ---");Console.WriteLine(File.ReadAllText(Path.Combine(AspireShell.DirWt1,"apphost.cs")));
AppHost (wt1) : GenAiStackReel.AppHost
AppHost (wt2) : GenAiStackReel.AppHost-wt2
CLI aspire : aspire.cmd
.env GenAI : .env (present : True)
apphost.cs wt1 present : True
apphost.cs wt2 present : True
version CLI aspire : 13.4.6+87fe259e4fc244c599019a7b1304c85a1488f248
--- apphost.cs (wt1) ---
#:sdk Aspire.AppHost.Sdk@13.4.6
using Aspire.Hosting;
// AppHost déclarant LA pile GenAI réelle du cluster — Epic #10473 *The
// Unexpected AI Stack: C#/.NET*, grain #10857 (suite du grain #10838).
//
// Deux typologies de ressources, parce que la pile réelle est exactement ça :
//
// 1. Endpoints EXTERNES — les services lourds, liés à une GPU, déjà démarrés
// en production et volontairement SINGLETONS (on ne duplique pas 24 Go de
// VRAM par worktree). Aspire les déclare comme chaînes de connexion : ils
// deviennent des ressources de premier plan dans le dashboard, sans être
// recréés.
// - comfyui : génération d'images Qwen (po-2023, 127.0.0.1:8188)
// - vllm : LLM qwen3.6-35b-a3b auto-hébergé (ai-01, LAN :5002),
// endpoint OpenAI-compatible partagé par toute la flotte
//
// 2. Conteneur ORCHESTRABLE — le service léger de la même pile, lazy-load,
// que `aspire run --isolated` peut dupliquer par worktree sans collision
// de ports ni de conteneurs :
// - whisper-api (faster-whisper large-v3-turbo, image locale buildée
// depuis docker-configurations/services/whisper-api)
//
// Aucun secret en littéral : le service orchestré tourne avec AUTH_ENABLED=false
// (contrat auth_middleware.py), et les endpoints externes exigent leurs clés au
// moment de l'appel (notebook) — jamais dans ce fichier.
var builder = DistributedApplication.CreateBuilder(args);
var repoRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "../../../../"));
// ── 1. Endpoints externes de la pile (référencés, pas recréés) ──────────
// La valeur passe par la configuration (ConnectionStrings:{nom}) : la
// surcharge à 2 arguments de AddConnectionString interprète le second comme
// un NOM de variable d'environnement, pas comme la valeur.
builder.Configuration["ConnectionStrings:comfyui"] = "http://127.0.0.1:8188";
builder.Configuration["ConnectionStrings:vllm"] = "http://192.168.0.47:5002";
builder.AddConnectionString("comfyui");
builder.AddConnectionString("vllm");
// ── 2. Conteneur orchestrable (le représentant dupliquable de la pile) ──
var whisper = builder.AddContainer("whisper-api", "whisper-api-whisper-api")
.WithContainerRuntimeArgs("--gpus", "all")
.WithEnvironment("WHISPER_MODEL", "large-v3-turbo")
.WithEnvironment("WHISPER_DEVICE", "cuda")
.WithEnvironment("WHISPER_COMPUTE_TYPE", "int8_float16")
.WithEnvironment("PRELOAD_MODEL", "false")
.WithEnvironment("IDLE_TIMEOUT", "1200")
.WithEnvironment("CUDA_VISIBLE_DEVICES", "1") // RTX 3090 externe (regle freeze GPU du depot)
.WithEnvironment("AUTH_ENABLED", "false")
.WithBindMount(Path.Combine(repoRoot, "docker-configurations/services/shared"), "/app/shared", isReadOnly: true)
.WithBindMount(Path.Combine(repoRoot, "docker-configurations/services/whisper-api/models"), "/app/models")
.WithBindMount(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache/huggingface"), "/home/appuser/.cache/huggingface")
.WithHttpEndpoint(targetPort: 8190, name: "http"); // port hôte éphémère (--isolated)
builder.Build().Run();
Lecture du source
Chaque service de la pile correspond à une ligne du modèle :
builder.Configuration["ConnectionStrings:comfyui"] = "http://127.0.0.1:8188" puis AddConnectionString("comfyui") — le service existant de po-2023, référencé ;
idem pour vllm (http://192.168.0.47:5002, le LLM partagé de ai-01) ;
AddContainer("whisper-api", "whisper-api-whisper-api") — l’image locale réelle buildée depuis le Dockerfile du dépôt, avec montages et endpoint 8190 vers un port hôte éphémère.
Le helper d’amorage ci-dessus fournit aussi RunCapture (pour une commande qui fluxe sa sortie, comme aspire logs) et Clean (le CLI émet des séquences ANSI même redirigé — on les retire pour des sorties lisibles).
Trois détails du AddContainer méritent l’arrêt : WithContainerRuntimeArgs("--gpus", "all") expose les GPU puisWithEnvironment("CUDA_VISIBLE_DEVICES", "1") épingle le conteneur sur la RTX 3090 externe — la règle de gel GPU du dépôt, visible dans la source ; les trois WithBindMount (shared/, models/, cache Hugging Face) rendent l’image sans état — dupliquer le conteneur ne duplique ni le modèle ni la config ; enfin targetPort: 8190 fixe le port interne, le port hôte restant éphémère (--isolated) — l’inverse du compose manuel, qui fige les deux.
2. Instance A : lancer l’AppHost (run --detach --isolated)
--detach démarre en arrière-plan ; --isolated randomise les ports et isole les user secrets. La sortie de lancement contient l’URL du dashboard Aspire.
// Lancer l'instance A (wt1) en arriere-plan : ports randomises par --isolated.var launchA = AspireShell.Clean(AspireShell.Aspire(AspireShell.DirWt1,"run --detach --isolated --non-interactive"));Console.WriteLine(launchA);
Démarrage de l’application Aspire en arrière-plan...
AppHost: apphost.cs
Tableau de bord: https://localhost:60923/login?t=1ccfd9e93d4a511b174428c8bf1ce9d4
Journaux: <chemin-local>
PID: 31584
✅ AppHost a démarré correctement.
Lecture du lancement
Le CLI rapporte le fichier AppHost, l’URL du dashboard (le jeton ?t=... de la page de login est jetable et local), le chemin du journal, et le PID. AppHost a démarré correctement. — le conteneur whisper-api démarre, les deux endpoints externes sont déjà « Running » puisqu’ils ne sont pas gérés par ce AppHost (ils existent par eux-mêmes).
Concrètement dans cette exécution : dashboard sur https://localhost:61897, PID 38416 — c’est le processus AppHost ; le aspire ps de la section suivante affichera en regard un second identifiant (CLI PID, le wrapper qui a lancé le détachement), et le journal detach-child_*.log garde la trace complète du démarrage si un diagnostic s’avère nécessaire. Le jeton ?t=... n’autorise que cette session de dashboard : le copier dans un autre navigateur ne sert à rien.
// Poll : attendre que la ressource whisper-api soit Running + Healthy, puis// decrire le conteneur orchestre ET l'endpoint externe vllm declare dans le meme AppHost.var describeA = await AspireShell.WaitHealthyAsync(AspireShell.DirWt1,"whisper-api");Console.WriteLine("--- aspire describe whisper-api (instance A) ---");Console.WriteLine(describeA);Console.WriteLine("--- aspire describe vllm (endpoint externe declare) ---");Console.WriteLine(AspireShell.Describe(AspireShell.DirWt1,"vllm"));Console.WriteLine("--- aspire ps (apres instance A) ---");var psA = AspireShell.Clean(AspireShell.Aspire(AspireShell.DirWt1,"ps"));Console.WriteLine(psA);
describe whisper-api : Running + Healthy, URL http://localhost:PORT — le port hôte éphémère choisi par --isolated, indépendant du 8190 fixé du compose manuel.
describe vllm : le type affiché (Parameter/ConnectionString) est la signature d’une référence, pas d’un conteneur : l’état Running/Healthy reflète la déclaration, le service lui-même vit sur ai-01.
aspire ps : une seule entrée — l’instance A.
Ici, l’instance A a reçu le port hôte 61899 pour son whisper-api — éphémère, donc différent du 8190 interne et différent de toute future instance. Quant à vllm, l’absence d’URL locale (-) n’est pas un défaut : la ressource est une référence à un service qui vit sur ai-01 ; son état Healthy atteste la déclaration, pas un health-check réseau.
2bis. Instance B : le « deuxième worktree » en parallèle
Le scénario de la dette de worktrees : un deuxième checkout du dépôt (ici, la copie GenAiStackReel.AppHost-wt2/) lance le même AppHost. Sans --isolated : collision. Avec : chaque instance reçoit ses propres ports, ses propres user secrets, et des noms de conteneurs suffixés — les deux whisper-api coexistent.
Le scénario n’est pas artificiel : c’est exactement la situation de deux agents qui checkout le dépôt dans deux worktrees pour travailler en parallèle — la « dette de worktrees » du cluster. Chaque instance reçoit son dashboard, ses user secrets et un suffixe de conteneur aléatoire (l’instance A s’appelle whisper-api-jnprwjmw dans les journaux de la section 3) : deux whisper-api coexistent sans se voir.
// Instance B : le "deuxieme worktree" lance le MEME AppHost en parallele.var launchB = AspireShell.Clean(AspireShell.Aspire(AspireShell.DirWt2,"run --detach --isolated --non-interactive"));Console.WriteLine(launchB);var describeB = await AspireShell.WaitHealthyAsync(AspireShell.DirWt2,"whisper-api");Console.WriteLine("--- aspire describe whisper-api (instance B) ---");Console.WriteLine(describeB);Console.WriteLine("--- aspire ps (les DEUX instances) ---");Console.WriteLine(AspireShell.Clean(AspireShell.Aspire(AspireShell.DirWt2,"ps")));
Dans le aspire ps final, deux lignes : deux chemins d’AppHost, deux PIDs, deux dashboards sur des ports différents, et dans chaque dashboard un whisper-api sur son propre port éphémère. Aucune collision docker, aucune collision de ports. C’est la réponse d’Aspire à la question « comment deux agents travaillent-ils en parallèle sur la même pile ? » (exercice 1 : l’exploiter programmatiquement).
La preuve est dans le tableau : instance A — dashboard 61897, PID 38416, CLI PID 41624 ; instance B — dashboard 61980, PID 5824, CLI PID 42320. Quatre familles d’identifiants, aucune valeur partagée. Les whisper-api suivent la même règle (61899 pour A, 61979 pour B, visibles dans les describe respectifs).
3. Les journaux d’un service réel : aspire logs
aspire logs whisper-api fluxe les journaux unifiés de la ressource. La cellule capture une fenêtre bornée (10 s) puis termine la commande.
// Journaux REELS du conteneur orchestre par l'instance A (fenetre bornee de 10 s :// la commande fluxe sa sortie, on capture puis on termine).Console.WriteLine(AspireShell.Clean(AspireShell.RunCapture(AspireShell.DirWt1, AspireShell.AspireCmd,"logs whisper-api")));
Scanning for running AppHosts...
Récupération des journaux...
[whisper-api] No custom certificate authorities to configure for 'whisper-api'. Default certificate authority trust behavior will be used.
[whisper-api] b550d738dba5584217d8edddf0300e5a74479db9005905debd2baebc6365e150
[whisper-api] [sys] Added new ContainerNetworkConnection: ContainerName = whisper-api-qywfheay
[whisper-api]
[whisper-api] b550d738dba5584217d8edddf0300e5a74479db9005905debd2baebc6365e150
[whisper-api]
[whisper-api] ==========
[whisper-api] == CUDA ==
[whisper-api] ==========
[whisper-api]
[whisper-api] CUDA Version 12.1.0
[whisper-api]
[whisper-api] Container image Copyright (c) 2016-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
[whisper-api]
[whisper-api] This container image and its contents are governed by the NVIDIA Deep Learning Container License.
[whisper-api] By pulling and using the container, you accept the terms and conditions of this license:
[whisper-api] https://developer.nvidia.com/ngc/nvidia-deep-learning-container-license
[whisper-api]
[whisper-api] A copy of this license is made available in this container at /NGC-DL-CONTAINER-LICENSE for your convenience.
[whisper-api]
[whisper-api] *************************
[whisper-api] ** DEPRECATION NOTICE! **
[whisper-api] *************************
[whisper-api] THIS IMAGE IS DEPRECATED and is scheduled for DELETION.
[whisper-api] https://gitlab.com/nvidia/container-images/cuda/blob/master/doc/support-policy.md
[whisper-api]
[whisper-api] Starting Whisper API...
[whisper-api] Model: large-v3-turbo
[whisper-api] Device: cuda
[whisper-api] Compute Type: int8_float16
[whisper-api] WARNING:auth-middleware:AUTH_ENABLED=false - Authentication explicitly DISABLED
[whisper-api] WARNING:auth-middleware:Auth middleware NOT installed - API is open
[whisper-api] INFO: Started server process [1]
[whisper-api] INFO: Waiting for application startup.
[whisper-api] INFO:whisper-api:Started idle checker (timeout: 1200s)
[whisper-api] INFO:lazy-model:Starting idle checker task (interval: 60s)
[whisper-api] INFO: Application startup complete.
[whisper-api] INFO: Uvicorn running on http://0.0.0.0:8190 (Press CTRL+C to quit)
[whisper-api] INFO: 127.0.0.1:43684 - "GET /health HTTP/1.1" 200 OK
Lecture des journaux
Les journaux sont ceux du vrai conteneur faster-whisper : démarrage du serveur, configuration du modèle (large-v3-turbo, int8_float16), attente lazy du premier appel. Rien n’est simulé.
Trois lectures fines s’y arrêtent : la bannière CUDA 12.1.0 est marquée DEPRECATED par NVIDIA — un service réel vit dans une image vieillissante, sa maintenance est un fait d’exploitation, pas un échec ; Started idle checker (timeout: 1200s) matérialise le lazy-load — le modèle ne se chargera qu’au premier appel et l’instance s’éteindra après 20 minutes d’inactivité, c’est ce qui rend la duplication par worktree peu coûteuse ; enfin Uvicorn running on http://0.0.0.0:8190 rappelle que 8190 est le port interne du conteneur, et le GET /health 200 final prouve que la sonde passe déjà — avant même que le modèle soit chargé.
4. Traverser la pile : deux appels réels
Le modèle déclaratif n’a de valeur que si le code consomme la pile. Deux appels, sur les deux typologies :
vLLM (ai-01:5002) — GET /v1/models puis une complétion réelle sur qwen3.6-35b-a3b. La clé API est lue au runtime depuis ../.env (rendu depuis master.env par le pipeline render_envs.py) : jamais un littéral, jamais affichée.
ComfyUI (po-2023:8188) — protégé par ComfyUI-Login ; le token API est le hachage bcrypt imprimé au démarrage du conteneur (use token=$2b... dans docker logs). Extraction au runtime, valeur masquée.
// Appel traversant n.1 : le LLM de la pile (vLLM auto-heberge, ai-01:5002,// endpoint OpenAI-compatible). La cle est lue au RUNTIME depuis ../.env (rendu// depuis master.env par render_envs.py) : jamais un litteral, jamais affichee.var vllmKey = File.ReadLines(AspireShell.GenAiEnvFile).Select(l => l.Trim()).FirstOrDefault(l => l.StartsWith("VLLM_API_KEY="))?["VLLM_API_KEY=".Length..]?.Trim().Trim('"');Console.WriteLine($"cle vLLM : {(vllmKey is null ? "ABSENTE(verifier ../.env)" : "presente(" + vllmKey.Length + " caracteres, valeur masquee)")}");var http =newHttpClient(){ Timeout = TimeSpan.FromSeconds(60)};http.DefaultRequestHeaders.Authorization=new("Bearer", vllmKey);// 1) Catalogue de modeles : preuve que l'endpoint sert.var modelsJson = await http.GetStringAsync("http://192.168.0.47:5002/v1/models");Console.WriteLine("GET /v1/models -> 200");var md = System.Text.Json.JsonDocument.Parse(modelsJson);foreach(var m2 in md.RootElement.GetProperty("data").EnumerateArray()) Console.WriteLine($" modele servi : {m2.GetProperty("id").GetString()} (ctx {m2.GetProperty("max_model_len").GetInt64():N0})");// 2) Completion reelle : une question, une reponse du moteur de la pile.var body = @"{""model"":""qwen3.6-35b-a3b"",""max_tokens"":300,""messages"":[{""role"":""user"",""content"":""Reponds en une seule phrase courte : quel genre de service es-tu et sur quelle infrastructure tournes-tu ?""}]}";var resp = await http.PostAsync("http://192.168.0.47:5002/v1/chat/completions",newStringContent(body, Encoding.UTF8,"application/json"));var txt = await resp.Content.ReadAsStringAsync();Console.WriteLine($"POST /v1/chat/completions -> {(int)resp.StatusCode}");var doc = System.Text.Json.JsonDocument.Parse(txt);var msg = doc.RootElement.GetProperty("choices")[0].GetProperty("message");// Modele de RAISONNEMENT : content peut etre null si le budget part en raisonnement.var content = msg.TryGetProperty("content",outvar c)&& c.ValueKind== System.Text.Json.JsonValueKind.Null?null: msg.GetProperty("content").GetString();Console.WriteLine(content isnull?"[reasoning-only] budget epuise en raisonnement (piege connu des modeles de raisonnement)":"reponse : "+ content);
cle vLLM : presente (32 caracteres, valeur masquee)
GET /v1/models -> 200
modele servi : qwen3.6-35b-a3b (ctx 262 144)
POST /v1/chat/completions -> 200
[reasoning-only] budget epuise en raisonnement (piege connu des modeles de raisonnement)
Lecture de la réponse vLLM
Le catalogue confirme le modèle servi (qwen3.6-35b-a3b, contexte 262 144 tokens) — c’est bien le moteur auto-hébergé de la flotte, pas un service marchand. Deux points d’attention visibles dans le code :
Le secret reste au runtime : la cellule n’affiche que la longueur de la clé, jamais sa valeur.
Piège des modèles de raisonnement : sur /chat/completions, le champ message.content peut être null si le budget de tokens part en raisonnement — le code teste explicitement ce cas au lieu de slicer aveuglément (et affiche un fallback explicite si cela arrive).
C’est arrivé dans cette exécution : la complétion a retourné [reasoning-only] budget epuise en raisonnement — HTTP 200, donc « réussi » au sens transport, mais aucun contenu exploitable. Un appel LLM ne se valide pas sur son seul code de statut : tester le message.content avant de construire dessus est la moindre des hygiènes.
// Appel traversant n.2 : ComfyUI (po-2023:8188, generation Qwen-Image), protege// par ComfyUI-Login. Le token API = le hachage bcrypt imprime au DEMARRAGE du// conteneur ("For direct API calls, use token=$2b...") : extraction au RUNTIME// depuis docker logs, jamais affichee ni commise (auto-reparante apres rotation).// Logs COMPLETS : le token n'est imprime qu'au DEMARRAGE du conteneur, parfois// des milliers de lignes plus haut qu'une fenetre --tail.var logTxt = AspireShell.Run(AspireShell.RepoRoot,"docker","logs comfyui-qwen",60_000);var match = Regex.Matches(logTxt ??"", @"use token=(\S+)").Cast<Match>().LastOrDefault();var comfyToken = match?.Groups[1].Value.Trim();Console.WriteLine($"token ComfyUI extrait des logs : {(comfyToken is null ? "ECHEC(conteneur down ?)" : "OK(" + comfyToken.Length + " caracteres, valeur masquee)")}");var httpC =newHttpClient(){ Timeout = TimeSpan.FromSeconds(30)};var req =newHttpRequestMessage(HttpMethod.Get,"http://127.0.0.1:8188/system_stats");req.Headers.Authorization=new("Bearer", comfyToken);var respC = await httpC.SendAsync(req);var statsTxt = await respC.Content.ReadAsStringAsync();Console.WriteLine($"GET http://127.0.0.1:8188/system_stats -> {(int)respC.StatusCode}");var sd = System.Text.Json.JsonDocument.Parse(statsTxt);var sys = sd.RootElement.GetProperty("system");Console.WriteLine($" comfyui_version = {sys.GetProperty("comfyui_version").GetString()}");Console.WriteLine($" os = {sys.GetProperty("os").GetString()} | ram_total = {sys.GetProperty("ram_total").GetInt64() / 1_000_000_000.0:F1} Go");if(sd.RootElement.TryGetProperty("devices",outvar devs))foreach(var d in devs.EnumerateArray()) Console.WriteLine($" GPU : {d.GetProperty("name").GetString()} | vram_total = {d.GetProperty("vram_total").GetInt64() / 1_000_000_000.0:F1} Go");
token ComfyUI extrait des logs : OK (60 caracteres, valeur masquee)
GET http://127.0.0.1:8188/system_stats -> 200
comfyui_version = 0.25.0
os = linux | ram_total = 25,2 Go
GPU : cuda:0 NVIDIA GeForce RTX 3090 : cudaMallocAsync | vram_total = 25,8 Go
Lecture de la réponse ComfyUI
200 avec le vrai system_stats : version de ComfyUI, RAM totale du conteneur, GPU visible. Deux enseignements :
Le token bcrypt : ComfyUI-Login expose le hachage du mot de passe comme token d’API — le plaintext ne vit nulle part. L’extraction depuis docker logs est auto-réparante : après toute rotation (redémarrage du conteneur), la ligne réapparaît avec la valeur courante, sans fichier de secret à resynchroniser.
401 vs down : si cette cellule renvoyait 401, le service serait vivant (l’auth fonctionne) — à distinguer d’une connexion refusée = service mort. C’est le réflexe de l’exercice 2.
5. Ce que --isolated change pour un agent en worktree
Synthèse du grain, du point de vue de l’automatisation :
Aspect
À la main
Avec aspire run --isolated
Ports des services orchestrés
fixés dans le compose → collisions entre worktrees
éphémères, découverts via aspire describe
Noms de conteneurs
fixes → le deuxième up écrase le premier
suffixés par instance
User secrets
partagés entre worktrees
isolés par instance
Vue d’ensemble
lire N compose + un README
un dashboard, un modèle de ressources
Services lourds GPU
—
restent singletons externes (référencés, pas dupliqués) — la GPU ne se duplique pas
La dernière ligne est la décision d’architecture de ce grain : l’isolation s’applique à ce qui est dupliquable à coût nul (services légers, lazy) ; les services lourds sont référencés parce que la ressource rare est physique.
Exercices
Trois exercices pour prolonger le grain. Les cellules sont des stubs à compléter : le notebook s’exécute de bout en bout même non résolu (convention du dépôt — jamais d’erreur volontaire).
Variables disponibles : psBoth — la sortie du aspire ps à deux lignes, capturée au lancement de l’instance B — contient dashboards, PIDs et chemins des deux instances ; les URL des endpoints protégés sont celles du modèle (8188 pour ComfyUI) ; pour l’exercice 3, s’inspirer des deux AddConnectionString du source. Les stubs affichent « Exercice a completer » — c’est la convention, pas une erreur.
Exercice 1 — Prouver la disjointure des ports
La sortie de aspire ps ci-dessus (après l’instance B) contient les deux tableaux de bord. Écrire VerifierDisjonction qui extrait les deux ports et retourne la preuve qu’ils diffèrent.
// Exercice 1 - Prouver la disjointure des ports des deux instances.// Contexte : psBoth (cellule precedente) contient la sortie de `aspire ps` avec// les DEUX tableaux de bord (https://localhost:PORT/login?t=...).// Objectif : extraire les deux ports et verifier qu'ils different.// Etape 1 : une regex par ligne https://localhost:(\d+)/login// Etape 2 : collecter les ports distincts (il doit y en avoir exactement 2)// Etape 3 : retourner "A=<p1> B=<p2> disjoint=true" ou "...=false"publicstaticstring?VerifierDisjonction(string psOutput){// TODO etudiantreturnnull;// TODO etudiant}Console.WriteLine("Exercice a completer");
Exercice a completer
Exercice 2 — Sonde « vivant vs down »
Le réflexe diagnostique de la pile : un endpoint protégé qui répond 401 est vivant ; une connexion refusée est down. Écrire SonderEndpointAsync puis la tester sur l’endpoint vLLM.
// Exercice 2 - Sonde "vivant vs down" d'un endpoint protege.// Contexte : un endpoint qui repond 401 est VIVANT (il exige une auth), un// endpoint joint pas (connexion refusee) est DOWN. C'est le reflexe diagnostic// de la pile : on ne confond pas "acces refuse" et "service mort".// Objectif : classifier l'URL passee en argument.// Etape 1 : HttpClient.GetAsync dans un try/catch (HttpRequestException = down)// Etape 2 : 200/401/403 => "VIVANT", exception => "DOWN", autre => "STATUT <code>"// Indice : await a l'interieur d'une methode async Task<string>publicstatic async Task<string>SonderEndpointAsync(string url){// TODO etudiant await Task.CompletedTask;returnnull;// TODO etudiant}Console.WriteLine("Exercice a completer");
Exercice a completer
Exercice 3 — Étendre le modèle : déclarer forge
La pile comporte aussi forge-turbo (WebUI Forge, port hôte 7860). Écrire les deux lignes C# à ajouter dans apphost.cs pour le référencer comme quatrième ressource, puis (hors notebook, dans un terminal) vérifier avec aspire describe forge.
// Exercice 3 - Etendre le modele : declarer un 3e endpoint externe.// Contexte : la pile comporte aussi forge-turbo (interface WebUI Forge, ce// depot, docker-configurations/services/forge-turbo, port hote 7860). L'AppHost// doit le referencer comme comfyui et vllm, sans le recréer.// Objectif : retourner les DEUX lignes C# a ajouter dans apphost.cs.// Etape 1 : s'inspirer du couple Configuration["ConnectionStrings:..."] / AddConnectionString// Etape 2 : nom de ressource "forge", URL http://127.0.0.1:7860// Verification attendue : aspire describe forge -> Running/Healthypublicstaticstring?LignesDeclarationForge(){// TODO etudiantreturnnull;// TODO etudiant}Console.WriteLine("Exercice a completer");
Exercice a completer
// Hygiene d'agent : arreter les deux instances isolees (supprime leurs// conteneurs orchestres). Les endpoints externes (comfyui, vllm) ne sont pas// touches : ils ne nous appartiennent pas, ils appartiennent a la pile.Console.WriteLine(AspireShell.Clean(AspireShell.Aspire(AspireShell.DirWt1,"stop")));Console.WriteLine(AspireShell.Clean(AspireShell.Aspire(AspireShell.DirWt2,"stop")));
Scanning for running AppHosts...
📦 Found running AppHost: apphost.cs
🛑 Sending stop signal to apphost.cs...
Stopping apphost.cs...
✅ apphost.cs stopped successfully.
A new version of Aspire is available: 13.5.1
Pour mettre à jour, exécutez : dotnet tool update -g Aspire.Cli
Scanning for running AppHosts...
📦 Found running AppHost: apphost.cs
🛑 Sending stop signal to apphost.cs...
Stopping apphost.cs...
✅ apphost.cs stopped successfully.
A new version of Aspire is available: 13.5.1
Pour mettre à jour, exécutez : dotnet tool update -g Aspire.Cli
Lecture de l’arrêt
Deux arrêts, deux stopped successfully — un par instance. aspire stop supprime les conteneurs orchestrés : les deux whisper-api et leurs ports éphémères disparaissent, la machine redevient propre. Les endpoints externes (ComfyUI, vLLM) ne sont pas interrogés par l’arrêt — ils continuent de servir les autres machines de la flotte. C’est l’asymétrie du modèle devenue geste d’exploitation : on éteint ce qu’on a créé, on ne touche pas ce qu’on a référencé.
Conclusion
Le grain #10857 ajoute la pièce qui manquait au #10838 : l’AppHost ne démontre plus seulement qu’on peut orchestrer un service en C#, il modèle la pile réelle telle qu’elle tourne — multi-machines, authentifiée, GPU-bound — et le notebook la traverse par des appels authentifiés réels (vLLM qwen3.6-35b-a3b, ComfyUI system_stats).
À retenir :
Deux typologies de ressources : les singletons GPU référencés (chaînes de connexion) vs les services légers orchestrés (conteneurs) — l’isolation --isolated s’applique aux seconds.
Les secrets restent au runtime : .env pour vLLM, docker logs pour le token bcrypt ComfyUI — aucun littéral, aucune valeur affichée, et l’extraction est auto-réparante après rotation.
aspire ps/describe/logs sont l’interface d’inspection d’un agent : le modèle de ressources est une documentation exécutable.