Back to sh0
sh0

Jour Zéro : 10 crates Rust en 24 heures

Comment nous avons échafaudé une plateforme PaaS complète -- 10 crates Rust, 24 tables de base de données, un client Docker Engine, un serveur API, un moteur de build et des health checks -- en une seule journée.

Thales & Claude | March 30, 2026 12 min sh0
EN/ FR/ ES
rustarchitecturepaascargo-workspacesqlitedockerday-one

La plupart des gens passeraient un mois à rédiger un document de conception pour une plateforme PaaS. Nous avons passé ce temps à construire la plateforme elle-même.

Le 12 mars 2026, à un bureau à Abidjan, Thales a ouvert un terminal et tapé cargo init. Vingt-quatre heures plus tard, sh0.dev existait : 10 crates Rust, 24 tables de base de données, un client Docker Engine construit de zéro, une API REST complète, un moteur de build capable de détecter 19 stacks technologiques, et un moteur d'analyse statique avec 34 règles. Pas un prototype. Pas un jouet. Une fondation qui porterait le produit entier jusqu'au lancement.

Voici l'histoire de cette journée -- les décisions d'architecture, le code, et la session marathon qui a prouvé qu'un CEO et un CTO IA pouvaient construire un PaaS de production sans un seul ingénieur humain.

Le pari : pourquoi Rust pour un PaaS

La première décision fut la plus déterminante. Chaque PaaS grand public -- Heroku, Railway, Render, Coolify -- est construit en Go ou en TypeScript. Nous avons choisi Rust.

Pas parce que c'était à la mode. Parce que nous construisions une plateforme de déploiement en binaire unique, destinée aux développeurs qui gèrent leurs propres serveurs. Ce binaire devait être rapide, léger et autonome. Pas de runtime. Pas de pauses du ramasse-miettes pendant le routage du trafic de production. Pas de « installez Node 18 puis faites npm install de 400 paquets » avant de pouvoir déployer votre première application.

Rust nous a donné un avantage supplémentaire : si le code compile, toute une classe de bugs n'existe tout simplement pas. Quand on est deux -- un humain, une IA -- à construire un logiciel d'infrastructure, le compilateur est votre troisième coéquipier.

Le workspace à 10 crates

La structure du workspace était le squelette de tout ce qui allait suivre. Nous l'avons conçue selon le principe que chaque crate possède exactement un domaine, ne dépend que de ce dont elle a besoin, et peut être testée isolément.

sh0/
  Cargo.toml              # racine du workspace
  crates/
    sh0/                  # binaire principal (CLI + démarrage serveur)
    sh0-api/              # serveur HTTP API Axum
    sh0-auth/             # authentification et clés API
    sh0-backup/           # sauvegarde et restauration
    sh0-builder/          # détection de stack, génération de Dockerfile, health checks
    sh0-db/               # pool de connexions SQLite, migrations, 21 modèles
    sh0-docker/           # client Docker Engine API (socket Unix)
    sh0-git/              # opérations Git et parsing de webhooks
    sh0-monitor/          # collecte de métriques et alertes
    sh0-proxy/            # gestion du reverse proxy (Caddy)

Le Cargo.toml du workspace centralisait toutes les versions de dépendances partagées. Ce n'est pas qu'une question d'ordre -- cela empêche le bug insidieux où sh0-api compile avec serde 1.0.197 tandis que sh0-docker lie serde 1.0.195, et où une différence subtile de sérialisation provoque une erreur à l'exécution à 3 heures du matin.

toml[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["ws"] }
rusqlite = { version = "0.31", features = ["bundled"] }
r2d2 = "0.8"
r2d2_sqlite = "0.24"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }

Chaque crate feuille déclarait ses dépendances en référençant le workspace :

toml[dependencies]
serde = { workspace = true }
tokio = { workspace = true }

Une seule source de vérité. Zéro dérive de versions.

Phase 1 : les fondations de la base de données

Nous avons commencé par sh0-db parce que tout le reste en dépendrait. La première question portait sur la base de données. PostgreSQL ? MySQL ? Nous avons choisi SQLite avec le mode WAL.

Le raisonnement était pragmatique. sh0 est un outil en binaire unique. Dire aux utilisateurs « avant de pouvoir déployer vos applications, commencez par installer un cluster PostgreSQL » serait absurde. SQLite nous offre une base de données embarquée, sans configuration, éprouvée au combat. Le mode WAL (Write-Ahead Logging) nous donne des lectures concurrentes sans bloquer les écrivains -- essentiel quand le serveur API lit le statut d'une application pendant que le pipeline de déploiement le met à jour.

La configuration du pool de connexions tenait en 30 lignes de Rust qui allaient sous-tendre chaque opération de base de données du système :

rustuse r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::OpenFlags;

pub type DbPool = Pool<SqliteConnectionManager>;

pub fn create_pool(path: &str) -> Result<DbPool, PoolError> {
    let manager = SqliteConnectionManager::file(path)
        .with_flags(
            OpenFlags::SQLITE_OPEN_READ_WRITE
            | OpenFlags::SQLITE_OPEN_CREATE
            | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
        )
        .with_init(|conn| {
            conn.execute_batch(
                "PRAGMA journal_mode=WAL;
                 PRAGMA foreign_keys=ON;
                 PRAGMA busy_timeout=5000;"
            )?;
            Ok(())
        });

    Pool::builder()
        .max_size(10)
        .build(manager)
        .map_err(PoolError::R2d2)
}

Trois PRAGMAs. Trois décisions critiques. journal_mode=WAL pour la concurrence. foreign_keys=ON parce que SQLite les désactive par défaut (un piège qui a ruiné bien des projets). busy_timeout=5000 pour que les écrivains concurrents attendent cinq secondes avant d'abandonner plutôt que de renvoyer immédiatement SQLITE_BUSY.

24 tables en une seule migration

La migration initiale définissait 24 tables : 13 entités principales, 7 fonctionnalités étendues et 4 capacités avancées. Nous avons écrit le schéma entier sous la forme d'un seul fichier 001_initial.sql, embarqué directement dans le binaire grâce à la macro include_str! de Rust.

Le moteur de migration lui-même était volontairement simple :

rustpub fn run_migrations(conn: &Connection) -> Result<(), MigrationError> {
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS _migrations (
            version INTEGER PRIMARY KEY,
            name TEXT NOT NULL,
            applied_at TEXT NOT NULL DEFAULT (datetime('now'))
        );"
    )?;

    let applied: Vec<i64> = conn
        .prepare("SELECT version FROM _migrations ORDER BY version")?
        .query_map([], |row| row.get(0))?
        .collect::<Result<_, _>>()?;

    for migration in MIGRATIONS {
        if !applied.contains(&migration.version) {
            conn.execute_batch(migration.sql)?;
            conn.execute(
                "INSERT INTO _migrations (version, name) VALUES (?1, ?2)",
                params![migration.version, migration.name],
            )?;
        }
    }
    Ok(())
}

Pas d'ORM. Pas de framework de migration avec son propre DSL et 50 dépendances transitives. Juste du SQL, une table de versions et une boucle. Cela s'exécute en microsecondes, c'est évident ce que ça fait, et ça ne cassera jamais parce qu'une bibliothèque en amont a changé son format de migration.

21 modèles, un seul pattern

Chacun des 21 fichiers de modèle suivait le même schéma : une struct, un constructeur from_row(), et les opérations CRUD standard. Voici un exemple représentatif :

rustimpl App {
    pub fn from_row(row: &Row) -> rusqlite::Result<Self> {
        Ok(Self {
            id: row.get("id")?,
            project_id: row.get("project_id")?,
            name: row.get("name")?,
            status: row.get("status")?,
            created_at: row.get("created_at")?,
            updated_at: row.get("updated_at")?,
        })
    }

    pub fn insert(conn: &Connection, app: &NewApp) -> Result<Self, Sh0DbError> {
        let id = Uuid::new_v4().to_string();
        conn.execute(
            "INSERT INTO apps (id, project_id, name, status) VALUES (?1, ?2, ?3, ?4)",
            params![id, app.project_id, app.name, "active"],
        )?;
        Self::find_by_id(conn, &id)
    }

    pub fn find_by_id(conn: &Connection, id: &str) -> Result<Self, Sh0DbError> {
        conn.query_row("SELECT * FROM apps WHERE id = ?1", params![id], Self::from_row)
            .map_err(|e| match e {
                rusqlite::Error::QueryReturnedNoRows => Sh0DbError::NotFound(id.to_string()),
                other => Sh0DbError::Sqlite(other),
            })
    }
}

Vingt et une fois ce pattern. Répétitif ? Oui. Mais chaque modèle compile indépendamment, ne contient aucune magie cachée, et correspond exactement au SQL sous-jacent. Quand on construit de l'infrastructure dont dépendent les applications de production d'autres personnes, l'ennui est une fonctionnalité.

Phase 2 : le client Docker Engine

Une fois la base de données en place, nous avons construit sh0-docker -- un client complet pour l'API Docker Engine communiquant via des sockets Unix. C'était la pièce techniquement la plus difficile de toute la journée. (Nous la couvrons en détail dans l'article suivant, « Écrire un client Docker Engine from scratch en Rust ».)

La décision clé : nous avons écrit notre propre client avec hyper 1.x au lieu de passer par le CLI Docker ou d'utiliser une bibliothèque existante. Le résultat : un UnixConnector personnalisé en environ 40 lignes, une gestion complète du cycle de vie des conteneurs, un parsing de flux multiplexés, et un calcul des statistiques CPU/mémoire.

Six tests unitaires. Cinq fichiers de tests d'intégration. Zéro dépendance externe à une bibliothèque Docker.

Phase 3 : le serveur API

La phase 3 a connecté la base de données et le client Docker à travers une API HTTP Axum. La struct AppState transportait tout :

rustpub struct AppState {
    pub pool: Arc<DbPool>,
    pub docker: Arc<DockerClient>,
    pub started_at: Instant,
}

Trois champs. Le pool de base de données, le client Docker, et un timestamp pour le calcul de l'uptime. Chaque handler recevait cet état via le système d'extracteurs d'Axum.

L'arbre de routes était propre et RESTful :

GET    /api/v1/health
GET    /api/v1/status
POST   /api/v1/apps
GET    /api/v1/apps
GET    /api/v1/apps/:id
PUT    /api/v1/apps/:id
DELETE /api/v1/apps/:id
POST   /api/v1/apps/:id/deployments
GET    /api/v1/apps/:id/deployments
GET    /api/v1/deployments/:id
WS     /api/v1/apps/:id/logs

Une décision de conception mérite d'être soulignée : l'extracteur AuthUser était implémenté comme un no-op qui retourne toujours un utilisateur admin. Ce n'était pas de la paresse -- c'était de l'architecture. La signature de type de l'extracteur correspondait à ce que l'authentification réelle par JWT utiliserait en Phase 9. Chaque handler était écrit pour accepter AuthUser en paramètre. Quand nous avons implémenté l'authentification réelle plus tard, nous avons modifié un seul fichier -- l'extracteur -- et chaque handler a hérité de l'authentification réelle sans qu'une seule ligne de code handler ne change.

L'API incluait également la pagination avec des valeurs par défaut sensées et des garde-fous :

rustpub struct PaginationParams {
    pub page: u32,     // défaut 1
    pub per_page: u32, // défaut 20, plafonné à 1..=100
}

Aucun utilisateur ne peut demander la page -1 ou per_page 10000. L'API se défend elle-même.

Phases 5 et 6 : moteur de build et health checks

L'après-midi a apporté le moteur de build (sh0-builder), que nous avons divisé en deux capacités distinctes : la détection de stack avec génération de Dockerfile, et un moteur de vérification de santé du code.

Le détecteur de stack examine un répertoire de projet et identifie l'une des 19 stacks technologiques. La détection est basée sur les priorités -- si un utilisateur fournit son propre Dockerfile, celui-ci l'emporte toujours. Sinon, le moteur cherche des fichiers signatures : package.json pour Node.js, go.mod pour Go, Cargo.toml pour Rust, etc.

La détection de Node.js à elle seule comporte plusieurs couches : quel gestionnaire de paquets (npm, yarn, pnpm, bun), quel framework (Express, Fastify, Hono, Koa, NestJS), et quel méta-framework (Next.js, Nuxt, SvelteKit, Astro). Bien faire les choses signifie générer le bon Dockerfile -- et se tromper signifie un build échoué et un utilisateur frustré.

Le moteur de health check a ajouté 34 règles d'analyse statique réparties en 8 catégories, entièrement en Rust pur. Pas de LLM. Pas d'appel réseau. Juste des pointeurs de fonction et du pattern matching, scrutant les problèmes de sécurité, les erreurs de configuration et les erreurs de déploiement courantes avant qu'elles n'atteignent la production. (Le moteur de build et le moteur de health check auront chacun leur article dédié plus tard dans cette série.)

Le moment où tout a compilé

À la fin de la session, nous avons lancé trois commandes :

cargo build              # passe proprement
cargo test               # tous les tests passent
cargo clippy -D warnings # zéro warning

Dix crates. Vingt-quatre tables de base de données. Vingt et un fichiers de modèle. Un client Docker Engine. Une API REST complète avec 12 tests d'intégration. Un moteur de build avec 23 tests unitaires. Un moteur de health check avec 34 règles et 82 tests. Un binaire CLI avec quatre sous-commandes.

Tout cela compilé, lié et testé en une seule invocation de cargo build. Le compilateur Rust avait vérifié, au niveau des types, que ces dix crates pouvaient interopérer correctement. Pas de surprise à l'exécution. Pas de « ça marche sur ma machine ».

Quand Thales a lancé cargo run -- version et vu sh0 v0.1.0 s'afficher dans le terminal, c'est à ce moment que sh0.dev a cessé d'être une idée pour devenir un produit.

Ce qui a rendu cela possible

Construire les fondations d'un PaaS en 24 heures n'est pas normal. Trois facteurs l'ont rendu possible.

Premièrement, le workflow CEO-CTO IA. Thales prenait les décisions d'architecture -- Rust, SQLite, la division en 10 crates -- sur la base de son intuition produit et de sa compréhension du marché. Claude les implémentait à la vitesse de la pensée, écrivant du code Rust correct qui compilait du premier ou deuxième coup. Pas d'allers-retours sur le style de code, pas de cycle de revue de pull request, pas de « laissez-moi d'abord configurer mon environnement de dev ».

Deuxièmement, le compilateur Rust comme barrière de qualité. Dans un langage dynamiquement typé, le code aurait peut-être compilé rapidement mais caché des dizaines de bugs d'intégration. Rust nous a forcés à gérer chaque erreur, vérifier chaque type, et rendre explicite chaque relation de propriété. Le temps « perdu » à satisfaire le borrow checker était du temps économisé sur le débogage de crashs en production plus tard.

Troisièmement, la simplicité délibérée. Pas d'ORM. Pas de framework de migration. Pas de bibliothèque Docker. Pas de framework de test au-delà de la macro standard #[test]. Chaque dépendance a été choisie parce qu'elle résolvait un problème spécifique (r2d2 pour le pooling de connexions, hyper pour HTTP, axum pour le routage) et rien de plus. Moins il y a d'abstractions entre nous et la machine, moins il y a de choses qui peuvent casser.

Ce qui est venu ensuite

Le Jour Zéro nous a donné le squelette. Les jours suivants allaient ajouter les muscles : les opérations Git et le parsing de webhooks (Phase 4), le reverse proxy avec SSL automatique (Phase 7), le pipeline de déploiement complet reliant le tout (Phase 8), l'authentification réelle (Phase 9), et le monitoring (Phase 10).

Mais d'abord, nous devons parler de la pièce de code la plus difficile que nous ayons écrite le Jour Zéro : le client Docker Engine. C'est le prochain article.


Ceci est la Partie 1 de la série « Comment nous avons construit sh0.dev », documentant comment un CEO à Abidjan et un CTO IA ont construit une plateforme PaaS complète en 14 jours.

Navigation de la série : - [1] Jour Zéro : 10 crates Rust en 24 heures (vous êtes ici) - [2] Écrire un client Docker Engine from scratch en Rust - [3] Détection automatique de 19 stacks technologiques depuis le code source - [4] 34 règles pour détecter les erreurs de déploiement avant qu'elles ne surviennent

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles