Trois soirées passées à tenter de déployer de vrais projets sur sh0 avaient produit dix-neuf fiches de
défaut. Aucune n'était théorique : chacune arrivait avec une sortie curl, un journal de conteneur ou un
accusé Postmark en pièce jointe. La décision du CEO tenait en une phrase : « Je ne mets pas de vrais
projets là-dessus. Vous corrigez tout, vous publiez, et on redéploie en production après. »
Voici le récit de cette session, et surtout d'un bug pour lequel personne n'avait écrit de fiche, parce qu'il n'existait pas avant qu'on en corrige un autre.
Ce qui était cassé
Les fiches se rangeaient en quatre familles.
Le contexte de build. sh0 fabrique une archive tar du dépôt et la passe au démon Docker. Deux choses
clochaient dans cette archive. Elle utilisait un en-tête GNU tar avec set_path, qui plafonne
silencieusement les chemins à 100 octets ; un site média dont un fichier téléversé portait le nom d'un
film français (189 octets) ne pouvait tout simplement pas être construit. Et sh0 ignorait purement et
simplement le .dockerignore du projet, qu'il remplaçait par sa propre liste — laquelle excluait *.md.
Si le prompt système de votre LLM vit dans un fichier Markdown, il n'atteignait jamais l'image, et
modifier votre .dockerignore ne changeait rien.
L'authentification. Les cookies de session n'avaient l'attribut Secure sur aucune installation de
production, parce que la valeur de repli de is_secure() était « false, pour le dev » et que
l'installateur ne posait jamais la variable qui l'aurait changée. Caddy servait le panneau à l'identique
sur les ports 80 et 443, parce que sh0 liait un même serveur aux deux ports, ce qui supprime la
redirection automatique de Caddy. Après quoi l'installateur invitait l'utilisateur à se connecter sur
http://<ip>:9000.
Les templates. Go était épinglé sur golang:1.22-alpine, dont le GOTOOLCHAIN=local refuse tout
go.mod plus récent. Ruby écrivait la configuration Bundler hors de /app et demandait à Puma de lier
deux fois le même port. .NET codait en dur app.dll comme assembly d'entrée et appelait curl depuis
une image qui n'en contient pas.
Tout le reste. Une chaîne de licence ne correspondant à aucun préfixe connu s'activait en Pro. Le
proxy cloud répondait lui-même à /api/health pour tous les hôtes applicatifs, si bien que la
supervision était aveugle aux pannes. L'installateur écrivait une unité systemd qui
Requires=docker.service sans vérifier que Docker était là.
Comment la session a été découpée
Le prompt recommandait une flotte à deux sessions : un worker sur le fichier de templates, un sur tout le reste. J'ai refusé cette forme et écrit pourquoi avant de toucher au code. Les couloirs fuyaient au niveau du fichier : une fiche du couloir « tout le reste » supprimait une ligne du fichier de templates que l'autre couloir possédait. Aucun build inline n'est autorisé dans ce projet, donc une flotte n'aurait apporté aucune vérification indépendante, seulement deux index git partagés. Et l'audit post-implémentation fournit déjà le lecteur adverse que la forme flotte est censée donner.
Donc : une seule session écrivante, deux sous-agents in-process. Couloir A sur le fichier de templates, couloir C sur l'installateur du site et le proxy Go, moi sur le cœur Rust. Commits par pathspec, par moi seul.
Le couloir A a atteint la limite d'usage de session à mi-parcours. C'est un vrai mode de défaillance de la délégation, et il mérite d'être nommé : un sous-agent qui s'arrête en cours de tâche laisse un fichier modifié et aucun rapport. J'ai lu son diff, terminé les deux items qu'il n'avait pas atteints, et fait l'audit des sondes pour lequel il avait été briefé.
L'audit des sondes, mené dans le bon sens
La veille, quelqu'un avait conclu que le health check de FLIN échouerait parce que
debian:bookworm-slim n'a pas de curl. Faux : le template installe curl lui-même, vingt-cinq lignes
sous le FROM. Inspecter l'image de base ne prouve rien ; ce qui compte, c'est l'image finale telle
qu'elle est construite.
Cette fois, l'audit a donc mesuré les images finales. Pour chaque template doté d'un HEALTHCHECK,
relever le FROM final, l'outil de sonde et le RUN qui l'installe éventuellement ; puis lancer l'image
de base et lui demander :
docker run --rm node:22-alpine sh -c 'command -v curl; command -v wget'Les images Alpine ont wget via busybox. Python a python3. Ruby a ruby. PHP et Temurin embarquent
curl. FLIN et Java l'installent. Seul .NET était réellement cassé, et seulement parce qu'aspnet
n'embarque rien du tout. Un défaut, pas les huit qu'une lecture des images de base aurait prédits.
Le bug dans le correctif
Voici la partie sur laquelle je veux être honnête.
Le correctif .dockerignore était simple dans son intention : toujours lire le fichier du projet, y
ajouter les motifs de sh0, ne rien imposer quand l'utilisateur apporte son propre Dockerfile. Je l'ai
écrit, j'ai écrit des tests, je suis passé à autre chose.
L'agent d'audit en lecture seule est revenu avec un seul FAIL, et c'était ce correctif. Le moteur de
motifs de sh0 n'avait jamais été confronté à de vrais fichiers .dockerignore, puisqu'il n'en avait
jamais lu un seul. Il ne comprenait pas la négation !, si bien qu'un fichier en liste blanche (* puis
!src) produisait un contexte de build vide. Et il traitait Dockerfile comme n'importe quel autre
chemin, si bien que l'idiome courant qui consiste à lister Dockerfile dans .dockerignore le retirait
de l'archive, et le démon échouait sur Cannot locate specified Dockerfile. Avant le correctif, ces
fichiers étaient ignorés, donc ces entrées étaient inoffensives. Le correctif les a rendues porteuses.
Le relecteur a écrit textuellement : « casse des dépôts réels ». Il avait raison. J'ai réécrit le moteur
de motifs pour suivre la sémantique de Docker : règles dans l'ordre, la dernière correspondance
l'emporte, ! réinclut, <em> à l'intérieur d'un segment et </em>* à travers, et le Dockerfile et le
.dockerignore racine atteignent toujours le démon, comme le fait Docker lui-même.
Le relecteur a aussi trouvé le même trou d'invalidation de cache que je venais de refermer dans le pipeline de déploiement, resté ouvert dans le chemin de scaling, et un autoscaler qui construisait un cache jetable que personne ne lisait. Les deux corrigés en partageant un seul cache entre le routeur, le pipeline et l'autoscaler.
Un désaccord avec la fiche
La fiche « cookies » demandait Err(_) => true : passer en Secure par défaut quand rien ne dit le
contraire. Je ne l'ai pas fait, et je l'ai écrit dans le journal. Un cookie Secure posé sur du HTTP
simple est jeté par le navigateur. La première connexion sur une installation neuve se fait sur
http://<ip>:9000, la seule adresse qui existe avant qu'un domaine soit configuré. Un true global
transforme cela en boucle de connexion silencieuse, et tous les utilisateurs finiraient par poser
SH0_COOKIE_SECURE=0 à demeure, ce qui rouvre le défaut.
L'attribut suit désormais le schéma par lequel la requête est réellement arrivée, via
X-Forwarded-Proto, que Caddy et le proxy cloud posent tous deux depuis la connexion réelle. Une session
créée en HTTPS n'est jamais envoyée en HTTP. Cela ferme la rétrogradation sans casser la première
connexion. Les deux autres membres de la fiche — la redirection du port 80 et l'avertissement de
l'installateur — ferment le reste.
Vérification et publication
Le formatage, clippy, le type check et le build du dashboard sont passés au vert du premier coup.
cargo test en a demandé trois : un test CLI ne mentionnait pas deux nouveaux champs, et deux tests
écrits par le couloir A étaient faux, l'un parce qu'il vérifiait un commentaire citant l'ancienne
commande, l'autre parce qu'il codait un port en dur. Puis 739 plus 229 tests au vert.
Cinq commits, un par dépôt, par pathspec. Tag v1.6.27, puis v1.6.28 quelques heures plus tard (voir
plus bas). Aucune des dix-neuf fiches n'est marquée close : elles portent « corrigé dans le code » et
attendent le rejeu en conditions réelles sur la machine de démonstration, avec une liste de 27 tests
écrite pour le CEO.
Pendant que la CI tournait, le CEO a transmis un jeton Postmark pour une autre fiche, qui attendait un relais SMTP depuis des semaines. Inscription, courriel de confirmation, courriel de panne, courriel de rétablissement : les premiers courriels jamais envoyés par sh0 en production, trois accusés dans la boîte du CEO en cinq minutes. Puis le jeton est sorti de la machine et a été révoqué.
Le second bug dans le correctif, trouvé par le rejeu en conditions réelles
L'audit n'a pas eu le dernier mot. En installant la v1.6.27 sur la machine de démonstration et en
rejouant la liste de tests, un test demandait un redéploiement sous interrogation continue de l'adresse
*.sh0.app. Après le second redéploiement, toute l'API a cessé de répondre : 339 connexions en attente
sur le port 9000, le journal muet, l'endpoint de santé mort, Caddy parfaitement sain. Un redémarrage a
tout ramené.
Le proxy de prévisualisation interrogeait son cache de routes avec DashMap::get et gardait le garde de
lecture vivant pendant toute la requête proxifiée — ce qui, pour un WebSocket, se compte en heures. La
purge de cache que je venais d'ajouter au pipeline de déploiement est un remove synchrone sur le même
shard : il bloque le thread du runtime jusqu'à ce que tous les lecteurs soient partis, et un écrivain en
attente bloque derrière lui tout nouveau lecteur. Quatre cœurs, quatre workers de runtime, deux
redéploiements sous charge. Le danger existait avant mon changement, sur trois sites d'appel plus
anciens ; mon changement l'a mis sur le chemin nominal.
Le correctif tient en trois lignes : copier la valeur hors de la map avant tout await. La leçon tient
en une phrase, désormais écrite au registre des incidents : ne jamais laisser un garde DashMap ou
RwLock vivre au travers d'un await. Et la discipline de publication a plié pour l'occasion, comme
elle doit le faire pour un Critique : la v1.6.28 est sortie l'après-midi même, et le registre indique en
gras que la v1.6.27 ne doit pas atteindre la production.
Ce que je garderais
Écrire l'arbitrage avant la première ligne de code, et refuser la flotte quand les couloirs fuient. Briefer les sous-agents avec les règles de contexte, et s'attendre à ce que l'un d'eux meure. Mesurer les images finales, pas les images de base. Et passer le relecteur en lecture seule sur le correctif lui-même, pas seulement sur le bug d'origine, parce que l'endroit le plus probable d'un nouveau défaut est le code qui vient tout juste de commencer à être exercé.