Lot 24 : moteur de l'image tenu par la façade (LOKI_ENGINE_IMAGE=rust)

Port de web_engine.go, web_engine_reco.go et backend_engine_oci.go
(rust/loki-rs/src/engineimg) : statut du moteur (build, source, variante,
versions installées, avis, version recommandée, version précédente,
relevé du rendu), dernier build publié, plan (dernière ou recommandée,
risque de rendu du raisonnement au défaut de b10763), mise à jour depuis
le registre officiel (couches du sommet, liens, runtime ignoré, essai à
blanc, REASONING_PRESERVE=off dans le preset actif ou la configuration,
ménage, vérification du rendu avant/après par la sonde de gabarit),
bascules, suppression et retour à la version précédente.

Tenu seulement avec LOKI_BACKENDS=rust : la mise à jour est un job des
moteurs. La sonde est celle de la conversation quand la façade la tient.
/api/engine/cmdline reste au Go, qui relaie les routes du tunnel.

Parité : parity_engineimg_test.go tire du Go un journal (faux registre
OCI, faux moteur local pour la sonde) rejoué par tests/engineimg.rs ;
TestFacadeMoteurImage côté Go. Les aides de fichiers des rejeux passent
dans tests/common/parity.rs. Mutations : 71 tuées sur 74, trois
équivalentes.

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 22:15:30 +00:00
1 parent 0f3a3d52c1
commit 8c9bb6b450
25 files changed
+8155 -173

No files matched your search

+1
View File
@@ -239,6 +239,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `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_ENGINE_IMAGE` | `rust` (avec `LOKI_BACKENDS=rust`) : le panneau « moteur de l'image » — version qui tourne, dernier build publié, version recommandée, mise à jour depuis le registre officiel avec essai à blanc, bascules, retour à la version précédente, vérification du rendu du gabarit — est tenu par la façade Rust (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,130 @@
# Lot 24 — panneau « moteur de l'image » tenu par la façade
## Contexte
Le panneau Moteur permet de mettre à jour llama.cpp sans reconstruire
l'image. Le moteur est pris dans l'image de conteneur officielle
(`ghcr.io/ggml-org/llama.cpp:server-cuda-b#####`), dont on ne télécharge que
les couches du sommet, celles qui portent `/app`. Trois fichiers du Go le
servent :
- `web_engine.go` : statut, dernier build publié, mise à jour, bascule,
suppression ;
- `web_engine_reco.go` : version recommandée, risque de rendu du
raisonnement (défaut de b10763), retour à la version précédente,
vérification du rendu du gabarit avant/après la bascule ;
- `backend_engine_oci.go` : dialogue avec le registre, extraction des
couches, essai à blanc.
La mise à jour est un job, le job des moteurs que la façade tient depuis le
lot 22. Le risque de rendu et la vérification du rendu reposent sur la sonde
de gabarit, portée au lot 23.
## Le lot
Avec `LOKI_ENGINE_IMAGE=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la
façade (`rust/loki-rs/src/engineimg`) sert les routes suivantes :
| Route | Rôle |
| --- | --- |
| `/api/engine` | statut : binaire, build, source, variante, versions installées, avis, encart « version recommandée », version précédente, relevé du rendu |
| `/api/engine/check` | dernier build publié pour la variante, comparé au courant |
| `/api/engine/update` | job : recherche, téléchargement des couches du sommet, essai à blanc, REASONING_PRESERVE=off si demandé, bascule, ménage, vérification du rendu |
| `/api/engine/use` | bascule vers une version installée ou vers le moteur de l'image |
| `/api/engine/remove` | suppression d'une version installée (refusée pour celle qui tourne) |
| `/api/engine/plan` | sur clic : version visée (dernière ou recommandée) et risque de rendu |
| `/api/engine/rollback` | retour au moteur d'avant la dernière bascule |
Sans les backends, loki-rs ne passe pas la variable au Go : le panneau
reste au Go et le dit au démarrage. `/api/engine/cmdline` reste au Go.
La sonde de gabarit est celle de la conversation quand la façade la tient
(`LOKI_CHAT=rust`) : le relevé du rendu rejoue alors la forme de ses vraies
requêtes. Sinon, la façade a sa propre sonde, qui sonde une requête nue.
Pour REASONING_PRESERVE=off posée dans le preset actif, la façade réutilise
l'enregistrement du lot 15 (`save_applying`). Le service est arrêté puis
relancé comme au lot 22 : par la façade si elle pilote le moteur, sinon par
le Go (`/internal/v1/service/action`).
Côté Go, `facade_engineimg.go` relaie à la façade les routes reçues par le
tunnel.
## Parité
`internal/loki/parity_engineimg_test.go` (Linux) tire du Go un journal
rejoué par `tests/engineimg.rs`. Il tourne contre :
- un faux registre OCI : jeton, index multi-architecture, manifeste annoté,
couches .tar.gz ;
- un faux moteur local (le `fakeTpl` du Go, porté) pour la sonde et le rendu.
Le journal couvre :
- **le statut** : moteur de l'image, téléchargé ou compilé, avec la version
précédente (absente quand c'est le moteur courant), le relevé du rendu,
sans moteur d'image, sans aucun moteur ;
- **le dernier build** : publié, sans numéro, ou refusé (jeton refusé ou
illisible, manifeste refusé ou illisible, aucune image pour
l'architecture) ;
- **le plan** :
- dernière version (avec ou sans numéro) ou recommandée (build trop
ancien, tag figé absent, sans numéro), et déjà à jour ;
- chaque verdict du risque de rendu : moteur arrêté, gabarit vide,
REASONING_PRESERVE posée, EXTRA_ARGS, gabarit sans les variables, gabarit
qui garde la réflexion, build antérieur au défaut, preset externe ;
- **la mise à jour** :
- couches du sommet vers la base ; une couche du dessus masque celle du
dessous ; liens absolus ramenés dans `/app` ; marqueurs de suppression et
sous-dossiers ignorés ; couche sans fichier du moteur ; couche du runtime
ignorée ; phase du téléchargement ;
- essai à blanc ;
- risque de rendu rappelé dans le journal, établi ou impossible à vérifier
(moteur local arrêté) ;
- REASONING_PRESERVE=off : posée dans le preset actif ou dans la
configuration, déjà posée, ou inconnue du moteur ;
- ménage : versions triées par build, dossier sans moteur épargné, version
précédente gardée ;
- rendu comparé avant/après : changé, identique, ou impossible sur le
nouveau moteur ;
- déjà à jour, corps illisible ;
- **les échecs** :
- optimisation en cours, version inconnue, image sans serveur ou sans
version annoncée, manifeste sans couche, runtime au sommet, couche
introuvable, tag illisible ;
- moteur qui ne démarre pas (12 dernières lignes de sa sortie) ou qui ne
voit aucune carte ;
- **les bascules** : vers une version installée, vers l'image, vers une
version absente, pendant une optimisation ; retour, deux fois, et en GET ;
- **la suppression** : version (`../` neutralisé), version absente, moteur
utilisé ;
- **après chaque appel** : BIN, REASONING_PRESERVE, `engine/previous-bin` et
l'arborescence de `engine/`.
Autres tests :
- la façade vérifie le service arrêté puis relancé autour d'une mise à jour
et redémarré par une bascule (`service_arrete_puis_relance`), le relevé
« non vérifié à temps », le 503 sans backends, les variantes et les
vérifications périmées ;
- le Go (`TestFacadeMoteurImage`) vérifie que les sept routes partent à la
façade, et elles seulement.
## Mutations
74 mutations du port Rust, toutes tuées sauf trois, équivalentes :
- la garde « moteur d'image absent » de la source : `same_path` ne
rapproche jamais un chemin vide ;
- l'essai à blanc qui relance l'inventaire des cartes quand l'ancien moteur
est le nouveau : même résultat ;
- une vérification du rendu périmée qui continue d'attendre son moteur : son
relevé est de toute façon écarté au rangement.
## Écarts assumés
- Les erreurs réseau (registre injoignable) gardent le texte de `reqwest`.
- Un manifeste dont un champ a le mauvais type ne donne pas le message du
Go : le nom Go du type anonyme n'est pas reproduit.
- La vérification du rendu attend son délai à la cadence choisie (5 s), sans
le contexte Go qui coupe aussi une requête en cours au bout de 20 min.
+4 -1
View File
@@ -141,7 +141,8 @@ 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), les backends llama.cpp (facade_backends.go).
// jour (facade_update.go), les backends llama.cpp (facade_backends.go), le
// moteur de l'image (facade_engineimg.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 {
@@ -174,6 +175,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
held, unavailable = facadeUpdate, "mise à jour indisponible : "
case facadeBackendsRoutes[path]:
held, unavailable = facadeBackends, "moteurs indisponibles : "
case facadeEngineImageRoutes[path]:
held, unavailable = facadeEngineImage, "moteur de l'image indisponible : "
default:
return h
}
+63
View File
@@ -1102,3 +1102,66 @@ func TestFacadeSondeDeGabarit(t *testing.T) {
t.Fatal("verdict sans façade")
}
}
// Le panneau « moteur de l'image » part à la façade avec LOKI_ENGINE_IMAGE=rust
// (lot 24) ; la commande du dernier lancement reste au Go.
func TestFacadeMoteurImage(t *testing.T) {
testHome(t)
var mu sync.Mutex
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest {
w.WriteHeader(403)
return
}
mu.Lock()
calls = append(calls, r.Method+" "+r.URL.Path)
mu.Unlock()
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(facadeEngineImageEnv, "")
for p := range facadeEngineImageRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != "go" {
t.Fatalf("sans LOKI_ENGINE_IMAGE, %s : %q", p, w.Body.String())
}
}
if len(calls) != 0 {
t.Fatalf("appels à la façade sans LOKI_ENGINE_IMAGE : %v", calls)
}
t.Setenv(facadeEngineImageEnv, "Rust ")
if len(facadeEngineImageRoutes) != 7 {
t.Fatalf("routes : %v", facadeEngineImageRoutes)
}
for p := range facadeEngineImageRoutes {
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())
}
}
mu.Lock()
n := len(calls)
mu.Unlock()
if n != len(facadeEngineImageRoutes) {
t.Fatalf("relais : %v", calls)
}
w := httptest.NewRecorder()
withFacadeChat("/api/engine/cmdline", local)(w, httptest.NewRequest("GET", "/api/engine/cmdline", nil))
if w.Body.String() != "go" {
t.Fatalf("/api/engine/cmdline doit rester au Go : %q", w.Body.String())
}
// Sans jeton interne, rien ne part.
t.Setenv(internalTokenEnv, "")
if facadeEngineImage() {
t.Fatal("tenu sans jeton interne")
}
}
+34
View File
@@ -0,0 +1,34 @@
package loki
import (
"os"
"strings"
)
// facade_engineimg.go — panneau « moteur de l'image » tenu par la façade Rust
// (LOKI_ENGINE_IMAGE=rust, lot 24 de la migration ; expérimental, le Go reste
// le défaut) : version qui tourne et versions installées, dernier build publié
// sur le registre, mise à jour (job des moteurs, tenu par la façade avec
// LOKI_BACKENDS=rust), bascules, suppression, version recommandée, retour à la
// version précédente et vérification du rendu du gabarit
// (rust/loki-rs/src/engineimg). loki-rs ne passe la variable qu'avec les
// backends. /api/engine/cmdline reste ici.
const facadeEngineImageEnv = "LOKI_ENGINE_IMAGE"
// facadeEngineImage : la façade tient-elle le panneau ?
func facadeEngineImage() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeEngineImageEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeEngineImageRoutes : les routes que la façade sert alors.
var facadeEngineImageRoutes = map[string]bool{
"/api/engine": true,
"/api/engine/check": true,
"/api/engine/update": true,
"/api/engine/use": true,
"/api/engine/remove": true,
"/api/engine/plan": true,
"/api/engine/rollback": true,
}
+544
View File
@@ -0,0 +1,544 @@
//go:build linux
package loki
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
"time"
)
// parity_engineimg_test.go — journal de référence du panneau « moteur de
// l'image » (lot 24 de la migration Rust) : statut (variante, build, avis,
// encart de version recommandée, versions installées, version précédente,
// vérification du rendu), dernier build publié, plan (dernière ou recommandée,
// risque de rendu du raisonnement), mise à jour depuis un faux registre OCI
// (couches du sommet, liens, couche du runtime ignorée, essai à blanc,
// REASONING_PRESERVE=off, ménage des versions, rendu comparé avant/après),
// bascules, suppression, retour à la version précédente, et les échecs.
// Rangé dans rust/loki-rs/tests/parity_engineimg/engineimg.json ;
// tests/engineimg.rs le rejoue par la façade.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMoteurImage
const parityEngineImgFile = "../../rust/loki-rs/tests/parity_engineimg/engineimg.json"
// Ce que sert le faux registre : le jeton, la version annoncée par les tags,
// les tags figés qui existent, et les couches (de la base au sommet).
type parityRegistry struct {
Token int `json:"token,omitempty"` // statut de /token (0 = 200)
Blob int `json:"blob,omitempty"` // statut imposé aux couches (0 = servies)
// Corps de /token servi à la place du jeton ; statut et corps imposés
// aux manifestes ; index sans image pour cette architecture.
TokenBody string `json:"token_body,omitempty"`
Manifest int `json:"manifest,omitempty"`
ManifestBody string `json:"manifest_body,omitempty"`
NoArch bool `json:"no_arch,omitempty"`
Version string `json:"version"`
Fixed []string `json:"fixed,omitempty"`
Layers []parityRegistryLay `json:"layers"`
}
type parityRegistryLay struct {
Size int64 `json:"size,omitempty"` // taille annoncée (0 = réelle)
Entries []parityArchEntry `json:"entries"`
}
// Le moteur local (sonde de gabarit) : gabarit, build, ou arrêté.
type parityLocal struct {
Mode string `json:"mode"`
Build string `json:"build"`
Template string `json:"template,omitempty"`
Down bool `json:"down,omitempty"`
Apply int `json:"apply,omitempty"` // statut imposé à /apply-template (0 = rendu)
}
type parityEngineImgStep struct {
Name string `json:"name"`
Files map[string]string `json:"files,omitempty"`
Config map[string]string `json:"config,omitempty"`
Registry *parityRegistry `json:"registry,omitempty"`
Local *parityLocal `json:"local,omitempty"`
Tune bool `json:"tune,omitempty"`
EngineEnv *string `json:"engine_env,omitempty"` // LOKI_ENGINE_BIN
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body,omitempty"`
Wait bool `json:"wait,omitempty"`
AfterJob *parityLocal `json:"after_job,omitempty"` // le moteur local une fois le job fini
WaitRender bool `json:"wait_render,omitempty"`
Status int `json:"status"`
Out any `json:"out"`
Job any `json:"job,omitempty"`
Render any `json:"render,omitempty"`
After map[string]string `json:"after"`
Tree []string `json:"tree"`
}
// Les scripts des moteurs.
func parityEngineScript(version, devices, help string, failList bool) string {
s := "#!/bin/sh\ncase \"$1\" in\n--version) echo '" + version + "';;\n--list-devices)"
if failList {
s += " i=0; while [ $i -lt 13 ]; do echo \"trace $i\"; i=$((i+1)); done; echo 'erreur: libcudart.so.13 introuvable'; exit 1;;\n"
} else {
s += " echo 'Available devices:'" + devices + ";;\n"
}
return s + "--help) echo '" + help + "';;\nesac\n"
}
func parityEngineLayers(version, script string, withServer bool, runtimeSize int64) []parityRegistryLay {
// Le serveur en 0700 : l'installation le rend exécutable par tous.
server := []parityArchEntry{{Name: "app/llama", Type: "file", Body: "#!/bin/true\n", Mode: 0o755}}
libs := []parityArchEntry{}
if withServer {
server = append(server, parityArchEntry{Name: "app/llama-server", Type: "file", Body: script, Mode: 0o700})
// Masqué par la couche du dessus : jamais extrait.
libs = append(libs, parityArchEntry{Name: "app/llama-server", Type: "file", Body: "ancien", Mode: 0o755})
}
return []parityRegistryLay{
{Size: 2 << 30, Entries: []parityArchEntry{{Name: "usr/local/cuda/lib64/libcudart.so", Type: "file", Body: "runtime", Mode: 0o644}}},
{Entries: []parityArchEntry{{Name: "etc/hosts", Type: "file", Body: "x", Mode: 0o644}}},
{Entries: append(libs, []parityArchEntry{
{Name: "app/", Type: "dir", Mode: 0o755},
{Name: "app/libggml-base.so.0.20.0", Type: "file", Body: "elf " + version, Mode: 0o644},
{Name: "app/libggml-base.so", Type: "symlink", Link: "libggml-base.so.0.20.0"},
{Name: "app/libllama.so.0.1.0", Type: "file", Body: "elf", Mode: 0o644},
{Name: "app/libllama.so", Type: "symlink", Link: "/app/libllama.so.0.1.0"},
{Name: "app/libggml-cuda.so", Type: "file", Body: "elf", Mode: 0o644},
{Name: "app/sous/ignoré", Type: "file", Body: "x", Mode: 0o644},
{Name: "app/.wh.supprimé", Type: "file", Body: "", Mode: 0o644},
{Name: "etc/passwd", Type: "file", Body: "x", Mode: 0o644},
}...)},
{Size: runtimeSize, Entries: server},
}
}
var parityAtRe = regexp.MustCompile(`"at":"[^"]*"`)
func TestPariteMoteurImage(t *testing.T) {
home := testHome(t)
freshTplProbe(t)
resetBackendsState()
t.Cleanup(resetBackendsState)
oldPoll, oldWait := engineRenderPoll, engineRenderWait
engineRenderPoll, engineRenderWait = time.Millisecond, 10*time.Second
reset := func() {
engineRenderMu.Lock()
engineRenderLast = nil
engineRenderGen++
engineRenderMu.Unlock()
buildMu.Lock()
buildCache = map[string]int{}
buildMu.Unlock()
}
reset()
t.Cleanup(func() {
engineRenderPoll, engineRenderWait = oldPoll, oldWait
reset()
})
// Le faux registre.
var mu sync.Mutex
var reg parityRegistry
type built struct {
layers map[string][]byte
manifest []byte
manDig string
index []byte
}
build := func(r parityRegistry) built {
b := built{layers: map[string][]byte{}}
var layers []map[string]any
for _, l := range r.Layers {
data := parityTarGz(t, l.Entries)
sum := sha256.Sum256(data)
d := "sha256:" + hex.EncodeToString(sum[:])
b.layers[d] = data
size := int64(len(data))
if l.Size > 0 {
size = l.Size
}
layers = append(layers, map[string]any{"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": d, "size": size})
}
b.manifest, _ = json.Marshal(map[string]any{"schemaVersion": 2, "layers": layers,
"annotations": map[string]string{"org.opencontainers.image.version": r.Version}})
sum := sha256.Sum256(b.manifest)
b.manDig = "sha256:" + hex.EncodeToString(sum[:])
arch := ociArch()
if r.NoArch {
arch = "s390x"
}
b.index, _ = json.Marshal(map[string]any{"schemaVersion": 2,
"manifests": []map[string]any{
{"digest": "sha256:autre", "platform": map[string]string{"os": "linux", "architecture": "ppc64le"}},
{"digest": b.manDig, "platform": map[string]string{"os": "linux", "architecture": arch}},
}})
return b
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
cur := reg
mu.Unlock()
b := build(cur)
switch {
case r.URL.Path == "/token":
if cur.Token != 0 {
w.WriteHeader(cur.Token)
return
}
if cur.TokenBody != "" {
_, _ = w.Write([]byte(cur.TokenBody))
return
}
_, _ = w.Write([]byte(`{"token":"jeton"}`))
case strings.Contains(r.URL.Path, "/manifests/"):
if r.Header.Get("Authorization") != "Bearer jeton" {
w.WriteHeader(401)
return
}
if cur.Manifest != 0 {
w.WriteHeader(cur.Manifest)
return
}
if cur.ManifestBody != "" {
_, _ = w.Write([]byte(cur.ManifestBody))
return
}
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
switch {
case ref == b.manDig:
_, _ = w.Write(b.manifest)
case ref == "server-cuda":
_, _ = w.Write(b.index)
default:
for _, f := range cur.Fixed {
if f == ref {
_, _ = w.Write(b.index)
return
}
}
w.WriteHeader(404)
}
case strings.Contains(r.URL.Path, "/blobs/"):
if cur.Blob != 0 {
w.WriteHeader(cur.Blob)
return
}
data, ok := b.layers[r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]]
if !ok {
w.WriteHeader(404)
return
}
_, _ = w.Write(data)
default:
w.WriteHeader(404)
}
}))
defer srv.Close()
oldBase := ociBase
ociBase = srv.URL
defer func() { ociBase = oldBase }()
regHost := strings.TrimPrefix(srv.URL, "http://")
// Le moteur local, pour la sonde de gabarit.
local := &fakeTpl{mode: "qwen3", build: "b10450-img"}
down := false
eng := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
local.mu.Lock()
d := down
local.mu.Unlock()
if d {
w.WriteHeader(503)
return
}
local.handler(w, r)
}))
defer eng.Close()
u, _ := url.Parse(eng.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
setLocal := func(l *parityLocal) {
local.mu.Lock()
local.mode, local.build, local.tpl = l.Mode, l.Build, l.Template
local.applyStatus, local.applyBody = nil, ""
if l.Apply != 0 {
for range 1000 {
local.applyStatus = append(local.applyStatus, l.Apply)
}
local.applyBody = `{"error":{"code":500,"message":"Jinja Exception: gabarit cassé"}}`
}
down = l.Down
local.mu.Unlock()
}
img := filepath.Join(home, "image")
t.Setenv("LOKI_ENGINE_BIN", filepath.Join(img, "llama-server"))
dev1 := "; echo ' CUDA0: Carte (1000 MiB, 900 MiB free)'"
mask := func(s string) string {
s = strings.ReplaceAll(s, srv.URL, "{{REG}}")
s = strings.ReplaceAll(s, regHost, "{{REGHOST}}")
s = strings.ReplaceAll(s, home, "{{HOME}}")
s = parityTSRe.ReplaceAllString(s, `"$1":"{{T}}"`)
return parityAtRe.ReplaceAllString(s, `"at":"{{T}}"`)
}
unmask := func(s string) string {
return strings.ReplaceAll(strings.ReplaceAll(s, "{{HOME}}", home), "{{PORT}}", u.Port())
}
handlers := map[string]http.HandlerFunc{
"/api/engine": handleEngineStatus,
"/api/engine/check": handleEngineCheck,
"/api/engine/update": handleEngineUpdate,
"/api/engine/use": handleEngineUse,
"/api/engine/remove": handleEngineRemove,
"/api/engine/plan": handleEnginePlan,
"/api/engine/rollback": handleEngineRollback,
}
call := func(method, path, body string) (int, any) {
rec := httptest.NewRecorder()
handlers[path](rec, httptest.NewRequest(method, path, strings.NewReader(body)))
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
}
run := func(st parityEngineImgStep) parityEngineImgStep {
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.Registry != nil {
mu.Lock()
reg = *st.Registry
mu.Unlock()
}
if st.Local != nil {
setLocal(st.Local)
}
if st.EngineEnv != nil {
t.Setenv("LOKI_ENGINE_BIN", unmask(*st.EngineEnv))
}
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, 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(10 * time.Millisecond)
}
b, _ := json.Marshal(lcJobSnapshot(0, true))
_ = json.Unmarshal([]byte(mask(string(b))), &st.Job)
if st.AfterJob != nil {
setLocal(st.AfterJob)
}
}
if st.WaitRender {
deadline := time.Now().Add(15 * time.Second)
for {
c := engineRenderSnapshot()
if c != nil && c.Status != "pending" {
break
}
if time.Now().After(deadline) {
t.Fatalf("%s : la vérification du rendu ne finit pas", st.Name)
}
time.Sleep(10 * time.Millisecond)
}
_, out := call("GET", "/api/engine", "")
st.Render = out.(map[string]any)["render_check"]
}
st.After = map[string]string{}
for _, k := range []string{"BIN", "REASONING_PRESERVE"} {
if v := ReadConfig()[k]; v != "" {
st.After["config/"+k] = mask(v)
}
}
if b, err := os.ReadFile(filepath.Join(home, "engine", "previous-bin")); err == nil {
st.After["previous-bin"] = mask(string(b))
}
st.Tree = parityTree(t, home)
for i := range st.Tree {
st.Tree[i] = mask(st.Tree[i])
}
return st
}
v10900 := "version: 0.1.0-dev (build 10900, commit def5678)"
good := parityEngineScript(v10900, dev1, "--no-reasoning-preserve", false)
reg10900 := &parityRegistry{Version: "b10900", Fixed: []string{"server-cuda-b10900"},
Layers: parityEngineLayers("b10900", good, true, 0)}
qwenTpl := "{% if preserve_thinking %}garde{% endif %}"
noImage := ""
var steps []parityEngineImgStep
for _, st := range []parityEngineImgStep{
{Name: "statut, moteur de l'image", Files: map[string]string{
"image/llama-server": parityEngineScript("version: 10450 (abc1234)", dev1, "usage", false),
"image/libggml-cuda.so": "elf",
}, Registry: reg10900, Local: &parityLocal{Mode: "qwen3", Build: "b10450-img", Template: qwenTpl},
Method: "GET", Path: "/api/engine"},
{Name: "dernière version publiée", Method: "POST", Path: "/api/engine/check"},
{Name: "dernière version sans numéro", Registry: &parityRegistry{Layers: parityEngineLayers("x", good, true, 0)},
Method: "POST", Path: "/api/engine/check"},
{Name: "jeton illisible", Registry: &parityRegistry{TokenBody: `{"token":""}`, Version: "b10900"}, Method: "POST", Path: "/api/engine/check"},
{Name: "manifeste refusé", Registry: &parityRegistry{Manifest: 403, Version: "b10900"}, Method: "POST", Path: "/api/engine/check"},
{Name: "manifeste illisible", Registry: &parityRegistry{ManifestBody: `{"mediaType":`, Version: "b10900"}, Method: "POST", Path: "/api/engine/check"},
{Name: "aucune image pour cette architecture", Registry: &parityRegistry{NoArch: true, Version: "b10900", Layers: parityEngineLayers("b10900", good, true, 0)},
Method: "POST", Path: "/api/engine/check"},
{Name: "jeton refusé", Registry: &parityRegistry{Token: 500, Version: "b10900"}, Method: "POST", Path: "/api/engine/check"},
{Name: "plan : dernière version", Registry: reg10900, Method: "POST", Path: "/api/engine/plan", Body: `{}`},
{Name: "plan : version recommandée", Method: "POST", Path: "/api/engine/plan", Body: `{"recommended":true}`},
{Name: "plan recommandé, build trop ancien", Registry: &parityRegistry{Version: "b10800", Fixed: []string{"server-cuda-b10800"},
Layers: parityEngineLayers("b10800", good, true, 0)}, Method: "POST", Path: "/api/engine/plan", Body: `{"recommended":true}`},
{Name: "plan recommandé, tag figé absent", Registry: &parityRegistry{Version: "b10900", Layers: parityEngineLayers("b10900", good, true, 0)},
Method: "POST", Path: "/api/engine/plan", Body: `{"recommended":true}`},
{Name: "plan recommandé, sans numéro", Registry: &parityRegistry{Version: "", Layers: parityEngineLayers("x", good, true, 0)},
Method: "POST", Path: "/api/engine/plan", Body: `{"recommended":true}`},
{Name: "plan, dernière version sans numéro", Method: "POST", Path: "/api/engine/plan", Body: `{}`},
{Name: "plan en GET", Method: "GET", Path: "/api/engine/plan"},
{Name: "plan, moteur local arrêté", Registry: reg10900, Local: &parityLocal{Down: true},
Method: "POST", Path: "/api/engine/plan", Body: `{}`},
{Name: "plan, REASONING_PRESERVE posée", Local: &parityLocal{Mode: "qwen3", Build: "b10450-img", Template: qwenTpl},
Config: map[string]string{"REASONING_PRESERVE": "on"}, Method: "POST", Path: "/api/engine/plan", Body: `{}`},
{Name: "plan, EXTRA_ARGS", Config: map[string]string{"REASONING_PRESERVE": "", "EXTRA_ARGS": "--reasoning-preserve"},
Method: "POST", Path: "/api/engine/plan", Body: `{}`},
{Name: "plan, gabarit sans les variables", Config: map[string]string{"EXTRA_ARGS": ""},
Local: &parityLocal{Mode: "qwen3", Build: "b10450-img", Template: "simple"}, Method: "POST", Path: "/api/engine/plan", Body: `{}`},
{Name: "plan, gabarit qui garde tout", Local: &parityLocal{Mode: "keep", Build: "b10451-img", Template: qwenTpl},
Method: "POST", Path: "/api/engine/plan", Body: `{}`},
{Name: "plan, gabarit vide", Local: &parityLocal{Build: "b10452-img"},
Method: "POST", Path: "/api/engine/plan", Body: `{}`},
{Name: "plan, build antérieur au défaut", Registry: &parityRegistry{Version: "b10700", Fixed: []string{"server-cuda-b10700"},
Layers: parityEngineLayers("b10700", good, true, 0)}, Local: &parityLocal{Mode: "qwen3", Build: "b10450-img", Template: qwenTpl},
Method: "POST", Path: "/api/engine/plan", Body: `{}`},
{Name: "plan, preset externe", Registry: reg10900, Config: map[string]string{"EXTERNAL": "1"},
Method: "POST", Path: "/api/engine/plan", Body: `{}`},
{Name: "mise à jour pendant une optimisation", Config: map[string]string{"EXTERNAL": ""}, Tune: true, Method: "POST", Path: "/api/engine/update", Body: `{}`},
{Name: "mise à jour, moteur local arrêté", Registry: &parityRegistry{Version: "b10920", Fixed: []string{"server-cuda-b10920"},
Layers: parityEngineLayers("b10920", good, true, 0)}, Local: &parityLocal{Down: true},
Method: "POST", Path: "/api/engine/update", Body: `{}`, Wait: true},
{Name: "retour au moteur de l'image", Method: "POST", Path: "/api/engine/use", Body: `{"tag":"image"}`},
{Name: "suppression de la version d'essai", Method: "POST", Path: "/api/engine/remove", Body: `{"tag":"server-cuda-b10920"}`},
{Name: "mise à jour", Registry: reg10900, Local: &parityLocal{Mode: "qwen3", Build: "b10450-img", Template: qwenTpl},
Method: "POST", Path: "/api/engine/update", Body: `{}`, Wait: true,
AfterJob: &parityLocal{Mode: "keep", Build: "b10900-new", Template: qwenTpl}, WaitRender: true},
{Name: "statut après la mise à jour", Method: "GET", Path: "/api/engine"},
{Name: "plan, déjà à jour", Method: "POST", Path: "/api/engine/plan", Body: `{}`},
{Name: "déjà à jour", Method: "POST", Path: "/api/engine/update", Body: `{}`, Wait: true},
{Name: "retour à la version précédente", Method: "POST", Path: "/api/engine/rollback"},
{Name: "statut après le retour", Method: "GET", Path: "/api/engine"},
{Name: "retour en GET", Method: "GET", Path: "/api/engine/rollback"},
{Name: "retour pendant une optimisation", Tune: true, Method: "POST", Path: "/api/engine/rollback"},
{Name: "bascule vers la version installée", Method: "POST", Path: "/api/engine/use", Body: `{"tag":" server-cuda-b10900 "}`},
{Name: "bascule vers l'image", Method: "POST", Path: "/api/engine/use", Body: `{"tag":"image"}`},
{Name: "bascule vers une version absente", Method: "POST", Path: "/api/engine/use", Body: `{"tag":"server-cuda-b1"}`},
{Name: "bascule pendant une optimisation", Tune: true, Method: "POST", Path: "/api/engine/use", Body: `{"tag":"image"}`},
{Name: "suppression d'une version", Method: "POST", Path: "/api/engine/remove", Body: `{"tag":"../server-cuda-b10900"}`},
{Name: "suppression d'une version absente", Method: "POST", Path: "/api/engine/remove", Body: `{"tag":"server-cuda-b10900"}`},
{Name: "mise à jour épinglée, REASONING_PRESERVE=off dans le preset actif",
Files: map[string]string{"presets/actif.env": "PORT={{PORT}}\nBIN={{HOME}}/image/llama-server\n"}, Local: &parityLocal{Mode: "qwen3", Build: "b10450-img", Template: qwenTpl},
Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10900","preserve_off":true}`, Wait: true,
AfterJob: &parityLocal{Mode: "qwen3", Build: "b10900-new", Template: qwenTpl}, WaitRender: true},
{Name: "suppression du moteur utilisé", Method: "POST", Path: "/api/engine/remove", Body: `{"tag":"server-cuda-b10900"}`},
{Name: "REASONING_PRESERVE déjà posée", Method: "POST", Path: "/api/engine/update",
Body: `{"tag":"server-cuda-b10900","preserve_off":true}`, Wait: true,
AfterJob: &parityLocal{Mode: "drop", Build: "b10900-bis", Template: qwenTpl}, WaitRender: true},
{Name: "REASONING_PRESERVE=off posée dans la configuration", Files: map[string]string{"presets/actif.env": "-"},
Config: map[string]string{"REASONING_PRESERVE": ""}, Method: "POST", Path: "/api/engine/update",
Body: `{"tag":"server-cuda-b10900","preserve_off":true}`, Wait: true,
AfterJob: &parityLocal{Mode: "qwen3", Build: "b10900-ter", Template: qwenTpl, Apply: 500}, WaitRender: true},
{Name: "nouvelle version, ménage", Files: map[string]string{"engine/server-cuda-b10000/llama-server": "#!/bin/sh\n",
"engine/server-cuda-b9999/llama-server": "#!/bin/sh\n", "engine/server-cuda-b10001": "/"},
Config: map[string]string{"REASONING_PRESERVE": ""},
Registry: &parityRegistry{Version: "b10950", Fixed: []string{"server-cuda-b10950"}, Layers: parityEngineLayers("b10950", parityEngineScript("version: 0.1.0-dev (build 10950, commit 0ab1234)", dev1, "", false), true, 10<<20)},
Local: &parityLocal{Down: true}, Method: "POST", Path: "/api/engine/update", Body: `{}`, Wait: true},
{Name: "statut, version précédente téléchargée", Method: "GET", Path: "/api/engine"},
{Name: "REASONING_PRESERVE=off inconnue du moteur", Method: "POST", Path: "/api/engine/update",
Body: `{"tag":"server-cuda-b10950","preserve_off":true}`, Wait: true},
{Name: "version inconnue du registre", Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b1"}`, Wait: true},
{Name: "image sans serveur", Registry: &parityRegistry{Version: "b10960", Fixed: []string{"server-cuda-b10960"},
Layers: parityEngineLayers("b10960", good, false, 0)}, Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10960"}`, Wait: true},
{Name: "manifeste sans couche", Registry: &parityRegistry{Version: "b10966", Fixed: []string{"server-cuda-b10966"}},
Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10966"}`, Wait: true},
{Name: "image sans version annoncée", Registry: &parityRegistry{Fixed: []string{"server-cuda-b10967"},
Layers: parityEngineLayers("b10967", good, false, 0)}, Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10967"}`, Wait: true},
{Name: "couche du runtime au sommet", Registry: &parityRegistry{Version: "b10961", Fixed: []string{"server-cuda-b10961"},
Layers: parityEngineLayers("b10961", good, true, 700<<20)}, Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10961"}`, Wait: true},
{Name: "moteur qui ne démarre pas", Registry: &parityRegistry{Version: "b10962", Fixed: []string{"server-cuda-b10962"},
Layers: parityEngineLayers("b10962", parityEngineScript("version: 10962 (aaa)", dev1, "", true), true, 0)},
Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10962"}`, Wait: true},
{Name: "moteur qui ne voit aucune carte", Registry: &parityRegistry{Version: "b10963", Fixed: []string{"server-cuda-b10963"},
Layers: parityEngineLayers("b10963", parityEngineScript("version: 10963 (bbb)", "", "", false), true, 0)},
Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10963"}`, Wait: true},
{Name: "jeton refusé pendant la mise à jour", Registry: &parityRegistry{Token: 401, Version: "b10964"},
Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10964"}`, Wait: true},
{Name: "couche introuvable", Registry: &parityRegistry{Blob: 404, Version: "b10965", Fixed: []string{"server-cuda-b10965"},
Layers: parityEngineLayers("b10965", good, true, 0)}, Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10965"}`, Wait: true},
{Name: "tag illisible", Method: "POST", Path: "/api/engine/update", Body: `{"tag":"."}`, Wait: true},
{Name: "corps illisible, preset externe", Registry: reg10900, Config: map[string]string{"EXTERNAL": "1"},
Local: &parityLocal{Mode: "qwen3", Build: "b10900-ext", Template: qwenTpl}, Method: "POST", Path: "/api/engine/update", Body: `{"tag":1}`, Wait: true},
{Name: "statut, moteur compilé", Files: map[string]string{
"custom/llama-server": parityEngineScript("version: 10800 (c0ffee1)", dev1, "", false),
}, Config: map[string]string{"BIN": "{{HOME}}/custom/llama-server"}, Method: "GET", Path: "/api/engine"},
{Name: "bascule vers l'image, sans moteur d'image", EngineEnv: &noImage, Method: "POST", Path: "/api/engine/use", Body: `{"tag":"image"}`},
{Name: "statut final", Method: "GET", Path: "/api/engine"},
{Name: "statut sans aucun moteur", Config: map[string]string{"BIN": ""}, Method: "GET", Path: "/api/engine"},
{Name: "statut, version précédente = moteur courant", Files: map[string]string{
"engine/previous-bin": "{{HOME}}/engine/server-cuda-b10900/llama-server\n",
}, Config: map[string]string{"BIN": "{{HOME}}/engine/server-cuda-b10900/llama-server"}, Method: "GET", Path: "/api/engine"},
} {
steps = append(steps, run(st))
}
got, err := parityJSON(map[string]any{"goarch": ociArch(), "steps": steps})
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityEngineImgFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityEngineImgFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityEngineImgFile)
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("le panneau du moteur de l'image ne produit plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMoteurImage,\n"+
"puis cargo test --test engineimg dit si le port Rust suit.\n--- obtenu ---\n%s", parityEngineImgFile, got)
}
}
+4
View File
@@ -56,6 +56,9 @@ pub struct Ctx {
/// Backends llama.cpp et job des moteurs tenus par la façade
/// (`LOKI_BACKENDS=rust`).
pub backends: Option<std::sync::Arc<crate::backends::Backends>>,
/// Panneau « moteur de l'image » tenu par la façade
/// (`LOKI_ENGINE_IMAGE=rust`, avec les backends).
pub engineimg: Option<std::sync::Arc<crate::engineimg::EngineImg>>,
/// Jeton de l'API interne : une requête qui le porte vient du Go.
pub internal_token: String,
}
@@ -78,6 +81,7 @@ impl Ctx {
|| self.mcp.is_some()
|| self.update
|| self.backends.is_some()
|| self.engineimg.is_some()
}
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
+23 -12
View File
@@ -214,6 +214,18 @@ async fn list_devices(bin: &str, cvd: &str) -> (String, Option<String>) {
c.env("CUDA_VISIBLE_DEVICES", cvd)
.env("CUDA_DEVICE_ORDER", "PCI_BUS_ID");
}
c.stdin(Stdio::null()).kill_on_drop(true);
combined(c, bin, Some(LIST_TIMEOUT)).await
}
/// `cmd.CombinedOutput()` : sorties standard et d'erreur dans le même tube ;
/// l'erreur au format d'`exec` (`exit status N`, `signal: killed`,
/// `fork/exec …`). `limit` : délai au-delà duquel le processus est tué.
pub async fn combined(
mut c: tokio::process::Command,
bin: &str,
limit: Option<Duration>,
) -> (String, Option<String>) {
// Sortie standard et d'erreur dans le même tube, comme CombinedOutput.
let (reader, writer) = match std::io::pipe() {
Ok(p) => p,
@@ -223,10 +235,7 @@ async fn list_devices(bin: &str, cvd: &str) -> (String, Option<String>) {
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);
c.stdout(Stdio::from(writer)).stderr(Stdio::from(writer2));
let mut child = match c.spawn() {
Ok(ch) => ch,
Err(e) => {
@@ -243,14 +252,16 @@ async fn list_devices(bin: &str, cvd: &str) -> (String, Option<String>) {
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 status = match limit {
None => child.wait().await,
Some(d) => match tokio::time::timeout(d, child.wait()).await {
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);
+37 -23
View File
@@ -123,7 +123,7 @@ impl Backends {
}
/// `providedEngineBin`.
fn provided_engine_bin(&self) -> String {
pub(crate) fn provided_engine_bin(&self) -> String {
let env = self
.engine_bin_env
.read()
@@ -289,7 +289,7 @@ impl Log<'_> {
}
/// Le verrou de l'optimiseur (au Go) : le message s'il tient le moteur.
async fn tune_busy(store: &Store) -> Option<String> {
pub(crate) async fn tune_busy(store: &Store) -> Option<String> {
let v = store
.internal_json(Method::GET, "/internal/v1/tune/guard", None)
.await
@@ -304,7 +304,7 @@ async fn tune_busy(store: &Store) -> Option<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) {
pub(crate) 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());
@@ -335,6 +335,18 @@ async fn service(ctx: &Ctx, action: &str, if_active: bool) -> (bool, String) {
}
}
/// Arrête le moteur s'il tourne : (il tournait, erreur de l'arrêt).
pub(crate) async fn service_stop_if_up(ctx: &Ctx) -> (bool, String) {
if ctx.engine_ctl.is_some() {
if crate::enginectl::service_is_active(ctx) {
return service(ctx, "stop", false).await;
}
return (false, String::new());
}
// Côté Go, « arrête-le s'il tourne » dit aussi s'il tournait.
service(ctx, "stop", true).await
}
/// Les routes servies par ce module.
pub fn handles(path: &str) -> bool {
matches!(
@@ -635,23 +647,11 @@ async fn run_prebuilt(ctx: &Ctx, b: &Backends) {
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
};
let (svc_was_up, err) = service_stop_if_up(ctx).await;
if svc_was_up {
log.phase("arrêt du service le temps de l'installation…")
.await;
}
if svc_was_up && !err.is_empty() {
log.line(&format!("[warn] impossible d'arrêter le service : {err}"))
.await;
@@ -770,20 +770,34 @@ pub async fn install(b: &Backends, log: &Log<'_>) -> Result<String, String> {
Ok(bin)
}
/// Le téléchargement d'un asset, sa progression au journal du job au fil de
/// l'eau et dans l'ordre.
/// Le téléchargement d'un asset, sa progression au journal du job.
async fn download(
b: &Backends,
a: &prebuilt::Asset,
dest: &Path,
log: &Log<'_>,
) -> Result<(), String> {
let rq = b
.client
.get(&a.url)
.header("User-Agent", "Go-http-client/1.1");
log_download(log, rq, dest, a.size).await
}
/// Un téléchargement dont la progression part au journal du job au fil de
/// l'eau, dans l'ordre.
pub async fn log_download(
log: &Log<'_>,
rq: reqwest::RequestBuilder,
dest: &Path,
total: i64,
) -> 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
prebuilt::download_request(rq, dest, total, &mut logf).await
};
let pump = async {
while let Some(s) = rx.recv().await {
+14 -7
View File
@@ -312,12 +312,19 @@ pub async fn download(
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())?;
let rq = client.get(url).header("User-Agent", "Go-http-client/1.1");
download_request(rq, dest, total, logf).await
}
/// `downloadRequest` : la même chose pour une requête déjà construite — un
/// registre OCI exige un en-tête d'autorisation, même en lecture anonyme.
pub async fn download_request(
rq: reqwest::RequestBuilder,
dest: &Path,
total: i64,
logf: &mut (dyn FnMut(String) + Send),
) -> Result<(), String> {
let resp = rq.send().await.map_err(|e| e.to_string())?;
if resp.status().as_u16() != 200 {
return Err(format!("HTTP {}", resp.status().as_u16()));
}
@@ -439,7 +446,7 @@ fn mkdir_all(p: &Path) -> Result<(), String> {
/// `applyLinks` : liens symboliques, ou copie de la cible quand le système
/// les refuse.
fn apply_links(links: &[(PathBuf, String)]) -> Result<(), String> {
pub(crate) 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() {
File diff suppressed because it is too large. Load diff
+631
View File
@@ -0,0 +1,631 @@
//! Le moteur llama.cpp pris dans l'image de conteneur officielle — port de
//! `backend_engine_oci.go` : jeton anonyme, manifeste (index multi-
//! architecture puis manifeste de la plateforme), couches du sommet vers la
//! base jusqu'à tenir un moteur complet, extraction à plat de `/app` avec ses
//! liens, bascule atomique du dossier, et lecture du numéro de build.
use std::collections::HashSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::Duration;
use regex::Regex;
use serde::Deserialize;
use serde_json::Value;
use crate::backends::Log;
use crate::gojson::{self, Kind};
/// `ociEngineRepo`.
pub const ENGINE_REPO: &str = "ggml-org/llama.cpp";
/// Au-delà, une couche est le runtime de l'image, pas le moteur (`ociLayerCap`).
pub const LAYER_CAP: i64 = 600 << 20;
/// `ociAccept`.
const ACCEPT: &str = "application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json";
/// `ociDoc` décodé : index ou manifeste.
const DOC_TYPE: &str = "ociDoc";
/// `ociBase` : `https://ghcr.io`, ou `LOKI_OCI_REGISTRY` (schéma ajouté au
/// besoin, `/` final retiré).
pub fn base_from_env(v: Option<&str>) -> String {
let v = v.unwrap_or("").trim().trim_end_matches('/');
if v.is_empty() {
return "https://ghcr.io".into();
}
if v.contains("://") {
v.into()
} else {
format!("https://{v}")
}
}
/// `ociHost`.
pub fn host_of(base: &str) -> String {
base.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches('/')
.to_string()
}
/// `ociArch`.
pub fn oci_arch(arm64: bool) -> &'static str {
if arm64 { "arm64" } else { "amd64" }
}
/// Une couche du manifeste.
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct Layer {
#[serde(default, rename = "mediaType")]
pub media_type: String,
#[serde(default)]
pub digest: String,
#[serde(default)]
pub size: i64,
}
/// Le client du registre.
#[derive(Clone)]
pub struct Registry {
pub base: String,
pub arm64: bool,
client: reqwest::Client,
}
impl Registry {
pub fn new(base: String, arm64: bool) -> Self {
crate::chat::engine::install_crypto();
Registry {
base,
arm64,
client: reqwest::Client::builder().build().unwrap_or_default(),
}
}
pub fn host(&self) -> String {
host_of(&self.base)
}
/// `ociToken` : un jeton de lecture anonyme.
pub async fn token(&self, repo: &str) -> Result<String, String> {
let u = format!(
"{}/token?scope=repository:{repo}:pull&service={}",
self.base,
self.host()
);
let resp = self
.client
.get(&u)
.timeout(Duration::from_secs(30))
.send()
.await
.map_err(|e| format!("registre {} injoignable : {e}", self.host()))?;
if resp.status().as_u16() != 200 {
return Err(format!(
"{}/token : HTTP {}",
self.host(),
resp.status().as_u16()
));
}
let body = resp.bytes().await.unwrap_or_default();
let d = gojson::decode_struct(
&body,
"struct { Token string \"json:\\\"token\\\"\" }",
&[("token", Kind::Str)],
);
let t = d.str("token");
if d.err.is_some() || t.is_empty() {
return Err("jeton de lecture illisible".into());
}
Ok(t)
}
/// Un manifeste (index ou image) par référence.
async fn doc(&self, repo: &str, reference: &str, token: &str) -> Result<Value, String> {
let resp = self
.client
.get(format!("{}/v2/{repo}/manifests/{reference}", self.base))
.header("Authorization", format!("Bearer {token}"))
.header("Accept", ACCEPT)
.send()
.await
.map_err(|e| e.to_string())?;
match resp.status().as_u16() {
404 => return Err(format!("version inconnue dans le registre : {reference}")),
200 => {}
s => return Err(format!("manifeste {reference} : HTTP {s}")),
}
let body = resp.bytes().await.map_err(|e| e.to_string())?;
let d = gojson::decode_struct(
&body,
DOC_TYPE,
&[
("mediaType", Kind::Str),
("manifests", Kind::Any),
("layers", Kind::Any),
("annotations", Kind::Any),
],
);
if let Some(e) = d.err {
return Err(format!("manifeste {reference} illisible : {e}"));
}
Ok(Value::Object(d.fields))
}
/// `ociResolve` : les couches de l'image pour cette architecture, et le
/// build qu'elle annonce.
pub async fn resolve(&self, repo: &str, tag: &str) -> Result<(Vec<Layer>, String), String> {
let token = self.token(repo).await?;
let version_of = |d: &Value| -> String {
d.get("annotations")
.and_then(|a| a.get("org.opencontainers.image.version"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
let mut doc = self.doc(repo, tag, &token).await?;
let mut version = version_of(&doc);
let manifests = doc
.get("manifests")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if !manifests.is_empty() {
let want = oci_arch(self.arm64);
let digest = manifests
.iter()
.find(|m| m["platform"]["os"] == "linux" && m["platform"]["architecture"] == want)
.and_then(|m| m.get("digest").and_then(Value::as_str))
.unwrap_or("")
.to_string();
if digest.is_empty() {
return Err(format!("aucune image linux/{want} pour {tag}"));
}
doc = self.doc(repo, &digest, &token).await?;
let v = version_of(&doc);
if !v.is_empty() {
version = v;
}
}
let layers: Vec<Layer> = doc
.get("layers")
.cloned()
.and_then(|l| serde_json::from_value(l).ok())
.unwrap_or_default();
if layers.is_empty() {
return Err(format!("manifeste {tag} sans couche"));
}
Ok((layers, version))
}
/// `engineOCILatest` : le dernier build publié pour la variante (numéro et
/// tag figé), lu sur l'annotation du tag mouvant.
pub async fn latest(&self, variant: &str) -> Result<(i64, String), String> {
let (_, version) = self.resolve(ENGINE_REPO, variant).await?;
if version.is_empty() {
return Ok((0, variant.into()));
}
let n = version.trim_start_matches('b').parse().unwrap_or(0);
Ok((n, format!("{variant}-{version}")))
}
}
/// `engineDir`.
pub fn engine_dir(home: &Path) -> PathBuf {
home.join("engine")
}
static RE_TAG_BUILD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"-b([0-9]+)$").expect("motif"));
/// `engineTagBuild` : « server-cuda-b10450 » → 10450, 0 sans numéro.
pub fn tag_build(tag: &str) -> i64 {
RE_TAG_BUILD
.captures(tag)
.and_then(|m| m[1].parse().ok())
.unwrap_or(0)
}
/// `engineInstalled` : les versions présentes, de la plus ancienne à la plus
/// récente.
pub fn installed(home: &Path) -> Vec<String> {
let dir = engine_dir(home);
let Ok(rd) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut tags: 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(|t| crate::backends::probe::is_file(&dir.join(t).join("llama-server")))
.collect();
tags.sort();
tags.sort_by_key(|t| tag_build(t));
tags
}
/// `engineBinFor` : le llama-server d'une version installée, "" sinon.
pub fn bin_for(home: &Path, tag: &str) -> String {
let p = engine_dir(home)
.join(crate::paths::go_base(tag))
.join("llama-server");
if crate::backends::probe::is_file(&p) {
p.to_string_lossy().into_owned()
} else {
String::new()
}
}
/// `enginePrune` : les versions installées hors `keep`, supprimées.
pub fn prune(home: &Path, keep: &HashSet<String>) -> Vec<String> {
let mut out = Vec::new();
for tag in installed(home) {
if keep.contains(&tag) {
continue;
}
if std::fs::remove_dir_all(engine_dir(home).join(&tag)).is_ok() {
out.push(format!("ancienne version supprimée : {tag}"));
}
}
out
}
/// `appEntryName` : « app/libllama.so » → « libllama.so » ; rien pour tout ce
/// qui n'est pas un fichier direct de /app (ni marqueur de suppression).
pub fn app_entry_name(raw: &str) -> Option<String> {
let p = raw.trim().replace('\\', "/");
let p = p.strip_prefix("./").unwrap_or(&p);
let rest = p.strip_prefix("app/")?;
if rest.is_empty() || rest.contains('/') || rest.starts_with(".wh.") {
return None;
}
Some(rest.to_string())
}
/// `engineSetComplete` : le binaire ET ses bibliothèques.
pub fn set_complete(seen: &HashSet<String>) -> bool {
seen.contains("llama-server")
&& seen.iter().any(|n| n.starts_with("libggml-base.so"))
&& seen.iter().any(|n| n.starts_with("libllama.so"))
}
/// `extractAppLayer` : les entrées `app/…` d'une couche, à plat dans `dir` ;
/// une couche supérieure fait foi (`seen`). Rend le nombre de fichiers.
pub fn extract_app_layer(
archive: &Path,
dir: &Path,
seen: &mut HashSet<String>,
links: &mut Vec<(PathBuf, String)>,
) -> Result<usize, String> {
let f = std::fs::File::open(archive)
.map_err(|e| crate::paths::os_err("open", &archive.to_string_lossy(), &e))?;
let gz = flate2::read::GzDecoder::new(f);
let mut tr = tar::Archive::new(gz);
let mut count = 0;
for entry in tr.entries().map_err(|e| e.to_string())? {
let mut entry = entry.map_err(|e| e.to_string())?;
let raw = String::from_utf8_lossy(&entry.path_bytes()).into_owned();
let Some(name) = app_entry_name(&raw) else {
continue;
};
if seen.contains(&name) {
continue;
}
let p = dir.join(&name);
let kind = entry.header().entry_type();
if kind.is_file() {
let mut buf = Vec::new();
entry.read_to_end(&mut buf).map_err(|e| e.to_string())?;
std::fs::write(&p, &buf)
.map_err(|e| crate::paths::os_err("open", &p.to_string_lossy(), &e))?;
#[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));
}
seen.insert(name);
count += 1;
} else if kind.is_symlink() || kind.is_hard_link() {
let link = entry
.link_name_bytes()
.map(|b| String::from_utf8_lossy(&b).into_owned())
.unwrap_or_default();
let target = app_entry_name(&link).unwrap_or_else(|| crate::paths::go_base(&link));
links.push((p, target));
seen.insert(name);
count += 1;
}
}
Ok(count)
}
/// `engineOCIInstall` : le moteur du tag dans `engine/<tag>` ; rend le chemin
/// du llama-server. Une version déjà là est rendue telle quelle.
pub async fn install(
home: &Path,
reg: &Registry,
tag: &str,
log: &Log<'_>,
) -> Result<String, String> {
let tag = crate::paths::go_base(tag.trim());
if tag.is_empty() || tag == "." {
return Err("version du moteur non précisée".into());
}
let bin = bin_for(home, &tag);
if !bin.is_empty() {
log.line(&format!("déjà installé : {tag}")).await;
return Ok(bin);
}
log.phase(&format!("lecture du manifeste {tag}…")).await;
let (layers, version) = reg.resolve(ENGINE_REPO, &tag).await?;
if !version.is_empty() {
log.line(&format!(
"image {ENGINE_REPO}:{tag} — llama.cpp {version}, {} couches",
layers.len()
))
.await;
}
let dest = engine_dir(home).join(&tag);
let tmp = PathBuf::from(format!("{}.part", dest.to_string_lossy()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp)
.map_err(|e| crate::paths::os_err("mkdir", &tmp.to_string_lossy(), &e))?;
let res = install_into(reg, &tag, &layers, &tmp, &dest, log).await;
let _ = std::fs::remove_dir_all(&tmp);
res
}
async fn install_into(
reg: &Registry,
tag: &str,
layers: &[Layer],
tmp: &Path,
dest: &Path,
log: &Log<'_>,
) -> Result<String, String> {
let token = reg.token(ENGINE_REPO).await?;
let mut seen = HashSet::new();
let mut links = Vec::new();
for (i, l) in layers.iter().enumerate().rev() {
if l.size > LAYER_CAP {
log.line(&format!(
"couche {i} ignorée ({} Mo) : c'est le runtime de l'image, pas le moteur",
l.size / 1_000_000
))
.await;
break;
}
let part = tmp.join("layer.tgz");
if l.size > 4 << 20 {
log.phase(&format!(
"téléchargement du moteur ({} Mo)…",
l.size / 1_000_000
))
.await;
}
let rq = reg
.client
.get(format!("{}/v2/{ENGINE_REPO}/blobs/{}", reg.base, l.digest))
.header("Authorization", format!("Bearer {token}"));
crate::backends::log_download(log, rq, &part, l.size)
.await
.map_err(|e| format!("téléchargement de la couche {i} : {e}"))?;
let (p2, d2) = (part.clone(), tmp.to_path_buf());
let mut s2 = std::mem::take(&mut seen);
let mut l2 = std::mem::take(&mut links);
let (n, s2, l2) = tokio::task::spawn_blocking(move || {
let r = extract_app_layer(&p2, &d2, &mut s2, &mut l2);
(r, s2, l2)
})
.await
.map_err(|e| e.to_string())?;
seen = s2;
links = l2;
let _ = std::fs::remove_file(&part);
let n = n.map_err(|e| format!("extraction de la couche {i} : {e}"))?;
if n > 0 {
log.line(&format!("couche {i} : {n} fichiers du moteur"))
.await;
}
if set_complete(&seen) {
break;
}
}
crate::backends::prebuilt::apply_links(&links)?;
if !seen.contains("llama-server") {
return Err(format!(
"llama-server absent de l'image {tag} — variante inattendue ?"
));
}
let _ = std::fs::remove_dir_all(dest);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| crate::paths::os_err("mkdir", &parent.to_string_lossy(), &e))?;
}
std::fs::rename(tmp, dest).map_err(|e| e.to_string())?;
let bin = dest.join("llama-server");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
.map_err(|e| crate::paths::os_err("chmod", &bin.to_string_lossy(), &e))?;
}
let bin = bin.to_string_lossy().into_owned();
log.line(&format!("moteur installé : {bin}")).await;
Ok(bin)
}
static RE_BUILD_LONG: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"build[\t\n\f\r ]+([0-9]+),[\t\n\f\r ]*commit[\t\n\f\r ]+([0-9a-fA-F]+)")
.expect("motif")
});
static RE_BUILD_SHORT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?m)^[\t\n\f\r ]*(?:version|build)[\t\n\f\r ]*:[\t\n\f\r ]*([0-9]+)[\t\n\f\r ]*\(([0-9a-fA-F]+)\)",
)
.expect("motif")
});
/// `parseEngineBuild` : (build, commit) des trois formes de la bannière.
pub fn parse_build(out: &str) -> (i64, String) {
for re in [&*RE_BUILD_LONG, &*RE_BUILD_SHORT] {
if let Some(m) = re.captures(out) {
return (m[1].parse().unwrap_or(0), m[2].to_string());
}
}
(0, String::new())
}
/// Un binaire lancé avec ses bibliothèques à côté : (sortie mêlée, erreur au
/// format d'`exec`). `limit` : délai (tué au-delà).
pub async fn run_bin(bin: &str, arg: &str, limit: Option<Duration>) -> (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(arg)
.env(k, v)
.stdin(std::process::Stdio::null())
.kill_on_drop(true);
crate::backends::devices::combined(c, bin, limit).await
}
/// `engineBuildOf` : le build d'un llama-server (0 s'il ne répond pas).
pub async fn build_of(bin: &str) -> (i64, String) {
if bin.is_empty() || !crate::backends::probe::is_file(Path::new(bin)) {
return (0, String::new());
}
let (out, _) = run_bin(bin, "--version", Some(Duration::from_secs(15))).await;
parse_build(&out)
}
/// `tailLines` : les n dernières lignes.
pub fn tail_lines(s: &str, n: usize) -> String {
let lines: Vec<&str> = s.trim_end_matches('\n').split('\n').collect();
let from = lines.len().saturating_sub(n);
lines[from..].join("\n")
}
/// `engineListDevices`.
pub async fn list_devices(bin: &str) -> Result<usize, String> {
let (out, err) = run_bin(bin, "--list-devices", None).await;
if let Some(e) = err {
return Err(format!(
"le moteur ne démarre pas ici : {e}\n{}",
tail_lines(&out, 12)
));
}
Ok(crate::backends::devices::parse_list_devices(&out).len())
}
/// `engineSmokeTest` : le nouveau moteur démarre, et voit les cartes que
/// voyait l'actuel.
pub async fn smoke_test(bin: &str, ancien: &str) -> Result<(), String> {
if list_devices(bin).await? > 0 {
return Ok(());
}
if ancien.is_empty() || ancien == bin {
return Ok(());
}
match list_devices(ancien).await {
Ok(n) if n > 0 => Err(format!(
"le moteur téléchargé ne voit aucune carte graphique, alors que le moteur actuel en voit {n} — il tournerait sur le processeur, beaucoup plus lentement"
)),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entrees_app() {
assert_eq!(
app_entry_name("app/libllama.so").as_deref(),
Some("libllama.so")
);
assert_eq!(
app_entry_name("./app/llama-server").as_deref(),
Some("llama-server")
);
assert_eq!(app_entry_name("app/"), None);
assert_eq!(app_entry_name("app/sous/x"), None);
assert_eq!(app_entry_name("app/.wh.x"), None);
assert_eq!(app_entry_name("usr/app/x"), None);
}
#[test]
fn builds() {
assert_eq!(
parse_build("version: 0.1.0-dev (build 10450, commit ece963f41)"),
(10450, "ece963f41".into())
);
assert_eq!(
parse_build("version: 10450 (ece963f)"),
(10450, "ece963f".into())
);
assert_eq!(
parse_build("build: 4589 (a1b2c3d)"),
(4589, "a1b2c3d".into())
);
assert_eq!(parse_build("rien"), (0, String::new()));
assert_eq!(tag_build("server-cuda-b10450"), 10450);
assert_eq!(tag_build("server-cuda"), 0);
}
#[test]
fn moteur_complet() {
let set = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<HashSet<_>>();
assert!(!set_complete(&set(&["libggml-base.so", "libllama.so"])));
assert!(!set_complete(&set(&["llama-server", "libggml-base.so.0"])));
assert!(!set_complete(&set(&["llama-server", "libllama.so.0"])));
assert!(set_complete(&set(&[
"llama-server",
"libggml-base.so.0",
"libllama.so"
])));
assert_eq!(tail_lines("a\nb\nc\n", 2), "b\nc");
assert_eq!(tail_lines("a", 5), "a");
}
/// Ne voir aucune carte n'est un échec que si le moteur courant en voyait.
#[tokio::test]
async fn essai_a_blanc() {
use std::os::unix::fs::PermissionsExt;
let d = tempfile::tempdir().unwrap();
let script = |name: &str, body: &str| {
let p = d.path().join(name);
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p.to_string_lossy().into_owned()
};
let none = script("aucune", "echo 'Available devices:'");
let one = script(
"une",
"echo 'Available devices:'; echo ' CUDA0: Carte (1000 MiB, 900 MiB free)'",
);
let ko = script("ko", "echo panne; exit 3");
assert_eq!(smoke_test(&none, "").await, Ok(()));
assert_eq!(smoke_test(&none, &none).await, Ok(()));
assert_eq!(smoke_test(&none, &ko).await, Ok(()));
assert_eq!(smoke_test(&one, &ko).await, Ok(()));
let e = smoke_test(&none, &one).await.unwrap_err();
assert!(e.contains("en voit 1"), "{e}");
let e = smoke_test(&ko, &one).await.unwrap_err();
assert_eq!(e, "le moteur ne démarre pas ici : exit status 3\npanne");
}
#[test]
fn registre() {
assert_eq!(base_from_env(None), "https://ghcr.io");
assert_eq!(
base_from_env(Some(" miroir.local:5000/ ")),
"https://miroir.local:5000"
);
assert_eq!(base_from_env(Some("http://x")), "http://x");
assert_eq!(host_of("https://ghcr.io/"), "ghcr.io");
}
}
+1
View File
@@ -22,6 +22,7 @@ pub mod dictate;
pub mod downloads;
pub mod engine;
pub mod enginectl;
pub mod engineimg;
pub mod engstatus;
pub mod gguf;
pub mod gojson;
+31
View File
@@ -167,6 +167,13 @@ fn serve() -> ExitCode {
// 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");
// LOKI_ENGINE_IMAGE=rust : le panneau « moteur de l'image » (mise à
// jour depuis le registre officiel), avec les backends seulement : sa
// mise à jour est un job, et le job est à eux.
let engineimg_rust = held_by_facade("LOKI_ENGINE_IMAGE") && backends_rust;
if held_by_facade("LOKI_ENGINE_IMAGE") && !backends_rust {
eprintln!("[loki-rs] moteur de l'image tenu par le Go (LOKI_BACKENDS=rust requis)");
}
let engine_rust = held_by_facade("LOKI_ENGINE")
&& match loki_rs::enginectl::usable(
std::env::var("LOKI_CONTAINER").ok().as_deref(),
@@ -199,6 +206,7 @@ fn serve() -> ExitCode {
("LOKI_MCP", mcp_rust),
("LOKI_UPDATE", update_rust),
("LOKI_BACKENDS", backends_rust),
("LOKI_ENGINE_IMAGE", engineimg_rust),
];
for (var, on) in held {
if on {
@@ -265,6 +273,28 @@ fn serve() -> ExitCode {
None
};
// La sonde de gabarit de la conversation quand la façade la tient :
// le relevé du rendu rejoue la forme de ses vraies requêtes.
let engineimg = engineimg_rust.then(|| {
let probe = chat
.as_ref()
.map(|c| c.shared.tpl.clone())
.unwrap_or_else(|| std::sync::Arc::new(loki_rs::chat::tplprobe::TplProbe::new()));
let reg = loki_rs::engineimg::oci::Registry::new(
loki_rs::engineimg::oci::base_from_env(
std::env::var("LOKI_OCI_REGISTRY").ok().as_deref(),
),
cfg!(target_arch = "aarch64"),
);
std::sync::Arc::new(loki_rs::engineimg::EngineImg::new(
engine_paths.home.clone(),
reg,
cfg!(target_os = "linux")
&& (cfg!(target_arch = "x86_64") || cfg!(target_arch = "aarch64")),
probe,
))
});
let files = if files_rust {
Some(start_files(backend, &token).await)
} else {
@@ -321,6 +351,7 @@ fn serve() -> ExitCode {
projects: projects_rust,
update: update_rust,
backends,
engineimg,
mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))),
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
+16
View File
@@ -359,6 +359,22 @@ pub fn go_dir(path: &str) -> String {
format!("{}{dir}", &path[..vol_len])
}
/// `filepath.Base` : le dernier élément ; "." pour un chemin vide, "/" pour
/// une suite de séparateurs.
pub fn go_base(p: &str) -> String {
if p.is_empty() {
return ".".into();
}
let t = p.trim_end_matches(|c: char| c == '/' || (cfg!(windows) && c == '\\'));
if t.is_empty() {
return "/".into();
}
match t.rfind(|c: char| c == '/' || (cfg!(windows) && c == '\\')) {
Some(i) => t[i + 1..].to_string(),
None => t.to_string(),
}
}
/// `filepath.IsAbs`.
pub fn go_is_abs(path: &str) -> bool {
if !cfg!(windows) {
+1 -1
View File
@@ -870,7 +870,7 @@ async fn live(store: &Store) -> Live {
/// `SavePresetApplying` : enregistre ; si c'était le preset EN SERVICE (avant
/// l'écriture), l'applique (`mode` : save ou apply).
async fn save_applying(
pub(crate) async fn save_applying(
ctx: &Ctx,
id: &str,
name: &str,
+10
View File
@@ -96,6 +96,10 @@ pub struct Config {
/// 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>>,
/// Panneau « moteur de l'image » (version, mise à jour depuis le
/// registre, bascules, rendu du gabarit) tenu par la façade
/// (`LOKI_ENGINE_IMAGE=rust`) ; `None` : au Go.
pub engineimg: Option<Arc<crate::engineimg::EngineImg>>,
}
struct Proxy {
@@ -136,6 +140,7 @@ pub async fn serve(
mcp: cfg.mcp,
update: cfg.update,
backends: cfg.backends,
engineimg: cfg.engineimg,
internal_token: cfg.internal_token.clone(),
},
});
@@ -240,6 +245,11 @@ impl Proxy {
{
return chatapi::serve(&self.ctx, chat, req, from_go).await;
}
if let Some(e) = &self.ctx.engineimg
&& crate::engineimg::handles(path)
{
return crate::engineimg::serve(&self.ctx, e, req, from_go).await;
}
if let Some(b) = &self.ctx.backends
&& crate::backends::handles(path)
{
+1 -129
View File
@@ -22,6 +22,7 @@ use std::time::Duration;
use bytes::Bytes;
use common::fakego;
use common::parity::{apply_files, tar_gz, tree, write_file};
use http_body_util::Full;
use hyper::service::service_fn;
use hyper::{Request, Response};
@@ -89,87 +90,6 @@ async fn fake_github(rel: Arc<Mutex<Value>>) -> String {
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();
@@ -240,54 +160,6 @@ fn set_machine(tools: &Path, machines: &Value, name: &str) {
}
}
/// `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>,
+20
View File
@@ -78,6 +78,9 @@ pub struct FakeGo {
pub tune_busy: Mutex<Option<String>>,
/// /internal/v1/preset/apply reçus : (chemin, mode).
pub preset_applies: Mutex<Vec<(String, String)>>,
/// /internal/v1/preset/apply écrit-il le preset dans la configuration
/// (`applyPresetFile` : clés « appareil » et BIN de la machine gardés) ?
pub apply_presets: Mutex<bool>,
/// /internal/v1/chat/generating : le Go écrit une réponse.
pub generating: Mutex<bool>,
/// /internal/v1/service/prestart reçus.
@@ -450,6 +453,21 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
if mode == "refus" || path.ends_with("refus.env") {
return resp(500, r#"{"error":"application refusée"}"#);
}
if *st.apply_presets.lock().unwrap() {
let text = std::fs::read_to_string(&path).unwrap_or_default();
let mut next = loki_rs::presets::parse_env(&text);
let mut store = st.store.lock().unwrap();
let cur = store.entry("config".into()).or_default();
for k in loki_rs::presets::PRESERVED_KEYS {
if let Some(v) = cur.get(*k) {
next.insert(k.to_string(), String::from_utf8_lossy(v).into_owned());
}
}
if let Some(bin) = cur.get("BIN").filter(|v| !v.is_empty()) {
next.insert("BIN".into(), String::from_utf8_lossy(bin).into_owned());
}
*cur = next.into_iter().map(|(k, v)| (k, v.into_bytes())).collect();
}
st.preset_applies.lock().unwrap().push((path, mode));
resp(204, "")
}
@@ -642,6 +660,7 @@ pub struct Opts {
pub mcp: Option<Arc<loki_rs::mcp::Mcp>>,
pub update: bool,
pub backends: Option<Arc<loki_rs::backends::Backends>>,
pub engineimg: Option<Arc<loki_rs::engineimg::EngineImg>>,
}
/// Comme [`start`], avec la conversation tenue par la façade
@@ -744,6 +763,7 @@ pub async fn start_with(
mcp: opts.mcp,
update: opts.update,
backends: opts.backends,
engineimg: opts.engineimg,
};
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
let _ = rx.await;
+1
View File
@@ -3,6 +3,7 @@
#![allow(dead_code)]
pub mod fakego;
pub mod parity;
pub mod toolsfx;
use bytes::Bytes;
+139
View File
@@ -0,0 +1,139 @@
//! Aides des rejeux de parité qui posent des fichiers et lisent une
//! arborescence (`parityApplyFiles`, `parityTarGz`, `parityTree` du Go).
use std::path::Path;
use serde_json::Value;
fn s(v: &Value) -> &str {
v.as_str().unwrap_or("")
}
/// Une archive .tar.gz ; les noms sont écrits tels quels (« ../x » compris).
pub 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()
}
pub 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();
}
pub 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`).
pub 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 });
}
}
/// `info.Mode().Perm().String()`.
pub 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`.
pub 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
}
+607
View File
@@ -0,0 +1,607 @@
//! Lot 24 : panneau « moteur de l'image » tenu par la façade
//! (`LOKI_ENGINE_IMAGE=rust`, avec `LOKI_BACKENDS=rust`).
//!
//! Le journal tiré du Go (`internal/loki/parity_engineimg_test.go` →
//! `tests/parity_engineimg/engineimg.json`) est rejoué par une façade, contre
//! un faux registre OCI (jeton, index multi-architecture, manifeste annoté,
//! couches .tar.gz) et un faux moteur local (`/health`, `/props`,
//! `/apply-template` : le `fakeTpl` du Go) pour la sonde de gabarit et la
//! vérification du rendu avant/après la bascule : statut, dernier build
//! publié, plan (dernière ou recommandée, risque de rendu du raisonnement),
//! mise à jour (couches, liens, essai à blanc, REASONING_PRESERVE=off dans le
//! preset actif ou la configuration, ménage, rendu comparé), bascules,
//! suppression, retour à la version précédente, et les échecs.
mod common;
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use bytes::Bytes;
use common::fakego;
use common::parity::{apply_files, tar_gz, tree};
use http_body_util::{BodyExt, Full};
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use loki_rs::backends::Backends;
use loki_rs::backends::probe::Probe;
use loki_rs::chat::tplprobe::TplProbe;
use loki_rs::engineimg::{EngineImg, oci};
use regex::Regex;
use serde_json::{Value, json};
use tokio::net::TcpListener;
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_engineimg/engineimg.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";
/// Sert un handler HTTP sur un port libre ; renvoie l'adresse.
async fn serve<F>(h: F) -> SocketAddr
where
F: Fn(String, String, Option<String>, Vec<u8>) -> (u16, Vec<u8>) + Send + Sync + 'static,
{
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
let h = Arc::new(h);
tokio::spawn(async move {
while let Ok((st, _)) = l.accept().await {
let h = h.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
let h = h.clone();
async move {
let method = req.method().to_string();
let path = req.uri().path().to_string();
let auth = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let body = req
.into_body()
.collect()
.await
.map(|b| b.to_bytes().to_vec())
.unwrap_or_default();
let (code, out) = h(method, path, auth, body);
let mut r = Response::new(Full::new(Bytes::from(out)));
*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;
});
}
});
addr
}
/// Le faux registre : `{token, token_body, manifest, manifest_body, no_arch,
/// blob, version, fixed, layers}`.
async fn fake_registry(reg: Arc<Mutex<Value>>) -> String {
let addr = serve(move |_, path, auth, _| {
let cur = reg.lock().unwrap().clone();
let version = s(&cur["version"]).to_string();
let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();
let mut layers = Vec::new();
for (i, l) in cur["layers"].as_array().into_iter().flatten().enumerate() {
let data = tar_gz(l["entries"].as_array().map_or(&[][..], Vec::as_slice));
let digest = format!("sha256:couche{i}");
let size = l["size"].as_i64().unwrap_or(0);
let size = if size > 0 { size } else { data.len() as i64 };
layers.push(json!({
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": digest, "size": size,
}));
blobs.insert(digest, data);
}
let annotations = json!({ "org.opencontainers.image.version": version });
let manifest = json!({ "schemaVersion": 2, "layers": layers, "annotations": annotations });
let man_dig = "sha256:manifeste";
let arch = if cur["no_arch"] == json!(true) {
"s390x"
} else {
oci::oci_arch(cfg!(target_arch = "aarch64"))
};
let index = json!({
"schemaVersion": 2,
"manifests": [
{ "digest": "sha256:autre", "platform": { "os": "linux", "architecture": "ppc64le" } },
{ "digest": man_dig, "platform": { "os": "linux", "architecture": arch } },
],
});
let last = path.rsplit('/').next().unwrap_or("").to_string();
if path == "/token" {
return match cur["token"].as_u64() {
Some(code) if code != 0 => (code as u16, Vec::new()),
_ if !s(&cur["token_body"]).is_empty() => {
(200, s(&cur["token_body"]).as_bytes().to_vec())
}
_ => (200, br#"{"token":"jeton"}"#.to_vec()),
};
}
if path.contains("/manifests/") {
if auth.as_deref() != Some("Bearer jeton") {
return (401, Vec::new());
}
if let Some(code) = cur["manifest"].as_u64().filter(|c| *c != 0) {
return (code as u16, Vec::new());
}
if !s(&cur["manifest_body"]).is_empty() {
return (200, s(&cur["manifest_body"]).as_bytes().to_vec());
}
let fixed = cur["fixed"]
.as_array()
.into_iter()
.flatten()
.any(|f| s(f) == last);
return if last == man_dig {
(200, manifest.to_string().into_bytes())
} else if last == "server-cuda" || fixed {
(200, index.to_string().into_bytes())
} else {
(404, Vec::new())
};
}
if path.contains("/blobs/") {
if let Some(code) = cur["blob"].as_u64().filter(|c| *c != 0) {
return (code as u16, Vec::new());
}
return match blobs.remove(&last) {
Some(d) => (200, d),
None => (404, Vec::new()),
};
}
(404, Vec::new())
})
.await;
format!("http://{addr}")
}
/// Le faux moteur local : le `fakeTpl` du Go (mode, gabarit, build), ou
/// arrêté (503 partout).
#[derive(Clone, Default)]
struct Local {
mode: String,
build: String,
tpl: String,
down: bool,
/// Statut imposé à `/apply-template` (0 : rendu).
apply: u16,
}
/// `fakeTpl.render`.
fn render(mode: &str, body: &Value) -> (u16, Vec<u8>) {
let id_re = Regex::new("^[A-Za-z0-9]{9}$").unwrap();
let empty = Vec::new();
let msgs = body["messages"].as_array().unwrap_or(&empty);
let last_user = msgs.iter().rposition(|m| m["role"] == "user");
let mut b = String::new();
if let Some(tools) = body["tools"].as_array() {
b.push_str(&format!("<tools n={}>\n", tools.len()));
}
for (i, m) in msgs.iter().enumerate() {
let role = s(&m["role"]);
b.push_str(&format!("<|im_start|>{role}\n"));
let r = s(&m["reasoning_content"]);
if !r.is_empty()
&& role == "assistant"
&& mode != "drop"
&& (mode != "qwen3" || last_user.is_none_or(|u| i > u))
{
b.push_str(&format!("<think>{r}</think>"));
}
b.push_str(s(&m["content"]));
for call in m["tool_calls"].as_array().into_iter().flatten() {
let id = s(&call["id"]);
if !id_re.is_match(id) || i + 1 >= msgs.len() || msgs[i + 1]["tool_call_id"] != id {
return (
500,
br#"{"error":{"code":500,"message":"Jinja Exception: tool call id invalide"}}"#
.to_vec(),
);
}
b.push_str(&format!(
"<tool_call>{}</tool_call>",
s(&call["function"]["name"])
));
}
b.push_str("<|im_end|>\n");
}
if body["add_generation_prompt"] == json!(true) || mode == "noagp" {
b.push_str("<|im_start|>assistant\n");
}
(200, json!({ "prompt": b }).to_string().into_bytes())
}
async fn fake_engine(local: Arc<Mutex<Local>>) -> u16 {
serve(move |_, path, _, body| {
let l = local.lock().unwrap().clone();
if l.down {
return (503, Vec::new());
}
match path.as_str() {
"/health" => (200, br#"{"status":"ok"}"#.to_vec()),
"/props" => {
let tpl = if l.tpl.is_empty() { &l.mode } else { &l.tpl };
let build = if l.build.is_empty() {
"b7000-abc"
} else {
&l.build
};
let v = json!({
"build_info": build,
"model_path": "/models/Qwen3-8B-Q8_0.gguf",
"chat_template": tpl,
});
(200, v.to_string().into_bytes())
}
"/apply-template" if l.apply != 0 => (
l.apply,
r#"{"error":{"code":500,"message":"Jinja Exception: gabarit cassé"}}"#
.as_bytes()
.to_vec(),
),
"/apply-template" => {
let v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
render(&l.mode, &v)
}
_ => (404, Vec::new()),
}
})
.await
.port()
}
struct Bench {
facade: SocketAddr,
go: Arc<fakego::FakeGo>,
_stop: tokio::sync::oneshot::Sender<()>,
home: PathBuf,
b: Arc<Backends>,
reg: Arc<Mutex<Value>>,
reg_base: String,
local: Arc<Mutex<Local>>,
port: u16,
_tmp: Vec<tempfile::TempDir>,
}
async fn bench() -> Bench {
bench_with(Duration::from_secs(10)).await
}
async fn bench_with(render_wait: Duration) -> Bench {
let tmp = tempfile::tempdir().unwrap();
let root = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let reg = Arc::new(Mutex::new(json!({})));
let reg_base = fake_registry(reg.clone()).await;
let local = Arc::new(Mutex::new(Local::default()));
let port = fake_engine(local.clone()).await;
let b = Arc::new(Backends::with(
home.clone(),
home.join("image/llama-server")
.to_string_lossy()
.into_owned(),
Probe {
path: Some(vec![]),
root: root.path().to_path_buf(),
},
String::new(),
));
let mut probe = TplProbe::new();
probe.auto = false;
probe.backoff = Duration::from_millis(1);
let mut e = EngineImg::new(
home.clone(),
oci::Registry::new(reg_base.clone(), cfg!(target_arch = "aarch64")),
true,
Arc::new(probe),
);
e.render_poll = Duration::from_millis(1);
e.render_wait = render_wait;
e.env = Some(Default::default());
let (facade, go, stop, _) = fakego::start_with(
home.clone(),
fakego::Opts {
backends: Some(b.clone()),
engineimg: Some(Arc::new(e)),
..Default::default()
},
)
.await;
go.set("config", "PORT", &port.to_string());
*go.apply_presets.lock().unwrap() = true;
Bench {
facade,
go,
_stop: stop,
home,
b,
reg,
reg_base,
local,
port,
_tmp: vec![tmp, root],
}
}
impl Bench {
fn mask(&self, v: &str) -> String {
let ts = Regex::new(r#""(started_at|ended_at)":[1-9][0-9]*"#).unwrap();
let at = Regex::new(r#""at":"[^"]*""#).unwrap();
let v = v
.replace(&self.reg_base, "{{REG}}")
.replace(&oci::host_of(&self.reg_base), "{{REGHOST}}")
.replace(&self.home.to_string_lossy().into_owned(), "{{HOME}}");
let v = ts.replace_all(&v, r#""$1":"{{T}}""#);
at.replace_all(&v, r#""at":"{{T}}""#).into_owned()
}
fn unmask(&self, v: &str) -> String {
v.replace("{{HOME}}", &self.home.to_string_lossy())
.replace("{{PORT}}", &self.port.to_string())
}
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)
}
fn set_local(&self, v: &Value) {
*self.local.lock().unwrap() = Local {
mode: s(&v["mode"]).into(),
build: s(&v["build"]).into(),
tpl: s(&v["template"]).into(),
down: v["down"] == json!(true),
apply: v["apply"].as_u64().unwrap_or(0) as u16,
};
}
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");
}
async fn wait_render(&self) -> Value {
for _ in 0..1500 {
let (_, v) = self.call("GET", "/api/engine", "").await;
let c = &v["render_check"];
if !c.is_null() && c["status"] != "pending" {
return c.clone();
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("la vérification du rendu ne finit pas");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn parite_du_panneau() {
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;
for st in fx["steps"].as_array().unwrap() {
let name = s(&st["name"]);
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 !st["registry"].is_null() {
*bn.reg.lock().unwrap() = st["registry"].clone();
}
if !st["local"].is_null() {
bn.set_local(&st["local"]);
}
if let Some(e) = st["engine_env"].as_str() {
bn.b.set_engine_bin_env(&bn.unmask(e));
}
*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");
if !st["after_job"].is_null() {
bn.set_local(&st["after_job"]);
}
}
if st["wait_render"].as_bool() == Some(true) {
assert_eq!(bn.wait_render().await, st["render"], "{name} : rendu");
}
let mut after = BTreeMap::new();
for k in ["BIN", "REASONING_PRESERVE"] {
if let Some(v) = bn.go.get("config", k).filter(|v| !v.is_empty()) {
after.insert(format!("config/{k}"), Value::String(bn.mask(&v)));
}
}
if let Ok(b) = std::fs::read(bn.home.join("engine/previous-bin")) {
after.insert(
"previous-bin".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();
let t = if t.is_empty() {
Value::Null
} else {
Value::Array(t)
};
assert_eq!(t, st["tree"], "{name} : arborescence");
}
}
/// Autour de la bascule d'une mise à jour, le moteur qui tournait est arrêté
/// puis relancé (le Go pilote le service) ; une bascule sans réseau le
/// redémarre s'il tourne.
#[tokio::test]
async fn service_arrete_puis_relance() {
let fx = fixture();
let steps = fx["steps"].as_array().unwrap();
let step = |n: &str| steps.iter().find(|s| s["name"] == n).unwrap().clone();
let bn = bench().await;
let first = step("statut, moteur de l'image");
let files: serde_json::Map<String, Value> = first["files"]
.as_object()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v)))))
.collect();
apply_files(&bn.home, &Value::Object(files));
*bn.reg.lock().unwrap() = first["registry"].clone();
bn.set_local(&json!({ "down": true }));
*bn.go.service_active.lock().unwrap() = true;
assert_eq!(bn.call("POST", "/api/engine/update", "{}").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();
let bin = "BIN → {{HOME}}/engine/server-cuda-b10900/llama-server";
let at = lines.iter().position(|l| *l == bin).expect("BIN posé");
assert_eq!(lines[at - 1], "▶ arrêt du moteur…");
assert_eq!(lines[at + 1], "▶ redémarrage du moteur…");
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/engine/use", r#"{"tag":"image"}"#)
.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é : la mise à jour ne le relance pas.
*bn.go.service_active.lock().unwrap() = false;
bn.go.service_actions.lock().unwrap().clear();
let (c, _) = bn
.call(
"POST",
"/api/engine/update",
r#"{"tag":"server-cuda-b10900"}"#,
)
.await;
assert_eq!(c, 200);
bn.wait_job().await;
assert_eq!(
*bn.go.service_actions.lock().unwrap(),
vec![("stop".to_string(), true)]
);
let (_, job) = bn.call("GET", "/api/llamacpp/job", "").await;
assert!(
!job["lines"]
.as_array()
.unwrap()
.iter()
.any(|l| l.as_str().unwrap().contains("arrêt du moteur")),
"{job}"
);
}
/// Sans les backends dans la façade (pas de job), le panneau répond 503.
#[tokio::test]
async fn sans_backends() {
let tmp = tempfile::tempdir().unwrap();
let e = EngineImg::new(
tmp.path().to_path_buf(),
oci::Registry::new("http://127.0.0.1:9".into(), false),
true,
Arc::new(TplProbe::new()),
);
let (facade, _go, _stop, _) = fakego::start_with(
tmp.path().to_path_buf(),
fakego::Opts {
engineimg: Some(Arc::new(e)),
..Default::default()
},
)
.await;
let (c, b) = fakego::send(facade, "GET", "/api/engine", &[], "").await;
assert_eq!(c.as_u16(), 503, "{b:?}");
}
/// Le nouveau moteur ne répond pas à temps (build inchangé) : relevé
/// « unknown », sans comparaison.
#[tokio::test]
async fn rendu_non_verifie_a_temps() {
let fx = fixture();
let steps = fx["steps"].as_array().unwrap();
let first = steps[0].clone();
let bn = bench_with(Duration::from_millis(200)).await;
let files: serde_json::Map<String, Value> = first["files"]
.as_object()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v)))))
.collect();
apply_files(&bn.home, &Value::Object(files));
*bn.reg.lock().unwrap() = first["registry"].clone();
bn.set_local(&first["local"]);
assert_eq!(bn.call("POST", "/api/engine/update", "{}").await.0, 200);
bn.wait_job().await;
let c = bn.wait_render().await;
assert_eq!(c["status"], "unknown", "{c}");
assert_eq!(
c["detail"],
"le nouveau moteur n'a pas répondu à temps : rendu non vérifié"
);
assert_eq!(c["tag"], "server-cuda-b10900");
}
+1
View File
@@ -107,6 +107,7 @@ async fn banc(cle: Option<&str>) -> Banc {
mcp: None,
update: false,
backends: None,
engineimg: 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
@@ -113,6 +113,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
mcp: None,
update: false,
backends: None,
engineimg: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;