diff --git a/docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md b/docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md new file mode 100644 index 0000000..25ed6b7 --- /dev/null +++ b/docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md @@ -0,0 +1,150 @@ +# Migration de Loki vers Rust — lot 1 : façade Rust + mise à jour au démarrage + +Date : 2026-10-13 +Statut : validé (design), à planifier + +## Contexte + +Loki est aujourd'hui un monobinaire Go (`internal/loki`, ~65 000 lignes hors +tests) qui sert une UI embarquée (~26 000 lignes JS/CSS/HTML) et pilote +llama.cpp, Strata, MCP, le mode Code, etc. L'objectif à terme est de le +réécrire en Rust. Une réécriture d'un bloc ne livrerait rien pendant des +semaines : la migration est **progressive**, par lots, avec Rust en production +dès le premier lot. + +Second besoin : aujourd'hui, un `docker restart` relance la même image. Le +conteneur doit prendre la dernière version de Loki **au prochain démarrage**, +sans `docker compose pull` manuel. + +## Architecture de cohabitation (« strangler fig ») + +``` +:8090 ──► loki-rs (Rust, axum/tokio) + ├─ routes portées → traitées en Rust + └─ tout le reste → proxy HTTP / SSE / WebSocket + → loki web (Go) sur 127.0.0.1:8091 +``` + +- Nouveau workspace Cargo dans `rust/`, binaire `loki-rs`, compilé en statique + (`x86_64-unknown-linux-musl`) : indépendant de la glibc de l'image. +- Go écoute uniquement en local sur 8091 (nouvelle option d'adresse d'écoute + de `loki web`). Le port public reste 8090 : compose et reverse proxy + inchangés. +- Au lot 1, **aucune route n'est portée** : la façade proxifie tout. Elle sert + de socle aux lots suivants. +- Authentification (clé de pilotage, `LOKI_TRUSTED_HOSTS`) : reste à Go tant + qu'il sert la route. Go ne fonde aucune décision d'accès sur l'IP source + (vérifié : ni `RemoteAddr` ni `X-Forwarded-For` dans `internal/loki`), donc + passer par 127.0.0.1 n'élargit rien. La façade transmet `Host`, + `Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto` + **sans les altérer**, et retire seulement les en-têtes hop-by-hop. +- **Sécurité** : la façade **efface `X-Loki-Via`** de toute requête entrante. + Cet en-tête, posé par le tunnel du relais, dispense du contrôle d'hôte + anti-DNS-rebinding (`crossSiteReject`). Aujourd'hui l'écoute HTTP directe de + Go ne l'efface pas : sans clé de pilotage, une page en DNS rebinding peut le + forger. La façade ferme ce trou dans le conteneur ; un correctif Go séparé + le ferme pour `loki web` hors conteneur. Test dédié. +- Flux : pas de timeout d'écriture, pas de bufferisation des réponses + `text/event-stream` (chat SSE, `/v1` en streaming), upgrade WebSocket relayé + (`/api/node/ws` et autres). Pas de recompression : Go gère déjà gzip. +- Le relais `loki link` reste entièrement côté Go jusqu'à son propre lot. + +### Démarrage et arrêt + +`loki-rs serve` lance `loki web 8091` (Go) comme processus enfant, attend +qu'il réponde, puis ouvre 8090. Si l'enfant meurt, la façade le relance +(backoff borné) et répond 502 avec un message clair entre-temps. Sur SIGTERM, +la façade transmet le signal à l'enfant et attend sa fin (moteur llama-server +arrêté proprement par Go, comme aujourd'hui). + +## Mise à jour au démarrage + +### Publication (CI) + +À chaque push sur `main`, en plus de l'image GHCR, un workflow publie la +release GitHub **`rolling`** (pré-release, assets écrasés) : + +- `loki-linux` — binaire Go (déjà `CGO_ENABLED=0`) +- `loki-rs-linux` — binaire Rust musl +- `SHA256SUMS` — format `sha256sum` (celui que `loki update` sait déjà lire) +- `manifest.json` — `{"commit": "", "image_abi": }` + +La release `rolling` n'est jamais marquée `latest` : `loki update` (machines +non conteneurisées) continue de suivre les tags `vX.Y.Z`. + +### ABI d'image + +Le Dockerfile déclare `ENV LOKI_IMAGE_ABI=1`. On l'incrémente (Dockerfile et +source du manifeste dans le même commit) quand le code exige un changement de +l'image : nouvelle dépendance système, Python/Node, chemin du moteur, etc. +Un binaire publié avec une ABI différente de celle de l'image n'est **jamais** +installé : il ne pourrait pas tourner correctement. + +### Au boot (`loki-rs self-update`) + +Appelé par `docker-entrypoint.sh`, avec le `loki-rs` de l'image : + +1. `LOKI_AUTO_UPDATE=0` → ne fait rien. +2. Télécharge `manifest.json` (timeout global 20 s). Erreur réseau → étape 6. +3. `image_abi` ≠ `LOKI_IMAGE_ABI` → journal : + « nouvelle version nécessitant une nouvelle image : docker compose pull », + étape 6. +4. `commit` identique à `/data/bin/VERSION` → rien à faire, étape 6. +5. Télécharge les deux binaires en `/data/bin/*.part`, vérifie chacun contre + `SHA256SUMS`, `chmod +x`, renomme atomiquement, écrit `VERSION` en dernier. + Toute erreur → `.part` supprimés, rien remplacé. +6. Fin. Code de sortie 0 dans tous les cas : le boot n'est jamais bloqué. + +L'entrypoint choisit ensuite les binaires : `/data/bin/loki` et +`/data/bin/loki-rs` s'ils existent, sont exécutables **et** que +`/data/bin/ABI` égale `LOKI_IMAGE_ABI` ; sinon ceux de l'image. Les deux +binaires viennent toujours de la même source (jamais un Go de l'image avec un +Rust de `/data/bin`). Le chemin Go choisi est passé à la façade +(`LOKI_GO_BIN`) et utilisé pour les appels `loki config` de l'entrypoint. + +Limite assumée : seuls les binaires Loki se mettent à jour ainsi ; llama.cpp +se met à jour via l'UI (existant), le reste de l'image via un pull signalé par +l'ABI. + +## Organisation du code Rust + +``` +rust/ + Cargo.toml workspace + loki-rs/ + src/main.rs CLI : serve | self-update | version + src/proxy.rs façade HTTP/SSE/WebSocket + src/child.rs supervision du processus Go + src/update/ manifeste, téléchargement, SHA256SUMS, installation +``` + +Dépendances : `tokio`, `axum`, `hyper`/`hyper-util`, `reqwest` (rustls, +sans OpenSSL — musl), `sha2`, `serde`/`serde_json`, `clap`. + +## Tests + +- Unitaires : parse de `SHA256SUMS` (entrées `*nom`, nom absent), décision + d'installation (ABI différente, même commit, `LOKI_AUTO_UPDATE=0`), + installation atomique (échec au milieu → rien remplacé). +- Intégration : façade devant un faux backend axum — + SSE reçu morceau par morceau (pas en bloc), WebSocket écho, + en-têtes `Host`/`Authorization`/`Origin` transmis intacts, `X-Loki-Via` + entrant effacé, 502 si backend absent. +- Mise à jour : faux serveur de release local (URL de base injectable par + variable d'environnement de test). +- CI : `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test` dans un + job dédié ; la CI Go (gofmt + staticcheck + tests) reste inchangée. + +## Feuille de route des lots suivants (hors périmètre de ce spec) + +| Lot | Contenu | +|---|---| +| 2 | Fichiers statiques de l'UI ; lecture de la config via une API interne Go | +| 3 | Supervision de llama-server | +| 4 | Client LLM, `/v1` OpenAI, flux chat SSE | +| 5 | Outils de l'agent, MCP, mode Code/LSP | +| 6 | Modèles et téléchargements, Strata, optimiseur, bench | +| 7 | Mémoire, tâches, dictée, relais, CDP | +| 8 | Store bbolt → redb, retrait de Go | + +Chaque lot aura son propre spec, plan et PR.