mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 22:48:11 +02:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b859c4eb6 | ||
|
|
0a6914c936 | ||
|
|
342f787eff | ||
|
|
f1f73cfc50 | ||
|
|
6ea661d705 | ||
|
|
e236069c92 | ||
|
|
6afe07a823 | ||
|
|
30ce3a4e86 | ||
|
|
a463ccbd9f | ||
|
|
393004cb06 | ||
|
|
7d614324a0 | ||
|
|
853e59c085 | ||
|
|
e7fb63b1dd | ||
|
|
6aaf9360b4 | ||
|
|
c2a2cc5748 |
No files matched your search
@@ -8,3 +8,4 @@ dist/
|
||||
!README.md
|
||||
.env
|
||||
.env.*
|
||||
rust/target/
|
||||
@@ -31,6 +31,24 @@ jobs:
|
||||
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
|
||||
done
|
||||
|
||||
# Façade Rust (rust/loki-rs) : même exigence que le Go, zéro avertissement.
|
||||
rust:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: rust
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: rust
|
||||
- run: cargo fmt --all --check
|
||||
- run: cargo clippy --all-targets --locked -- -D warnings
|
||||
- run: cargo test --locked
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
LOKI_VERSION=${{ github.sha }}
|
||||
LOKI_BUILD=${{ github.run_number }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
|
||||
@@ -61,3 +62,56 @@ jobs:
|
||||
# pas : seul le code Go et l'assemblage de l'image se refont.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Release « rolling » : les binaires que le conteneur installe lui-même au
|
||||
# démarrage (loki-rs self-update, voir docker-entrypoint.sh). Même numéro de
|
||||
# run que l'image ci-dessus : c'est lui qui ordonne les versions.
|
||||
# manifest.json part EN DERNIER — tant qu'il annonce l'ancien build, un
|
||||
# conteneur qui démarre pendant l'envoi garde ce qu'il a ; s'il lit des
|
||||
# binaires à moitié remplacés, l'empreinte ne correspond pas et il n'installe
|
||||
# rien. Pré-release : `loki update` (hors conteneur, /releases/latest) ne la
|
||||
# voit pas.
|
||||
rolling:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: rolling-release
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-musl
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: rust
|
||||
- name: Outils musl
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
|
||||
- name: Binaires
|
||||
run: |
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -trimpath -ldflags "-s -w" -o dist/loki-linux ./cmd/loki
|
||||
(cd rust && cargo build --release --locked --target x86_64-unknown-linux-musl)
|
||||
cp rust/target/x86_64-unknown-linux-musl/release/loki-rs dist/loki-rs-linux
|
||||
file dist/loki-rs-linux
|
||||
(cd dist && sha256sum loki-linux loki-rs-linux > SHA256SUMS)
|
||||
abi=$(tr -d '[:space:]' < IMAGE_ABI)
|
||||
printf '{"build":%s,"abi":%s,"commit":"%s"}\n' \
|
||||
"${{ github.run_number }}" "$abi" "$GITHUB_SHA" > dist/manifest.json
|
||||
cat dist/manifest.json dist/SHA256SUMS
|
||||
- name: Publier
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release view rolling >/dev/null 2>&1 || \
|
||||
gh release create rolling --prerelease --target "$GITHUB_SHA" \
|
||||
--title "rolling (mise à jour au démarrage)" \
|
||||
--notes "Binaires installés automatiquement par le conteneur à son démarrage. Ne pas télécharger à la main."
|
||||
gh release upload rolling dist/loki-linux dist/loki-rs-linux dist/SHA256SUMS --clobber
|
||||
gh release upload rolling dist/manifest.json --clobber
|
||||
@@ -22,3 +22,6 @@ Thumbs.db
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
node_modules/
|
||||
|
||||
# Rust (façade loki-rs)
|
||||
rust/target/
|
||||
+21
@@ -42,6 +42,17 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
|
||||
# limité à cette étape de build.
|
||||
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
|
||||
|
||||
# ── Étape 1 ter : façade Rust loki-rs ───────────────────────────────────
|
||||
# Premier lot de la migration Go → Rust (rust/, voir docs/superpowers/specs/).
|
||||
# Alpine = musl : binaire STATIQUE, indépendant de la glibc de l'image runtime —
|
||||
# le même que celui publié dans la release « rolling » et installé à chaud
|
||||
# dans /data/bin par `loki-rs self-update`.
|
||||
FROM rust:1-alpine AS rustbuild
|
||||
RUN apk add --no-cache musl-dev
|
||||
WORKDIR /src
|
||||
COPY rust/ ./
|
||||
RUN cargo build --release --locked && mkdir -p /out && cp target/release/loki-rs /out/loki-rs
|
||||
|
||||
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
|
||||
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
|
||||
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
|
||||
@@ -131,6 +142,7 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
|
||||
|
||||
COPY --from=gobuild /out/loki /usr/local/bin/loki
|
||||
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
|
||||
COPY --from=rustbuild /out/loki-rs /usr/local/bin/loki-rs
|
||||
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
|
||||
# l'image : téléchargé à la demande dans /data/asr/.
|
||||
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
|
||||
@@ -147,6 +159,15 @@ ENV LOKI_CONTAINER=1 \
|
||||
NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
# Mise à jour au démarrage (loki-rs self-update) : IMAGE_ABI dit quelles
|
||||
# dépendances d'image les binaires exigent — un binaire publié pour une autre
|
||||
# ABI n'est jamais installé. LOKI_BUILD = numéro de run CI de cette image : un
|
||||
# binaire de /data/bin n'est pris que s'il est PLUS RÉCENT. Posés ici, en fin
|
||||
# d'image, pour ne pas invalider le cache des couches lourdes à chaque build.
|
||||
COPY IMAGE_ABI /etc/loki/IMAGE_ABI
|
||||
ARG LOKI_BUILD=0
|
||||
ENV LOKI_BUILD=${LOKI_BUILD}
|
||||
|
||||
# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
|
||||
# conteneur — il n'est pas authentifié par défaut.
|
||||
EXPOSE 8090
|
||||
|
||||
@@ -16,7 +16,9 @@ seule image.
|
||||
|
||||
```
|
||||
┌────────────────── conteneur loki ──────────────────┐
|
||||
│ loki web (UI + API, port 8090, premier plan) │
|
||||
│ loki-rs serve (façade Rust, port 8090) │
|
||||
│ │ relaie HTTP/SSE/WebSocket │
|
||||
│ loki web (UI + API Go, 127.0.0.1:8091) │
|
||||
│ │ pilote (fichier PID — pas de systemd) │
|
||||
│ loki serve ──exec──► llama-server (CUDA, :8080) │
|
||||
│ ▲ modèles .gguf │
|
||||
@@ -35,6 +37,10 @@ seule image.
|
||||
redémarre le moteur normalement.
|
||||
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
|
||||
seule l'UI (8090) l'est.
|
||||
- **Migration vers Rust en cours**, par lots : la façade `loki-rs`
|
||||
(`rust/loki-rs`) prend le port public et relaie au serveur Go ; chaque lot
|
||||
lui fait reprendre des routes. Plan et feuille de route :
|
||||
`docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
|
||||
|
||||
## Démarrage rapide (Docker, GPU NVIDIA)
|
||||
|
||||
@@ -120,6 +126,43 @@ verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
|
||||
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
|
||||
llama.cpp.
|
||||
|
||||
### Strata sur un SSD dédié (Unraid)
|
||||
|
||||
Strata range **tout** dans `/data/strata` : moteur, environnement Python (~10
|
||||
Go), modèle (66 à 84 Go), et, quand la RAM ne suffit pas à garder les experts
|
||||
(mode « lus depuis le disque »), il les relit en continu depuis ce dossier. Un
|
||||
SSD de 250 Go dédié lui suffit largement (quelle que soit la qualité choisie)
|
||||
et c'est lui qui fait la vitesse en mode disque : un NVMe vaut mieux qu'un SATA,
|
||||
et un disque dur de l'array est à proscrire.
|
||||
|
||||
1. **Le SSD en pool Unraid.** Array arrêté : *Main → Add Pool*, nom `strata`,
|
||||
1 emplacement, assigne le SSD. Démarre l'array, clique le pool, *File
|
||||
system type* = `xfs` (ou `btrfs`), puis *Format* en bas de la page Main.
|
||||
Le SSD est alors monté en `/mnt/strata`.
|
||||
(Avec le plugin *Unassigned Devices* à la place : monte-le, il apparaît en
|
||||
`/mnt/disks/<nom>` — même suite en remplaçant le chemin.)
|
||||
2. **Le dossier** (terminal Unraid) : `mkdir -p /mnt/strata/loki`.
|
||||
Vérifie avec `df -h /mnt/strata` que c'est bien le SSD (~230 Go) et pas
|
||||
`rootfs` : un `/mnt/<pool>` qui n'existe pas est dans la RAM d'Unraid.
|
||||
3. **Ce qui existe déjà.** Si une installation de Strata avait commencé dans
|
||||
`/mnt/user/appdata/loki/data/strata`, *Compose Down* puis, au choix :
|
||||
`rsync -a /mnt/user/appdata/loki/data/strata/ /mnt/strata/loki/` (on garde,
|
||||
les presets restent valides : le chemin vu par le conteneur ne change pas),
|
||||
ou supprime ce dossier pour repartir de zéro. Sinon il reste caché sous le
|
||||
montage et occupe de la place pour rien.
|
||||
4. **Le compose** : décommente dans `docker-compose.unraid.yml` la ligne
|
||||
```yaml
|
||||
- /mnt/strata/loki:/data/strata
|
||||
```
|
||||
(sous les deux volumes existants), puis *Compose Up*.
|
||||
5. **Installer** : *Paramètres → Moteur → Strata*. La fenêtre affiche
|
||||
maintenant l'espace libre **du SSD** ; choisis la version et la qualité
|
||||
proposées, puis installe.
|
||||
|
||||
Pas de partage `/mnt/user/…` pour ce montage : la couche FUSE d'Unraid
|
||||
ralentit fortement la lecture des experts en mmap. Et rien à faire côté mover
|
||||
ou cache : le pool `strata` n'est lié à aucun partage.
|
||||
|
||||
## Installation sur Unraid
|
||||
|
||||
L'image est construite et publiée par GitHub Actions sur GHCR
|
||||
@@ -135,6 +178,32 @@ Unraid. Compose prêt à l'emploi : [`docker-compose.unraid.yml`](docker-compose
|
||||
`docker-compose.unraid.yml` → **Compose Up**.
|
||||
4. Interface : `http://<ip-unraid>:8090`.
|
||||
|
||||
## Mise à jour au démarrage
|
||||
|
||||
Un **simple redémarrage du conteneur** (ou du serveur Unraid) suffit à passer
|
||||
à la dernière version de Loki : pas besoin de `docker compose pull` pour les
|
||||
changements de code.
|
||||
|
||||
- À chaque push sur `main`, la CI publie la release GitHub
|
||||
[`rolling`](https://github.com/R0m1k3/Loki/releases/tag/rolling) : binaires
|
||||
`loki` (Go) et `loki-rs` (Rust), `SHA256SUMS`, `manifest.json`.
|
||||
- Au démarrage, `loki-rs self-update` les télécharge, vérifie leur empreinte
|
||||
SHA-256 et les installe dans `/data/bin/<build>/`. Le journal du conteneur
|
||||
dit ce qui s'est passé (`[loki-rs update] …`, puis
|
||||
`[entrypoint] binaires Loki : …`).
|
||||
- **Jamais bloquant** : réseau coupé, empreinte fausse, GitHub injoignable →
|
||||
les binaires déjà en place servent, le conteneur démarre normalement.
|
||||
- **Quand une nouvelle image est nécessaire** (nouvelle dépendance système,
|
||||
Python, Node, llama.cpp…), le fichier `IMAGE_ABI` est incrémenté : les
|
||||
conteneurs plus anciens n'installent pas ces binaires et le journal demande
|
||||
un `docker compose pull`.
|
||||
- La mise à jour est faite par le `loki-rs` **de l'image**, jamais par celui
|
||||
téléchargé : une version publiée défectueuse ne peut pas empêcher le
|
||||
correctif suivant d'arriver au redémarrage d'après.
|
||||
- `docker exec loki loki …` utilise aussi la version installée.
|
||||
- Couper : `LOKI_AUTO_UPDATE=0`. Revenir aux binaires de l'image : supprimer
|
||||
`/data/bin` et redémarrer avec `LOKI_AUTO_UPDATE=0`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux
|
||||
@@ -152,6 +221,9 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
|
||||
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
|
||||
| `LOKI_TRUSTED_HOSTS` | noms de domaine autorisés à servir l'UI **sans** clé de pilotage (reverse proxy), séparés par des virgules | — |
|
||||
| `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` |
|
||||
| `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` |
|
||||
| `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
|
||||
|
||||
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
|
||||
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
|
||||
@@ -1025,7 +1097,7 @@ Une étape à la fois, mesurée avant la suivante :
|
||||
| Agent de code | — | mode Code : critères d'acceptation, passe de vérification, LSP, outils git |
|
||||
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
|
||||
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
|
||||
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
|
||||
| Mise à jour | `ajean update` (binaire GitHub) | au redémarrage du conteneur (release `rolling`) ; `docker compose pull` si l'image change |
|
||||
|
||||
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
|
||||
Pour récupérer les évolutions de l'amont :
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
# Les modèles se téléchargent directement depuis l'UI (catalogue intégré),
|
||||
# ou dépose tes .gguf dans /mnt/user/appdata/loki/models.
|
||||
#
|
||||
# Mise à jour : Compose Down puis Up avec « pull image », ou
|
||||
# « docker compose pull » avant Up.
|
||||
# Mise à jour : AUTOMATIQUE au redémarrage du conteneur (binaires Loki de
|
||||
# la release « rolling », vérifiés SHA-256 ; LOKI_AUTO_UPDATE=0 pour couper).
|
||||
# Si le journal demande une nouvelle image : Compose Down puis Up avec
|
||||
# « pull image », ou « docker compose pull » avant Up.
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
services:
|
||||
loki:
|
||||
@@ -64,6 +66,15 @@ services:
|
||||
- /mnt/user/appdata/loki/data:/data
|
||||
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
||||
- /mnt/user/appdata/loki/models:/models
|
||||
# Strata sur un SSD dédié (optionnel) : tout ce que Strata installe
|
||||
# (moteur, Python, modèle de 66 à 84 Go, experts lus en mmap) va dans
|
||||
# /data/strata. Monter un SSD à cet endroit l'y envoie entier. Le SSD
|
||||
# doit être un pool Unraid (Main -> Add Pool, ex. « strata ») : chemin
|
||||
# /mnt/<pool>/…, PAS /mnt/user/… (FUSE ralentit le mmap des experts).
|
||||
# Vérifie que /mnt/strata existe avant Compose Up : sinon Docker crée
|
||||
# le dossier dans la RAM d'Unraid. Détails : README, « Strata sur un
|
||||
# SSD dédié ».
|
||||
# - /mnt/strata/loki:/data/strata
|
||||
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
|
||||
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
|
||||
ulimits:
|
||||
|
||||
+39
-3
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Entrypoint du conteneur Loki : sème la configuration au premier démarrage,
|
||||
# lance le moteur (supervision PID, sans systemd), puis l'UI au premier plan.
|
||||
# Entrypoint du conteneur Loki : met à jour les binaires Loki, sème la
|
||||
# configuration au premier démarrage, lance le moteur (supervision PID, sans
|
||||
# systemd), puis l'UI au premier plan derrière la façade Rust (loki-rs).
|
||||
set -eu
|
||||
|
||||
: "${LOKI_HOME:=/data}"
|
||||
@@ -15,6 +16,33 @@ if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
|
||||
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
|
||||
fi
|
||||
|
||||
# ── Mise à jour au démarrage ───────────────────────────────────────────────
|
||||
# Le loki-rs DE L'IMAGE télécharge la dernière release « rolling » (binaires
|
||||
# vérifiés par SHA-256) dans $LOKI_HOME/bin/<build>/ : un simple redémarrage
|
||||
# du conteneur suffit à passer à la dernière version. C'est toujours celui de
|
||||
# l'image qui s'en charge — un binaire publié défectueux ne peut donc pas
|
||||
# empêcher le correctif suivant d'arriver. Jamais bloquant (réseau coupé,
|
||||
# empreinte fausse : l'état précédent reste). LOKI_AUTO_UPDATE=0 le coupe.
|
||||
/usr/local/bin/loki-rs self-update || true
|
||||
|
||||
# loki et loki-rs viennent TOUJOURS du même dossier : /data/bin/<build> s'il
|
||||
# est plus récent que l'image et de même ABI, sinon l'image.
|
||||
BIN_DIR=$(/usr/local/bin/loki-rs which-bin 2>/dev/null || true)
|
||||
if [ -z "$BIN_DIR" ] || [ ! -x "$BIN_DIR/loki" ] || [ ! -x "$BIN_DIR/loki-rs" ]; then
|
||||
BIN_DIR=/usr/local/bin
|
||||
fi
|
||||
echo "[entrypoint] binaires Loki : $BIN_DIR"
|
||||
# Tous les appels « loki » ci-dessous visent le binaire choisi.
|
||||
loki() { "$BIN_DIR/loki" "$@"; }
|
||||
# Et `docker exec loki loki …` aussi : /usr/local/sbin passe AVANT
|
||||
# /usr/local/bin dans le PATH, un lien y suffit sans toucher aux binaires de
|
||||
# l'image (celui de loki-rs doit rester intact, c'est lui qui met à jour).
|
||||
if [ "$BIN_DIR" != /usr/local/bin ]; then
|
||||
ln -sf "$BIN_DIR/loki" /usr/local/sbin/loki 2>/dev/null || true
|
||||
else
|
||||
rm -f /usr/local/sbin/loki 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
|
||||
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
|
||||
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
|
||||
@@ -59,4 +87,12 @@ else
|
||||
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
|
||||
fi
|
||||
|
||||
exec loki web "${LOKI_WEB_PORT:-8090}"
|
||||
# Façade Rust (loki-rs) sur le port public ; elle lance `loki web` sur
|
||||
# 127.0.0.1:${LOKI_GO_PORT:-8091} et lui relaie tout. LOKI_FACADE=0 revient à
|
||||
# l'ancien schéma (Go seul, directement sur le port public) — porte de sortie
|
||||
# si la façade pose problème.
|
||||
export LOKI_WEB_PORT="${LOKI_WEB_PORT:-8090}"
|
||||
if [ "${LOKI_FACADE:-1}" = "0" ]; then
|
||||
exec "$BIN_DIR/loki" web "$LOKI_WEB_PORT"
|
||||
fi
|
||||
exec "$BIN_DIR/loki-rs" serve
|
||||
@@ -0,0 +1,122 @@
|
||||
# Migration Rust lot 1 — façade + mise à jour au démarrage — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Mettre un binaire Rust `loki-rs` en frontal du conteneur (proxy vers le Go) et faire que le conteneur prenne les derniers binaires publiés à chaque démarrage.
|
||||
|
||||
**Architecture:** `loki-rs serve` écoute sur `LOKI_WEB_PORT` (8090), lance `loki web 8091` (Go, `LOKI_WEB_BIND=127.0.0.1`) en enfant supervisé et lui proxifie tout (HTTP, SSE, WebSocket). `loki-rs self-update` télécharge la release GitHub `rolling` dans `/data/bin/<build>/`, `loki-rs which-bin` dit à l'entrypoint quel dossier de binaires utiliser.
|
||||
|
||||
**Tech Stack:** Rust 2021 (tokio, hyper 1, hyper-util, http-body-util, reqwest rustls, sha2, serde_json), cible `x86_64-unknown-linux-musl` ; Go 1.25 existant ; GitHub Actions.
|
||||
|
||||
Spec : `docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Port public inchangé : 8090 (`LOKI_WEB_PORT`). Go interne : `127.0.0.1:8091` (`LOKI_GO_PORT`).
|
||||
- Release : tag `rolling`, pré-release, assets `loki-linux`, `loki-rs-linux`, `SHA256SUMS`, `manifest.json` (uploadé en dernier).
|
||||
- `manifest.json` = `{"build": <github.run_number>, "abi": <IMAGE_ABI>, "commit": "<sha>"}`.
|
||||
- Source unique de l'ABI : fichier `IMAGE_ABI` à la racine (contenu `1`), copié dans l'image en `/etc/loki/IMAGE_ABI`. Build de l'image : `ENV LOKI_BUILD=<run_number>` (0 en build local).
|
||||
- Les binaires de `/data/bin/<build>/` ne sont utilisés que si `abi == IMAGE_ABI` **et** `build > LOKI_BUILD`.
|
||||
- `LOKI_AUTO_UPDATE=0` : aucune requête réseau. Le boot n'est jamais bloqué (code 0, timeouts bornés).
|
||||
- La façade efface `X-Loki-Via` entrant ; transmet `Host`, `Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto` intacts ; retire les hop-by-hop ; ne bufferise rien.
|
||||
- Go : `gofmt -l .` vide + `staticcheck ./...` propre. Rust : `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`.
|
||||
- Git : jamais `git add -A` ni `git stash` ; commits en français, style narratif.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
IMAGE_ABI ABI d'image (entier)
|
||||
rust/Cargo.toml workspace
|
||||
rust/loki-rs/Cargo.toml
|
||||
rust/loki-rs/src/main.rs CLI : serve | self-update | which-bin | version
|
||||
rust/loki-rs/src/proxy.rs proxy HTTP/SSE/WS vers le Go
|
||||
rust/loki-rs/src/child.rs supervision de `loki web`
|
||||
rust/loki-rs/src/update/mod.rs orchestration self-update + which-bin
|
||||
rust/loki-rs/src/update/sums.rs parse SHA256SUMS
|
||||
rust/loki-rs/src/update/state.rs release.json, décision, sélection du dossier
|
||||
rust/loki-rs/tests/proxy.rs intégration façade
|
||||
rust/loki-rs/tests/update.rs intégration self-update (faux serveur)
|
||||
internal/loki/web_server.go LOKI_WEB_BIND
|
||||
Dockerfile étape rustbuild, IMAGE_ABI, LOKI_BUILD
|
||||
docker-entrypoint.sh self-update, which-bin, exec loki-rs serve
|
||||
.github/workflows/ci.yml job rust
|
||||
.github/workflows/docker-publish.yml LOKI_BUILD + job rolling
|
||||
README.md section mise à jour au démarrage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Go — adresse d'écoute configurable
|
||||
|
||||
**Files:** Modify `internal/loki/web_server.go` (cmdWeb), Test `internal/loki/web_bind_test.go`
|
||||
|
||||
**Interfaces:** Produces `webBindAddr(port int) string` — `LOKI_WEB_BIND` (défaut `0.0.0.0`) + `:port`.
|
||||
|
||||
- [ ] Test : `t.Setenv("LOKI_WEB_BIND","")` → `"0.0.0.0:8090"` ; `"127.0.0.1"` → `"127.0.0.1:8091"`.
|
||||
- [ ] `go test ./internal/loki -run TestWebBindAddr` → FAIL (indéfini).
|
||||
- [ ] Implémenter, l'utiliser dans `cmdWeb` à la place de `fmt.Sprintf("0.0.0.0:%d", port)`.
|
||||
- [ ] Test PASS, `gofmt -l .` vide, staticcheck propre. Commit.
|
||||
|
||||
### Task 2: Workspace Rust + parse SHA256SUMS + état d'installation
|
||||
|
||||
**Files:** Create `rust/Cargo.toml`, `rust/loki-rs/Cargo.toml`, `src/main.rs` (squelette CLI), `src/update/{mod,sums,state}.rs`, `.gitignore` (`rust/target/`).
|
||||
|
||||
**Interfaces (produits) :**
|
||||
- `sums::parse(text:&str) -> HashMap<String,String>` (nom → hex minuscule ; accepte `*nom`, ignore lignes vides/invalides).
|
||||
- `state::Manifest { build:u64, abi:u32, commit:String }` (serde).
|
||||
- `state::Installed` = Manifest écrit dans `<dir>/release.json`.
|
||||
- `state::read_installed(dir:&Path) -> Option<Manifest>`.
|
||||
- `state::select_dir(data_bin:&Path, image_dir:&Path, image_build:u64, image_abi:u32) -> PathBuf` : `<data_bin>/<build>` si release.json valide, abi égal, build > image_build, et `loki` + `loki-rs` présents dedans ; sinon `image_dir`.
|
||||
- `state::needs_update(m:&Manifest, image_build:u64, image_abi:u32, installed:Option<&Manifest>) -> Decision` avec `enum Decision { Install, UpToDate, AbiMismatch }`.
|
||||
|
||||
- [ ] Tests unitaires : parse (2 espaces, `*`, majuscules → minuscules, ligne cassée ignorée) ; `needs_update` (abi ≠ → AbiMismatch ; build ≤ image → UpToDate ; build ≤ installé → UpToDate ; sinon Install) ; `select_dir` (aucun fichier → image ; abi ≠ → image ; build ≤ image → image ; binaire manquant → image ; ok → data dir).
|
||||
- [ ] `cargo test` FAIL puis implémentation, PASS, fmt + clippy. Commit.
|
||||
|
||||
### Task 3: self-update (téléchargement vérifié, installation atomique)
|
||||
|
||||
**Files:** `src/update/mod.rs`, `tests/update.rs`
|
||||
|
||||
**Interfaces :** `update::run(cfg:&UpdateConfig) -> Outcome` (jamais d'erreur remontée au CLI : journalise et rend `Outcome::{Installed(u64), UpToDate, AbiMismatch, Failed(String), Disabled}`) ; `UpdateConfig { base_url, data_bin, image_build, image_abi, enabled, timeout }` construit par `UpdateConfig::from_env()` (`LOKI_UPDATE_URL` défaut `https://github.com/R0m1k3/Loki/releases/download/rolling`, `LOKI_HOME/bin`, `LOKI_BUILD`, `/etc/loki/IMAGE_ABI` puis `LOKI_IMAGE_ABI`, `LOKI_AUTO_UPDATE`).
|
||||
|
||||
Algorithme : manifeste → décision → `SHA256SUMS` → télécharge `loki-linux`, `loki-rs-linux` dans `<data_bin>/.tmp-<build>/` → vérifie chaque hash → chmod 755 (unix) → renomme en `loki`, `loki-rs` → écrit `release.json` → renomme le dossier en `<data_bin>/<build>` → écrit `<data_bin>/release.json` (tmp + rename) → supprime les autres `<n>/` et `.tmp-*`. Toute erreur : supprime `.tmp-<build>`, `Failed`.
|
||||
|
||||
- [ ] Tests d'intégration (faux serveur hyper sur 127.0.0.1:0 servant des fichiers en mémoire) : installation OK (fichiers présents, release.json, select_dir pointe dessus) ; hash faux → rien installé, ancien état intact ; abi ≠ → rien téléchargé hormis le manifeste ; serveur absent → Failed, pas de panic ; `enabled=false` → Disabled sans requête ; seconde exécution → UpToDate et nettoyage des anciens builds.
|
||||
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
|
||||
|
||||
### Task 4: Façade proxy
|
||||
|
||||
**Files:** `src/proxy.rs`, `tests/proxy.rs`
|
||||
|
||||
**Interfaces :** `proxy::serve(listener: tokio::net::TcpListener, backend: SocketAddr, shutdown: impl Future) -> io::Result<()>`.
|
||||
|
||||
Comportement : hyper http1 + `with_upgrades` ; chaque requête → client hyper-util vers `backend` (URI `http://backend{path_and_query}`), Host d'origine conservé ; retire hop-by-hop (`connection`, `keep-alive`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`, + noms listés dans `Connection`) et `x-loki-via` ; si requête d'upgrade (`Connection: upgrade` + `Upgrade`), garde `Connection: upgrade` + `Upgrade`, attend 101, puis `copy_bidirectional` entre les deux côtés upgradés ; corps en streaming dans les deux sens ; erreur de connexion → 502 `text/plain` « Loki (Go) redémarre — réessaie dans un instant ».
|
||||
|
||||
- [ ] Tests (faux backend hyper) : GET relayé avec Host/Authorization/Origin/X-Forwarded-Proto intacts et `X-Loki-Via` absent côté backend ; POST corps relayé ; SSE : backend envoie 1er événement puis attend 500 ms — le client reçoit le 1er avant 300 ms ; WebSocket écho via tokio-tungstenite ; backend arrêté → 502.
|
||||
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
|
||||
|
||||
### Task 5: Supervision du Go + commande `serve`
|
||||
|
||||
**Files:** `src/child.rs`, `src/main.rs`
|
||||
|
||||
**Interfaces :** `child::Supervisor::spawn(go_bin:PathBuf, port:u16) -> Supervisor` (relance avec backoff 1 s → 30 s, remis à 1 s après 60 s de vie ; stdin null, stdout/stderr hérités ; env `LOKI_WEB_BIND=127.0.0.1`) ; `Supervisor::shutdown(self, grace:Duration)` (SIGTERM unix / kill windows, puis kill après `grace`).
|
||||
`main serve` : go_bin = `LOKI_GO_BIN` sinon `loki` à côté de l'exe courant ; attend jusqu'à 30 s que `127.0.0.1:LOKI_GO_PORT` accepte (sinon ouvre quand même, 502 en attendant) ; `proxy::serve` sur `0.0.0.0:LOKI_WEB_PORT` ; SIGTERM/SIGINT → arrêt du proxy + `shutdown(10 s)`.
|
||||
`main which-bin` imprime `select_dir(...)` ; `main version` imprime version crate + `LOKI_BUILD`.
|
||||
|
||||
- [ ] Test unitaire `go_bin` par défaut ; test du backoff (fonction pure `next_backoff(prev, lived) -> Duration`).
|
||||
- [ ] Test manuel local : `loki-rs serve` avec un `loki.exe` construit → `curl localhost:8090/` renvoie l'UI.
|
||||
- [ ] Commit.
|
||||
|
||||
### Task 6: Image, entrypoint, CI, release rolling, doc
|
||||
|
||||
**Files:** `IMAGE_ABI`, `Dockerfile`, `docker-entrypoint.sh`, `.github/workflows/ci.yml`, `.github/workflows/docker-publish.yml`, `README.md`.
|
||||
|
||||
- Dockerfile : étape `FROM rust:1-alpine AS rustbuild` (`apk add musl-dev`, `cargo build --release --locked`), copie `/usr/local/bin/loki-rs` ; `ARG LOKI_BUILD=0` → `ENV LOKI_BUILD` ; `COPY IMAGE_ABI /etc/loki/IMAGE_ABI`.
|
||||
- Entrypoint : `loki-rs self-update` (best-effort) ; `BIN_DIR=$(loki-rs which-bin)` ; `LOKI="$BIN_DIR/loki"` pour tous les appels config/start ; `exec "$BIN_DIR/loki-rs" serve`.
|
||||
- CI : job `rust` (fmt, clippy, test sur ubuntu).
|
||||
- docker-publish : `LOKI_BUILD=${{ github.run_number }}` en build-arg ; job `rolling` (contents: write, concurrency `rolling`) : build Go linux CGO=0, build Rust musl (`rustup target add x86_64-unknown-linux-musl`, `musl-tools`), SHA256SUMS, manifest, `gh release create rolling --prerelease` si absent, upload `--clobber` binaires+sums puis manifest.
|
||||
- README : section « Mise à jour au démarrage » (comportement, `LOKI_AUTO_UPDATE=0`, ABI → pull).
|
||||
- [ ] `sh -n docker-entrypoint.sh`, YAML relu, commit.
|
||||
|
||||
## Self-review
|
||||
|
||||
Spec couvert : façade (T4/T5), X-Loki-Via (T4), bind Go (T1), rolling + manifeste + ABI (T2/T3/T6), repli et boot non bloquant (T3/T6), désactivation (T3), tests (T2–T4), CI (T6). Écart assumé vs spec : ordre des versions par `build` (run_number) et non par commit, et installation dans `/data/bin/<build>/` (bascule atomique par `release.json`).
|
||||
@@ -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": "<sha>", "image_abi": <entier>}`
|
||||
|
||||
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.
|
||||
@@ -527,6 +527,7 @@ type serveSysInfo struct {
|
||||
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
|
||||
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
|
||||
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
|
||||
DraftIsModel bool // MODEL_DRAFT désigne le modèle principal lui-même (voir specArgs)
|
||||
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
|
||||
|
||||
// Cartes listées par le moteur (--list-devices), lues seulement pour
|
||||
|
||||
@@ -113,6 +113,11 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
|
||||
return
|
||||
}
|
||||
si.Draft = p
|
||||
if a, err := os.Stat(p); err == nil {
|
||||
if b, err := os.Stat(si.Model); err == nil && os.SameFile(a, b) {
|
||||
si.DraftIsModel = true
|
||||
}
|
||||
}
|
||||
if g, err := ggufMeta(p); err == nil {
|
||||
si.DraftGGUF = &g
|
||||
}
|
||||
@@ -241,6 +246,23 @@ func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) str
|
||||
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
|
||||
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
|
||||
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
|
||||
// MODEL_DRAFT = le modèle principal lui-même (choisi dans la liste des
|
||||
// brouillons de l'éditeur) : -md recharge alors les 27B une seconde fois
|
||||
// (common_speculative_init_result charge tout fichier -md comme un modèle
|
||||
// à part), quand la tête MTP intégrée se greffe sur le modèle déjà en VRAM.
|
||||
// Vécu : « allocating 9628.34 MiB on device 0 » pour le brouillon, moteur
|
||||
// mort ; ou, quand ça passait, des couches renvoyées sur le CPU et 5 t/s au
|
||||
// lieu de 22. On l'ignore : même tête, sans les poids en double.
|
||||
if si.DraftIsModel && strings.TrimSpace(cfg["MODEL_DRAFT"]) != "" {
|
||||
c := make(map[string]string, len(cfg))
|
||||
for k, v := range cfg {
|
||||
c[k] = v
|
||||
}
|
||||
delete(c, "MODEL_DRAFT")
|
||||
args, notes, auto = specArgs(c, extra, si)
|
||||
return args, append([]string{"MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée " +
|
||||
"sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)"}, notes...), auto
|
||||
}
|
||||
mode := specMode(cfg)
|
||||
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||||
switch mode {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -107,6 +108,13 @@ func TestSpecArgs(t *testing.T) {
|
||||
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
|
||||
}},
|
||||
{name: "MODEL_DRAFT = le modèle lui-même : pas de -md, la tête intégrée, et on le dit",
|
||||
cfg: with(forced, "MODEL_DRAFT", "Qwen3.6-27B-MTP.gguf"), notes: 2, want: mtp,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Draft = s.Model
|
||||
s.DraftGGUF = s.GGUF
|
||||
s.DraftIsModel = true
|
||||
}},
|
||||
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
|
||||
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
|
||||
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
|
||||
@@ -493,3 +501,26 @@ func TestSynthAcceptanceWarned(t *testing.T) {
|
||||
t.Errorf("variable : %q", n)
|
||||
}
|
||||
}
|
||||
|
||||
// probeSpec reconnaît un MODEL_DRAFT qui désigne le modèle principal.
|
||||
func TestProbeSpecDraftIsModel(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
model := dir + "/Swift-27B.gguf"
|
||||
other := dir + "/mtp-Swift.gguf"
|
||||
for _, p := range []string{model, other} {
|
||||
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
si := serveSysInfo{Model: model}
|
||||
probeSpec(map[string]string{"MODEL_DRAFT": model}, &si)
|
||||
if !si.DraftIsModel {
|
||||
t.Errorf("MODEL_DRAFT = le modèle, non reconnu (Draft=%q, err=%q)", si.Draft, si.DraftErr)
|
||||
}
|
||||
si = serveSysInfo{Model: model}
|
||||
probeSpec(map[string]string{"MODEL_DRAFT": other}, &si)
|
||||
if si.DraftIsModel || si.Draft != other {
|
||||
t.Errorf("tête à part prise pour le modèle : %+v", si)
|
||||
}
|
||||
}
|
||||
+283
-76
@@ -129,9 +129,120 @@ const strataDropHeadroomGB = 7
|
||||
// le contexte CUDA, l'encodeur d'images et la marge : 5,3 Gio sur une 3070 8 Go).
|
||||
func strataHelperExpertsGB(vramGB float64) float64 { return max(vramGB-2.5, 0) }
|
||||
|
||||
// strataMmapExtraGB : le mode mmap écrit les experts dans un fichier à part
|
||||
// (experts.bin), à compter en plus du téléchargement.
|
||||
const strataMmapExtraGB = 45
|
||||
// Où vivent les experts que les cartes ne gardent pas (clé STRATA_EXPERTS du
|
||||
// preset : auto, ram ou disk), et le mode effectivement lancé :
|
||||
//
|
||||
// drop experts de la carte d'appoint hors RAM, le reste verrouillé en RAM
|
||||
// (le plus rapide mesuré par AJEAN) — demande un experts.bin existant
|
||||
// ram tous les experts chargés et verrouillés en RAM
|
||||
// arena experts.bin existant mappé tel quel (cache système)
|
||||
// disk experts lus dans les fichiers du modèle (--mmap-experts) : rien
|
||||
// n'est chargé au démarrage, le cache système garde ce que la RAM
|
||||
// permet. Un peu plus lent, mais jamais tué faute de RAM.
|
||||
//
|
||||
// Vécu (2026-10) : 62 Go de RAM dont 18 déjà pris par le reste du serveur, le
|
||||
// mode « hors RAM » choisi sur la RAM TOTALE, et un premier lancement qui
|
||||
// écrivait experts.bin en chargeant ~41 Go en mémoire anonyme : le noyau a tué
|
||||
// le moteur. D'où : décision sur la RAM DISPONIBLE au lancement, et plus jamais
|
||||
// de premier lancement qui écrit experts.bin.
|
||||
const (
|
||||
strataExpertsAuto = "auto"
|
||||
strataExpertsRAM = "ram"
|
||||
strataExpertsDisk = "disk"
|
||||
)
|
||||
|
||||
// memAvailableGB : la RAM réellement disponible (MemAvailable : libre + cache
|
||||
// récupérable), en Go. Hors Linux ou illisible : la RAM totale.
|
||||
func memAvailableGB() float64 {
|
||||
b, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return totalRAMGB()
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "MemAvailable:"); ok {
|
||||
f := strings.Fields(v)
|
||||
if len(f) > 0 {
|
||||
if kb, err := strconv.ParseFloat(f[0], 64); err == nil {
|
||||
return kb * 1024 / 1e9
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalRAMGB()
|
||||
}
|
||||
|
||||
// strataArenaGB : la taille des experts du quant d'un preset (lue dans le nom
|
||||
// de sa config d'installeur : strata-swift-iq3_xxs.json → IQ3_XXS). 0 = inconnu.
|
||||
func strataArenaGB(cfg map[string]string) float64 {
|
||||
tag := strings.ToUpper(strataConfigTag(strings.TrimSpace(cfg["STRATA_CONFIG"])))
|
||||
tag = strings.TrimPrefix(tag, "SWIFT-")
|
||||
if q, ok := strataQuants[tag]; ok {
|
||||
return q.ArenaGB
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// strataPackHasExpertsBin : experts.bin est déjà dans le pack (écrit par un
|
||||
// lancement précédent ou par l'installeur en mode peu de RAM).
|
||||
func strataPackHasExpertsBin(args []string) bool {
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--pack" {
|
||||
if _, err := os.Stat(filepath.Join(args[i+1], "experts.bin")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// strataResolveExperts : le mode lancé, d'après le choix du preset et la RAM
|
||||
// disponible (availGB) ; helperGB = VRAM de la carte d'appoint (0 sans).
|
||||
// Pure : la décision se teste en table.
|
||||
func strataResolveExperts(cfg map[string]string, args []string, availGB, helperGB float64) string {
|
||||
binReady := strataPackHasExpertsBin(args) && strataEngineInstalled() == strataEngineAsset
|
||||
dropReady := binReady && strataHelperOn(cfg) && cfg["STRATA_DROP"] != "0"
|
||||
arena := strataArenaGB(cfg)
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["STRATA_EXPERTS"])) {
|
||||
case strataExpertsDisk:
|
||||
if binReady {
|
||||
return "arena"
|
||||
}
|
||||
return "disk"
|
||||
case strataExpertsRAM:
|
||||
if dropReady {
|
||||
return "drop"
|
||||
}
|
||||
return "ram"
|
||||
}
|
||||
if arena <= 0 { // quant inconnu : on ne parie pas sur la RAM
|
||||
return "disk"
|
||||
}
|
||||
if dropReady && availGB >= arena-strataHelperExpertsGB(helperGB)+strataDropHeadroomGB {
|
||||
return "drop"
|
||||
}
|
||||
if availGB >= arena+strataLowRAMHeadroomGB {
|
||||
return "ram"
|
||||
}
|
||||
if binReady {
|
||||
return "arena"
|
||||
}
|
||||
return "disk"
|
||||
}
|
||||
|
||||
// strataExpertsLabel : le mode en clair, pour l'interface et le journal.
|
||||
func strataExpertsLabel(mode string) string {
|
||||
switch mode {
|
||||
case "drop":
|
||||
return "verrouillés en RAM, hors ceux de la carte d'appoint"
|
||||
case "ram":
|
||||
return "chargés et verrouillés en RAM"
|
||||
case "arena":
|
||||
return "experts.bin mappé (cache système)"
|
||||
case "disk":
|
||||
return "lus depuis le disque (mmap)"
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// strataMaxCtx : contexte natif maximal de Qwen3.8 Flash Next.
|
||||
const strataMaxCtx = 262144
|
||||
@@ -162,6 +273,7 @@ type strataEnv struct {
|
||||
Main int `json:"main"` // index nvidia-smi de la carte principale
|
||||
Helper int `json:"helper"` // index de la carte d'aide, -1 sans
|
||||
RAMGB float64 `json:"ram_gb"`
|
||||
AvailGB float64 `json:"avail_gb"` // RAM disponible (+ celle du moteur Strata en marche)
|
||||
DiskFreeGB float64 `json:"disk_free_gb"`
|
||||
Python string `json:"python,omitempty"`
|
||||
}
|
||||
@@ -171,7 +283,10 @@ const strataMinArch = 75
|
||||
|
||||
func strataDetect() strataEnv {
|
||||
env := strataEnv{Main: -1, Helper: -1, RAMGB: totalRAMGB()}
|
||||
if f := diskFree(LokiHome()); f > 0 {
|
||||
env.AvailGB = min(memAvailableGB()+strataEngineRSSGB(), env.RAMGB)
|
||||
// strataHome() et pas LokiHome() : /data/strata peut être un disque à part
|
||||
// (SSD dédié monté dans le conteneur), c'est sa place libre qui compte.
|
||||
if f := diskFree(strataHome()); f > 0 {
|
||||
env.DiskFreeGB = float64(f) / 1e9
|
||||
}
|
||||
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
|
||||
@@ -281,32 +396,60 @@ func strataHaveGB(family, quant string) float64 {
|
||||
strataDirGB(filepath.Join(strataDataDir(), "mtp"))
|
||||
}
|
||||
|
||||
// strataNeedsMmap : les experts ne tiennent pas en RAM avec la marge
|
||||
// nécessaire, ils seront lus en mmap depuis un fichier à part (experts.bin).
|
||||
func strataNeedsMmap(q strataQuant, ramGB float64) bool {
|
||||
return ramGB < q.ArenaGB+strataLowRAMHeadroomGB
|
||||
}
|
||||
|
||||
// strataDropFits : avec une carte d'aide, les experts qu'elle garde ne sont pas
|
||||
// en RAM ; le reste est verrouillé en RAM (plus rapide que le mmap : génération
|
||||
// ~64 au lieu de ~56 tok/s, lecture +17 à +38 %). Il faut que ce reste tienne.
|
||||
func strataDropFits(q strataQuant, env strataEnv) bool {
|
||||
if env.Helper < 0 {
|
||||
return false
|
||||
// strataEngineRSSGB : la RAM tenue par le moteur Strata qui tourne (serveur et
|
||||
// moteur, arbre du service), en Go. Ajoutée à la RAM disponible quand on prédit
|
||||
// pour CETTE machine : sinon le moteur en marche ferait croire qu'il ne tient pas.
|
||||
func strataEngineRSSGB() float64 {
|
||||
if !strataActive() {
|
||||
return 0
|
||||
}
|
||||
for _, g := range env.GPUs {
|
||||
if g.Index == env.Helper {
|
||||
return env.RAMGB >= q.ArenaGB-strataHelperExpertsGB(g.VRAMGB)+strataDropHeadroomGB
|
||||
root := readServicePID()
|
||||
if root <= 0 {
|
||||
return 0
|
||||
}
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
parent := map[int]int{}
|
||||
rss := map[int]float64{}
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join("/proc", e.Name(), "status"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "PPid:"); ok {
|
||||
parent[pid], _ = strconv.Atoi(strings.TrimSpace(v))
|
||||
} else if v, ok := strings.CutPrefix(line, "VmRSS:"); ok {
|
||||
if f := strings.Fields(v); len(f) > 0 {
|
||||
kb, _ := strconv.ParseFloat(f[0], 64)
|
||||
rss[pid] = kb * 1024 / 1e9
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
var total float64
|
||||
for pid, r := range rss {
|
||||
for p, n := pid, 0; p > 0 && n < 64; p, n = parent[p], n+1 {
|
||||
if p == root {
|
||||
total += r
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// strataFit : ce qu'un quant demande sur cette machine, et s'il y tient.
|
||||
type strataFit struct {
|
||||
Quant strataQuant `json:"quant"`
|
||||
DiskGB float64 `json:"disk_gb"`
|
||||
Mmap bool `json:"mmap"`
|
||||
Mmap bool `json:"mmap"` // en mode auto, les experts seraient lus depuis le disque
|
||||
Drop bool `json:"drop"` // experts de la carte d'aide hors RAM, le reste verrouillé
|
||||
OK bool `json:"ok"`
|
||||
Why string `json:"why,omitempty"`
|
||||
@@ -329,9 +472,15 @@ type strataDetails struct {
|
||||
CacheRoot string `json:"cache_root"`
|
||||
Mmap bool `json:"mmap"`
|
||||
Drop bool `json:"drop"`
|
||||
MainGPU string `json:"main_gpu"`
|
||||
HelperGPU string `json:"helper_gpu,omitempty"`
|
||||
VisionGPU string `json:"vision_gpu,omitempty"`
|
||||
// Experts : le choix du preset (auto, ram, disk) ; Mode : le mode lancé
|
||||
// (drop, ram, arena, disk) — celui du dernier lancement pour le preset
|
||||
// actif, sinon celui que la RAM disponible donnerait maintenant.
|
||||
Experts string `json:"experts"`
|
||||
Mode string `json:"mode"`
|
||||
ModeLabel string `json:"mode_label"`
|
||||
MainGPU string `json:"main_gpu"`
|
||||
HelperGPU string `json:"helper_gpu,omitempty"`
|
||||
VisionGPU string `json:"vision_gpu,omitempty"`
|
||||
// pour les réglages : ce que la machine permet et l'état actuel
|
||||
HasHelper bool `json:"has_helper"` // une carte d'aide est disponible
|
||||
HelperOn bool `json:"helper_on"`
|
||||
@@ -353,7 +502,13 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
|
||||
if json.Unmarshal(raw, &base) != nil {
|
||||
return nil
|
||||
}
|
||||
out, err := strataBuildConfig(base, pc, "")
|
||||
mode := strataResolveExperts(pc, strataArgList(base), env.AvailGB, strataHelperVRAM(pc, env.GPUs))
|
||||
if active, _ := strataPresetActive(presetID); active {
|
||||
if m := getStr(bkState, strataLaunchModeKey); m != "" {
|
||||
mode = m
|
||||
}
|
||||
}
|
||||
out, err := strataBuildConfig(base, pc, "", mode)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -379,9 +534,11 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
|
||||
Spec: val("--spec"), Prefill: val("--prefill"), ShortRead: val("--short-read"),
|
||||
CacheRoot: val("--prompt-cache-root"), MainGPU: gpuName(pc["STRATA_MAIN_GPU"]),
|
||||
}
|
||||
if e, ok := out["env"].(map[string]any); ok {
|
||||
d.Mmap = fmt.Sprint(e["STRATA_ARENA_MMAP"]) == "1"
|
||||
d.Drop = fmt.Sprint(e["STRATA_REMOTE_DROP"]) == "1"
|
||||
d.Mode, d.ModeLabel = mode, strataExpertsLabel(mode)
|
||||
d.Mmap, d.Drop = mode == "disk" || mode == "arena", mode == "drop"
|
||||
d.Experts = strings.ToLower(strings.TrimSpace(pc["STRATA_EXPERTS"]))
|
||||
if d.Experts != strataExpertsRAM && d.Experts != strataExpertsDisk {
|
||||
d.Experts = strataExpertsAuto
|
||||
}
|
||||
if h := strings.TrimSpace(pc["STRATA_HELPER_GPU"]); h != "" && h != "-1" {
|
||||
d.HasHelper = true
|
||||
@@ -402,15 +559,11 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
|
||||
}
|
||||
|
||||
func strataFitFor(q strataQuant, env strataEnv, haveGB float64) strataFit {
|
||||
f := strataFit{Quant: q, Mmap: strataNeedsMmap(q, env.RAMGB)}
|
||||
if f.Mmap && strataDropFits(q, env) {
|
||||
f.Mmap, f.Drop = false, true
|
||||
}
|
||||
// Prédiction du mode auto sur la RAM DISPONIBLE (pas la RAM totale) : sous
|
||||
// la marge, les experts seront lus depuis le disque. Le mode « hors RAM » de
|
||||
// la carte d'appoint demande un experts.bin, qu'une installation neuve n'a pas.
|
||||
f := strataFit{Quant: q, Mmap: env.AvailGB < q.ArenaGB+strataLowRAMHeadroomGB}
|
||||
f.DiskGB = q.DownloadGB
|
||||
// les deux modes lisent les experts dans experts.bin, écrit au premier lancement
|
||||
if f.Mmap || f.Drop {
|
||||
f.DiskGB += strataMmapExtraGB
|
||||
}
|
||||
// ce qui est déjà là ne se retélécharge pas ; il reste toujours les
|
||||
// environnements (Python, bibliothèques CUDA, moteur) : ~10 Go
|
||||
f.DiskGB = max(f.DiskGB-haveGB, 10)
|
||||
@@ -623,7 +776,10 @@ func strataRunInstall(req strataInstallReq) {
|
||||
// réserver sa propre variante (qui ne voit pas un experts.bin présent).
|
||||
"--low-ram", "off",
|
||||
}
|
||||
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1"
|
||||
// XDG_CACHE_HOME : les caches de pip et de Hugging Face (~/.cache, soit
|
||||
// /root dans le conteneur, donc l'image Docker — 20 Go sur Unraid) suivent
|
||||
// le reste de Strata dans /data/strata.
|
||||
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1\x00XDG_CACHE_HOME=" + filepath.Join(strataHome(), "cache")
|
||||
if err := runStepEnv("installation de Strata", strataSrcDir(), extra, "bash", args...); err != nil {
|
||||
lcFail(fmt.Errorf("l'installation a échoué : %w (voir le journal)", err))
|
||||
return
|
||||
@@ -646,8 +802,9 @@ func strataRunInstall(req strataInstallReq) {
|
||||
"STRATA_MAIN_GPU=" + strconv.Itoa(env.Main),
|
||||
"STRATA_HELPER_GPU=" + strconv.Itoa(env.Helper),
|
||||
"STRATA_VISION=1",
|
||||
"STRATA_MMAP=" + strataBool(fit.Mmap),
|
||||
"STRATA_DROP=" + strataBool(fit.Drop),
|
||||
// experts placés au lancement selon la RAM disponible (strataResolveExperts)
|
||||
"STRATA_EXPERTS=" + strataExpertsAuto,
|
||||
"STRATA_DROP=1",
|
||||
// la meilleure qualité validée par AJEAN : cache KV en fp16 (32K en
|
||||
// VRAM, le reste en RAM pour ne pas prendre la place des experts) et 3
|
||||
// jetons de brouillon MTP (mesuré meilleur que 4)
|
||||
@@ -773,8 +930,9 @@ func strataArgList(cfg map[string]any) []string {
|
||||
}
|
||||
|
||||
// strataBuildConfig lit la config de l'installeur et y applique les réglages
|
||||
// du preset. Pure (testable) : ne lance rien.
|
||||
func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey string) (map[string]any, error) {
|
||||
// du preset, avec les experts placés selon `mode` (strataResolveExperts). Pure
|
||||
// (testable) : ne lance rien.
|
||||
func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode string) (map[string]any, error) {
|
||||
raw, _ := json.Marshal(base)
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
@@ -811,32 +969,39 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey string
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mode « peu de RAM » : l'installeur écrit --mmap-experts / --resident-experts.
|
||||
// On applique à la place la variante mesurée par AJEAN : le pack experts.bin
|
||||
// mappé tel quel (STRATA_ARENA_MMAP, le système garde en cache ce que la RAM
|
||||
// permet), sans part PCIe (le mappage n'en donne pas l'adresse GPU). Le
|
||||
// moteur écrit experts.bin lui-même au premier démarrage s'il manque.
|
||||
// Experts (strataResolveExperts) : les drapeaux de l'installeur
|
||||
// (--mmap-experts / --resident-experts) sont remplacés par ceux du mode.
|
||||
env := map[string]any{}
|
||||
if e, ok := out["env"].(map[string]any); ok {
|
||||
env = e
|
||||
}
|
||||
if hasHelper && cfg["STRATA_DROP"] == "1" && strataDropReady(args) {
|
||||
// Les experts de la carte d'aide hors RAM, le reste verrouillé en RAM
|
||||
// (strataDropFits) : la principale relit aussi par PCIe une part de ce
|
||||
// qui lui manque (part automatique) ; la réserve de la carte d'aide est figée.
|
||||
args = strataDropFlag(strataDropFlag(args, "--mmap-experts"), "--resident-experts")
|
||||
args = strataDropFlag(strataDropFlag(args, "--mmap-experts"), "--resident-experts")
|
||||
delete(env, "STRATA_ARENA_MMAP")
|
||||
delete(env, "STRATA_REMOTE_DROP")
|
||||
switch mode {
|
||||
case "drop":
|
||||
if !hasHelper {
|
||||
break // sans carte d'appoint, c'est le mode « ram »
|
||||
}
|
||||
// Les experts de la carte d'aide hors RAM, le reste verrouillé en RAM :
|
||||
// la principale relit aussi par PCIe une part de ce qui lui manque (part
|
||||
// automatique) ; la réserve de la carte d'aide est figée.
|
||||
args = strataDropFlagValue(args, "--pcie-frac")
|
||||
delete(env, "STRATA_ARENA_MMAP")
|
||||
env["STRATA_REMOTE_DROP"] = "1"
|
||||
// ses experts sont relus de sa VRAM à la lecture d'un prompt, par les fils
|
||||
// de copie : 8 fils et 32 tampons (le moteur en prendrait 32 et 128)
|
||||
env["STRATA_STAGER_THREADS"] = "8"
|
||||
env["STRATA_STAGER_RING"] = "32"
|
||||
} else if cfg["STRATA_MMAP"] == "1" || cfg["STRATA_DROP"] == "1" || strataHasArg(args, "--mmap-experts") || strataHasArg(args, "--resident-experts") {
|
||||
// (STRATA_DROP sans experts.bin : ce lancement l'écrit, en mmap)
|
||||
args = strataDropFlag(strataDropFlag(args, "--mmap-experts"), "--resident-experts")
|
||||
case "arena":
|
||||
// experts.bin mappé tel quel (variante mesurée par AJEAN), sans part PCIe :
|
||||
// le mappage n'en donne pas l'adresse GPU.
|
||||
args = strataSetArg(args, "--pcie-frac", "0")
|
||||
env["STRATA_ARENA_MMAP"] = "1"
|
||||
case "disk":
|
||||
// Lus dans les fichiers du modèle par le cache système : rien n'est
|
||||
// chargé au démarrage. Pas de part PCIe non plus (mémoire non épinglée).
|
||||
args = append(args, "--mmap-experts")
|
||||
args = strataSetArg(args, "--pcie-frac", "0")
|
||||
}
|
||||
for _, kv := range strataEnvTuning {
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
@@ -874,23 +1039,50 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey string
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// strataDropReady : le mode « experts de la carte d'aide hors RAM » lit les
|
||||
// experts dans experts.bin (écrit au premier lancement, en mmap) et demande le
|
||||
// moteur du paquet.
|
||||
func strataDropReady(args []string) bool {
|
||||
pack := ""
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--pack" {
|
||||
pack = args[i+1]
|
||||
// strataLaunchModeKey : le mode des experts du dernier lancement (bkState),
|
||||
// montré dans la fenêtre de Strata pour le preset actif.
|
||||
const strataLaunchModeKey = "strata_experts_mode"
|
||||
|
||||
// strataHelperVRAM : VRAM (Go) de la carte d'appoint du preset, 0 sans.
|
||||
func strataHelperVRAM(cfg map[string]string, gpus []strataGPU) float64 {
|
||||
if !strataHelperOn(cfg) {
|
||||
return 0
|
||||
}
|
||||
h := strings.TrimSpace(cfg["STRATA_HELPER_GPU"])
|
||||
for _, g := range gpus {
|
||||
if strconv.Itoa(g.Index) == h {
|
||||
return g.VRAMGB
|
||||
}
|
||||
}
|
||||
if pack == "" {
|
||||
return false
|
||||
return 0
|
||||
}
|
||||
|
||||
// strataPresetActive : le preset id est-il celui en service ?
|
||||
func strataPresetActive(id string) (bool, error) {
|
||||
list, err := ListPresets()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(pack, "experts.bin")); err != nil {
|
||||
return false
|
||||
for _, p := range list {
|
||||
if p.ID == id {
|
||||
return p.Active, nil
|
||||
}
|
||||
}
|
||||
return strataEngineInstalled() == strataEngineAsset
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// strataGPUs : les cartes NVIDIA vues par nvidia-smi (nil si indisponible).
|
||||
func strataGPUs() []strataGPU {
|
||||
gpus, err := detectGPUs()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []strataGPU
|
||||
for _, g := range gpus {
|
||||
mib, _ := strconv.ParseFloat(g.MemTotal, 64)
|
||||
out = append(out, strataGPU{Index: g.Index, Name: g.Name, VRAMGB: mib / 1024})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// strataEngineMarker : le paquet dont vient le moteur installé.
|
||||
@@ -1009,7 +1201,14 @@ func serveStrata(cfg map[string]string) error {
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] moteur %s non installé (%v) : l'ancien reste en place\n", strataEngineAsset, err)
|
||||
}
|
||||
apiKey, _ := effectiveAPIKeyErr()
|
||||
final, err := strataBuildConfig(base, cfg, apiKey)
|
||||
// Experts : décidé ICI, sur la RAM disponible au moment du lancement (l'ancien
|
||||
// moteur est déjà arrêté), pas sur la RAM totale.
|
||||
avail := memAvailableGB()
|
||||
mode := strataResolveExperts(cfg, strataArgList(base), avail, strataHelperVRAM(cfg, strataGPUs()))
|
||||
fmt.Fprintf(os.Stderr, "%s experts : %s (choix %s, %.0f Go de RAM disponibles, %.0f Go d'experts)\n",
|
||||
strataServeMarker, strataExpertsLabel(mode), firstNonEmpty(cfg["STRATA_EXPERTS"], strataExpertsAuto), avail, strataArenaGB(cfg))
|
||||
_ = putStr(bkState, strataLaunchModeKey, mode)
|
||||
final, err := strataBuildConfig(base, cfg, apiKey, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1110,12 +1309,13 @@ func handleStrata(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// strataSettingsReq : les réglages modifiables d'un modèle installé.
|
||||
type strataSettingsReq struct {
|
||||
Preset string `json:"preset"`
|
||||
Ctx int `json:"ctx"`
|
||||
KV string `json:"kv"`
|
||||
Spec int `json:"spec"`
|
||||
Vision bool `json:"vision"`
|
||||
Helper bool `json:"helper"`
|
||||
Preset string `json:"preset"`
|
||||
Ctx int `json:"ctx"`
|
||||
KV string `json:"kv"`
|
||||
Spec int `json:"spec"`
|
||||
Vision bool `json:"vision"`
|
||||
Helper bool `json:"helper"`
|
||||
Experts string `json:"experts"` // auto, ram ou disk ; vide = inchangé
|
||||
}
|
||||
|
||||
// strataApplySettings réécrit les clés du preset (pure, testable).
|
||||
@@ -1128,11 +1328,18 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
||||
return "", fmt.Errorf("cache KV inconnu : %s", r.KV)
|
||||
case r.Spec < 2 || r.Spec > 4:
|
||||
return "", fmt.Errorf("MTP hors limites : %d", r.Spec)
|
||||
case r.Experts != "" && r.Experts != strataExpertsAuto && r.Experts != strataExpertsRAM && r.Experts != strataExpertsDisk:
|
||||
return "", fmt.Errorf("placement des experts inconnu : %s", r.Experts)
|
||||
}
|
||||
set := map[string]string{
|
||||
"CTX": strconv.Itoa(r.Ctx), "STRATA_KV": r.KV, "STRATA_SPEC": strconv.Itoa(r.Spec),
|
||||
"STRATA_VISION": strataBool(r.Vision), "STRATA_HELPER": strataBool(r.Helper),
|
||||
}
|
||||
keys := []string{"CTX", "STRATA_KV", "STRATA_SPEC", "STRATA_VISION", "STRATA_HELPER"}
|
||||
if r.Experts != "" {
|
||||
set["STRATA_EXPERTS"] = r.Experts
|
||||
keys = append(keys, "STRATA_EXPERTS")
|
||||
}
|
||||
var lines []string
|
||||
for _, l := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
|
||||
k, _, _ := strings.Cut(strings.TrimSpace(l), "=")
|
||||
@@ -1141,7 +1348,7 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
||||
}
|
||||
lines = append(lines, l)
|
||||
}
|
||||
for _, k := range []string{"CTX", "STRATA_KV", "STRATA_SPEC", "STRATA_VISION", "STRATA_HELPER"} {
|
||||
for _, k := range keys {
|
||||
lines = append(lines, k+"="+set[k])
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n", nil
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestStrataBuildConfigApplyTuning(t *testing.T) {
|
||||
"args": []any{"--pack", "/p", "--prefill", "8192", "--max-context", "131072"},
|
||||
"port": 8080.0,
|
||||
}
|
||||
out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k")
|
||||
out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k", "ram")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestStrataBuildConfigApplyTuning(t *testing.T) {
|
||||
func TestStrataBuildConfigHelperGPU(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts", "--vram-reserve-mib", "700"}, "gpu": 1.0,
|
||||
"vision": map[string]any{"exe": "v", "gpu": true}}
|
||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "")
|
||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -84,70 +84,6 @@ func TestStrataBuildConfigHelperGPU(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataDropFits(t *testing.T) {
|
||||
q := strataQuants["IQ3_XXS"]
|
||||
env := strataEnv{Main: 1, Helper: 0, RAMGB: 46.9, GPUs: []strataGPU{{Index: 0, VRAMGB: 7.8}, {Index: 1, VRAMGB: 15.9}}}
|
||||
if !strataDropFits(q, env) {
|
||||
t.Error("serveur de test (46 Go + 3070) : le mode hors RAM devrait tenir")
|
||||
}
|
||||
f := strataFitFor(q, env, 0)
|
||||
if f.Mmap || !f.Drop {
|
||||
t.Errorf("fit = mmap %v drop %v, attendu drop", f.Mmap, f.Drop)
|
||||
}
|
||||
env.Helper = -1
|
||||
if strataDropFits(q, env) {
|
||||
t.Error("sans carte d'aide, pas de mode hors RAM")
|
||||
}
|
||||
env.Helper, env.RAMGB = 0, 32
|
||||
if strataDropFits(q, env) {
|
||||
t.Error("32 Go : ne tient pas, doit rester en mmap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigDrop(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
pack := filepath.Join(home, "pack")
|
||||
_ = os.MkdirAll(pack, 0o755)
|
||||
base := map[string]any{"args": []any{"--pack", pack, "--pcie-frac", "0.3"}, "env": map[string]any{}}
|
||||
cfg := map[string]string{"STRATA_HELPER_GPU": "0", "STRATA_DROP": "1", "STRATA_MMAP": "0"}
|
||||
// sans experts.bin ni moteur à jour : repli mmap (ce lancement écrit experts.bin)
|
||||
out, _ := strataBuildConfig(base, cfg, "")
|
||||
if out["env"].(map[string]any)["STRATA_ARENA_MMAP"] != "1" || out["env"].(map[string]any)["STRATA_REMOTE_DROP"] != nil {
|
||||
t.Fatalf("repli mmap attendu : %v", out["env"])
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(pack, "experts.bin"), []byte("x"), 0o644)
|
||||
_ = os.MkdirAll(filepath.Join(strataSrcDir(), "engine-cuda12"), 0o755)
|
||||
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
|
||||
out, _ = strataBuildConfig(base, cfg, "")
|
||||
e := out["env"].(map[string]any)
|
||||
if e["STRATA_REMOTE_DROP"] != "1" || e["STRATA_ARENA_MMAP"] != nil {
|
||||
t.Errorf("mode hors RAM attendu : %v", e)
|
||||
}
|
||||
if a := strataArgs(t, out); strataHasArg(a, "--pcie-frac") {
|
||||
t.Errorf("--pcie-frac gardé (la part PCIe doit être automatique) : %v", a)
|
||||
}
|
||||
// carte d'aide coupée : pas de mode hors RAM
|
||||
cfg["STRATA_HELPER"] = "0"
|
||||
out, _ = strataBuildConfig(base, cfg, "")
|
||||
if out["env"].(map[string]any)["STRATA_REMOTE_DROP"] != nil {
|
||||
t.Error("mode hors RAM sans carte d'aide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigMmap(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts"}, "env": map[string]any{"X": "1"}}
|
||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
env := out["env"].(map[string]any)
|
||||
if strataHasArg(a, "--mmap-experts") || argAfter(a, "--pcie-frac") != "0" || env["STRATA_ARENA_MMAP"] != "1" || env["X"] != "1" {
|
||||
t.Errorf("mmap validé non appliqué : %v %v", a, env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataCudaDevices(t *testing.T) {
|
||||
cases := []struct {
|
||||
main, helper, want string
|
||||
@@ -167,7 +103,7 @@ func TestStrataCudaDevices(t *testing.T) {
|
||||
|
||||
func TestStrataRecommend(t *testing.T) {
|
||||
// la machine de test : 5060 Ti 16 Go + 3070 8 Go, 47 Go de RAM, disque large
|
||||
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 400,
|
||||
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, AvailGB: 47, DiskFreeGB: 400,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
||||
if got := strataRecommend("swift", env); got != "IQ3_XXS" {
|
||||
t.Errorf("swift : %q, attendu IQ3_XXS", got)
|
||||
@@ -177,12 +113,13 @@ func TestStrataRecommend(t *testing.T) {
|
||||
t.Errorf("qwen 47 Go : %q, attendu IQ3_XXS", got)
|
||||
}
|
||||
f := strataFitFor(strataQuants["IQ3_XXS"], env, 0)
|
||||
// trop juste pour tout garder en RAM, mais la 3070 garde les siens : mode hors RAM
|
||||
if !f.OK || f.Mmap || !f.Drop {
|
||||
t.Errorf("IQ3_XXS sur 47 Go : ok=%v mmap=%v drop=%v (attendu ok, hors RAM)", f.OK, f.Mmap, f.Drop)
|
||||
// trop juste pour tout garder en RAM : experts lus depuis le disque (le mode
|
||||
// hors RAM demande un experts.bin, qu'une installation neuve n'a pas)
|
||||
if !f.OK || !f.Mmap || f.Drop {
|
||||
t.Errorf("IQ3_XXS sur 47 Go : ok=%v mmap=%v drop=%v (attendu ok, disque)", f.OK, f.Mmap, f.Drop)
|
||||
}
|
||||
// 64 Go : IQ3_S tient en RAM
|
||||
env.RAMGB = 64
|
||||
env.RAMGB, env.AvailGB = 64, 64
|
||||
if got := strataRecommend("qwen", env); got != "IQ3_S" {
|
||||
t.Errorf("qwen 64 Go : %q, attendu IQ3_S", got)
|
||||
}
|
||||
@@ -208,22 +145,6 @@ func TestStrataPresetName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigMmapFromPreset(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p"}}
|
||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_MMAP": "1"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
if argAfter(a, "--pcie-frac") != "0" || out["env"].(map[string]any)["STRATA_ARENA_MMAP"] != "1" {
|
||||
t.Errorf("STRATA_MMAP=1 non appliqué : %v %v", a, out["env"])
|
||||
}
|
||||
out, _ = strataBuildConfig(base, map[string]string{"STRATA_MMAP": "0"}, "")
|
||||
if out["env"].(map[string]any)["STRATA_ARENA_MMAP"] != nil {
|
||||
t.Errorf("mmap appliqué sans STRATA_MMAP : %v", out["env"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataFitCountsExistingData(t *testing.T) {
|
||||
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 32,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
||||
@@ -237,7 +158,7 @@ func TestStrataFitCountsExistingData(t *testing.T) {
|
||||
|
||||
func TestStrataBuildConfigPresetOverrides(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--kv", "int8", "--spec", "4"}}
|
||||
out, _ := strataBuildConfig(base, map[string]string{"STRATA_KV": "fp16", "STRATA_SPEC": "3"}, "")
|
||||
out, _ := strataBuildConfig(base, map[string]string{"STRATA_KV": "fp16", "STRATA_SPEC": "3"}, "", "ram")
|
||||
a := strataArgs(t, out)
|
||||
if argAfter(a, "--kv") != "fp16" || argAfter(a, "--spec") != "3" {
|
||||
t.Errorf("%v", a)
|
||||
@@ -307,7 +228,7 @@ func TestStrataApplySettings(t *testing.T) {
|
||||
t.Error("carte d'aide coupée mais encore utilisée")
|
||||
}
|
||||
base := map[string]any{"args": []any{"--vision", "--pack", "/p"}, "vision": map[string]any{"exe": "v"}}
|
||||
b, _ := strataBuildConfig(base, cfg, "")
|
||||
b, _ := strataBuildConfig(base, cfg, "", "ram")
|
||||
if strataHasArg(strataArgs(t, b), "--vision") || b["vision"] != nil || strataHasArg(strataArgs(t, b), "--expert-cache-device1") {
|
||||
t.Errorf("vision / aide coupées mais présentes : %v", b)
|
||||
}
|
||||
@@ -341,3 +262,118 @@ func TestModelLoadErrorStrata(t *testing.T) {
|
||||
t.Errorf("ancienne trace prise pour l'échec en cours : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Où vont les experts : décidé sur la RAM DISPONIBLE. Vécu : 62 Go dont 18 pris
|
||||
// ailleurs, mode « hors RAM » choisi sur la RAM totale, moteur tué par le noyau.
|
||||
func TestStrataResolveExperts(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
pack := filepath.Join(home, "pack")
|
||||
_ = os.MkdirAll(pack, 0o755)
|
||||
args := []string{"--pack", pack}
|
||||
cfg := map[string]string{"STRATA_CONFIG": "/x/strata-swift-iq3_xxs.json", "STRATA_HELPER_GPU": "1", "STRATA_DROP": "1"}
|
||||
// IQ3_XXS : 42,9 Go d'experts
|
||||
cases := []struct {
|
||||
experts string
|
||||
avail float64
|
||||
want string
|
||||
}{
|
||||
{"", 44, "disk"}, // le cas vécu : 44 Go disponibles, pas de experts.bin
|
||||
{"", 60, "ram"}, // assez de RAM pour tout charger avec la marge
|
||||
{"auto", 52.8, "disk"},
|
||||
{"disk", 60, "disk"}, // choix de l'utilisateur
|
||||
{"ram", 20, "ram"}, // choix de l'utilisateur, même à l'étroit
|
||||
}
|
||||
for _, c := range cases {
|
||||
cfg["STRATA_EXPERTS"] = c.experts
|
||||
if got := strataResolveExperts(cfg, args, c.avail, 12); got != c.want {
|
||||
t.Errorf("experts=%q dispo=%.0f : %s, attendu %s", c.experts, c.avail, got, c.want)
|
||||
}
|
||||
}
|
||||
// experts.bin présent (moteur du paquet) : le mode hors RAM et le mappage deviennent possibles
|
||||
_ = os.WriteFile(filepath.Join(pack, "experts.bin"), []byte("x"), 0o644)
|
||||
_ = os.MkdirAll(filepath.Join(strataSrcDir(), "engine-cuda12"), 0o755)
|
||||
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
|
||||
cfg["STRATA_EXPERTS"] = ""
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "drop" {
|
||||
t.Errorf("44 Go + carte d'appoint 12 Go + experts.bin : %s, attendu drop", got)
|
||||
}
|
||||
if got := strataResolveExperts(cfg, args, 30, 12); got != "arena" {
|
||||
t.Errorf("30 Go + experts.bin : %s, attendu arena", got)
|
||||
}
|
||||
cfg["STRATA_HELPER"] = "0"
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "arena" {
|
||||
t.Errorf("carte d'appoint coupée : %s, attendu arena", got)
|
||||
}
|
||||
cfg["STRATA_EXPERTS"] = "disk"
|
||||
if got := strataResolveExperts(cfg, args, 60, 12); got != "arena" {
|
||||
t.Errorf("disque demandé avec experts.bin : %s, attendu arena", got)
|
||||
}
|
||||
// quant inconnu : on ne parie pas sur la RAM
|
||||
if got := strataResolveExperts(map[string]string{"STRATA_CONFIG": "/x/autre.json"}, nil, 500, 0); got != "disk" {
|
||||
t.Errorf("quant inconnu : %s, attendu disk", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigModes(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p", "--resident-experts", "--pcie-frac", "0.3"}, "env": map[string]any{"X": "1"}}
|
||||
cfg := map[string]string{"STRATA_HELPER_GPU": "1"}
|
||||
env := func(m map[string]any) map[string]any { return m["env"].(map[string]any) }
|
||||
|
||||
out, _ := strataBuildConfig(base, cfg, "", "disk")
|
||||
a := strataArgs(t, out)
|
||||
if !strataHasArg(a, "--mmap-experts") || strataHasArg(a, "--resident-experts") || argAfter(a, "--pcie-frac") != "0" ||
|
||||
env(out)["STRATA_ARENA_MMAP"] != nil || env(out)["X"] != "1" {
|
||||
t.Errorf("disk : %v %v", a, env(out))
|
||||
}
|
||||
out, _ = strataBuildConfig(base, cfg, "", "ram")
|
||||
a = strataArgs(t, out)
|
||||
if strataHasArg(a, "--mmap-experts") || strataHasArg(a, "--resident-experts") || env(out)["STRATA_ARENA_MMAP"] != nil || env(out)["STRATA_REMOTE_DROP"] != nil {
|
||||
t.Errorf("ram : %v %v", a, env(out))
|
||||
}
|
||||
out, _ = strataBuildConfig(base, cfg, "", "arena")
|
||||
if a = strataArgs(t, out); argAfter(a, "--pcie-frac") != "0" || env(out)["STRATA_ARENA_MMAP"] != "1" {
|
||||
t.Errorf("arena : %v %v", a, env(out))
|
||||
}
|
||||
out, _ = strataBuildConfig(base, cfg, "", "drop")
|
||||
if a = strataArgs(t, out); strataHasArg(a, "--pcie-frac") || env(out)["STRATA_REMOTE_DROP"] != "1" {
|
||||
t.Errorf("drop : la part PCIe doit être automatique : %v %v", a, env(out))
|
||||
}
|
||||
// drop sans carte d'appoint : rien de spécial, c'est le mode ram
|
||||
out, _ = strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "-1"}, "", "drop")
|
||||
if env(out)["STRATA_REMOTE_DROP"] != nil {
|
||||
t.Error("mode hors RAM sans carte d'appoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataApplySettingsExperts(t *testing.T) {
|
||||
in := "ENGINE=strata\nSTRATA_EXPERTS=auto\n"
|
||||
r := strataSettingsReq{Ctx: 131072, KV: "fp16", Spec: 3, Experts: "disk"}
|
||||
out, err := strataApplySettings(in, r)
|
||||
if err != nil || parseEnv(out)["STRATA_EXPERTS"] != "disk" {
|
||||
t.Fatalf("%v %q", err, out)
|
||||
}
|
||||
r.Experts = "nvme"
|
||||
if _, err := strataApplySettings(in, r); err == nil {
|
||||
t.Error("placement inconnu accepté")
|
||||
}
|
||||
r.Experts = ""
|
||||
if out, _ := strataApplySettings(in, r); parseEnv(out)["STRATA_EXPERTS"] != "auto" {
|
||||
t.Errorf("vide doit laisser le choix en place : %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Vécu : bascule de Strata vers llama-server, l'encodeur de vision ne trouve
|
||||
// plus de VRAM. La pile d'appels cite « load_model » : elle ne doit pas passer
|
||||
// pour une nouvelle tentative qui effacerait la cause, écrite juste avant.
|
||||
func TestModelLoadErrorOOMBeforeBacktrace(t *testing.T) {
|
||||
log := "0.00.247.285 I srv load_model: loading model '/data/models/Swift.gguf'\n" +
|
||||
"4.47.957.764 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 884.62 MiB on device 0: cudaMalloc failed: out of memory\n" +
|
||||
"/app/ggml/src/ggml-backend.cpp:328: GGML_ASSERT(buffer) failed\n" +
|
||||
"/data/engine/libmtmd.so.0(mtmd_init_from_file+0x31)[0x14bd4fea4c01]\n" +
|
||||
"/data/engine/libllama-server-impl.so(_ZN19server_context_impl10load_modelER13common_params+0x7ff)[0x14bd50b9f1ff]\n" +
|
||||
"/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x14bd5059a28b]\n"
|
||||
if got := modelLoadErrorFrom(log); !strings.Contains(got, "mémoire GPU insuffisante") {
|
||||
t.Errorf("cause mémoire perdue derrière la pile d'appels : %q", got)
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,15 @@ func tuneActive() (tuneOwner, bool) {
|
||||
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
|
||||
var tuneHeld sync.Map
|
||||
|
||||
// tuneFileMu sérialise, DANS CE PROCESSUS, les opérations sur le fichier de
|
||||
// verrou : prise, réécriture, rendu, et le déplacement-relecture du ménage
|
||||
// (tuneReapStale). Sans lui, le ménage pouvait déplacer un verrou vivant
|
||||
// pendant que release() le cherchait : release, ne le trouvant plus, rendait
|
||||
// l'inscription ; le ménage le remettait ensuite en place, désormais orphelin,
|
||||
// et le ménage suivant le prenait pour une optimisation interrompue (moteur
|
||||
// relancé, preset « rétabli »). Vu en CI : TestTuneRecoverNeverReapsOwnLock.
|
||||
var tuneFileMu sync.Mutex
|
||||
|
||||
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d|%d", o.Via, o.Since, o.Nonce) }
|
||||
|
||||
// tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui
|
||||
@@ -201,6 +210,7 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
||||
key := tuneHeldKey(l.owner)
|
||||
tuneHeld.Store(key, true)
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
tuneFileMu.Lock()
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err == nil {
|
||||
b, _ := json.Marshal(l.owner)
|
||||
@@ -209,11 +219,14 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
||||
if werr != nil || cerr != nil {
|
||||
_ = os.Remove(path)
|
||||
tuneHeld.Delete(key)
|
||||
tuneFileMu.Unlock()
|
||||
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
|
||||
}
|
||||
l.held = true
|
||||
tuneFileMu.Unlock()
|
||||
return l, nil
|
||||
}
|
||||
tuneFileMu.Unlock()
|
||||
if !os.IsExist(err) {
|
||||
tuneHeld.Delete(key)
|
||||
return nil, err
|
||||
@@ -232,6 +245,13 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
||||
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
|
||||
// lecteur ne voit jamais un JSON à moitié écrit.
|
||||
func (l *tuneLock) write() error {
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
return l.writeLocked()
|
||||
}
|
||||
|
||||
// writeLocked : write, tuneFileMu déjà tenu par l'appelant.
|
||||
func (l *tuneLock) writeLocked() error {
|
||||
b, err := json.Marshal(l.owner)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -291,6 +311,8 @@ func (l *tuneLock) setBackup(backup, id, name string) {
|
||||
func (l *tuneLock) release() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
if l.held {
|
||||
// Un fichier absent un instant (renommé par un ménage qui le remet en
|
||||
// place, voir tuneReapStale) n'est pas un fichier rendu : on relit.
|
||||
@@ -305,7 +327,7 @@ func (l *tuneLock) release() {
|
||||
if ours {
|
||||
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
|
||||
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
|
||||
_ = l.write()
|
||||
_ = l.writeLocked()
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
|
||||
break
|
||||
@@ -328,6 +350,24 @@ func (l *tuneLock) release() {
|
||||
// périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou
|
||||
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
|
||||
func tuneReapStale() (mainWasActive bool) {
|
||||
o, ok := tuneReapClaim()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if o.Trial != nil && tuneProcAlive(*o.Trial) {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
|
||||
tuneKillTree(o.Trial.PID, nil)
|
||||
}
|
||||
tuneUndoApply(o)
|
||||
return o.MainWasActive
|
||||
}
|
||||
|
||||
// tuneReapClaim : la partie de tuneReapStale qui touche au fichier, sous
|
||||
// tuneFileMu — lecture, déplacement, relecture, remise en place. Rend le
|
||||
// verrou périmé effectivement retiré (ok=false : rien à écarter).
|
||||
func tuneReapClaim() (tuneOwner, bool) {
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
path := tuneLockPath()
|
||||
o, ok := readTuneOwner(path)
|
||||
if !ok {
|
||||
@@ -338,14 +378,14 @@ func tuneReapStale() (mainWasActive bool) {
|
||||
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
if tuneOwnerAlive(o) {
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
|
||||
if os.Rename(path, stale) != nil {
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
defer os.Remove(stale)
|
||||
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un
|
||||
@@ -358,14 +398,9 @@ func tuneReapStale() (mainWasActive bool) {
|
||||
if err := os.Link(stale, path); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] verrou vivant déplacé par erreur, remise en place impossible : %v\n", err)
|
||||
}
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
if o.Trial != nil && tuneProcAlive(*o.Trial) {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
|
||||
tuneKillTree(o.Trial.PID, nil)
|
||||
}
|
||||
tuneUndoApply(o)
|
||||
return o.MainWasActive
|
||||
return o, true
|
||||
}
|
||||
|
||||
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
|
||||
|
||||
@@ -1069,6 +1069,12 @@ func friendlyLLMError(err error) error {
|
||||
}
|
||||
|
||||
func errEngineDown() error {
|
||||
// Un refus de connexion ne veut pas toujours dire « il charge encore » : si
|
||||
// le journal montre que le dernier lancement a échoué, c'est cela qu'il faut
|
||||
// dire — réessayer dans quelques secondes n'y changera rien.
|
||||
if msg := modelLoadError(); msg != "" {
|
||||
return fmt.Errorf("⚠️ %s", msg)
|
||||
}
|
||||
return fmt.Errorf("⚠️ Le moteur (llama-server) ne répond pas sur le port %d. Il est probablement en train de démarrer ou de charger le modèle — réessaie dans quelques secondes.", LLMPort())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// procRef : un processus repéré par son PID ET son instant de démarrage — un
|
||||
// PID recyclé pendant l'attente ne passe pas pour le même processus.
|
||||
type procRef struct {
|
||||
pid int
|
||||
start string
|
||||
}
|
||||
|
||||
// procStat : les champs de /proc/<pid>/stat utiles ici.
|
||||
type procStat struct {
|
||||
state string
|
||||
ppid, pgrp, sid int
|
||||
start string
|
||||
}
|
||||
|
||||
// readProcStat lit /proc/<pid>/stat. Le nom (champ 2, entre parenthèses) peut
|
||||
// contenir des blancs et des parenthèses : on repart de la DERNIÈRE fermante.
|
||||
func readProcStat(pid int) (procStat, bool) {
|
||||
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||
if err != nil {
|
||||
return procStat{}, false
|
||||
}
|
||||
return parseProcStat(string(b))
|
||||
}
|
||||
|
||||
func parseProcStat(s string) (procStat, bool) {
|
||||
i := strings.LastIndexByte(s, ')')
|
||||
if i < 0 {
|
||||
return procStat{}, false
|
||||
}
|
||||
// f[0] est le champ 3 (état), f[1] ppid, f[2] groupe, f[3] session, f[19]
|
||||
// l'instant de démarrage (champ 22).
|
||||
f := strings.Fields(s[i+1:])
|
||||
if len(f) < 20 {
|
||||
return procStat{}, false
|
||||
}
|
||||
ppid, _ := strconv.Atoi(f[1])
|
||||
pgrp, _ := strconv.Atoi(f[2])
|
||||
sid, _ := strconv.Atoi(f[3])
|
||||
return procStat{state: f[0], ppid: ppid, pgrp: pgrp, sid: sid, start: f[19]}, true
|
||||
}
|
||||
|
||||
// procTable : tous les processus vivants (les zombies ont fini de tourner : leur
|
||||
// mémoire, VRAM comprise, est déjà rendue).
|
||||
func procTable() map[int]procStat {
|
||||
ents, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
t := make(map[int]procStat, len(ents))
|
||||
for _, e := range ents {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if st, ok := readProcStat(pid); ok && st.state != "Z" {
|
||||
t[pid] = st
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// procTreeOf : le chef root et tout ce qui en descend — sa session et son
|
||||
// groupe (Setsid au lancement du service), plus les descendants par la
|
||||
// filiation, pour un enfant qui aurait pris sa propre session (le serveur
|
||||
// Python de Strata lance son moteur dans un processus à part). À relever AVANT
|
||||
// d'envoyer le moindre signal : une fois le chef mort, ses orphelins sont
|
||||
// rattachés à PID 1 et la filiation est perdue.
|
||||
func procTreeOf(t map[int]procStat, root, self int) []procRef {
|
||||
in := map[int]bool{}
|
||||
for pid, st := range t {
|
||||
if pid != self && (pid == root || st.sid == root || st.pgrp == root) {
|
||||
in[pid] = true
|
||||
}
|
||||
}
|
||||
// Fermeture par la filiation, jusqu'à ce que plus rien ne s'ajoute.
|
||||
for grew := true; grew; {
|
||||
grew = false
|
||||
for pid, st := range t {
|
||||
if !in[pid] && pid != self && in[st.ppid] {
|
||||
in[pid] = true
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
refs := make([]procRef, 0, len(in))
|
||||
for pid := range in {
|
||||
refs = append(refs, procRef{pid: pid, start: t[pid].start})
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// procRefAlive : le processus tourne encore (ni disparu, ni zombie, ni
|
||||
// remplacé par un autre sous le même PID).
|
||||
func procRefAlive(r procRef) bool {
|
||||
st, ok := readProcStat(r.pid)
|
||||
return ok && st.state != "Z" && st.start == r.start
|
||||
}
|
||||
|
||||
func procRefsAlive(refs []procRef) []procRef {
|
||||
var alive []procRef
|
||||
for _, r := range refs {
|
||||
if procRefAlive(r) {
|
||||
alive = append(alive, r)
|
||||
}
|
||||
}
|
||||
return alive
|
||||
}
|
||||
|
||||
// stopProcTree arrête le service lancé avec Setsid (pid = chef de session) et
|
||||
// N'EN REVIENT QU'UNE FOIS TOUT L'ARBRE PARTI.
|
||||
//
|
||||
// Attendre le seul chef ne suffit pas : avec Strata, le chef est le serveur
|
||||
// Python, et le moteur qui tient la VRAM (cache d'experts, plusieurs Go) est un
|
||||
// autre processus qui finit de s'arrêter après lui. La relance qui suivait
|
||||
// 500 ms plus tard démarrait alors llama-server sur une carte encore pleine —
|
||||
// « cudaMalloc failed: out of memory » sur un modèle qui tient pourtant — et
|
||||
// Strata mesurait la RAM disponible avec l'ancien moteur encore chargé.
|
||||
//
|
||||
// SIGTERM à tous, grace pour finir proprement, SIGKILL aux survivants, puis
|
||||
// settle au plus pour que le noyau les ait vraiment retirés (un processus
|
||||
// coincé dans une lecture disque ne meurt qu'à la sortie de l'appel système).
|
||||
// Renvoie ceux qui tournent encore au-delà (rien à faire de plus, mais le dire).
|
||||
func stopProcTree(pid int, grace, settle time.Duration) []procRef {
|
||||
refs := procTreeOf(procTable(), pid, os.Getpid())
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
|
||||
_ = syscall.Kill(pid, syscall.SIGTERM)
|
||||
}
|
||||
for _, r := range refs {
|
||||
_ = syscall.Kill(r.pid, syscall.SIGTERM)
|
||||
}
|
||||
if left := waitProcRefs(refs, grace); len(left) == 0 {
|
||||
return nil
|
||||
}
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
for _, r := range procRefsAlive(refs) {
|
||||
_ = syscall.Kill(r.pid, syscall.SIGKILL)
|
||||
}
|
||||
return waitProcRefs(refs, settle)
|
||||
}
|
||||
|
||||
// waitProcRefs attend que tous aient disparu, au plus wait. Renvoie les survivants.
|
||||
func waitProcRefs(refs []procRef, wait time.Duration) []procRef {
|
||||
deadline := time.Now().Add(wait)
|
||||
for {
|
||||
left := procRefsAlive(refs)
|
||||
if len(left) == 0 || time.Now().After(deadline) {
|
||||
return left
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseProcStat(t *testing.T) {
|
||||
st, ok := parseProcStat("4242 (python3 (x) y) S 4200 4100 4000 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 987654 0 0")
|
||||
if !ok || st.state != "S" || st.ppid != 4200 || st.pgrp != 4100 || st.sid != 4000 || st.start != "987654" {
|
||||
t.Fatalf("lecture fausse : %+v ok=%v", st, ok)
|
||||
}
|
||||
if _, ok := parseProcStat("4242 (tronqué"); ok {
|
||||
t.Error("ligne sans parenthèse fermante acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcTreeOf(t *testing.T) {
|
||||
tab := map[int]procStat{
|
||||
100: {ppid: 1, pgrp: 100, sid: 100, start: "a"}, // chef (Setsid)
|
||||
101: {ppid: 100, pgrp: 100, sid: 100, start: "b"},
|
||||
200: {ppid: 101, pgrp: 200, sid: 200, start: "c"}, // moteur dans sa propre session
|
||||
201: {ppid: 200, pgrp: 200, sid: 200, start: "d"},
|
||||
300: {ppid: 1, pgrp: 300, sid: 300, start: "e"}, // étranger
|
||||
400: {ppid: 1, pgrp: 100, sid: 100, start: "f"}, // orphelin rattaché à PID 1
|
||||
500: {ppid: 100, pgrp: 100, sid: 100, start: "g"}, // nous-mêmes
|
||||
}
|
||||
got := map[int]bool{}
|
||||
for _, r := range procTreeOf(tab, 100, 500) {
|
||||
got[r.pid] = true
|
||||
}
|
||||
for _, pid := range []int{100, 101, 200, 201, 400} {
|
||||
if !got[pid] {
|
||||
t.Errorf("PID %d oublié", pid)
|
||||
}
|
||||
}
|
||||
if got[300] || got[500] {
|
||||
t.Errorf("hors de l'arbre pris : %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Le chef meurt vite, son enfant (dans une autre session, comme le moteur de
|
||||
// Strata) traîne à s'arrêter : stopProcTree ne revient qu'une fois tout parti.
|
||||
func TestStopProcTreeWaitsForChildren(t *testing.T) {
|
||||
// Le chef lance un enfant qui ignore SIGTERM dans sa propre session, écrit
|
||||
// son PID, puis attend.
|
||||
pidFile := t.TempDir() + "/child"
|
||||
script := `setsid sh -c 'trap "" TERM; echo $$ > ` + pidFile + `; while :; do sleep 0.1; done' & wait`
|
||||
cmd := exec.Command("sh", "-c", script)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Skip("sh indisponible :", err)
|
||||
}
|
||||
go cmd.Wait() // pas de zombie pour le chef
|
||||
var child int
|
||||
for i := 0; i < 50 && child == 0; i++ {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if b, err := os.ReadFile(pidFile); err == nil {
|
||||
child, _ = strconv.Atoi(strings.TrimSpace(string(b)))
|
||||
}
|
||||
}
|
||||
if child == 0 {
|
||||
t.Fatal("l'enfant n'a pas démarré")
|
||||
}
|
||||
st, ok := readProcStat(child)
|
||||
if !ok || st.sid == cmd.Process.Pid {
|
||||
t.Fatalf("l'enfant devait avoir sa propre session : %+v", st)
|
||||
}
|
||||
ref := procRef{pid: child, start: st.start}
|
||||
if left := stopProcTree(cmd.Process.Pid, 300*time.Millisecond, 5*time.Second); len(left) != 0 {
|
||||
t.Fatalf("survivants : %v", left)
|
||||
}
|
||||
if procRefAlive(ref) {
|
||||
t.Fatal("l'enfant tourne encore au retour de stopProcTree")
|
||||
}
|
||||
}
|
||||
@@ -125,25 +125,25 @@ func userCheckStarted(pid int) error {
|
||||
|
||||
func userSvcStop(verbose bool) error {
|
||||
pid := readServicePID()
|
||||
if pid <= 0 || !processAlive(pid) {
|
||||
// Le chef parti, ses enfants peuvent tourner encore (moteur de Strata) :
|
||||
// on ne conclut « rien à arrêter » que si l'arbre entier est vide.
|
||||
if pid <= 0 || len(procTreeOf(procTable(), pid, os.Getpid())) == 0 {
|
||||
_ = os.Remove(pidFilePath())
|
||||
if verbose {
|
||||
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Setsid a fait de l'enfant un chef de groupe : le PID négatif vise le
|
||||
// groupe entier, donc llama-server s'arrête avec lui.
|
||||
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
|
||||
_ = syscall.Kill(pid, syscall.SIGTERM)
|
||||
}
|
||||
for i := 0; i < 40 && processAlive(pid); i++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if processAlive(pid) {
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
}
|
||||
// Setsid a fait de l'enfant un chef de session : tout ce qu'il a lancé
|
||||
// (llama-server, serveur et moteur de Strata) s'arrête avec lui, et on
|
||||
// attend qu'il soit VRAIMENT parti — la relance qui suit en a besoin pour
|
||||
// trouver la VRAM et la RAM libres.
|
||||
left := stopProcTree(pid, 4*time.Second, 20*time.Second)
|
||||
_ = os.Remove(pidFilePath())
|
||||
if len(left) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "%s %d processus du moteur toujours là après SIGKILL (PID %d…) : "+
|
||||
"la mémoire du GPU peut ne pas être encore rendue\n", yellow("[attention]"), len(left), left[0].pid)
|
||||
}
|
||||
if verbose {
|
||||
fmt.Println(green("[ok]") + " arrêté")
|
||||
}
|
||||
|
||||
@@ -4521,7 +4521,7 @@ async function loadCfg(){
|
||||
rows.push(row('CTX', d.ctx));
|
||||
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
|
||||
rows.push(row('MTP', d.spec));
|
||||
rows.push(row('EXPERTS', d.drop ? 'mlock' : d.mmap ? 'mmap' : 'RAM'));
|
||||
rows.push(row('EXPERTS', ({drop:'mlock (hors appoint)', ram:'RAM', arena:'experts.bin', disk:'mmap (disque)'})[d.mode] || (d.mmap ? 'mmap' : 'RAM'), d.mode_label||''));
|
||||
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
|
||||
}else if(c.CTX) rows.push(row('CTX', c.CTX));
|
||||
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
|
||||
@@ -6635,7 +6635,10 @@ async function populateDraft(){
|
||||
if(!sel) return;
|
||||
const list = await jget('/api/models');
|
||||
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
|
||||
const items = (list||[]).filter(m => !isMmprojName(m.name));
|
||||
// Le modèle principal n'est pas un brouillon : le choisir rechargeait tous
|
||||
// ses poids une seconde fois (-md), quand sa tête MTP s'utilise sans.
|
||||
const main = matchModel(currentModelInTextarea(), list);
|
||||
const items = (list||[]).filter(m => !isMmprojName(m.name) && m !== main);
|
||||
const hit = matchModel(cur, items);
|
||||
let html = '<option value="">— aucun —</option>';
|
||||
let matched = false;
|
||||
@@ -6645,7 +6648,9 @@ async function populateDraft(){
|
||||
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||
}
|
||||
if(cur && !matched){
|
||||
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
|
||||
const self = main && matchModel(cur, [main]);
|
||||
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+
|
||||
(self ? ' (le modèle lui-même : ignoré, choisis « aucun »)' : ' (introuvable)')+'</option>';
|
||||
}
|
||||
sel.innerHTML = html;
|
||||
syncSpecRow();
|
||||
@@ -9188,8 +9193,9 @@ async function openStrata(presetId){
|
||||
.sort((a,b)=>(a.index===env.main?-1:1))
|
||||
.map(g=>g.name.replace(/^NVIDIA GeForce /,'')+' '+Math.round(g.vram_gb)+' Go');
|
||||
document.getElementById('strata-machine').textContent =
|
||||
'Machine : '+cards.join(' + ')+', '+Math.round(env.ram_gb)+' Go de RAM, '
|
||||
+Math.round(env.disk_free_gb)+' Go libres. Réglages automatiques.';
|
||||
'Machine : '+cards.join(' + ')+', '+Math.round(env.ram_gb)+' Go de RAM dont '
|
||||
+Math.round(env.avail_gb||env.ram_gb)+' disponibles, '
|
||||
+Math.round(env.disk_free_gb)+' Go de disque libres. Réglages automatiques.';
|
||||
if(!strataFam) strataFam = (s.families[0]||{}).id || '';
|
||||
if(strataTarget){
|
||||
for(const f of s.families){
|
||||
@@ -9241,7 +9247,7 @@ function strataRenderQuants(pickReco){
|
||||
strataRenderDetails(fit);
|
||||
document.getElementById('strata-note').textContent = !fit ? 'Aucune version ne tient sur cette machine.'
|
||||
: fit.active ? 'Modèle actif.' : fit.preset ? 'Modèle installé, prêt à être activé.'
|
||||
: fit.mmap ? 'RAM insuffisante pour tout charger : experts lus depuis le disque (mmap, ~45 Go de plus). Installation et activation automatiques.'
|
||||
: fit.mmap ? 'RAM disponible insuffisante pour tout charger : les experts seront lus depuis le disque (mmap, un peu plus lent). Installation et activation automatiques.'
|
||||
: 'Installation et activation automatiques. Premier chargement : quelques minutes.';
|
||||
}
|
||||
|
||||
@@ -9270,6 +9276,9 @@ function strataRenderSettings(fit){
|
||||
['Contexte', '<span class="moe-ctx"><input type="range" id="strata-s-ctx" min="32768" max="262144" step="4096" value="'+(+d.ctx||131072)+'" oninput="strataCtxLbl()"><span id="strata-s-ctx-v"></span></span>'],
|
||||
['Cache KV', sel('strata-s-kv', [['fp16', 'fp16 (meilleure qualité)'],['int8', '8 bits (plus léger)']], d.kv)],
|
||||
['Prédiction (MTP)', sel('strata-s-spec', [[2,'2'],[3,'3'],[4,'4']], d.spec)],
|
||||
// Placement des experts : auto décide AU LANCEMENT sur la RAM disponible ;
|
||||
// « disque » ne charge rien en RAM au démarrage (jamais tué faute de RAM).
|
||||
['Experts', sel('strata-s-experts', [['auto','auto (selon la RAM libre)'],['ram','en RAM (le plus rapide)'],['disk','depuis le disque (mmap, économe)']], d.experts||'auto')],
|
||||
['Lire les images', sw('strata-s-vision', d.vision)],
|
||||
];
|
||||
if(d.has_helper) rows.push(["Carte d'appoint", sw('strata-s-helper', d.helper_on)]);
|
||||
@@ -9292,7 +9301,8 @@ async function strataSaveSettings(){
|
||||
if(!fit || !fit.preset) return;
|
||||
const v = id => document.getElementById(id);
|
||||
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
|
||||
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false};
|
||||
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
|
||||
experts: v('strata-s-experts') ? v('strata-s-experts').value : ''};
|
||||
if(fit.active && !await askConfirm('Le modèle va redémarrer avec ces réglages (quelques minutes).', {title:'Strata — Qwen3.8 Flash Next', okText:'Enregistrer'})) return;
|
||||
const r = await jpost('/api/strata/settings', body);
|
||||
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
|
||||
@@ -9316,7 +9326,8 @@ function strataRenderDetails(fit){
|
||||
['Contexte', (+d.ctx).toLocaleString('fr-FR')+' jetons'],
|
||||
['Cache KV', d.kv + (d.kv_resident ? ' · '+k(d.kv_resident)+' en VRAM, le reste en RAM' : '')],
|
||||
['Prédiction (MTP)', d.spec+' jetons'],
|
||||
['Experts hors GPU', d.drop ? "verrouillés en RAM, hors ceux de la carte d'appoint" : d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'],
|
||||
['Experts hors GPU', (d.mode_label || (d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'))
|
||||
+ (fit.active ? ' — dernier lancement' : ' — selon la RAM libre maintenant')],
|
||||
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
|
||||
["Démarrage d'une conversation", 'reprise après le prompt système (dès '+d.cache_root+' jetons)'],
|
||||
['Version du moteur', 'paquet AJEAN MoE '+d.version],
|
||||
|
||||
@@ -330,7 +330,7 @@ async function loadCfg(){
|
||||
rows.push(row('CTX', d.ctx));
|
||||
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
|
||||
rows.push(row('MTP', d.spec));
|
||||
rows.push(row('EXPERTS', d.drop ? 'mlock' : d.mmap ? 'mmap' : 'RAM'));
|
||||
rows.push(row('EXPERTS', ({drop:'mlock (hors appoint)', ram:'RAM', arena:'experts.bin', disk:'mmap (disque)'})[d.mode] || (d.mmap ? 'mmap' : 'RAM'), d.mode_label||''));
|
||||
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
|
||||
}else if(c.CTX) rows.push(row('CTX', c.CTX));
|
||||
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
|
||||
|
||||
@@ -1383,7 +1383,10 @@ async function populateDraft(){
|
||||
if(!sel) return;
|
||||
const list = await jget('/api/models');
|
||||
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
|
||||
const items = (list||[]).filter(m => !isMmprojName(m.name));
|
||||
// Le modèle principal n'est pas un brouillon : le choisir rechargeait tous
|
||||
// ses poids une seconde fois (-md), quand sa tête MTP s'utilise sans.
|
||||
const main = matchModel(currentModelInTextarea(), list);
|
||||
const items = (list||[]).filter(m => !isMmprojName(m.name) && m !== main);
|
||||
const hit = matchModel(cur, items);
|
||||
let html = '<option value="">— aucun —</option>';
|
||||
let matched = false;
|
||||
@@ -1393,7 +1396,9 @@ async function populateDraft(){
|
||||
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||
}
|
||||
if(cur && !matched){
|
||||
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
|
||||
const self = main && matchModel(cur, [main]);
|
||||
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+
|
||||
(self ? ' (le modèle lui-même : ignoré, choisis « aucun »)' : ' (introuvable)')+'</option>';
|
||||
}
|
||||
sel.innerHTML = html;
|
||||
syncSpecRow();
|
||||
|
||||
@@ -40,8 +40,9 @@ async function openStrata(presetId){
|
||||
.sort((a,b)=>(a.index===env.main?-1:1))
|
||||
.map(g=>g.name.replace(/^NVIDIA GeForce /,'')+' '+Math.round(g.vram_gb)+' Go');
|
||||
document.getElementById('strata-machine').textContent =
|
||||
'Machine : '+cards.join(' + ')+', '+Math.round(env.ram_gb)+' Go de RAM, '
|
||||
+Math.round(env.disk_free_gb)+' Go libres. Réglages automatiques.';
|
||||
'Machine : '+cards.join(' + ')+', '+Math.round(env.ram_gb)+' Go de RAM dont '
|
||||
+Math.round(env.avail_gb||env.ram_gb)+' disponibles, '
|
||||
+Math.round(env.disk_free_gb)+' Go de disque libres. Réglages automatiques.';
|
||||
if(!strataFam) strataFam = (s.families[0]||{}).id || '';
|
||||
if(strataTarget){
|
||||
for(const f of s.families){
|
||||
@@ -93,7 +94,7 @@ function strataRenderQuants(pickReco){
|
||||
strataRenderDetails(fit);
|
||||
document.getElementById('strata-note').textContent = !fit ? 'Aucune version ne tient sur cette machine.'
|
||||
: fit.active ? 'Modèle actif.' : fit.preset ? 'Modèle installé, prêt à être activé.'
|
||||
: fit.mmap ? 'RAM insuffisante pour tout charger : experts lus depuis le disque (mmap, ~45 Go de plus). Installation et activation automatiques.'
|
||||
: fit.mmap ? 'RAM disponible insuffisante pour tout charger : les experts seront lus depuis le disque (mmap, un peu plus lent). Installation et activation automatiques.'
|
||||
: 'Installation et activation automatiques. Premier chargement : quelques minutes.';
|
||||
}
|
||||
|
||||
@@ -122,6 +123,9 @@ function strataRenderSettings(fit){
|
||||
['Contexte', '<span class="moe-ctx"><input type="range" id="strata-s-ctx" min="32768" max="262144" step="4096" value="'+(+d.ctx||131072)+'" oninput="strataCtxLbl()"><span id="strata-s-ctx-v"></span></span>'],
|
||||
['Cache KV', sel('strata-s-kv', [['fp16', 'fp16 (meilleure qualité)'],['int8', '8 bits (plus léger)']], d.kv)],
|
||||
['Prédiction (MTP)', sel('strata-s-spec', [[2,'2'],[3,'3'],[4,'4']], d.spec)],
|
||||
// Placement des experts : auto décide AU LANCEMENT sur la RAM disponible ;
|
||||
// « disque » ne charge rien en RAM au démarrage (jamais tué faute de RAM).
|
||||
['Experts', sel('strata-s-experts', [['auto','auto (selon la RAM libre)'],['ram','en RAM (le plus rapide)'],['disk','depuis le disque (mmap, économe)']], d.experts||'auto')],
|
||||
['Lire les images', sw('strata-s-vision', d.vision)],
|
||||
];
|
||||
if(d.has_helper) rows.push(["Carte d'appoint", sw('strata-s-helper', d.helper_on)]);
|
||||
@@ -144,7 +148,8 @@ async function strataSaveSettings(){
|
||||
if(!fit || !fit.preset) return;
|
||||
const v = id => document.getElementById(id);
|
||||
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
|
||||
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false};
|
||||
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
|
||||
experts: v('strata-s-experts') ? v('strata-s-experts').value : ''};
|
||||
if(fit.active && !await askConfirm('Le modèle va redémarrer avec ces réglages (quelques minutes).', {title:'Strata — Qwen3.8 Flash Next', okText:'Enregistrer'})) return;
|
||||
const r = await jpost('/api/strata/settings', body);
|
||||
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
|
||||
@@ -168,7 +173,8 @@ function strataRenderDetails(fit){
|
||||
['Contexte', (+d.ctx).toLocaleString('fr-FR')+' jetons'],
|
||||
['Cache KV', d.kv + (d.kv_resident ? ' · '+k(d.kv_resident)+' en VRAM, le reste en RAM' : '')],
|
||||
['Prédiction (MTP)', d.spec+' jetons'],
|
||||
['Experts hors GPU', d.drop ? "verrouillés en RAM, hors ceux de la carte d'appoint" : d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'],
|
||||
['Experts hors GPU', (d.mode_label || (d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'))
|
||||
+ (fit.active ? ' — dernier lancement' : ' — selon la RAM libre maintenant')],
|
||||
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
|
||||
["Démarrage d'une conversation", 'reprise après le prompt système (dès '+d.cache_root+' jetons)'],
|
||||
['Version du moteur', 'paquet AJEAN MoE '+d.version],
|
||||
|
||||
@@ -107,6 +107,13 @@ func modelLoadErrorFrom(log string) string {
|
||||
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
|
||||
start := 0
|
||||
for i, l := range lines {
|
||||
// Une pile d'appels cite les fonctions traversées
|
||||
// (…server_context_impl10load_model…) : ce n'est pas une nouvelle
|
||||
// tentative, et repartir de là perdait la cause écrite juste avant
|
||||
// (« cudaMalloc failed: out of memory » devenait « a planté »).
|
||||
if isBacktraceFrame(l) {
|
||||
continue
|
||||
}
|
||||
// Un lancement refusé par LOAD_GUARD (backend_serve_moe.go) s'arrête
|
||||
// avant tout chargement : c'est lui, la dernière tentative.
|
||||
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") || strings.Contains(l, loadGuardMarker) ||
|
||||
@@ -159,7 +166,8 @@ func modelLoadErrorFrom(log string) string {
|
||||
strings.Contains(low, "cudamalloc failed"),
|
||||
strings.Contains(low, "failed to allocate"):
|
||||
// Prioritaire sur l'échec générique : la cause mémoire doit gagner.
|
||||
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite"
|
||||
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite, " +
|
||||
"et vérifie qu'aucun autre programme n'occupe la carte (nvidia-smi)"
|
||||
case strings.Contains(low, "ggml_cuda error"), strings.Contains(low, "cuda error"):
|
||||
if reason == "" {
|
||||
reason = "erreur CUDA — GPU indisponible ? (après un redémarrage du serveur : docker restart loki)"
|
||||
@@ -196,6 +204,10 @@ func modelLoadErrorFrom(log string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// isBacktraceFrame : une ligne de pile d'appels de glibc
|
||||
// (« /chemin/lib.so(symbole+0x1c)[0x14bd…] »).
|
||||
func isBacktraceFrame(l string) bool { return strings.Contains(l, ")[0x") }
|
||||
|
||||
// handleServiceLog (GET /api/service/log) : dernières lignes du journal du
|
||||
// service, pour que l'UI puisse montrer POURQUOI le modèle ne se charge pas
|
||||
// (fichier .gguf illisible, VRAM insuffisante, bibliothèque manquante…) au lieu
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// La façade Rust lance `loki web` derrière elle : le Go ne doit alors écouter
|
||||
// qu'en local, sinon il resterait joignable en direct, sans la façade.
|
||||
func TestWebBindAddr(t *testing.T) {
|
||||
t.Setenv("LOKI_WEB_BIND", "")
|
||||
if got := webBindAddr(8090); got != "0.0.0.0:8090" {
|
||||
t.Fatalf("défaut : %q", got)
|
||||
}
|
||||
t.Setenv("LOKI_WEB_BIND", "127.0.0.1")
|
||||
if got := webBindAddr(8091); got != "127.0.0.1:8091" {
|
||||
t.Fatalf("local : %q", got)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func cmdWeb(args []string) error {
|
||||
port = n
|
||||
}
|
||||
mux := newWebMux()
|
||||
addr := fmt.Sprintf("0.0.0.0:%d", port)
|
||||
addr := webBindAddr(port)
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
@@ -100,6 +100,17 @@ func cmdWeb(args []string) error {
|
||||
return srv.Serve(ln)
|
||||
}
|
||||
|
||||
// webBindAddr renvoie l'adresse d'écoute de l'UI. LOKI_WEB_BIND (défaut
|
||||
// 0.0.0.0) existe pour la façade Rust du conteneur (rust/loki-rs) : elle prend
|
||||
// le port public et lance `loki web` en local seulement, sur 127.0.0.1.
|
||||
func webBindAddr(port int) string {
|
||||
host := strings.TrimSpace(os.Getenv("LOKI_WEB_BIND"))
|
||||
if host == "" {
|
||||
host = "0.0.0.0"
|
||||
}
|
||||
return net.JoinHostPort(host, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
// newWebMux construit le routeur HTTP de l'UI web. Extrait de cmdWeb pour être
|
||||
// réutilisé par `loki link`, qui sert ce même mux à travers le tunnel sans
|
||||
// repasser par un écouteur TCP local.
|
||||
|
||||
Generated
+670
@@ -0,0 +1,670 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"hyper",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "loki-rs"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"sha1",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
|
||||
dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,7 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["loki-rs"]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = "thin"
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "loki-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bytes = "1.12.1"
|
||||
hex = "0.4.3"
|
||||
http-body-util = "0.1.5"
|
||||
hyper = { version = "1.12.0", features = ["http1", "server", "client"] }
|
||||
hyper-util = { version = "0.1.21", features = ["tokio", "server", "client-legacy", "http1"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
sha2 = "0.11.1"
|
||||
tokio = { version = "1.53.2", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "signal", "process", "sync", "fs"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
futures-util = "0.3.34"
|
||||
tempfile = "3.27.0"
|
||||
tokio-tungstenite = "0.30.0"
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Supervision du serveur Go (`loki web`) lancé derrière la façade.
|
||||
//!
|
||||
//! Le Go reste propriétaire de presque tout au lot 1 : s'il meurt, la façade
|
||||
//! le relance (attente croissante 1 s → 30 s, remise à 1 s après une minute
|
||||
//! de vie) et répond 502 entre-temps au lieu de tomber avec lui.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
const MIN_DELAY: Duration = Duration::from_secs(1);
|
||||
const MAX_DELAY: Duration = Duration::from_secs(30);
|
||||
const STABLE_AFTER: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Attente avant la relance suivante, connaissant la précédente et la durée
|
||||
/// de vie du processus qui vient de mourir.
|
||||
pub fn next_delay(prev: Duration, lived: Duration) -> Duration {
|
||||
if lived >= STABLE_AFTER {
|
||||
MIN_DELAY
|
||||
} else {
|
||||
(prev * 2).clamp(MIN_DELAY, MAX_DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
/// Binaire Go à lancer : `LOKI_GO_BIN`, sinon `loki` à côté de l'exécutable
|
||||
/// courant (les deux viennent toujours du même dossier, cf. `which-bin`).
|
||||
pub fn go_bin(env_value: Option<String>, exe: &Path) -> PathBuf {
|
||||
if let Some(p) = env_value.filter(|v| !v.trim().is_empty()) {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
let name = if cfg!(windows) { "loki.exe" } else { "loki" };
|
||||
exe.parent().unwrap_or(Path::new(".")).join(name)
|
||||
}
|
||||
|
||||
pub struct Supervisor {
|
||||
stop: Option<oneshot::Sender<Duration>>,
|
||||
done: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Supervisor {
|
||||
/// Lance `<go_bin> web <port>` en écoute locale et le maintient en vie.
|
||||
pub fn spawn(go_bin: PathBuf, port: u16) -> Supervisor {
|
||||
let (tx, mut rx) = oneshot::channel::<Duration>();
|
||||
let done = tokio::spawn(async move {
|
||||
let mut delay = Duration::ZERO;
|
||||
loop {
|
||||
let started = Instant::now();
|
||||
let mut child = match launch(&go_bin, port) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[loki-rs] lancement de {} impossible : {e}",
|
||||
go_bin.display()
|
||||
);
|
||||
delay = next_delay(delay, Duration::ZERO);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(delay) => continue,
|
||||
_ = &mut rx => return,
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
status = child.wait() => {
|
||||
let lived = started.elapsed();
|
||||
delay = next_delay(delay, lived);
|
||||
let status = match status {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
eprintln!(
|
||||
"[loki-rs] serveur Go arrêté ({status}) après {lived:?} — relance dans {delay:?}"
|
||||
);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(delay) => {}
|
||||
_ = &mut rx => return,
|
||||
}
|
||||
}
|
||||
grace = &mut rx => {
|
||||
stop_child(&mut child, grace.unwrap_or(MIN_DELAY)).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Supervisor {
|
||||
stop: Some(tx),
|
||||
done,
|
||||
}
|
||||
}
|
||||
|
||||
/// Arrête le Go : SIGTERM, puis SIGKILL passé `grace`.
|
||||
pub async fn shutdown(mut self, grace: Duration) {
|
||||
if let Some(tx) = self.stop.take() {
|
||||
let _ = tx.send(grace);
|
||||
}
|
||||
let _ = (&mut self.done).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn launch(go_bin: &Path, port: u16) -> std::io::Result<Child> {
|
||||
Command::new(go_bin)
|
||||
.arg("web")
|
||||
.arg(port.to_string())
|
||||
.env("LOKI_WEB_BIND", "127.0.0.1")
|
||||
// stdin fermé : `loki web` ne doit jamais attendre une réponse au
|
||||
// clavier (résolution de conflit de port).
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
}
|
||||
|
||||
async fn stop_child(child: &mut Child, grace: Duration) {
|
||||
#[cfg(unix)]
|
||||
if let Some(pid) = child.id() {
|
||||
// SAFETY: kill(2) sur le PID de notre propre enfant, pas encore récolté.
|
||||
unsafe {
|
||||
libc::kill(pid as libc::pid_t, libc::SIGTERM);
|
||||
}
|
||||
if tokio::time::timeout(grace, child.wait()).await.is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = grace;
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attente_croissante_puis_remise_a_zero() {
|
||||
let s = Duration::from_secs;
|
||||
assert_eq!(next_delay(Duration::ZERO, s(0)), s(1));
|
||||
assert_eq!(next_delay(s(1), s(2)), s(2));
|
||||
assert_eq!(next_delay(s(16), s(2)), s(30));
|
||||
assert_eq!(next_delay(s(30), s(2)), s(30));
|
||||
assert_eq!(next_delay(s(30), s(61)), s(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binaire_go_par_defaut_a_cote_de_l_exe() {
|
||||
let exe = Path::new("/usr/local/bin/loki-rs");
|
||||
let name = if cfg!(windows) { "loki.exe" } else { "loki" };
|
||||
let dir = Path::new("/usr/local/bin");
|
||||
assert_eq!(go_bin(None, exe), dir.join(name));
|
||||
assert_eq!(go_bin(Some(" ".into()), exe), dir.join(name));
|
||||
assert_eq!(
|
||||
go_bin(Some("/x/loki".into()), exe),
|
||||
PathBuf::from("/x/loki")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! loki-rs — façade Rust de Loki (lot 1 de la migration Go → Rust).
|
||||
//!
|
||||
//! - `proxy` : façade HTTP/SSE/WebSocket devant le serveur Go ;
|
||||
//! - `child` : supervision du processus `loki web` (Go) ;
|
||||
//! - `update` : mise à jour des binaires au démarrage du conteneur.
|
||||
|
||||
pub mod child;
|
||||
pub mod proxy;
|
||||
pub mod update;
|
||||
@@ -0,0 +1,151 @@
|
||||
//! loki-rs — façade Rust de Loki.
|
||||
//!
|
||||
//! ```text
|
||||
//! loki-rs serve façade sur LOKI_WEB_PORT (8090), Go sur 127.0.0.1:LOKI_GO_PORT (8091)
|
||||
//! loki-rs self-update met à jour les binaires dans $LOKI_HOME/bin (jamais bloquant)
|
||||
//! loki-rs which-bin dossier d'où prendre loki et loki-rs
|
||||
//! loki-rs version
|
||||
//! ```
|
||||
|
||||
use loki_rs::{child, proxy, update};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cmd = std::env::args().nth(1).unwrap_or_default();
|
||||
match cmd.as_str() {
|
||||
"serve" => serve(),
|
||||
"self-update" => {
|
||||
let outcome = update::run(&update::UpdateConfig::from_env());
|
||||
eprintln!("[loki-rs update] {}", outcome.message());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
"which-bin" => {
|
||||
let cfg = update::UpdateConfig::from_env();
|
||||
let dir = update::state::select_dir(
|
||||
&cfg.data_bin,
|
||||
&image_dir(),
|
||||
cfg.image_build,
|
||||
cfg.image_abi,
|
||||
);
|
||||
println!("{}", dir.display());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
"version" | "--version" | "-V" => {
|
||||
let cfg = update::UpdateConfig::from_env();
|
||||
println!(
|
||||
"loki-rs {} (build image {}, ABI {})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
cfg.image_build,
|
||||
cfg.image_abi
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
_ => {
|
||||
eprintln!("usage : loki-rs serve | self-update | which-bin | version");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exe() -> PathBuf {
|
||||
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("loki-rs"))
|
||||
}
|
||||
|
||||
fn image_dir() -> PathBuf {
|
||||
exe()
|
||||
.parent()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn env_port(key: &str, default: u16) -> u16 {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn serve() -> ExitCode {
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("[loki-rs] runtime : {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
rt.block_on(async {
|
||||
let web_port = env_port("LOKI_WEB_PORT", 8090);
|
||||
let go_port = env_port("LOKI_GO_PORT", 8091);
|
||||
let go_bin = child::go_bin(std::env::var("LOKI_GO_BIN").ok(), &exe());
|
||||
let backend = SocketAddr::from(([127, 0, 0, 1], go_port));
|
||||
|
||||
eprintln!(
|
||||
"[loki-rs] serveur Go : {} web {go_port} (local)",
|
||||
go_bin.display()
|
||||
);
|
||||
let sup = child::Supervisor::spawn(go_bin, go_port);
|
||||
wait_for(backend, Duration::from_secs(30)).await;
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], web_port));
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("[loki-rs] écoute sur {addr} impossible : {e}");
|
||||
sup.shutdown(Duration::from_secs(10)).await;
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
eprintln!("[loki-rs] façade : http://{addr} → {backend}");
|
||||
let res = proxy::serve(listener, backend, shutdown_signal()).await;
|
||||
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
|
||||
sup.shutdown(Duration::from_secs(10)).await;
|
||||
match res {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("[loki-rs] {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Attend que le Go accepte les connexions, sans bloquer au-delà de `max` :
|
||||
/// la façade ouvre quand même et répond 502 en attendant.
|
||||
async fn wait_for(addr: SocketAddr, max: Duration) {
|
||||
let t0 = Instant::now();
|
||||
while t0.elapsed() < max {
|
||||
if tokio::net::TcpStream::connect(addr).await.is_ok() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
eprintln!("[loki-rs] le serveur Go ne répond pas encore — façade ouverte quand même");
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
match signal(SignalKind::terminate()) {
|
||||
Ok(mut term) => {
|
||||
tokio::select! {
|
||||
_ = term.recv() => {}
|
||||
_ = tokio::signal::ctrl_c() => {}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Façade HTTP : reçoit tout le trafic public et le relaie au serveur Go
|
||||
//! (`loki web` sur 127.0.0.1). Les lots suivants de la migration y brancheront
|
||||
//! les routes réécrites en Rust ; au lot 1, tout est relayé.
|
||||
//!
|
||||
//! Règles, héritées de `web_server.go` :
|
||||
//! - aucun tampon : un flux SSE (chat, `/v1` en streaming) part trame par trame ;
|
||||
//! - aucun délai d'écriture : un flux reste ouvert tant que la page l'écoute ;
|
||||
//! - en-têtes de bout en bout transmis intacts (`Host`, `Authorization`,
|
||||
//! `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto`…) : le Go s'en sert pour ses
|
||||
//! contrôles d'accès ; seuls les en-têtes hop-by-hop sont retirés ;
|
||||
//! - `X-Loki-Via` entrant effacé : il marque le trafic du tunnel du relais et
|
||||
//! dispense du contrôle d'hôte anti-DNS-rebinding — un client ne doit jamais
|
||||
//! pouvoir le poser lui-même.
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full, combinators::BoxBody};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::header::{self, HeaderMap, HeaderName, HeaderValue};
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Request, Response, StatusCode, Uri};
|
||||
use hyper_util::client::legacy::{Client, connect::HttpConnector};
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||
use std::convert::Infallible;
|
||||
use std::future::Future;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
pub type Body = BoxBody<Bytes, hyper::Error>;
|
||||
|
||||
/// En-tête posé par le tunnel du relais côté Go (`viaTunnelHeader`).
|
||||
pub const VIA_TUNNEL: &str = "x-loki-via";
|
||||
|
||||
const HOP_BY_HOP: [&str; 8] = [
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-connection",
|
||||
"proxy-authenticate",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
];
|
||||
|
||||
struct Proxy {
|
||||
client: Client<HttpConnector, Incoming>,
|
||||
backend: SocketAddr,
|
||||
}
|
||||
|
||||
/// Sert la façade sur `listener` jusqu'à ce que `shutdown` se termine.
|
||||
pub async fn serve(
|
||||
listener: TcpListener,
|
||||
backend: SocketAddr,
|
||||
shutdown: impl Future<Output = ()>,
|
||||
) -> std::io::Result<()> {
|
||||
let client = Client::builder(TokioExecutor::new()).build_http();
|
||||
let proxy = Arc::new(Proxy { client, backend });
|
||||
tokio::pin!(shutdown);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut shutdown => return Ok(()),
|
||||
res = listener.accept() => {
|
||||
let stream = match res {
|
||||
Ok((s, _)) => s,
|
||||
Err(e) => {
|
||||
// EMFILE & co : on ne lâche pas l'écoute pour autant.
|
||||
eprintln!("[loki-rs] accept : {e}");
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let _ = stream.set_nodelay(true);
|
||||
let p = proxy.clone();
|
||||
tokio::spawn(async move {
|
||||
let svc = service_fn(move |req| {
|
||||
let p = p.clone();
|
||||
async move { Ok::<_, Infallible>(p.handle(req).await) }
|
||||
});
|
||||
let _ = hyper::server::conn::http1::Builder::new()
|
||||
.timer(hyper_util::rt::TokioTimer::new())
|
||||
.header_read_timeout(Duration::from_secs(10))
|
||||
.serve_connection(TokioIo::new(stream), svc)
|
||||
.with_upgrades()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Proxy {
|
||||
async fn handle(&self, mut req: Request<Incoming>) -> Response<Body> {
|
||||
let upgrade = upgrade_protocol(req.headers());
|
||||
strip_hop_by_hop(req.headers_mut());
|
||||
req.headers_mut().remove(VIA_TUNNEL);
|
||||
let client_upgrade = upgrade.as_ref().map(|proto| {
|
||||
let h = req.headers_mut();
|
||||
h.insert(header::CONNECTION, HeaderValue::from_static("upgrade"));
|
||||
h.insert(header::UPGRADE, proto.clone());
|
||||
hyper::upgrade::on(&mut req)
|
||||
});
|
||||
|
||||
let pq = req
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(|p| p.as_str())
|
||||
.unwrap_or("/");
|
||||
let uri: Uri = match format!("http://{}{}", self.backend, pq).parse() {
|
||||
Ok(u) => u,
|
||||
Err(_) => return plain(StatusCode::BAD_REQUEST, "URI invalide"),
|
||||
};
|
||||
*req.uri_mut() = uri;
|
||||
|
||||
let mut resp = match self.client.request(req).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("[loki-rs] backend Go injoignable : {e}");
|
||||
return plain(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Loki (serveur Go) redémarre — réessaie dans un instant.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(client_upgrade) = client_upgrade
|
||||
&& resp.status() == StatusCode::SWITCHING_PROTOCOLS
|
||||
{
|
||||
let backend_upgrade = hyper::upgrade::on(&mut resp);
|
||||
tokio::spawn(async move {
|
||||
match (client_upgrade.await, backend_upgrade.await) {
|
||||
(Ok(a), Ok(b)) => {
|
||||
let (mut a, mut b) = (TokioIo::new(a), TokioIo::new(b));
|
||||
let _ = tokio::io::copy_bidirectional(&mut a, &mut b).await;
|
||||
}
|
||||
(Err(e), _) | (_, Err(e)) => eprintln!("[loki-rs] upgrade : {e}"),
|
||||
}
|
||||
});
|
||||
// 101 : Connection/Upgrade font partie de la poignée de main.
|
||||
return resp.map(BodyExt::boxed);
|
||||
}
|
||||
strip_hop_by_hop(resp.headers_mut());
|
||||
resp.map(BodyExt::boxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocole demandé si la requête est une demande d'upgrade (WebSocket).
|
||||
fn upgrade_protocol(h: &HeaderMap) -> Option<HeaderValue> {
|
||||
let wants = h
|
||||
.get_all(header::CONNECTION)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|s| s.split(','))
|
||||
.any(|t| t.trim().eq_ignore_ascii_case("upgrade"));
|
||||
if wants {
|
||||
h.get(header::UPGRADE).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire les en-têtes propres à une connexion (RFC 9110 §7.6.1), y compris
|
||||
/// ceux que `Connection` désigne.
|
||||
pub fn strip_hop_by_hop(h: &mut HeaderMap) {
|
||||
let listed: Vec<HeaderName> = h
|
||||
.get_all(header::CONNECTION)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|s| s.split(','))
|
||||
.filter_map(|t| HeaderName::from_bytes(t.trim().as_bytes()).ok())
|
||||
.collect();
|
||||
for n in listed {
|
||||
h.remove(n);
|
||||
}
|
||||
for n in HOP_BY_HOP {
|
||||
h.remove(n);
|
||||
}
|
||||
}
|
||||
|
||||
fn plain(status: StatusCode, msg: &'static str) -> Response<Body> {
|
||||
let mut r = Response::new(
|
||||
Full::new(Bytes::from_static(msg.as_bytes()))
|
||||
.map_err(|n| match n {})
|
||||
.boxed(),
|
||||
);
|
||||
*r.status_mut() = status;
|
||||
r.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||
);
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn retire_les_hop_by_hop_et_ceux_listes() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
"connection",
|
||||
HeaderValue::from_static("keep-alive, x-perso"),
|
||||
);
|
||||
h.insert("keep-alive", HeaderValue::from_static("timeout=5"));
|
||||
h.insert("x-perso", HeaderValue::from_static("1"));
|
||||
h.insert("transfer-encoding", HeaderValue::from_static("chunked"));
|
||||
h.insert("authorization", HeaderValue::from_static("Bearer k"));
|
||||
strip_hop_by_hop(&mut h);
|
||||
assert_eq!(h.len(), 1);
|
||||
assert!(h.contains_key("authorization"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detecte_l_upgrade() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
"connection",
|
||||
HeaderValue::from_static("keep-alive, Upgrade"),
|
||||
);
|
||||
h.insert("upgrade", HeaderValue::from_static("websocket"));
|
||||
assert_eq!(
|
||||
upgrade_protocol(&h),
|
||||
Some(HeaderValue::from_static("websocket"))
|
||||
);
|
||||
h.insert("connection", HeaderValue::from_static("keep-alive"));
|
||||
assert_eq!(upgrade_protocol(&h), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Mise à jour des binaires Loki au démarrage du conteneur.
|
||||
//!
|
||||
//! La CI publie à chaque push sur `main` une release GitHub `rolling` :
|
||||
//! `loki-linux`, `loki-rs-linux`, `SHA256SUMS` puis `manifest.json` (en
|
||||
//! dernier). Au boot, [`run`] compare le manifeste au build en service et, si
|
||||
//! besoin, installe les deux binaires dans `<LOKI_HOME>/bin/<build>/` (voir
|
||||
//! [`state`]). Rien ici ne doit bloquer le démarrage : toute erreur se solde
|
||||
//! par [`Outcome::Failed`] et l'ancien état reste en place.
|
||||
//!
|
||||
//! Le transfert HTTPS passe par `curl` (présent dans l'image) : le binaire
|
||||
//! n'embarque ainsi aucune pile TLS.
|
||||
|
||||
pub mod state;
|
||||
pub mod sums;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use state::{Decision, Manifest};
|
||||
|
||||
pub const DEFAULT_URL: &str = "https://github.com/R0m1k3/Loki/releases/download/rolling";
|
||||
pub const IMAGE_ABI_FILE: &str = "/etc/loki/IMAGE_ABI";
|
||||
|
||||
/// Nom de l'asset publié → nom du binaire installé.
|
||||
pub const ASSETS: [(&str, &str); 2] = [
|
||||
("loki-linux", state::GO_BIN),
|
||||
("loki-rs-linux", state::RS_BIN),
|
||||
];
|
||||
|
||||
const TMP_DIR: &str = ".tmp-download";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateConfig {
|
||||
pub enabled: bool,
|
||||
pub base_url: String,
|
||||
pub data_bin: PathBuf,
|
||||
pub image_build: u64,
|
||||
pub image_abi: u32,
|
||||
/// Durée maximale du téléchargement du manifeste et des sommes.
|
||||
pub small_timeout: Duration,
|
||||
/// Durée maximale de chaque binaire (un transfert figé est coupé bien
|
||||
/// avant, cf. `--speed-limit`).
|
||||
pub big_timeout: Duration,
|
||||
}
|
||||
|
||||
impl UpdateConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
|
||||
let home = env("LOKI_HOME").unwrap_or_else(|| "/data".into());
|
||||
let image_abi = fs::read_to_string(IMAGE_ABI_FILE)
|
||||
.ok()
|
||||
.or_else(|| env("LOKI_IMAGE_ABI"))
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(0);
|
||||
UpdateConfig {
|
||||
enabled: !matches!(
|
||||
env("LOKI_AUTO_UPDATE").as_deref().map(str::trim),
|
||||
Some("0" | "false" | "off" | "no")
|
||||
),
|
||||
base_url: env("LOKI_UPDATE_URL").unwrap_or_else(|| DEFAULT_URL.into()),
|
||||
data_bin: Path::new(&home).join("bin"),
|
||||
image_build: env("LOKI_BUILD")
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(0),
|
||||
image_abi,
|
||||
small_timeout: Duration::from_secs(20),
|
||||
big_timeout: Duration::from_secs(600),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Disabled,
|
||||
UpToDate(u64),
|
||||
/// Version publiée incompatible avec l'image : il faut tirer l'image.
|
||||
AbiMismatch {
|
||||
remote_abi: u32,
|
||||
image_abi: u32,
|
||||
},
|
||||
Installed(u64),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
/// Ligne de journal pour l'entrypoint.
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
Outcome::Disabled => "mise à jour au démarrage désactivée (LOKI_AUTO_UPDATE=0)".into(),
|
||||
Outcome::UpToDate(b) => format!("binaires à jour (build {b})"),
|
||||
Outcome::AbiMismatch {
|
||||
remote_abi,
|
||||
image_abi,
|
||||
} if remote_abi < image_abi => format!(
|
||||
"image plus récente que la release publiée (ABI {remote_abi}, image {image_abi}) \
|
||||
— binaires de l'image conservés"
|
||||
),
|
||||
Outcome::AbiMismatch {
|
||||
remote_abi,
|
||||
image_abi,
|
||||
} => format!(
|
||||
"nouvelle version disponible mais elle exige une nouvelle image \
|
||||
(ABI {remote_abi}, image {image_abi}) : docker compose pull"
|
||||
),
|
||||
Outcome::Installed(b) => format!("build {b} installé — actif dès ce démarrage"),
|
||||
Outcome::Failed(e) => {
|
||||
format!("mise à jour impossible ({e}) — binaires actuels conservés")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(cfg: &UpdateConfig) -> Outcome {
|
||||
if !cfg.enabled {
|
||||
return Outcome::Disabled;
|
||||
}
|
||||
if let Err(e) = fs::create_dir_all(&cfg.data_bin) {
|
||||
return Outcome::Failed(format!("{}: {e}", cfg.data_bin.display()));
|
||||
}
|
||||
let tmp = cfg.data_bin.join(TMP_DIR);
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
let res = fs::create_dir_all(&tmp)
|
||||
.map_err(|e| format!("{}: {e}", tmp.display()))
|
||||
.and_then(|_| run_in(cfg, &tmp));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
res.unwrap_or_else(Outcome::Failed)
|
||||
}
|
||||
|
||||
fn run_in(cfg: &UpdateConfig, tmp: &Path) -> Result<Outcome, String> {
|
||||
let current = state::current(&cfg.data_bin, cfg.image_build, cfg.image_abi)
|
||||
.map(|(m, _)| m.build)
|
||||
.unwrap_or(cfg.image_build);
|
||||
|
||||
let man_path = tmp.join("manifest.json");
|
||||
fetch(&cfg.base_url, "manifest.json", &man_path, cfg.small_timeout)?;
|
||||
let remote = state::read_manifest(&man_path).ok_or("manifest.json illisible")?;
|
||||
|
||||
match state::decide(&remote, current, cfg.image_abi) {
|
||||
Decision::AbiMismatch => {
|
||||
return Ok(Outcome::AbiMismatch {
|
||||
remote_abi: remote.abi,
|
||||
image_abi: cfg.image_abi,
|
||||
});
|
||||
}
|
||||
Decision::UpToDate => {
|
||||
prune(&cfg.data_bin, current);
|
||||
return Ok(Outcome::UpToDate(current));
|
||||
}
|
||||
Decision::Install => {}
|
||||
}
|
||||
|
||||
let sums_path = tmp.join("SHA256SUMS");
|
||||
fetch(&cfg.base_url, "SHA256SUMS", &sums_path, cfg.small_timeout)?;
|
||||
let sums_text = fs::read_to_string(&sums_path).map_err(|e| format!("SHA256SUMS: {e}"))?;
|
||||
let sums = sums::parse(&sums_text);
|
||||
|
||||
let stage = tmp.join("stage");
|
||||
fs::create_dir_all(&stage).map_err(|e| e.to_string())?;
|
||||
for (asset, local) in ASSETS {
|
||||
let want = sums
|
||||
.get(asset)
|
||||
.ok_or(format!("SHA256SUMS sans entrée pour {asset}"))?;
|
||||
let dst = stage.join(local);
|
||||
fetch(&cfg.base_url, asset, &dst, cfg.big_timeout)?;
|
||||
let got = sha256_file(&dst).map_err(|e| format!("{asset}: {e}"))?;
|
||||
if &got != want {
|
||||
return Err(format!("empreinte de {asset} invalide"));
|
||||
}
|
||||
make_executable(&dst).map_err(|e| format!("{asset}: {e}"))?;
|
||||
}
|
||||
write_manifest(&stage.join(state::RELEASE_FILE), &remote)?;
|
||||
|
||||
let dest = cfg.data_bin.join(remote.build.to_string());
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
fs::rename(&stage, &dest).map_err(|e| format!("{}: {e}", dest.display()))?;
|
||||
|
||||
// Bascule : release.json réécrit en dernier, par renommage atomique.
|
||||
let pointer = cfg.data_bin.join(state::RELEASE_FILE);
|
||||
let pointer_tmp = cfg.data_bin.join("release.json.tmp");
|
||||
write_manifest(&pointer_tmp, &remote)?;
|
||||
fs::rename(&pointer_tmp, &pointer).map_err(|e| format!("{}: {e}", pointer.display()))?;
|
||||
|
||||
prune(&cfg.data_bin, remote.build);
|
||||
Ok(Outcome::Installed(remote.build))
|
||||
}
|
||||
|
||||
/// Télécharge `<base>/<name>` dans `dst` via curl. `--speed-limit` coupe un
|
||||
/// transfert figé (moins de 10 Ko/s pendant 20 s) bien avant `timeout`.
|
||||
fn fetch(base: &str, name: &str, dst: &Path, timeout: Duration) -> Result<(), String> {
|
||||
let url = format!("{}/{}", base.trim_end_matches('/'), name);
|
||||
let out = Command::new("curl")
|
||||
.args([
|
||||
"-fsSL",
|
||||
"--connect-timeout",
|
||||
"10",
|
||||
"--speed-limit",
|
||||
"10240",
|
||||
"--speed-time",
|
||||
"20",
|
||||
])
|
||||
.arg("--max-time")
|
||||
.arg(timeout.as_secs().max(1).to_string())
|
||||
.arg("-o")
|
||||
.arg(dst)
|
||||
.arg(&url)
|
||||
.output()
|
||||
.map_err(|e| format!("curl introuvable : {e}"))?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(format!("{name} : {}", err.trim()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn sha256_file(path: &Path) -> std::io::Result<String> {
|
||||
let mut f = fs::File::open(path)?;
|
||||
let mut h = Sha256::new();
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let n = f.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
h.update(&buf[..n]);
|
||||
}
|
||||
Ok(hex::encode(h.finalize()))
|
||||
}
|
||||
|
||||
fn write_manifest(path: &Path, m: &Manifest) -> Result<(), String> {
|
||||
let text = serde_json::to_string(m).map_err(|e| e.to_string())?;
|
||||
fs::write(path, text).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn make_executable(path: &Path) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn make_executable(_: &Path) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Supprime les builds autres que `keep` (un seul build est gardé : celui en
|
||||
/// service). Ne touche qu'aux dossiers au nom purement numérique.
|
||||
fn prune(data_bin: &Path, keep: u64) {
|
||||
let Ok(rd) = fs::read_dir(data_bin) else {
|
||||
return;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let name = e.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if let Ok(n) = name.parse::<u64>()
|
||||
&& n != keep
|
||||
&& e.path().is_dir()
|
||||
{
|
||||
let _ = fs::remove_dir_all(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! État d'installation des binaires mis à jour, sous `<LOKI_HOME>/bin` :
|
||||
//!
|
||||
//! ```text
|
||||
//! bin/release.json manifeste du build actif (écrit en DERNIER)
|
||||
//! bin/<build>/loki binaire Go
|
||||
//! bin/<build>/loki-rs binaire Rust
|
||||
//! ```
|
||||
//!
|
||||
//! La bascule d'une version à l'autre se résume à réécrire `release.json` :
|
||||
//! un arrêt brutal pendant une mise à jour laisse l'ancien build intact.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const RELEASE_FILE: &str = "release.json";
|
||||
pub const GO_BIN: &str = "loki";
|
||||
pub const RS_BIN: &str = "loki-rs";
|
||||
|
||||
/// Contenu de `manifest.json` (release) et de `release.json` (installé).
|
||||
///
|
||||
/// `build` est le numéro de run de la CI : il ne fait que croître, c'est lui
|
||||
/// qui ordonne les versions (un commit ne s'ordonne pas). `abi` est l'ABI
|
||||
/// d'image exigée par ces binaires.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub build: u64,
|
||||
pub abi: u32,
|
||||
#[serde(default)]
|
||||
pub commit: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
Install,
|
||||
UpToDate,
|
||||
AbiMismatch,
|
||||
}
|
||||
|
||||
pub fn read_manifest(path: &Path) -> Option<Manifest> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// Build installé dans `data_bin` et utilisable avec cette image : même ABI,
|
||||
/// plus récent que les binaires de l'image, et les deux binaires présents.
|
||||
pub fn current(data_bin: &Path, image_build: u64, image_abi: u32) -> Option<(Manifest, PathBuf)> {
|
||||
let m = read_manifest(&data_bin.join(RELEASE_FILE))?;
|
||||
if m.abi != image_abi || m.build <= image_build {
|
||||
return None;
|
||||
}
|
||||
let dir = data_bin.join(m.build.to_string());
|
||||
if !dir.join(GO_BIN).is_file() || !dir.join(RS_BIN).is_file() {
|
||||
return None;
|
||||
}
|
||||
Some((m, dir))
|
||||
}
|
||||
|
||||
/// Dossier dont l'entrypoint doit prendre `loki` et `loki-rs` : toujours les
|
||||
/// deux du même endroit, jamais un Go d'une version avec un Rust d'une autre.
|
||||
pub fn select_dir(data_bin: &Path, image_dir: &Path, image_build: u64, image_abi: u32) -> PathBuf {
|
||||
match current(data_bin, image_build, image_abi) {
|
||||
Some((_, dir)) => dir,
|
||||
None => image_dir.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Faut-il installer `remote`, sachant que le build en service est `current_build` ?
|
||||
pub fn decide(remote: &Manifest, current_build: u64, image_abi: u32) -> Decision {
|
||||
if remote.abi != image_abi {
|
||||
Decision::AbiMismatch
|
||||
} else if remote.build <= current_build {
|
||||
Decision::UpToDate
|
||||
} else {
|
||||
Decision::Install
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn m(build: u64, abi: u32) -> Manifest {
|
||||
Manifest {
|
||||
build,
|
||||
abi,
|
||||
commit: "abc".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn install(data_bin: &Path, man: &Manifest, bins: &[&str]) {
|
||||
let dir = data_bin.join(man.build.to_string());
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for b in bins {
|
||||
std::fs::write(dir.join(b), b"x").unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
data_bin.join(RELEASE_FILE),
|
||||
serde_json::to_string(man).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision() {
|
||||
assert_eq!(decide(&m(10, 2), 5, 1), Decision::AbiMismatch);
|
||||
assert_eq!(decide(&m(5, 1), 5, 1), Decision::UpToDate);
|
||||
assert_eq!(decide(&m(4, 1), 5, 1), Decision::UpToDate);
|
||||
assert_eq!(decide(&m(6, 1), 5, 1), Decision::Install);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_sans_installation() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let img = Path::new("/usr/local/bin");
|
||||
assert_eq!(select_dir(t.path(), img, 3, 1), img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_installation_valide() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
install(t.path(), &m(7, 1), &[GO_BIN, RS_BIN]);
|
||||
let img = Path::new("/usr/local/bin");
|
||||
assert_eq!(select_dir(t.path(), img, 3, 1), t.path().join("7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_refuse_abi_differente() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
install(t.path(), &m(7, 2), &[GO_BIN, RS_BIN]);
|
||||
let img = Path::new("/img");
|
||||
assert_eq!(select_dir(t.path(), img, 3, 1), img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_refuse_plus_ancien_que_l_image() {
|
||||
// Image tirée à la main plus récente que /data/bin : l'image gagne.
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
install(t.path(), &m(7, 1), &[GO_BIN, RS_BIN]);
|
||||
let img = Path::new("/img");
|
||||
assert_eq!(select_dir(t.path(), img, 7, 1), img);
|
||||
assert_eq!(select_dir(t.path(), img, 9, 1), img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_refuse_binaire_manquant() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
install(t.path(), &m(7, 1), &[GO_BIN]);
|
||||
let img = Path::new("/img");
|
||||
assert_eq!(select_dir(t.path(), img, 3, 1), img);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Lecture du fichier SHA256SUMS publié avec chaque release (format
|
||||
//! `sha256sum` : "<hex> <nom>", ou "<hex> *<nom>" en mode binaire).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Associe chaque nom de fichier à son empreinte SHA-256 en hexadécimal
|
||||
/// minuscule. Les lignes vides ou mal formées sont ignorées : une entrée
|
||||
/// manquante fera échouer la vérification du fichier concerné, pas la lecture.
|
||||
pub fn parse(text: &str) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
for line in text.lines() {
|
||||
let mut it = line.split_whitespace();
|
||||
let (Some(hash), Some(name), None) = (it.next(), it.next(), it.next()) else {
|
||||
continue;
|
||||
};
|
||||
if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
continue;
|
||||
}
|
||||
out.insert(
|
||||
name.trim_start_matches('*').to_string(),
|
||||
hash.to_ascii_lowercase(),
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
#[test]
|
||||
fn lit_les_deux_formats() {
|
||||
let up = A.to_ascii_uppercase();
|
||||
let text = format!("{A} loki-linux\n{up} *loki-rs-linux\n");
|
||||
let m = parse(&text);
|
||||
assert_eq!(m.get("loki-linux").map(String::as_str), Some(A));
|
||||
assert_eq!(m.get("loki-rs-linux").map(String::as_str), Some(A));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_les_lignes_cassees() {
|
||||
let text = format!("\nzz loki-linux\n{A}\n{A} a b\n{A} ok\n");
|
||||
let m = parse(&text);
|
||||
assert_eq!(m.len(), 1);
|
||||
assert!(m.contains_key("ok"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Serveur HTTP minimal pour les tests : sert des fichiers en mémoire et
|
||||
//! journalise les chemins demandés.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::Full;
|
||||
use hyper::{Request, Response, StatusCode, body::Incoming, service::service_fn};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub struct FileServer {
|
||||
pub addr: SocketAddr,
|
||||
pub hits: Arc<Mutex<Vec<String>>>,
|
||||
_rt: tokio::runtime::Runtime,
|
||||
}
|
||||
|
||||
impl FileServer {
|
||||
pub fn url(&self) -> String {
|
||||
format!("http://{}/rolling", self.addr)
|
||||
}
|
||||
pub fn hits(&self) -> Vec<String> {
|
||||
self.hits.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serve_files(files: HashMap<String, Vec<u8>>) -> FileServer {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let files = Arc::new(files);
|
||||
let hits = Arc::new(Mutex::new(Vec::new()));
|
||||
let listener = rt
|
||||
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
|
||||
.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let h = hits.clone();
|
||||
rt.spawn(async move {
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let files = files.clone();
|
||||
let h = h.clone();
|
||||
tokio::spawn(async move {
|
||||
let svc = service_fn(move |req: Request<Incoming>| {
|
||||
let files = files.clone();
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let path = req.uri().path().trim_start_matches("/rolling/").to_string();
|
||||
h.lock().unwrap().push(path.clone());
|
||||
let resp = match files.get(&path) {
|
||||
Some(b) => Response::new(Full::new(Bytes::from(b.clone()))),
|
||||
None => {
|
||||
let mut r = Response::new(Full::new(Bytes::from_static(b"404")));
|
||||
*r.status_mut() = StatusCode::NOT_FOUND;
|
||||
r
|
||||
}
|
||||
};
|
||||
Ok::<_, std::convert::Infallible>(resp)
|
||||
}
|
||||
});
|
||||
let _ = hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(stream), svc)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
FileServer {
|
||||
addr,
|
||||
hits,
|
||||
_rt: rt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use bytes::Bytes;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use http_body_util::{BodyExt, Empty, Full, StreamBody, combinators::BoxBody};
|
||||
use hyper::body::{Frame, Incoming};
|
||||
use hyper::{Request, Response, StatusCode, service::service_fn};
|
||||
use hyper_util::client::legacy::Client;
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
type B = BoxBody<Bytes, Infallible>;
|
||||
|
||||
/// Backend factice : /echo renvoie les en-têtes reçus, /sse émet un événement,
|
||||
/// attend 500 ms, puis un second ; /post renvoie le corps reçu.
|
||||
async fn backend() -> SocketAddr {
|
||||
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = l.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (s, _) = l.accept().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let svc = service_fn(|req: Request<Incoming>| async move {
|
||||
let resp: Response<B> = match req.uri().path() {
|
||||
"/echo" => {
|
||||
let mut lines: Vec<String> = req
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}: {}", k, v.to_str().unwrap()))
|
||||
.collect();
|
||||
lines.push(format!("uri: {}", req.uri()));
|
||||
Response::new(Full::new(Bytes::from(lines.join("\n"))).boxed())
|
||||
}
|
||||
"/post" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
Response::new(Full::new(body).boxed())
|
||||
}
|
||||
"/sse" => {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(4);
|
||||
tokio::spawn(async move {
|
||||
tx.send(Bytes::from_static(b"data: un\n\n")).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let _ = tx.send(Bytes::from_static(b"data: deux\n\n")).await;
|
||||
});
|
||||
let stream = futures_util::stream::unfold(rx, |mut rx| async move {
|
||||
rx.recv()
|
||||
.await
|
||||
.map(|b| (Ok::<_, Infallible>(Frame::data(b)), rx))
|
||||
});
|
||||
let mut r = Response::new(BodyExt::boxed(StreamBody::new(stream)));
|
||||
r.headers_mut()
|
||||
.insert("content-type", "text/event-stream".parse().unwrap());
|
||||
r
|
||||
}
|
||||
_ => Response::new(Empty::new().boxed()),
|
||||
};
|
||||
Ok::<_, Infallible>(resp)
|
||||
});
|
||||
let _ = hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(s), svc)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
async fn ws_backend() -> SocketAddr {
|
||||
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = l.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((s, _)) = l.accept().await {
|
||||
tokio::spawn(async move {
|
||||
let mut ws = tokio_tungstenite::accept_async(s).await.unwrap();
|
||||
while let Some(Ok(m)) = ws.next().await {
|
||||
if m.is_text() {
|
||||
ws.send(m).await.unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// Lance la façade devant `backend`, rend son adresse.
|
||||
async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
|
||||
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = l.local_addr().unwrap();
|
||||
let (tx, rx) = oneshot::channel::<()>();
|
||||
tokio::spawn(loki_rs::proxy::serve(l, backend, async move {
|
||||
let _ = rx.await;
|
||||
}));
|
||||
(addr, tx)
|
||||
}
|
||||
|
||||
fn client() -> Client<hyper_util::client::legacy::connect::HttpConnector, Full<Bytes>> {
|
||||
Client::builder(TokioExecutor::new()).build_http()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transmet_les_en_tetes_et_efface_x_loki_via() {
|
||||
let (f, _stop) = facade(backend().await).await;
|
||||
let req = Request::get(format!("http://{f}/echo?a=1"))
|
||||
.header("host", "loki.exemple.fr")
|
||||
.header("authorization", "Bearer secret")
|
||||
.header("origin", "https://loki.exemple.fr")
|
||||
.header("x-forwarded-proto", "https")
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.header("x-loki-via", "tunnel")
|
||||
.body(Full::new(Bytes::new()))
|
||||
.unwrap();
|
||||
let resp = client().request(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = String::from_utf8(
|
||||
resp.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
for want in [
|
||||
"host: loki.exemple.fr",
|
||||
"authorization: Bearer secret",
|
||||
"origin: https://loki.exemple.fr",
|
||||
"x-forwarded-proto: https",
|
||||
"sec-fetch-site: same-origin",
|
||||
"uri: /echo?a=1",
|
||||
] {
|
||||
assert!(
|
||||
body.lines().any(|l| l == want),
|
||||
"manque « {want} » dans :\n{body}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!body.contains("x-loki-via"),
|
||||
"X-Loki-Via a traversé :\n{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relaie_le_corps() {
|
||||
let (f, _stop) = facade(backend().await).await;
|
||||
let req = Request::post(format!("http://{f}/post"))
|
||||
.body(Full::new(Bytes::from_static(b"{\"x\":1}")))
|
||||
.unwrap();
|
||||
let resp = client().request(req).await.unwrap();
|
||||
let body = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(&body[..], b"{\"x\":1}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_sans_tampon() {
|
||||
let (f, _stop) = facade(backend().await).await;
|
||||
let t0 = Instant::now();
|
||||
let req = Request::get(format!("http://{f}/sse"))
|
||||
.body(Full::new(Bytes::new()))
|
||||
.unwrap();
|
||||
let mut body = client().request(req).await.unwrap().into_body();
|
||||
let first = body.frame().await.unwrap().unwrap().into_data().unwrap();
|
||||
let elapsed = t0.elapsed();
|
||||
assert_eq!(&first[..], b"data: un\n\n");
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(300),
|
||||
"premier événement reçu après {elapsed:?}"
|
||||
);
|
||||
let second = body.frame().await.unwrap().unwrap().into_data().unwrap();
|
||||
assert_eq!(&second[..], b"data: deux\n\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_relaye() {
|
||||
let (f, _stop) = facade(ws_backend().await).await;
|
||||
let (mut ws, resp) = tokio_tungstenite::connect_async(format!("ws://{f}/api/node/ws"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SWITCHING_PROTOCOLS);
|
||||
ws.send(Message::text("bonjour")).await.unwrap();
|
||||
let got = tokio::time::timeout(Duration::from_secs(5), ws.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(got.into_text().unwrap().as_str(), "bonjour");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_absent_donne_502() {
|
||||
// Port réservé puis libéré : personne n'y écoute.
|
||||
let dead = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap();
|
||||
let (f, _stop) = facade(dead).await;
|
||||
let req = Request::get(format!("http://{f}/"))
|
||||
.body(Full::new(Bytes::new()))
|
||||
.unwrap();
|
||||
let resp = client().request(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
mod common;
|
||||
|
||||
use loki_rs::update::{self, Outcome, UpdateConfig, sha256_file, state};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
const GO: &[u8] = b"binaire go";
|
||||
const RS: &[u8] = b"binaire rust";
|
||||
|
||||
fn sha(b: &[u8]) -> String {
|
||||
let t = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::write(t.path(), b).unwrap();
|
||||
sha256_file(t.path()).unwrap()
|
||||
}
|
||||
|
||||
fn release(
|
||||
build: u64,
|
||||
abi: u32,
|
||||
go: &[u8],
|
||||
rs: &[u8],
|
||||
sums_go: Option<&str>,
|
||||
) -> HashMap<String, Vec<u8>> {
|
||||
let sums = format!(
|
||||
"{} loki-linux\n{} loki-rs-linux\n",
|
||||
sums_go.map(str::to_string).unwrap_or_else(|| sha(go)),
|
||||
sha(rs)
|
||||
);
|
||||
HashMap::from([
|
||||
(
|
||||
"manifest.json".into(),
|
||||
format!(r#"{{"build":{build},"abi":{abi},"commit":"c{build}"}}"#).into_bytes(),
|
||||
),
|
||||
("SHA256SUMS".into(), sums.into_bytes()),
|
||||
("loki-linux".into(), go.to_vec()),
|
||||
("loki-rs-linux".into(), rs.to_vec()),
|
||||
])
|
||||
}
|
||||
|
||||
fn cfg(url: String, data_bin: &Path) -> UpdateConfig {
|
||||
UpdateConfig {
|
||||
enabled: true,
|
||||
base_url: url,
|
||||
data_bin: data_bin.to_path_buf(),
|
||||
image_build: 3,
|
||||
image_abi: 1,
|
||||
small_timeout: Duration::from_secs(10),
|
||||
big_timeout: Duration::from_secs(10),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installe_puis_reconnait_a_jour() {
|
||||
let srv = common::serve_files(release(7, 1, GO, RS, None));
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let c = cfg(srv.url(), t.path());
|
||||
|
||||
assert_eq!(update::run(&c), Outcome::Installed(7));
|
||||
let dir = state::select_dir(t.path(), Path::new("/img"), 3, 1);
|
||||
assert_eq!(dir, t.path().join("7"));
|
||||
assert_eq!(std::fs::read(dir.join("loki")).unwrap(), GO);
|
||||
assert_eq!(std::fs::read(dir.join("loki-rs")).unwrap(), RS);
|
||||
assert!(!t.path().join(".tmp-download").exists());
|
||||
|
||||
assert_eq!(update::run(&c), Outcome::UpToDate(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nouveau_build_remplace_et_nettoie_l_ancien() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let srv = common::serve_files(release(7, 1, GO, RS, None));
|
||||
assert_eq!(
|
||||
update::run(&cfg(srv.url(), t.path())),
|
||||
Outcome::Installed(7)
|
||||
);
|
||||
let srv2 = common::serve_files(release(9, 1, b"go 9", b"rs 9", None));
|
||||
assert_eq!(
|
||||
update::run(&cfg(srv2.url(), t.path())),
|
||||
Outcome::Installed(9)
|
||||
);
|
||||
assert!(!t.path().join("7").exists());
|
||||
assert_eq!(
|
||||
state::select_dir(t.path(), Path::new("/img"), 3, 1),
|
||||
t.path().join("9")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empreinte_fausse_ne_change_rien() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let srv = common::serve_files(release(7, 1, GO, RS, None));
|
||||
assert_eq!(
|
||||
update::run(&cfg(srv.url(), t.path())),
|
||||
Outcome::Installed(7)
|
||||
);
|
||||
|
||||
let bad = "0".repeat(64);
|
||||
let srv2 = common::serve_files(release(9, 1, b"go 9", b"rs 9", Some(&bad)));
|
||||
assert!(matches!(
|
||||
update::run(&cfg(srv2.url(), t.path())),
|
||||
Outcome::Failed(_)
|
||||
));
|
||||
assert_eq!(
|
||||
state::select_dir(t.path(), Path::new("/img"), 3, 1),
|
||||
t.path().join("7")
|
||||
);
|
||||
assert!(!t.path().join("9").exists());
|
||||
assert!(!t.path().join(".tmp-download").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abi_differente_ne_telecharge_que_le_manifeste() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let srv = common::serve_files(release(7, 2, GO, RS, None));
|
||||
assert_eq!(
|
||||
update::run(&cfg(srv.url(), t.path())),
|
||||
Outcome::AbiMismatch {
|
||||
remote_abi: 2,
|
||||
image_abi: 1
|
||||
}
|
||||
);
|
||||
assert_eq!(srv.hits(), vec!["manifest.json".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_plus_recente_que_la_release() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let srv = common::serve_files(release(3, 1, GO, RS, None));
|
||||
assert_eq!(update::run(&cfg(srv.url(), t.path())), Outcome::UpToDate(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serveur_absent() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let mut c = cfg("http://127.0.0.1:9/rolling".into(), t.path());
|
||||
c.small_timeout = Duration::from_secs(3);
|
||||
assert!(matches!(update::run(&c), Outcome::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desactive_sans_requete() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let srv = common::serve_files(release(7, 1, GO, RS, None));
|
||||
let mut c = cfg(srv.url(), t.path());
|
||||
c.enabled = false;
|
||||
assert_eq!(update::run(&c), Outcome::Disabled);
|
||||
assert!(srv.hits().is_empty());
|
||||
}
|
||||
Reference in new issue
Block a user