//! Lot 22 : backends llama.cpp tenus par la façade (`LOKI_BACKENDS=rust`). //! //! Le journal tiré du Go (`internal/loki/parity_backends_test.go` → //! `tests/parity_backends/backends.json`) est rejoué par une seule façade : //! statut (dépôt git, précompilé, moteur téléchargé ou de l'image, plan de //! build et conseil), vérification git, backends custom et désinstallation, //! bascule fast/opt, dernière release, job des binaires précompilés //! (extraction avec liens, ménage, VERSION, échecs), GPU vus par un moteur ; //! puis le plan et la release machine par machine (faux outils dans le PATH), //! le job pas à pas et sa reprise au démarrage. Le reste — l'API interne par //! laquelle les jobs du Go rapportent leurs étapes, le service arrêté puis //! relancé autour de l'installation — est vérifié contre le faux Go. mod common; use std::collections::BTreeMap; use std::convert::Infallible; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; use bytes::Bytes; use common::fakego; use common::parity::{apply_files, tar_gz, tree, write_file}; use http_body_util::Full; use hyper::service::service_fn; use hyper::{Request, Response}; use hyper_util::rt::TokioIo; use loki_rs::backends::job::Jobs; use loki_rs::backends::probe::Probe; use loki_rs::backends::{Backends, prebuilt}; use regex::Regex; use serde_json::{Value, json}; use tokio::net::TcpListener; fn fixture() -> Value { serde_json::from_str(include_str!("parity_backends/backends.json")).expect("journal") } fn s(v: &Value) -> &str { v.as_str().unwrap_or("") } const TUNE_BUSY: &str = "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la"; /// Le faux GitHub : la release courante (`{status, json, assets}`). async fn fake_github(rel: Arc>) -> String { let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = l.local_addr().unwrap(); tokio::spawn(async move { while let Ok((st, _)) = l.accept().await { let rel = rel.clone(); tokio::spawn(async move { let svc = service_fn(move |req: Request| { let cur = rel.lock().unwrap().clone(); async move { let host = req .headers() .get("host") .and_then(|h| h.to_str().ok()) .unwrap_or("") .to_string(); let path = req.uri().path().to_string(); let (code, body) = if path == "/latest" { ( cur["status"].as_u64().unwrap_or(200) as u16, s(&cur["json"]) .replace("{{GH}}", &format!("http://{host}")) .into_bytes(), ) } else { let name = path.trim_start_matches("/dl/"); match cur["assets"].get(name).and_then(Value::as_array) { Some(entries) => (200, tar_gz(entries)), None => (404, b"404 page not found\n".to_vec()), } }; let mut r = Response::new(Full::new(Bytes::from(body))); *r.status_mut() = hyper::StatusCode::from_u16(code).unwrap(); Ok::<_, Infallible>(r) } }); let _ = hyper::server::conn::http1::Builder::new() .serve_connection(TokioIo::new(st), svc) .await; }); } }); format!("http://{addr}") } /// Les opérations git d'une étape (`parityGit`). fn git_op(home: &Path, op: &str) { let f: Vec<&str> = op.splitn(4, ' ').collect(); let git = |dir: &Path, date: &str, args: &[&str]| { let out = std::process::Command::new("git") .args(args) .current_dir(dir) .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_AUTHOR_NAME", "Loki") .env("GIT_AUTHOR_EMAIL", "loki@exemple.fr") .env("GIT_COMMITTER_NAME", "Loki") .env("GIT_COMMITTER_EMAIL", "loki@exemple.fr") .env("GIT_AUTHOR_DATE", date) .env("GIT_COMMITTER_DATE", date) .output() .unwrap(); assert!(out.status.success(), "git {args:?} : {out:?}"); }; match f[0] { "init" => { let d = home.join(f[1]); std::fs::create_dir_all(&d).unwrap(); git(&d, "", &["init", "-q", "-b", "master"]); } "commit" => { let d = home.join(f[1]); std::fs::write(d.join("README"), f[3]).unwrap(); git(&d, f[2], &["add", "README"]); git(&d, f[2], &["commit", "-q", "-m", f[3]]); } "clone" => { let (src, dst) = (home.join(f[1]), home.join(f[2])); git( home, "", &[ "clone", "-q", &src.to_string_lossy(), &dst.to_string_lossy(), ], ); } "pull" => git(&home.join(f[1]), "", &["pull", "-q", "--ff-only"]), "detach" => git(&home.join(f[1]), "", &["checkout", "-q", "--detach"]), "remote-bad" => { let bad = home.join("nulle-part"); git( &home.join(f[1]), "", &["remote", "set-url", "origin", &bad.to_string_lossy()], ); } other => panic!("opération git inconnue : {other}"), } } /// Les outils d'une machine dans le PATH des sondes (git compris). fn set_machine(tools: &Path, machines: &Value, name: &str) { for e in std::fs::read_dir(tools).unwrap().flatten() { std::fs::remove_file(e.path()).unwrap(); } let git = loki_rs::paths::look_path("git").expect("git"); std::os::unix::fs::symlink(git, tools.join("git")).unwrap(); for (tool, script) in machines[name].as_object().into_iter().flatten() { write_file(&tools.join(tool), s(script), 0o755); } } struct Bench { facade: SocketAddr, go: Arc, _stop: tokio::sync::oneshot::Sender<()>, home: PathBuf, tools: PathBuf, b: Arc, rel: Arc>, gh: String, _tmp: Vec, } async fn bench() -> Bench { let tmp = tempfile::tempdir().unwrap(); let tools = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap(); let home = tmp.path().to_path_buf(); let rel = Arc::new(Mutex::new(json!({ "status": 200, "json": "{}" }))); let gh = fake_github(rel.clone()).await; let b = Arc::new(Backends::with( home.clone(), String::new(), Probe { path: Some(vec![tools.path().to_path_buf()]), root: root.path().to_path_buf(), }, format!("{gh}/latest"), )); let (facade, go, stop, _) = fakego::start_with( home.clone(), fakego::Opts { backends: Some(b.clone()), ..Default::default() }, ) .await; Bench { facade, go, _stop: stop, home, tools: tools.path().to_path_buf(), b, rel, gh, _tmp: vec![tmp, tools, root], } } impl Bench { fn mask(&self, v: &str) -> String { let ts = Regex::new(r#""(started_at|ended_at)":[1-9][0-9]*"#).unwrap(); let jobs = Regex::new(r#""jobs":[0-9]+"#).unwrap(); let v = v .replace(&self.gh, "{{GH}}") .replace(&self.home.to_string_lossy().into_owned(), "{{HOME}}"); let v = jobs.replace_all(&v, r#""jobs":"{{JOBS}}""#); ts.replace_all(&v, r#""$1":"{{T}}""#).into_owned() } fn unmask(&self, v: &str) -> String { v.replace("{{HOME}}", &self.home.to_string_lossy()) } async fn call(&self, method: &str, path: &str, body: &str) -> (u16, Value) { let (code, b) = fakego::send(self.facade, method, path, &[], body).await; let out = serde_json::from_str(&self.mask(&String::from_utf8_lossy(&b))) .unwrap_or_else(|_| panic!("{path} : corps illisible {b:?}")); (code.as_u16(), out) } async fn wait_job(&self) { for _ in 0..1500 { let (_, v) = self.call("GET", "/api/llamacpp/job", "").await; if v["running"] != json!(true) { return; } tokio::time::sleep(Duration::from_millis(20)).await; } panic!("le job ne finit pas"); } } #[tokio::test] async fn parite_des_routes() { let fx = fixture(); if (fx["goarch"] == "arm64") != cfg!(target_arch = "aarch64") { eprintln!("journal tiré sur une autre architecture : rejeu sauté"); return; } let bn = bench().await; let machines = &fx["machines"]; set_machine(&bn.tools, machines, "nu"); for st in fx["steps"].as_array().unwrap() { let name = s(&st["name"]); if let Some(m) = st["machine"].as_str() { set_machine(&bn.tools, machines, m); } for g in st["git"].as_array().into_iter().flatten() { git_op(&bn.home, s(g)); } let files: serde_json::Map = st["files"] .as_object() .into_iter() .flatten() .map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v))))) .collect(); apply_files(&bn.home, &Value::Object(files)); for (k, v) in st["config"].as_object().into_iter().flatten() { let v = bn.unmask(s(v)); if v.is_empty() { bn.go.del("config", k); } else { bn.go.set("config", k, &v); } } if let Some(e) = st["engine_env"].as_str() { bn.b.set_engine_bin_env(&bn.unmask(e)); } if !st["release"].is_null() { *bn.rel.lock().unwrap() = st["release"].clone(); } *bn.go.tune_busy.lock().unwrap() = (st["tune"].as_bool() == Some(true)).then(|| TUNE_BUSY.to_string()); let (code, out) = bn .call(s(&st["method"]), s(&st["path"]), &bn.unmask(s(&st["body"]))) .await; *bn.go.tune_busy.lock().unwrap() = None; assert_eq!(code as i64, st["status"].as_i64().unwrap(), "{name}"); assert_eq!(out, st["out"], "{name}"); if st["wait"].as_bool() == Some(true) { bn.wait_job().await; let (_, job) = bn.call("GET", "/api/llamacpp/job?from=0", "").await; assert_eq!(job, st["job"], "{name} : job"); } let mut after = BTreeMap::new(); if let Some(v) = bn.go.get("config", "BIN").filter(|v| !v.is_empty()) { after.insert("config/BIN".to_string(), Value::String(bn.mask(&v))); } if let Some(v) = bn.go.get("state", "build_job").filter(|v| !v.is_empty()) { after.insert("state/build_job".to_string(), Value::String(bn.mask(&v))); } for f in ["loki-build.log", "devices.json"] { if let Ok(b) = std::fs::read(bn.home.join(f)) { after.insert( f.to_string(), Value::String(bn.mask(&String::from_utf8_lossy(&b))), ); } } assert_eq!( Value::Object(after.into_iter().collect()), st["after"], "{name} : après" ); let t: Vec = tree(&bn.home) .into_iter() .map(|l| Value::String(bn.mask(&l))) .collect(); // Une liste vide est nil côté Go : null. let t = if t.is_empty() { Value::Null } else { Value::Array(t) }; assert_eq!(t, st["tree"], "{name} : arborescence"); } // Plan, conseil et release, machine par machine. *bn.rel.lock().unwrap() = fx["plan_release"].clone(); for p in fx["plans"].as_array().unwrap() { let m = s(&p["machine"]); set_machine(&bn.tools, machines, m); let (_, out) = bn.call("GET", "/api/llamacpp", "").await; assert_eq!(out["plan"], p["plan"], "{m} : plan"); assert_eq!(out["reco"], p["reco"], "{m} : conseil"); let (_, check) = bn.call("POST", "/api/llamacpp/prebuilt/check", "").await; assert_eq!(check, p["check"], "{m} : release"); } for (machine, key) in [ ("vulkaninfo-seul", "sans_vulkan"), ("rocm", "rocm_sans_asset"), ] { set_machine(&bn.tools, machines, machine); *bn.rel.lock().unwrap() = fx[key]["release"].clone(); let (_, check) = bn.call("POST", "/api/llamacpp/prebuilt/check", "").await; assert_eq!(check, fx[key]["out"], "{key}"); } } /// `norm` du journal : les instants masqués. fn norm(v: &Value) -> Value { let ts = Regex::new(r#""(started_at|ended_at)":[1-9][0-9]*"#).unwrap(); serde_json::from_str(&ts.replace_all(&v.to_string(), r#""$1":"{{T}}""#)).unwrap() } #[tokio::test] async fn parite_du_job_pas_a_pas() { let fx = fixture(); let bn = bench().await; let backend = bn.go.backend.lock().unwrap().unwrap(); let store = loki_rs::store::Store::new(backend, fakego::JETON.into()); let ts = Regex::new(r#""(started_at|ended_at)":[1-9][0-9]*"#).unwrap(); let state = || { ts.replace_all( &bn.go.get("state", "build_job").unwrap_or_default(), r#""$1":"{{T}}""#, ) .into_owned() }; let jobs = Jobs::new(bn.home.clone()); let job = &fx["job"]; for o in job["ops"].as_array().unwrap() { let (op, arg) = (s(&o["op"]), s(&o["arg"])); let from = o["from"].as_i64().unwrap_or(0); let got = match op { "start" => Some(Value::String(match jobs.start(&store, arg).await { Ok(()) => "ok".into(), Err(e) => e, })), "line" => { jobs.append(&store, arg).await; None } "lines" => { for _ in 0..4002 { jobs.append(&store, &format!("[{arg}] bruit")).await; } None } "phase" => { jobs.phase(&store, arg).await; None } "fail" => { jobs.fail(&store, arg).await; None } "done" => { jobs.done(&store, arg).await; None } "old" => { jobs.set_commits(Some(arg), None).await; None } "new" => { jobs.set_commits(None, Some(arg)).await; None } "end" => { jobs.end(&store).await; None } "snapshot" => Some(norm(&jobs.snapshot(from, true).await)), "summary" => { let mut v = jobs.snapshot(from, true).await; let lines = v["lines"].as_array().unwrap().clone(); v["lines"] = json!({ "n": lines.len(), "first": lines[0], "last": lines[lines.len() - 1], }); Some(norm(&v)) } "head" => Some(norm(&jobs.snapshot(0, false).await)), "state" => Some(Value::String(state())), other => panic!("op inconnue {other}"), }; assert_eq!(got.unwrap_or(Value::Null), o["out"], "{o}"); } let log = std::fs::read_to_string(jobs.log_path()).unwrap(); assert_eq!( log.matches('\n').count() as i64, job["log_lines"].as_i64().unwrap() ); for rc in job["restores"].as_array().unwrap() { let st = s(&rc["state"]); if st.is_empty() { bn.go.del("state", "build_job"); } else { bn.go.set("state", "build_job", st); } let jobs = Jobs::new(bn.home.clone()); let _ = std::fs::remove_file(jobs.log_path()); if let Some(l) = rc["log"].as_str() { std::fs::write(jobs.log_path(), l).unwrap(); } jobs.restore(&store).await; assert_eq!(norm(&jobs.snapshot(0, true).await), rc["out"], "{rc}"); assert_eq!(state(), s(&rc["after"]), "{rc} : base"); } let jobs = Jobs::new(bn.home.clone()); bn.go.set( "state", "build_job", r#"{"action":"update","running":false}"#, ); std::fs::write(jobs.log_path(), "ligne\n".repeat(4003)).unwrap(); jobs.restore(&store).await; assert_eq!(norm(&jobs.snapshot(4000, true).await), job["long_restore"]); } /// Les étapes des jobs du Go, par l'API interne (jeton du Go seulement). #[tokio::test] async fn api_interne_du_job() { let bn = bench().await; let tok = [("X-Loki-Internal", fakego::JETON)]; let post = |op: &'static str, body: &'static str| { let f = bn.facade; async move { let (c, b) = fakego::send( f, "POST", &format!("/internal/facade/v1/build-job/{op}"), &tok, body, ) .await; (c.as_u16(), String::from_utf8_lossy(&b).into_owned()) } }; // Sans le jeton : l'API interne n'existe pas. let (c, _) = fakego::send( bn.facade, "POST", "/internal/facade/v1/build-job/start", &[], r#"{"action":"engine"}"#, ) .await; assert_eq!(c.as_u16(), 404); assert_eq!(post("start", r#"{"action":"engine"}"#).await.0, 200); let (c, b) = post("start", r#"{"action":"strata"}"#).await; assert_eq!( (c, b.contains("un job engine est déjà en cours")), (409, true) ); // La route publique refuse aussi un second job (précompilé). let (c, out) = bn.call("POST", "/api/llamacpp/prebuilt", "").await; assert_eq!(c, 409); assert_eq!(out["error"], "un job engine est déjà en cours"); post("commits", r#"{"old":"aaa"}"#).await; post( "phase", r#"{"phase":"recherche de la dernière version publiée…"}"#, ) .await; post( "line", r#"{"line":"[ 50%] Building CXX object a/b/c.cpp.o"}"#, ) .await; post("commits", r#"{"new":"bbb"}"#).await; post("fail", r#"{"error":"raté"}"#).await; post("done", r#"{"msg":"rattrapé"}"#).await; let (_, v) = bn.call("GET", "/api/llamacpp/job?from=1", "").await; assert_eq!(v["running"], true); assert_eq!(v["compiled"], 1); assert_eq!(v["old"], "aaa"); assert_eq!(v["new"], "bbb"); assert_eq!( v["lines"], json!([ "[ 50%] Building CXX object a/b/c.cpp.o", "✗ raté", "✓ rattrapé" ]) ); // La vue que le Go relaie (statut du moteur de l'image). let (c, b) = fakego::send( bn.facade, "GET", "/internal/facade/v1/build-job/snapshot?from=0&lines=0", &tok, "", ) .await; assert_eq!(c.as_u16(), 200); let head: Value = serde_json::from_slice(&b).unwrap(); assert_eq!(head["phase"], "rattrapé"); assert!(head.get("lines").is_none()); assert_eq!(post("end", "{}").await.0, 200); assert_eq!( bn.call("GET", "/api/llamacpp", "").await.1["job"]["running"], false ); // Méthode et opération. let (c, _) = fakego::send( bn.facade, "GET", "/internal/facade/v1/build-job/line", &tok, "", ) .await; assert_eq!(c.as_u16(), 405); assert_eq!(post("autre", "{}").await.0, 404); // L'entête suit en base. let st: Value = serde_json::from_str(&bn.go.get("state", "build_job").unwrap()).unwrap(); assert_eq!(st["action"], "engine"); assert_eq!(st["running"], false); assert_eq!(st["error"], "raté"); } /// Le moteur tourne : arrêté pendant l'installation, relancé après ; la /// bascule le relance. #[tokio::test] async fn service_arrete_puis_relance() { let fx = fixture(); let bn = bench().await; set_machine(&bn.tools, &fx["machines"], "nu"); *bn.go.service_active.lock().unwrap() = true; let ok = fx["steps"] .as_array() .unwrap() .iter() .find(|s| s["name"] == "installation") .unwrap(); *bn.rel.lock().unwrap() = ok["release"].clone(); assert_eq!(bn.call("POST", "/api/llamacpp/prebuilt", "").await.0, 200); bn.wait_job().await; let (_, job) = bn.call("GET", "/api/llamacpp/job", "").await; let lines: Vec<&str> = job["lines"] .as_array() .unwrap() .iter() .map(|v| v.as_str().unwrap()) .collect(); assert_eq!(lines[0], "▶ arrêt du service le temps de l'installation…"); assert_eq!( &lines[lines.len() - 3..], &[ "BIN mis à jour", "▶ redémarrage du service…", "✓ binaires précompilés installés (b300)" ] ); assert_eq!( *bn.go.service_actions.lock().unwrap(), vec![("stop".to_string(), true), ("start".to_string(), false)] ); bn.go.service_actions.lock().unwrap().clear(); let (c, out) = bn .call("POST", "/api/llamacpp/use", r#"{"mode":"fast"}"#) .await; assert_eq!((c, out["ok"].clone()), (200, json!(true))); assert_eq!( *bn.go.service_actions.lock().unwrap(), vec![("restart".to_string(), true)] ); // Arrêté : rien n'est relancé. *bn.go.service_active.lock().unwrap() = false; bn.go.service_actions.lock().unwrap().clear(); assert_eq!(bn.call("POST", "/api/llamacpp/prebuilt", "").await.0, 200); bn.wait_job().await; assert_eq!( *bn.go.service_actions.lock().unwrap(), vec![("stop".to_string(), true)] ); } /// Une archive .zip (Windows) s'extrait aussi, sans évasion possible. #[test] fn extraction_zip() { let tmp = tempfile::tempdir().unwrap(); let zpath = tmp.path().join("a.zip"); { let f = std::fs::File::create(&zpath).unwrap(); let mut z = zip::ZipWriter::new(f); let o = zip::write::SimpleFileOptions::default(); z.add_directory("llama-b1/", o).unwrap(); z.start_file("llama-b1/llama-server.exe", o).unwrap(); std::io::Write::write_all(&mut z, b"exe").unwrap(); z.finish().unwrap(); } let out = tmp.path().join("out"); prebuilt::extract(&zpath, &out).unwrap(); assert_eq!( std::fs::read(out.join("llama-b1/llama-server.exe")).unwrap(), b"exe" ); let bad = tmp.path().join("b.zip"); { let f = std::fs::File::create(&bad).unwrap(); let mut z = zip::ZipWriter::new(f); z.start_file("../evasion", zip::write::SimpleFileOptions::default()) .unwrap(); z.finish().unwrap(); } assert_eq!( prebuilt::extract(&bad, &out).unwrap_err(), "entrée d'archive suspecte : ../evasion" ); } /// 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"); } } }