Lot 22 : backends llama.cpp et job des moteurs tenus par la façade (LOKI_BACKENDS=rust)

La façade (rust/loki-rs/src/backends) sert le statut du moteur llama.cpp
(dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé,
plan de build et conseil), la vérification git, la dernière release et
l'installation des binaires précompilés (extraction avec liens, VERSION,
ménage), la bascule fast/opt, les backends custom et leur désinstallation,
et les GPU vus par un moteur (caches mémoire et disque).

Elle détient LE job des moteurs (state/build_job + loki-build.log, repris
au démarrage). Les jobs que le Go fait encore tourner (compilation depuis
les sources, moteur de l'image, Strata) lui rapportent chaque étape par
/internal/facade/v1/build-job/* ; le Go garde le garde de l'optimiseur et
ne restaure plus de job quand la façade tient le lot.

Correction du Go : prebuiltVersionFormat lisait le format comme 3e champ
du marqueur VERSION ; hors CUDA Windows (« b300  fmt2 ») il n'y en a que
deux, et chaque vérification retéléchargeait la release au lieu de dire
« déjà à jour ». Le port suit.

Parité : parity_backends_test.go (Linux) → tests/parity_backends, rejoué
par tests/backends.rs (fausses machines dans le PATH, faux GitHub, dépôt
git reproductible) ; TestFacadeBackends pour les étapes transmises.
Mutations : 55 tuées sur 57, deux inobservables sous Linux (cudart
Windows, garde d'un avertissement).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 20:58:28 +00:00
1 parent 25e31c1c26
commit 2c926a8274
26 files changed
+10896 -30

No files matched your search

+1
View File
@@ -238,6 +238,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go |
| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go |
| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels (expérimental, migration en cours) | Go |
| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust ; les jobs du Go (compilation, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go |
| `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go |
| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go |
| `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go |
@@ -0,0 +1,111 @@
# Lot 22 — backends llama.cpp et job des moteurs tenus par la façade
## Contexte
Le panneau Moteur s'appuie sur une famille de routes du Go :
- `web_llamacpp.go` : statut, vérification git, bascule fast/opt, job ;
- `backend_prebuilt.go` : binaires précompilés officiels ;
- `web_api.go` : backends custom et leur désinstallation ;
- `web_devices.go` : GPU vus par un moteur.
Elles partagent **un** job (`lcJob`) avec la mise à jour du moteur de l'image
(`web_engine.go`) et l'installation de Strata (`backend_strata.go`). L'entête
du job est en base (`state/build_job`), ses lignes dans
`$LOKI_HOME/loki-build.log`. Au démarrage, un job noté « en cours » est déclaré
interrompu (`lcRestore`).
## Le lot
Avec `LOKI_BACKENDS=rust` (expérimental), la façade (`rust/loki-rs/src/backends`)
sert les routes suivantes :
| Route | Rôle |
| --- | --- |
| `/api/llamacpp` | statut : dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé, plan de build, conseil, job |
| `/api/llamacpp/check` | `git fetch`, puis le retard sur origin |
| `/api/llamacpp/prebuilt/check` | dernière release officielle comparée à la version installée |
| `/api/llamacpp/prebuilt` | job : téléchargement, extraction (liens compris), VERSION, ménage, BIN, service arrêté puis relancé |
| `/api/llamacpp/use` | bascule fast/opt, refusée pendant une optimisation |
| `/api/llamacpp/job` | progression et lignes à partir d'un décalage |
| `/api/llamacpp/uninstall-custom` | désinstallation d'un backend custom |
| `/api/backends`, `/api/backends/custom` | les backends présents |
| `/api/backends/devices` | GPU vus par un moteur, avec un cache mémoire et un cache disque |
**Le job vit dans la façade** (`backends/job.rs`) : même entête en base, même
journal, même reprise au démarrage. Les jobs que le Go fait encore tourner lui
rapportent chaque étape par l'API interne `/internal/facade/v1/build-job/*`
(`start`, `line`, `phase`, `fail`, `done`, `commits`, `end`, `snapshot`).
Ces jobs sont :
- la compilation depuis les sources (`install`, `update`, `install-custom`,
relayées au Go qui garde son refus 409 quand l'image fournit le moteur) ;
- la mise à jour du moteur de l'image ;
- Strata.
Côté Go (`facade_backends.go`), quand la façade tient le lot :
- `startLcJob`, `lcAppend`, `lcPhase`, `lcFail`, `lcDone`, `lcSetCommit` et
`lcJobSnapshot` passent par la façade ;
- le garde de l'optimiseur reste au Go, avant le démarrage ;
- le Go ne restaure plus de job au démarrage ;
- les routes reçues par le tunnel sont relayées à la façade.
Pour arrêter et relancer le service, la façade pilote le moteur si elle le
tient (lot 16), sinon elle le demande au Go (`/internal/v1/service/action`,
avec `if_active`). Les sondes de la machine passent par un PATH et une racine
injectables (`backends/probe.rs`) :
- `nvidia-smi`, `nvcc`, `hipcc`, `/opt/rocm` ;
- `glslc`, `vulkaninfo`, `ldconfig` et les chemins de `libvulkan`.
## Correction du Go
`prebuiltVersionFormat` lisait le format comme 3ᵉ champ du marqueur VERSION.
Hors CUDA Windows, la version CUDA est vide : le marqueur
« `b300 fmt2` » n'a alors que deux champs. Le format n'était donc jamais
reconnu, et chaque vérification retéléchargeait la release sous Linux et
macOS au lieu de dire « déjà à jour ». Le format est désormais le dernier
champ, et la version CUDA ignore ce champ. Le port Rust suit.
## Parité
`internal/loki/parity_backends_test.go` (Linux) tire du Go un journal rejoué
par `tests/backends.rs`. Le journal est sauté si `/usr/local/cuda*` ou
`/opt/rocm` existent sur la machine.
Le journal contient :
- **le statut** dans tous ses états :
- dépôt git reproductible (dates et identité fixées), retard connu ou vérifié, tête détachée ;
- origine perdue, BIN dans un fork ;
- précompilé versionné, moteur téléchargé, moteur de l'image ;
- **les backends custom** et chaque refus de désinstallation ;
- **la bascule fast/opt** ;
- **la dernière release** :
- refus HTTP, tag vide, JSON illisible ;
- aucun asset adapté ;
- **le job précompilé** :
- installation, « déjà à jour », ancien format réextrait ;
- optimisation en cours, GitHub qui refuse ;
- archive absente, archive piégée (`../`), archive sans serveur ;
- **les GPU** :
- liaison PCIe, mémoire complétée par nvidia-smi ;
- cache, mémoire nulle non figée, repli sur le disque ;
- moteur planté, tué ou non exécutable, pilote ancien, cartes de même nom ;
- **après chaque appel** : BIN, entête en base, journal, `devices.json` et l'arborescence de `backends/` et `engine/` ;
- **machine par machine** (dix fausses machines) : le plan de build, le conseil et la variante de release ;
- **le job pas à pas** :
- phases déduites des lignes de build, troncature à 4000 lignes, vues à partir d'un décalage ;
- écritures en base limitées à une toutes les 2 s ;
- refus d'un second job ;
- reprise au démarrage dans tous les cas : absent, illisible, fini, interrompu, journal vide ou trop long.
Autres tests :
- le Go (`TestFacadeBackends`) vérifie que ses jobs rapportent chaque étape à
la façade, dans l'ordre, et que la compilation n'est pas relayée ;
- la façade (`api_interne_du_job`, `service_arrete_puis_relance`) vérifie
l'API interne et le service arrêté puis relancé autour de l'installation.
## Écarts assumés
- Les erreurs réseau (GitHub injoignable) gardent le texte de `reqwest`.
- Quand un asset de la release a un champ de mauvais type, le message n'est
pas celui du Go.
- `git fetch` lancé par `/api/llamacpp/check` pendant un job du Go n'écrit
plus ses lignes dans ce job.
+21 -15
View File
@@ -29,7 +29,8 @@ import (
"time"
)
const llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
// llamaReleasesAPI : variable pour que les tests la pointent sur un faux GitHub.
var llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
type ghAsset struct {
Name string `json:"name"`
@@ -41,18 +42,25 @@ func prebuiltDir() string {
return filepath.Join(backendsDir(), "llama.cpp-prebuilt")
}
// prebuiltVersionFields : les champs du marqueur VERSION, « tag cudaVer
// format ». La version CUDA est vide hors CUDA Windows : « b300 fmt2 » n'a
// alors que DEUX champs, et le format est le second.
func prebuiltVersionFields() []string {
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return nil
}
return strings.Fields(strings.TrimSpace(string(b)))
}
// prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer"
// (cudaVer vide hors CUDA Windows).
func prebuiltVersion() (tag, cudaVer string) {
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return "", ""
}
f := strings.Fields(strings.TrimSpace(string(b)))
f := prebuiltVersionFields()
if len(f) > 0 {
tag = f[0]
}
if len(f) > 1 {
if len(f) > 1 && !strings.HasPrefix(f[1], "fmt") {
cudaVer = f[1]
}
return
@@ -64,15 +72,13 @@ func prebuiltVersion() (tag, cudaVer string) {
// sera refaite au lieu d'être déclarée « déjà à jour ».
const prebuiltFormat = "fmt2"
// prebuiltVersionFormat lit le 3e champ du marqueur VERSION ("" si absent).
// prebuiltVersionFormat lit le format, dernier champ du marqueur VERSION (""
// si absent). Lu jadis comme 3e champ, il manquait dès que la version CUDA
// était vide (Linux, macOS) : chaque vérification réinstallait la release.
func prebuiltVersionFormat() string {
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return ""
}
f := strings.Fields(strings.TrimSpace(string(b)))
if len(f) > 2 {
return f[2]
f := prebuiltVersionFields()
if len(f) > 1 && strings.HasPrefix(f[len(f)-1], "fmt") {
return f[len(f)-1]
}
return ""
}
+154
View File
@@ -0,0 +1,154 @@
package loki
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// facade_backends.go — backends llama.cpp tenus par la façade Rust
// (LOKI_BACKENDS=rust, lot 22 de la migration ; expérimental, le Go reste le
// défaut) : statut, vérification git, binaires précompilés, bascule fast/opt,
// suivi du job, backends custom et GPU vus par un moteur
// (rust/loki-rs/src/backends).
//
// La façade détient alors LE job des moteurs. Ceux que le Go fait encore
// tourner — compilation depuis les sources (install, update, install-custom,
// que la façade lui relaie), moteur de l'image, Strata — lui rapportent chaque
// étape (/internal/facade/v1/build-job/*) : lcAppend, lcPhase, lcFail, lcDone,
// startLcJob et lcJobSnapshot passent par elle, et le Go ne restaure plus de
// job au démarrage (la façade s'en charge).
const facadeBackendsEnv = "LOKI_BACKENDS"
// facadeBackends : la façade tient-elle les backends et le job ?
func facadeBackends() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeBackendsEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeBackendsRoutes : les routes que la façade sert alors (backends/mod.rs).
var facadeBackendsRoutes = map[string]bool{
"/api/llamacpp": true,
"/api/llamacpp/check": true,
"/api/llamacpp/prebuilt": true,
"/api/llamacpp/prebuilt/check": true,
"/api/llamacpp/use": true,
"/api/llamacpp/job": true,
"/api/llamacpp/uninstall-custom": true,
"/api/backends": true,
"/api/backends/custom": true,
"/api/backends/devices": true,
}
// facadeJobClient : appels courts (une ligne de journal, une phase).
var facadeJobClient = &http.Client{Timeout: 30 * time.Second}
// facadeJobCall envoie une étape du job à la façade. out peut être nil.
// Une réponse 409 rend l'erreur que la façade explique (job déjà en cours).
func facadeJobCall(op string, body map[string]any, out any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/build-job/"+op, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeJobClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusConflict {
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error == "" {
e.Error = resp.Status
}
return errors.New(e.Error)
}
if resp.StatusCode >= 300 {
return fmt.Errorf("façade : %s", resp.Status)
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// facadeJobNote : une étape dont l'échec ne doit pas arrêter le job (le
// journal perd une ligne, rien de plus) — tracée sur la sortie d'erreur.
func facadeJobNote(op string, body map[string]any) {
if err := facadeJobCall(op, body, nil); err != nil {
fmt.Fprintf(os.Stderr, "[job] étape %s non transmise à la façade : %v\n", op, err)
}
}
// facadeJobStart démarre le job côté façade, puis son corps ici.
func facadeJobStart(action string, run func()) error {
if err := facadeJobCall("start", map[string]any{"action": action}, nil); err != nil {
return err
}
setBuildSink(lcAppend)
go func() {
defer func() {
setBuildSink(nil)
facadeJobNote("end", map[string]any{})
}()
run()
}()
return nil
}
// facadeJobSnapshot : la vue du job tenue par la façade.
func facadeJobSnapshot(from int, withLines bool) map[string]any {
lines := "0"
if withLines {
lines = "1"
}
req, err := http.NewRequest(http.MethodGet, "http://"+facadeAddr()+
"/internal/facade/v1/build-job/snapshot?from="+strconv.Itoa(from)+"&lines="+lines, nil)
if err != nil {
return map[string]any{"exists": false}
}
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeJobClient.Do(req)
if err != nil {
return map[string]any{"exists": false}
}
defer resp.Body.Close()
var out map[string]any
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&out) != nil {
return map[string]any{"exists": false}
}
return out
}
// lcSetCommit note le commit d'avant ("old") ou d'après ("new") une mise à
// jour.
func lcSetCommit(which, commit string) {
if facadeBackends() {
facadeJobNote("commits", map[string]any{which: commit})
return
}
lcMu.Lock()
if lcCur != nil {
if which == "old" {
lcCur.OldCommit = commit
} else {
lcCur.NewCommit = commit
}
}
lcMu.Unlock()
}
+3 -1
View File
@@ -151,7 +151,7 @@ var facadeChatRoutes = map[string]bool{
// mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du
// moteur (facade_engine.go), les interrupteurs système (facade_system.go), les
// projets (facade_projects.go), les serveurs MCP (facade_mcp.go), la mise à
// jour (facade_update.go).
// jour (facade_update.go), les backends llama.cpp (facade_backends.go).
// Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le
// jeton interne qui en tient lieu là-bas.
func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
@@ -182,6 +182,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
held, unavailable = facadeMCP, "serveurs MCP indisponibles : "
case facadeUpdateRoutes[path]:
held, unavailable = facadeUpdate, "mise à jour indisponible : "
case facadeBackendsRoutes[path]:
held, unavailable = facadeBackends, "moteurs indisponibles : "
default:
return h
}
+137
View File
@@ -2,6 +2,7 @@ package loki
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -927,3 +928,139 @@ func TestFacadeMiseAJour(t *testing.T) {
}
}
}
// Lot 22 : les routes des backends partent à la façade, et les jobs du Go
// (compilation, moteur de l'image, Strata) lui rapportent chaque étape — le
// job vit chez elle.
func TestFacadeBackends(t *testing.T) {
testHome(t)
resetBackendsState()
t.Cleanup(resetBackendsState)
var mu sync.Mutex
var calls []string
busy := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest {
w.WriteHeader(403)
return
}
b, _ := io.ReadAll(r.Body)
mu.Lock()
calls = append(calls, r.Method+" "+r.URL.RequestURI()+" "+string(b))
refuse := busy
mu.Unlock()
switch r.URL.Path {
case "/internal/facade/v1/build-job/start":
if refuse {
w.WriteHeader(409)
io.WriteString(w, `{"ok":false,"error":"un job engine est déjà en cours"}`)
return
}
io.WriteString(w, `{"ok":true}`)
case "/internal/facade/v1/build-job/snapshot":
io.WriteString(w, `{"exists":true,"action":"engine","running":true}`)
default:
io.WriteString(w, `{"ok":true,"de":"facade"}`)
}
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
t.Setenv(facadeBackendsEnv, "")
w := httptest.NewRecorder()
withFacadeChat("/api/llamacpp", local)(w, httptest.NewRequest("GET", "/api/llamacpp", nil))
if w.Body.String() != "go" || len(calls) != 0 {
t.Fatalf("sans LOKI_BACKENDS : %q", w.Body.String())
}
t.Setenv(facadeBackendsEnv, "rust")
for p := range facadeBackendsRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if !strings.Contains(w.Body.String(), "facade") {
t.Fatalf("%s non relayé : %q", p, w.Body.String())
}
}
// La compilation reste au Go.
for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} {
if facadeBackendsRoutes[p] {
t.Fatalf("%s ne doit pas partir à la façade", p)
}
}
mu.Lock()
calls = nil
mu.Unlock()
// Un job du Go : chaque étape part à la façade, dans l'ordre.
done := make(chan struct{})
if err := startLcJob("engine", func() {
lcPhase("recherche…")
emitBuildLine("[ 10%] Building CXX object a/b.cpp.o")
lcSetCommit("old", "")
lcSetCommit("new", "bbb")
lcFail(errors.New("raté"))
lcDone("fini")
close(done)
}); err != nil {
t.Fatal(err)
}
<-done
want := []string{
`POST /internal/facade/v1/build-job/start {"action":"engine"}`,
`POST /internal/facade/v1/build-job/phase {"phase":"recherche…"}`,
`POST /internal/facade/v1/build-job/line {"line":"[ 10%] Building CXX object a/b.cpp.o"}`,
`POST /internal/facade/v1/build-job/commits {"old":""}`,
`POST /internal/facade/v1/build-job/commits {"new":"bbb"}`,
`POST /internal/facade/v1/build-job/fail {"error":"raté"}`,
`POST /internal/facade/v1/build-job/done {"msg":"fini"}`,
`POST /internal/facade/v1/build-job/end {}`,
}
for i := 0; i < 100; i++ {
mu.Lock()
n := len(calls)
mu.Unlock()
if n >= len(want) {
break
}
time.Sleep(10 * time.Millisecond)
}
mu.Lock()
got := append([]string(nil), calls...)
calls = nil
busy = true
mu.Unlock()
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("étapes transmises :\n%s", strings.Join(got, "\n"))
}
// Rien ne reste dans le job du Go, et le sink est retiré.
if lcCur != nil {
t.Fatalf("job tenu par le Go : %+v", lcCur)
}
// Un second job : la façade le refuse, le Go le dit.
if err := startLcJob("strata", func() { t.Error("ne doit pas tourner") }); err == nil || err.Error() != "un job engine est déjà en cours" {
t.Fatalf("refus : %v", err)
}
// La vue du job (statut du moteur de l'image) vient de la façade.
if v := lcJobSnapshot(0, false); v["action"] != "engine" || v["running"] != true {
t.Fatalf("vue : %v", v)
}
mu.Lock()
last := calls[len(calls)-1]
mu.Unlock()
if last != "GET /internal/facade/v1/build-job/snapshot?from=0&lines=0 " {
t.Fatalf("vue demandée : %q", last)
}
if v := lcJobSnapshot(3, true); v["exists"] != true {
t.Fatalf("vue avec lignes : %v", v)
}
// Façade injoignable : le job ne démarre pas, la vue dit « aucun job ».
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if err := startLcJob("engine", func() { t.Error("ne doit pas tourner") }); err == nil {
t.Fatal("job démarré sans façade")
}
if v := lcJobSnapshot(0, true); v["exists"] != false {
t.Fatalf("vue sans façade : %v", v)
}
lcAppend("perdue") // tracée, sans panique
}
+922
View File
@@ -0,0 +1,922 @@
//go:build linux
package loki
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"testing"
"time"
)
// parity_backends_test.go — journal de référence des backends llama.cpp
// (lot 22 de la migration Rust) : statut (dépôt git, binaire, moteur de
// l'image, précompilé, moteur téléchargé, plan de build et conseil selon la
// machine), vérification git, backends custom et leur désinstallation,
// bascule fast/opt, vérification de la dernière release, job des binaires
// précompilés (téléchargement, extraction avec liens, ménage, marqueur
// VERSION, échecs), GPU vus par un moteur (caches mémoire et disque), puis le
// job lui-même pas à pas et sa reprise au démarrage. Les machines sont de faux
// outils dans le PATH (nvidia-smi, nvcc, hipcc, glslc, vulkaninfo, ldconfig) ;
// GitHub est un faux serveur. Rangé dans
// rust/loki-rs/tests/parity_backends/backends.json ; tests/backends.rs le
// rejoue par la façade.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteBackends
const parityBackendsFile = "../../rust/loki-rs/tests/parity_backends/backends.json"
// Une entrée d'archive servie par le faux GitHub.
type parityArchEntry struct {
Name string `json:"name"`
Type string `json:"type"` // file | dir | symlink | hardlink
Body string `json:"body,omitempty"`
Link string `json:"link,omitempty"`
Mode int64 `json:"mode,omitempty"`
}
// Ce que sert le faux GitHub : la release (« {{GH}} » = son adresse) et les
// archives par nom.
type parityRelease struct {
Status int `json:"status"`
JSON string `json:"json"`
Assets map[string][]parityArchEntry `json:"assets,omitempty"`
}
type parityBackendsStep struct {
Name string `json:"name"`
Machine string `json:"machine,omitempty"` // outils du PATH (machines)
Git []string `json:"git,omitempty"` // avant les fichiers : init <d> | commit <d> <date> <msg> | clone <src> <dst> | pull <d> | detach <d> | remote-bad <d>
Files map[string]string `json:"files,omitempty"` // sous LOKI_HOME : contenu ; « #! » exécutable ; « -> x » lien ; « - » supprimé ; « / » dossier
Config map[string]string `json:"config,omitempty"` // "" = supprimé
Engine *string `json:"engine_env,omitempty"`
Release *parityRelease `json:"release,omitempty"`
Tune bool `json:"tune,omitempty"`
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body,omitempty"`
Wait bool `json:"wait,omitempty"` // job lancé : attendre sa fin, relire /api/llamacpp/job
Status int `json:"status"`
Out any `json:"out"`
Job any `json:"job,omitempty"`
After map[string]string `json:"after"`
Tree []string `json:"tree"`
}
// Les machines : nom → outil → script.
var parityMachines = map[string]map[string]string{
"nu": {},
"nvidia": {
"nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n-L) echo 'GPU 0: NVIDIA GeForce RTX 3090 (UUID: GPU-1)';;\n--query-gpu=compute_cap) printf '8.6\\n8.9\\n8.6\\n';;\n'') echo '| NVIDIA-SMI 570.00 Driver Version: 570.00 CUDA Version: 12.8 |';;\n--query-gpu=*) printf '0, NVIDIA GeForce RTX 3090, 24576, 100, 8.6, 00000000:01:00.0, 1, 4, 16, 16, 9751\\n1, Carte B, 8192, 5, 7.5, [N/A], [N/A], 3, 8, 16, 7001\\n';;\nesac\n",
"nvcc": "#!/bin/sh\nexit 0\n",
},
"nvidia-sans-nvcc": {
"nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n-L) echo 'GPU 0: NVIDIA (UUID: GPU-1)';;\n--query-gpu=compute_cap) echo '12.0';;\nesac\n",
},
"nvidia-muet": {
"nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n-L) echo 'rien';;\nesac\n",
"nvcc": "#!/bin/sh\nexit 0\n",
},
"nvidia-en-panne": {
"nvidia-smi": "#!/bin/sh\nexit 9\n",
"nvcc": "#!/bin/sh\nexit 0\n",
},
"rocm": {
"hipcc": "#!/bin/sh\nexit 0\n",
},
"glslc-ldconfig": {
"glslc": "#!/bin/sh\nexit 0\n",
"ldconfig": "#!/bin/sh\necho '\tlibvulkan.so.1 (libc6,x86-64) => /usr/lib/libvulkan.so.1'\n",
},
"vulkan": {
"glslc": "#!/bin/sh\nexit 0\n",
"vulkaninfo": "#!/bin/sh\nexit 0\n",
},
"nvidia-jumelles": {
"nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n-L) echo 'GPU 0: Carte B';;\n--query-gpu=*) printf '0, Carte B, 8192, 5, 7.5, [N/A], 1, 3, 8, 16, 7001\\n1, Carte B, 8192, 5, 7.5, [N/A], 1, 3, 8, 16, 7001\\n';;\nesac\n",
},
"liaison-partielle": {
"nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n--query-gpu=*) echo '0, Carte P, 4096, 1, 8.6, 00000000:02:00.0, [N/A], [N/A], 4, 8, 5001';;\nesac\n",
},
"vulkaninfo-seul": {
"vulkaninfo": "#!/bin/sh\nexit 0\n",
},
"arch-ancienne": {
"nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n--query-gpu=index*pci.bus_id*) echo 'Field \"pci.bus_id\" is not a valid field to query.'; exit 2;;\n--query-gpu=*) echo '0, Vieille carte, 4096, 10, 6.1';;\n-L) echo 'GPU 0: Vieille carte';;\nesac\n",
},
}
var (
parityTSRe = regexp.MustCompile(`"(started_at|ended_at)":[1-9][0-9]*`)
parityJobsRe = regexp.MustCompile(`"jobs":[0-9]+`)
)
func TestPariteBackends(t *testing.T) {
for _, p := range []string{"/usr/local/cuda", "/opt/rocm"} {
if _, err := os.Stat(p); err == nil {
t.Skipf("%s présent : la machine fausserait le journal", p)
}
}
if m, _ := filepath.Glob("/usr/local/cuda-*"); len(m) > 0 {
t.Skip("CUDA installé : la machine fausserait le journal")
}
gitBin, err := exec.LookPath("git")
if err != nil {
t.Skip("git absent")
}
home := testHome(t)
t.Setenv("LOKI_ENGINE_BIN", "")
tools := t.TempDir()
t.Setenv("PATH", tools)
resetBackendsState()
t.Cleanup(resetBackendsState)
// Le faux GitHub.
var mu sync.Mutex
var rel parityRelease
gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
cur := rel
mu.Unlock()
if r.URL.Path == "/latest" {
w.WriteHeader(cur.Status)
_, _ = w.Write([]byte(strings.ReplaceAll(cur.JSON, "{{GH}}", "http://"+r.Host)))
return
}
entries, ok := cur.Assets[strings.TrimPrefix(r.URL.Path, "/dl/")]
if !ok {
http.NotFound(w, r)
return
}
_, _ = w.Write(parityTarGz(t, entries))
}))
defer gh.Close()
old := llamaReleasesAPI
llamaReleasesAPI = gh.URL + "/latest"
defer func() { llamaReleasesAPI = old }()
mask := func(s string) string {
s = strings.ReplaceAll(s, gh.URL, "{{GH}}")
s = strings.ReplaceAll(s, home, "{{HOME}}")
s = parityJobsRe.ReplaceAllString(s, `"jobs":"{{JOBS}}"`)
return parityTSRe.ReplaceAllString(s, `"$1":"{{T}}"`)
}
unmask := func(s string) string { return strings.ReplaceAll(s, "{{HOME}}", home) }
handlers := map[string]http.HandlerFunc{
"/api/llamacpp": handleLlamacpp,
"/api/llamacpp/check": handleLlamacppCheck,
"/api/llamacpp/prebuilt/check": handleLlamacppPrebuiltCheck,
"/api/llamacpp/prebuilt": handleLlamacppPrebuilt,
"/api/llamacpp/use": handleLlamacppUse,
"/api/llamacpp/job": handleLlamacppJob,
"/api/llamacpp/uninstall-custom": handleLlamacppUninstallCustom,
"/api/backends": handleBackends,
"/api/backends/custom": handleBackendsCustom,
"/api/backends/devices": handleBackendDevices,
}
call := func(method, path, body string) (int, any) {
req := httptest.NewRequest(method, path, strings.NewReader(body))
rec := httptest.NewRecorder()
handlers[strings.SplitN(path, "?", 2)[0]](rec, req)
var out any
if err := json.Unmarshal([]byte(mask(rec.Body.String())), &out); err != nil {
t.Fatalf("%s : corps illisible %q", path, rec.Body.String())
}
return rec.Code, out
}
machine := func(name string) {
entries, _ := os.ReadDir(tools)
for _, e := range entries {
_ = os.Remove(filepath.Join(tools, e.Name()))
}
if err := os.Symlink(gitBin, filepath.Join(tools, "git")); err != nil {
t.Fatal(err)
}
for tool, script := range parityMachines[name] {
if err := os.WriteFile(filepath.Join(tools, tool), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
}
}
machine("nu")
run := func(st parityBackendsStep) parityBackendsStep {
if st.Machine != "" {
machine(st.Machine)
}
for _, g := range st.Git {
parityGit(t, gitBin, home, g)
}
files := map[string]string{}
for k, v := range st.Files {
files[k] = unmask(v)
}
parityApplyFiles(t, home, files)
for k, v := range st.Config {
if err := SetConfigKey(k, unmask(v)); err != nil {
t.Fatal(err)
}
}
if st.Engine != nil {
t.Setenv("LOKI_ENGINE_BIN", unmask(*st.Engine))
}
if st.Release != nil {
mu.Lock()
rel = *st.Release
mu.Unlock()
}
var lock *tuneLock
if st.Tune {
l, err := tuneLockAcquire("web", false)
if err != nil {
t.Fatal(err)
}
lock = l
}
st.Status, st.Out = call(st.Method, st.Path, unmask(st.Body))
if lock != nil {
lock.release()
}
if st.Wait {
deadline := time.Now().Add(30 * time.Second)
for lcJobSnapshot(0, false)["running"] == true {
if time.Now().After(deadline) {
t.Fatalf("%s : le job ne finit pas", st.Name)
}
time.Sleep(20 * time.Millisecond)
}
_, st.Job = call("GET", "/api/llamacpp/job?from=0", "")
}
st.After = map[string]string{}
if v := ReadConfig()["BIN"]; v != "" {
st.After["config/BIN"] = mask(v)
}
if v := getStr(bkState, "build_job"); v != "" {
st.After["state/build_job"] = mask(v)
}
if b, err := os.ReadFile(filepath.Join(home, "loki-build.log")); err == nil {
st.After["loki-build.log"] = mask(string(b))
}
if b, err := os.ReadFile(filepath.Join(home, "devices.json")); err == nil {
st.After["devices.json"] = mask(string(b))
}
st.Tree = parityTree(t, home)
for i := range st.Tree {
st.Tree[i] = mask(st.Tree[i])
}
return st
}
str := func(s string) *string { return &s }
tarOK := []parityArchEntry{
{Name: "llama-b300/", Type: "dir", Mode: 0o755},
{Name: "llama-b300/llama-server", Type: "file", Body: "#!/bin/sh\necho serveur\n", Mode: 0o750},
{Name: "llama-b300/libggml.so.1.0", Type: "file", Body: "lib", Mode: 0o644},
{Name: "llama-b300/libggml.so.1", Type: "symlink", Link: "libggml.so.1.0"},
{Name: "llama-b300/libggml.so", Type: "symlink", Link: "absente.so"},
{Name: "llama-b300/llama-cli", Type: "hardlink", Link: "llama-b300/llama-server"},
{Name: "llama-b300/sous/doc.txt", Type: "file", Body: "doc", Mode: 0o600},
}
relJSON := func(tag string, names ...string) string {
var assets []string
for _, n := range names {
assets = append(assets, `{"name":"`+n+`","browser_download_url":"{{GH}}/dl/`+n+`","size":2500000}`)
}
return `{"tag_name":"` + tag + `","assets":[` + strings.Join(assets, ",") + `]}`
}
cpu := "llama-b300-bin-ubuntu-x64.tar.gz"
if runtime.GOARCH == "arm64" {
cpu = "llama-b300-bin-ubuntu-arm64.tar.gz"
}
allAssets := []string{"llama-b300-bin-macos-arm64.zip", "cudart-llama-bin-win-cuda-12.4-x64.zip",
"llama-b300-bin-win-cuda-12.4-x64.zip", "llama-b300-bin-ubuntu-vulkan-x64.tar.gz",
"llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz", "llama-b300-bin-ubuntu-vulkan-arm64.tar.gz",
"llama-b300-bin-ubuntu-arm64.tar.gz", "llama-b300-bin-ubuntu-x64.tar.gz"}
okRel := &parityRelease{Status: 200, JSON: relJSON("b300", allAssets...),
Assets: map[string][]parityArchEntry{cpu: tarOK}}
pb := "backends/llama.cpp-prebuilt/"
var steps []parityBackendsStep
for _, st := range []parityBackendsStep{
// --- statut, rien d'installé ---
{Name: "statut vide", Method: "GET", Path: "/api/llamacpp"},
{Name: "backends vides", Method: "GET", Path: "/api/backends"},
{Name: "custom vides", Method: "GET", Path: "/api/backends/custom"},
{Name: "job absent", Method: "GET", Path: "/api/llamacpp/job"},
// --- précompilé déjà là ---
{Name: "deux versions précompilées", Files: map[string]string{
pb + "llama-b100/llama-server": "#!/bin/sh\n",
pb + "llama-b200/build/bin/llama-server": "#!/bin/sh\n",
pb + "VERSION": "b100 12.4 fmt2\n",
}, Config: map[string]string{"BIN": "{{HOME}}/" + pb + "llama-b200/build/bin/llama-server"},
Method: "GET", Path: "/api/llamacpp"},
{Name: "VERSION absent : la plus récente", Files: map[string]string{pb + "VERSION": "-"},
Method: "GET", Path: "/api/llamacpp"},
{Name: "BIN précompilé disparu", Config: map[string]string{"BIN": "{{HOME}}/" + pb + "llama-b050/llama-server"},
Method: "GET", Path: "/api/llamacpp"},
// --- backends custom ---
{Name: "backends custom", Files: map[string]string{
"backends/fork-a/build/bin/llama-server": "#!/bin/sh\n",
"backends/fork-a/CMakeLists.txt": "",
"backends/fork-b/bin/llama-server": "#!/bin/sh\n",
"backends/fork-c/build/bin/llama-server": "/",
"backends/fork-c/llama-server.exe": "x",
"backends/.cache/llama-server": "x",
"backends/vide/": "/",
"backends/lien": "-> fork-b",
"backends/fichier": "x",
pb + "bin/llama-server": "#!/bin/sh\n", // vu par le balayage, mais pas un backend custom
}, Config: map[string]string{"BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server"},
Method: "GET", Path: "/api/backends"},
{Name: "custom seulement", Method: "GET", Path: "/api/backends/custom"},
{Name: "statut sur un fork", Method: "GET", Path: "/api/llamacpp"},
{Name: "désinstaller le canonique", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"llama.cpp"}`},
{Name: "désinstaller le précompilé", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":" LLAMA.CPP-prebuilt "}`},
{Name: "désinstaller sans nom", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":""}`},
{Name: "désinstaller, corps illisible", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{`},
{Name: "désinstaller l'actif", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"Fork-A"}`},
{Name: "désinstaller par un chemin", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"../fork-b"}`},
{Name: "désinstaller un absent", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"fork-z"}`},
{Name: "désinstaller un lien", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"lien"}`},
{Name: "désinstaller un fichier", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"fichier"}`},
{Name: "désinstaller un dossier sans binaire", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"vide"}`},
// --- dépôt git du build local ---
{Name: "dépôt cloné", Git: []string{
"init src", "commit src 2024-01-02T03:04:05+00:00 premier état",
"clone src backends/llama.cpp",
}, Files: map[string]string{"backends/llama.cpp/build/bin/llama-server": "#!/bin/sh\n"},
Config: map[string]string{"BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server"},
Method: "GET", Path: "/api/llamacpp"},
{Name: "amont avancé, pas encore vu", Git: []string{"commit src 2024-02-03T04:05:06+00:00 <second> & état"},
Method: "GET", Path: "/api/llamacpp"},
{Name: "vérification git", Method: "POST", Path: "/api/llamacpp/check"},
{Name: "retard connu", Method: "GET", Path: "/api/llamacpp"},
{Name: "binaire Release", Files: map[string]string{
"backends/llama.cpp/build/bin/llama-server": "-",
"backends/llama.cpp/build/bin/Release/llama-server": "#!/bin/sh\n",
}, Method: "POST", Path: "/api/llamacpp/check"},
{Name: "sans binaire", Files: map[string]string{"backends/llama.cpp/build/bin/Release/llama-server": "-"},
Method: "POST", Path: "/api/llamacpp/check"},
{Name: "à jour, sans binaire", Git: []string{"pull backends/llama.cpp"}, Method: "POST", Path: "/api/llamacpp/check"},
{Name: "tête détachée", Git: []string{"detach backends/llama.cpp"}, Method: "GET", Path: "/api/llamacpp"},
{Name: "vérification, tête détachée", Method: "POST", Path: "/api/llamacpp/check"},
{Name: "origine perdue", Git: []string{"remote-bad backends/llama.cpp"}, Method: "POST", Path: "/api/llamacpp/check"},
{Name: "pas de dépôt", Config: map[string]string{"BIN": "/nulle/part/llama-server"},
Files: map[string]string{"backends/llama.cpp/.git": "-"}, Method: "POST", Path: "/api/llamacpp/check"},
// --- bascule fast / opt ---
{Name: "bascule fast", Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"fast"}`},
{Name: "bascule opt sans build", Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"opt"}`},
{Name: "bascule opt", Files: map[string]string{"backends/llama.cpp/build/llama-server": "#!/bin/sh\n"},
Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"opt"}`},
{Name: "bascule opt depuis un fork", Config: map[string]string{"BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server"},
Files: map[string]string{"backends/fork-a/build/llama-server": "#!/bin/sh\n"},
Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"opt"}`},
{Name: "mode inconnu", Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"turbo"}`},
{Name: "mode absent", Method: "POST", Path: "/api/llamacpp/use", Body: ``},
{Name: "mode de travers", Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":1}`},
{Name: "bascule pendant une optimisation", Tune: true, Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"fast"}`},
{Name: "fast sans précompilé", Files: map[string]string{pb: "-"},
Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"fast"}`},
// --- moteur téléchargé, moteur de l'image ---
{Name: "moteur téléchargé", Files: map[string]string{"engine/server-cuda-b10450/llama-server": "#!/bin/sh\n"},
Config: map[string]string{"BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server"},
Method: "GET", Path: "/api/llamacpp"},
{Name: "moteur de l'image", Engine: str(" {{HOME}}/engine/server-cuda-b10450/llama-server "),
Method: "GET", Path: "/api/llamacpp"},
{Name: "moteur de l'image absent", Engine: str("{{HOME}}/image/llama-server"),
Method: "GET", Path: "/api/llamacpp"},
{Name: "moteur de l'image : un dossier", Engine: str("{{HOME}}/engine"),
Method: "GET", Path: "/api/llamacpp"},
// --- dernière release ---
{Name: "release : CPU", Release: okRel, Engine: str(""), Method: "POST", Path: "/api/llamacpp/prebuilt/check"},
{Name: "release refusée", Release: &parityRelease{Status: 403, JSON: `{"message":"rate limit"}`},
Method: "POST", Path: "/api/llamacpp/prebuilt/check"},
{Name: "release sans tag", Release: &parityRelease{Status: 200, JSON: `{"tag_name":"","assets":[]}`},
Method: "POST", Path: "/api/llamacpp/prebuilt/check"},
{Name: "release illisible", Release: &parityRelease{Status: 200, JSON: `{"tag_name":`},
Method: "POST", Path: "/api/llamacpp/prebuilt/check"},
{Name: "release vide", Release: &parityRelease{Status: 200, JSON: ``},
Method: "POST", Path: "/api/llamacpp/prebuilt/check"},
{Name: "release, tag de travers", Release: &parityRelease{Status: 200, JSON: `{"tag_name":5}`},
Method: "POST", Path: "/api/llamacpp/prebuilt/check"},
{Name: "release sans asset adapté", Release: &parityRelease{Status: 200, JSON: relJSON("b301", "llama-b301-bin-macos-arm64.zip")},
Method: "POST", Path: "/api/llamacpp/prebuilt/check"},
// --- job des binaires précompilés ---
{Name: "installation", Release: okRel, Files: map[string]string{
pb + "llama-b100/llama-server": "#!/bin/sh\n",
pb + "llama-b200/llama-server": "#!/bin/sh\n",
pb + "llama-b30/llama-server": "#!/bin/sh\n", // préfixe de llama-b300 : supprimé quand même
pb + "autre/llama-server": "#!/bin/sh\n",
pb + "VERSION": "b100 12.4 fmt2\n",
}, Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true},
{Name: "statut après installation", Method: "GET", Path: "/api/llamacpp"},
{Name: "release à jour", Method: "POST", Path: "/api/llamacpp/prebuilt/check"},
{Name: "lignes à partir de 3", Method: "GET", Path: "/api/llamacpp/job?from=3"},
{Name: "lignes au-delà", Method: "GET", Path: "/api/llamacpp/job?from=99"},
{Name: "lignes, from négatif", Method: "GET", Path: "/api/llamacpp/job?from=-4"},
{Name: "lignes, from illisible", Method: "GET", Path: "/api/llamacpp/job?from=abc"},
{Name: "lignes, from démesuré", Method: "GET", Path: "/api/llamacpp/job?from=99999999999999999999"},
{Name: "déjà à jour", Files: map[string]string{pb + "llama-b250/x": ""},
Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true},
{Name: "ancien format : réextrait", Files: map[string]string{pb + "VERSION": "b300 \n"},
Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true},
{Name: "pendant une optimisation", Tune: true, Method: "POST", Path: "/api/llamacpp/prebuilt"},
{Name: "GitHub refuse", Release: &parityRelease{Status: 403, JSON: `{}`},
Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true},
{Name: "rien d'adapté", Release: &parityRelease{Status: 200, JSON: relJSON("b302", "llama-b302-bin-macos-arm64.zip")},
Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true},
{Name: "archive absente", Release: &parityRelease{Status: 200, JSON: relJSON("b303", allAssets...)},
Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true},
{Name: "archive piégée", Release: &parityRelease{Status: 200, JSON: relJSON("b304", allAssets...),
Assets: map[string][]parityArchEntry{cpu: {
{Name: "llama-b304/ok", Type: "file", Body: "x", Mode: 0o644},
{Name: "../evasion", Type: "file", Body: "x", Mode: 0o644},
}}}, Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true},
{Name: "archive sans serveur", Release: &parityRelease{Status: 200, JSON: relJSON("b305", allAssets...),
Assets: map[string][]parityArchEntry{cpu: {
{Name: "llama-b305/llama-cli", Type: "file", Body: "x", Mode: 0o755},
}}}, Files: map[string]string{pb: "-"}, Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true},
{Name: "statut après échecs", Method: "GET", Path: "/api/llamacpp"},
// --- GPU vus par un moteur ---
{Name: "GPU sans moteur", Config: map[string]string{"BIN": ""}, Method: "POST", Path: "/api/backends/devices", Body: `{}`},
{Name: "GPU d'un moteur absent", Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"/nulle/part/llama-server"}`},
{Name: "GPU : pas un llama-server", Files: map[string]string{"outils/autre": "#!/bin/sh\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/outils/autre"}`},
{Name: "GPU listés", Machine: "nvidia", Files: map[string]string{
"gpu/LLAMA-SERVER": "#!/bin/sh\n[ \"$1\" = --list-devices ] || exit 7\necho 'Available devices:'\necho \" CUDA0: NVIDIA GeForce RTX 3090 (0 MiB, 0 MiB free)\"\necho \" CUDA1: Carte B (8192 MiB, 8000 MiB free)\"\necho \" Vulkan0: NVIDIA GeForce RTX 3090 (24576 MiB, 20000 MiB free)\"\necho \" CUDA2: ordre ${CUDA_DEVICE_ORDER:-aucun} cvd ${CUDA_VISIBLE_DEVICES:-aucun} (1 MiB, 1 MiB free)\" >&2\n",
}, Method: "POST", Path: "/api/backends/devices", Body: `{"bin":" {{HOME}}/gpu/LLAMA-SERVER "}`},
{Name: "GPU en cache", Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: changé (1 MiB, 1 MiB free)'\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/gpu/LLAMA-SERVER"}`},
{Name: "GPU filtrés", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "BIN": "{{HOME}}/gpu/LLAMA-SERVER"},
Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho \" CUDA0: ordre $CUDA_DEVICE_ORDER cvd $CUDA_VISIBLE_DEVICES (10 MiB, 5 MiB free)\"\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{"bin":""}`},
{Name: "GPU : mémoire nulle non figée", Machine: "nu", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "0"},
Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Saturée (0 MiB, 0 MiB free)'\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{}`},
{Name: "GPU : relu, pas en cache", Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Saturée (100 MiB, 0 MiB free)'\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{}`},
{Name: "GPU : moteur planté, repli disque", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0"},
Files: map[string]string{"gpu/llama-server": "#!/bin/sh\necho ' CUDA0: partielle (1 MiB, 1 MiB free)'\nexit 3\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/gpu/llama-server"}`},
{Name: "GPU : planté, partiel", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "2"},
Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/gpu/llama-server"}`},
{Name: "GPU : planté, rien", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "3"},
Files: map[string]string{"gpu/llama-server": "#!/bin/sh\necho 'CUDA error: out of memory'\nexit 4\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/gpu/llama-server"}`},
{Name: "GPU : tué", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "4"},
Files: map[string]string{"gpu/llama-server": "#!/bin/sh\nkill -9 $$\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/gpu/llama-server"}`},
{Name: "GPU : non exécutable", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "5"},
Files: map[string]string{"gpu/llama-server": "pas un script"},
Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/gpu/llama-server"}`},
{Name: "GPU : corps illisible, BIN de la config", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "6"},
Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Lue (2 MiB, 1 MiB free)'\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{"bin":`},
{Name: "GPU : pilote ancien", Machine: "arch-ancienne", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "7"},
Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Vieille carte (0 MiB, 0 MiB free)'\necho ' CUDA1: Vieille carte (0 MiB, 0 MiB free)'\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{}`},
{Name: "GPU : deux cartes de même nom", Machine: "nvidia", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "8"},
Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Carte B (0 MiB, 0 MiB free)'\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{}`},
{Name: "GPU : lu à vide, gardé sur disque", Machine: "nu", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "9"},
Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Pleine (0 MiB, 0 MiB free)'\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{}`},
{Name: "GPU : cartes jumelles chez nvidia-smi", Machine: "nvidia-jumelles", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "10"},
Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Carte B (0 MiB, 0 MiB free)'\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{}`},
{Name: "GPU : liaison sans génération connue", Machine: "liaison-partielle", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "11"},
Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Carte P (4096 MiB, 4000 MiB free)'\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{}`},
{Name: "GPU : liste vide sur le disque, pas de repli", Machine: "nu", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "vide"},
Files: map[string]string{
"devices.json": `{"{{HOME}}/gpu/LLAMA-SERVER\u0000vide": []}`,
"gpu/LLAMA-SERVER": "#!/bin/sh\nexit 3\n",
}, Method: "POST", Path: "/api/backends/devices", Body: `{}`},
{Name: "GPU : planté, repli sur le disque",
Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: partielle (1 MiB, 1 MiB free)'\nexit 3\n"},
Method: "POST", Path: "/api/backends/devices", Body: `{}`},
} {
steps = append(steps, run(st))
}
// --- plan de build, conseil et release, machine par machine ---
type planCase struct {
Machine string `json:"machine"`
Plan any `json:"plan"`
Reco any `json:"reco"`
Check any `json:"check"`
}
var plans []planCase
machineNames := make([]string, 0, len(parityMachines))
for n := range parityMachines {
machineNames = append(machineNames, n)
}
sort.Strings(machineNames)
planRel := parityRelease{Status: 200, JSON: relJSON("b400", allAssets...)}
mu.Lock()
rel = planRel
mu.Unlock()
for _, n := range machineNames {
machine(n)
_, out := call("GET", "/api/llamacpp", "")
m := out.(map[string]any)
_, check := call("POST", "/api/llamacpp/prebuilt/check", "")
plans = append(plans, planCase{Machine: n, Plan: m["plan"], Reco: m["reco"], Check: check})
}
machine("vulkaninfo-seul")
sansVulkanRel := parityRelease{Status: 200, JSON: relJSON("b401", "llama-b401-bin-ubuntu-rocm-7.0-x64.tar.gz", "llama-b401-bin-ubuntu-x64.tar.gz", "llama-b401-bin-ubuntu-arm64.tar.gz")}
mu.Lock()
rel = sansVulkanRel
mu.Unlock()
_, sansVulkan := call("POST", "/api/llamacpp/prebuilt/check", "")
machine("rocm")
rocmRel := parityRelease{Status: 200, JSON: relJSON("b402", "llama-b402-bin-ubuntu-vulkan-x64.tar.gz", "llama-b402-bin-ubuntu-x64.tar.gz", "llama-b402-bin-ubuntu-vulkan-arm64.tar.gz", "llama-b402-bin-ubuntu-arm64.tar.gz")}
mu.Lock()
rel = rocmRel
mu.Unlock()
_, rocmSansAsset := call("POST", "/api/llamacpp/prebuilt/check", "")
got, err := parityJSON(map[string]any{
"goarch": runtime.GOARCH,
"machines": parityMachines,
"steps": steps,
"plans": plans,
"plan_release": planRel,
"sans_vulkan": map[string]any{"release": sansVulkanRel, "out": sansVulkan},
"rocm_sans_asset": map[string]any{"release": rocmRel, "out": rocmSansAsset},
"job": parityJobScript(t, home),
})
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityBackendsFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityBackendsFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityBackendsFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if string(got) != string(want) {
t.Errorf("les backends ne produisent plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteBackends,\n"+
"puis cargo test --test backends dit si le port Rust suit.\n--- obtenu ---\n%s", parityBackendsFile, got)
}
}
// resetBackendsState : pas de job ni de GPU en cache d'un test à l'autre.
func resetBackendsState() {
lcMu.Lock()
lcCur = nil
lcLastSave = time.Time{}
lcMu.Unlock()
setBuildSink(nil)
devCacheMu.Lock()
devCache = map[string]devCacheEntry{}
devCacheMu.Unlock()
}
// parityApplyFiles pose les fichiers d'une étape, dans l'ordre des noms.
func parityApplyFiles(t *testing.T, home string, files map[string]string) {
t.Helper()
names := make([]string, 0, len(files))
for n := range files {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
v := files[n]
p := filepath.Join(home, n)
switch {
case v == "-":
if err := os.RemoveAll(p); err != nil {
t.Fatal(err)
}
continue
case v == "/":
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if target, ok := strings.CutPrefix(v, "-> "); ok {
_ = os.Remove(p)
if err := os.Symlink(target, p); err != nil {
t.Fatal(err)
}
continue
}
mode := os.FileMode(0o644)
if strings.HasPrefix(v, "#!") {
mode = 0o755
}
// Remplacé, pas réécrit en place : un script qui vient de tourner ne
// gêne pas l'écriture (ETXTBSY).
_ = os.Remove(p)
if err := os.WriteFile(p, []byte(v), mode); err != nil {
t.Fatal(err)
}
}
}
// parityGit : les opérations git d'une étape, dates et identité fixées pour
// des commits reproductibles.
func parityGit(t *testing.T, gitBin, home, op string) {
t.Helper()
f := strings.SplitN(op, " ", 4)
gitIn := func(dir, date string, args ...string) {
cmd := exec.Command(gitBin, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
"GIT_AUTHOR_NAME=Loki", "GIT_AUTHOR_EMAIL=loki@exemple.fr",
"GIT_COMMITTER_NAME=Loki", "GIT_COMMITTER_EMAIL=loki@exemple.fr",
"GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v : %v\n%s", args, err, out)
}
}
switch f[0] {
case "init":
dir := filepath.Join(home, f[1])
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
gitIn(dir, "", "init", "-q", "-b", "master")
case "commit":
dir := filepath.Join(home, f[1])
if err := os.WriteFile(filepath.Join(dir, "README"), []byte(f[3]), 0o644); err != nil {
t.Fatal(err)
}
gitIn(dir, f[2], "add", "README")
gitIn(dir, f[2], "commit", "-q", "-m", f[3])
case "clone":
gitIn(home, "", "clone", "-q", filepath.Join(home, f[1]), filepath.Join(home, f[2]))
case "pull":
gitIn(filepath.Join(home, f[1]), "", "pull", "-q", "--ff-only")
case "detach":
gitIn(filepath.Join(home, f[1]), "", "checkout", "-q", "--detach")
case "remote-bad":
gitIn(filepath.Join(home, f[1]), "", "remote", "set-url", "origin", filepath.Join(home, "nulle-part"))
default:
t.Fatalf("opération git inconnue : %s", op)
}
}
// parityTarGz construit une archive .tar.gz.
func parityTarGz(t *testing.T, entries []parityArchEntry) []byte {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, e := range entries {
h := &tar.Header{Name: e.Name, Mode: e.Mode, ModTime: time.Unix(1700000000, 0)}
switch e.Type {
case "dir":
h.Typeflag = tar.TypeDir
case "symlink":
h.Typeflag, h.Linkname, h.Mode = tar.TypeSymlink, e.Link, 0o777
case "hardlink":
h.Typeflag, h.Linkname = tar.TypeLink, e.Link
default:
h.Typeflag, h.Size = tar.TypeReg, int64(len(e.Body))
}
if err := tw.WriteHeader(h); err != nil {
t.Error(err)
}
if h.Typeflag == tar.TypeReg {
_, _ = tw.Write([]byte(e.Body))
}
}
_ = tw.Close()
_ = gz.Close()
return buf.Bytes()
}
// parityTree : ce qu'il y a sous backends/ et engine/ (« d », « f mode », « l
// cible »), trié.
func parityTree(t *testing.T, home string) []string {
var out []string
for _, root := range []string{"backends", "engine"} {
_ = filepath.WalkDir(filepath.Join(home, root), func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(home, p)
if strings.Contains(rel, "/.git/") || strings.HasSuffix(rel, "/.git") {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
info, err := os.Lstat(p)
if err != nil {
return nil
}
switch {
case info.Mode()&os.ModeSymlink != 0:
target, _ := os.Readlink(p)
out = append(out, "l "+rel+" -> "+target)
case info.IsDir():
out = append(out, "d "+rel)
default:
b, _ := os.ReadFile(p)
out = append(out, "f "+rel+" "+info.Mode().Perm().String()+" "+string(b))
}
return nil
})
}
sort.Strings(out)
return out
}
// parityJobScript rejoue le job pas à pas (sans la façade) : début, lignes
// de build (phases déduites, fichiers compilés), troncature à lcMaxLines,
// phases, commits, échec, fin, refus d'un second job, vues à partir d'un
// décalage, état écrit en base et journal ; puis la reprise au démarrage.
func parityJobScript(t *testing.T, home string) any {
resetBackendsState()
_ = os.Remove(lcJobLogPath())
type op struct {
Op string `json:"op"`
Arg string `json:"arg,omitempty"`
From int `json:"from,omitempty"`
Out any `json:"out,omitempty"`
}
tsRe := regexp.MustCompile(`"(started_at|ended_at)":[1-9][0-9]*`)
norm := func(v any) any {
b, _ := json.Marshal(v)
var out any
_ = json.Unmarshal([]byte(tsRe.ReplaceAllString(string(b), `"$1":"{{T}}"`)), &out)
return out
}
var ops []op
done := make(chan struct{})
release := make(chan struct{})
do := func(o op) {
switch o.Op {
case "start":
err := startLcJob(o.Arg, func() { <-release; close(done) })
if err != nil {
o.Out = err.Error()
} else {
o.Out = "ok"
}
case "line":
lcAppend(o.Arg)
case "lines":
for i := 0; i < 4002; i++ {
lcAppend("[" + o.Arg + "] bruit")
}
case "phase":
lcPhase(o.Arg)
case "fail":
lcFail(errors.New(o.Arg))
case "done":
lcDone(o.Arg)
case "old", "new":
lcSetCommit(o.Op, o.Arg)
case "end":
close(release)
<-done
deadline := time.Now().Add(10 * time.Second)
for lcJobSnapshot(0, false)["running"] == true && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
case "snapshot":
o.Out = norm(lcJobSnapshot(o.From, true))
case "summary": // vue longue résumée : nombre de lignes, première, dernière
v := lcJobSnapshot(o.From, true)
lines, _ := v["lines"].([]string)
v["lines"] = map[string]any{"n": len(lines), "first": lines[0], "last": lines[len(lines)-1]}
o.Out = norm(v)
case "head":
o.Out = norm(lcJobSnapshot(0, false))
case "state":
o.Out = tsRe.ReplaceAllString(getStr(bkState, "build_job"), `"$1":"{{T}}"`)
case "log":
b, _ := os.ReadFile(lcJobLogPath())
o.Out = string(b)
}
ops = append(ops, o)
}
for _, o := range []op{
{Op: "head"},
{Op: "line", Arg: "avant tout job"},
{Op: "start", Arg: "update"},
{Op: "state"},
{Op: "start", Arg: "engine"},
{Op: "line", Arg: "-- The C compiler identification is GNU 13.2.0 avec un très long suffixe é"},
{Op: "head"},
{Op: "line", Arg: "[ 12%] Building CXX object ggml/src/CMakeFiles/ggml.dir/ggml.cpp.o"},
{Op: "line", Arg: " Compiling CUDA source file ..\\ggml-cuda\\fattn.cu..."},
{Op: "line", Arg: "softmax.cuh"},
{Op: "line", Arg: "[100%] Linking CXX executable bin/llama-server"},
{Op: "line", Arg: "état : é.cpp"},
{Op: "head"},
{Op: "old", Arg: "abc1234"},
{Op: "phase", Arg: "git pull origin/master…"},
{Op: "new", Arg: ""},
{Op: "state"},
{Op: "line", Arg: "-- Configuring done"}, // moins de 2 s après la dernière écriture : pas réécrit
{Op: "state"},
{Op: "snapshot", From: 2},
{Op: "lines", Arg: "bloc"},
{Op: "snapshot", From: 4005},
{Op: "summary", From: 0},
{Op: "summary", From: 3},
{Op: "line", Arg: "Build files have been written to: /src/build"},
{Op: "summary", From: 0},
{Op: "line", Arg: "une de plus"},
{Op: "summary", From: 0},
{Op: "fail", Arg: "compilation échouée: exit status 2"},
{Op: "done", Arg: "tout de même"},
{Op: "end"},
{Op: "head"},
{Op: "state"},
} {
do(o)
}
logLines := strings.Count(func() string { b, _ := os.ReadFile(lcJobLogPath()); return string(b) }(), "\n")
// Reprise au démarrage : un job noté « en cours » est mort avec le
// processus précédent ; un job fini est rendu tel quel.
type restoreCase struct {
State string `json:"state"`
Log *string `json:"log"`
Out any `json:"out"`
After string `json:"after"`
}
str := func(s string) *string { return &s }
var restores []restoreCase
for _, rc := range []restoreCase{
{State: ``},
{State: `{"action":""}`},
{State: `{"action":"prebuilt","phase":"fini","running":false,"started_at":5,"ended_at":6}`, Log: str("a\nb\n")},
{State: `{"action":"update","phase":"compilation… 3 fichiers","compiled":3,"running":true,"started_at":5,"old":"abc"}`, Log: str("x\n")},
{State: `{"action":"strata","running":true}`},
{State: `{"action":"engine","running":false}`, Log: str("")},
{State: `{"action":"engine","running":false}`, Log: str("\n\n")},
{State: `pas du json`},
{State: `{"action":"engine","compiled":"3"}`},
} {
resetBackendsState()
if rc.State == "" {
_ = putStr(bkState, "build_job", "")
} else if err := putStr(bkState, "build_job", rc.State); err != nil {
t.Fatal(err)
}
_ = os.Remove(lcJobLogPath())
if rc.Log != nil {
if err := os.WriteFile(lcJobLogPath(), []byte(*rc.Log), 0o644); err != nil {
t.Fatal(err)
}
}
lcRestore()
rc.Out = norm(lcJobSnapshot(0, true))
rc.After = tsRe.ReplaceAllString(getStr(bkState, "build_job"), `"$1":"{{T}}"`)
restores = append(restores, rc)
}
// Journal de plus de lcMaxLines lignes.
resetBackendsState()
var long strings.Builder
for i := 0; i < lcMaxLines+3; i++ {
long.WriteString("ligne\n")
}
_ = putStr(bkState, "build_job", `{"action":"update","running":false}`)
_ = os.WriteFile(lcJobLogPath(), []byte(long.String()), 0o644)
lcRestore()
snap := lcJobSnapshot(lcMaxLines, true)
resetBackendsState()
_ = putStr(bkState, "build_job", "")
_ = os.Remove(lcJobLogPath())
return map[string]any{"ops": ops, "log_lines": logLines, "restores": restores, "long_restore": norm(snap)}
}
+24 -6
View File
@@ -50,6 +50,10 @@ const lcMaxLines = 4000
// compteur de fichiers compilés à partir du contenu (mêmes heuristiques que le
// spinner du CLI). Appelée par le sink de build (emitBuildLine).
func lcAppend(line string) {
if facadeBackends() {
facadeJobNote("line", map[string]any{"line": line})
return
}
lcMu.Lock()
defer lcMu.Unlock()
if lcCur == nil {
@@ -74,6 +78,10 @@ func lcAppend(line string) {
// lcPhase pose une phase explicite (étapes hors build : clone, fetch, service…)
// et la trace aussi dans le log.
func lcPhase(phase string) {
if facadeBackends() {
facadeJobNote("phase", map[string]any{"phase": phase})
return
}
lcMu.Lock()
if lcCur != nil {
lcCur.Phase = phase
@@ -91,6 +99,9 @@ func startLcJob(action string, run func()) error {
if err := tuneGuard(); err != nil {
return err
}
if facadeBackends() { // le job est tenu par la façade (facade_backends.go)
return facadeJobStart(action, run)
}
lcMu.Lock()
defer lcMu.Unlock()
if lcCur != nil && lcCur.Running {
@@ -115,6 +126,10 @@ func startLcJob(action string, run func()) error {
}
func lcFail(err error) {
if facadeBackends() {
facadeJobNote("fail", map[string]any{"error": err.Error()})
return
}
lcMu.Lock()
if lcCur != nil {
lcCur.Err = err.Error()
@@ -127,6 +142,10 @@ func lcFail(err error) {
}
func lcDone(msg string) {
if facadeBackends() {
facadeJobNote("done", map[string]any{"msg": msg})
return
}
lcMu.Lock()
if lcCur != nil {
lcCur.Phase = msg
@@ -476,6 +495,9 @@ func handleLlamacppJob(w http.ResponseWriter, r *http.Request) {
// lcJobSnapshot construit la vue JSON du job courant. withLines=false renvoie
// juste l'entête (imbriquée dans /api/llamacpp).
func lcJobSnapshot(from int, withLines bool) map[string]any {
if facadeBackends() {
return facadeJobSnapshot(from, withLines)
}
lcMu.Lock()
defer lcMu.Unlock()
if lcCur == nil {
@@ -576,9 +598,7 @@ func lcRunUpdate(clean bool) {
}
oldCommit := gitOutput(repo, "rev-parse", "--short", "HEAD")
lcMu.Lock()
lcCur.OldCommit = oldCommit
lcMu.Unlock()
lcSetCommit("old", oldCommit)
branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD")
if branch == "" || branch == "HEAD" {
@@ -604,9 +624,7 @@ func lcRunUpdate(clean bool) {
}
}
newCommit := gitOutput(repo, "rev-parse", "--short", "HEAD")
lcMu.Lock()
lcCur.NewCommit = newCommit
lcMu.Unlock()
lcSetCommit("new", newCommit)
// Le binaire en cours d'exécution ne peut pas être réécrit → stop du service
// pendant le build, redémarrage après (même en échec).
+3 -1
View File
@@ -143,7 +143,9 @@ func newWebMux() *http.ServeMux {
// Idem pour une installation de moteur : elle meurt avec le process. On
// recharge son état pour l'annoncer « interrompue » au lieu de n'afficher
// plus rien du tout.
lcRestoreOnce.Do(lcRestore)
if !facadeBackends() { // sinon la façade a repris le job (facade_backends.go)
lcRestoreOnce.Do(lcRestore)
}
// Chiffrement de la mémoire : une migration interrompue (coupure de courant
// en plein chiffrement) reprend ici, si la clé est disponible. Sans clé, elle
// attend le déverrouillage (voir handleMemUnlock). Idempotent.
+5
View File
@@ -18,6 +18,7 @@ use crate::{engstatus, gguf, paths, presets, sysinfo};
use std::collections::BTreeMap;
/// Ce dont les routes ont besoin, partagé par toutes les connexions.
#[derive(Clone)]
pub struct Ctx {
pub store: Store,
pub internal: InternalClient,
@@ -52,6 +53,9 @@ pub struct Ctx {
pub mcp: Option<std::sync::Arc<crate::mcp::Mcp>>,
/// Mise à jour de Loki tenue par la façade (`LOKI_UPDATE=rust`).
pub update: bool,
/// Backends llama.cpp et job des moteurs tenus par la façade
/// (`LOKI_BACKENDS=rust`).
pub backends: Option<std::sync::Arc<crate::backends::Backends>>,
/// Jeton de l'API interne : une requête qui le porte vient du Go.
pub internal_token: String,
}
@@ -73,6 +77,7 @@ impl Ctx {
|| self.projects
|| self.mcp.is_some()
|| self.update
|| self.backends.is_some()
}
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
+1
View File
@@ -32,6 +32,7 @@ pub struct AuthInfo {
}
/// Client de l'API interne du Go.
#[derive(Clone)]
pub struct InternalClient {
client: Client<HttpConnector, Empty<Bytes>>,
backend: SocketAddr,
+327
View File
@@ -0,0 +1,327 @@
//! `/api/backends/devices` : les GPU tels que les voit UN moteur
//! (`llama-server --list-devices`), port de `web_devices.go` — caches
//! mémoire (10 min) et disque (`devices.json`, dernière énumération réussie).
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use regex::Regex;
use serde_json::{Map, Value, json};
use super::probe::{GpuInfo, Probe};
/// `devCacheTTL`.
const CACHE_TTL: Duration = Duration::from_secs(600);
/// Délai de l'énumération.
const LIST_TIMEOUT: Duration = Duration::from_secs(20);
// `\s` du Go : blancs ASCII seulement.
static DEVICE_LINE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
&r"^\s*([A-Za-z]+[0-9]+):\s*(.+?)\s*\(([0-9]+)\s*MiB,\s*([0-9]+)\s*MiB free\)\s*$"
.replace(r"\s", r"[\t\n\f\r ]"),
)
.expect("motif")
});
/// `parseListDevices`.
pub fn parse_list_devices(out: &str) -> Vec<Map<String, Value>> {
let mut devs = Vec::new();
for line in out.split('\n') {
let Some(m) = DEVICE_LINE.captures(line.trim_end_matches('\r')) else {
continue;
};
let mut d = Map::new();
d.insert("id".into(), json!(&m[1]));
d.insert("name".into(), json!(&m[2]));
d.insert("total_mib".into(), json!(atoi(&m[3])));
d.insert("free_mib".into(), json!(atoi(&m[4])));
devs.push(d);
}
devs
}
/// `strconv.Atoi` d'une suite de chiffres : 0 si elle déborde.
fn atoi(s: &str) -> i64 {
s.parse().unwrap_or(0)
}
/// Mémoire totale annoncée (`d["total_mib"].(int)`) : seuls les entiers
/// issus de l'énumération comptent ; 0 sinon.
fn total_mib(d: &Map<String, Value>) -> i64 {
d.get("total_mib").and_then(Value::as_i64).unwrap_or(0)
}
/// `annotateDevices` : mémoire manquante et liaison PCIe, par nom de carte.
pub fn annotate(devs: &mut [Map<String, Value>], gpus: &[GpuInfo]) {
let mut by_name: HashMap<String, &GpuInfo> = HashMap::new();
let mut dup: HashMap<String, bool> = HashMap::new();
for g in gpus {
let name = g.name.trim().to_string();
if by_name.contains_key(&name) {
dup.insert(name, true);
continue;
}
by_name.insert(name, g);
}
for d in devs.iter_mut() {
let name = d
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
let Some(g) = by_name.get(&name) else {
continue;
};
if dup.contains_key(&name) {
continue;
}
if total_mib(d) <= 0
&& let Ok(mb) = g.mem_total.trim().parse::<i64>()
{
d.insert("total_mib".into(), json!(mb));
}
if !d
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.starts_with("CUDA")
{
continue;
}
if g.link_gen_max == 0 && g.link_width_max == 0 && g.mem_clock_max == 0 {
continue;
}
d.insert(
"link".into(),
json!({
"gen": g.link_gen, "gen_max": g.link_gen_max,
"width": g.link_width, "width_max": g.link_width_max,
"mem_clock_mhz": g.mem_clock_max,
}),
);
}
}
/// `hasZeroMemory`.
pub fn has_zero_memory(devs: &[Map<String, Value>]) -> bool {
devs.iter().any(|d| total_mib(d) <= 0)
}
/// Une énumération en cache, et quand.
type Cached = (Vec<Map<String, Value>>, Instant);
/// Les deux caches de l'énumération.
pub struct Devices {
home: PathBuf,
mem: Mutex<HashMap<String, Cached>>,
persist: Mutex<()>,
}
impl Devices {
pub fn new(home: PathBuf) -> Self {
Devices {
home,
mem: Mutex::new(HashMap::new()),
persist: Mutex::new(()),
}
}
fn cache_get(&self, key: &str) -> Option<Vec<Map<String, Value>>> {
let m = self.mem.lock().unwrap_or_else(|e| e.into_inner());
m.get(key)
.filter(|(_, at)| at.elapsed() <= CACHE_TTL)
.map(|(d, _)| d.clone())
}
fn cache_put(&self, key: &str, devs: &[Map<String, Value>]) {
self.mem
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key.into(), (devs.to_vec(), Instant::now()));
}
/// `devPersistPath`.
pub fn persist_path(&self) -> PathBuf {
self.home.join("devices.json")
}
fn persist_load(&self) -> BTreeMap<String, Value> {
std::fs::read(self.persist_path())
.ok()
.and_then(|b| serde_json::from_slice::<BTreeMap<String, Value>>(&b).ok())
.unwrap_or_default()
}
fn persist_get(&self, key: &str) -> Option<Value> {
let _g = self.persist.lock().unwrap_or_else(|e| e.into_inner());
self.persist_load()
.remove(key)
.filter(|d| d.as_array().is_some_and(|a| !a.is_empty()))
}
fn persist_put(&self, key: &str, devs: &[Map<String, Value>]) {
let _g = self.persist.lock().unwrap_or_else(|e| e.into_inner());
let mut m = self.persist_load();
m.insert(key.into(), json!(devs));
let _ = std::fs::write(self.persist_path(), crate::chatx::marshal_indent(&m));
}
/// `handleBackendDevices`, une fois `bin` résolu et vérifié : la réponse.
pub async fn list(&self, probe: &Probe, bin: &str, cvd: &str) -> Value {
let key = format!("{bin}\x00{cvd}");
if let Some(devs) = self.cache_get(&key) {
return json!({ "ok": true, "devices": devs });
}
let (out, err) = list_devices(bin, cvd).await;
let mut devs = parse_list_devices(&out);
if let Some(err) = err {
if let Some(good) = self.persist_get(&key) {
return json!({ "ok": true, "devices": good, "stale": true });
}
if devs.is_empty() {
return json!({ "ok": false, "error": format!("le moteur n'a pas répondu : {err}") });
}
return json!({ "ok": true, "devices": devs, "stale": true });
}
if let Ok(gpus) = probe.detect_gpus().await {
annotate(&mut devs, &gpus);
}
if !has_zero_memory(&devs) {
self.cache_put(&key, &devs);
}
if !devs.is_empty() {
self.persist_put(&key, &devs);
}
json!({ "ok": true, "devices": devs })
}
}
/// `bin --list-devices`, sortie standard et d'erreur mêlées ; l'erreur au
/// format d'`exec` (`exit status N`, `signal: killed`…).
async fn list_devices(bin: &str, cvd: &str) -> (String, Option<String>) {
let dir = crate::paths::go_dir(bin);
let env = crate::serve::run::Env::default();
let (k, v) = crate::serve::run::library_path_value(&env, &dir);
let mut c = tokio::process::Command::new(bin);
c.arg("--list-devices").env(k, v);
if !cvd.is_empty() {
c.env("CUDA_VISIBLE_DEVICES", cvd)
.env("CUDA_DEVICE_ORDER", "PCI_BUS_ID");
}
// Sortie standard et d'erreur dans le même tube, comme CombinedOutput.
let (reader, writer) = match std::io::pipe() {
Ok(p) => p,
Err(e) => return (String::new(), Some(e.to_string())),
};
let writer2 = match writer.try_clone() {
Ok(w) => w,
Err(e) => return (String::new(), Some(e.to_string())),
};
c.stdin(Stdio::null())
.stdout(Stdio::from(writer))
.stderr(Stdio::from(writer2))
.kill_on_drop(true);
let mut child = match c.spawn() {
Ok(ch) => ch,
Err(e) => {
return (
String::new(),
Some(crate::paths::os_err("fork/exec", bin, &e)),
);
}
};
drop(c);
let read = tokio::task::spawn_blocking(move || {
let mut r = reader;
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut r, &mut buf);
buf
});
let status = tokio::time::timeout(LIST_TIMEOUT, child.wait()).await;
let status = match status {
Ok(s) => s,
Err(_) => {
let _ = child.start_kill();
let _ = child.wait().await;
Err(std::io::Error::other("signal: killed"))
}
};
let out = read.await.unwrap_or_default();
let out = crate::system::go_lossy(&out);
let err = match status {
Ok(s) if s.success() => None,
Ok(s) => Some(exit_text(s)),
Err(e) => Some(e.to_string()),
};
(out, err)
}
/// Texte d'un `*exec.ExitError`.
fn exit_text(s: std::process::ExitStatus) -> String {
if let Some(code) = s.code() {
return format!("exit status {code}");
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = s.signal() {
return format!("signal: {}", signal_name(sig));
}
}
s.to_string()
}
/// Nom d'un signal comme `syscall.Signal.String()`.
#[cfg(unix)]
fn signal_name(sig: i32) -> String {
match sig {
1 => "hangup".into(),
2 => "interrupt".into(),
3 => "quit".into(),
4 => "illegal instruction".into(),
6 => "aborted".into(),
7 => "bus error".into(),
8 => "floating point exception".into(),
9 => "killed".into(),
11 => "segmentation fault".into(),
13 => "broken pipe".into(),
15 => "terminated".into(),
n => format!("signal {n}"),
}
}
/// Le binaire est-il un serveur llama.cpp (garde-fou de la route) ?
pub fn is_llama_server(bin: &str) -> bool {
let base = Path::new(bin)
.file_name()
.map(|b| b.to_string_lossy().to_lowercase())
.unwrap_or_default();
base == "llama-server" || base == "llama-server.exe"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse() {
let d = parse_list_devices(
"Available devices:\n CUDA0: NVIDIA GeForce RTX 5060 Ti (15849 MiB, 15579 MiB free)\r\n bruit\n",
);
assert_eq!(d.len(), 1);
assert_eq!(d[0]["id"], "CUDA0");
assert_eq!(d[0]["total_mib"], 15849);
}
#[test]
fn serveur() {
assert!(is_llama_server("/x/LLAMA-SERVER"));
assert!(!is_llama_server("/bin/sh"));
}
}
+414
View File
@@ -0,0 +1,414 @@
//! LE job des moteurs (un seul à la fois) : port de `lcJob` et de sa
//! persistance (`web_llamacpp.go`, `web_llamacpp_job.go`).
//!
//! Quand la façade tient les backends, c'est elle qui détient le job : le
//! sien (binaires précompilés) comme ceux que le Go fait encore tourner
//! (compilation, moteur de l'image, Strata), qui lui rapportent chaque étape
//! par l'API interne (`/internal/facade/v1/build-job/*`). L'entête vit en base
//! (`state/build_job`), les lignes dans `$LOKI_HOME/loki-build.log`.
use std::io::Write;
use std::path::PathBuf;
use std::sync::LazyLock;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::sync::Mutex;
use crate::gojson;
use crate::store::{BK_STATE, Store};
/// `lcMaxLines`.
pub const MAX_LINES: usize = 4000;
/// Clé de l'entête en base.
pub const STATE_KEY: &str = "build_job";
/// Progression throttlée : une écriture au plus toutes les 2 s.
const SAVE_EVERY: Duration = Duration::from_secs(2);
/// Le job courant (`lcJob`).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Job {
pub action: String,
pub phase: String,
pub compiled: i64,
pub running: bool,
pub err: String,
pub started_at: i64,
pub ended_at: i64,
pub old_commit: String,
pub new_commit: String,
pub lines: Vec<String>,
pub base: usize,
}
/// `lcPersisted` : l'entête sur disque, dans l'ordre des champs du Go.
#[derive(Serialize, Deserialize, Default)]
struct Persisted {
#[serde(default)]
action: String,
#[serde(default)]
phase: String,
#[serde(default)]
compiled: i64,
#[serde(default)]
running: bool,
#[serde(default, rename = "error")]
err: String,
#[serde(default)]
started_at: i64,
#[serde(default)]
ended_at: i64,
#[serde(default, rename = "old")]
old_commit: String,
#[serde(default, rename = "new")]
new_commit: String,
}
struct State {
cur: Option<Job>,
last_save: Option<Instant>,
}
/// Le détenteur du job.
pub struct Jobs {
home: PathBuf,
state: Mutex<State>,
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}
impl Jobs {
pub fn new(home: PathBuf) -> Self {
Jobs {
home,
state: Mutex::new(State {
cur: None,
last_save: None,
}),
}
}
/// `lcJobLogPath`.
pub fn log_path(&self) -> PathBuf {
self.home.join("loki-build.log")
}
/// `lcSaveLine` : une ligne de plus au journal (append).
fn save_line(&self, line: &str) {
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.log_path())
{
let _ = f.write_all(format!("{line}\n").as_bytes());
}
}
/// `lcSave` (appelée sous le verrou, comme au Go).
async fn save(&self, st: &mut State, store: &Store, force: bool) {
let Some(j) = &st.cur else { return };
if !force && st.last_save.is_some_and(|t| t.elapsed() < SAVE_EVERY) {
return;
}
st.last_save = Some(Instant::now());
let p = Persisted {
action: j.action.clone(),
phase: j.phase.clone(),
compiled: j.compiled,
running: j.running,
err: j.err.clone(),
started_at: j.started_at,
ended_at: j.ended_at,
old_commit: j.old_commit.clone(),
new_commit: j.new_commit.clone(),
};
if let Err(e) = store
.put(BK_STATE, STATE_KEY, gojson::marshal(&p).into_bytes())
.await
{
eprintln!("[moteurs] état du job non enregistré : {e}");
}
}
/// `startLcJob` (sans le garde de l'optimiseur, posé par l'appelant).
pub async fn start(&self, store: &Store, action: &str) -> Result<(), String> {
let mut st = self.state.lock().await;
if let Some(j) = &st.cur
&& j.running
{
return Err(format!("un job {} est déjà en cours", j.action));
}
st.cur = Some(Job {
action: action.into(),
running: true,
phase: "démarrage…".into(),
started_at: now(),
..Job::default()
});
let _ = std::fs::remove_file(self.log_path());
self.save(&mut st, store, true).await;
Ok(())
}
/// Fin du corps du job (le `defer` de `startLcJob`).
pub async fn end(&self, store: &Store) {
let mut st = self.state.lock().await;
if let Some(j) = &mut st.cur {
j.running = false;
j.ended_at = now();
}
self.save(&mut st, store, true).await;
}
/// `lcAppend` : une ligne, et la phase qu'elle annonce.
pub async fn append(&self, store: &Store, line: &str) {
let mut st = self.state.lock().await;
let Some(j) = &mut st.cur else { return };
j.lines.push(line.into());
if j.lines.len() > MAX_LINES {
let drop = j.lines.len() - MAX_LINES;
j.lines.drain(..drop);
j.base += drop;
}
if !compiled_file(line).is_empty() {
j.compiled += 1;
j.phase = format!("compilation… {} fichiers", j.compiled);
} else {
let p = phase_label(line);
if !p.is_empty() {
j.phase = p;
}
}
self.save_line(line);
self.save(&mut st, store, false).await;
}
/// Une ligne marquée (phase, échec, fin) : posée sans troncature, comme
/// au Go, et écrite tout de suite.
async fn mark(&self, store: &Store, line: String, f: impl FnOnce(&mut Job)) {
let mut st = self.state.lock().await;
let Some(j) = &mut st.cur else { return };
f(j);
j.lines.push(line.clone());
self.save_line(&line);
self.save(&mut st, store, true).await;
}
/// `lcPhase`.
pub async fn phase(&self, store: &Store, phase: &str) {
let p = phase.to_string();
self.mark(store, format!("▶ {phase}"), |j| j.phase = p)
.await;
}
/// `lcFail`.
pub async fn fail(&self, store: &Store, err: &str) {
let e = err.to_string();
self.mark(store, format!("✗ {err}"), |j| {
j.err = e;
j.phase = "échec".into();
})
.await;
}
/// `lcDone`.
pub async fn done(&self, store: &Store, msg: &str) {
let m = msg.to_string();
self.mark(store, format!("✓ {msg}"), |j| j.phase = m).await;
}
/// `lcCur.OldCommit = …` / `lcCur.NewCommit = …` (sans écriture : la
/// prochaine les emporte).
pub async fn set_commits(&self, old: Option<&str>, new: Option<&str>) {
let mut st = self.state.lock().await;
if let Some(j) = &mut st.cur {
if let Some(o) = old {
j.old_commit = o.into();
}
if let Some(n) = new {
j.new_commit = n.into();
}
}
}
/// `lcJobSnapshot`.
pub async fn snapshot(&self, from: i64, with_lines: bool) -> Value {
let st = self.state.lock().await;
let Some(j) = &st.cur else {
return json!({ "exists": false });
};
let mut out = json!({
"exists": true,
"action": j.action,
"running": j.running,
"phase": j.phase,
"compiled": j.compiled,
"error": j.err,
"started_at": j.started_at,
"ended_at": j.ended_at,
"old": j.old_commit,
"new": j.new_commit,
});
if with_lines {
let start = (from - j.base as i64).clamp(0, j.lines.len() as i64) as usize;
let rest = &j.lines[start..];
// `append([]string(nil), …)` d'une tranche vide : nil → null.
out["lines"] = if rest.is_empty() {
Value::Null
} else {
json!(rest)
};
out["next"] = json!(j.base + j.lines.len());
}
out
}
/// Le job court-il ?
pub async fn running(&self) -> bool {
self.state
.lock()
.await
.cur
.as_ref()
.is_some_and(|j| j.running)
}
/// `lcRestore` : le dernier job connu au démarrage ; un job noté « en
/// cours » est mort avec le processus précédent.
pub async fn restore(&self, store: &Store) {
let Ok(Some(raw)) = store.get(BK_STATE, STATE_KEY).await else {
return;
};
let Ok(p) = serde_json::from_slice::<Persisted>(&raw) else {
return;
};
if p.action.is_empty() {
return;
}
let mut j = Job {
action: p.action,
phase: p.phase,
compiled: p.compiled,
running: false,
err: p.err,
started_at: p.started_at,
ended_at: p.ended_at,
old_commit: p.old_commit,
new_commit: p.new_commit,
..Job::default()
};
if p.running {
j.err = "interrompu : le service a redémarré pendant l'opération — relancez-la".into();
j.phase = "interrompu".into();
j.ended_at = now();
}
if let Ok(b) = std::fs::read(self.log_path()) {
let s = String::from_utf8_lossy(&b);
let mut lines: Vec<String> = s
.trim_end_matches('\n')
.split('\n')
.map(str::to_string)
.collect();
if lines.len() == 1 && lines[0].is_empty() {
lines.clear();
}
if lines.len() > MAX_LINES {
j.base = lines.len() - MAX_LINES;
lines.drain(..j.base);
}
j.lines = lines;
}
if p.running {
j.lines.push(
"✗ interrompu : le service a redémarré (la compilation ne survit pas au redémarrage)"
.into(),
);
}
let mut st = self.state.lock().await;
if st.cur.is_none() {
st.cur = Some(j);
}
if p.running {
self.save(&mut st, store, true).await;
}
}
}
/// `truncLabel` : coupé à l'octet, comme le Go ; un caractère coupé devient
/// U+FFFD par octet, comme l'encodeur JSON du Go le rendra.
fn trunc_label(s: &str, n: usize) -> String {
let s = s.trim();
if s.len() > n {
return format!("{}…", crate::system::go_lossy(&s.as_bytes()[..n - 1]));
}
s.to_string()
}
/// `phaseLabel`.
pub fn phase_label(line: &str) -> String {
let t = line.trim();
if let Some(rest) = t.strip_prefix("-- ") {
return format!("configuration… {}", trunc_label(rest, 50));
}
if t.contains("Linking") || t.contains("Build files have been written") {
return "édition de liens…".into();
}
String::new()
}
static RE_COMPILING_CUDA: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"Compiling .*?([0-9A-Za-z_.\-]+\.cu)(?-u:\b)").expect("motif"));
static RE_BARE_SOURCE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[0-9A-Za-z_.\-]+\.(c|cc|cpp|cxx|cu|cuh)$").expect("motif"));
static RE_BUILDING_OBJ: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"Building (?:C|CXX|CUDA|ASM)[0-9A-Za-z_]* object .*?/([^/]+?)\.o(?:bj)?(?-u:\b)")
.expect("motif")
});
/// `compiledFile` : le source qu'une ligne de build annonce, "" sinon. Les
/// classes `\w` et `\b` du Go sont ASCII : on les garde ASCII ici aussi.
pub fn compiled_file(line: &str) -> String {
let t = line.trim();
if let Some(m) = RE_COMPILING_CUDA.captures(t) {
return m[1].to_string();
}
if let Some(m) = RE_BUILDING_OBJ.captures(t) {
return m[1].to_string();
}
if RE_BARE_SOURCE.is_match(t) {
return t.to_string();
}
String::new()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn labels() {
assert_eq!(
phase_label("-- The C compiler identification is GNU"),
"configuration… The C compiler identification is GNU"
);
assert_eq!(
phase_label("[100%] Linking CXX executable"),
"édition de liens…"
);
assert_eq!(phase_label("rien"), "");
assert_eq!(
compiled_file("[ 45%] Building CXX object ggml/src/CMakeFiles/ggml.dir/ggml.cpp.o"),
"ggml.cpp"
);
assert_eq!(compiled_file("foo.cu"), "foo.cu");
assert_eq!(compiled_file("rien"), "");
}
}
+936
View File
@@ -0,0 +1,936 @@
//! Backends llama.cpp tenus par la façade (lot 22 de la migration,
//! `LOKI_BACKENDS=rust` ; expérimental, le Go reste le défaut) — port de
//! `web_llamacpp.go`, `web_llamacpp_job.go`, `backend_prebuilt.go`, de
//! `handleBackends`, `handleBackendsCustom`, `handleLlamacppUninstallCustom`
//! (`web_api.go`) et `handleBackendDevices` (`web_devices.go`).
//!
//! La façade détient LE job des moteurs ([`job::Jobs`]) : le sien (binaires
//! précompilés) et ceux que le Go fait encore tourner — compilation depuis les
//! sources (install, update, install-custom, relayées au Go), moteur de
//! l'image, Strata — qui lui rapportent chaque étape par l'API interne
//! (`/internal/facade/v1/build-job/*`).
pub mod devices;
pub mod job;
pub mod prebuilt;
pub mod probe;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use http_body_util::{BodyExt, Limited};
use hyper::body::Incoming;
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use crate::api::Ctx;
use crate::auth::json as send_json;
use crate::gojson::{self, Kind};
use crate::paths::{go_clean, go_dir};
use crate::proxy::Body;
use crate::serve::run::{EnginePaths, same_path};
use crate::store::{BK_CONFIG, Store};
use probe::{Probe, is_dir, is_file};
/// Préfixe de l'API interne du job (pour le Go seulement).
pub const INTERNAL_PREFIX: &str = "/internal/facade/v1/build-job/";
/// `refuseIfProvided` n'est plus utile ici : les routes de compilation
/// restent au Go, qui répond lui-même.
const USE_REQ_TYPE: &str = r#"struct { Mode string "json:\"mode\"" }"#;
const NAME_REQ_TYPE: &str = r#"struct { Name string "json:\"name\"" }"#;
const BIN_REQ_TYPE: &str = r#"struct { Bin string "json:\"bin\"" }"#;
/// Les backends tenus par la façade.
pub struct Backends {
pub home: PathBuf,
pub probe: Probe,
pub target: prebuilt::Target,
/// `LOKI_ENGINE_BIN` (moteur livré par l'image), lu au démarrage.
engine_bin_env: std::sync::RwLock<String>,
/// `llamaReleasesAPI`.
pub releases_api: String,
pub jobs: job::Jobs,
pub devices: devices::Devices,
client: reqwest::Client,
}
impl Backends {
pub fn new(home: PathBuf, engine_bin_env: String) -> Self {
Self::with(
home,
engine_bin_env,
Probe::system(),
prebuilt::RELEASES_API.into(),
)
}
/// Pour les tests : machine et API des releases choisies.
pub fn with(home: PathBuf, engine_bin_env: String, probe: Probe, releases_api: String) -> Self {
crate::chat::engine::install_crypto();
Backends {
jobs: job::Jobs::new(home.clone()),
devices: devices::Devices::new(home.clone()),
home,
probe,
target: prebuilt::Target::current(),
engine_bin_env: std::sync::RwLock::new(engine_bin_env),
releases_api,
client: reqwest::Client::builder().build().unwrap_or_default(),
}
}
/// Change le moteur livré par l'image (tests).
pub fn set_engine_bin_env(&self, v: &str) {
*self
.engine_bin_env
.write()
.unwrap_or_else(|e| e.into_inner()) = v.to_string();
}
fn engine_paths(&self) -> EnginePaths {
EnginePaths {
home: self.home.clone(),
}
}
fn home_s(&self) -> String {
self.home.to_string_lossy().into_owned()
}
/// `backendsDir`.
fn backends_dir(&self) -> PathBuf {
self.home.join("backends")
}
/// `defaultRepoDir`.
fn default_repo_dir(&self) -> PathBuf {
self.backends_dir().join("llama.cpp")
}
/// `llamacppRepoDir` : la racine du dépôt du BIN, sinon le dépôt canonique.
fn repo_dir(&self, cfg_bin: &str) -> PathBuf {
if !cfg_bin.is_empty() {
let real = crate::paths::eval_symlinks(Path::new(cfg_bin))
.unwrap_or_else(|| cfg_bin.to_string());
if let Some(root) = find_repo_root(&real) {
return PathBuf::from(root);
}
}
self.default_repo_dir()
}
/// `providedEngineBin`.
fn provided_engine_bin(&self) -> String {
let env = self
.engine_bin_env
.read()
.unwrap_or_else(|e| e.into_inner())
.clone();
let p = env.trim();
if p.is_empty() {
return String::new();
}
if is_file(Path::new(&self.engine_paths().prebuilt_resolve_bin(p))) {
p.to_string()
} else {
String::new()
}
}
/// `gitOutput` : sortie nettoyée, "" en cas d'échec.
async fn git(&self, repo: &Path, args: &[&str]) -> String {
self.probe
.output_in(Some(repo), "git", args)
.await
.map(|o| String::from_utf8_lossy(&o).trim().to_string())
.unwrap_or_default()
}
/// `listBackendBins` : un binaire par dossier de `backends/`.
pub fn list_backend_bins(&self) -> Vec<(String, String)> {
let root = format!("{}/backends", self.home_s());
let Ok(rd) = std::fs::read_dir(&root) else {
return Vec::new();
};
let mut names: Vec<String> = rd
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
const SUBPATHS: [&str; 9] = [
"build/bin/llama-server",
"build-sm120/bin/llama-server",
"build/llama-server",
"bin/llama-server",
"llama-server",
"build/bin/Release/llama-server.exe",
"build/bin/llama-server.exe",
"build/bin/Release/llama-server",
"llama-server.exe",
];
let mut out = Vec::new();
for name in names {
if name.starts_with('.') {
continue;
}
for sp in SUBPATHS {
let p = format!("{root}/{name}/{sp}");
if is_file(Path::new(&p)) {
out.push((name.clone(), p));
break;
}
}
}
out
}
/// `backendDir` : `backends/<nom>` sans évasion possible.
fn backend_dir(&self, name: &str) -> Result<String, String> {
let name = sanitize_backend_name(name);
let root = crate::paths::go_abs(&self.backends_dir().to_string_lossy())
.ok_or_else(|| "répertoire courant illisible".to_string())?;
let p = crate::paths::go_join(&[&root, &name]);
if !p.starts_with(&format!("{root}/")) {
return Err("nom de backend invalide".into());
}
Ok(p)
}
}
/// `findRepoRoot` : le dossier du dépôt llama.cpp au-dessus d'un binaire.
fn find_repo_root(bin: &str) -> Option<String> {
let mut d = go_dir(bin);
for _ in 0..6 {
if is_dir(&Path::new(&d).join(".git")) || is_file(&Path::new(&d).join("CMakeLists.txt")) {
return Some(d);
}
let parent = go_dir(&d);
if parent == d {
break;
}
d = parent;
}
None
}
/// `llamaServerBin` : le binaire compilé sous `repo`, "" sinon.
fn llama_server_bin(repo: &Path) -> String {
let ext = if cfg!(windows) { ".exe" } else { "" };
for rel in [
format!("build/bin/Release/llama-server{ext}"),
format!("build/bin/llama-server{ext}"),
format!("build/Release/llama-server{ext}"),
format!("build/llama-server{ext}"),
] {
let p = go_clean(&repo.join(&rel).to_string_lossy());
if is_file(Path::new(&p)) {
return p;
}
}
String::new()
}
/// `sanitizeBackendName`.
pub fn sanitize_backend_name(name: &str) -> String {
let name = name.trim().to_lowercase();
let mut b = String::new();
for r in name.chars() {
match r {
'a'..='z' | '0'..='9' | '.' | '_' | '-' => b.push(r),
' ' | '/' | '\\' => b.push('-'),
_ => {}
}
}
let out = b.trim_matches(['.', '-']).to_string();
if out.is_empty() {
"backend".into()
} else {
out
}
}
/// `engineOwns` / `engineTagOf`.
fn engine_tag_of(ep: &EnginePaths, bin: &str) -> String {
if !ep.engine_owns(bin) {
return String::new();
}
crate::paths::base_name(&go_dir(bin))
}
/// `strconv.Atoi` : 0 sur erreur de syntaxe, borné sur débordement.
pub fn go_atoi(s: &str) -> i64 {
let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return 0;
}
s.parse().unwrap_or(if s.starts_with('-') {
i64::MIN
} else {
i64::MAX
})
}
/// Journal d'un job tenu ici : chaque ligne va au job, dans l'ordre.
pub struct Log<'a> {
pub jobs: &'a job::Jobs,
pub store: &'a Store,
}
impl Log<'_> {
pub async fn line(&self, s: &str) {
self.jobs.append(self.store, s).await;
}
pub async fn phase(&self, s: &str) {
self.jobs.phase(self.store, s).await;
}
}
/// Le verrou de l'optimiseur (au Go) : le message s'il tient le moteur.
async fn tune_busy(store: &Store) -> Option<String> {
let v = store
.internal_json(Method::GET, "/internal/v1/tune/guard", None)
.await
.ok()?;
(v.get("busy").and_then(Value::as_bool) == Some(true)).then(|| {
v.get("error")
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
})
}
/// Le service moteur : celui de la façade si elle le pilote, sinon celui du
/// Go. `if_active` : seulement s'il tourne. Rend (lancé, erreur).
async fn service(ctx: &Ctx, action: &str, if_active: bool) -> (bool, String) {
if let Some(ctl) = &ctx.engine_ctl {
if if_active && !crate::enginectl::service_is_active(ctx) {
return (false, String::new());
}
let err = crate::enginectl::service_action(ctx, ctl, action)
.await
.err()
.unwrap_or_default();
return (true, err);
}
match ctx
.store
.internal_json(
Method::POST,
"/internal/v1/service/action",
Some(&json!({ "action": action, "if_active": if_active })),
)
.await
{
Ok(v) => (
v.get("ran").and_then(Value::as_bool).unwrap_or(false),
v.get("error")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
),
Err(e) => (true, e),
}
}
/// Les routes servies par ce module.
pub fn handles(path: &str) -> bool {
matches!(
path,
"/api/llamacpp"
| "/api/llamacpp/check"
| "/api/llamacpp/prebuilt/check"
| "/api/llamacpp/prebuilt"
| "/api/llamacpp/use"
| "/api/llamacpp/job"
| "/api/llamacpp/uninstall-custom"
| "/api/backends"
| "/api/backends/custom"
| "/api/backends/devices"
)
}
/// L'API interne du job ?
pub fn is_internal(path: &str) -> bool {
path.starts_with(INTERNAL_PREFIX)
}
fn fail(code: StatusCode, e: impl Into<String>) -> Response<Body> {
send_json(code, &json!({ "ok": false, "error": e.into() }))
}
/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui.
pub async fn serve(
ctx: &Ctx,
b: &Arc<Backends>,
req: Request<Incoming>,
from_go: bool,
) -> Response<Body> {
let (parts, body) = req.into_parts();
if !from_go && let Err(refus) = ctx.guard(&parts.headers).await {
return *refus;
}
let raw = Limited::new(body, 1 << 20)
.collect()
.await
.map(|b| b.to_bytes().to_vec())
.unwrap_or_default();
let store = &ctx.store;
let query = parts.uri.query().unwrap_or("");
match parts.uri.path() {
"/api/llamacpp" => send_json(StatusCode::OK, &status(b, store).await),
"/api/llamacpp/check" => send_json(StatusCode::OK, &check(b, store).await),
"/api/llamacpp/prebuilt/check" => send_json(StatusCode::OK, &prebuilt_check(b).await),
"/api/llamacpp/prebuilt" => {
if let Some(e) = tune_busy(store).await {
return fail(StatusCode::CONFLICT, e);
}
if let Err(e) = b.jobs.start(store, "prebuilt").await {
return fail(StatusCode::CONFLICT, e);
}
let (b2, ctx2) = (b.clone(), ctx.clone());
tokio::spawn(async move {
run_prebuilt(&ctx2, &b2).await;
b2.jobs.end(&ctx2.store).await;
});
send_json(StatusCode::OK, &json!({ "ok": true }))
}
"/api/llamacpp/use" => {
if let Some(e) = tune_busy(store).await {
return send_json(
StatusCode::CONFLICT,
&json!({ "ok": false, "tuning": true, "error": e }),
);
}
let d = gojson::decode_struct(&raw, USE_REQ_TYPE, &[("mode", Kind::Str)]);
let cfg = store.config().await;
let cfg_bin = cfg.get("BIN").cloned().unwrap_or_default();
let bin = match d.str("mode").as_str() {
"fast" => prebuilt::server_bin(&b.home),
"opt" => llama_server_bin(&b.repo_dir(&cfg_bin)),
_ => return fail(StatusCode::BAD_REQUEST, "mode inconnu"),
};
if bin.is_empty() {
return fail(StatusCode::BAD_REQUEST, "cette version n'est pas installée");
}
if let Err(e) = store.put(BK_CONFIG, "BIN", bin.clone().into_bytes()).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
let _ = service(ctx, "restart", true).await;
send_json(StatusCode::OK, &json!({ "ok": true, "bin": bin }))
}
"/api/llamacpp/job" => {
let from = query_param(query, "from");
send_json(StatusCode::OK, &b.jobs.snapshot(go_atoi(&from), true).await)
}
"/api/llamacpp/uninstall-custom" => uninstall_custom(b, store, &raw).await,
"/api/backends" => {
let list: Vec<Value> = b
.list_backend_bins()
.into_iter()
.map(|(name, path)| json!({ "name": name, "path": path }))
.collect();
send_json(StatusCode::OK, &json!(list))
}
"/api/backends/custom" => {
let cfg_bin = store.config().await.get("BIN").cloned().unwrap_or_default();
let list: Vec<Value> = b
.list_backend_bins()
.into_iter()
.filter(|(name, _)| name != "llama.cpp" && name != "llama.cpp-prebuilt")
.map(|(name, path)| {
let in_use = same_path(&path, &cfg_bin);
json!({ "name": name, "path": path, "in_use": in_use })
})
.collect();
send_json(StatusCode::OK, &json!(list))
}
"/api/backends/devices" => {
let d = gojson::decode_struct(&raw, BIN_REQ_TYPE, &[("bin", Kind::Str)]);
let cfg = store.config().await;
let mut bin = d.str("bin").trim().to_string();
if bin.is_empty() {
bin = cfg.get("BIN").cloned().unwrap_or_default();
}
let bin = b.engine_paths().prebuilt_resolve_bin(&bin);
if bin.is_empty() || !is_file(Path::new(&bin)) {
return send_json(
StatusCode::OK,
&json!({ "ok": false, "error": "moteur introuvable — choisissez d'abord un moteur" }),
);
}
if !devices::is_llama_server(&bin) {
return fail(
StatusCode::BAD_REQUEST,
"ce chemin n'est pas un llama-server",
);
}
let cvd = cfg.get("CUDA_VISIBLE_DEVICES").cloned().unwrap_or_default();
send_json(StatusCode::OK, &b.devices.list(&b.probe, &bin, &cvd).await)
}
_ => fail(StatusCode::NOT_FOUND, "route inconnue"),
}
}
/// Valeur d'un paramètre de requête (dernière occurrence ignorée : la
/// première, comme `Query().Get`).
fn query_param(query: &str, key: &str) -> String {
url::form_urlencoded::parse(query.as_bytes())
.find(|(k, _)| k == key)
.map(|(_, v)| v.into_owned())
.unwrap_or_default()
}
/// `handleLlamacpp` : le statut complet.
pub async fn status(b: &Backends, store: &Store) -> Value {
let cfg = store.config().await;
let cfg_bin = cfg.get("BIN").cloned().unwrap_or_default();
let repo = b.repo_dir(&cfg_bin);
let installed = is_dir(&repo.join(".git"));
let mut out = json!({
"repo": repo.to_string_lossy(),
"installed": installed,
});
if installed {
let branch = b.git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]).await;
out["commit"] = json!(b.git(&repo, &["rev-parse", "--short", "HEAD"]).await);
out["commit_date"] = json!(b.git(&repo, &["log", "-1", "--format=%ci"]).await);
out["commit_msg"] = json!(b.git(&repo, &["log", "-1", "--format=%s"]).await);
if !branch.is_empty() && branch != "HEAD" {
let behind = b
.git(
&repo,
&["rev-list", "--count", &format!("HEAD..origin/{branch}")],
)
.await;
if !behind.is_empty() {
out["behind"] = json!(behind.parse::<i64>().unwrap_or(0));
}
}
out["branch"] = json!(branch);
}
let bin = llama_server_bin(&repo);
let ep = b.engine_paths();
out["bin"] = json!(bin);
out["config_bin"] = json!(cfg_bin);
out["in_use"] = json!(!bin.is_empty() && same_path(&bin, &cfg_bin));
let provided = b.provided_engine_bin();
out["provided"] = json!(!provided.is_empty());
out["provided_bin"] = json!(provided);
out["engine"] = json!({
"dir": b.home.join("engine").to_string_lossy(),
"in_use": ep.engine_owns(&cfg_bin),
"tag": engine_tag_of(&ep, &cfg_bin),
});
let plan = b.probe.detect_build_plan().await;
out["plan"] = json!({ "backend": plan.backend, "arch": plan.cuda_arch, "jobs": plan.jobs });
let (pb_tag, _) = prebuilt::version(&b.home);
let pb_bin = prebuilt::server_bin(&b.home);
out["prebuilt"] = json!({
"tag": pb_tag,
"bin": pb_bin,
"dir": prebuilt::dir(&b.home).to_string_lossy(),
"in_use": !pb_bin.is_empty() && ep.prebuilt_owns(&cfg_bin),
});
out["backends_dir"] = json!(b.backends_dir().to_string_lossy());
out["reco"] = prebuilt::recommended_mode(plan.backend, b.target.os);
out["job"] = b.jobs.snapshot(0, false).await;
out
}
/// `runStep` (sans terminal interactif) : la commande, sorties sur celles de
/// la façade ; l'erreur au format d'`exec`.
async fn run_step(probe: &Probe, dir: &Path, name: &str, args: &[&str]) -> Result<(), String> {
let Some(bin) = probe.look_path(name) else {
return Err(format!(
"exec: \"{name}\": executable file not found in $PATH"
));
};
let status = tokio::process::Command::new(&bin)
.args(args)
.current_dir(dir)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await
.map_err(|e| crate::paths::os_err("fork/exec", &bin.to_string_lossy(), &e))?;
match status.code() {
Some(0) => Ok(()),
Some(c) => Err(format!("exit status {c}")),
None => Err(status.to_string()),
}
}
/// `handleLlamacppCheck` : git fetch, puis le retard sur origin.
async fn check(b: &Backends, store: &Store) -> Value {
let cfg_bin = store.config().await.get("BIN").cloned().unwrap_or_default();
let repo = b.repo_dir(&cfg_bin);
if !is_dir(&repo.join(".git")) {
return json!({ "ok": false, "error": "llama.cpp n'est pas installé" });
}
let mut branch = b.git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]).await;
if branch.is_empty() || branch == "HEAD" {
branch = "master".into();
}
if let Err(e) = run_step(&b.probe, &repo, "git", &["fetch", "origin", "--quiet"]).await {
return json!({ "ok": false, "error": format!("git fetch a échoué : {e}") });
}
let origin = format!("origin/{branch}");
let behind_s = b
.git(&repo, &["rev-list", "--count", &format!("HEAD..{origin}")])
.await;
let behind = if behind_s.is_empty() {
0
} else {
behind_s.parse::<i64>().unwrap_or(0)
};
let has_binary = !llama_server_bin(&repo).is_empty();
json!({
"ok": true,
"behind": behind,
"local": b.git(&repo, &["rev-parse", "--short", "HEAD"]).await,
"remote": b.git(&repo, &["rev-parse", "--short", &origin]).await,
"remote_date": b.git(&repo, &["log", "-1", "--format=%ci", &origin]).await,
"remote_msg": b.git(&repo, &["log", "-1", "--format=%s", &origin]).await,
"branch": branch,
"has_binary": has_binary,
"needs_rebuild": behind > 0 || !has_binary,
})
}
/// La dernière release officielle (`fetchLlamaLatest`).
async fn fetch_latest(b: &Backends) -> Result<(String, Vec<prebuilt::Asset>), String> {
prebuilt::fetch_latest(&b.client, &b.releases_api).await
}
/// `handleLlamacppPrebuiltCheck`.
async fn prebuilt_check(b: &Backends) -> Value {
let (tag, assets) = match fetch_latest(b).await {
Ok(r) => r,
Err(e) => return json!({ "ok": false, "error": e }),
};
let pick = match prebuilt::pick(&assets, &b.probe, b.target).await {
Ok(p) => p,
Err(e) => return json!({ "ok": false, "error": e }),
};
let (cur, _) = prebuilt::version(&b.home);
let update = cur != tag || prebuilt::server_bin(&b.home).is_empty();
json!({
"ok": true,
"latest": tag,
"current": cur,
"variant": pick.label,
"size_mb": pick.main.size / 1_000_000,
"update": update,
})
}
/// `lcRunPrebuilt` : installe, pointe BIN, service arrêté pendant le
/// remplacement puis relancé.
async fn run_prebuilt(ctx: &Ctx, b: &Backends) {
let log = Log {
jobs: &b.jobs,
store: &ctx.store,
};
let (svc_was_up, err) = if ctx.engine_ctl.is_some() {
if crate::enginectl::service_is_active(ctx) {
log.phase("arrêt du service le temps de l'installation…")
.await;
service(ctx, "stop", false).await
} else {
(false, String::new())
}
} else {
// Côté Go, « arrête-le s'il tourne » dit aussi s'il tournait.
let r = service(ctx, "stop", true).await;
if r.0 {
log.phase("arrêt du service le temps de l'installation…")
.await;
}
r
};
if svc_was_up && !err.is_empty() {
log.line(&format!("[warn] impossible d'arrêter le service : {err}"))
.await;
}
let mut res = install(b, &log).await;
if let Ok(bin) = &res {
match ctx
.store
.put(BK_CONFIG, "BIN", bin.clone().into_bytes())
.await
{
Err(e) => {
res = Err(format!(
"binaires installés mais échec d'écriture de BIN : {e}"
))
}
Ok(()) => log.line("BIN mis à jour").await,
}
}
if svc_was_up {
log.phase("redémarrage du service…").await;
let (_, e) = service(ctx, "start", false).await;
if !e.is_empty() {
log.line(&format!("[warn] redémarrage du service échoué : {e}"))
.await;
}
}
match res {
Err(e) => b.jobs.fail(&ctx.store, &e).await,
Ok(_) => {
let (tag, _) = prebuilt::version(&b.home);
b.jobs
.done(
&ctx.store,
&format!("binaires précompilés installés ({tag})"),
)
.await;
}
}
}
/// `prebuiltInstall` : télécharge et installe la dernière release ; rend le
/// chemin du binaire.
pub async fn install(b: &Backends, log: &Log<'_>) -> Result<String, String> {
log.phase("récupération de la dernière release llama.cpp…")
.await;
let (tag, assets) = fetch_latest(b)
.await
.map_err(|e| format!("impossible d'interroger les releases llama.cpp : {e}"))?;
let (cur_tag, cur_cuda) = prebuilt::version(&b.home);
let pick = prebuilt::pick(&assets, &b.probe, b.target).await?;
log.line(&format!("release {tag} — variant retenu : {}", pick.label))
.await;
let cur = prebuilt::server_bin(&b.home);
if cur_tag == tag && prebuilt::version_format(&b.home) == prebuilt::FORMAT && !cur.is_empty() {
log.line(&format!("déjà à jour ({tag})")).await;
prune(b, &cur, log).await;
return Ok(cur);
}
let dir = prebuilt::dir(&b.home);
std::fs::create_dir_all(&dir)
.map_err(|e| crate::paths::os_err("mkdir", &dir.to_string_lossy(), &e))?;
let mut cudart = pick.cudart.clone();
if cudart.is_some() && !cudart_present(&dir).is_empty() && cur_cuda == pick.cuda_ver {
log.line(&format!(
"cudart {} déjà présent — téléchargement évité",
pick.cuda_ver
))
.await;
cudart = None;
}
for a in [Some(pick.main.clone()), cudart].into_iter().flatten() {
log.phase(&format!(
"téléchargement de {} ({} Mo)…",
a.name,
a.size / 1_000_000
))
.await;
let tmp = dir.join(format!("{}.part", a.name));
if let Err(e) = download(b, &a, &tmp, log).await {
let _ = std::fs::remove_file(&tmp);
return Err(format!("téléchargement de {} : {e}", a.name));
}
log.phase(&format!("extraction de {}…", a.name)).await;
let (t2, d2) = (tmp.clone(), dir.clone());
let ex = tokio::task::spawn_blocking(move || prebuilt::extract(&t2, &d2))
.await
.unwrap_or_else(|e| Err(e.to_string()));
if let Err(e) = ex {
let _ = std::fs::remove_file(&tmp);
return Err(format!("extraction de {} : {e}", a.name));
}
let _ = std::fs::remove_file(&tmp);
}
let vpath = dir.join("VERSION");
std::fs::write(
&vpath,
format!("{tag} {} {}\n", pick.cuda_ver, prebuilt::FORMAT),
)
.map_err(|e| crate::paths::os_err("open", &vpath.to_string_lossy(), &e))?;
let bin = prebuilt::server_bin(&b.home);
if bin.is_empty() {
return Err(format!(
"archives extraites mais llama-server introuvable sous {}",
dir.to_string_lossy()
));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755));
}
prune(b, &bin, log).await;
log.line(&format!("binaire installé : {bin} (release {tag})"))
.await;
Ok(bin)
}
/// Le téléchargement d'un asset, sa progression au journal du job au fil de
/// l'eau et dans l'ordre.
async fn download(
b: &Backends,
a: &prebuilt::Asset,
dest: &Path,
log: &Log<'_>,
) -> Result<(), String> {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let dl = async move {
let mut logf = |s: String| {
let _ = tx.send(s);
};
prebuilt::download(&b.client, &a.url, dest, a.size, &mut logf).await
};
let pump = async {
while let Some(s) = rx.recv().await {
log.line(&s).await;
}
};
let (res, ()) = tokio::join!(dl, pump);
res
}
/// cudart déjà extrait (`filepath.Glob(dir/**/cudart64*.dll)` puis
/// `dir/cudart64*.dll` : `**` vaut `*` pour Glob).
fn cudart_present(dir: &Path) -> Vec<PathBuf> {
let is_dll = |n: &str| n.starts_with("cudart64") && n.ends_with(".dll");
let mut found = Vec::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
if let Ok(sub) = std::fs::read_dir(e.path()) {
for s in sub.flatten() {
if is_dll(&s.file_name().to_string_lossy()) {
found.push(s.path());
}
}
}
}
}
if found.is_empty()
&& let Ok(rd) = std::fs::read_dir(dir)
{
for e in rd.flatten() {
if is_dll(&e.file_name().to_string_lossy()) {
found.push(e.path());
}
}
}
found
}
/// `prebuiltPrune`, ses lignes au job.
async fn prune(b: &Backends, keep: &str, log: &Log<'_>) {
let mut lines = Vec::new();
prebuilt::prune(&b.home, keep, &mut |s| lines.push(s));
for l in lines {
log.line(&l).await;
}
}
/// `handleLlamacppUninstallCustom`.
async fn uninstall_custom(b: &Backends, store: &Store, raw: &[u8]) -> Response<Body> {
let d = gojson::decode_struct(raw, NAME_REQ_TYPE, &[("name", Kind::Str)]);
let name = sanitize_backend_name(&d.str("name"));
if name.is_empty() {
return fail(StatusCode::BAD_REQUEST, "nom requis");
}
if name == "llama.cpp" || name == "llama.cpp-prebuilt" {
return fail(
StatusCode::BAD_REQUEST,
"ce moteur se gère depuis les cartes ⚡ / 🔧",
);
}
let dir = match b.backend_dir(&name) {
Ok(d) => d,
Err(e) => return fail(StatusCode::BAD_REQUEST, e),
};
if !is_dir(Path::new(&dir)) {
return fail(StatusCode::NOT_FOUND, "backend introuvable");
}
let cfg_bin = store.config().await.get("BIN").cloned().unwrap_or_default();
for (n, p) in b.list_backend_bins() {
if n == name && same_path(&p, &cfg_bin) {
return fail(
StatusCode::CONFLICT,
"backend utilisé par le modèle actif — bascule d'abord ce modèle sur un autre moteur",
);
}
}
if let Err(e) = std::fs::remove_dir_all(&dir) {
return fail(
StatusCode::INTERNAL_SERVER_ERROR,
crate::paths::os_err("unlinkat", &dir, &e),
);
}
send_json(StatusCode::OK, &json!({ "ok": true }))
}
/// L'API interne du job : le Go y rapporte les étapes de ses jobs.
pub async fn serve_internal(ctx: &Ctx, b: &Backends, req: Request<Incoming>) -> Response<Body> {
let (parts, body) = req.into_parts();
let raw = Limited::new(body, 1 << 20)
.collect()
.await
.map(|b| b.to_bytes().to_vec())
.unwrap_or_default();
let v: Value = serde_json::from_slice(&raw).unwrap_or(Value::Null);
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let store = &ctx.store;
let op = parts.uri.path().trim_start_matches(INTERNAL_PREFIX);
if op == "snapshot" {
let q = parts.uri.query().unwrap_or("");
let from = go_atoi(&query_param(q, "from"));
let lines = query_param(q, "lines") == "1";
return send_json(StatusCode::OK, &b.jobs.snapshot(from, lines).await);
}
if parts.method != Method::POST {
return fail(StatusCode::METHOD_NOT_ALLOWED, "POST attendu");
}
match op {
"start" => {
if let Err(e) = b.jobs.start(store, &s("action")).await {
return fail(StatusCode::CONFLICT, e);
}
return send_json(StatusCode::OK, &json!({ "ok": true }));
}
"line" => b.jobs.append(store, &s("line")).await,
"phase" => b.jobs.phase(store, &s("phase")).await,
"fail" => b.jobs.fail(store, &s("error")).await,
"done" => b.jobs.done(store, &s("msg")).await,
"end" => b.jobs.end(store).await,
"commits" => {
let old = v.get("old").and_then(Value::as_str);
let new = v.get("new").and_then(Value::as_str);
b.jobs.set_commits(old, new).await;
}
_ => return fail(StatusCode::NOT_FOUND, "route inconnue"),
}
send_json(StatusCode::OK, &json!({ "ok": true }))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize() {
assert_eq!(sanitize_backend_name(" Mon Fork/Test "), "mon-fork-test");
assert_eq!(sanitize_backend_name("../.."), "backend");
assert_eq!(sanitize_backend_name(""), "backend");
assert_eq!(sanitize_backend_name("é-x."), "x");
}
#[test]
fn atoi() {
assert_eq!(go_atoi("12"), 12);
assert_eq!(go_atoi("-3"), -3);
assert_eq!(go_atoi("+4"), 4);
assert_eq!(go_atoi("x"), 0);
assert_eq!(go_atoi(""), 0);
assert_eq!(go_atoi("99999999999999999999"), i64::MAX);
}
}
+559
View File
@@ -0,0 +1,559 @@
//! Binaires précompilés officiels de llama.cpp : port de `backend_prebuilt.go`
//! (dernière release, choix de l'asset, téléchargement, extraction avec les
//! liens, marqueur VERSION, ménage des anciennes versions).
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use futures_util::StreamExt;
use regex::Regex;
use serde::Deserialize;
use serde_json::Value;
use crate::gojson::Kind;
use tokio::io::AsyncWriteExt;
use super::probe::Probe;
/// `llamaReleasesAPI`.
pub const RELEASES_API: &str = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest";
/// La struct anonyme que `fetchLlamaLatest` décode.
const RELEASE_TYPE: &str =
r#"struct { TagName string "json:\"tag_name\""; Assets []loki.ghAsset "json:\"assets\"" }"#;
/// `prebuiltFormat` : extraction qui recrée les liens des archives.
pub const FORMAT: &str = "fmt2";
/// `ghAsset`.
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct Asset {
#[serde(default)]
pub name: String,
#[serde(default, rename = "browser_download_url")]
pub url: String,
#[serde(default)]
pub size: i64,
}
/// `prebuiltDir`.
pub fn dir(home: &Path) -> PathBuf {
home.join("backends").join("llama.cpp-prebuilt")
}
fn version_fields(home: &Path) -> Vec<String> {
std::fs::read_to_string(dir(home).join("VERSION"))
.map(|s| s.split_whitespace().map(str::to_string).collect())
.unwrap_or_default()
}
/// `prebuiltVersion` : (tag, version CUDA). La version CUDA est vide hors
/// CUDA Windows : « b300 fmt2 » n'a alors que deux champs, le format en
/// second.
pub fn version(home: &Path) -> (String, String) {
let f = version_fields(home);
let cuda = f
.get(1)
.filter(|c| !c.starts_with("fmt"))
.cloned()
.unwrap_or_default();
(f.first().cloned().unwrap_or_default(), cuda)
}
/// `prebuiltVersionFormat` : le dernier champ du marqueur, s'il en est un.
pub fn version_format(home: &Path) -> String {
let f = version_fields(home);
match f.last() {
Some(last) if f.len() > 1 && last.starts_with("fmt") => last.clone(),
_ => String::new(),
}
}
/// `prebuiltServerBin`.
pub fn server_bin(home: &Path) -> String {
crate::serve::run::EnginePaths {
home: home.to_path_buf(),
}
.prebuilt_server_bin()
}
/// `prebuiltPrune` : les extractions d'autres releases supprimées.
pub fn prune(home: &Path, keep: &str, logf: &mut dyn FnMut(String)) {
let keep_dir = if keep.is_empty() {
String::new()
} else {
crate::paths::go_clean(keep)
};
let d = dir(home);
let Ok(rd) = std::fs::read_dir(&d) else {
return;
};
let mut names: Vec<String> = rd
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with("llama-b"))
.collect();
names.sort();
for name in names {
let p = d.join(&name);
let ps = crate::paths::go_clean(&p.to_string_lossy());
if !keep_dir.is_empty() && keep_dir.starts_with(&format!("{ps}/")) {
continue;
}
if std::fs::remove_dir_all(&p).is_ok() {
logf(format!("ancienne version supprimée : {name}"));
}
}
}
/// `fetchLlamaLatest` : (tag, assets) de la dernière release.
pub async fn fetch_latest(
client: &reqwest::Client,
url: &str,
) -> Result<(String, Vec<Asset>), String> {
let resp = client
.get(url)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Go-http-client/1.1")
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status().as_u16() != 200 {
return Err(format!("GitHub API : HTTP {}", resp.status().as_u16()));
}
let body = resp.bytes().await.map_err(|e| e.to_string())?;
// Erreurs de décodage au mot près du Go (`json.NewDecoder(…).Decode`).
let d = crate::gojson::decode_struct(
&body,
RELEASE_TYPE,
&[("tag_name", Kind::Str), ("assets", Kind::Any)],
);
if let Some(e) = d.err {
return Err(e);
}
let assets: Vec<Asset> = match d.fields.get("assets") {
Some(Value::Null) | None => Vec::new(),
Some(v) => serde_json::from_value(v.clone()).map_err(|e| e.to_string())?,
};
let tag = d.str("tag_name");
if tag.is_empty() {
return Err("release invalide (tag vide)".into());
}
Ok((tag, assets))
}
/// `assetMatch` : les assets dont le nom contient TOUS les fragments.
pub fn asset_match(assets: &[Asset], frags: &[&str]) -> Vec<Asset> {
assets
.iter()
.filter(|a| frags.iter().all(|f| a.name.contains(f)))
.cloned()
.collect()
}
static RE_CUDA_ASSET_VER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"cuda-([0-9]+\.[0-9]+)").expect("motif"));
/// Le choix de `pickPrebuilt`.
#[derive(Clone, Debug, PartialEq)]
pub struct Pick {
pub main: Asset,
pub cudart: Option<Asset>,
pub label: String,
pub cuda_ver: String,
}
/// Système et architecture visés (`runtime.GOOS`, `runtime.GOARCH`).
#[derive(Clone, Copy, Debug)]
pub struct Target {
pub os: &'static str,
pub arm64: bool,
}
impl Target {
pub fn current() -> Self {
Target {
os: if cfg!(windows) {
"windows"
} else if cfg!(target_os = "macos") {
"darwin"
} else {
"linux"
},
arm64: cfg!(target_arch = "aarch64"),
}
}
}
/// `pickPrebuilt` : l'asset principal (+ cudart pour CUDA Windows) adapté à
/// la machine, et un libellé lisible du variant.
pub async fn pick(assets: &[Asset], probe: &Probe, t: Target) -> Result<Pick, String> {
let first = |l: Vec<Asset>| l.into_iter().next();
let mut cudart = None;
let mut cuda_ver = String::new();
let (main, label): (Option<Asset>, String) = match t.os {
"windows" => 'w: {
if t.arm64 {
break 'w (
first(asset_match(assets, &["llama-", "bin-win-cpu-arm64"])),
"CPU (Windows arm64)".into(),
);
}
if probe.has_nvidia_gpu().await {
let cand: Vec<Asset> =
asset_match(assets, &["llama-", "bin-win-cuda-", "-x64.zip"])
.into_iter()
.filter(|a| !a.name.starts_with("cudart"))
.collect();
let max_v = probe.driver_cuda_version().await;
let mut best: Option<Asset> = None;
let mut best_v = 0.0;
for a in cand {
let Some(m) = RE_CUDA_ASSET_VER.captures(&a.name) else {
continue;
};
let v: f64 = m[1].parse().unwrap_or(0.0);
let ok = (max_v == 0.0 && (best.is_none() || v < best_v))
|| (max_v > 0.0 && v <= max_v && v > best_v);
if ok {
cuda_ver = m[1].to_string();
best = Some(a);
best_v = v;
}
}
if let Some(b) = best {
cudart = first(asset_match(
assets,
&["cudart-", &format!("win-cuda-{cuda_ver}")],
));
break 'w (Some(b), format!("CUDA {cuda_ver} (Windows x64)"));
}
}
if let Some(a) = first(asset_match(assets, &["llama-", "bin-win-vulkan-x64"])) {
break 'w (Some(a), "Vulkan (Windows x64)".into());
}
(
first(asset_match(assets, &["llama-", "bin-win-cpu-x64"])),
"CPU (Windows x64)".into(),
)
}
"darwin" => {
let arch = if t.arm64 { "arm64" } else { "x64" };
(
first(asset_match(
assets,
&["llama-", &format!("bin-macos-{arch}")],
)),
format!("Metal (macOS {arch})"),
)
}
_ => 'l: {
let arch = if t.arm64 { "arm64" } else { "x64" };
if (probe.has_tool("hipcc") || super::probe::is_dir(&probe.sys("/opt/rocm")))
&& let Some(a) = first(asset_match(assets, &["llama-", "bin-ubuntu-rocm-", arch]))
{
break 'l (Some(a), format!("ROCm (Linux {arch})"));
}
let nvidia = probe.has_nvidia_gpu().await;
if (nvidia || probe.has_tool("vulkaninfo"))
&& let Some(a) = first(asset_match(
assets,
&["llama-", &format!("bin-ubuntu-vulkan-{arch}")],
))
{
let mut label = format!("Vulkan (Linux {arch})");
// Le Go relance nvidia-smi pour ce suffixe : même résultat.
if probe.has_nvidia_gpu().await {
label.push_str(" — pas de build CUDA officiel Linux ; compile localement pour du CUDA natif");
}
break 'l (Some(a), label);
}
(
first(asset_match(
assets,
&["llama-", &format!("bin-ubuntu-{arch}.tar.gz")],
)),
format!("CPU (Linux {arch})"),
)
}
};
match main {
Some(main) => Ok(Pick {
main,
cudart,
label,
cuda_ver,
}),
None => {
Err("aucun binaire précompilé adapté à cette machine dans la release officielle".into())
}
}
}
/// `recommendedMode` : {mode, why}.
pub fn recommended_mode(backend: &str, os: &str) -> serde_json::Value {
if os == "linux" && backend == "cuda" {
return serde_json::json!({
"mode": "opt",
"why": "seule voie vers du CUDA natif : llama.cpp ne publie aucun binaire CUDA pour Linux, le précompilé ne donnerait que du Vulkan. Compte plusieurs minutes de compilation.",
});
}
serde_json::json!({ "mode": "fast", "why": "" })
}
/// `downloadWithProgress` : progression journalisée par tranches de ~25 Mo.
pub async fn download(
client: &reqwest::Client,
url: &str,
dest: &Path,
total: i64,
logf: &mut (dyn FnMut(String) + Send),
) -> Result<(), String> {
let resp = client
.get(url)
.header("User-Agent", "Go-http-client/1.1")
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status().as_u16() != 200 {
return Err(format!("HTTP {}", resp.status().as_u16()));
}
let total = if total <= 0 {
resp.content_length().map_or(-1, |n| n as i64)
} else {
total
};
let mut f = tokio::fs::File::create(dest)
.await
.map_err(|e| crate::paths::os_err("open", &dest.to_string_lossy(), &e))?;
let (mut done, mut last_log) = (0i64, 0i64);
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| e.to_string())?;
f.write_all(&chunk)
.await
.map_err(|e| crate::paths::os_err("write", &dest.to_string_lossy(), &e))?;
done += chunk.len() as i64;
if done - last_log >= 25 << 20 {
last_log = done;
if total > 0 {
logf(format!(
"⬇ {} / {} Mo ({}%)",
done / 1_000_000,
total / 1_000_000,
done * 100 / total
));
} else {
logf(format!("⬇ {} Mo", done / 1_000_000));
}
}
}
f.flush()
.await
.map_err(|e| crate::paths::os_err("write", &dest.to_string_lossy(), &e))?;
Ok(())
}
/// `extractArchive` : .zip ou .tar.gz dans `dir`, sans entrée qui
/// s'échapperait du dossier ; les liens des tar posés après coup.
pub fn extract(path: &Path, dir: &Path) -> Result<(), String> {
let dir_s = dir.to_string_lossy().into_owned();
let safe = |name: &str| -> Result<PathBuf, String> {
let p = crate::paths::go_join(&[&dir_s, name]);
let rel = go_rel(&crate::paths::go_clean(&dir_s), &p);
match rel {
Some(r) if !r.starts_with("..") => Ok(PathBuf::from(p)),
_ => Err(format!("entrée d'archive suspecte : {name}")),
}
};
let ps = path.to_string_lossy();
if ps.ends_with(".zip") || ps.ends_with(".zip.part") {
let f = std::fs::File::open(path).map_err(|e| crate::paths::os_err("open", &ps, &e))?;
let mut zr = zip::ZipArchive::new(f).map_err(|e| e.to_string())?;
for i in 0..zr.len() {
let mut e = zr.by_index(i).map_err(|e| e.to_string())?;
let name = e.name().to_string();
let p = safe(&name)?;
if e.is_dir() {
mkdir_all(&p)?;
continue;
}
if let Some(parent) = p.parent() {
mkdir_all(parent)?;
}
let mut w = std::fs::File::create(&p)
.map_err(|err| crate::paths::os_err("open", &p.to_string_lossy(), &err))?;
std::io::copy(&mut e, &mut w).map_err(|err| err.to_string())?;
}
return Ok(());
}
let f = std::fs::File::open(path).map_err(|e| crate::paths::os_err("open", &ps, &e))?;
let gz = flate2::read::GzDecoder::new(f);
let mut tr = tar::Archive::new(gz);
let mut links: Vec<(PathBuf, String)> = Vec::new();
let entries = tr.entries().map_err(|e| e.to_string())?;
for entry in entries {
let mut entry = entry.map_err(|e| e.to_string())?;
let name = String::from_utf8_lossy(&entry.path_bytes()).into_owned();
let p = safe(&name)?;
let kind = entry.header().entry_type();
if kind.is_dir() {
mkdir_all(&p)?;
} else if kind.is_file() {
if let Some(parent) = p.parent() {
mkdir_all(parent)?;
}
let mut w = std::fs::File::create(&p)
.map_err(|err| crate::paths::os_err("open", &p.to_string_lossy(), &err))?;
let mut buf = Vec::new();
entry.read_to_end(&mut buf).map_err(|e| e.to_string())?;
std::io::Write::write_all(&mut w, &buf).map_err(|e| e.to_string())?;
drop(w);
#[cfg(unix)]
if let Ok(mode) = entry.header().mode() {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode & 0o777));
}
} else if kind.is_symlink() || kind.is_hard_link() {
let target = entry
.link_name_bytes()
.map(|b| String::from_utf8_lossy(&b).into_owned())
.unwrap_or_default();
let target = if kind.is_hard_link() {
safe(&target)?.to_string_lossy().into_owned()
} else {
target
};
links.push((p, target));
}
}
apply_links(&links)
}
fn mkdir_all(p: &Path) -> Result<(), String> {
std::fs::create_dir_all(p).map_err(|e| crate::paths::os_err("mkdir", &p.to_string_lossy(), &e))
}
/// `applyLinks` : liens symboliques, ou copie de la cible quand le système
/// les refuse.
fn apply_links(links: &[(PathBuf, String)]) -> Result<(), String> {
for (path, target) in links {
let _ = std::fs::remove_file(path);
if let Some(parent) = path.parent() {
mkdir_all(parent)?;
}
#[cfg(unix)]
if std::os::unix::fs::symlink(target, path).is_ok() {
continue;
}
let src = if Path::new(target).is_absolute() {
PathBuf::from(target)
} else {
path.parent().unwrap_or(Path::new(".")).join(target)
};
let Ok(data) = std::fs::read(&src) else {
continue;
};
std::fs::write(path, data)
.map_err(|e| crate::paths::os_err("open", &path.to_string_lossy(), &e))?;
}
Ok(())
}
/// `filepath.Rel` pour deux chemins nettoyés, `base` absolu : `None` si
/// `targ` n'est pas exprimable relativement (l'un absolu, l'autre non).
fn go_rel(base: &str, targ: &str) -> Option<String> {
if base.starts_with('/') != targ.starts_with('/') {
return None;
}
if base == targ {
return Some(".".into());
}
let b: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect();
let t: Vec<&str> = targ.split('/').filter(|s| !s.is_empty()).collect();
let common = b.iter().zip(&t).take_while(|(x, y)| x == y).count();
let mut parts: Vec<&str> = vec![".."; b.len() - common];
parts.extend(&t[common..]);
Some(parts.join("/"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rel() {
assert_eq!(go_rel("/a/b", "/a/b/c").as_deref(), Some("c"));
assert_eq!(go_rel("/a/b", "/a/c").as_deref(), Some("../c"));
assert_eq!(go_rel("/a/b", "/a/b").as_deref(), Some("."));
assert_eq!(go_rel("/a/b", "/a/b..x").as_deref(), Some("../b..x"));
}
#[test]
fn marqueur_version() {
let tmp = tempfile::tempdir().unwrap();
let d = dir(tmp.path());
std::fs::create_dir_all(&d).unwrap();
for (content, tag, cuda, fmt) in [
("b300 fmt2\n", "b300", "", "fmt2"),
("b300 12.4 fmt2\n", "b300", "12.4", "fmt2"),
("b300 12.4\n", "b300", "12.4", ""),
("b300 \n", "b300", "", ""),
("fmt2\n", "fmt2", "", ""),
("", "", "", ""),
] {
std::fs::write(d.join("VERSION"), content).unwrap();
assert_eq!(
version(tmp.path()),
(tag.into(), cuda.into()),
"{content:?}"
);
assert_eq!(version_format(tmp.path()), fmt, "{content:?}");
}
}
/// ROCm reconnu à son dossier (/opt/rocm), sans hipcc dans le PATH.
#[tokio::test]
async fn rocm_par_son_dossier() {
let root = tempfile::tempdir().unwrap();
let tools = tempfile::tempdir().unwrap();
let probe = Probe {
path: Some(vec![tools.path().to_path_buf()]),
root: root.path().to_path_buf(),
};
let asset = |n: &str| Asset {
name: n.into(),
url: String::new(),
size: 1,
};
let assets = [
asset("llama-b1-bin-ubuntu-rocm-7.0-x64.tar.gz"),
asset("llama-b1-bin-ubuntu-x64.tar.gz"),
];
let linux = Target {
os: "linux",
arm64: false,
};
assert_eq!(
pick(&assets, &probe, linux).await.unwrap().label,
"CPU (Linux x64)"
);
assert_eq!(probe.detect_build_plan().await.backend, "cpu");
std::fs::create_dir_all(root.path().join("opt/rocm")).unwrap();
assert_eq!(
pick(&assets, &probe, linux).await.unwrap().label,
"ROCm (Linux x64)"
);
assert_eq!(probe.detect_build_plan().await.backend, "hip");
}
#[test]
fn reco() {
assert_eq!(recommended_mode("cuda", "linux")["mode"], "opt");
assert_eq!(recommended_mode("cuda", "windows")["mode"], "fast");
assert_eq!(recommended_mode("vulkan", "linux")["why"], "");
}
}
+428
View File
@@ -0,0 +1,428 @@
//! Sondes de la machine : port de `detectBuildPlan` (le seul plan exposé :
//! backend, architectures CUDA, parallélisme), `findNvcc`, `hasNvidiaGPU`,
//! `detectCudaArch`, `hasTool`, `hasVulkanLib`, `driverCudaVersion` et
//! `detectGPUs` (`backend_build.go`, `backend_prebuilt.go`, `backend_gpu.go`).
//!
//! Les outils sont cherchés dans un PATH et les chemins système sous une
//! racine, tous deux injectables : les tests rejouent une machine sans toucher
//! à l'environnement du processus.
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::LazyLock;
use std::time::Duration;
use regex::Regex;
use tokio::process::Command;
/// Délai de `nvidiaSmiGPUQuery` (`nvidiaSmiTimeout`).
const NVIDIA_SMI_TIMEOUT: Duration = Duration::from_secs(10);
/// `gpuBaseFields` / `gpuLinkFields`.
const GPU_BASE_FIELDS: &str = "index,name,memory.total,memory.used,compute_cap";
const GPU_LINK_FIELDS: &str = "pci.bus_id,pcie.link.gen.current,pcie.link.gen.max,pcie.link.width.current,pcie.link.width.max,clocks.max.memory";
/// La machine telle que les sondes la voient.
#[derive(Clone, Debug, Default)]
pub struct Probe {
/// Dossiers où chercher les outils ; `None` : le PATH du processus, relu
/// à chaque recherche (comme `exec.LookPath`).
pub path: Option<Vec<PathBuf>>,
/// Racine des chemins système (`/usr/local/cuda`, `/opt/rocm`…) ; vide :
/// la vraie racine.
pub root: PathBuf,
}
/// Le plan de compilation tel que le statut l'expose (`buildPlan`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BuildPlan {
pub backend: &'static str,
pub cuda_arch: String,
pub jobs: usize,
}
/// `gpuInfo` : une carte vue par nvidia-smi.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GpuInfo {
pub index: i64,
pub name: String,
pub mem_total: String,
pub mem_used: String,
pub cap: String,
pub bus_id: String,
pub link_gen: i64,
pub link_gen_max: i64,
pub link_width: i64,
pub link_width_max: i64,
pub mem_clock_max: i64,
}
impl Probe {
/// La vraie machine.
pub fn system() -> Self {
Probe::default()
}
/// Un chemin système sous la racine des sondes.
pub fn sys(&self, p: &str) -> PathBuf {
if self.root.as_os_str().is_empty() {
PathBuf::from(p)
} else {
self.root.join(p.trim_start_matches('/'))
}
}
fn dirs(&self) -> Vec<PathBuf> {
match &self.path {
Some(p) => p.clone(),
None => std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default(),
}
}
/// `exec.LookPath` dans le PATH des sondes.
pub fn look_path(&self, name: &str) -> Option<PathBuf> {
if name.contains('/') {
let p = PathBuf::from(name);
return is_exec(&p).then_some(p);
}
self.dirs()
.into_iter()
.map(|d| {
if d.as_os_str().is_empty() {
PathBuf::from(".").join(name)
} else {
d.join(name)
}
})
.find(|p| is_exec(p))
}
/// `hasTool`.
pub fn has_tool(&self, name: &str) -> bool {
self.look_path(name).is_some()
}
/// `exec.Command(name, args…).Output()` : la sortie standard si le
/// programme est trouvé et sort en succès.
pub async fn output(&self, name: &str, args: &[&str]) -> Option<Vec<u8>> {
self.output_in(None, name, args).await
}
/// [`Probe::output`] lancé dans `dir`.
pub async fn output_in(
&self,
dir: Option<&Path>,
name: &str,
args: &[&str],
) -> Option<Vec<u8>> {
let bin = self.look_path(name)?;
let mut c = Command::new(bin);
if let Some(d) = dir {
c.current_dir(d);
}
let out = c
.args(args)
.stdin(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.output()
.await
.ok()?;
out.status.success().then_some(out.stdout)
}
/// `findNvcc` (Linux, macOS) : un vrai toolkit avant le PATH.
pub fn find_nvcc(&self) -> Option<PathBuf> {
let p = self.sys("/usr/local/cuda/bin/nvcc");
if is_file(&p) {
return Some(p);
}
let local = self.sys("/usr/local");
let mut versioned: Vec<PathBuf> = std::fs::read_dir(&local)
.map(|rd| {
rd.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with("cuda-"))
.map(|e| e.path().join("bin").join("nvcc"))
.filter(|p| std::fs::symlink_metadata(p).is_ok())
.collect()
})
.unwrap_or_default();
versioned.sort();
if let Some(p) = versioned.pop() {
return Some(p);
}
self.look_path("nvcc")
}
/// `hasNvidiaGPU`.
pub async fn has_nvidia_gpu(&self) -> bool {
if !self.has_tool("nvidia-smi") {
return false;
}
self.output("nvidia-smi", &["-L"])
.await
.is_some_and(|o| String::from_utf8_lossy(&o).contains("GPU"))
}
/// `detectCudaArch` : « 8.6 » → « 86 », dédoublonnés, joints par « ; ».
pub async fn detect_cuda_arch(&self) -> String {
let Some(out) = self
.output(
"nvidia-smi",
&["--query-gpu=compute_cap", "--format=csv,noheader"],
)
.await
else {
return String::new();
};
cuda_archs(&String::from_utf8_lossy(&out))
}
/// `hasVulkanLib` : le cache de ldconfig, puis des chemins connus.
pub async fn has_vulkan_lib(&self) -> bool {
if let Some(out) = self.output("ldconfig", &["-p"]).await
&& String::from_utf8_lossy(&out).contains("libvulkan.so")
{
return true;
}
[
"/usr/lib/x86_64-linux-gnu/libvulkan.so.1",
"/usr/lib64/libvulkan.so.1",
"/usr/lib/libvulkan.so.1",
"/lib64/libvulkan.so.1",
]
.iter()
.any(|p| is_file(&self.sys(p)))
}
/// `detectBuildPlan`, réduit à ce que le statut expose.
pub async fn detect_build_plan(&self) -> BuildPlan {
let mut p = BuildPlan {
backend: "cpu",
cuda_arch: String::new(),
jobs: num_jobs(),
};
if cfg!(target_os = "macos") {
p.backend = "metal";
return p;
}
if self.find_nvcc().is_some() && self.has_nvidia_gpu().await {
p.backend = "cuda";
p.cuda_arch = self.detect_cuda_arch().await;
return p;
}
if self.has_tool("hipcc") || is_dir(&self.sys("/opt/rocm")) {
p.backend = "hip";
return p;
}
if self.has_tool("glslc") && (self.has_vulkan_lib().await || self.has_tool("vulkaninfo")) {
p.backend = "vulkan";
}
p
}
/// `driverCudaVersion` : la version CUDA du bandeau de nvidia-smi, 0 si
/// inconnue.
pub async fn driver_cuda_version(&self) -> f64 {
let Some(out) = self.output("nvidia-smi", &[]).await else {
return 0.0;
};
static RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"CUDA Version:\s*([0-9]+\.[0-9]+)").expect("motif"));
RE.captures(&String::from_utf8_lossy(&out))
.and_then(|m| m[1].parse().ok())
.unwrap_or(0.0)
}
/// `nvidiaSmiGPUQuery` : (sortie, code de sortie) ; `None` = pas lancé ou
/// délai dépassé.
async fn smi_query(&self, fields: &str) -> Option<(Vec<u8>, i32)> {
let bin = self.look_path("nvidia-smi")?;
let child = Command::new(bin)
.arg(format!("--query-gpu={fields}"))
.arg("--format=csv,noheader,nounits")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.ok()?;
let out = tokio::time::timeout(NVIDIA_SMI_TIMEOUT, child.wait_with_output())
.await
.ok()?
.ok()?;
Some((out.stdout, out.status.code().unwrap_or(-1)))
}
/// `detectGPUs` : les cartes NVIDIA, champs de liaison compris quand le
/// pilote les connaît.
pub async fn detect_gpus(&self) -> Result<Vec<GpuInfo>, String> {
if !self.has_tool("nvidia-smi") {
return Err(
"nvidia-smi introuvable — sélection GPU disponible uniquement sur NVIDIA".into(),
);
}
let mut res = self
.smi_query(&format!("{GPU_BASE_FIELDS},{GPU_LINK_FIELDS}"))
.await;
if let Some((out, code)) = &res
&& *code != 0
&& (String::from_utf8_lossy(out).contains("valid field") || *code == 2)
{
res = self.smi_query(GPU_BASE_FIELDS).await;
}
let out = match res {
Some((out, 0)) => out,
Some((_, code)) => return Err(format!("nvidia-smi a échoué: exit status {code}")),
None => return Err("nvidia-smi a échoué".into()),
};
let gpus = parse_gpu_query(&String::from_utf8_lossy(&out));
if gpus.is_empty() {
return Err("aucun GPU NVIDIA détecté".into());
}
Ok(gpus)
}
}
/// Les architectures CUDA d'une sortie `compute_cap` (`detectCudaArch`).
pub fn cuda_archs(out: &str) -> String {
let mut archs: Vec<String> = Vec::new();
for line in out.trim().split('\n') {
let cap = line.trim();
if cap.is_empty() || cap.to_lowercase().contains("not supported") {
continue;
}
let code = cap.replace('.', "");
if !code.is_empty() && !archs.contains(&code) {
archs.push(code);
}
}
archs.join(";")
}
/// `parseGPUQuery`.
pub fn parse_gpu_query(out: &str) -> Vec<GpuInfo> {
let mut gpus = Vec::new();
for line in out.trim().split('\n') {
let parts: Vec<&str> = line.split(',').map(str::trim).collect();
if parts.len() < 5 {
continue;
}
let mut g = GpuInfo {
index: parts[0].parse().unwrap_or(0),
name: parts[1].into(),
mem_total: parts[2].into(),
mem_used: parts[3].into(),
cap: parts[4].into(),
..GpuInfo::default()
};
if parts.len() >= 11 {
let b = parts[5];
if !b.is_empty() && b != "N/A" && !b.starts_with('[') {
g.bus_id = b.into();
}
g.link_gen = smi_num(parts[6]);
g.link_gen_max = smi_num(parts[7]);
g.link_width = smi_num(parts[8]);
g.link_width_max = smi_num(parts[9]);
g.mem_clock_max = smi_num(parts[10]);
}
gpus.push(g);
}
gpus
}
/// `smiNum` : « [N/A] », vide ou négatif → 0.
pub fn smi_num(s: &str) -> i64 {
match s.trim().parse::<i64>() {
Ok(n) if n >= 0 => n,
_ => 0,
}
}
/// `numJobs` : `runtime.NumCPU()`, c'est-à-dire les processeurs de
/// l'affinité du processus (sans les quotas de cgroup, que le Go ignore).
pub fn num_jobs() -> usize {
#[cfg(target_os = "linux")]
{
// SAFETY: cpu_set_t est une simple table de bits, initialisée à zéro
// avant que sched_getaffinity ne la remplisse.
unsafe {
let mut set: libc::cpu_set_t = std::mem::zeroed();
if libc::sched_getaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &mut set) == 0 {
let n = libc::CPU_COUNT(&set);
if n > 0 {
return n as usize;
}
}
}
}
std::thread::available_parallelism().map_or(1, |n| n.get())
}
fn is_exec(p: &Path) -> bool {
std::fs::metadata(p).is_ok_and(|m| {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
m.is_file() && m.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
m.is_file()
}
})
}
/// `isFile`.
pub fn is_file(p: &Path) -> bool {
std::fs::metadata(p).is_ok_and(|m| !m.is_dir())
}
/// `isDir`.
pub fn is_dir(p: &Path) -> bool {
std::fs::metadata(p).is_ok_and(|m| m.is_dir())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn archs() {
assert_eq!(cuda_archs("8.6\n8.9\n8.6\n"), "86;89");
assert_eq!(cuda_archs("[Not Supported]\n12.0"), "120");
assert_eq!(cuda_archs(""), "");
}
#[test]
fn requete_gpu() {
let g = parse_gpu_query(
"0, A, 100, 1, 8.6, 00000000:01:00.0, 1, 4, 16, 16, 9751\n1, B, 8, 0, 7.5, [N/A], 2, 3, 8, 16, 7001\n2, C, 8, 0, 7.5, N/A, x, 3, 8, 16, 1\n3, D, 1, 0\n4, E, 2, 1, 6.1",
);
assert_eq!(g.len(), 4);
assert_eq!(g[0].bus_id, "00000000:01:00.0");
assert_eq!((g[0].link_gen, g[0].mem_clock_max), (1, 9751));
assert_eq!(g[1].bus_id, "");
assert_eq!(g[2].bus_id, "");
assert_eq!(g[2].link_gen, 0);
assert_eq!(
(g[3].index, g[3].name.as_str(), g[3].bus_id.as_str()),
(4, "E", "")
);
}
#[test]
fn smi() {
assert_eq!(smi_num(" 4 "), 4);
assert_eq!(smi_num("[N/A]"), 0);
assert_eq!(smi_num("-1"), 0);
}
#[test]
fn jobs_positif() {
assert!(num_jobs() >= 1);
}
}
+1
View File
@@ -12,6 +12,7 @@
pub mod api;
pub mod auth;
pub mod backends;
pub mod chat;
pub mod chatapi;
pub mod chatx;
+21
View File
@@ -163,6 +163,10 @@ fn serve() -> ExitCode {
// (l'image se met à jour par Docker).
let update_rust = held_by_facade("LOKI_UPDATE")
&& loki_rs::sysupdate::usable(std::env::var("LOKI_CONTAINER").ok().as_deref());
// LOKI_BACKENDS=rust : les backends llama.cpp (statut, précompilé,
// bascule, GPU) et LE job des moteurs, auquel les jobs du Go
// (compilation, moteur de l'image, Strata) rapportent leurs étapes.
let backends_rust = held_by_facade("LOKI_BACKENDS");
let engine_rust = held_by_facade("LOKI_ENGINE")
&& match loki_rs::enginectl::usable(
std::env::var("LOKI_CONTAINER").ok().as_deref(),
@@ -194,6 +198,7 @@ fn serve() -> ExitCode {
("LOKI_PROJECTS", projects_rust),
("LOKI_MCP", mcp_rust),
("LOKI_UPDATE", update_rust),
("LOKI_BACKENDS", backends_rust),
];
for (var, on) in held {
if on {
@@ -245,6 +250,21 @@ fn serve() -> ExitCode {
std::sync::Arc::new(loki_rs::dictate::Dictation::new(&engine_paths.home))
});
// Le dernier job connu, repris de la base (un job « en cours » est mort
// avec le processus précédent).
let backends = if backends_rust {
let b = std::sync::Arc::new(loki_rs::backends::Backends::new(
engine_paths.home.clone(),
std::env::var("LOKI_ENGINE_BIN").unwrap_or_default(),
));
b.jobs
.restore(&loki_rs::store::Store::new(backend, token.clone()))
.await;
Some(b)
} else {
None
};
let files = if files_rust {
Some(start_files(backend, &token).await)
} else {
@@ -300,6 +320,7 @@ fn serve() -> ExitCode {
system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())),
projects: projects_rust,
update: update_rust,
backends,
mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))),
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
+15
View File
@@ -93,6 +93,9 @@ pub struct Config {
/// Mise à jour de Loki tenue par la façade (`LOKI_UPDATE=rust`, en
/// conteneur).
pub update: bool,
/// Backends llama.cpp (statut, précompilé, bascule, GPU) et job des
/// moteurs tenus par la façade (`LOKI_BACKENDS=rust`) ; `None` : au Go.
pub backends: Option<Arc<crate::backends::Backends>>,
}
struct Proxy {
@@ -132,6 +135,7 @@ pub async fn serve(
projects: cfg.projects,
mcp: cfg.mcp,
update: cfg.update,
backends: cfg.backends,
internal_token: cfg.internal_token.clone(),
},
});
@@ -206,6 +210,12 @@ impl Proxy {
{
return crate::mcp::serve_internal(&self.ctx, m, req).await;
}
if from_go
&& let Some(b) = &self.ctx.backends
&& crate::backends::is_internal(path)
{
return crate::backends::serve_internal(&self.ctx, b, req).await;
}
// L'API interne du Go n'est jamais joignable de l'extérieur.
if path.starts_with("/internal/") {
return plain(StatusCode::NOT_FOUND, "404 page not found");
@@ -230,6 +240,11 @@ impl Proxy {
{
return chatapi::serve(&self.ctx, chat, req, from_go).await;
}
if let Some(b) = &self.ctx.backends
&& crate::backends::handles(path)
{
return crate::backends::serve(&self.ctx, b, req, from_go).await;
}
if self.ctx.update && crate::sysupdate::handles(path) {
return crate::sysupdate::serve(&self.ctx, req, from_go).await;
}
+2 -2
View File
@@ -189,7 +189,7 @@ impl EnginePaths {
!p.is_empty() && norm_path(p).starts_with(&(norm_path(&self.engine_dir()) + "/"))
}
/// `prebuiltServerBin` : le binaire du VERSION, sinon le dernier.
fn prebuilt_server_bin(&self) -> String {
pub fn prebuilt_server_bin(&self) -> String {
let mut found = Vec::new();
fn walk(d: &Path, out: &mut Vec<String>) {
let Ok(rd) = std::fs::read_dir(d) else { return };
@@ -229,7 +229,7 @@ impl EnginePaths {
if cur.is_empty() { bin.into() } else { cur }
}
/// `providedEngineBin`.
fn provided_engine_bin(&self) -> String {
pub fn provided_engine_bin(&self) -> String {
let p = std::env::var("LOKI_ENGINE_BIN").unwrap_or_default();
let p = trim(&p);
if p.is_empty() {
+1 -1
View File
@@ -171,7 +171,7 @@ pub fn key_hint(key: &str) -> String {
/// Octets → texte comme l'encodeur JSON du Go : chaque octet invalide devient
/// U+FFFD.
fn go_lossy(mut b: &[u8]) -> String {
pub(crate) fn go_lossy(mut b: &[u8]) -> String {
let mut out = String::new();
loop {
match std::str::from_utf8(b) {
+786
View File
@@ -0,0 +1,786 @@
//! Lot 22 : backends llama.cpp tenus par la façade (`LOKI_BACKENDS=rust`).
//!
//! Le journal tiré du Go (`internal/loki/parity_backends_test.go` →
//! `tests/parity_backends/backends.json`) est rejoué par une seule façade :
//! statut (dépôt git, précompilé, moteur téléchargé ou de l'image, plan de
//! build et conseil), vérification git, backends custom et désinstallation,
//! bascule fast/opt, dernière release, job des binaires précompilés
//! (extraction avec liens, ménage, VERSION, échecs), GPU vus par un moteur ;
//! puis le plan et la release machine par machine (faux outils dans le PATH),
//! le job pas à pas et sa reprise au démarrage. Le reste — l'API interne par
//! laquelle les jobs du Go rapportent leurs étapes, le service arrêté puis
//! relancé autour de l'installation — est vérifié contre le faux Go.
mod common;
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use bytes::Bytes;
use common::fakego;
use http_body_util::Full;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use loki_rs::backends::job::Jobs;
use loki_rs::backends::probe::Probe;
use loki_rs::backends::{Backends, prebuilt};
use regex::Regex;
use serde_json::{Value, json};
use tokio::net::TcpListener;
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_backends/backends.json")).expect("journal")
}
fn s(v: &Value) -> &str {
v.as_str().unwrap_or("")
}
const TUNE_BUSY: &str = "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la";
/// Le faux GitHub : la release courante (`{status, json, assets}`).
async fn fake_github(rel: Arc<Mutex<Value>>) -> String {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((st, _)) = l.accept().await {
let rel = rel.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
let cur = rel.lock().unwrap().clone();
async move {
let host = req
.headers()
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("")
.to_string();
let path = req.uri().path().to_string();
let (code, body) = if path == "/latest" {
(
cur["status"].as_u64().unwrap_or(200) as u16,
s(&cur["json"])
.replace("{{GH}}", &format!("http://{host}"))
.into_bytes(),
)
} else {
let name = path.trim_start_matches("/dl/");
match cur["assets"].get(name).and_then(Value::as_array) {
Some(entries) => (200, tar_gz(entries)),
None => (404, b"404 page not found\n".to_vec()),
}
};
let mut r = Response::new(Full::new(Bytes::from(body)));
*r.status_mut() = hyper::StatusCode::from_u16(code).unwrap();
Ok::<_, Infallible>(r)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(st), svc)
.await;
});
}
});
format!("http://{addr}")
}
/// Une archive .tar.gz ; les noms sont écrits tels quels (« ../x » compris).
fn tar_gz(entries: &[Value]) -> Vec<u8> {
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut b = tar::Builder::new(gz);
for e in entries {
let mut h = tar::Header::new_ustar();
let name = s(&e["name"]).as_bytes();
h.as_old_mut().name[..name.len()].copy_from_slice(name);
let link = s(&e["link"]).as_bytes();
h.as_old_mut().linkname[..link.len()].copy_from_slice(link);
h.set_mtime(1_700_000_000);
h.set_mode(e["mode"].as_u64().unwrap_or(0) as u32);
let body = s(&e["body"]).as_bytes().to_vec();
match s(&e["type"]) {
"dir" => h.set_entry_type(tar::EntryType::Directory),
"symlink" => {
h.set_entry_type(tar::EntryType::Symlink);
h.set_mode(0o777);
}
"hardlink" => h.set_entry_type(tar::EntryType::Link),
_ => {
h.set_entry_type(tar::EntryType::Regular);
h.set_size(body.len() as u64);
}
}
if s(&e["type"]) != "file" {
h.set_size(0);
}
h.set_cksum();
let data: &[u8] = if s(&e["type"]) == "file" { &body } else { &[] };
b.append(&h, data).unwrap();
}
b.into_inner().unwrap().finish().unwrap()
}
fn write_file(p: &Path, content: &str, mode: u32) {
use std::os::unix::fs::OpenOptionsExt;
let _ = std::fs::remove_file(p);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(p)
.unwrap();
std::io::Write::write_all(&mut f, content.as_bytes()).unwrap();
}
fn remove_all(p: &Path) {
match std::fs::symlink_metadata(p) {
Ok(m) if m.is_dir() => std::fs::remove_dir_all(p).unwrap(),
Ok(_) => std::fs::remove_file(p).unwrap(),
Err(_) => {}
}
}
/// Les fichiers d'une étape (`parityApplyFiles`).
fn apply_files(home: &Path, files: &Value) {
for (n, v) in files.as_object().into_iter().flatten() {
let v = s(v);
let p = home.join(n.trim_end_matches('/'));
match v {
"-" => {
remove_all(&p);
continue;
}
"/" => {
std::fs::create_dir_all(&p).unwrap();
continue;
}
_ => {}
}
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
if let Some(target) = v.strip_prefix("-> ") {
let _ = std::fs::remove_file(&p);
std::os::unix::fs::symlink(target, &p).unwrap();
continue;
}
write_file(&p, v, if v.starts_with("#!") { 0o755 } else { 0o644 });
}
}
/// Les opérations git d'une étape (`parityGit`).
fn git_op(home: &Path, op: &str) {
let f: Vec<&str> = op.splitn(4, ' ').collect();
let git = |dir: &Path, date: &str, args: &[&str]| {
let out = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_NAME", "Loki")
.env("GIT_AUTHOR_EMAIL", "loki@exemple.fr")
.env("GIT_COMMITTER_NAME", "Loki")
.env("GIT_COMMITTER_EMAIL", "loki@exemple.fr")
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?} : {out:?}");
};
match f[0] {
"init" => {
let d = home.join(f[1]);
std::fs::create_dir_all(&d).unwrap();
git(&d, "", &["init", "-q", "-b", "master"]);
}
"commit" => {
let d = home.join(f[1]);
std::fs::write(d.join("README"), f[3]).unwrap();
git(&d, f[2], &["add", "README"]);
git(&d, f[2], &["commit", "-q", "-m", f[3]]);
}
"clone" => {
let (src, dst) = (home.join(f[1]), home.join(f[2]));
git(
home,
"",
&[
"clone",
"-q",
&src.to_string_lossy(),
&dst.to_string_lossy(),
],
);
}
"pull" => git(&home.join(f[1]), "", &["pull", "-q", "--ff-only"]),
"detach" => git(&home.join(f[1]), "", &["checkout", "-q", "--detach"]),
"remote-bad" => {
let bad = home.join("nulle-part");
git(
&home.join(f[1]),
"",
&["remote", "set-url", "origin", &bad.to_string_lossy()],
);
}
other => panic!("opération git inconnue : {other}"),
}
}
/// Les outils d'une machine dans le PATH des sondes (git compris).
fn set_machine(tools: &Path, machines: &Value, name: &str) {
for e in std::fs::read_dir(tools).unwrap().flatten() {
std::fs::remove_file(e.path()).unwrap();
}
let git = loki_rs::paths::look_path("git").expect("git");
std::os::unix::fs::symlink(git, tools.join("git")).unwrap();
for (tool, script) in machines[name].as_object().into_iter().flatten() {
write_file(&tools.join(tool), s(script), 0o755);
}
}
/// `info.Mode().Perm().String()`.
fn perm_string(mode: u32) -> String {
let mut out = String::from("-");
for shift in [6, 3, 0] {
let b = (mode >> shift) & 7;
out.push(if b & 4 != 0 { 'r' } else { '-' });
out.push(if b & 2 != 0 { 'w' } else { '-' });
out.push(if b & 1 != 0 { 'x' } else { '-' });
}
out
}
/// `parityTree`.
fn tree(home: &Path) -> Vec<String> {
use std::os::unix::fs::PermissionsExt;
fn walk(home: &Path, p: &Path, out: &mut Vec<String>) {
let Ok(m) = std::fs::symlink_metadata(p) else {
return;
};
let rel = p.strip_prefix(home).unwrap().to_string_lossy().into_owned();
if rel.contains("/.git/") || rel.ends_with("/.git") {
return;
}
if m.file_type().is_symlink() {
let t = std::fs::read_link(p).unwrap();
out.push(format!("l {rel} -> {}", t.to_string_lossy()));
} else if m.is_dir() {
out.push(format!("d {rel}"));
for e in std::fs::read_dir(p).unwrap().flatten() {
walk(home, &e.path(), out);
}
} else {
let b = std::fs::read(p).unwrap();
out.push(format!(
"f {rel} {} {}",
perm_string(m.permissions().mode() & 0o777),
String::from_utf8_lossy(&b)
));
}
}
let mut out = Vec::new();
for root in ["backends", "engine"] {
walk(home, &home.join(root), &mut out);
}
out.sort();
out
}
struct Bench {
facade: SocketAddr,
go: Arc<fakego::FakeGo>,
_stop: tokio::sync::oneshot::Sender<()>,
home: PathBuf,
tools: PathBuf,
b: Arc<Backends>,
rel: Arc<Mutex<Value>>,
gh: String,
_tmp: Vec<tempfile::TempDir>,
}
async fn bench() -> Bench {
let tmp = tempfile::tempdir().unwrap();
let tools = tempfile::tempdir().unwrap();
let root = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let rel = Arc::new(Mutex::new(json!({ "status": 200, "json": "{}" })));
let gh = fake_github(rel.clone()).await;
let b = Arc::new(Backends::with(
home.clone(),
String::new(),
Probe {
path: Some(vec![tools.path().to_path_buf()]),
root: root.path().to_path_buf(),
},
format!("{gh}/latest"),
));
let (facade, go, stop, _) = fakego::start_with(
home.clone(),
fakego::Opts {
backends: Some(b.clone()),
..Default::default()
},
)
.await;
Bench {
facade,
go,
_stop: stop,
home,
tools: tools.path().to_path_buf(),
b,
rel,
gh,
_tmp: vec![tmp, tools, root],
}
}
impl Bench {
fn mask(&self, v: &str) -> String {
let ts = Regex::new(r#""(started_at|ended_at)":[1-9][0-9]*"#).unwrap();
let jobs = Regex::new(r#""jobs":[0-9]+"#).unwrap();
let v = v
.replace(&self.gh, "{{GH}}")
.replace(&self.home.to_string_lossy().into_owned(), "{{HOME}}");
let v = jobs.replace_all(&v, r#""jobs":"{{JOBS}}""#);
ts.replace_all(&v, r#""$1":"{{T}}""#).into_owned()
}
fn unmask(&self, v: &str) -> String {
v.replace("{{HOME}}", &self.home.to_string_lossy())
}
async fn call(&self, method: &str, path: &str, body: &str) -> (u16, Value) {
let (code, b) = fakego::send(self.facade, method, path, &[], body).await;
let out = serde_json::from_str(&self.mask(&String::from_utf8_lossy(&b)))
.unwrap_or_else(|_| panic!("{path} : corps illisible {b:?}"));
(code.as_u16(), out)
}
async fn wait_job(&self) {
for _ in 0..1500 {
let (_, v) = self.call("GET", "/api/llamacpp/job", "").await;
if v["running"] != json!(true) {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("le job ne finit pas");
}
}
#[tokio::test]
async fn parite_des_routes() {
let fx = fixture();
if (fx["goarch"] == "arm64") != cfg!(target_arch = "aarch64") {
eprintln!("journal tiré sur une autre architecture : rejeu sauté");
return;
}
let bn = bench().await;
let machines = &fx["machines"];
set_machine(&bn.tools, machines, "nu");
for st in fx["steps"].as_array().unwrap() {
let name = s(&st["name"]);
if let Some(m) = st["machine"].as_str() {
set_machine(&bn.tools, machines, m);
}
for g in st["git"].as_array().into_iter().flatten() {
git_op(&bn.home, s(g));
}
let files: serde_json::Map<String, Value> = st["files"]
.as_object()
.into_iter()
.flatten()
.map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v)))))
.collect();
apply_files(&bn.home, &Value::Object(files));
for (k, v) in st["config"].as_object().into_iter().flatten() {
let v = bn.unmask(s(v));
if v.is_empty() {
bn.go.del("config", k);
} else {
bn.go.set("config", k, &v);
}
}
if let Some(e) = st["engine_env"].as_str() {
bn.b.set_engine_bin_env(&bn.unmask(e));
}
if !st["release"].is_null() {
*bn.rel.lock().unwrap() = st["release"].clone();
}
*bn.go.tune_busy.lock().unwrap() =
(st["tune"].as_bool() == Some(true)).then(|| TUNE_BUSY.to_string());
let (code, out) = bn
.call(s(&st["method"]), s(&st["path"]), &bn.unmask(s(&st["body"])))
.await;
*bn.go.tune_busy.lock().unwrap() = None;
assert_eq!(code as i64, st["status"].as_i64().unwrap(), "{name}");
assert_eq!(out, st["out"], "{name}");
if st["wait"].as_bool() == Some(true) {
bn.wait_job().await;
let (_, job) = bn.call("GET", "/api/llamacpp/job?from=0", "").await;
assert_eq!(job, st["job"], "{name} : job");
}
let mut after = BTreeMap::new();
if let Some(v) = bn.go.get("config", "BIN").filter(|v| !v.is_empty()) {
after.insert("config/BIN".to_string(), Value::String(bn.mask(&v)));
}
if let Some(v) = bn.go.get("state", "build_job").filter(|v| !v.is_empty()) {
after.insert("state/build_job".to_string(), Value::String(bn.mask(&v)));
}
for f in ["loki-build.log", "devices.json"] {
if let Ok(b) = std::fs::read(bn.home.join(f)) {
after.insert(
f.to_string(),
Value::String(bn.mask(&String::from_utf8_lossy(&b))),
);
}
}
assert_eq!(
Value::Object(after.into_iter().collect()),
st["after"],
"{name} : après"
);
let t: Vec<Value> = tree(&bn.home)
.into_iter()
.map(|l| Value::String(bn.mask(&l)))
.collect();
// Une liste vide est nil côté Go : null.
let t = if t.is_empty() {
Value::Null
} else {
Value::Array(t)
};
assert_eq!(t, st["tree"], "{name} : arborescence");
}
// Plan, conseil et release, machine par machine.
*bn.rel.lock().unwrap() = fx["plan_release"].clone();
for p in fx["plans"].as_array().unwrap() {
let m = s(&p["machine"]);
set_machine(&bn.tools, machines, m);
let (_, out) = bn.call("GET", "/api/llamacpp", "").await;
assert_eq!(out["plan"], p["plan"], "{m} : plan");
assert_eq!(out["reco"], p["reco"], "{m} : conseil");
let (_, check) = bn.call("POST", "/api/llamacpp/prebuilt/check", "").await;
assert_eq!(check, p["check"], "{m} : release");
}
for (machine, key) in [
("vulkaninfo-seul", "sans_vulkan"),
("rocm", "rocm_sans_asset"),
] {
set_machine(&bn.tools, machines, machine);
*bn.rel.lock().unwrap() = fx[key]["release"].clone();
let (_, check) = bn.call("POST", "/api/llamacpp/prebuilt/check", "").await;
assert_eq!(check, fx[key]["out"], "{key}");
}
}
/// `norm` du journal : les instants masqués.
fn norm(v: &Value) -> Value {
let ts = Regex::new(r#""(started_at|ended_at)":[1-9][0-9]*"#).unwrap();
serde_json::from_str(&ts.replace_all(&v.to_string(), r#""$1":"{{T}}""#)).unwrap()
}
#[tokio::test]
async fn parite_du_job_pas_a_pas() {
let fx = fixture();
let bn = bench().await;
let backend = bn.go.backend.lock().unwrap().unwrap();
let store = loki_rs::store::Store::new(backend, fakego::JETON.into());
let ts = Regex::new(r#""(started_at|ended_at)":[1-9][0-9]*"#).unwrap();
let state = || {
ts.replace_all(
&bn.go.get("state", "build_job").unwrap_or_default(),
r#""$1":"{{T}}""#,
)
.into_owned()
};
let jobs = Jobs::new(bn.home.clone());
let job = &fx["job"];
for o in job["ops"].as_array().unwrap() {
let (op, arg) = (s(&o["op"]), s(&o["arg"]));
let from = o["from"].as_i64().unwrap_or(0);
let got = match op {
"start" => Some(Value::String(match jobs.start(&store, arg).await {
Ok(()) => "ok".into(),
Err(e) => e,
})),
"line" => {
jobs.append(&store, arg).await;
None
}
"lines" => {
for _ in 0..4002 {
jobs.append(&store, &format!("[{arg}] bruit")).await;
}
None
}
"phase" => {
jobs.phase(&store, arg).await;
None
}
"fail" => {
jobs.fail(&store, arg).await;
None
}
"done" => {
jobs.done(&store, arg).await;
None
}
"old" => {
jobs.set_commits(Some(arg), None).await;
None
}
"new" => {
jobs.set_commits(None, Some(arg)).await;
None
}
"end" => {
jobs.end(&store).await;
None
}
"snapshot" => Some(norm(&jobs.snapshot(from, true).await)),
"summary" => {
let mut v = jobs.snapshot(from, true).await;
let lines = v["lines"].as_array().unwrap().clone();
v["lines"] = json!({
"n": lines.len(),
"first": lines[0],
"last": lines[lines.len() - 1],
});
Some(norm(&v))
}
"head" => Some(norm(&jobs.snapshot(0, false).await)),
"state" => Some(Value::String(state())),
other => panic!("op inconnue {other}"),
};
assert_eq!(got.unwrap_or(Value::Null), o["out"], "{o}");
}
let log = std::fs::read_to_string(jobs.log_path()).unwrap();
assert_eq!(
log.matches('\n').count() as i64,
job["log_lines"].as_i64().unwrap()
);
for rc in job["restores"].as_array().unwrap() {
let st = s(&rc["state"]);
if st.is_empty() {
bn.go.del("state", "build_job");
} else {
bn.go.set("state", "build_job", st);
}
let jobs = Jobs::new(bn.home.clone());
let _ = std::fs::remove_file(jobs.log_path());
if let Some(l) = rc["log"].as_str() {
std::fs::write(jobs.log_path(), l).unwrap();
}
jobs.restore(&store).await;
assert_eq!(norm(&jobs.snapshot(0, true).await), rc["out"], "{rc}");
assert_eq!(state(), s(&rc["after"]), "{rc} : base");
}
let jobs = Jobs::new(bn.home.clone());
bn.go.set(
"state",
"build_job",
r#"{"action":"update","running":false}"#,
);
std::fs::write(jobs.log_path(), "ligne\n".repeat(4003)).unwrap();
jobs.restore(&store).await;
assert_eq!(norm(&jobs.snapshot(4000, true).await), job["long_restore"]);
}
/// Les étapes des jobs du Go, par l'API interne (jeton du Go seulement).
#[tokio::test]
async fn api_interne_du_job() {
let bn = bench().await;
let tok = [("X-Loki-Internal", fakego::JETON)];
let post = |op: &'static str, body: &'static str| {
let f = bn.facade;
async move {
let (c, b) = fakego::send(
f,
"POST",
&format!("/internal/facade/v1/build-job/{op}"),
&tok,
body,
)
.await;
(c.as_u16(), String::from_utf8_lossy(&b).into_owned())
}
};
// Sans le jeton : l'API interne n'existe pas.
let (c, _) = fakego::send(
bn.facade,
"POST",
"/internal/facade/v1/build-job/start",
&[],
r#"{"action":"engine"}"#,
)
.await;
assert_eq!(c.as_u16(), 404);
assert_eq!(post("start", r#"{"action":"engine"}"#).await.0, 200);
let (c, b) = post("start", r#"{"action":"strata"}"#).await;
assert_eq!(
(c, b.contains("un job engine est déjà en cours")),
(409, true)
);
// La route publique refuse aussi un second job (précompilé).
let (c, out) = bn.call("POST", "/api/llamacpp/prebuilt", "").await;
assert_eq!(c, 409);
assert_eq!(out["error"], "un job engine est déjà en cours");
post("commits", r#"{"old":"aaa"}"#).await;
post(
"phase",
r#"{"phase":"recherche de la dernière version publiée…"}"#,
)
.await;
post(
"line",
r#"{"line":"[ 50%] Building CXX object a/b/c.cpp.o"}"#,
)
.await;
post("commits", r#"{"new":"bbb"}"#).await;
post("fail", r#"{"error":"raté"}"#).await;
post("done", r#"{"msg":"rattrapé"}"#).await;
let (_, v) = bn.call("GET", "/api/llamacpp/job?from=1", "").await;
assert_eq!(v["running"], true);
assert_eq!(v["compiled"], 1);
assert_eq!(v["old"], "aaa");
assert_eq!(v["new"], "bbb");
assert_eq!(
v["lines"],
json!([
"[ 50%] Building CXX object a/b/c.cpp.o",
"✗ raté",
"✓ rattrapé"
])
);
// La vue que le Go relaie (statut du moteur de l'image).
let (c, b) = fakego::send(
bn.facade,
"GET",
"/internal/facade/v1/build-job/snapshot?from=0&lines=0",
&tok,
"",
)
.await;
assert_eq!(c.as_u16(), 200);
let head: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(head["phase"], "rattrapé");
assert!(head.get("lines").is_none());
assert_eq!(post("end", "{}").await.0, 200);
assert_eq!(
bn.call("GET", "/api/llamacpp", "").await.1["job"]["running"],
false
);
// Méthode et opération.
let (c, _) = fakego::send(
bn.facade,
"GET",
"/internal/facade/v1/build-job/line",
&tok,
"",
)
.await;
assert_eq!(c.as_u16(), 405);
assert_eq!(post("autre", "{}").await.0, 404);
// L'entête suit en base.
let st: Value = serde_json::from_str(&bn.go.get("state", "build_job").unwrap()).unwrap();
assert_eq!(st["action"], "engine");
assert_eq!(st["running"], false);
assert_eq!(st["error"], "raté");
}
/// Le moteur tourne : arrêté pendant l'installation, relancé après ; la
/// bascule le relance.
#[tokio::test]
async fn service_arrete_puis_relance() {
let fx = fixture();
let bn = bench().await;
set_machine(&bn.tools, &fx["machines"], "nu");
*bn.go.service_active.lock().unwrap() = true;
let ok = fx["steps"]
.as_array()
.unwrap()
.iter()
.find(|s| s["name"] == "installation")
.unwrap();
*bn.rel.lock().unwrap() = ok["release"].clone();
assert_eq!(bn.call("POST", "/api/llamacpp/prebuilt", "").await.0, 200);
bn.wait_job().await;
let (_, job) = bn.call("GET", "/api/llamacpp/job", "").await;
let lines: Vec<&str> = job["lines"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(lines[0], "▶ arrêt du service le temps de l'installation…");
assert_eq!(
&lines[lines.len() - 3..],
&[
"BIN mis à jour",
"▶ redémarrage du service…",
"✓ binaires précompilés installés (b300)"
]
);
assert_eq!(
*bn.go.service_actions.lock().unwrap(),
vec![("stop".to_string(), true), ("start".to_string(), false)]
);
bn.go.service_actions.lock().unwrap().clear();
let (c, out) = bn
.call("POST", "/api/llamacpp/use", r#"{"mode":"fast"}"#)
.await;
assert_eq!((c, out["ok"].clone()), (200, json!(true)));
assert_eq!(
*bn.go.service_actions.lock().unwrap(),
vec![("restart".to_string(), true)]
);
// Arrêté : rien n'est relancé.
*bn.go.service_active.lock().unwrap() = false;
bn.go.service_actions.lock().unwrap().clear();
assert_eq!(bn.call("POST", "/api/llamacpp/prebuilt", "").await.0, 200);
bn.wait_job().await;
assert_eq!(
*bn.go.service_actions.lock().unwrap(),
vec![("stop".to_string(), true)]
);
}
/// Une archive .zip (Windows) s'extrait aussi, sans évasion possible.
#[test]
fn extraction_zip() {
let tmp = tempfile::tempdir().unwrap();
let zpath = tmp.path().join("a.zip");
{
let f = std::fs::File::create(&zpath).unwrap();
let mut z = zip::ZipWriter::new(f);
let o = zip::write::SimpleFileOptions::default();
z.add_directory("llama-b1/", o).unwrap();
z.start_file("llama-b1/llama-server.exe", o).unwrap();
std::io::Write::write_all(&mut z, b"exe").unwrap();
z.finish().unwrap();
}
let out = tmp.path().join("out");
prebuilt::extract(&zpath, &out).unwrap();
assert_eq!(
std::fs::read(out.join("llama-b1/llama-server.exe")).unwrap(),
b"exe"
);
let bad = tmp.path().join("b.zip");
{
let f = std::fs::File::create(&bad).unwrap();
let mut z = zip::ZipWriter::new(f);
z.start_file("../evasion", zip::write::SimpleFileOptions::default())
.unwrap();
z.finish().unwrap();
}
assert_eq!(
prebuilt::extract(&bad, &out).unwrap_err(),
"entrée d'archive suspecte : ../evasion"
);
}
+12 -4
View File
@@ -90,6 +90,8 @@ pub struct FakeGo {
pub cdp_shutdowns: Mutex<u32>,
/// /internal/v1/service/action reçus : (action, if_active).
pub service_actions: Mutex<Vec<(String, bool)>>,
/// Le moteur tourne-t-il (`if_active` des actions de service) ?
pub service_active: Mutex<bool>,
/// Comportement de /internal/v1/crypt/* (préfixe « SCELLE: » par défaut).
pub crypt_mode: Mutex<CryptMode>,
/// Réponse de /internal/v1/conv/workspace (None : 404).
@@ -426,10 +428,14 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
"/internal/v1/service/action" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
st.service_actions.lock().unwrap().push((
v["action"].as_str().unwrap_or("").to_string(),
v["if_active"].as_bool().unwrap_or(false),
));
let if_active = v["if_active"].as_bool().unwrap_or(false);
st.service_actions
.lock()
.unwrap()
.push((v["action"].as_str().unwrap_or("").to_string(), if_active));
if if_active && !*st.service_active.lock().unwrap() {
return resp(200, r#"{"ran":false,"error":""}"#);
}
resp(200, r#"{"ran":true,"error":""}"#)
}
"/internal/v1/dictate/stop" => {
@@ -635,6 +641,7 @@ pub struct Opts {
pub projects: bool,
pub mcp: Option<Arc<loki_rs::mcp::Mcp>>,
pub update: bool,
pub backends: Option<Arc<loki_rs::backends::Backends>>,
}
/// Comme [`start`], avec la conversation tenue par la façade
@@ -736,6 +743,7 @@ pub async fn start_with(
projects: opts.projects,
mcp: opts.mcp,
update: opts.update,
backends: opts.backends,
};
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
let _ = rx.await;
+1
View File
@@ -106,6 +106,7 @@ async fn banc(cle: Option<&str>) -> Banc {
projects: false,
mcp: None,
update: false,
backends: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
File diff suppressed because it is too large. Load diff
+1
View File
@@ -112,6 +112,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
projects: false,
mcp: None,
update: false,
backends: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;