From b378311873dc813519baaa23be69849918c349c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 20:43:47 +0000 Subject: [PATCH] =?UTF-8?q?Moteur=20:=20mettre=20=C3=A0=20jour=20llama.cpp?= =?UTF-8?q?=20sans=20reconstruire=20l'image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit llama.cpp publie plusieurs versions par jour ; l'image de Loki ne se reconstruit qu'à une mise à jour de Loki. Le moteur y était donc figé à la date du dernier build, et le rattraper imposait un rebuild complet de 2,6 Go pour un composant qui en pèse 170 Mo. Le panneau « Moteur » ne le disait même pas : il annonçait « rien à mettre à jour ici ». Réglages → Moteur affiche maintenant la version qui tourne (bXXXXX et son commit, lus dans la bannière du binaire — jusqu'ici invisibles ailleurs que dans le journal du moteur) et la met à jour en un clic. Où le moteur est pris. Pas dans les releases GitHub de llama.cpp : elles ne contiennent AUCUN binaire CUDA pour Linux, et le mode « précompilé » retomberait sur Vulkan, donc sur une régression pour une carte NVIDIA. La seule distribution CUDA/Linux officielle et précompilée est l'image de conteneur — celle-là même dont l'image de Loki hérite. On lit son manifeste OCI et on ne télécharge que les couches qui portent /app, en descendant du sommet : le runtime CUDA (2 Go) et la base système sont déjà là. S'arrêter au binaire ne suffit pas — llama.cpp le range dans une couche et ses .so dans la précédente — d'où une règle d'arrêt sur « binaire + libggml-base + libllama », et un garde-fou de taille qui interdit de descendre jusqu'au runtime. Le moteur atterrit dans /data/engine//, donc sur le volume de données : il survit à un docker compose pull. La variante (CUDA, Vulkan, SYCL, MUSA, CPU) est déduite des backends ggml posés à côté du moteur courant — le conteneur ne sait pas de quelle image il vient, et faire retenir « server-cuda » à l'utilisateur serait un piège. Le risque, et ce qui le couvre. La mise à jour apporte llama.cpp, pas le runtime CUDA, qui reste celui de l'image : un llama.cpp compilé pour un CUDA plus récent ne chargerait pas son backend GPU. Le symptôme serait silencieux — tout marche, mais sur le processeur. Le nouveau moteur est donc lancé à blanc avant toute bascule ; il est refusé s'il ne démarre pas, ET s'il ne voit plus aucune carte alors que le moteur courant en voyait. Dans les deux cas le moteur courant n'est pas touché, et celui de l'image reste intact : « revenir au moteur de l'image » y ramène en un clic, sans réseau. Vérifié de bout en bout contre le vrai ghcr.io (166 Mo, 7 s, toutes les bibliothèques et leurs liens de version présents) et, pour les chemins d'échec, contre un faux registre. LOKI_OCI_REGISTRY permet de viser un miroir quand ghcr.io n'est pas joignable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd --- README.md | 40 +- internal/loki/backend_engine_oci.go | 650 +++++++++++++++++++ internal/loki/backend_engine_oci_net_test.go | 53 ++ internal/loki/backend_engine_oci_test.go | 390 +++++++++++ internal/loki/backend_prebuilt.go | 12 +- internal/loki/ui/index.html | 119 +++- internal/loki/ui/src/index.tmpl.html | 21 +- internal/loki/ui/src/js/05-status.js | 6 + internal/loki/ui/src/js/10-llamacpp.js | 88 ++- internal/loki/ui/src/styles.css | 4 + internal/loki/web_engine.go | 253 ++++++++ internal/loki/web_llamacpp.go | 13 +- internal/loki/web_server.go | 7 + 13 files changed, 1634 insertions(+), 22 deletions(-) create mode 100644 internal/loki/backend_engine_oci.go create mode 100644 internal/loki/backend_engine_oci_net_test.go create mode 100644 internal/loki/backend_engine_oci_test.go create mode 100644 internal/loki/web_engine.go diff --git a/README.md b/README.md index 39d5f77..8c8e87a 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ Retirées par ce fork : |---|---|---| | Installation | binaire + `sudo ajean install` (systemd) | `docker compose up` | | Moteur llama.cpp | compilé sur la machine (`ajean llamacpp install`) | image officielle llama.cpp (`server-cuda`), précompilée | -| Panneau « Moteur » | propose d'installer/compiler | annonce le moteur fourni par l'image, sans proposer d'install | +| Panneau « Moteur » | propose d'installer/compiler | affiche la version qui tourne et la met à jour en un clic (sans rebuild) | | Supervision moteur | systemd / launchd / PID (Windows) | fichier PID (`LOKI_CONTAINER=1`) | | Configuration initiale | `ajean edit` ($EDITOR) | entrypoint + `loki config set` | | Choix du modèle | lien Hugging Face collé à la main | recherche intégrée + verdict VRAM + projecteur lié | @@ -274,6 +274,44 @@ docker build --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda-b Aucune compilation de llama.cpp n'a lieu : le moteur est maintenu et précompilé par l'équipe amont (toutes architectures GPU courantes). +## Mettre à jour llama.cpp sans reconstruire l'image + +llama.cpp publie plusieurs versions par jour ; l'image de Loki, elle, ne se +reconstruit qu'à une mise à jour de Loki. Le moteur y était donc figé à la date +du dernier build, et le rattraper imposait un rebuild complet (2,6 Go) pour un +composant de 170 Mo. + +**Réglages → Moteur** affiche désormais la version de llama.cpp qui tourne +(`b10450`, avec son commit) et deux boutons : + +- **vérifier la version** — interroge le registre et dit s'il existe plus récent ; +- **mettre à jour le moteur** — télécharge `llama-server` et ses bibliothèques + depuis l'image officielle, `ghcr.io/ggml-org/llama.cpp:server-cuda`. + +Ce qui est téléchargé n'est **pas l'image** : un manifeste OCI liste ses couches, +et seules celles qui portent `/app` sont récupérées (~170 Mo). Le runtime CUDA +(2 Go) et la base système sont déjà dans l'image de Loki. Le moteur atterrit dans +`/data/engine//`, donc sur le volume de données : il survit à un +`docker compose pull`. + +La variante est déduite du moteur en place (CUDA, Vulkan, SYCL, MUSA ou CPU) : +une installation Vulkan ne se verra jamais proposer une image CUDA. + +**Le garde-fou.** La mise à jour apporte llama.cpp, pas le runtime CUDA, qui +reste celui de l'image. Un llama.cpp compilé pour un CUDA plus récent ne +chargerait pas son backend GPU ici — et le symptôme serait silencieux : tout +fonctionne, mais sur le processeur. Le nouveau moteur est donc lancé à blanc +avant toute bascule ; s'il ne démarre pas, ou s'il ne voit plus aucune carte +alors que le moteur courant en voyait, la mise à jour est refusée et le moteur +courant n'est pas touché. Le moteur livré par l'image reste par ailleurs intact : +**revenir au moteur de l'image** y ramène en un clic, sans réseau. + +Quand cette limite est atteinte pour de bon (CUDA majeur trop ancien), la +solution reste le rebuild avec un `LLAMACPP_IMAGE` récent, ci-dessus. + +Derrière un miroir de registre ou un réseau qui n'atteint pas ghcr.io : +`LOKI_OCI_REGISTRY=https://mon-miroir.interne` dans l'environnement du conteneur. + ## Licence MIT — © les contributeurs d'AJEAN (« Jean contributors ») pour le code amont, diff --git a/internal/loki/backend_engine_oci.go b/internal/loki/backend_engine_oci.go new file mode 100644 index 0000000..d11d84c --- /dev/null +++ b/internal/loki/backend_engine_oci.go @@ -0,0 +1,650 @@ +// backend_engine_oci.go — mise à jour du moteur llama.cpp SANS reconstruire +// l'image et sans compiler : on va chercher le moteur là où l'équipe llama.cpp +// le publie déjà tout compilé — dans son image de conteneur officielle — et on +// en extrait le seul dossier qui nous intéresse, /app. +// +// Pourquoi ce détour plutôt que les releases GitHub (backend_prebuilt.go) : +// llama.cpp ne publie AUCUN binaire CUDA pour Linux dans ses releases. La seule +// distribution CUDA/Linux officielle et précompilée, ce sont ces images +// (ghcr.io/ggml-org/llama.cpp:server-cuda-b#####). C'est aussi exactement ce +// dont l'image de Loki hérite : mettre à jour par ce chemin, c'est obtenir le +// même moteur qu'une reconstruction complète de l'image, en ~170 Mo au lieu de +// 2,6 Go et sans passer par un rebuild. +// +// Ce qu'on télécharge : PAS l'image. Un manifeste OCI liste ses couches ; le +// runtime CUDA (2 Go) et les paquets système sont déjà présents dans l'image de +// Loki, donc inutiles. Seules les couches du haut portent /app (llama-server et +// ses .so, ~170 Mo). On les prend en partant du sommet et on s'arrête dès qu'on +// tient un moteur complet. +// +// Ce que ça NE remplace PAS : le runtime CUDA, lui, vient de l'image. Un +// llama.cpp compilé pour un CUDA majeur plus récent que celui de l'image ne +// démarrera pas — d'où le test à blanc (--list-devices) avant toute bascule, et +// la possibilité de revenir au moteur d'origine en un clic. +package loki + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "time" +) + +// ociBase est une variable et non une constante pour que les tests puissent la +// pointer sur un faux registre : tout ce qui suit se joue dans le dialogue avec +// le registre, et le vérifier en frappant ghcr.io rendrait la CI dépendante du +// réseau. +// LOKI_OCI_REGISTRY le remplace : c'est la porte de sortie pour un réseau qui +// n'atteint pas ghcr.io directement — miroir d'entreprise, cache local — sans +// quoi la mise à jour du moteur y serait tout simplement impossible. +var ociBase = "https://ghcr.io" + +func init() { + if v := strings.TrimRight(strings.TrimSpace(os.Getenv("LOKI_OCI_REGISTRY")), "/"); v != "" { + if !strings.Contains(v, "://") { + v = "https://" + v + } + ociBase = v + } +} + +// ociHost : l'hôte seul, tel que l'attend le paramètre `service` du jeton. +func ociHost() string { + h := strings.TrimPrefix(strings.TrimPrefix(ociBase, "https://"), "http://") + return strings.TrimSuffix(h, "/") +} + +const ( + ociEngineRepo = "ggml-org/llama.cpp" + + // Au-delà de cette taille, une couche n'est plus une couche « applicative » : + // c'est le runtime CUDA (2 Go) ou une base système, qu'on ne veut à aucun + // prix télécharger. On descend les couches du sommet vers la base ; croiser + // une couche de cette taille signifie qu'on a dépassé /app. + ociLayerCap = 600 << 20 +) + +// ociLayer : une couche du manifeste (on n'a besoin que de ça). +type ociLayer struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +// ociDoc couvre à la fois un index multi-architecture et un manifeste d'image : +// les deux arrivent sur la même URL, distingués par le champ qui est rempli. +type ociDoc struct { + MediaType string `json:"mediaType"` + Manifests []struct { + Digest string `json:"digest"` + Platform struct { + Architecture string `json:"architecture"` + OS string `json:"os"` + } `json:"platform"` + } `json:"manifests"` + Layers []ociLayer `json:"layers"` + Annotations map[string]string `json:"annotations"` +} + +// ociAccept : tous les types de manifeste qu'on sait lire. Sans cet en-tête, le +// registre répond en schéma 1 (obsolète) et la lecture échoue sans expliquer. +const ociAccept = "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" + +// ociToken obtient un jeton de lecture ANONYME. Les images publiques de ghcr.io +// sont lisibles sans compte, mais le registre exige quand même un jeton porteur. +func ociToken(repo string) (string, error) { + u := fmt.Sprintf("%s/token?scope=repository:%s:pull&service=%s", ociBase, repo, ociHost()) + resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(u) + if err != nil { + return "", fmt.Errorf("registre %s injoignable : %w", ociHost(), err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("%s/token : HTTP %d", ociHost(), resp.StatusCode) + } + var out struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Token == "" { + return "", fmt.Errorf("jeton de lecture illisible") + } + return out.Token, nil +} + +// ociFetch fait un GET authentifié sur le registre. +func ociFetch(url, token, accept string) (*http.Response, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + if accept != "" { + req.Header.Set("Accept", accept) + } + return (&http.Client{Timeout: 0}).Do(req) +} + +// ociArch traduit GOARCH vers le nom de plateforme OCI. +func ociArch() string { + if runtime.GOARCH == "arm64" { + return "arm64" + } + return "amd64" +} + +// ociResolve lit le manifeste d'un tag et renvoie les couches de l'image pour +// CETTE architecture, plus le numéro de build annoncé par l'image +// (org.opencontainers.image.version, ex. « b10450 »). +func ociResolve(repo, tag string) (layers []ociLayer, version string, err error) { + token, err := ociToken(repo) + if err != nil { + return nil, "", err + } + get := func(ref string) (*ociDoc, error) { + resp, err := ociFetch(fmt.Sprintf("%s/v2/%s/manifests/%s", ociBase, repo, ref), token, ociAccept) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == 404 { + return nil, fmt.Errorf("version inconnue dans le registre : %s", ref) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("manifeste %s : HTTP %d", ref, resp.StatusCode) + } + var d ociDoc + if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { + return nil, fmt.Errorf("manifeste %s illisible : %w", ref, err) + } + return &d, nil + } + + doc, err := get(tag) + if err != nil { + return nil, "", err + } + version = doc.Annotations["org.opencontainers.image.version"] + if len(doc.Manifests) > 0 { + want := ociArch() + digest := "" + for _, m := range doc.Manifests { + if m.Platform.OS == "linux" && m.Platform.Architecture == want { + digest = m.Digest + break + } + } + if digest == "" { + return nil, "", fmt.Errorf("aucune image linux/%s pour %s", want, tag) + } + if doc, err = get(digest); err != nil { + return nil, "", err + } + if v := doc.Annotations["org.opencontainers.image.version"]; v != "" { + version = v + } + } + if len(doc.Layers) == 0 { + return nil, "", fmt.Errorf("manifeste %s sans couche", tag) + } + return doc.Layers, version, nil +} + +// --------------------------------------------------------------------------- +// Variante, version et emplacement du moteur +// --------------------------------------------------------------------------- + +// engineOCISupported : ce mécanisme n'a de sens que là où ces images tournent. +// Les images llama.cpp sont linux/amd64 et linux/arm64, rien d'autre. +func engineOCISupported() bool { + return runtime.GOOS == "linux" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64") +} + +// engineVariant déduit la variante d'image correspondant au moteur COURANT, en +// regardant les backends ggml posés à côté du binaire. C'est la seule source +// fiable : le conteneur ne sait pas de quelle image il a été bâti, et demander +// à l'utilisateur de retenir « server-cuda » serait un piège à erreur. +// +// Renvoie un préfixe de tag : "server-cuda", "server-vulkan", "server-intel", +// "server-musa", ou "server" (CPU) par défaut. +func engineVariant() string { + dir := "" + if bin := currentEngineBin(); bin != "" { + dir = filepath.Dir(bin) + } + if dir == "" { + return "server" + } + has := func(prefix string) bool { + m, _ := filepath.Glob(filepath.Join(dir, prefix+"*")) + return len(m) > 0 + } + switch { + case has("libggml-cuda.so"): + return "server-cuda" + case has("libggml-musa.so"): + return "server-musa" + case has("libggml-sycl.so"): + return "server-intel" + case has("libggml-vulkan.so"): + return "server-vulkan" + } + return "server" +} + +// currentEngineBin : le binaire réellement utilisé (BIN de la configuration, +// avec le repli sur le moteur de l'image quand BIN n'est pas encore posé). +func currentEngineBin() string { + bin := prebuiltResolveBin(ReadConfig()["BIN"]) + if bin != "" && isFile(bin) { + return bin + } + return providedEngineBin() +} + +// La bannière de version de llama.cpp a changé de forme plusieurs fois, et un +// binaire ancien reste parfaitement utilisable : on lit les trois formes. +// +// version: 0.1.0-dev (build 10450, commit ece963f41) ← actuelle +// version: 10450 (ece963f) ← précédente +// build: 4589 (a1b2c3d) ← plus ancienne +// +// C'est le NUMÉRO DE BUILD qui compte : c'est lui qui figure dans les tags +// d'image (server-cuda-b10450) et donc la seule chose comparable entre ce qui +// tourne et ce qui est publié. +var reEngineBuildLong = regexp.MustCompile(`build\s+(\d+),\s*commit\s+([0-9a-fA-F]+)`) +var reEngineBuildShort = regexp.MustCompile(`(?m)^\s*(?:version|build)\s*:\s*(\d+)\s*\(([0-9a-fA-F]+)\)`) + +func parseEngineBuild(out string) (build int, commit string) { + for _, re := range []*regexp.Regexp{reEngineBuildLong, reEngineBuildShort} { + if m := re.FindStringSubmatch(out); m != nil { + n, _ := strconv.Atoi(m[1]) + return n, m[2] + } + } + return 0, "" +} + +// engineBuildOf interroge un llama-server pour son numéro de build. Renvoie +// (0, "") si le binaire ne répond pas — un moteur cassé ne doit pas empêcher +// d'en installer un autre. +func engineBuildOf(bin string) (int, string) { + if bin == "" || !isFile(bin) { + return 0, "" + } + cmd := hideCmd(exec.Command(bin, "--version")) + cmd.Env = libraryPathEnv(filepath.Dir(bin)) + out, _ := cmd.CombinedOutput() // --version sort parfois en code non nul + return parseEngineBuild(string(out)) +} + +// engineDir : racine des moteurs installés par ce mécanisme, sous LOKI_HOME — +// c'est-à-dire sur le volume /data en conteneur, donc conservés d'un +// redémarrage (et d'une mise à jour d'image) à l'autre. +func engineDir() string { return filepath.Join(LokiHome(), "engine") } + +// engineOwns dit si un chemin désigne un moteur installé ici. +func engineOwns(p string) bool { + if p == "" { + return false + } + norm := func(s string) string { return strings.ToLower(filepath.ToSlash(filepath.Clean(s))) } + return strings.HasPrefix(norm(p), norm(engineDir())+"/") +} + +// engineInstalled liste les moteurs présents sous engineDir(), du plus ancien +// au plus récent (ordre des numéros de build). +func engineInstalled() []string { + entries, err := os.ReadDir(engineDir()) + if err != nil { + return nil + } + var tags []string + for _, e := range entries { + if e.IsDir() && isFile(filepath.Join(engineDir(), e.Name(), "llama-server")) { + tags = append(tags, e.Name()) + } + } + sort.Slice(tags, func(i, j int) bool { return engineTagBuild(tags[i]) < engineTagBuild(tags[j]) }) + return tags +} + +var reTagBuild = regexp.MustCompile(`-b(\d+)$`) + +// engineTagBuild extrait le numéro de build d'un tag (« server-cuda-b10450 » → +// 10450), 0 si le tag n'en porte pas (tag mouvant « server-cuda »). +func engineTagBuild(tag string) int { + m := reTagBuild.FindStringSubmatch(tag) + if m == nil { + return 0 + } + n, _ := strconv.Atoi(m[1]) + return n +} + +// engineTagOf : le tag d'un moteur téléchargé ici (« server-cuda-b10450 »), "" +// pour tout autre chemin. +func engineTagOf(bin string) string { + if !engineOwns(bin) { + return "" + } + return filepath.Base(filepath.Dir(bin)) +} + +// engineBinFor : chemin du llama-server d'une version installée. +func engineBinFor(tag string) string { + p := filepath.Join(engineDir(), filepath.Base(tag), "llama-server") + if isFile(p) { + return p + } + return "" +} + +// enginePrune supprime les versions installées autres que celles à garder. +// Chaque version pèse ~450 Mo une fois décompressée : en laisser traîner cinq +// remplit le volume /data sans rien apporter. +func enginePrune(keep map[string]bool, logf func(string)) { + for _, tag := range engineInstalled() { + if keep[tag] { + continue + } + if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err == nil && logf != nil { + logf("ancienne version supprimée : " + tag) + } + } +} + +// --------------------------------------------------------------------------- +// Installation +// --------------------------------------------------------------------------- + +// engineOCIInstall télécharge le moteur du tag demandé et l'installe dans +// engineDir()/. Renvoie le chemin du llama-server installé. +// +// Réentrant : une version déjà installée est renvoyée telle quelle, et une +// installation interrompue laisse un dossier « .part » qui est repris de zéro. +func engineOCIInstall(tag string, logf, phasef func(string)) (string, error) { + tag = filepath.Base(strings.TrimSpace(tag)) // jamais de séparateur dans un nom de dossier + if tag == "" || tag == "." { + return "", fmt.Errorf("version du moteur non précisée") + } + if bin := engineBinFor(tag); bin != "" { + logf("déjà installé : " + tag) + return bin, nil + } + + phasef("lecture du manifeste " + tag + "…") + layers, version, err := ociResolve(ociEngineRepo, tag) + if err != nil { + return "", err + } + if version != "" { + logf(fmt.Sprintf("image %s:%s — llama.cpp %s, %d couches", ociEngineRepo, tag, version, len(layers))) + } + + dest := filepath.Join(engineDir(), tag) + tmp := dest + ".part" + if err := os.RemoveAll(tmp); err != nil { + return "", err + } + if err := os.MkdirAll(tmp, 0o755); err != nil { + return "", err + } + defer os.RemoveAll(tmp) // no-op après le rename final + + token, err := ociToken(ociEngineRepo) + if err != nil { + return "", err + } + + // On descend du SOMMET vers la base : /app est ajouté en dernier dans le + // Dockerfile amont, et les couches du bas sont le runtime CUDA et le système, + // déjà présents ici. Une couche du haut peut masquer un fichier d'une couche + // plus basse : le premier extrait gagne, comme le ferait un runtime OCI. + seen := map[string]bool{} + var links []archiveLink + for i := len(layers) - 1; i >= 0; i-- { + l := layers[i] + if l.Size > ociLayerCap { + logf(fmt.Sprintf("couche %d ignorée (%d Mo) : c'est le runtime de l'image, pas le moteur", i, l.Size/1_000_000)) + break + } + part := filepath.Join(tmp, "layer.tgz") + if l.Size > 4<<20 { + phasef(fmt.Sprintf("téléchargement du moteur (%d Mo)…", l.Size/1_000_000)) + } + if err := ociDownloadBlob(ociEngineRepo, l.Digest, token, part, l.Size, logf); err != nil { + return "", fmt.Errorf("téléchargement de la couche %d : %w", i, err) + } + n, err := extractAppLayer(part, tmp, seen, &links) + _ = os.Remove(part) + if err != nil { + return "", fmt.Errorf("extraction de la couche %d : %w", i, err) + } + if n > 0 { + logf(fmt.Sprintf("couche %d : %d fichiers du moteur", i, n)) + } + if engineSetComplete(seen) { + break + } + } + if err := applyLinks(links); err != nil { + return "", err + } + if !seen["llama-server"] { + return "", fmt.Errorf("llama-server absent de l'image %s — variante inattendue ?", tag) + } + + if err := os.RemoveAll(dest); err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return "", err + } + if err := os.Rename(tmp, dest); err != nil { + return "", err + } + bin := filepath.Join(dest, "llama-server") + if err := os.Chmod(bin, 0o755); err != nil { + return "", err + } + logf("moteur installé : " + bin) + return bin, nil +} + +// engineSetComplete : a-t-on de quoi lancer le moteur ? Le binaire seul ne +// suffit pas — depuis que llama.cpp éclate son code en bibliothèques, l'exécutable +// tient dans une couche et ses .so dans la précédente. S'arrêter au binaire +// donnait un moteur qui meurt sur « libllama.so: cannot open shared object ». +func engineSetComplete(seen map[string]bool) bool { + if !seen["llama-server"] { + return false + } + base, llama := false, false + for name := range seen { + switch { + case strings.HasPrefix(name, "libggml-base.so"): + base = true + case strings.HasPrefix(name, "libllama.so"): + llama = true + } + } + return base && llama +} + +// ociDownloadBlob télécharge une couche vers dest, avec la progression du +// téléchargement dans le journal du job. +func ociDownloadBlob(repo, digest, token, dest string, size int64, logf func(string)) error { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/v2/%s/blobs/%s", ociBase, repo, digest), nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + return downloadRequest(req, dest, size, logf) +} + +// extractAppLayer extrait les entrées « app/… » d'une couche (tar.gz) à PLAT +// dans dir, en ignorant celles déjà vues (une couche supérieure fait foi) et +// les marqueurs de suppression OCI (.wh.*). Renvoie le nombre de fichiers +// extraits. Les liens sont accumulés dans links : leur cible n'est pas +// forcément déjà extraite au moment où on croise l'entrée. +func extractAppLayer(archive, dir string, seen map[string]bool, links *[]archiveLink) (int, error) { + f, err := os.Open(archive) + if err != nil { + return 0, err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return 0, err + } + defer gz.Close() + + count := 0 + tr := tar.NewReader(gz) + for { + h, err := tr.Next() + if err == io.EOF { + return count, nil + } + if err != nil { + return count, err + } + name, ok := appEntryName(h.Name) + if !ok || seen[name] { + continue + } + p := filepath.Join(dir, name) + switch h.Typeflag { + case tar.TypeReg: + w, err := os.Create(p) + if err != nil { + return count, err + } + if _, err := io.Copy(w, tr); err != nil { + w.Close() + return count, err + } + w.Close() + _ = os.Chmod(p, os.FileMode(h.Mode)&0o777) + seen[name] = true + count++ + case tar.TypeSymlink, tar.TypeLink: + // Indispensable : les .so sont livrés sous leur nom versionné + // (libllama.so.0.1.0) plus deux liens (libllama.so.0, libllama.so) — + // c'est le nom court que l'éditeur de liens cherche au lancement. + target, ok := appEntryName(h.Linkname) + if !ok { + target = filepath.Base(h.Linkname) // lien relatif au sein de /app + } + *links = append(*links, archiveLink{path: p, target: target}) + seen[name] = true + count++ + } + } +} + +// appEntryName ramène « app/libllama.so » à « libllama.so ». Renvoie ok=false +// pour tout ce qui n'est pas un fichier direct de /app : autres dossiers, +// sous-dossiers, et marqueurs de suppression OCI. +func appEntryName(raw string) (string, bool) { + p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "./") + rest, ok := strings.CutPrefix(p, "app/") + if !ok || rest == "" || strings.Contains(rest, "/") { + return "", false + } + if strings.HasPrefix(rest, ".wh.") { + return "", false + } + return rest, true +} + +// --------------------------------------------------------------------------- +// Vérification et bascule +// --------------------------------------------------------------------------- + +// engineOCILatest renvoie le dernier build publié pour la variante (numéro et +// tag figé correspondant). On lit le tag MOUVANT (« server-cuda ») et son +// annotation de version plutôt que de lister les 1000 tags du dépôt : une seule +// requête, et c'est l'amont qui dit ce qu'est « la dernière ». +func engineOCILatest(variant string) (build int, tag string, err error) { + _, version, err := ociResolve(ociEngineRepo, variant) + if err != nil { + return 0, "", err + } + if version == "" { + // Pas d'annotation : on retombe sur le tag mouvant lui-même, qui pointe + // bien sur la dernière image — on ne saura juste pas dire son numéro. + return 0, variant, nil + } + // L'annotation vaut « b10450 » : le tag figé est « server-cuda-b10450 ». + n, _ := strconv.Atoi(strings.TrimPrefix(version, "b")) + return n, variant + "-" + version, nil +} + +// engineSmokeTest lance à blanc le moteur fraîchement installé — c'est là que +// se joue le seul vrai risque de ce mécanisme. La mise à jour apporte llama.cpp, +// PAS le runtime CUDA, qui reste celui de l'image : un llama.cpp compilé pour un +// CUDA plus récent ne chargera pas son backend GPU ici. +// +// Deux échecs distincts, et le second est le sournois : +// +// 1. le binaire ne démarre pas du tout (bibliothèque manquante) — bruyant ; +// 2. il démarre mais ne voit plus AUCUNE carte, alors que le moteur courant en +// voyait. Rien ne casse : le modèle se charge, tourne sur le processeur, et +// on ne s'en aperçoit qu'au débit. C'est le pire résultat possible, donc on +// le traite comme un échec de mise à jour. +// +// Ne voir aucune carte n'est un échec que si le moteur courant en voyait : +// sur une machine sans GPU, « aucune carte » est le résultat normal. +func engineSmokeTest(bin, ancien string) error { + devs, err := engineListDevices(bin) + if err != nil { + return err + } + if len(devs) > 0 { + return nil + } + if ancien == "" || ancien == bin { + return nil + } + avant, err := engineListDevices(ancien) + if err != nil || len(avant) == 0 { + return nil // le moteur courant n'en voyait pas plus : rien à reprocher au nouveau + } + return fmt.Errorf("le moteur téléchargé ne voit aucune carte graphique, alors que le moteur actuel en voit %d — "+ + "il tournerait sur le processeur, beaucoup plus lentement", len(avant)) +} + +// engineListDevices lance `llama-server --list-devices` sur un binaire donné. +func engineListDevices(bin string) ([]map[string]any, error) { + cmd := hideCmd(exec.Command(bin, "--list-devices")) + cmd.Env = libraryPathEnv(filepath.Dir(bin)) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("le moteur ne démarre pas ici : %v\n%s", err, tailLines(string(out), 12)) + } + return parseListDevices(string(out)), nil +} + +// tailLines garde les n dernières lignes non vides d'une sortie. +func tailLines(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} diff --git a/internal/loki/backend_engine_oci_net_test.go b/internal/loki/backend_engine_oci_net_test.go new file mode 100644 index 0000000..d3303e8 --- /dev/null +++ b/internal/loki/backend_engine_oci_net_test.go @@ -0,0 +1,53 @@ +package loki + +// Vérification contre le VRAI registre. Ignoré par défaut (la CI ne doit pas +// dépendre du réseau ni télécharger 170 Mo) : +// +// LOKI_NET_TEST=1 go test ./internal/loki -run TestMoteurReelDepuisGhcr -v +// +// Ce test existe parce que le faux registre ne peut pas garantir une chose : que +// la DISPOSITION de l'image amont est toujours celle qu'on suppose. Si llama.cpp +// range un jour son moteur autrement — sous-dossier, couche gigantesque, autre +// jeu de bibliothèques — c'est ici qu'on le verra, et pas chez l'utilisateur. + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMoteurReelDepuisGhcr(t *testing.T) { + if os.Getenv("LOKI_NET_TEST") == "" { + t.Skip("test réseau — LOKI_NET_TEST=1 pour l'exécuter") + } + if os.Getenv("LOKI_HOME") == "" { + t.Setenv("LOKI_HOME", t.TempDir()) + } + + build, tag, err := engineOCILatest("server-cuda") + if err != nil { + t.Fatal(err) + } + t.Logf("dernier build publié : %d → %s", build, tag) + if build == 0 { + t.Error("numéro de build absent de l'annotation de l'image") + } + + bin, err := engineOCIInstall(tag, func(s string) { t.Log(s) }, func(s string) { t.Log("▶ " + s) }) + if err != nil { + t.Fatal(err) + } + dir := filepath.Dir(bin) + // Ce que l'éditeur de liens réclamera au lancement. Un seul manquant et le + // moteur meurt sur « cannot open shared object file ». + for _, n := range []string{"libllama.so", "libggml.so", "libggml-base.so", "libggml-cuda.so", "libmtmd.so"} { + if _, err := os.Stat(filepath.Join(dir, n)); err != nil { + t.Errorf("%s : %v", n, err) + } + } + // Et le binaire doit savoir dire sa version : c'est ce qui pilote la + // comparaison « installé vs publié » de tout le panneau. + if got, commit := engineBuildOf(bin); got != build { + t.Errorf("le moteur installé annonce le build %d (%s), attendu %d", got, commit, build) + } +} diff --git a/internal/loki/backend_engine_oci_test.go b/internal/loki/backend_engine_oci_test.go new file mode 100644 index 0000000..0b9dad1 --- /dev/null +++ b/internal/loki/backend_engine_oci_test.go @@ -0,0 +1,390 @@ +package loki + +// Mise à jour du moteur depuis l'image officielle llama.cpp. +// +// Ce qui se joue ici n'est pas « est-ce qu'on télécharge » mais « est-ce qu'on +// télécharge le MINIMUM et est-ce que ce qu'on pose est LANÇABLE ». Deux pièges +// concrets, tous deux invisibles sans test : +// +// - l'image officielle range llama-server dans une couche et ses .so dans la +// précédente ; s'arrêter au binaire donne un moteur qui meurt sur +// « libllama.so: cannot open shared object file » ; +// - la couche du dessous est le runtime CUDA (2 Go). La descendre par erreur, +// c'est transformer une mise à jour de 170 Mo en un téléchargement de 2 Go. + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// --- Faux registre OCI ------------------------------------------------------ + +// fauxCouche fabrique une couche tar.gz. Les entrées sont décrites par leur nom +// complet dans l'archive ; une valeur préfixée par « -> » crée un lien. +func fauxCouche(t *testing.T, entrees map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for nom, contenu := range entrees { + if cible, ok := strings.CutPrefix(contenu, "-> "); ok { + if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeSymlink, Linkname: cible, Mode: 0o777}); err != nil { + t.Fatal(err) + } + continue + } + if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeReg, Size: int64(len(contenu)), Mode: 0o755}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(contenu)); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// fauxRegistre sert un index multi-architecture, le manifeste amd64 et les +// blobs. `tailles` permet d'annoncer une taille différente de la taille réelle +// (c'est ce que lit le garde-fou des grosses couches). Renvoie le compteur des +// blobs réellement téléchargés. +func fauxRegistre(t *testing.T, version string, couches [][]byte, tailles []int64) (servis map[string]int) { + t.Helper() + servis = map[string]int{} + digests := make([]string, len(couches)) + blobs := map[string][]byte{} + for i, c := range couches { + sum := sha256.Sum256(c) + digests[i] = "sha256:" + hex.EncodeToString(sum[:]) + blobs[digests[i]] = c + } + manifeste := map[string]any{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "layers": func() []map[string]any { + var out []map[string]any + for i := range couches { + taille := int64(len(couches[i])) + if tailles != nil && tailles[i] > 0 { + taille = tailles[i] + } + out = append(out, map[string]any{ + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": digests[i], "size": taille, + }) + } + return out + }(), + } + manBytes, _ := json.Marshal(manifeste) + manSum := sha256.Sum256(manBytes) + manDigest := "sha256:" + hex.EncodeToString(manSum[:]) + + index := map[string]any{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": []map[string]any{ + // L'architecture qui ne nous concerne pas vient EN PREMIER : choisir + // « le premier manifeste » au lieu de filtrer sur la plateforme + // installerait un moteur d'une autre architecture. + {"digest": "sha256:mauvaise", "platform": map[string]string{"os": "linux", "architecture": "ppc64le"}}, + {"digest": manDigest, "platform": map[string]string{"os": "linux", "architecture": ociArch()}}, + }, + "annotations": map[string]string{"org.opencontainers.image.version": version}, + } + indexBytes, _ := json.Marshal(index) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/token"): + _, _ = w.Write([]byte(`{"token":"jeton-de-test"}`)) + case strings.Contains(r.URL.Path, "/manifests/"): + if r.Header.Get("Authorization") != "Bearer jeton-de-test" { + w.WriteHeader(401) + return + } + ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + if ref == manDigest { + _, _ = w.Write(manBytes) + return + } + _, _ = w.Write(indexBytes) + case strings.Contains(r.URL.Path, "/blobs/"): + ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + b, ok := blobs[ref] + if !ok { + w.WriteHeader(404) + return + } + servis[ref]++ + _, _ = w.Write(b) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(srv.Close) + + ancien := ociBase + ociBase = srv.URL + t.Cleanup(func() { ociBase = ancien }) + return servis +} + +// --- Tests ------------------------------------------------------------------ + +// Le cas réel : trois couches utiles au-dessus d'un runtime de 2 Go. On doit +// prendre le binaire ET les bibliothèques, résoudre les liens de version, et +// ne JAMAIS descendre jusqu'au runtime. +func TestInstallationMoteurDepuisImage(t *testing.T) { + testHome(t) + couches := [][]byte{ + fauxCouche(t, map[string]string{"usr/local/cuda/lib64/libcudart.so": "runtime CUDA"}), // 2 Go annoncés + fauxCouche(t, map[string]string{ + "app/libggml-base.so": "-> libggml-base.so.0", + "app/libggml-base.so.0": "-> libggml-base.so.0.20.0", + "app/libggml-base.so.0.20.0": "binaire ELF", + "app/libllama.so": "-> libllama.so.0", + "app/libllama.so.0": "-> libllama.so.0.1.0", + "app/libllama.so.0.1.0": "binaire ELF", + "app/libggml-cuda.so": "binaire ELF", + }), + fauxCouche(t, map[string]string{"app/llama-server": "#!/bin/true\n", "app/llama": "#!/bin/true\n"}), + fauxCouche(t, map[string]string{"etc/passwd": "sans rapport"}), + } + servis := fauxRegistre(t, "b10450", couches, []int64{2 << 30, 0, 0, 0}) + + bin, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {}) + if err != nil { + t.Fatal(err) + } + if veut := filepath.Join(engineDir(), "server-cuda-b10450", "llama-server"); bin != veut { + t.Errorf("binaire installé en %s, attendu %s", bin, veut) + } + + // La couche du runtime CUDA ne doit pas avoir été demandée : c'est tout + // l'intérêt (170 Mo au lieu de 2,6 Go). + sum := sha256.Sum256(couches[0]) + if n := servis["sha256:"+hex.EncodeToString(sum[:])]; n != 0 { + t.Errorf("la couche du runtime CUDA a été téléchargée %d fois", n) + } + + dir := filepath.Dir(bin) + for _, nom := range []string{"llama-server", "llama", "libggml-cuda.so", "libggml-base.so.0.20.0", "libllama.so.0.1.0"} { + if !isFile(filepath.Join(dir, nom)) { + t.Errorf("%s manquant", nom) + } + } + // Les noms courts sont ceux que l'éditeur de liens cherche au lancement : + // sans eux le moteur ne démarre pas, alors que tout « a l'air » installé. + for _, lien := range []string{"libggml-base.so", "libggml-base.so.0", "libllama.so", "libllama.so.0"} { + p := filepath.Join(dir, lien) + if _, err := os.Stat(p); err != nil { // Stat suit le lien : vérifie aussi qu'il ne pend pas + t.Errorf("%s : %v", lien, err) + } + } + // /app à plat, et rien d'autre que /app. + if isDir(filepath.Join(dir, "etc")) || isDir(filepath.Join(dir, "usr")) { + t.Error("des fichiers hors /app ont été extraits") + } + if st, err := os.Stat(bin); err != nil || st.Mode()&0o100 == 0 { + t.Errorf("llama-server non exécutable : %v", err) + } + + // Réinstaller la même version ne redemande RIEN au registre : le job de mise + // à jour repasse par là à chaque vérification. + total := func() int { + n := 0 + for _, v := range servis { + n += v + } + return n + } + avant := total() + if _, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {}); err != nil { + t.Fatal(err) + } + if total() != avant { + t.Errorf("une version déjà installée a été re-téléchargée (%d → %d couches)", avant, total()) + } + + // Et la version se retrouve dans la liste des moteurs installés. + if got := engineInstalled(); len(got) != 1 || got[0] != "server-cuda-b10450" { + t.Errorf("versions installées = %v", got) + } + if engineTagBuild("server-cuda-b10450") != 10450 { + t.Error("numéro de build mal extrait du tag") + } + if !engineOwns(bin) { + t.Error("le binaire installé n'est pas reconnu comme géré par Loki") + } +} + +// Une variante dont l'image ne contient pas de llama-server (mauvais tag, image +// « full » sans serveur) doit échouer CLAIREMENT, et ne rien laisser derrière +// elle qui ressemblerait à un moteur installé. +func TestInstallationRefuseUneImageSansMoteur(t *testing.T) { + testHome(t) + fauxRegistre(t, "b10450", [][]byte{ + fauxCouche(t, map[string]string{"app/README": "pas de moteur ici"}), + }, nil) + + if _, err := engineOCIInstall("server-b1", func(string) {}, func(string) {}); err == nil { + t.Fatal("installation acceptée sans llama-server") + } + if got := engineInstalled(); len(got) != 0 { + t.Errorf("versions installées après échec = %v", got) + } +} + +// engineOCILatest lit le numéro de build dans l'annotation de l'image, et en +// déduit le tag figé — c'est lui qu'on installe, pour que « mis à jour vers » +// désigne une version précise et reproductible plutôt qu'un tag mouvant. +func TestDerniereVersionPubliee(t *testing.T) { + fauxRegistre(t, "b10450", [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil) + build, tag, err := engineOCILatest("server-cuda") + if err != nil { + t.Fatal(err) + } + if build != 10450 || tag != "server-cuda-b10450" { + t.Errorf("build=%d tag=%q", build, tag) + } +} + +// La bannière du moteur est la seule source de vérité sur la version qui tourne +// vraiment — le tag de l'image, lui, ment dès qu'on a basculé de moteur. +func TestLectureVersionMoteur(t *testing.T) { + for _, c := range []struct { + nom, sortie string + build int + commit string + }{ + // Relevée sur le binaire réel de l'image server-cuda-b10450. + {"forme actuelle", "version: 0.1.0-dev (build 10450, commit ece963f41)\nbuilt with GNU 14.2.0 for Linux x86_64\n", 10450, "ece963f41"}, + {"forme précédente", "version: 10450 (ece963f)\nbuilt with cc\n", 10450, "ece963f"}, + {"forme ancienne", "build: 4589 (a1b2c3d)\n", 4589, "a1b2c3d"}, + {"bannière de démarrage", "ggml_cuda_init: found 1 device\nversion: 9001 (deadbee)\n", 9001, "deadbee"}, + {"rien d'exploitable", "usage: llama-server [options]\n", 0, ""}, + } { + t.Run(c.nom, func(t *testing.T) { + build, commit := parseEngineBuild(c.sortie) + if build != c.build || commit != c.commit { + t.Errorf("= (%d, %q), attendu (%d, %q)", build, commit, c.build, c.commit) + } + }) + } +} + +// Le filtre des entrées d'archive : on ne veut QUE les fichiers directs de /app. +// Un sous-dossier ou un marqueur de suppression OCI recopié à plat produirait +// des fichiers parasites à côté du binaire. +func TestFiltreDesEntreesApp(t *testing.T) { + for _, c := range []struct { + entree, veut string + ok bool + }{ + {"app/llama-server", "llama-server", true}, + {"./app/libllama.so", "libllama.so", true}, + {"app/", "", false}, + {"app/tools/quantize", "", false}, + {"app/.wh.libggml-cuda.so", "", false}, + {"usr/local/cuda/lib64/libcudart.so", "", false}, + {"apparence/piege", "", false}, + } { + got, ok := appEntryName(c.entree) + if ok != c.ok || got != c.veut { + t.Errorf("%q → (%q, %v), attendu (%q, %v)", c.entree, got, ok, c.veut, c.ok) + } + } +} + +// Le binaire seul ne fait pas un moteur : c'est la règle d'arrêt de la descente +// des couches. +func TestMoteurCompletExigeLesBibliotheques(t *testing.T) { + vu := func(noms ...string) map[string]bool { + m := map[string]bool{} + for _, n := range noms { + m[n] = true + } + return m + } + if engineSetComplete(vu("llama-server")) { + t.Error("binaire seul accepté comme moteur complet") + } + if engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0")) { + t.Error("moteur sans libllama accepté") + } + if !engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0", "libllama.so.0.1.0")) { + t.Error("moteur complet refusé") + } +} + +// La variante se déduit des backends posés à côté du binaire : c'est ce qui +// évite de proposer une image CUDA à une installation Vulkan (et l'inverse). +func TestVarianteDeduiteDesBackends(t *testing.T) { + for _, c := range []struct{ backend, veut string }{ + {"libggml-cuda.so", "server-cuda"}, + {"libggml-vulkan.so", "server-vulkan"}, + {"libggml-sycl.so", "server-intel"}, + {"libggml-musa.so", "server-musa"}, + {"", "server"}, + } { + t.Run(c.veut, func(t *testing.T) { + home := testHome(t) + dir := filepath.Join(home, "faux-moteur") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + bin := filepath.Join(dir, "llama-server") + if err := os.WriteFile(bin, []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + if c.backend != "" { + if err := os.WriteFile(filepath.Join(dir, c.backend), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := SetConfigKey("BIN", bin); err != nil { + t.Fatal(err) + } + if got := engineVariant(); got != c.veut { + t.Errorf("variante = %q, attendu %q", got, c.veut) + } + }) + } +} + +// Le ménage garde la version demandée et efface les autres : sans lui, chaque +// mise à jour laisse ~450 Mo sur le volume de données. +func TestMenageDesAnciennesVersions(t *testing.T) { + testHome(t) + for _, tag := range []string{"server-cuda-b1", "server-cuda-b2", "server-cuda-b3"} { + d := filepath.Join(engineDir(), tag) + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + } + enginePrune(map[string]bool{"server-cuda-b3": true}, nil) + got := engineInstalled() + if fmt.Sprint(got) != "[server-cuda-b3]" { + t.Errorf("après ménage : %v", got) + } +} diff --git a/internal/loki/backend_prebuilt.go b/internal/loki/backend_prebuilt.go index 94654d6..6488521 100644 --- a/internal/loki/backend_prebuilt.go +++ b/internal/loki/backend_prebuilt.go @@ -440,8 +440,18 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) { // downloadWithProgress télécharge url vers dest en journalisant la progression // par tranches de ~25 Mo. func downloadWithProgress(url, dest string, total int64, logf func(string)) error { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + return downloadRequest(req, dest, total, logf) +} + +// downloadRequest est 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. +func downloadRequest(req *http.Request, dest string, total int64, logf func(string)) error { client := &http.Client{Timeout: 0} - resp, err := client.Get(url) + resp, err := client.Do(req) if err != nil { return err } diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 1809afc..1b0b1e4 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -1028,6 +1028,10 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} .mcp-name{font-size:12px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .mcp-meta{display:flex;align-items:center;gap:6px;margin-top:2px} .mcp-tag{font-size:10px;padding:1px 7px;border-radius:9px;border:1px solid var(--border);color:var(--dim);text-transform:uppercase;letter-spacing:.04em} +/* Version du moteur : même pastille, mais SANS majuscules forcées — un numéro + de build et un hash de commit se recopient et se comparent à l'œil, et + « AAAA111 » n'est pas le commit « aaaa111 ». Le monospace vient de là aussi. */ +.eng-ver{font-family:var(--mono);text-transform:none;letter-spacing:0;color:var(--text)} .mcp-tools{font-size:10px;color:var(--accent)} .mcp-err{font-size:10px;color:var(--err)} .mcp-switch{flex:none} @@ -2041,13 +2045,24 @@ html[data-files="1"] #files-btn{color:var(--accent)} même sujet. Les trois modes sont des LIGNES de cette carte (elles étaient des cartes dans une carte). -->
- +