diff --git a/README.md b/README.md index cbdd381..ced6484 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go | | `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go | | `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels ; les tours de la façade appellent son pool directement (expérimental, migration en cours) | Go | -| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust ; les jobs du Go (compilation, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go | +| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust, qui compile aussi llama.cpp depuis les sources sous Linux et macOS ; les jobs du Go (compilation sous Windows, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go | | `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go | | `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; les outils task_* de l'agent aussi, dans les tours de la façade ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go | | `LOKI_BENCH` | `rust` : le banc d'essai — lancement, progression, annulation, dernière mesure, mode rapide et mode complet en profondeur — est tenu par la façade Rust ; le verrou de génération reste celui du Go, pris par le bail du moteur (expérimental, migration en cours) | Go | diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot41-compilation-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot41-compilation-design.md new file mode 100644 index 0000000..2634532 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot41-compilation-design.md @@ -0,0 +1,133 @@ +# Lot 41 — Compilation de llama.cpp depuis les sources dans la façade + +## Contexte + +Depuis le lot 22, la façade tient les backends llama.cpp quand +`LOKI_BACKENDS=rust` : + +- statut, vérification git ; +- binaires précompilés ; +- bascule fast/opt ; +- backends custom ; +- le job des moteurs. + +La compilation depuis les sources restait au Go. Il en rapportait chaque +étape au job de la façade par l'API interne +(`/internal/facade/v1/build-job/*`). Elle couvre trois routes : + +- `POST /api/llamacpp/install` `{force}` ; +- `POST /api/llamacpp/update` `{clean}` ; +- `POST /api/llamacpp/install-custom` `{repo, name, ref}`. + +## Le lot + +`src/backends/build.rs` porte la compilation sous Linux et macOS. Sources Go : + +- `backend_build.go` : plan, CMake, étapes suivies ; +- `lcRunInstall`, `lcRunUpdate`, `lcBuildAndSwitch`, `lcAppendLogTail` + (`web_llamacpp.go`) ; +- `installCustomBackend`, `deriveBackendName`, `looksLikeGitURL` + (`backend_llamacpp.go`) ; +- `autoInstallTool`, `ensureCompiler` (`sys_platform_unix.go`). + +Le port couvre : + +- **le plan complet** (`detectBuildPlan`) : + - drapeaux Release, natif, UI de llama-server coupée ; + - Metal sous macOS ; + - CUDA quand nvcc et un GPU NVIDIA sont là : F16, racine du toolkit et + compilateur explicites hors `/usr`, architectures lues par + `nvidia-smi` ; + - HIP, puis Vulkan ; +- **CMake** : + - `build/` configuré ailleurs (dépôt déplacé) ou `clean` : mis de côté en + `build.old` ; + - nvcc dans le PATH et `CUDACXX` ; + - `configure.log` et `build.log` dans le dépôt ; + - erreurs « configuration CMake échouée » et « compilation échouée » ; +- **les étapes suivies** : + - sorties standard et d'erreur dans un même tube, ordre gardé ; + - git : découpage du `sinkWriter` (barre qui se redessine montrée toutes + les 10 s, reste sans fin de ligne perdu) ; + - CMake : découpage du `bufio.Scanner` (dernier morceau rendu, ligne de + plus de 1 Mio qui arrête la lecture) ; + - octets invalides en U+FFFD un à un ; + - erreurs au format d'`exec` (`exit status N`, `signal: killed`, + programme introuvable) ; +- **les outils** : + - git et cmake, puis cc, c++, make ; + - un manquant est installé au mieux par apt-get, dnf, pacman ou brew + (sudo hors root) ; + - sinon le message du Go ; +- **les jobs** : + - installation : clone, compilation propre, BIN ; dépôt présent : bascule + en mise à jour ; `force` : supprimé puis recloné ; + - mise à jour : fetch, pull au besoin, commits avant et après ; + « déjà à jour » sans recompiler ; service arrêté le temps du build et + relancé même en échec ; + - échec du build : fin des deux journaux (30 lignes) remontée dans le job ; + - backend custom : URL vérifiée, nom tiré du dépôt (propriétaire ajouté à + un fork nommé llama.cpp) ou assaini, clone ou mise à jour, ref, sans + toucher à BIN. + +Les routes répondent comme le Go : + +- moteur fourni par l'image : 409 et le message du Go ; +- custom sans URL : 400 « URL du dépôt requise » ; +- optimisation en cours : 409 ; +- job déjà lancé : 409 ; +- sinon 200 `{"ok":true}` et le job tourne. + +Le service moteur est celui de la façade si elle le pilote, sinon celui du +Go (`/internal/v1/service/action`), comme pour les binaires précompilés. + +Côté Go, `facadeBackendsRoutes` gagne les trois routes hors Windows. Sous +Windows, la compilation reste au Go, qui rapporte toujours ses étapes à la +façade : + +- générateur Visual Studio ; +- intégration CUDA de MSBuild ; +- `CUDA_PATH`. + +Les messages du terminal du Go (outils manquants, dépendance connue absente +après un configure en échec) vont à la sortie d'erreur de la façade. + +## Parité + +`internal/loki/parity_build_test.go` (Linux) tire du Go un journal de 15 +étapes (`tests/parity_backends/build.json`). De faux git, cmake et +compilateur, rangés dans le journal, sont seuls sur le PATH. Chaque étape +porte : + +- la vue du job (lignes, phase, erreur, commits, fichiers compilés) ; +- BIN ; +- les dépôts qui ont un `build.old`. + +Il couvre : + +- mise à jour sans dépôt ; +- installation, puis installation sur un dépôt présent et installation + forcée ; +- déjà à jour, nouveau commit, recompilation propre ; +- build en échec (fin des journaux) ; +- cmake absent, compilateur absent ; +- clone en échec ; +- custom : URL invalide, nouveau fork, nom assaini sur un ref, dossier + existant sur un ref (URL `git@`). + +`tests/backends.rs` (`compilation_comme_le_go`) rejoue le journal par la +façade : mêmes faux outils dans le PATH des sondes, POST de la route, job +suivi jusqu'au bout. `compilation_refusee` vérifie les refus des routes. + +Les tests unitaires de `build.rs` couvrent : + +- la barre de progression (`TestSinkWriterProgression`) ; +- le scanner (fin `\r\n`, dernier morceau, octets invalides, ligne + démesurée) ; +- la racine du toolkit CUDA (`TestCudaToolkitRoot`) ; +- le cache CMake déplacé ; +- noms et URL des backends custom ; +- le plan par machine (CPU, nvcc du PATH, toolkit versionné, + architectures, HIP, Vulkan) ; +- les erreurs des commandes (code de sortie, signal, programme absent, + journal écrit). diff --git a/internal/loki/facade_backends.go b/internal/loki/facade_backends.go index 0e833cc..11389b5 100644 --- a/internal/loki/facade_backends.go +++ b/internal/loki/facade_backends.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "runtime" "strconv" "strings" "time" @@ -18,12 +19,13 @@ import ( // suivi du job, backends custom et GPU vus par un moteur // (rust/loki-rs/src/backends). // -// La façade détient alors LE job des moteurs. Ceux que le Go fait encore -// tourner — compilation depuis les sources (install, update, install-custom, -// que la façade lui relaie), moteur de l'image, Strata — lui rapportent chaque -// étape (/internal/facade/v1/build-job/*) : lcAppend, lcPhase, lcFail, lcDone, -// startLcJob et lcJobSnapshot passent par elle, et le Go ne restaure plus de -// job au démarrage (la façade s'en charge). +// La façade détient alors LE job des moteurs, et compile elle-même llama.cpp +// depuis les sources sous Linux et macOS (install, update, install-custom ; +// lot 41, backends/build.rs). Ceux que le Go fait encore tourner — compilation +// sous Windows (chaîne MSVC), moteur de l'image, Strata — lui rapportent +// chaque étape (/internal/facade/v1/build-job/*) : lcAppend, lcPhase, lcFail, +// lcDone, startLcJob et lcJobSnapshot passent par elle, et le Go ne restaure +// plus de job au démarrage (la façade s'en charge). const facadeBackendsEnv = "LOKI_BACKENDS" @@ -47,6 +49,17 @@ var facadeBackendsRoutes = map[string]bool{ "/api/backends/devices": true, } +func init() { + // La compilation depuis les sources part aussi à la façade (lot 41), sauf + // sous Windows : générateur Visual Studio et intégration CUDA de MSBuild + // restent au Go. + if runtime.GOOS != "windows" { + for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} { + facadeBackendsRoutes[p] = true + } + } +} + // facadeJobClient : appels courts (une ligne de journal, une phase). var facadeJobClient = &http.Client{Timeout: 30 * time.Second} diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 73d9f59..15fe9d6 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -954,10 +955,10 @@ func TestFacadeBackends(t *testing.T) { t.Fatalf("%s non relayé : %q", p, w.Body.String()) } } - // La compilation reste au Go. + // La compilation depuis les sources part aussi (lot 41), sauf sous Windows. for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} { - if facadeBackendsRoutes[p] { - t.Fatalf("%s ne doit pas partir à la façade", p) + if facadeBackendsRoutes[p] != (runtime.GOOS != "windows") { + t.Fatalf("%s : tenu par la façade = %v sous %s", p, facadeBackendsRoutes[p], runtime.GOOS) } } mu.Lock() diff --git a/internal/loki/parity_build_test.go b/internal/loki/parity_build_test.go new file mode 100644 index 0000000..cdd1adb --- /dev/null +++ b/internal/loki/parity_build_test.go @@ -0,0 +1,238 @@ +//go:build linux + +package loki + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// parity_build_test.go — journal de référence de la compilation de llama.cpp +// depuis les sources (lot 41 de la migration Rust) : installation, mise à jour, +// recompilation propre, échec du build, outils manquants, backends custom. +// Les outils (git, cmake, compilateur) sont de faux scripts rangés dans le +// journal, seuls sur le PATH ; le job est rejoué jusqu'au bout et sa vue +// (lignes, phase, erreur, commits), BIN et la présence de build.old sont +// relevés après chaque étape. Rangé dans +// rust/loki-rs/tests/parity_backends/build.json ; tests/backends.rs le +// rejoue par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteCompilation + +const parityBuildFile = "../../rust/loki-rs/tests/parity_backends/build.json" + +// Les faux outils : le dépôt « distant » est un fichier à côté des scripts. +var parityBuildTools = map[string]string{ + "git": `#!/bin/sh +d=$(dirname "$0") +case "$1" in + clone) + dir="$4" + if [ -f "$(dirname "$dir")/CLONE_FAIL" ]; then echo "fatal: repository '$3' not found" >&2; exit 128; fi + mkdir -p "$dir/.git" + cat "$d/remote-sha" > "$dir/.git/HEAD_SHA" + echo "Cloning into '$dir'..." >&2 ;; + fetch) + exit 0 ;; + rev-parse) + case "$2" in + --short) + if [ "$3" = HEAD ]; then head -c 7 .git/HEAD_SHA; else head -c 7 "$d/remote-sha"; fi + echo ;; + --abbrev-ref) echo master ;; + HEAD) cat .git/HEAD_SHA ;; + *) cat "$d/remote-sha" ;; + esac ;; + pull) + cat "$d/remote-sha" > .git/HEAD_SHA + echo "Fast-forward" ;; + checkout) + echo "$2" > .git/REF + echo "HEAD is now at $2" >&2 ;; +esac +`, + "cmake": `#!/bin/sh +if [ "$1" = "-B" ]; then + mkdir -p build + echo "-- The C compiler identification is GNU 13.2.0" + echo "-- Configuring done (0.5s)" + printf 'CMAKE_HOME_DIRECTORY:INTERNAL=%s\n' "$(pwd)" > build/CMakeCache.txt + echo "-- Build files have been written to: build" + exit 0 +fi +if [ -f FAIL_BUILD ]; then + echo "[ 10%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o" + echo "common/arg.cpp:12:5: error: 'boom' was not declared in this scope" + exit 2 +fi +echo "[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o" +echo "[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o" +echo "[100%] Linking CXX executable ../bin/llama-server" +mkdir -p build/bin +printf '#!/bin/sh\necho llama-server\n' > build/bin/llama-server +chmod +x build/bin/llama-server +`, + "cc": "#!/bin/sh\n", + "c++": "#!/bin/sh\n", + "make": "#!/bin/sh\n", +} + +type parityBuildStep struct { + Name string `json:"name"` + Remote string `json:"remote,omitempty"` // sha du dépôt distant + Files map[string]string `json:"files,omitempty"` // relatifs à LOKI_HOME ; "-" supprime + NoTools []string `json:"no_tools,omitempty"` // faux outils retirés pour l'étape + Action string `json:"action"` // install, update, custom + Force bool `json:"force,omitempty"` + Clean bool `json:"clean,omitempty"` + URL string `json:"url,omitempty"` + BName string `json:"bname,omitempty"` + Ref string `json:"ref,omitempty"` + Job map[string]any `json:"job"` + Bin string `json:"bin"` + BuildOld []string `json:"build_old"` // dépôts qui ont un build.old +} + +type parityBuildJournal struct { + Tools map[string]string `json:"tools"` + Steps []parityBuildStep `json:"steps"` +} + +var parityBuildJobsRe = regexp.MustCompile(`jobs=[0-9]+`) + +func TestPariteCompilation(t *testing.T) { + home := testHome(t) + fake := filepath.Join(t.TempDir(), "outils") + sys := filepath.Join(t.TempDir(), "sys") + for _, d := range []string{fake, sys} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + for _, c := range []string{"mkdir", "cat", "head", "dirname", "chmod"} { + p, err := exec.LookPath(c) + if err != nil { + t.Fatal(err) + } + if err := os.Symlink(p, filepath.Join(sys, c)); err != nil { + t.Fatal(err) + } + } + writeTool := func(name string) { + if err := os.WriteFile(filepath.Join(fake, name), []byte(parityBuildTools[name]), 0o755); err != nil { + t.Fatal(err) + } + } + for name := range parityBuildTools { + writeTool(name) + } + t.Setenv("PATH", fake+string(os.PathListSeparator)+sys) + t.Setenv("LOKI_ENGINE_BIN", "") + mask := func(s string) string { + s = strings.ReplaceAll(s, home, "{{HOME}}") + return parityBuildJobsRe.ReplaceAllString(s, "jobs={{N}}") + } + var steps []parityBuildStep + run := func(st parityBuildStep) { + if st.Remote != "" { + if err := os.WriteFile(filepath.Join(fake, "remote-sha"), []byte(st.Remote+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + parityApplyFiles(t, home, st.Files) + for _, n := range st.NoTools { + _ = os.Remove(filepath.Join(fake, n)) + } + var body func() + switch st.Action { + case "install": + body = func() { lcRunInstall(st.Force) } + case "update": + body = func() { lcRunUpdate(st.Clean) } + case "custom": + body = func() { lcRunCustomInstall(st.URL, st.BName, st.Ref) } + } + if err := startLcJob(st.Action, body); err != nil { + t.Fatalf("%s : %v", st.Name, err) + } + deadline := time.Now().Add(20 * time.Second) + for { + if snap := lcJobSnapshot(0, true); snap["running"] == false { + break + } + if time.Now().After(deadline) { + t.Fatalf("%s : le job ne finit pas", st.Name) + } + time.Sleep(10 * time.Millisecond) + } + snap := lcJobSnapshot(0, true) + delete(snap, "started_at") + delete(snap, "ended_at") + b, _ := json.Marshal(snap) + var job map[string]any + _ = json.Unmarshal([]byte(mask(string(b))), &job) + st.Job = job + st.Bin = mask(ReadConfig()["BIN"]) + st.BuildOld = []string{} + entries, _ := os.ReadDir(filepath.Join(home, "backends")) + for _, e := range entries { + if isDir(filepath.Join(home, "backends", e.Name(), "build.old")) { + st.BuildOld = append(st.BuildOld, e.Name()) + } + } + for _, n := range st.NoTools { + writeTool(n) + } + steps = append(steps, st) + } + + run(parityBuildStep{Name: "mise à jour sans dépôt", Remote: "aaaaaaa1111111111111111111111111111111", Action: "update"}) + run(parityBuildStep{Name: "installation", Action: "install"}) + run(parityBuildStep{Name: "mise à jour : déjà à jour", Action: "update"}) + run(parityBuildStep{Name: "mise à jour : nouveau commit", Remote: "bbbbbbb2222222222222222222222222222222", Action: "update"}) + run(parityBuildStep{Name: "recompilation propre", Action: "update", Clean: true}) + run(parityBuildStep{Name: "installation sur un dépôt présent", Remote: "ccccccc3333333333333333333333333333333", Action: "install"}) + run(parityBuildStep{Name: "build en échec", Remote: "ddddddd4444444444444444444444444444444", + Files: map[string]string{"backends/llama.cpp/FAIL_BUILD": "1"}, Action: "update"}) + run(parityBuildStep{Name: "installation forcée", Files: map[string]string{"backends/llama.cpp/FAIL_BUILD": "-"}, + Action: "install", Force: true}) + run(parityBuildStep{Name: "cmake absent", Action: "update", NoTools: []string{"cmake"}}) + run(parityBuildStep{Name: "compilateur absent", Action: "update", NoTools: []string{"cc", "make"}}) + run(parityBuildStep{Name: "clone en échec", Files: map[string]string{"backends/llama.cpp": "-", "backends/CLONE_FAIL": "1"}, + Action: "install"}) + run(parityBuildStep{Name: "custom : URL invalide", Files: map[string]string{"backends/CLONE_FAIL": "-"}, + Action: "custom", URL: "ftp://exemple/x"}) + run(parityBuildStep{Name: "custom : nouveau fork", Action: "custom", URL: "https://github.com/prism/llama.cpp.git"}) + run(parityBuildStep{Name: "custom : nom assaini, sur un ref", Action: "custom", URL: "https://github.com/prism/llama.cpp.git", BName: "Mon Fork/../x", Ref: "v2"}) + run(parityBuildStep{Name: "custom : dossier existant sur un ref", Action: "custom", URL: "git@github.com:prism/llama.cpp.git", Ref: "v3"}) + + got, err := json.MarshalIndent(parityBuildJournal{Tools: parityBuildTools, Steps: steps}, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityBuildFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityBuildFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityBuildFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("la compilation ne produit plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityBuildFile) + } +} diff --git a/rust/loki-rs/src/backends/build.rs b/rust/loki-rs/src/backends/build.rs new file mode 100644 index 0000000..73477ff --- /dev/null +++ b/rust/loki-rs/src/backends/build.rs @@ -0,0 +1,978 @@ +//! Compilation de llama.cpp depuis les sources (lot 41) — port de +//! `backend_build.go` (plan, CMake, étapes suivies), de `lcRunInstall`, +//! `lcRunUpdate`, `lcBuildAndSwitch` (`web_llamacpp.go`) et de +//! `installCustomBackend` (`backend_llamacpp.go`), pour Linux et macOS. +//! +//! Chaque étape passe par le job des moteurs : phases, sortie des commandes +//! ligne à ligne (fichiers compilés et phases tirés des lignes), journaux +//! `configure.log` et `build.log` dans le dépôt, fin des journaux remontée +//! en cas d'échec. Les outils manquants sont installés au mieux par le +//! gestionnaire de paquets de la machine, comme le Go. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use super::probe::{Probe, is_dir}; +use super::{Backends, Log, llama_server_bin, sanitize_backend_name, service, service_stop_if_up}; +use crate::api::Ctx; +use crate::store::BK_CONFIG; +use crate::system::go_lossy; + +/// `llamacppRepoURL`. +pub const REPO_URL: &str = "https://github.com/ggml-org/llama.cpp.git"; + +/// `sinkProgressEvery` : une barre qui se redessine (\r), montrée toutes les +/// 10 s. +const PROGRESS_EVERY: Duration = Duration::from_secs(10); + +/// Une ligne de sortie lue au plus (`bufio.Scanner`, 1 Mio). +const MAX_LINE: usize = 1 << 20; + +/// `buildPlan` complet : backend, drapeaux CMake, nvcc. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Plan { + pub backend: &'static str, + pub cuda_arch: String, + pub jobs: usize, + pub flags: Vec, + pub cuda_cxx: String, +} + +/// `cudaToolkitRoot` : `/bin/nvcc` → ``, sauf `/usr` (la +/// racine n'apprend alors rien à CMake). +pub fn cuda_toolkit_root(nvcc: &Path) -> String { + let Some(bin) = nvcc.parent() else { + return String::new(); + }; + if bin.file_name().and_then(|n| n.to_str()) != Some("bin") { + return String::new(); + } + let root = bin + .parent() + .map(|r| r.to_string_lossy().into_owned()) + .unwrap_or_default(); + match root.as_str() { + "" | "." | "/" | "/usr" => String::new(), + _ => root, + } +} + +impl Probe { + /// `detectBuildPlan`. + pub async fn build_plan(&self) -> Plan { + let base = self.detect_build_plan().await; + let mut p = Plan { + backend: base.backend, + cuda_arch: base.cuda_arch, + jobs: base.jobs, + flags: [ + "-DCMAKE_BUILD_TYPE=Release", + "-DGGML_NATIVE=ON", + // L'UI de llama-server demande npm : Loki a la sienne. + "-DLLAMA_BUILD_UI=OFF", + "-DLLAMA_USE_PREBUILT_UI=OFF", + ] + .map(String::from) + .to_vec(), + cuda_cxx: String::new(), + }; + match p.backend { + "metal" => p.flags.push("-DGGML_METAL=ON".into()), + "cuda" => { + let nvcc = self.find_nvcc().unwrap_or_default(); + p.cuda_cxx = nvcc.to_string_lossy().into_owned(); + p.flags.push("-DGGML_CUDA=ON".into()); + p.flags.push("-DGGML_CUDA_F16=ON".into()); + let root = cuda_toolkit_root(&nvcc); + if !root.is_empty() { + p.flags.push(format!("-DCUDAToolkit_ROOT={root}")); + p.flags + .push(format!("-DCMAKE_CUDA_COMPILER={}", p.cuda_cxx)); + } + if !p.cuda_arch.is_empty() { + p.flags + .push(format!("-DCMAKE_CUDA_ARCHITECTURES={}", p.cuda_arch)); + } + } + "hip" => p.flags.push("-DGGML_HIP=ON".into()), + "vulkan" => p.flags.push("-DGGML_VULKAN=ON".into()), + _ => {} + } + p + } +} + +/// `cacheStale` : un `build/` configuré pour un autre dossier source (dépôt +/// déplacé) ne se reconfigure pas en place. +pub fn cache_stale(build: &Path, repo: &Path) -> bool { + let Ok(b) = std::fs::read_to_string(build.join("CMakeCache.txt")) else { + return false; + }; + let abs = std::path::absolute(repo).unwrap_or_else(|_| repo.to_path_buf()); + let abs = abs.to_string_lossy(); + for line in b.split('\n') { + if line.starts_with("CMAKE_HOME_DIRECTORY:") + && let Some(i) = line.find('=') + { + let home = line[i + 1..].trim(); + return !home.is_empty() && home != abs; + } + } + false +} + +/// L'erreur d'une commande, au format d'`exec` du Go. +fn exit_err(status: std::process::ExitStatus) -> Result<(), String> { + if status.success() { + return Ok(()); + } + match status.code() { + Some(c) => Err(format!("exit status {c}")), + None => { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt as _; + if let Some(s) = status.signal() { + return Err(format!("signal: {}", signal_name(s))); + } + } + Err(status.to_string()) + } + } +} + +#[cfg(unix)] +fn signal_name(s: i32) -> &'static str { + match s { + libc::SIGKILL => "killed", + libc::SIGTERM => "terminated", + libc::SIGINT => "interrupt", + libc::SIGSEGV => "segmentation fault", + libc::SIGABRT => "aborted", + _ => "signal", + } +} + +/// Une commande lancée par une étape : sorties standard et d'erreur dans le +/// même tube (leur ordre gardé), lues ligne à ligne (voir [`Splitter`]) et +/// copiées dans `log_file`, créé avant le lancement comme au Go. +struct Running { + rx: tokio::sync::mpsc::UnboundedReceiver, + done: tokio::task::JoinHandle>, +} + +fn spawn_lines( + probe: &Probe, + dir: Option<&Path>, + env: &[(String, String)], + name: &str, + args: &[String], + progress: bool, + log_file: Option<&Path>, +) -> Result { + let mut file = log_file.and_then(|p| std::fs::File::create(p).ok()); + let Some(bin) = probe.look_path(name) else { + return Err(format!( + "exec: \"{name}\": executable file not found in $PATH" + )); + }; + let (mut reader, writer) = std::io::pipe().map_err(|e| e.to_string())?; + let mut cmd = std::process::Command::new(&bin); + cmd.args(args) + .stdin(Stdio::null()) + .stdout(writer.try_clone().map_err(|e| e.to_string())?) + .stderr(writer); + if let Some(d) = dir { + cmd.current_dir(d); + } + for (k, v) in env { + cmd.env(k, v); + } + let spawned = cmd.spawn(); + // Les extrémités d'écriture n'appartiennent plus qu'à l'enfant : le tube + // se ferme à sa fin. + drop(cmd); + let mut child = + spawned.map_err(|e| crate::paths::os_err("fork/exec", &bin.to_string_lossy(), &e))?; + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + let done = tokio::task::spawn_blocking(move || { + split_lines(&mut reader, progress, |l| { + if let Some(f) = file.as_mut() { + use std::io::Write as _; + let _ = writeln!(f, "{l}"); + } + let _ = tx.send(l); + }); + drop(reader); + exit_err(child.wait().map_err(|e| e.to_string())?) + }); + Ok(Running { rx, done }) +} + +/// [`spawn_lines`], chaque ligne versée au job. +#[allow(clippy::too_many_arguments)] +async fn run_lines( + probe: &Probe, + log: &Log<'_>, + dir: Option<&Path>, + env: &[(String, String)], + name: &str, + args: &[String], + progress: bool, + log_file: Option<&Path>, +) -> Result<(), String> { + let mut r = spawn_lines(probe, dir, env, name, args, progress, log_file)?; + while let Some(line) = r.rx.recv().await { + log.line(&line).await; + } + r.done.await.map_err(|e| e.to_string())? +} + +/// [`spawn_lines`], chaque ligne passée à `f` (tests). +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +async fn run_lines_with( + probe: &Probe, + dir: Option<&Path>, + env: &[(String, String)], + name: &str, + args: &[String], + progress: bool, + log_file: Option<&Path>, + mut f: impl FnMut(String), +) -> Result<(), String> { + let mut r = spawn_lines(probe, dir, env, name, args, progress, log_file)?; + while let Some(line) = r.rx.recv().await { + f(line); + } + r.done.await.map_err(|e| e.to_string())? +} + +/// Le découpage en lignes de `run_lines` : `sinkWriter` (`progress`) ou +/// `bufio.Scanner`. +pub struct Splitter { + progress: bool, + buf: Vec, + /// Dernière barre de progression montrée. + pub last_prog: Option, + /// Ligne démesurée : le scanner s'est arrêté. + pub stopped: bool, +} + +impl Splitter { + pub fn new(progress: bool) -> Self { + Splitter { + progress, + buf: Vec::new(), + last_prog: None, + stopped: false, + } + } + + /// Une écriture de la commande. + pub fn feed(&mut self, p: &[u8], emit: &mut impl FnMut(String)) { + if self.stopped { + return; + } + self.buf.extend_from_slice(p); + let progress = self.progress; + while let Some(i) = self + .buf + .iter() + .position(|&c| c == b'\n' || (progress && c == b'\r')) + { + if self.buf[i] == b'\n' { + let mut line = &self.buf[..i]; + if line.last() == Some(&b'\r') { + line = &line[..line.len() - 1]; + } + if !progress && line.len() > MAX_LINE { + self.stopped = true; + return; + } + emit(go_lossy(line)); + self.buf.drain(..=i); + continue; + } + // \r : fin de ligne Windows ou barre qui se redessine ; le \n + // peut arriver dans l'écriture suivante. + if i + 1 >= self.buf.len() { + break; + } + if self.buf[i + 1] == b'\n' { + emit(go_lossy(&self.buf[..i])); + self.buf.drain(..i + 2); + continue; + } + let prog = go_lossy(&self.buf[..i]).trim().to_string(); + if !prog.is_empty() && self.last_prog.is_none_or(|t| t.elapsed() >= PROGRESS_EVERY) { + self.last_prog = Some(Instant::now()); + emit(prog); + } + self.buf.drain(..=i); + } + if !progress && self.buf.len() > MAX_LINE { + self.stopped = true; + } + } + + /// Fin du flux : le scanner rend le dernier morceau, le `sinkWriter` le + /// perd. + pub fn finish(&mut self, emit: &mut impl FnMut(String)) { + if self.progress || self.stopped || self.buf.is_empty() { + return; + } + let mut line = &self.buf[..]; + if line.last() == Some(&b'\r') { + line = &line[..line.len() - 1]; + } + emit(go_lossy(line)); + self.buf.clear(); + } +} + +fn split_lines(r: &mut impl std::io::Read, progress: bool, mut emit: impl FnMut(String)) { + let mut sp = Splitter::new(progress); + let mut chunk = [0u8; 8192]; + loop { + match r.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => sp.feed(&chunk[..n], &mut emit), + } + if sp.stopped { + return; + } + } + sp.finish(&mut emit); +} + +/// `runStep` : une commande dont chaque ligne va au job. +pub async fn run_step( + probe: &Probe, + log: &Log<'_>, + dir: Option<&Path>, + name: &str, + args: &[&str], +) -> Result<(), String> { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + run_lines(probe, log, dir, &[], name, &args, true, None).await +} + +/// `buildLlamacpp` : configure puis compile la cible llama-server. Un +/// `build/` configuré ailleurs (ou `clean`) repart de zéro (`build.old`). +pub async fn build_llamacpp( + probe: &Probe, + log: &Log<'_>, + repo: &Path, + p: &Plan, + clean: bool, +) -> Result<(), String> { + let build = repo.join("build"); + if (clean || cache_stale(&build, repo)) && is_dir(&build) { + let old = repo.join("build.old"); + let _ = std::fs::remove_dir_all(&old); + if std::fs::rename(&build, &old).is_err() { + let _ = std::fs::remove_dir_all(&build); + } + } + // nvcc dans le PATH et exposé par CUDACXX pour la configuration CMake. + let mut env: Vec<(String, String)> = Vec::new(); + if p.backend == "cuda" && !p.cuda_cxx.is_empty() { + let cuda_bin = Path::new(&p.cuda_cxx) + .parent() + .map(|d| d.to_string_lossy().into_owned()) + .unwrap_or_default(); + env.push(("CUDACXX".into(), p.cuda_cxx.clone())); + let path = std::env::var("PATH").unwrap_or_default(); + env.push(("PATH".into(), format!("{cuda_bin}:{path}"))); + } + let mut cfg_args: Vec = ["-B", "build", "-S", "."].map(String::from).to_vec(); + cfg_args.extend(p.flags.iter().cloned()); + let cfg_log = repo.join("configure.log"); + if let Err(e) = run_lines( + probe, + log, + Some(repo), + &env, + "cmake", + &cfg_args, + false, + Some(&cfg_log), + ) + .await + { + hint_missing_dep(probe, p, &cfg_log); + return Err(format!("configuration CMake échouée: {e}")); + } + let build_args: Vec = [ + "--build", + "build", + "--config", + "Release", + "-j", + &p.jobs.to_string(), + "--target", + "llama-server", + ] + .map(String::from) + .to_vec(); + run_lines( + probe, + log, + Some(repo), + &env, + "cmake", + &build_args, + false, + Some(&repo.join("build.log")), + ) + .await + .map_err(|e| format!("compilation échouée: {e}")) +} + +/// `hintMissingBuildDep` : une dépendance connue manquante, dite sur la +/// sortie d'erreur de la façade (le Go l'écrit sur son terminal, pas dans +/// le job). +fn hint_missing_dep(probe: &Probe, p: &Plan, cfg_log: &Path) { + let Ok(log) = std::fs::read_to_string(cfg_log) else { + return; + }; + if p.backend == "cuda" && log.contains("CUDA Toolkit not found") { + eprintln!( + "[dépendance] nvcc est présent mais le CUDA Toolkit complet (en-têtes + cudart) est introuvable." + ); + } + if p.backend == "vulkan" && log.contains("SPIRV-Headers") { + let hint = pkg_install_hint(probe, "spirv-headers"); + eprintln!( + "[dépendance] en-têtes SPIR-V (paquet « SPIRV-Headers ») introuvables pour le backend Vulkan{}", + if hint.is_empty() { + String::new() + } else { + format!(" : {hint}") + } + ); + } +} + +/// `pkgInstallHint`. +fn pkg_install_hint(probe: &Probe, pkg: &str) -> String { + for (bin, cmd) in [ + ("pacman", "sudo pacman -S "), + ("apt-get", "sudo apt-get install -y "), + ("dnf", "sudo dnf install -y "), + ("zypper", "sudo zypper install -y "), + ("brew", "brew install "), + ] { + if probe.has_tool(bin) { + return format!("{cmd}{pkg}"); + } + } + String::new() +} + +/// `autoInstallTool` : le paquet par le gestionnaire de la machine (sudo +/// hors root), sorties sur celles de la façade. +async fn auto_install_tool(probe: &Probe, name: &str) -> Result<(), String> { + for (m, install) in [ + ("apt-get", &["install", "-y"][..]), + ("dnf", &["install", "-y"][..]), + ("pacman", &["-S", "--noconfirm"][..]), + ("brew", &["install"][..]), + ] { + let Some(bin) = probe.look_path(m) else { + continue; + }; + let mut argv: Vec = vec![bin.to_string_lossy().into_owned()]; + argv.extend(install.iter().map(|s| s.to_string())); + argv.push(name.to_string()); + #[cfg(unix)] + let root = unsafe { libc::geteuid() } == 0; + #[cfg(not(unix))] + let root = true; + if m != "brew" && !root { + let Some(sudo) = probe.look_path("sudo") else { + return Err(format!("{m} requiert root (ni root ni sudo disponibles)")); + }; + argv.insert(0, sudo.to_string_lossy().into_owned()); + } + let status = tokio::process::Command::new(&argv[0]) + .args(&argv[1..]) + .stdin(Stdio::null()) + .status() + .await + .map_err(|e| e.to_string())?; + return exit_err(status); + } + Err(format!( + "aucun gestionnaire de paquets connu — installe {name} manuellement" + )) +} + +/// `requireTools` : les outils manquants installés au mieux, puis revus. +pub async fn require_tools(probe: &Probe, tools: &[&str]) -> Result<(), String> { + let missing: Vec<&str> = tools + .iter() + .copied() + .filter(|t| !probe.has_tool(t)) + .collect(); + if missing.is_empty() { + return Ok(()); + } + eprintln!( + "[info] outils manquants: {} — installation automatique…", + missing.join(", ") + ); + for t in &missing { + if let Err(e) = auto_install_tool(probe, t).await { + eprintln!(" • {t}: {e}"); + } + } + let still: Vec<&str> = tools + .iter() + .copied() + .filter(|t| !probe.has_tool(t)) + .collect(); + if !still.is_empty() { + return Err(format!( + "outils toujours manquants après tentative d'install: {} — installe-les à la main puis réessaie", + still.join(", ") + )); + } + Ok(()) +} + +/// `ensureCompiler` (Unix) : cc, c++ et make, installés au mieux. +pub async fn ensure_compiler(probe: &Probe) -> Result<(), String> { + let any = |names: &[&str]| names.iter().any(|n| probe.has_tool(n)); + if any(&["cc", "gcc", "clang"]) && any(&["c++", "g++", "clang++"]) && probe.has_tool("make") { + return Ok(()); + } + let candidates: &[&str] = if probe.has_tool("apt-get") { + &["build-essential", "gcc", "g++", "make"] + } else { + &["gcc", "gcc-c++", "make"] + }; + eprintln!("[info] compilateur C/C++ absent — installation via le gestionnaire de paquets…"); + for pkg in candidates { + let _ = auto_install_tool(probe, pkg).await; + } + if any(&["cc", "gcc"]) && any(&["c++", "g++"]) && probe.has_tool("make") { + return Ok(()); + } + Err( + "compilateur C/C++ introuvable — installe gcc/g++/make (ou build-essential) manuellement" + .into(), + ) +} + +/// `lcAppendLogTail` : la fin des journaux de build, dans le job. +async fn append_log_tail(log: &Log<'_>, paths: &[PathBuf]) { + for p in paths { + let Ok(b) = std::fs::read(p) else { continue }; + let text = String::from_utf8_lossy(&b); + let lines: Vec<&str> = text.trim_end_matches('\n').split('\n').collect(); + let tail = &lines[lines.len().saturating_sub(30)..]; + let base = p + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + log.line(&format!("--- fin de {base} ---")).await; + for l in tail { + log.line(l).await; + } + } +} + +/// `lcBuildAndSwitch` : compile llama-server et y pointe BIN. Faux : le job +/// a échoué. +async fn build_and_switch( + ctx: &Ctx, + b: &Backends, + log: &Log<'_>, + repo: &Path, + clean: bool, +) -> bool { + let plan = b.probe.build_plan().await; + log.line(&format!( + "plan de build : backend={} arch={} jobs={}", + plan.backend, plan.cuda_arch, plan.jobs + )) + .await; + log.phase("configuration CMake…").await; + if let Err(e) = build_llamacpp(&b.probe, log, repo, &plan, clean).await { + append_log_tail(log, &[repo.join("configure.log"), repo.join("build.log")]).await; + b.jobs.fail(&ctx.store, &e).await; + return false; + } + let bin = llama_server_bin(repo); + if bin.is_empty() { + b.jobs + .fail( + &ctx.store, + &format!( + "build terminé mais binaire introuvable sous {}", + repo.join("build").display() + ), + ) + .await; + return false; + } + log.line(&format!("binaire compilé : {bin}")).await; + if let Err(e) = ctx.store.put(BK_CONFIG, "BIN", bin.into_bytes()).await { + b.jobs + .fail( + &ctx.store, + &format!("build ok mais échec d'écriture de BIN : {e}"), + ) + .await; + return false; + } + log.line("BIN mis à jour").await; + true +} + +/// La vérification commune des outils ; l'erreur est rangée dans le job. +async fn tools_ready(ctx: &Ctx, b: &Backends) -> bool { + let res = match require_tools(&b.probe, &["git", "cmake"]).await { + Ok(()) => ensure_compiler(&b.probe).await, + Err(e) => Err(e), + }; + match res { + Ok(()) => true, + Err(e) => { + b.jobs.fail(&ctx.store, &e).await; + false + } + } +} + +/// `lcRunInstall` : clone, compilation, BIN. Dépôt déjà là : une mise à +/// jour (`force` : il est supprimé et recloné). +pub async fn run_install(ctx: &Ctx, b: &Backends, force: bool) { + let log = Log { + jobs: &b.jobs, + store: &ctx.store, + }; + let repo = b.default_repo_dir(); + log.phase("vérification des outils (git, cmake, compilateur)…") + .await; + if !tools_ready(ctx, b).await { + return; + } + if is_dir(&repo.join(".git")) { + if !force { + log.phase("dépôt déjà présent — bascule en mise à jour") + .await; + Box::pin(run_update(ctx, b, false)).await; + return; + } + log.phase("suppression du dépôt existant (--force)…").await; + if let Err(e) = std::fs::remove_dir_all(&repo) { + b.jobs + .fail( + &ctx.store, + &crate::paths::os_err("unlinkat", &repo.to_string_lossy(), &e), + ) + .await; + return; + } + } + if let Some(parent) = repo.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + b.jobs + .fail( + &ctx.store, + &crate::paths::os_err("mkdir", &parent.to_string_lossy(), &e), + ) + .await; + return; + } + log.phase("clone de llama.cpp…").await; + let repo_s = repo.to_string_lossy().into_owned(); + if let Err(e) = run_step( + &b.probe, + &log, + None, + "git", + &["clone", "--depth=1", REPO_URL, &repo_s], + ) + .await + { + b.jobs + .fail(&ctx.store, &format!("git clone a échoué : {e}")) + .await; + return; + } + if !build_and_switch(ctx, b, &log, &repo, true).await { + return; + } + b.jobs.done(&ctx.store, "installation terminée").await; +} + +/// `lcRunUpdate` : fetch, pull au besoin, recompilation (service arrêté le +/// temps du build), BIN. +pub async fn run_update(ctx: &Ctx, b: &Backends, clean: bool) { + let log = Log { + jobs: &b.jobs, + store: &ctx.store, + }; + log.phase("vérification des outils (git, cmake, compilateur)…") + .await; + if !tools_ready(ctx, b).await { + return; + } + let cfg_bin = ctx + .store + .config() + .await + .get("BIN") + .cloned() + .unwrap_or_default(); + let repo = b.repo_dir(&cfg_bin); + if !is_dir(&repo.join(".git")) { + b.jobs + .fail( + &ctx.store, + &format!( + "aucun dépôt llama.cpp ({}) — lance d'abord l'installation", + repo.display() + ), + ) + .await; + return; + } + let old = b.git(&repo, &["rev-parse", "--short", "HEAD"]).await; + b.jobs.set_commits(Some(&old), None).await; + let mut branch = b.git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]).await; + if branch.is_empty() || branch == "HEAD" { + branch = "master".into(); + } + log.phase("git fetch origin…").await; + if let Err(e) = run_step( + &b.probe, + &log, + Some(&repo), + "git", + &["fetch", "origin", "--quiet"], + ) + .await + { + b.jobs + .fail(&ctx.store, &format!("git fetch a échoué : {e}")) + .await; + return; + } + let local = b.git(&repo, &["rev-parse", "HEAD"]).await; + let remote = b + .git(&repo, &["rev-parse", &format!("origin/{branch}")]) + .await; + if !local.is_empty() && local == remote && !clean && !llama_server_bin(&repo).is_empty() { + b.jobs + .done(&ctx.store, &format!("déjà à jour ({old}) — rien à faire")) + .await; + return; + } + if local != remote { + log.phase(&format!("git pull origin/{branch}…")).await; + if let Err(e) = run_step( + &b.probe, + &log, + Some(&repo), + "git", + &["pull", "--ff-only", "origin", &branch], + ) + .await + { + b.jobs + .fail( + &ctx.store, + &format!("git pull a échoué (modifs locales ?) : {e}"), + ) + .await; + return; + } + } + let new = b.git(&repo, &["rev-parse", "--short", "HEAD"]).await; + b.jobs.set_commits(None, Some(&new)).await; + // Le binaire en service ne se réécrit pas : le moteur s'arrête le temps + // du build, et repart après, même en échec. + let (svc_was_up, err) = service_stop_if_up(ctx).await; + if svc_was_up { + log.phase("arrêt du service le temps du build…").await; + if !err.is_empty() { + log.line(&format!("[warn] impossible d'arrêter le service : {err}")) + .await; + } + } + let ok = build_and_switch(ctx, b, &log, &repo, clean).await; + if svc_was_up { + log.phase("redémarrage du service…").await; + let (_, e) = service(ctx, "start", false).await; + if !e.is_empty() { + log.line(&format!("[warn] redémarrage du service échoué : {e}")) + .await; + } + } + if !ok { + return; + } + let msg = if old == new { + format!("recompilé ({new})") + } else { + format!("mis à jour : {old} → {new}") + }; + b.jobs.done(&ctx.store, &msg).await; +} + +/// `looksLikeGitURL`. +pub fn looks_like_git_url(u: &str) -> bool { + ["https://", "http://", "git@", "ssh://", "git://"] + .iter() + .any(|p| u.starts_with(p)) +} + +/// `deriveBackendName` : le nom du dépôt, suffixé du propriétaire pour un +/// fork nommé « llama.cpp ». +pub fn derive_backend_name(url: &str) -> String { + let s = url.trim(); + let s = s.strip_suffix(".git").unwrap_or(s); + let s = s.trim_end_matches('/'); + let parts: Vec<&str> = s.split(['/', ':']).filter(|p| !p.is_empty()).collect(); + let (repo, owner) = match parts.len() { + 0 => ("", ""), + 1 => (parts[0], ""), + n => (parts[n - 1], parts[n - 2]), + }; + let mut name = if repo.is_empty() { + "backend".to_string() + } else { + repo.to_string() + }; + if name.eq_ignore_ascii_case("llama.cpp") && !owner.is_empty() { + name = format!("{name}-{owner}"); + } + sanitize_backend_name(&name) +} + +/// `installCustomBackend` : clone (ou met à jour) un fork dans +/// `backends/` et le compile, sans toucher à BIN ; rend le binaire. +pub async fn install_custom( + b: &Backends, + log: &Log<'_>, + url: &str, + name: &str, + git_ref: &str, +) -> Result { + let url = url.trim(); + if url.is_empty() { + return Err("URL du dépôt vide".into()); + } + if !looks_like_git_url(url) { + return Err(format!( + "URL de dépôt invalide (attendu https://…, git@… ou ssh://…) : {url}" + )); + } + let name = if name.trim().is_empty() { + derive_backend_name(url) + } else { + sanitize_backend_name(name) + }; + // `backendDir` : le nom assaini ne sort jamais de backends/. + let dir = std::path::absolute(b.backends_dir()) + .map_err(|e| e.to_string())? + .join(&name); + log.phase("vérification des outils (git, cmake, compilateur)…") + .await; + require_tools(&b.probe, &["git", "cmake"]).await?; + ensure_compiler(&b.probe).await?; + let probe = &b.probe; + if is_dir(&dir.join(".git")) { + log.phase("dépôt déjà présent — mise à jour…").await; + let _ = run_step( + probe, + log, + Some(&dir), + "git", + &["fetch", "origin", "--quiet"], + ) + .await; + if !git_ref.is_empty() { + run_step(probe, log, Some(&dir), "git", &["checkout", git_ref]).await?; + } else { + let branch = b.git(&dir, &["rev-parse", "--abbrev-ref", "HEAD"]).await; + if !branch.is_empty() && branch != "HEAD" { + let _ = run_step( + probe, + log, + Some(&dir), + "git", + &["pull", "--ff-only", "origin", &branch], + ) + .await; + } + } + } else { + if let Some(parent) = dir.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| crate::paths::os_err("mkdir", &parent.to_string_lossy(), &e))?; + } + log.phase(&format!("clone de {url}…")).await; + let dir_s = dir.to_string_lossy().into_owned(); + run_step( + probe, + log, + None, + "git", + &["clone", "--depth=1", url, &dir_s], + ) + .await + .map_err(|e| format!("git clone a échoué : {e}"))?; + if !git_ref.is_empty() { + let _ = run_step( + probe, + log, + Some(&dir), + "git", + &["fetch", "--unshallow", "origin"], + ) + .await; + run_step(probe, log, Some(&dir), "git", &["checkout", git_ref]).await?; + } + } + let plan = probe.build_plan().await; + log.phase(&format!("compilation (backend={})…", plan.backend)) + .await; + build_llamacpp(probe, log, &dir, &plan, false).await?; + let bin = llama_server_bin(&dir); + if bin.is_empty() { + return Err(format!( + "build terminé mais binaire introuvable sous {}", + dir.join("build").display() + )); + } + Ok(bin) +} + +/// `lcRunCustomInstall` : un backend custom, sans bascule de BIN (il se +/// choisit par preset). +pub async fn run_custom(ctx: &Ctx, b: &Backends, url: &str, name: &str, git_ref: &str) { + let log = Log { + jobs: &b.jobs, + store: &ctx.store, + }; + match install_custom(b, &log, url, name, git_ref).await { + Err(e) => b.jobs.fail(&ctx.store, &e).await, + Ok(bin) => { + log.line(&format!("binaire compilé : {bin}")).await; + log.line("→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.") + .await; + b.jobs.done(&ctx.store, "backend custom installé").await; + } + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/backends/build/tests.rs b/rust/loki-rs/src/backends/build/tests.rs new file mode 100644 index 0000000..238f8bc --- /dev/null +++ b/rust/loki-rs/src/backends/build/tests.rs @@ -0,0 +1,271 @@ +use super::*; + +fn feed_all(sp: &mut Splitter, writes: &[&[u8]]) -> Vec { + let mut got = Vec::new(); + for w in writes { + sp.feed(w, &mut |l| got.push(l)); + } + sp.finish(&mut |l| got.push(l)); + got +} + +/// `TestSinkWriterProgression` : une barre (\r sans \n) apparaît sans noyer +/// le journal ; \r\n reste une fin de ligne, même coupé entre deux écritures. +#[test] +fn barre_de_progression() { + let mut sp = Splitter::new(true); + let mut got = Vec::new(); + sp.feed(b"Step 5\r", &mut |l| got.push(l)); + sp.feed( + "\n modèle : 1 / 76 GB\r modèle : 2 / 76 GB\r".as_bytes(), + &mut |l| got.push(l), + ); + sp.feed(" modèle : 3 / 76 GB\r".as_bytes(), &mut |l| got.push(l)); + sp.last_prog = Some(Instant::now() - Duration::from_secs(60)); + sp.feed(" modèle : 40 / 76 GB\r[ok] fini\n".as_bytes(), &mut |l| { + got.push(l) + }); + // Un reste sans fin de ligne est perdu, comme au Go. + sp.feed(b"reste", &mut |l| got.push(l)); + sp.finish(&mut |l| got.push(l)); + assert_eq!( + got, + [ + "Step 5", + "modèle : 1 / 76 GB", + "modèle : 3 / 76 GB", + "[ok] fini" + ] + ); +} + +/// Le scanner : \n seul coupe, \r final retiré, dernier morceau rendu, +/// octets invalides en U+FFFD un à un. +#[test] +fn scanner_de_lignes() { + let mut sp = Splitter::new(false); + let got = feed_all(&mut sp, &[b"a\r\nb\rc\n", b"d", b"\xff\xfee\n", b"fin\r"]); + assert_eq!(got, ["a", "b\rc", "d\u{fffd}\u{fffd}e", "fin"]); + let mut sp = Splitter::new(false); + let long = vec![b'x'; MAX_LINE + 1]; + let got = feed_all(&mut sp, &[b"ok\n", &long, b"\napres\n"]); + assert_eq!(got, ["ok"]); +} + +/// `TestCudaToolkitRoot`. +#[test] +fn racine_du_toolkit() { + for (nvcc, want) in [ + ("/usr/local/cuda/bin/nvcc", "/usr/local/cuda"), + ("/usr/local/cuda-12.8/bin/nvcc", "/usr/local/cuda-12.8"), + ("/usr/bin/nvcc", ""), + ("/opt/nvcc", ""), + ("/bin/nvcc", ""), + ("bin/nvcc", ""), + ("", ""), + ] { + assert_eq!(cuda_toolkit_root(Path::new(nvcc)), want, "{nvcc}"); + } +} + +#[test] +fn cache_deplace() { + let d = tempfile::tempdir().unwrap(); + let repo = d.path().join("llama.cpp"); + let build = repo.join("build"); + std::fs::create_dir_all(&build).unwrap(); + assert!(!cache_stale(&build, &repo), "sans cache"); + let cache = |home: &str| { + std::fs::write( + build.join("CMakeCache.txt"), + format!("# x\nCMAKE_HOME_DIRECTORY:INTERNAL={home} \nAUTRE=1\n"), + ) + .unwrap() + }; + cache(&repo.to_string_lossy()); + assert!(!cache_stale(&build, &repo), "même dossier"); + cache("/ailleurs/llama.cpp"); + assert!(cache_stale(&build, &repo), "dépôt déplacé"); + cache(""); + assert!(!cache_stale(&build, &repo), "chemin vide"); + std::fs::write( + build.join("CMakeCache.txt"), + "CMAKE_HOME_DIRECTORY:INTERNAL\n", + ) + .unwrap(); + assert!(!cache_stale(&build, &repo), "sans ="); +} + +#[test] +fn noms_et_urls() { + for (url, want) in [ + ("https://github.com/prism/llama.cpp.git", "llama.cpp-prism"), + ("git@github.com:prism/llama.cpp.git", "llama.cpp-prism"), + ("https://github.com/ikawrakow/ik_llama.cpp/", "ik_llama.cpp"), + (" https://github.com/Owner/LLAMA.CPP ", "llama.cpp-owner"), + ("llama.cpp", "llama.cpp"), + ("https://", "https"), + ("", "backend"), + ("/", "backend"), + ] { + assert_eq!(derive_backend_name(url), want, "{url}"); + } + for (url, ok) in [ + ("https://x", true), + ("http://x", true), + ("git@x:y", true), + ("ssh://x", true), + ("git://x", true), + ("ftp://x", false), + ("/chemin/local", false), + (" https://x", false), + ] { + assert_eq!(looks_like_git_url(url), ok, "{url}"); + } +} + +/// Le plan complet par machine (faux outils, fausse racine). +#[cfg(target_os = "linux")] +#[tokio::test] +async fn plan_par_machine() { + let tools = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let probe = Probe { + path: Some(vec![tools.path().to_path_buf()]), + root: root.path().to_path_buf(), + }; + let script = |name: &str, body: &str| { + let p = tools.path().join(name); + std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + }; + let base = [ + "-DCMAKE_BUILD_TYPE=Release", + "-DGGML_NATIVE=ON", + "-DLLAMA_BUILD_UI=OFF", + "-DLLAMA_USE_PREBUILT_UI=OFF", + ]; + let p = probe.build_plan().await; + assert_eq!( + (p.backend, p.flags.clone(), p.cuda_cxx.as_str()), + ("cpu", base.map(String::from).to_vec(), "") + ); + assert!(p.jobs >= 1); + + // nvcc dans le PATH seulement (racine /usr : rien à apprendre à CMake). + script("nvcc", ""); + script( + "nvidia-smi", + r#"case "$1" in -L) echo "GPU 0: RTX";; --query-gpu=compute_cap) printf '8.6\n8.9\n8.6\n';; esac"#, + ); + let p = probe.build_plan().await; + let nvcc = tools.path().join("nvcc").to_string_lossy().into_owned(); + let mut want: Vec = base.map(String::from).to_vec(); + want.extend(["-DGGML_CUDA=ON".into(), "-DGGML_CUDA_F16=ON".into()]); + want.push("-DCMAKE_CUDA_ARCHITECTURES=86;89".into()); + assert_eq!((p.backend, p.cuda_arch.as_str()), ("cuda", "86;89")); + // Le dossier des outils ne s'appelle pas bin : pas de racine. + assert_eq!(p.flags, want); + assert_eq!(p.cuda_cxx, nvcc); + + // Un vrai toolkit l'emporte, racine explicite. + let tk = root.path().join("usr/local/cuda-12.8/bin"); + std::fs::create_dir_all(&tk).unwrap(); + std::fs::write(tk.join("nvcc"), "").unwrap(); + let p = probe.build_plan().await; + let tk_nvcc = tk.join("nvcc").to_string_lossy().into_owned(); + let tk_root = root + .path() + .join("usr/local/cuda-12.8") + .to_string_lossy() + .into_owned(); + let mut want: Vec = base.map(String::from).to_vec(); + want.extend([ + "-DGGML_CUDA=ON".into(), + "-DGGML_CUDA_F16=ON".into(), + format!("-DCUDAToolkit_ROOT={tk_root}"), + format!("-DCMAKE_CUDA_COMPILER={tk_nvcc}"), + "-DCMAKE_CUDA_ARCHITECTURES=86;89".into(), + ]); + assert_eq!(p.flags, want); + assert_eq!(p.cuda_cxx, tk_nvcc); + + // Sans compute_cap : pas d'architecture imposée. + script("nvidia-smi", r#"[ "$1" = -L ] && echo "GPU 0: RTX""#); + let p = probe.build_plan().await; + assert!( + !p.flags + .iter() + .any(|f| f.starts_with("-DCMAKE_CUDA_ARCHITECTURES")) + ); + assert_eq!(p.cuda_arch, ""); + + // HIP, puis Vulkan. + std::fs::remove_file(tools.path().join("nvidia-smi")).unwrap(); + script("hipcc", ""); + let p = probe.build_plan().await; + assert_eq!( + (p.backend, p.flags.last().unwrap().as_str()), + ("hip", "-DGGML_HIP=ON") + ); + std::fs::remove_file(tools.path().join("hipcc")).unwrap(); + script("glslc", ""); + script("vulkaninfo", ""); + let p = probe.build_plan().await; + assert_eq!( + (p.backend, p.flags.last().unwrap().as_str()), + ("vulkan", "-DGGML_VULKAN=ON") + ); +} + +/// Les étapes suivies : sortie des deux flux dans l'ordre, journal écrit, +/// erreurs au format d'`exec`. +#[cfg(unix)] +#[tokio::test] +async fn erreurs_des_commandes() { + let tools = tempfile::tempdir().unwrap(); + let probe = Probe { + path: Some(vec![tools.path().to_path_buf()]), + root: tools.path().to_path_buf(), + }; + let p = tools.path().join("outil"); + std::fs::write(&p, "#!/bin/sh\necho un\necho deux >&2\nprintf trois\n[ -n \"$X\" ] && echo \"X=$X\"\nexit $1\n").unwrap(); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + let mut got = Vec::new(); + let log = tools.path().join("x.log"); + let r = run_lines_with( + &probe, + Some(tools.path()), + &[("X".into(), "1".into())], + "outil", + &args(&["3"]), + false, + Some(&log), + |l| got.push(l), + ) + .await; + assert_eq!(r, Err("exit status 3".into())); + assert_eq!(got, ["un", "deux", "troisX=1"]); + assert_eq!( + std::fs::read_to_string(&log).unwrap(), + "un\ndeux\ntroisX=1\n" + ); + let mut got = Vec::new(); + let r = run_lines_with(&probe, None, &[], "outil", &args(&["0"]), true, None, |l| { + got.push(l) + }) + .await; + assert_eq!(r, Ok(())); + assert_eq!(got, ["un", "deux"]); + let r = run_lines_with(&probe, None, &[], "absent", &[], true, None, |_| {}).await; + assert_eq!( + r, + Err("exec: \"absent\": executable file not found in $PATH".into()) + ); + std::fs::write(&p, "#!/bin/sh\nkill -9 $$\n").unwrap(); + let r = run_lines_with(&probe, None, &[], "outil", &[], true, None, |_| {}).await; + assert_eq!(r, Err("signal: killed".into())); +} diff --git a/rust/loki-rs/src/backends/mod.rs b/rust/loki-rs/src/backends/mod.rs index e4fd374..4a2212b 100644 --- a/rust/loki-rs/src/backends/mod.rs +++ b/rust/loki-rs/src/backends/mod.rs @@ -4,12 +4,14 @@ //! `handleBackends`, `handleBackendsCustom`, `handleLlamacppUninstallCustom` //! (`web_api.go`) et `handleBackendDevices` (`web_devices.go`). //! -//! La façade détient LE job des moteurs ([`job::Jobs`]) : le sien (binaires -//! précompilés) et ceux que le Go fait encore tourner — compilation depuis les -//! sources (install, update, install-custom, relayées au Go), moteur de +//! La façade détient LE job des moteurs ([`job::Jobs`]) : les siens +//! (binaires précompilés ; compilation depuis les sources — install, update, +//! install-custom — sous Linux et macOS depuis le lot 41, [`build`]) et ceux +//! que le Go fait encore tourner — compilation sous Windows, moteur de //! l'image, Strata — qui lui rapportent chaque étape par l'API interne //! (`/internal/facade/v1/build-job/*`). +pub mod build; pub mod devices; pub mod job; pub mod prebuilt; @@ -37,8 +39,9 @@ use probe::{Probe, is_dir, is_file}; /// Préfixe de l'API interne du job (pour le Go seulement). pub const INTERNAL_PREFIX: &str = "/internal/facade/v1/build-job/"; -/// `refuseIfProvided` n'est plus utile ici : les routes de compilation -/// restent au Go, qui répond lui-même. +const INSTALL_REQ_TYPE: &str = r#"struct { Force bool "json:\"force\"" }"#; +const UPDATE_REQ_TYPE: &str = r#"struct { Clean bool "json:\"clean\"" }"#; +const CUSTOM_REQ_TYPE: &str = r#"struct { Repo string "json:\"repo\""; Name string "json:\"name\""; Ref string "json:\"ref\"" }"#; const USE_REQ_TYPE: &str = r#"struct { Mode string "json:\"mode\"" }"#; const NAME_REQ_TYPE: &str = r#"struct { Name string "json:\"name\"" }"#; const BIN_REQ_TYPE: &str = r#"struct { Bin string "json:\"bin\"" }"#; @@ -361,6 +364,15 @@ pub fn handles(path: &str) -> bool { | "/api/backends" | "/api/backends/custom" | "/api/backends/devices" + ) || (cfg!(unix) && is_build_route(path)) +} + +/// Les routes de compilation depuis les sources (lot 41 ; Unix seulement, +/// la chaîne MSVC de Windows reste au Go). +fn is_build_route(path: &str) -> bool { + matches!( + path, + "/api/llamacpp/install" | "/api/llamacpp/update" | "/api/llamacpp/install-custom" ) } @@ -438,6 +450,7 @@ pub async fn serve( send_json(StatusCode::OK, &b.jobs.snapshot(go_atoi(&from), true).await) } "/api/llamacpp/uninstall-custom" => uninstall_custom(b, store, &raw).await, + p if cfg!(unix) && is_build_route(p) => start_build(ctx, b, p, &raw).await, "/api/backends" => { let list: Vec = b .list_backend_bins() @@ -486,6 +499,67 @@ pub async fn serve( } } +/// `handleLlamacppInstall`, `handleLlamacppUpdate`, +/// `handleLlamacppInstallCustom` : le job de compilation lancé. +async fn start_build(ctx: &Ctx, b: &Arc, path: &str, raw: &[u8]) -> Response { + let provided = b.provided_engine_bin(); + if !provided.is_empty() { + return fail( + StatusCode::CONFLICT, + format!( + "Le moteur est fourni par l'image Docker ({provided}) : il n'y a rien à compiler ici, \ + et l'image ne contient ni cmake ni compilateur. Pour le mettre à jour, utilise \ + « mettre à jour le moteur » dans Réglages → Moteur." + ), + ); + } + enum Run { + Install(bool), + Update(bool), + Custom(String, String, String), + } + let (action, run) = match path { + "/api/llamacpp/install" => { + let d = gojson::decode_struct(raw, INSTALL_REQ_TYPE, &[("force", Kind::Bool)]); + ("install", Run::Install(d.bool("force"))) + } + "/api/llamacpp/update" => { + let d = gojson::decode_struct(raw, UPDATE_REQ_TYPE, &[("clean", Kind::Bool)]); + ("update", Run::Update(d.bool("clean"))) + } + _ => { + let d = gojson::decode_struct( + raw, + CUSTOM_REQ_TYPE, + &[("repo", Kind::Str), ("name", Kind::Str), ("ref", Kind::Str)], + ); + if d.str("repo").trim().is_empty() { + return fail(StatusCode::BAD_REQUEST, "URL du dépôt requise"); + } + ( + "custom", + Run::Custom(d.str("repo"), d.str("name"), d.str("ref")), + ) + } + }; + if let Some(e) = tune_busy(&ctx.store).await { + return fail(StatusCode::CONFLICT, e); + } + if let Err(e) = b.jobs.start(&ctx.store, action).await { + return fail(StatusCode::CONFLICT, e); + } + let (b2, ctx2) = (b.clone(), ctx.clone()); + tokio::spawn(async move { + match run { + Run::Install(force) => build::run_install(&ctx2, &b2, force).await, + Run::Update(clean) => build::run_update(&ctx2, &b2, clean).await, + Run::Custom(url, name, r) => build::run_custom(&ctx2, &b2, &url, &name, &r).await, + } + b2.jobs.end(&ctx2.store).await; + }); + send_json(StatusCode::OK, &json!({ "ok": true })) +} + /// Valeur d'un paramètre de requête (dernière occurrence ignorée : la /// première, comme `Query().Get`). fn query_param(query: &str, key: &str) -> String { diff --git a/rust/loki-rs/tests/backends.rs b/rust/loki-rs/tests/backends.rs index 66389a8..5317294 100644 --- a/rust/loki-rs/tests/backends.rs +++ b/rust/loki-rs/tests/backends.rs @@ -656,3 +656,201 @@ fn extraction_zip() { "entrée d'archive suspecte : ../evasion" ); } + +/// Lot 41 : la compilation depuis les sources (install, update, +/// install-custom), rejouée sur le journal du Go +/// (`internal/loki/parity_build_test.go` → `parity_backends/build.json`) : +/// mêmes faux outils seuls dans le PATH des sondes, même vue du job (lignes, +/// phase, erreur, commits), même BIN, mêmes `build.old`. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn compilation_comme_le_go() { + let fx: Value = + serde_json::from_str(include_str!("parity_backends/build.json")).expect("journal"); + let bn = bench().await; + let tools = fx["tools"].as_object().unwrap(); + let write_tool = |name: &str| write_file(&bn.tools.join(name), s(&tools[name]), 0o755); + for name in tools.keys() { + write_tool(name); + } + let home = bn.home.to_string_lossy().into_owned(); + let jobs_re = Regex::new(r"jobs=[0-9]+").unwrap(); + let mask = |v: &str| { + jobs_re + .replace_all(&v.replace(&home, "{{HOME}}"), "jobs={{N}}") + .into_owned() + }; + for st in fx["steps"].as_array().unwrap() { + let name = s(&st["name"]); + if let Some(r) = st["remote"].as_str() { + std::fs::write(bn.tools.join("remote-sha"), format!("{r}\n")).unwrap(); + } + apply_files(&bn.home, &st["files"]); + let no_tools: Vec<&str> = st["no_tools"] + .as_array() + .into_iter() + .flatten() + .map(s) + .collect(); + for n in &no_tools { + let _ = std::fs::remove_file(bn.tools.join(n)); + } + let (path, body) = match s(&st["action"]) { + "install" => ( + "/api/llamacpp/install", + json!({ "force": st["force"] == json!(true) }), + ), + "update" => ( + "/api/llamacpp/update", + json!({ "clean": st["clean"] == json!(true) }), + ), + _ => ( + "/api/llamacpp/install-custom", + json!({ "repo": st["url"], "name": s(&st["bname"]), "ref": s(&st["ref"]) }), + ), + }; + let (code, v) = bn.call("POST", path, &body.to_string()).await; + assert_eq!((code, v), (200, json!({ "ok": true })), "{name}"); + bn.wait_job().await; + let (_, raw) = fakego::send(bn.facade, "GET", "/api/llamacpp/job", &[], "").await; + let mut job: Value = serde_json::from_str(&mask(&String::from_utf8_lossy(&raw))).unwrap(); + let o = job.as_object_mut().unwrap(); + o.remove("started_at"); + o.remove("ended_at"); + assert_eq!(job, st["job"], "{name} : job"); + let bin = bn.go.get("config", "BIN").unwrap_or_default(); + assert_eq!(mask(&bin), s(&st["bin"]), "{name} : BIN"); + let mut olds: Vec = std::fs::read_dir(bn.home.join("backends")) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.path().join("build.old").is_dir()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + olds.sort(); + assert_eq!(json!(olds), st["build_old"], "{name} : build.old"); + for n in &no_tools { + write_tool(n); + } + } +} + +/// Les refus des routes de compilation : moteur de l'image, URL absente, +/// optimisation en cours, job déjà lancé. +#[cfg(unix)] +#[tokio::test] +async fn compilation_refusee() { + let bn = bench().await; + let moteur = bn.home.join("image/llama-server"); + std::fs::create_dir_all(moteur.parent().unwrap()).unwrap(); + write_file(&moteur, "#!/bin/sh\n", 0o755); + bn.b.set_engine_bin_env(&moteur.to_string_lossy()); + let (code, v) = bn.call("POST", "/api/llamacpp/update", "{}").await; + assert_eq!(code, 409); + assert_eq!( + v["error"], + "Le moteur est fourni par l'image Docker ({{HOME}}/image/llama-server) : il n'y a rien à compiler ici, \ + et l'image ne contient ni cmake ni compilateur. Pour le mettre à jour, utilise \ + « mettre à jour le moteur » dans Réglages → Moteur." + ); + bn.b.set_engine_bin_env(""); + let (code, v) = bn + .call("POST", "/api/llamacpp/install-custom", r#"{"repo":" "}"#) + .await; + assert_eq!( + (code, v), + (400, json!({ "ok": false, "error": "URL du dépôt requise" })) + ); + *bn.go.tune_busy.lock().unwrap() = Some(TUNE_BUSY.into()); + let (code, v) = bn.call("POST", "/api/llamacpp/install", "{}").await; + assert_eq!((code, v), (409, json!({ "ok": false, "error": TUNE_BUSY }))); + *bn.go.tune_busy.lock().unwrap() = None; + let backend = bn.go.backend.lock().unwrap().unwrap(); + let store = loki_rs::store::Store::new(backend, fakego::JETON.into()); + bn.b.jobs.start(&store, "prebuilt").await.unwrap(); + let (code, v) = bn.call("POST", "/api/llamacpp/update", "{}").await; + assert_eq!( + (code, v), + ( + 409, + json!({ "ok": false, "error": "un job prebuilt est déjà en cours" }) + ) + ); +} + +/// La mise à jour arrête le moteur le temps du build et le relance, même +/// quand le build échoue. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn compilation_arrete_le_service() { + let fx: Value = + serde_json::from_str(include_str!("parity_backends/build.json")).expect("journal"); + let bn = bench().await; + for (name, script) in fx["tools"].as_object().unwrap() { + write_file(&bn.tools.join(name), s(script), 0o755); + } + let remote = + |sha: &str| std::fs::write(bn.tools.join("remote-sha"), format!("{sha}\n")).unwrap(); + let lines = |job: &Value| -> Vec { + job["lines"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect() + }; + remote("aaaaaaa1111111111111111111111111111111"); + assert_eq!(bn.call("POST", "/api/llamacpp/install", "{}").await.0, 200); + bn.wait_job().await; + assert!( + bn.go.service_actions.lock().unwrap().is_empty(), + "l'installation ne touche pas au service" + ); + + *bn.go.service_active.lock().unwrap() = true; + for (sha, fail_build) in [ + ("bbbbbbb2222222222222222222222222222222", false), + ("ccccccc3333333333333333333333333333333", true), + ] { + remote(sha); + let marker = bn.home.join("backends/llama.cpp/FAIL_BUILD"); + if fail_build { + std::fs::write(&marker, "1").unwrap(); + } + bn.go.service_actions.lock().unwrap().clear(); + assert_eq!(bn.call("POST", "/api/llamacpp/update", "{}").await.0, 200); + bn.wait_job().await; + let (_, job) = bn.call("GET", "/api/llamacpp/job", "").await; + let l = lines(&job); + let stop = l + .iter() + .position(|x| x == "▶ arrêt du service le temps du build…") + .expect("arrêt"); + let cfg = l + .iter() + .position(|x| x == "▶ configuration CMake…") + .unwrap(); + let start = l + .iter() + .position(|x| x == "▶ redémarrage du service…") + .expect("relance"); + assert!(stop < cfg && cfg < start, "{l:?}"); + assert_eq!( + *bn.go.service_actions.lock().unwrap(), + vec![("stop".to_string(), true), ("start".to_string(), false)] + ); + if fail_build { + assert_eq!(job["error"], "compilation échouée: exit status 2"); + // Comme au Go : l'échec est posé, puis le service relancé. + assert_eq!( + &l[l.len() - 2..], + [ + "✗ compilation échouée: exit status 2", + "▶ redémarrage du service…" + ] + ); + } else { + assert_eq!(l.last().unwrap(), "✓ mis à jour : aaaaaaa → bbbbbbb"); + } + } +} diff --git a/rust/loki-rs/tests/parity_backends/build.json b/rust/loki-rs/tests/parity_backends/build.json new file mode 100644 index 0000000..35c7a55 --- /dev/null +++ b/rust/loki-rs/tests/parity_backends/build.json @@ -0,0 +1,482 @@ +{ + "tools": { + "c++": "#!/bin/sh\n", + "cc": "#!/bin/sh\n", + "cmake": "#!/bin/sh\nif [ \"$1\" = \"-B\" ]; then\n mkdir -p build\n echo \"-- The C compiler identification is GNU 13.2.0\"\n echo \"-- Configuring done (0.5s)\"\n printf 'CMAKE_HOME_DIRECTORY:INTERNAL=%s\\n' \"$(pwd)\" \u003e build/CMakeCache.txt\n echo \"-- Build files have been written to: build\"\n exit 0\nfi\nif [ -f FAIL_BUILD ]; then\n echo \"[ 10%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o\"\n echo \"common/arg.cpp:12:5: error: 'boom' was not declared in this scope\"\n exit 2\nfi\necho \"[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o\"\necho \"[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o\"\necho \"[100%] Linking CXX executable ../bin/llama-server\"\nmkdir -p build/bin\nprintf '#!/bin/sh\\necho llama-server\\n' \u003e build/bin/llama-server\nchmod +x build/bin/llama-server\n", + "git": "#!/bin/sh\nd=$(dirname \"$0\")\ncase \"$1\" in\n clone)\n dir=\"$4\"\n if [ -f \"$(dirname \"$dir\")/CLONE_FAIL\" ]; then echo \"fatal: repository '$3' not found\" \u003e\u00262; exit 128; fi\n mkdir -p \"$dir/.git\"\n cat \"$d/remote-sha\" \u003e \"$dir/.git/HEAD_SHA\"\n echo \"Cloning into '$dir'...\" \u003e\u00262 ;;\n fetch)\n exit 0 ;;\n rev-parse)\n case \"$2\" in\n --short)\n if [ \"$3\" = HEAD ]; then head -c 7 .git/HEAD_SHA; else head -c 7 \"$d/remote-sha\"; fi\n echo ;;\n --abbrev-ref) echo master ;;\n HEAD) cat .git/HEAD_SHA ;;\n *) cat \"$d/remote-sha\" ;;\n esac ;;\n pull)\n cat \"$d/remote-sha\" \u003e .git/HEAD_SHA\n echo \"Fast-forward\" ;;\n checkout)\n echo \"$2\" \u003e .git/REF\n echo \"HEAD is now at $2\" \u003e\u00262 ;;\nesac\n", + "make": "#!/bin/sh\n" + }, + "steps": [ + { + "name": "mise à jour sans dépôt", + "remote": "aaaaaaa1111111111111111111111111111111", + "action": "update", + "job": { + "action": "update", + "compiled": 0, + "error": "aucun dépôt llama.cpp ({{HOME}}/backends/llama.cpp) — lance d'abord l'installation", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "✗ aucun dépôt llama.cpp ({{HOME}}/backends/llama.cpp) — lance d'abord l'installation" + ], + "new": "", + "next": 2, + "old": "", + "phase": "échec", + "running": false + }, + "bin": "", + "build_old": [] + }, + { + "name": "installation", + "action": "install", + "job": { + "action": "install", + "compiled": 2, + "error": "", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "▶ clone de llama.cpp…", + "Cloning into '{{HOME}}/backends/llama.cpp'...", + "plan de build : backend=cpu arch= jobs={{N}}", + "▶ configuration CMake…", + "-- The C compiler identification is GNU 13.2.0", + "-- Configuring done (0.5s)", + "-- Build files have been written to: build", + "[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o", + "[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o", + "[100%] Linking CXX executable ../bin/llama-server", + "binaire compilé : {{HOME}}/backends/llama.cpp/build/bin/llama-server", + "BIN mis à jour", + "✓ installation terminée" + ], + "new": "", + "next": 14, + "old": "", + "phase": "installation terminée", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [] + }, + { + "name": "mise à jour : déjà à jour", + "action": "update", + "job": { + "action": "update", + "compiled": 0, + "error": "", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "▶ git fetch origin…", + "✓ déjà à jour (aaaaaaa) — rien à faire" + ], + "new": "", + "next": 3, + "old": "aaaaaaa", + "phase": "déjà à jour (aaaaaaa) — rien à faire", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [] + }, + { + "name": "mise à jour : nouveau commit", + "remote": "bbbbbbb2222222222222222222222222222222", + "action": "update", + "job": { + "action": "update", + "compiled": 2, + "error": "", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "▶ git fetch origin…", + "▶ git pull origin/master…", + "Fast-forward", + "plan de build : backend=cpu arch= jobs={{N}}", + "▶ configuration CMake…", + "-- The C compiler identification is GNU 13.2.0", + "-- Configuring done (0.5s)", + "-- Build files have been written to: build", + "[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o", + "[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o", + "[100%] Linking CXX executable ../bin/llama-server", + "binaire compilé : {{HOME}}/backends/llama.cpp/build/bin/llama-server", + "BIN mis à jour", + "✓ mis à jour : aaaaaaa → bbbbbbb" + ], + "new": "bbbbbbb", + "next": 15, + "old": "aaaaaaa", + "phase": "mis à jour : aaaaaaa → bbbbbbb", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [] + }, + { + "name": "recompilation propre", + "action": "update", + "clean": true, + "job": { + "action": "update", + "compiled": 2, + "error": "", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "▶ git fetch origin…", + "plan de build : backend=cpu arch= jobs={{N}}", + "▶ configuration CMake…", + "-- The C compiler identification is GNU 13.2.0", + "-- Configuring done (0.5s)", + "-- Build files have been written to: build", + "[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o", + "[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o", + "[100%] Linking CXX executable ../bin/llama-server", + "binaire compilé : {{HOME}}/backends/llama.cpp/build/bin/llama-server", + "BIN mis à jour", + "✓ recompilé (bbbbbbb)" + ], + "new": "bbbbbbb", + "next": 13, + "old": "bbbbbbb", + "phase": "recompilé (bbbbbbb)", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [ + "llama.cpp" + ] + }, + { + "name": "installation sur un dépôt présent", + "remote": "ccccccc3333333333333333333333333333333", + "action": "install", + "job": { + "action": "install", + "compiled": 2, + "error": "", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "▶ dépôt déjà présent — bascule en mise à jour", + "▶ vérification des outils (git, cmake, compilateur)…", + "▶ git fetch origin…", + "▶ git pull origin/master…", + "Fast-forward", + "plan de build : backend=cpu arch= jobs={{N}}", + "▶ configuration CMake…", + "-- The C compiler identification is GNU 13.2.0", + "-- Configuring done (0.5s)", + "-- Build files have been written to: build", + "[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o", + "[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o", + "[100%] Linking CXX executable ../bin/llama-server", + "binaire compilé : {{HOME}}/backends/llama.cpp/build/bin/llama-server", + "BIN mis à jour", + "✓ mis à jour : bbbbbbb → ccccccc" + ], + "new": "ccccccc", + "next": 17, + "old": "bbbbbbb", + "phase": "mis à jour : bbbbbbb → ccccccc", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [ + "llama.cpp" + ] + }, + { + "name": "build en échec", + "remote": "ddddddd4444444444444444444444444444444", + "files": { + "backends/llama.cpp/FAIL_BUILD": "1" + }, + "action": "update", + "job": { + "action": "update", + "compiled": 2, + "error": "compilation échouée: exit status 2", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "▶ git fetch origin…", + "▶ git pull origin/master…", + "Fast-forward", + "plan de build : backend=cpu arch= jobs={{N}}", + "▶ configuration CMake…", + "-- The C compiler identification is GNU 13.2.0", + "-- Configuring done (0.5s)", + "-- Build files have been written to: build", + "[ 10%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o", + "common/arg.cpp:12:5: error: 'boom' was not declared in this scope", + "--- fin de configure.log ---", + "-- The C compiler identification is GNU 13.2.0", + "-- Configuring done (0.5s)", + "-- Build files have been written to: build", + "--- fin de build.log ---", + "[ 10%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o", + "common/arg.cpp:12:5: error: 'boom' was not declared in this scope", + "✗ compilation échouée: exit status 2" + ], + "new": "ddddddd", + "next": 19, + "old": "ccccccc", + "phase": "échec", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [ + "llama.cpp" + ] + }, + { + "name": "installation forcée", + "files": { + "backends/llama.cpp/FAIL_BUILD": "-" + }, + "action": "install", + "force": true, + "job": { + "action": "install", + "compiled": 2, + "error": "", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "▶ suppression du dépôt existant (--force)…", + "▶ clone de llama.cpp…", + "Cloning into '{{HOME}}/backends/llama.cpp'...", + "plan de build : backend=cpu arch= jobs={{N}}", + "▶ configuration CMake…", + "-- The C compiler identification is GNU 13.2.0", + "-- Configuring done (0.5s)", + "-- Build files have been written to: build", + "[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o", + "[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o", + "[100%] Linking CXX executable ../bin/llama-server", + "binaire compilé : {{HOME}}/backends/llama.cpp/build/bin/llama-server", + "BIN mis à jour", + "✓ installation terminée" + ], + "new": "", + "next": 15, + "old": "", + "phase": "installation terminée", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [] + }, + { + "name": "cmake absent", + "no_tools": [ + "cmake" + ], + "action": "update", + "job": { + "action": "update", + "compiled": 0, + "error": "outils toujours manquants après tentative d'install: cmake — installe-les à la main puis réessaie", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "✗ outils toujours manquants après tentative d'install: cmake — installe-les à la main puis réessaie" + ], + "new": "", + "next": 2, + "old": "", + "phase": "échec", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [] + }, + { + "name": "compilateur absent", + "no_tools": [ + "cc", + "make" + ], + "action": "update", + "job": { + "action": "update", + "compiled": 0, + "error": "compilateur C/C++ introuvable — installe gcc/g++/make (ou build-essential) manuellement", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "✗ compilateur C/C++ introuvable — installe gcc/g++/make (ou build-essential) manuellement" + ], + "new": "", + "next": 2, + "old": "", + "phase": "échec", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [] + }, + { + "name": "clone en échec", + "files": { + "backends/CLONE_FAIL": "1", + "backends/llama.cpp": "-" + }, + "action": "install", + "job": { + "action": "install", + "compiled": 0, + "error": "git clone a échoué : exit status 128", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "▶ clone de llama.cpp…", + "fatal: repository 'https://github.com/ggml-org/llama.cpp.git' not found", + "✗ git clone a échoué : exit status 128" + ], + "new": "", + "next": 4, + "old": "", + "phase": "échec", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [] + }, + { + "name": "custom : URL invalide", + "files": { + "backends/CLONE_FAIL": "-" + }, + "action": "custom", + "url": "ftp://exemple/x", + "job": { + "action": "custom", + "compiled": 0, + "error": "URL de dépôt invalide (attendu https://…, git@… ou ssh://…) : ftp://exemple/x", + "exists": true, + "lines": [ + "✗ URL de dépôt invalide (attendu https://…, git@… ou ssh://…) : ftp://exemple/x" + ], + "new": "", + "next": 1, + "old": "", + "phase": "échec", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [] + }, + { + "name": "custom : nouveau fork", + "action": "custom", + "url": "https://github.com/prism/llama.cpp.git", + "job": { + "action": "custom", + "compiled": 2, + "error": "", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "▶ clone de https://github.com/prism/llama.cpp.git…", + "Cloning into '{{HOME}}/backends/llama.cpp-prism'...", + "▶ compilation (backend=cpu)…", + "-- The C compiler identification is GNU 13.2.0", + "-- Configuring done (0.5s)", + "-- Build files have been written to: build", + "[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o", + "[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o", + "[100%] Linking CXX executable ../bin/llama-server", + "binaire compilé : {{HOME}}/backends/llama.cpp-prism/build/bin/llama-server", + "→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.", + "✓ backend custom installé" + ], + "new": "", + "next": 13, + "old": "", + "phase": "backend custom installé", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [] + }, + { + "name": "custom : nom assaini, sur un ref", + "action": "custom", + "url": "https://github.com/prism/llama.cpp.git", + "bname": "Mon Fork/../x", + "ref": "v2", + "job": { + "action": "custom", + "compiled": 2, + "error": "", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "▶ clone de https://github.com/prism/llama.cpp.git…", + "Cloning into '{{HOME}}/backends/mon-fork-..-x'...", + "HEAD is now at v2", + "▶ compilation (backend=cpu)…", + "-- The C compiler identification is GNU 13.2.0", + "-- Configuring done (0.5s)", + "-- Build files have been written to: build", + "[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o", + "[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o", + "[100%] Linking CXX executable ../bin/llama-server", + "binaire compilé : {{HOME}}/backends/mon-fork-..-x/build/bin/llama-server", + "→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.", + "✓ backend custom installé" + ], + "new": "", + "next": 14, + "old": "", + "phase": "backend custom installé", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [] + }, + { + "name": "custom : dossier existant sur un ref", + "action": "custom", + "url": "git@github.com:prism/llama.cpp.git", + "ref": "v3", + "job": { + "action": "custom", + "compiled": 2, + "error": "", + "exists": true, + "lines": [ + "▶ vérification des outils (git, cmake, compilateur)…", + "▶ dépôt déjà présent — mise à jour…", + "HEAD is now at v3", + "▶ compilation (backend=cpu)…", + "-- The C compiler identification is GNU 13.2.0", + "-- Configuring done (0.5s)", + "-- Build files have been written to: build", + "[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o", + "[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o", + "[100%] Linking CXX executable ../bin/llama-server", + "binaire compilé : {{HOME}}/backends/llama.cpp-prism/build/bin/llama-server", + "→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.", + "✓ backend custom installé" + ], + "new": "", + "next": 13, + "old": "", + "phase": "backend custom installé", + "running": false + }, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "build_old": [] + } + ] +}