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). -->
- +