Un développeur pousse son code. Trente secondes plus tard, il tourne dans un conteneur. Il n'a pas écrit de Dockerfile. Il n'a pas configuré de pipeline de build. Il n'a pas spécifié quel runtime, quel gestionnaire de paquets ou quel port exposer.
C'est la promesse d'un PaaS moderne, et la tenir nécessite de résoudre un problème d'une difficulté trompeuse : regarder un répertoire de fichiers sources et déterminer ce qu'est réellement le projet.
Le moteur de build de sh0 gère cela en Rust pur -- pas de service d'heuristiques, pas de LLM, pas d'appel API externe. Un algorithme de détection par priorité examine le projet, identifie la stack, génère un Dockerfile de production avec des builds multi-étapes, et crée un contexte de build optimisé. Tout cela se fait en millisecondes, et tout a été construit le Jour Zéro.
Les 19 stacks
L'enum Stack définit chaque technologie que sh0 peut détecter et construire :
rustpub enum Stack {
Dockerfile, // Dockerfile fourni par l'utilisateur (priorité maximale)
NextJs,
Nuxt,
SvelteKit,
Astro,
NodeGeneric,
Bun,
Python,
Django,
FastApi,
Go,
Rust,
JavaMaven,
JavaGradle,
Php,
DotNet,
Ruby,
StaticSite,
Unknown, // Repli (priorité minimale)
}Chaque variante porte des métadonnées via des méthodes de trait :
rustimpl Stack {
pub fn label(&self) -> &str {
match self {
Stack::NextJs => "Next.js",
Stack::FastApi => "FastAPI",
Stack::SvelteKit => "SvelteKit",
// ...
}
}
pub fn default_port(&self) -> u16 {
match self {
Stack::NextJs => 3000,
Stack::Django => 8000,
Stack::FastApi => 8000,
Stack::Go => 8080,
Stack::Rust => 8080,
Stack::StaticSite => 80,
// ...
}
}
pub fn needs_dockerfile(&self) -> bool {
matches!(self, Stack::Php | Stack::DotNet | Stack::Ruby | Stack::Unknown)
}
}La méthode needs_dockerfile() est honnête : pour PHP, .NET et Ruby, les configurations de build sont trop variées pour qu'un template générique soit fiable. sh0 dit à l'utilisateur « j'ai détecté votre stack, mais vous devez fournir un Dockerfile » plutôt que d'en générer un mauvais. L'honnêteté bat la magie.
La règle de priorité : le Dockerfile gagne toujours
L'algorithme de détection a une règle inviolable : si un Dockerfile existe à la racine du projet, c'est la stack. Point final.
rustpub fn detect(path: &Path) -> DetectedStack {
// Règle 1 : le Dockerfile fourni par l'utilisateur a toujours la priorité
if path.join("Dockerfile").exists() {
return DetectedStack {
stack: Stack::Dockerfile,
..Default::default()
};
}
// Règle 2 : détecter par fichiers signatures
if path.join("package.json").exists() {
return detect_node(path);
}
if path.join("go.mod").exists() {
return detect_go(path);
}
if path.join("Cargo.toml").exists() {
return detect_rust(path);
}
// ... stacks restantes
DetectedStack {
stack: Stack::Unknown,
..Default::default()
}
}C'est un choix de conception délibéré. Un utilisateur qui a écrit son propre Dockerfile a pris une décision consciente sur la façon dont son application doit être construite. Outrepasser cette décision avec de l'auto-détection serait présomptueux et, dans bien des cas, erroné. Son Dockerfile peut avoir des dépendances système personnalisées, des flags de compilateur spécifiques, ou une image de base propriétaire qu'aucun algorithme de détection ne pourrait deviner.
L'ordre de priorité après le Dockerfile est basé sur la spécificité des fichiers signatures. Nous vérifions package.json avant de chercher des fichiers Python parce qu'un projet peut avoir les deux (par exemple, un backend Python avec une étape de build JavaScript pour les assets). La correspondance la plus spécifique gagne.
Node.js : le défi de la sous-détection
Node.js est de loin la cible de détection la plus complexe. L'écosystème compte quatre gestionnaires de paquets majeurs, des dizaines de frameworks, et plusieurs méta-frameworks, chacun nécessitant des commandes de build et des configurations runtime différentes.
La détection procède par couches :
rustfn detect_node(path: &Path) -> DetectedStack {
let pkg_json = read_package_json(path);
// Couche 1 : détection du gestionnaire de paquets
let package_manager = if path.join("bun.lockb").exists() {
PackageManager::Bun
} else if path.join("pnpm-lock.yaml").exists() {
PackageManager::Pnpm
} else if path.join("yarn.lock").exists() {
PackageManager::Yarn
} else {
PackageManager::Npm
};
// Couche 2 : détection du méta-framework (fichiers de config)
if path.join("next.config.js").exists()
|| path.join("next.config.mjs").exists()
|| path.join("next.config.ts").exists()
{
return DetectedStack {
stack: Stack::NextJs,
package_manager: Some(package_manager),
framework: Some("next".into()),
build_command: Some(build_cmd(&package_manager, "build")),
start_command: Some("node server.js".into()),
..Default::default()
};
}
if path.join("svelte.config.js").exists()
|| path.join("svelte.config.ts").exists()
{
return DetectedStack {
stack: Stack::SvelteKit,
package_manager: Some(package_manager),
framework: Some("sveltekit".into()),
build_command: Some(build_cmd(&package_manager, "build")),
start_command: Some("node build/index.js".into()),
..Default::default()
};
}
// Couche 3 : détection du framework depuis les dépendances
if let Some(ref pkg) = pkg_json {
if has_dependency(pkg, "express") {
return DetectedStack {
stack: Stack::NodeGeneric,
framework: Some("express".into()),
// ...
};
}
// fastify, hono, koa, nestjs...
}
// Repli : Node.js générique
DetectedStack {
stack: Stack::NodeGeneric,
package_manager: Some(package_manager),
..Default::default()
}
}Notez l'ordre. Les méta-frameworks (Next.js, Nuxt, SvelteKit, Astro) sont détectés par les fichiers de configuration, pas par les dépendances de package.json. C'est important parce que les fichiers de configuration sont sans ambiguïté -- si next.config.js existe, c'est un projet Next.js -- tandis que la détection par dépendances est floue (un projet peut importer express comme sous-dépendance sans être une application Express).
Le helper build_cmd génère la bonne invocation pour chaque gestionnaire de paquets :
rustfn build_cmd(pm: &PackageManager, script: &str) -> String {
match pm {
PackageManager::Npm => format!("npm run {}", script),
PackageManager::Yarn => format!("yarn {}", script),
PackageManager::Pnpm => format!("pnpm run {}", script),
PackageManager::Bun => format!("bun run {}", script),
}
}Petite fonction. Mais se tromper signifie que pnpm run build échoue parce que quelqu'un a utilisé la syntaxe yarn build.
Python : l'astuce wsgi.py
La détection Python a sa propre complexité. Un projet Python peut être Django, FastAPI, Flask, ou quelque chose d'entièrement personnalisé. Nous utilisons des heuristiques basées sur les fichiers :
rustfn detect_python(path: &Path) -> DetectedStack {
// Django : chercher wsgi.py ou manage.py
if path.join("manage.py").exists() || find_wsgi(path) {
return DetectedStack {
stack: Stack::Django,
start_command: Some("gunicorn config.wsgi:application --bind 0.0.0.0:8000".into()),
..Default::default()
};
}
// FastAPI : chercher main.py/app.py avec un import fastapi
if detect_fastapi_entry(path) {
return DetectedStack {
stack: Stack::FastApi,
start_command: Some("uvicorn main:app --host 0.0.0.0 --port 8000".into()),
..Default::default()
};
}
// Python générique
DetectedStack {
stack: Stack::Python,
..Default::default()
}
}La détection Django via wsgi.py est robuste parce que chaque projet Django en possède un, et les projets non-Django n'en ont presque jamais. C'est un meilleur signal que de vérifier requirements.txt pour django (qui pourrait être une sous-dépendance ou un vestige inutilisé).
Génération de Dockerfile : 12 templates de production
Une fois la stack détectée, le moteur de build génère un Dockerfile optimisé pour cette stack. Les templates ne sont pas de simples scripts « FROM node, COPY, RUN ». Ils utilisent des builds multi-étapes, des images de base Alpine ou slim, des utilisateurs non-root, et l'optimisation du cache de build.
Voici le template générique Node.js (simplifié) :
rustfn dockerfile_node(detected: &DetectedStack) -> String {
let pm = detected.package_manager.as_ref().unwrap_or(&PackageManager::Npm);
let install_cmd = match pm {
PackageManager::Npm => "npm ci --only=production",
PackageManager::Yarn => "yarn install --frozen-lockfile --production",
PackageManager::Pnpm => "pnpm install --frozen-lockfile --prod",
PackageManager::Bun => "bun install --production",
};
format!(r#"
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN {install_cmd}
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /app .
USER app
EXPOSE {port}
CMD ["node", "dist/index.js"]
"#, install_cmd = install_cmd, port = detected.stack.default_port())
}Détails clés :
- Build multi-étapes : l'étape
builderinstalle les dépendances et compile ; l'étape finale ne copie que le nécessaire. Cela garde l'image de production légère. npm ciplutôt quenpm install:ciutilise le lockfile exactement, garantissant des builds reproductibles.installpourrait mettre à jour les dépendances.--frozen-lockfile: même principe pour yarn et pnpm.- Utilisateur non-root : l'utilisateur
appempêche le processus conteneur de tourner en tant que root, une mesure de sécurité basique. - Base Alpine : environ 50 Mo contre 900 Mo pour l'image Node complète.
Le template Next.js est plus sophistiqué, exploitant le mode standalone output de Next :
rustfn dockerfile_nextjs(detected: &DetectedStack) -> String {
format!(r#"
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER app
EXPOSE 3000
CMD ["node", "server.js"]
"#)
}Trois étapes : installation des dépendances, build, et production. L'image finale ne contient que le serveur standalone, les assets statiques et les fichiers publics -- pas de node_modules, pas de code source, pas d'outils de build.
Pour Go, le template va encore plus loin, en utilisant scratch comme image de base finale :
rustfn dockerfile_go(_detected: &DetectedStack) -> String {
r#"
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server .
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
"#.to_string()
}L'image finale ne contient littéralement rien d'autre que le binaire compilé. Pas de shell, pas d'utilitaires système, pas de surface d'attaque. Le -ldflags="-s -w" supprime les symboles de débogage et les informations DWARF, réduisant encore la taille du binaire.
Le .dockerignore : empêcher le gonflement du contexte de build
Une erreur de déploiement courante : le contexte de build Docker inclut node_modules, .git, ou d'autres répertoires volumineux, rendant les builds lents et les images inutilement grosses. sh0 génère un fichier .dockerignore adapté à la stack détectée :
rustfn generate_dockerignore(stack: &Stack) -> String {
let mut patterns = vec![
".git",
".gitignore",
"*.md",
"LICENSE",
".env*",
".DS_Store",
"Thumbs.db",
];
match stack {
Stack::NodeGeneric | Stack::NextJs | Stack::SvelteKit | Stack::Nuxt | Stack::Astro => {
patterns.extend_from_slice(&[
"node_modules",
".next",
".nuxt",
"dist",
"coverage",
".cache",
]);
}
Stack::Python | Stack::Django | Stack::FastApi => {
patterns.extend_from_slice(&[
"__pycache__",
"*.pyc",
".venv",
"venv",
".pytest_cache",
]);
}
Stack::Go => {
patterns.push("vendor");
}
Stack::Rust => {
patterns.push("target");
}
_ => {}
}
patterns.join("\n")
}Le pattern .env* est particulièrement important. Les fichiers d'environnement contiennent souvent des secrets -- mots de passe de base de données, clés API, identifiants cloud. Les inclure dans le contexte de build Docker signifie qu'ils se retrouvent dans l'historique des couches d'image, lisibles par quiconque a accès à l'image. sh0 les exclut par défaut.
Contexte de build : archives tar en mémoire
Les builds Docker nécessitent une archive tar comme contexte de build. sh0 crée cette archive en mémoire, en utilisant la crate tar de Rust :
rustpub fn create_build_context(
path: &Path,
dockerfile_content: &str,
ignore_patterns: &[&str],
) -> Result<Vec<u8>, BuilderError> {
let mut archive = tar::Builder::new(Vec::new());
// Injecter le Dockerfile généré
let dockerfile_bytes = dockerfile_content.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_path("Dockerfile")?;
header.set_size(dockerfile_bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
archive.append(&header, dockerfile_bytes)?;
// Parcourir le répertoire du projet, en respectant .dockerignore
for entry in walkdir::WalkDir::new(path) {
let entry = entry?;
let rel_path = entry.path().strip_prefix(path)?;
if should_ignore(rel_path, ignore_patterns) {
continue;
}
// ... ajouter le fichier à l'archive
}
Ok(archive.into_inner()?)
}Le Dockerfile généré est injecté directement dans l'archive tar. Le projet n'a jamais besoin d'un Dockerfile sur le disque. C'est plus propre que d'écrire un fichier temporaire en espérant que le nettoyage se fasse correctement.
Le répertoire .git/ est toujours exclu, quel que soit le contenu du .dockerignore. Un répertoire .git peut faire des centaines de mégaoctets et n'a pas sa place dans un contexte de build.
L'orchestrateur : détecter, générer, construire
La struct Builder lie tout ensemble dans un pipeline à trois étapes :
rustimpl Builder {
pub async fn build(&self, opts: BuildOpts) -> Result<BuildOutput, BuilderError> {
let start = Instant::now();
// Étape 1 : détecter la stack
let detected = detect(&opts.source_path);
// Étape 2 : générer le Dockerfile (ou utiliser l'existant)
let dockerfile = match detected.stack {
Stack::Dockerfile => {
std::fs::read_to_string(opts.source_path.join("Dockerfile"))?
}
stack if stack.needs_dockerfile() => {
return Err(BuilderError::NeedsDockerfile(stack.label().into()));
}
_ => generate_dockerfile(&detected),
};
// Étape 3 : créer le contexte et construire
let ignore = generate_dockerignore(&detected.stack);
let context = create_build_context(&opts.source_path, &dockerfile, &ignore)?;
let image_id = self.docker.build_image(&context, &opts.image_tag).await?;
Ok(BuildOutput {
image_id,
stack: detected,
duration: start.elapsed(),
// ...
})
}
}Le pipeline est linéaire et transparent. Chaque étape a une entrée et une sortie claires. Si une étape échoue, l'erreur se propage avec tout le contexte : « la détection de stack a échoué parce que le répertoire est vide », « la génération de Dockerfile n'est pas supportée pour PHP -- fournissez le vôtre », « le build Docker a échoué avec le code de sortie 1 et ces logs ».
Tests : 23 unitaires, pas de Docker requis
La logique de détection est rigoureusement testée :
rust#[test]
fn test_detect_nextjs() {
let dir = tempdir().unwrap();
File::create(dir.path().join("package.json")).unwrap();
File::create(dir.path().join("next.config.js")).unwrap();
let detected = detect(dir.path());
assert_eq!(detected.stack, Stack::NextJs);
}
#[test]
fn test_dockerfile_takes_priority() {
let dir = tempdir().unwrap();
File::create(dir.path().join("package.json")).unwrap();
File::create(dir.path().join("next.config.js")).unwrap();
File::create(dir.path().join("Dockerfile")).unwrap();
let detected = detect(dir.path());
assert_eq!(detected.stack, Stack::Dockerfile); // Pas NextJs
}
#[test]
fn test_detect_yarn() {
let dir = tempdir().unwrap();
File::create(dir.path().join("package.json")).unwrap();
File::create(dir.path().join("yarn.lock")).unwrap();
let detected = detect(dir.path());
assert_eq!(detected.package_manager, Some(PackageManager::Yarn));
}Chaque test crée un répertoire temporaire avec le minimum de fichiers nécessaires pour déclencher un chemin de détection spécifique. Pas de vrais projets, pas de fixtures, pas de Docker. Les tests s'exécutent en parallèle en moins d'une seconde.
Pourquoi c'est important
La détection de stack est la fonctionnalité qui transforme un outil de déploiement en plateforme. Sans elle, chaque utilisateur doit écrire un Dockerfile, configurer une commande de build et spécifier un port. Avec elle, on pousse du code et ça fonctionne.
Se tromper est pire que de ne pas avoir la fonctionnalité du tout. Une stack mal détectée signifie un build échoué, un message d'erreur confus, et un utilisateur qui perd confiance. C'est pourquoi la détection est conservatrice : des signaux spécifiques plutôt que vagues, des fichiers de configuration plutôt que du scan de dépendances, et « vous devez fournir un Dockerfile » plutôt qu'une mauvaise supposition.
Le moteur de build est aussi l'endroit où le système de health check de sh0 se branche. Après la détection de la stack mais avant la construction de l'image, sh0 exécute 34 règles d'analyse statique pour détecter les erreurs de déploiement. C'est le sujet du prochain article.
Ceci est la Partie 3 de la série « Comment nous avons construit sh0.dev ».
Navigation de la série : - [1] Jour Zéro : 10 crates Rust en 24 heures - [2] Écrire un client Docker Engine from scratch en Rust - [3] Détection automatique de 19 stacks technologiques depuis le code source (vous êtes ici) - [4] 34 règles pour détecter les erreurs de déploiement avant qu'elles ne surviennent