mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Lot 24 : moteur de l'image tenu par la façade (LOKI_ENGINE_IMAGE=rust)
Port de web_engine.go, web_engine_reco.go et backend_engine_oci.go (rust/loki-rs/src/engineimg) : statut du moteur (build, source, variante, versions installées, avis, version recommandée, version précédente, relevé du rendu), dernier build publié, plan (dernière ou recommandée, risque de rendu du raisonnement au défaut de b10763), mise à jour depuis le registre officiel (couches du sommet, liens, runtime ignoré, essai à blanc, REASONING_PRESERVE=off dans le preset actif ou la configuration, ménage, vérification du rendu avant/après par la sonde de gabarit), bascules, suppression et retour à la version précédente. Tenu seulement avec LOKI_BACKENDS=rust : la mise à jour est un job des moteurs. La sonde est celle de la conversation quand la façade la tient. /api/engine/cmdline reste au Go, qui relaie les routes du tunnel. Parité : parity_engineimg_test.go tire du Go un journal (faux registre OCI, faux moteur local pour la sonde) rejoué par tests/engineimg.rs ; TestFacadeMoteurImage côté Go. Les aides de fichiers des rejeux passent dans tests/common/parity.rs. Mutations : 71 tuées sur 74, trois équivalentes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
25 files changed
+8155
-173
No files matched your search
@@ -56,6 +56,9 @@ pub struct Ctx {
|
||||
/// Backends llama.cpp et job des moteurs tenus par la façade
|
||||
/// (`LOKI_BACKENDS=rust`).
|
||||
pub backends: Option<std::sync::Arc<crate::backends::Backends>>,
|
||||
/// Panneau « moteur de l'image » tenu par la façade
|
||||
/// (`LOKI_ENGINE_IMAGE=rust`, avec les backends).
|
||||
pub engineimg: Option<std::sync::Arc<crate::engineimg::EngineImg>>,
|
||||
/// Jeton de l'API interne : une requête qui le porte vient du Go.
|
||||
pub internal_token: String,
|
||||
}
|
||||
@@ -78,6 +81,7 @@ impl Ctx {
|
||||
|| self.mcp.is_some()
|
||||
|| self.update
|
||||
|| self.backends.is_some()
|
||||
|| self.engineimg.is_some()
|
||||
}
|
||||
|
||||
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
|
||||
|
||||
@@ -214,6 +214,18 @@ async fn list_devices(bin: &str, cvd: &str) -> (String, Option<String>) {
|
||||
c.env("CUDA_VISIBLE_DEVICES", cvd)
|
||||
.env("CUDA_DEVICE_ORDER", "PCI_BUS_ID");
|
||||
}
|
||||
c.stdin(Stdio::null()).kill_on_drop(true);
|
||||
combined(c, bin, Some(LIST_TIMEOUT)).await
|
||||
}
|
||||
|
||||
/// `cmd.CombinedOutput()` : sorties standard et d'erreur dans le même tube ;
|
||||
/// l'erreur au format d'`exec` (`exit status N`, `signal: killed`,
|
||||
/// `fork/exec …`). `limit` : délai au-delà duquel le processus est tué.
|
||||
pub async fn combined(
|
||||
mut c: tokio::process::Command,
|
||||
bin: &str,
|
||||
limit: Option<Duration>,
|
||||
) -> (String, Option<String>) {
|
||||
// Sortie standard et d'erreur dans le même tube, comme CombinedOutput.
|
||||
let (reader, writer) = match std::io::pipe() {
|
||||
Ok(p) => p,
|
||||
@@ -223,10 +235,7 @@ async fn list_devices(bin: &str, cvd: &str) -> (String, Option<String>) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return (String::new(), Some(e.to_string())),
|
||||
};
|
||||
c.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(writer))
|
||||
.stderr(Stdio::from(writer2))
|
||||
.kill_on_drop(true);
|
||||
c.stdout(Stdio::from(writer)).stderr(Stdio::from(writer2));
|
||||
let mut child = match c.spawn() {
|
||||
Ok(ch) => ch,
|
||||
Err(e) => {
|
||||
@@ -243,14 +252,16 @@ async fn list_devices(bin: &str, cvd: &str) -> (String, Option<String>) {
|
||||
let _ = std::io::Read::read_to_end(&mut r, &mut buf);
|
||||
buf
|
||||
});
|
||||
let status = tokio::time::timeout(LIST_TIMEOUT, child.wait()).await;
|
||||
let status = match status {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
Err(std::io::Error::other("signal: killed"))
|
||||
}
|
||||
let status = match limit {
|
||||
None => child.wait().await,
|
||||
Some(d) => match tokio::time::timeout(d, child.wait()).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
Err(std::io::Error::other("signal: killed"))
|
||||
}
|
||||
},
|
||||
};
|
||||
let out = read.await.unwrap_or_default();
|
||||
let out = crate::system::go_lossy(&out);
|
||||
|
||||
@@ -123,7 +123,7 @@ impl Backends {
|
||||
}
|
||||
|
||||
/// `providedEngineBin`.
|
||||
fn provided_engine_bin(&self) -> String {
|
||||
pub(crate) fn provided_engine_bin(&self) -> String {
|
||||
let env = self
|
||||
.engine_bin_env
|
||||
.read()
|
||||
@@ -289,7 +289,7 @@ impl Log<'_> {
|
||||
}
|
||||
|
||||
/// Le verrou de l'optimiseur (au Go) : le message s'il tient le moteur.
|
||||
async fn tune_busy(store: &Store) -> Option<String> {
|
||||
pub(crate) async fn tune_busy(store: &Store) -> Option<String> {
|
||||
let v = store
|
||||
.internal_json(Method::GET, "/internal/v1/tune/guard", None)
|
||||
.await
|
||||
@@ -304,7 +304,7 @@ async fn tune_busy(store: &Store) -> Option<String> {
|
||||
|
||||
/// Le service moteur : celui de la façade si elle le pilote, sinon celui du
|
||||
/// Go. `if_active` : seulement s'il tourne. Rend (lancé, erreur).
|
||||
async fn service(ctx: &Ctx, action: &str, if_active: bool) -> (bool, String) {
|
||||
pub(crate) async fn service(ctx: &Ctx, action: &str, if_active: bool) -> (bool, String) {
|
||||
if let Some(ctl) = &ctx.engine_ctl {
|
||||
if if_active && !crate::enginectl::service_is_active(ctx) {
|
||||
return (false, String::new());
|
||||
@@ -335,6 +335,18 @@ async fn service(ctx: &Ctx, action: &str, if_active: bool) -> (bool, String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Arrête le moteur s'il tourne : (il tournait, erreur de l'arrêt).
|
||||
pub(crate) async fn service_stop_if_up(ctx: &Ctx) -> (bool, String) {
|
||||
if ctx.engine_ctl.is_some() {
|
||||
if crate::enginectl::service_is_active(ctx) {
|
||||
return service(ctx, "stop", false).await;
|
||||
}
|
||||
return (false, String::new());
|
||||
}
|
||||
// Côté Go, « arrête-le s'il tourne » dit aussi s'il tournait.
|
||||
service(ctx, "stop", true).await
|
||||
}
|
||||
|
||||
/// Les routes servies par ce module.
|
||||
pub fn handles(path: &str) -> bool {
|
||||
matches!(
|
||||
@@ -635,23 +647,11 @@ async fn run_prebuilt(ctx: &Ctx, b: &Backends) {
|
||||
jobs: &b.jobs,
|
||||
store: &ctx.store,
|
||||
};
|
||||
let (svc_was_up, err) = if ctx.engine_ctl.is_some() {
|
||||
if crate::enginectl::service_is_active(ctx) {
|
||||
log.phase("arrêt du service le temps de l'installation…")
|
||||
.await;
|
||||
service(ctx, "stop", false).await
|
||||
} else {
|
||||
(false, String::new())
|
||||
}
|
||||
} else {
|
||||
// Côté Go, « arrête-le s'il tourne » dit aussi s'il tournait.
|
||||
let r = service(ctx, "stop", true).await;
|
||||
if r.0 {
|
||||
log.phase("arrêt du service le temps de l'installation…")
|
||||
.await;
|
||||
}
|
||||
r
|
||||
};
|
||||
let (svc_was_up, err) = service_stop_if_up(ctx).await;
|
||||
if svc_was_up {
|
||||
log.phase("arrêt du service le temps de l'installation…")
|
||||
.await;
|
||||
}
|
||||
if svc_was_up && !err.is_empty() {
|
||||
log.line(&format!("[warn] impossible d'arrêter le service : {err}"))
|
||||
.await;
|
||||
@@ -770,20 +770,34 @@ pub async fn install(b: &Backends, log: &Log<'_>) -> Result<String, String> {
|
||||
Ok(bin)
|
||||
}
|
||||
|
||||
/// Le téléchargement d'un asset, sa progression au journal du job au fil de
|
||||
/// l'eau et dans l'ordre.
|
||||
/// Le téléchargement d'un asset, sa progression au journal du job.
|
||||
async fn download(
|
||||
b: &Backends,
|
||||
a: &prebuilt::Asset,
|
||||
dest: &Path,
|
||||
log: &Log<'_>,
|
||||
) -> Result<(), String> {
|
||||
let rq = b
|
||||
.client
|
||||
.get(&a.url)
|
||||
.header("User-Agent", "Go-http-client/1.1");
|
||||
log_download(log, rq, dest, a.size).await
|
||||
}
|
||||
|
||||
/// Un téléchargement dont la progression part au journal du job au fil de
|
||||
/// l'eau, dans l'ordre.
|
||||
pub async fn log_download(
|
||||
log: &Log<'_>,
|
||||
rq: reqwest::RequestBuilder,
|
||||
dest: &Path,
|
||||
total: i64,
|
||||
) -> Result<(), String> {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let dl = async move {
|
||||
let mut logf = |s: String| {
|
||||
let _ = tx.send(s);
|
||||
};
|
||||
prebuilt::download(&b.client, &a.url, dest, a.size, &mut logf).await
|
||||
prebuilt::download_request(rq, dest, total, &mut logf).await
|
||||
};
|
||||
let pump = async {
|
||||
while let Some(s) = rx.recv().await {
|
||||
|
||||
@@ -312,12 +312,19 @@ pub async fn download(
|
||||
total: i64,
|
||||
logf: &mut (dyn FnMut(String) + Send),
|
||||
) -> Result<(), String> {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("User-Agent", "Go-http-client/1.1")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rq = client.get(url).header("User-Agent", "Go-http-client/1.1");
|
||||
download_request(rq, dest, total, logf).await
|
||||
}
|
||||
|
||||
/// `downloadRequest` : la même chose pour une requête déjà construite — un
|
||||
/// registre OCI exige un en-tête d'autorisation, même en lecture anonyme.
|
||||
pub async fn download_request(
|
||||
rq: reqwest::RequestBuilder,
|
||||
dest: &Path,
|
||||
total: i64,
|
||||
logf: &mut (dyn FnMut(String) + Send),
|
||||
) -> Result<(), String> {
|
||||
let resp = rq.send().await.map_err(|e| e.to_string())?;
|
||||
if resp.status().as_u16() != 200 {
|
||||
return Err(format!("HTTP {}", resp.status().as_u16()));
|
||||
}
|
||||
@@ -439,7 +446,7 @@ fn mkdir_all(p: &Path) -> Result<(), String> {
|
||||
|
||||
/// `applyLinks` : liens symboliques, ou copie de la cible quand le système
|
||||
/// les refuse.
|
||||
fn apply_links(links: &[(PathBuf, String)]) -> Result<(), String> {
|
||||
pub(crate) fn apply_links(links: &[(PathBuf, String)]) -> Result<(), String> {
|
||||
for (path, target) in links {
|
||||
let _ = std::fs::remove_file(path);
|
||||
if let Some(parent) = path.parent() {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,631 @@
|
||||
//! Le moteur llama.cpp pris dans l'image de conteneur officielle — port de
|
||||
//! `backend_engine_oci.go` : jeton anonyme, manifeste (index multi-
|
||||
//! architecture puis manifeste de la plateforme), couches du sommet vers la
|
||||
//! base jusqu'à tenir un moteur complet, extraction à plat de `/app` avec ses
|
||||
//! liens, bascule atomique du dossier, et lecture du numéro de build.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::backends::Log;
|
||||
use crate::gojson::{self, Kind};
|
||||
|
||||
/// `ociEngineRepo`.
|
||||
pub const ENGINE_REPO: &str = "ggml-org/llama.cpp";
|
||||
|
||||
/// Au-delà, une couche est le runtime de l'image, pas le moteur (`ociLayerCap`).
|
||||
pub const LAYER_CAP: i64 = 600 << 20;
|
||||
|
||||
/// `ociAccept`.
|
||||
const ACCEPT: &str = "application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json";
|
||||
|
||||
/// `ociDoc` décodé : index ou manifeste.
|
||||
const DOC_TYPE: &str = "ociDoc";
|
||||
|
||||
/// `ociBase` : `https://ghcr.io`, ou `LOKI_OCI_REGISTRY` (schéma ajouté au
|
||||
/// besoin, `/` final retiré).
|
||||
pub fn base_from_env(v: Option<&str>) -> String {
|
||||
let v = v.unwrap_or("").trim().trim_end_matches('/');
|
||||
if v.is_empty() {
|
||||
return "https://ghcr.io".into();
|
||||
}
|
||||
if v.contains("://") {
|
||||
v.into()
|
||||
} else {
|
||||
format!("https://{v}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `ociHost`.
|
||||
pub fn host_of(base: &str) -> String {
|
||||
base.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// `ociArch`.
|
||||
pub fn oci_arch(arm64: bool) -> &'static str {
|
||||
if arm64 { "arm64" } else { "amd64" }
|
||||
}
|
||||
|
||||
/// Une couche du manifeste.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
pub struct Layer {
|
||||
#[serde(default, rename = "mediaType")]
|
||||
pub media_type: String,
|
||||
#[serde(default)]
|
||||
pub digest: String,
|
||||
#[serde(default)]
|
||||
pub size: i64,
|
||||
}
|
||||
|
||||
/// Le client du registre.
|
||||
#[derive(Clone)]
|
||||
pub struct Registry {
|
||||
pub base: String,
|
||||
pub arm64: bool,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn new(base: String, arm64: bool) -> Self {
|
||||
crate::chat::engine::install_crypto();
|
||||
Registry {
|
||||
base,
|
||||
arm64,
|
||||
client: reqwest::Client::builder().build().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn host(&self) -> String {
|
||||
host_of(&self.base)
|
||||
}
|
||||
|
||||
/// `ociToken` : un jeton de lecture anonyme.
|
||||
pub async fn token(&self, repo: &str) -> Result<String, String> {
|
||||
let u = format!(
|
||||
"{}/token?scope=repository:{repo}:pull&service={}",
|
||||
self.base,
|
||||
self.host()
|
||||
);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&u)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("registre {} injoignable : {e}", self.host()))?;
|
||||
if resp.status().as_u16() != 200 {
|
||||
return Err(format!(
|
||||
"{}/token : HTTP {}",
|
||||
self.host(),
|
||||
resp.status().as_u16()
|
||||
));
|
||||
}
|
||||
let body = resp.bytes().await.unwrap_or_default();
|
||||
let d = gojson::decode_struct(
|
||||
&body,
|
||||
"struct { Token string \"json:\\\"token\\\"\" }",
|
||||
&[("token", Kind::Str)],
|
||||
);
|
||||
let t = d.str("token");
|
||||
if d.err.is_some() || t.is_empty() {
|
||||
return Err("jeton de lecture illisible".into());
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// Un manifeste (index ou image) par référence.
|
||||
async fn doc(&self, repo: &str, reference: &str, token: &str) -> Result<Value, String> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/v2/{repo}/manifests/{reference}", self.base))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("Accept", ACCEPT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
match resp.status().as_u16() {
|
||||
404 => return Err(format!("version inconnue dans le registre : {reference}")),
|
||||
200 => {}
|
||||
s => return Err(format!("manifeste {reference} : HTTP {s}")),
|
||||
}
|
||||
let body = resp.bytes().await.map_err(|e| e.to_string())?;
|
||||
let d = gojson::decode_struct(
|
||||
&body,
|
||||
DOC_TYPE,
|
||||
&[
|
||||
("mediaType", Kind::Str),
|
||||
("manifests", Kind::Any),
|
||||
("layers", Kind::Any),
|
||||
("annotations", Kind::Any),
|
||||
],
|
||||
);
|
||||
if let Some(e) = d.err {
|
||||
return Err(format!("manifeste {reference} illisible : {e}"));
|
||||
}
|
||||
Ok(Value::Object(d.fields))
|
||||
}
|
||||
|
||||
/// `ociResolve` : les couches de l'image pour cette architecture, et le
|
||||
/// build qu'elle annonce.
|
||||
pub async fn resolve(&self, repo: &str, tag: &str) -> Result<(Vec<Layer>, String), String> {
|
||||
let token = self.token(repo).await?;
|
||||
let version_of = |d: &Value| -> String {
|
||||
d.get("annotations")
|
||||
.and_then(|a| a.get("org.opencontainers.image.version"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
};
|
||||
let mut doc = self.doc(repo, tag, &token).await?;
|
||||
let mut version = version_of(&doc);
|
||||
let manifests = doc
|
||||
.get("manifests")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if !manifests.is_empty() {
|
||||
let want = oci_arch(self.arm64);
|
||||
let digest = manifests
|
||||
.iter()
|
||||
.find(|m| m["platform"]["os"] == "linux" && m["platform"]["architecture"] == want)
|
||||
.and_then(|m| m.get("digest").and_then(Value::as_str))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if digest.is_empty() {
|
||||
return Err(format!("aucune image linux/{want} pour {tag}"));
|
||||
}
|
||||
doc = self.doc(repo, &digest, &token).await?;
|
||||
let v = version_of(&doc);
|
||||
if !v.is_empty() {
|
||||
version = v;
|
||||
}
|
||||
}
|
||||
let layers: Vec<Layer> = doc
|
||||
.get("layers")
|
||||
.cloned()
|
||||
.and_then(|l| serde_json::from_value(l).ok())
|
||||
.unwrap_or_default();
|
||||
if layers.is_empty() {
|
||||
return Err(format!("manifeste {tag} sans couche"));
|
||||
}
|
||||
Ok((layers, version))
|
||||
}
|
||||
|
||||
/// `engineOCILatest` : le dernier build publié pour la variante (numéro et
|
||||
/// tag figé), lu sur l'annotation du tag mouvant.
|
||||
pub async fn latest(&self, variant: &str) -> Result<(i64, String), String> {
|
||||
let (_, version) = self.resolve(ENGINE_REPO, variant).await?;
|
||||
if version.is_empty() {
|
||||
return Ok((0, variant.into()));
|
||||
}
|
||||
let n = version.trim_start_matches('b').parse().unwrap_or(0);
|
||||
Ok((n, format!("{variant}-{version}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// `engineDir`.
|
||||
pub fn engine_dir(home: &Path) -> PathBuf {
|
||||
home.join("engine")
|
||||
}
|
||||
|
||||
static RE_TAG_BUILD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"-b([0-9]+)$").expect("motif"));
|
||||
|
||||
/// `engineTagBuild` : « server-cuda-b10450 » → 10450, 0 sans numéro.
|
||||
pub fn tag_build(tag: &str) -> i64 {
|
||||
RE_TAG_BUILD
|
||||
.captures(tag)
|
||||
.and_then(|m| m[1].parse().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `engineInstalled` : les versions présentes, de la plus ancienne à la plus
|
||||
/// récente.
|
||||
pub fn installed(home: &Path) -> Vec<String> {
|
||||
let dir = engine_dir(home);
|
||||
let Ok(rd) = std::fs::read_dir(&dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut tags: Vec<String> = rd
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|t| crate::backends::probe::is_file(&dir.join(t).join("llama-server")))
|
||||
.collect();
|
||||
tags.sort();
|
||||
tags.sort_by_key(|t| tag_build(t));
|
||||
tags
|
||||
}
|
||||
|
||||
/// `engineBinFor` : le llama-server d'une version installée, "" sinon.
|
||||
pub fn bin_for(home: &Path, tag: &str) -> String {
|
||||
let p = engine_dir(home)
|
||||
.join(crate::paths::go_base(tag))
|
||||
.join("llama-server");
|
||||
if crate::backends::probe::is_file(&p) {
|
||||
p.to_string_lossy().into_owned()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// `enginePrune` : les versions installées hors `keep`, supprimées.
|
||||
pub fn prune(home: &Path, keep: &HashSet<String>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for tag in installed(home) {
|
||||
if keep.contains(&tag) {
|
||||
continue;
|
||||
}
|
||||
if std::fs::remove_dir_all(engine_dir(home).join(&tag)).is_ok() {
|
||||
out.push(format!("ancienne version supprimée : {tag}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `appEntryName` : « app/libllama.so » → « libllama.so » ; rien pour tout ce
|
||||
/// qui n'est pas un fichier direct de /app (ni marqueur de suppression).
|
||||
pub fn app_entry_name(raw: &str) -> Option<String> {
|
||||
let p = raw.trim().replace('\\', "/");
|
||||
let p = p.strip_prefix("./").unwrap_or(&p);
|
||||
let rest = p.strip_prefix("app/")?;
|
||||
if rest.is_empty() || rest.contains('/') || rest.starts_with(".wh.") {
|
||||
return None;
|
||||
}
|
||||
Some(rest.to_string())
|
||||
}
|
||||
|
||||
/// `engineSetComplete` : le binaire ET ses bibliothèques.
|
||||
pub fn set_complete(seen: &HashSet<String>) -> bool {
|
||||
seen.contains("llama-server")
|
||||
&& seen.iter().any(|n| n.starts_with("libggml-base.so"))
|
||||
&& seen.iter().any(|n| n.starts_with("libllama.so"))
|
||||
}
|
||||
|
||||
/// `extractAppLayer` : les entrées `app/…` d'une couche, à plat dans `dir` ;
|
||||
/// une couche supérieure fait foi (`seen`). Rend le nombre de fichiers.
|
||||
pub fn extract_app_layer(
|
||||
archive: &Path,
|
||||
dir: &Path,
|
||||
seen: &mut HashSet<String>,
|
||||
links: &mut Vec<(PathBuf, String)>,
|
||||
) -> Result<usize, String> {
|
||||
let f = std::fs::File::open(archive)
|
||||
.map_err(|e| crate::paths::os_err("open", &archive.to_string_lossy(), &e))?;
|
||||
let gz = flate2::read::GzDecoder::new(f);
|
||||
let mut tr = tar::Archive::new(gz);
|
||||
let mut count = 0;
|
||||
for entry in tr.entries().map_err(|e| e.to_string())? {
|
||||
let mut entry = entry.map_err(|e| e.to_string())?;
|
||||
let raw = String::from_utf8_lossy(&entry.path_bytes()).into_owned();
|
||||
let Some(name) = app_entry_name(&raw) else {
|
||||
continue;
|
||||
};
|
||||
if seen.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
let p = dir.join(&name);
|
||||
let kind = entry.header().entry_type();
|
||||
if kind.is_file() {
|
||||
let mut buf = Vec::new();
|
||||
entry.read_to_end(&mut buf).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&p, &buf)
|
||||
.map_err(|e| crate::paths::os_err("open", &p.to_string_lossy(), &e))?;
|
||||
#[cfg(unix)]
|
||||
if let Ok(mode) = entry.header().mode() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode & 0o777));
|
||||
}
|
||||
seen.insert(name);
|
||||
count += 1;
|
||||
} else if kind.is_symlink() || kind.is_hard_link() {
|
||||
let link = entry
|
||||
.link_name_bytes()
|
||||
.map(|b| String::from_utf8_lossy(&b).into_owned())
|
||||
.unwrap_or_default();
|
||||
let target = app_entry_name(&link).unwrap_or_else(|| crate::paths::go_base(&link));
|
||||
links.push((p, target));
|
||||
seen.insert(name);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// `engineOCIInstall` : le moteur du tag dans `engine/<tag>` ; rend le chemin
|
||||
/// du llama-server. Une version déjà là est rendue telle quelle.
|
||||
pub async fn install(
|
||||
home: &Path,
|
||||
reg: &Registry,
|
||||
tag: &str,
|
||||
log: &Log<'_>,
|
||||
) -> Result<String, String> {
|
||||
let tag = crate::paths::go_base(tag.trim());
|
||||
if tag.is_empty() || tag == "." {
|
||||
return Err("version du moteur non précisée".into());
|
||||
}
|
||||
let bin = bin_for(home, &tag);
|
||||
if !bin.is_empty() {
|
||||
log.line(&format!("déjà installé : {tag}")).await;
|
||||
return Ok(bin);
|
||||
}
|
||||
log.phase(&format!("lecture du manifeste {tag}…")).await;
|
||||
let (layers, version) = reg.resolve(ENGINE_REPO, &tag).await?;
|
||||
if !version.is_empty() {
|
||||
log.line(&format!(
|
||||
"image {ENGINE_REPO}:{tag} — llama.cpp {version}, {} couches",
|
||||
layers.len()
|
||||
))
|
||||
.await;
|
||||
}
|
||||
let dest = engine_dir(home).join(&tag);
|
||||
let tmp = PathBuf::from(format!("{}.part", dest.to_string_lossy()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp)
|
||||
.map_err(|e| crate::paths::os_err("mkdir", &tmp.to_string_lossy(), &e))?;
|
||||
let res = install_into(reg, &tag, &layers, &tmp, &dest, log).await;
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
res
|
||||
}
|
||||
|
||||
async fn install_into(
|
||||
reg: &Registry,
|
||||
tag: &str,
|
||||
layers: &[Layer],
|
||||
tmp: &Path,
|
||||
dest: &Path,
|
||||
log: &Log<'_>,
|
||||
) -> Result<String, String> {
|
||||
let token = reg.token(ENGINE_REPO).await?;
|
||||
let mut seen = HashSet::new();
|
||||
let mut links = Vec::new();
|
||||
for (i, l) in layers.iter().enumerate().rev() {
|
||||
if l.size > LAYER_CAP {
|
||||
log.line(&format!(
|
||||
"couche {i} ignorée ({} Mo) : c'est le runtime de l'image, pas le moteur",
|
||||
l.size / 1_000_000
|
||||
))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
let part = tmp.join("layer.tgz");
|
||||
if l.size > 4 << 20 {
|
||||
log.phase(&format!(
|
||||
"téléchargement du moteur ({} Mo)…",
|
||||
l.size / 1_000_000
|
||||
))
|
||||
.await;
|
||||
}
|
||||
let rq = reg
|
||||
.client
|
||||
.get(format!("{}/v2/{ENGINE_REPO}/blobs/{}", reg.base, l.digest))
|
||||
.header("Authorization", format!("Bearer {token}"));
|
||||
crate::backends::log_download(log, rq, &part, l.size)
|
||||
.await
|
||||
.map_err(|e| format!("téléchargement de la couche {i} : {e}"))?;
|
||||
let (p2, d2) = (part.clone(), tmp.to_path_buf());
|
||||
let mut s2 = std::mem::take(&mut seen);
|
||||
let mut l2 = std::mem::take(&mut links);
|
||||
let (n, s2, l2) = tokio::task::spawn_blocking(move || {
|
||||
let r = extract_app_layer(&p2, &d2, &mut s2, &mut l2);
|
||||
(r, s2, l2)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
seen = s2;
|
||||
links = l2;
|
||||
let _ = std::fs::remove_file(&part);
|
||||
let n = n.map_err(|e| format!("extraction de la couche {i} : {e}"))?;
|
||||
if n > 0 {
|
||||
log.line(&format!("couche {i} : {n} fichiers du moteur"))
|
||||
.await;
|
||||
}
|
||||
if set_complete(&seen) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
crate::backends::prebuilt::apply_links(&links)?;
|
||||
if !seen.contains("llama-server") {
|
||||
return Err(format!(
|
||||
"llama-server absent de l'image {tag} — variante inattendue ?"
|
||||
));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| crate::paths::os_err("mkdir", &parent.to_string_lossy(), &e))?;
|
||||
}
|
||||
std::fs::rename(tmp, dest).map_err(|e| e.to_string())?;
|
||||
let bin = dest.join("llama-server");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| crate::paths::os_err("chmod", &bin.to_string_lossy(), &e))?;
|
||||
}
|
||||
let bin = bin.to_string_lossy().into_owned();
|
||||
log.line(&format!("moteur installé : {bin}")).await;
|
||||
Ok(bin)
|
||||
}
|
||||
|
||||
static RE_BUILD_LONG: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"build[\t\n\f\r ]+([0-9]+),[\t\n\f\r ]*commit[\t\n\f\r ]+([0-9a-fA-F]+)")
|
||||
.expect("motif")
|
||||
});
|
||||
static RE_BUILD_SHORT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?m)^[\t\n\f\r ]*(?:version|build)[\t\n\f\r ]*:[\t\n\f\r ]*([0-9]+)[\t\n\f\r ]*\(([0-9a-fA-F]+)\)",
|
||||
)
|
||||
.expect("motif")
|
||||
});
|
||||
|
||||
/// `parseEngineBuild` : (build, commit) des trois formes de la bannière.
|
||||
pub fn parse_build(out: &str) -> (i64, String) {
|
||||
for re in [&*RE_BUILD_LONG, &*RE_BUILD_SHORT] {
|
||||
if let Some(m) = re.captures(out) {
|
||||
return (m[1].parse().unwrap_or(0), m[2].to_string());
|
||||
}
|
||||
}
|
||||
(0, String::new())
|
||||
}
|
||||
|
||||
/// Un binaire lancé avec ses bibliothèques à côté : (sortie mêlée, erreur au
|
||||
/// format d'`exec`). `limit` : délai (tué au-delà).
|
||||
pub async fn run_bin(bin: &str, arg: &str, limit: Option<Duration>) -> (String, Option<String>) {
|
||||
let dir = crate::paths::go_dir(bin);
|
||||
let env = crate::serve::run::Env::default();
|
||||
let (k, v) = crate::serve::run::library_path_value(&env, &dir);
|
||||
let mut c = tokio::process::Command::new(bin);
|
||||
c.arg(arg)
|
||||
.env(k, v)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
crate::backends::devices::combined(c, bin, limit).await
|
||||
}
|
||||
|
||||
/// `engineBuildOf` : le build d'un llama-server (0 s'il ne répond pas).
|
||||
pub async fn build_of(bin: &str) -> (i64, String) {
|
||||
if bin.is_empty() || !crate::backends::probe::is_file(Path::new(bin)) {
|
||||
return (0, String::new());
|
||||
}
|
||||
let (out, _) = run_bin(bin, "--version", Some(Duration::from_secs(15))).await;
|
||||
parse_build(&out)
|
||||
}
|
||||
|
||||
/// `tailLines` : les n dernières lignes.
|
||||
pub fn tail_lines(s: &str, n: usize) -> String {
|
||||
let lines: Vec<&str> = s.trim_end_matches('\n').split('\n').collect();
|
||||
let from = lines.len().saturating_sub(n);
|
||||
lines[from..].join("\n")
|
||||
}
|
||||
|
||||
/// `engineListDevices`.
|
||||
pub async fn list_devices(bin: &str) -> Result<usize, String> {
|
||||
let (out, err) = run_bin(bin, "--list-devices", None).await;
|
||||
if let Some(e) = err {
|
||||
return Err(format!(
|
||||
"le moteur ne démarre pas ici : {e}\n{}",
|
||||
tail_lines(&out, 12)
|
||||
));
|
||||
}
|
||||
Ok(crate::backends::devices::parse_list_devices(&out).len())
|
||||
}
|
||||
|
||||
/// `engineSmokeTest` : le nouveau moteur démarre, et voit les cartes que
|
||||
/// voyait l'actuel.
|
||||
pub async fn smoke_test(bin: &str, ancien: &str) -> Result<(), String> {
|
||||
if list_devices(bin).await? > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if ancien.is_empty() || ancien == bin {
|
||||
return Ok(());
|
||||
}
|
||||
match list_devices(ancien).await {
|
||||
Ok(n) if n > 0 => Err(format!(
|
||||
"le moteur téléchargé ne voit aucune carte graphique, alors que le moteur actuel en voit {n} — il tournerait sur le processeur, beaucoup plus lentement"
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn entrees_app() {
|
||||
assert_eq!(
|
||||
app_entry_name("app/libllama.so").as_deref(),
|
||||
Some("libllama.so")
|
||||
);
|
||||
assert_eq!(
|
||||
app_entry_name("./app/llama-server").as_deref(),
|
||||
Some("llama-server")
|
||||
);
|
||||
assert_eq!(app_entry_name("app/"), None);
|
||||
assert_eq!(app_entry_name("app/sous/x"), None);
|
||||
assert_eq!(app_entry_name("app/.wh.x"), None);
|
||||
assert_eq!(app_entry_name("usr/app/x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds() {
|
||||
assert_eq!(
|
||||
parse_build("version: 0.1.0-dev (build 10450, commit ece963f41)"),
|
||||
(10450, "ece963f41".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_build("version: 10450 (ece963f)"),
|
||||
(10450, "ece963f".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_build("build: 4589 (a1b2c3d)"),
|
||||
(4589, "a1b2c3d".into())
|
||||
);
|
||||
assert_eq!(parse_build("rien"), (0, String::new()));
|
||||
assert_eq!(tag_build("server-cuda-b10450"), 10450);
|
||||
assert_eq!(tag_build("server-cuda"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moteur_complet() {
|
||||
let set = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<HashSet<_>>();
|
||||
assert!(!set_complete(&set(&["libggml-base.so", "libllama.so"])));
|
||||
assert!(!set_complete(&set(&["llama-server", "libggml-base.so.0"])));
|
||||
assert!(!set_complete(&set(&["llama-server", "libllama.so.0"])));
|
||||
assert!(set_complete(&set(&[
|
||||
"llama-server",
|
||||
"libggml-base.so.0",
|
||||
"libllama.so"
|
||||
])));
|
||||
assert_eq!(tail_lines("a\nb\nc\n", 2), "b\nc");
|
||||
assert_eq!(tail_lines("a", 5), "a");
|
||||
}
|
||||
|
||||
/// Ne voir aucune carte n'est un échec que si le moteur courant en voyait.
|
||||
#[tokio::test]
|
||||
async fn essai_a_blanc() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let script = |name: &str, body: &str| {
|
||||
let p = d.path().join(name);
|
||||
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
};
|
||||
let none = script("aucune", "echo 'Available devices:'");
|
||||
let one = script(
|
||||
"une",
|
||||
"echo 'Available devices:'; echo ' CUDA0: Carte (1000 MiB, 900 MiB free)'",
|
||||
);
|
||||
let ko = script("ko", "echo panne; exit 3");
|
||||
assert_eq!(smoke_test(&none, "").await, Ok(()));
|
||||
assert_eq!(smoke_test(&none, &none).await, Ok(()));
|
||||
assert_eq!(smoke_test(&none, &ko).await, Ok(()));
|
||||
assert_eq!(smoke_test(&one, &ko).await, Ok(()));
|
||||
let e = smoke_test(&none, &one).await.unwrap_err();
|
||||
assert!(e.contains("en voit 1"), "{e}");
|
||||
let e = smoke_test(&ko, &one).await.unwrap_err();
|
||||
assert_eq!(e, "le moteur ne démarre pas ici : exit status 3\npanne");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registre() {
|
||||
assert_eq!(base_from_env(None), "https://ghcr.io");
|
||||
assert_eq!(
|
||||
base_from_env(Some(" miroir.local:5000/ ")),
|
||||
"https://miroir.local:5000"
|
||||
);
|
||||
assert_eq!(base_from_env(Some("http://x")), "http://x");
|
||||
assert_eq!(host_of("https://ghcr.io/"), "ghcr.io");
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ pub mod dictate;
|
||||
pub mod downloads;
|
||||
pub mod engine;
|
||||
pub mod enginectl;
|
||||
pub mod engineimg;
|
||||
pub mod engstatus;
|
||||
pub mod gguf;
|
||||
pub mod gojson;
|
||||
|
||||
@@ -167,6 +167,13 @@ fn serve() -> ExitCode {
|
||||
// bascule, GPU) et LE job des moteurs, auquel les jobs du Go
|
||||
// (compilation, moteur de l'image, Strata) rapportent leurs étapes.
|
||||
let backends_rust = held_by_facade("LOKI_BACKENDS");
|
||||
// LOKI_ENGINE_IMAGE=rust : le panneau « moteur de l'image » (mise à
|
||||
// jour depuis le registre officiel), avec les backends seulement : sa
|
||||
// mise à jour est un job, et le job est à eux.
|
||||
let engineimg_rust = held_by_facade("LOKI_ENGINE_IMAGE") && backends_rust;
|
||||
if held_by_facade("LOKI_ENGINE_IMAGE") && !backends_rust {
|
||||
eprintln!("[loki-rs] moteur de l'image tenu par le Go (LOKI_BACKENDS=rust requis)");
|
||||
}
|
||||
let engine_rust = held_by_facade("LOKI_ENGINE")
|
||||
&& match loki_rs::enginectl::usable(
|
||||
std::env::var("LOKI_CONTAINER").ok().as_deref(),
|
||||
@@ -199,6 +206,7 @@ fn serve() -> ExitCode {
|
||||
("LOKI_MCP", mcp_rust),
|
||||
("LOKI_UPDATE", update_rust),
|
||||
("LOKI_BACKENDS", backends_rust),
|
||||
("LOKI_ENGINE_IMAGE", engineimg_rust),
|
||||
];
|
||||
for (var, on) in held {
|
||||
if on {
|
||||
@@ -265,6 +273,28 @@ fn serve() -> ExitCode {
|
||||
None
|
||||
};
|
||||
|
||||
// La sonde de gabarit de la conversation quand la façade la tient :
|
||||
// le relevé du rendu rejoue la forme de ses vraies requêtes.
|
||||
let engineimg = engineimg_rust.then(|| {
|
||||
let probe = chat
|
||||
.as_ref()
|
||||
.map(|c| c.shared.tpl.clone())
|
||||
.unwrap_or_else(|| std::sync::Arc::new(loki_rs::chat::tplprobe::TplProbe::new()));
|
||||
let reg = loki_rs::engineimg::oci::Registry::new(
|
||||
loki_rs::engineimg::oci::base_from_env(
|
||||
std::env::var("LOKI_OCI_REGISTRY").ok().as_deref(),
|
||||
),
|
||||
cfg!(target_arch = "aarch64"),
|
||||
);
|
||||
std::sync::Arc::new(loki_rs::engineimg::EngineImg::new(
|
||||
engine_paths.home.clone(),
|
||||
reg,
|
||||
cfg!(target_os = "linux")
|
||||
&& (cfg!(target_arch = "x86_64") || cfg!(target_arch = "aarch64")),
|
||||
probe,
|
||||
))
|
||||
});
|
||||
|
||||
let files = if files_rust {
|
||||
Some(start_files(backend, &token).await)
|
||||
} else {
|
||||
@@ -321,6 +351,7 @@ fn serve() -> ExitCode {
|
||||
projects: projects_rust,
|
||||
update: update_rust,
|
||||
backends,
|
||||
engineimg,
|
||||
mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))),
|
||||
};
|
||||
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
|
||||
|
||||
@@ -359,6 +359,22 @@ pub fn go_dir(path: &str) -> String {
|
||||
format!("{}{dir}", &path[..vol_len])
|
||||
}
|
||||
|
||||
/// `filepath.Base` : le dernier élément ; "." pour un chemin vide, "/" pour
|
||||
/// une suite de séparateurs.
|
||||
pub fn go_base(p: &str) -> String {
|
||||
if p.is_empty() {
|
||||
return ".".into();
|
||||
}
|
||||
let t = p.trim_end_matches(|c: char| c == '/' || (cfg!(windows) && c == '\\'));
|
||||
if t.is_empty() {
|
||||
return "/".into();
|
||||
}
|
||||
match t.rfind(|c: char| c == '/' || (cfg!(windows) && c == '\\')) {
|
||||
Some(i) => t[i + 1..].to_string(),
|
||||
None => t.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `filepath.IsAbs`.
|
||||
pub fn go_is_abs(path: &str) -> bool {
|
||||
if !cfg!(windows) {
|
||||
|
||||
@@ -870,7 +870,7 @@ async fn live(store: &Store) -> Live {
|
||||
|
||||
/// `SavePresetApplying` : enregistre ; si c'était le preset EN SERVICE (avant
|
||||
/// l'écriture), l'applique (`mode` : save ou apply).
|
||||
async fn save_applying(
|
||||
pub(crate) async fn save_applying(
|
||||
ctx: &Ctx,
|
||||
id: &str,
|
||||
name: &str,
|
||||
|
||||
@@ -96,6 +96,10 @@ pub struct Config {
|
||||
/// Backends llama.cpp (statut, précompilé, bascule, GPU) et job des
|
||||
/// moteurs tenus par la façade (`LOKI_BACKENDS=rust`) ; `None` : au Go.
|
||||
pub backends: Option<Arc<crate::backends::Backends>>,
|
||||
/// Panneau « moteur de l'image » (version, mise à jour depuis le
|
||||
/// registre, bascules, rendu du gabarit) tenu par la façade
|
||||
/// (`LOKI_ENGINE_IMAGE=rust`) ; `None` : au Go.
|
||||
pub engineimg: Option<Arc<crate::engineimg::EngineImg>>,
|
||||
}
|
||||
|
||||
struct Proxy {
|
||||
@@ -136,6 +140,7 @@ pub async fn serve(
|
||||
mcp: cfg.mcp,
|
||||
update: cfg.update,
|
||||
backends: cfg.backends,
|
||||
engineimg: cfg.engineimg,
|
||||
internal_token: cfg.internal_token.clone(),
|
||||
},
|
||||
});
|
||||
@@ -240,6 +245,11 @@ impl Proxy {
|
||||
{
|
||||
return chatapi::serve(&self.ctx, chat, req, from_go).await;
|
||||
}
|
||||
if let Some(e) = &self.ctx.engineimg
|
||||
&& crate::engineimg::handles(path)
|
||||
{
|
||||
return crate::engineimg::serve(&self.ctx, e, req, from_go).await;
|
||||
}
|
||||
if let Some(b) = &self.ctx.backends
|
||||
&& crate::backends::handles(path)
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use common::fakego;
|
||||
use common::parity::{apply_files, tar_gz, tree, write_file};
|
||||
use http_body_util::Full;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Request, Response};
|
||||
@@ -89,87 +90,6 @@ async fn fake_github(rel: Arc<Mutex<Value>>) -> String {
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
/// Une archive .tar.gz ; les noms sont écrits tels quels (« ../x » compris).
|
||||
fn tar_gz(entries: &[Value]) -> Vec<u8> {
|
||||
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
let mut b = tar::Builder::new(gz);
|
||||
for e in entries {
|
||||
let mut h = tar::Header::new_ustar();
|
||||
let name = s(&e["name"]).as_bytes();
|
||||
h.as_old_mut().name[..name.len()].copy_from_slice(name);
|
||||
let link = s(&e["link"]).as_bytes();
|
||||
h.as_old_mut().linkname[..link.len()].copy_from_slice(link);
|
||||
h.set_mtime(1_700_000_000);
|
||||
h.set_mode(e["mode"].as_u64().unwrap_or(0) as u32);
|
||||
let body = s(&e["body"]).as_bytes().to_vec();
|
||||
match s(&e["type"]) {
|
||||
"dir" => h.set_entry_type(tar::EntryType::Directory),
|
||||
"symlink" => {
|
||||
h.set_entry_type(tar::EntryType::Symlink);
|
||||
h.set_mode(0o777);
|
||||
}
|
||||
"hardlink" => h.set_entry_type(tar::EntryType::Link),
|
||||
_ => {
|
||||
h.set_entry_type(tar::EntryType::Regular);
|
||||
h.set_size(body.len() as u64);
|
||||
}
|
||||
}
|
||||
if s(&e["type"]) != "file" {
|
||||
h.set_size(0);
|
||||
}
|
||||
h.set_cksum();
|
||||
let data: &[u8] = if s(&e["type"]) == "file" { &body } else { &[] };
|
||||
b.append(&h, data).unwrap();
|
||||
}
|
||||
b.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
|
||||
fn write_file(p: &Path, content: &str, mode: u32) {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let _ = std::fs::remove_file(p);
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(mode)
|
||||
.open(p)
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut f, content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
fn remove_all(p: &Path) {
|
||||
match std::fs::symlink_metadata(p) {
|
||||
Ok(m) if m.is_dir() => std::fs::remove_dir_all(p).unwrap(),
|
||||
Ok(_) => std::fs::remove_file(p).unwrap(),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Les fichiers d'une étape (`parityApplyFiles`).
|
||||
fn apply_files(home: &Path, files: &Value) {
|
||||
for (n, v) in files.as_object().into_iter().flatten() {
|
||||
let v = s(v);
|
||||
let p = home.join(n.trim_end_matches('/'));
|
||||
match v {
|
||||
"-" => {
|
||||
remove_all(&p);
|
||||
continue;
|
||||
}
|
||||
"/" => {
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
if let Some(target) = v.strip_prefix("-> ") {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
std::os::unix::fs::symlink(target, &p).unwrap();
|
||||
continue;
|
||||
}
|
||||
write_file(&p, v, if v.starts_with("#!") { 0o755 } else { 0o644 });
|
||||
}
|
||||
}
|
||||
|
||||
/// Les opérations git d'une étape (`parityGit`).
|
||||
fn git_op(home: &Path, op: &str) {
|
||||
let f: Vec<&str> = op.splitn(4, ' ').collect();
|
||||
@@ -240,54 +160,6 @@ fn set_machine(tools: &Path, machines: &Value, name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `info.Mode().Perm().String()`.
|
||||
fn perm_string(mode: u32) -> String {
|
||||
let mut out = String::from("-");
|
||||
for shift in [6, 3, 0] {
|
||||
let b = (mode >> shift) & 7;
|
||||
out.push(if b & 4 != 0 { 'r' } else { '-' });
|
||||
out.push(if b & 2 != 0 { 'w' } else { '-' });
|
||||
out.push(if b & 1 != 0 { 'x' } else { '-' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `parityTree`.
|
||||
fn tree(home: &Path) -> Vec<String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fn walk(home: &Path, p: &Path, out: &mut Vec<String>) {
|
||||
let Ok(m) = std::fs::symlink_metadata(p) else {
|
||||
return;
|
||||
};
|
||||
let rel = p.strip_prefix(home).unwrap().to_string_lossy().into_owned();
|
||||
if rel.contains("/.git/") || rel.ends_with("/.git") {
|
||||
return;
|
||||
}
|
||||
if m.file_type().is_symlink() {
|
||||
let t = std::fs::read_link(p).unwrap();
|
||||
out.push(format!("l {rel} -> {}", t.to_string_lossy()));
|
||||
} else if m.is_dir() {
|
||||
out.push(format!("d {rel}"));
|
||||
for e in std::fs::read_dir(p).unwrap().flatten() {
|
||||
walk(home, &e.path(), out);
|
||||
}
|
||||
} else {
|
||||
let b = std::fs::read(p).unwrap();
|
||||
out.push(format!(
|
||||
"f {rel} {} {}",
|
||||
perm_string(m.permissions().mode() & 0o777),
|
||||
String::from_utf8_lossy(&b)
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for root in ["backends", "engine"] {
|
||||
walk(home, &home.join(root), &mut out);
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
struct Bench {
|
||||
facade: SocketAddr,
|
||||
go: Arc<fakego::FakeGo>,
|
||||
|
||||
@@ -78,6 +78,9 @@ pub struct FakeGo {
|
||||
pub tune_busy: Mutex<Option<String>>,
|
||||
/// /internal/v1/preset/apply reçus : (chemin, mode).
|
||||
pub preset_applies: Mutex<Vec<(String, String)>>,
|
||||
/// /internal/v1/preset/apply écrit-il le preset dans la configuration
|
||||
/// (`applyPresetFile` : clés « appareil » et BIN de la machine gardés) ?
|
||||
pub apply_presets: Mutex<bool>,
|
||||
/// /internal/v1/chat/generating : le Go écrit une réponse.
|
||||
pub generating: Mutex<bool>,
|
||||
/// /internal/v1/service/prestart reçus.
|
||||
@@ -450,6 +453,21 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
if mode == "refus" || path.ends_with("refus.env") {
|
||||
return resp(500, r#"{"error":"application refusée"}"#);
|
||||
}
|
||||
if *st.apply_presets.lock().unwrap() {
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let mut next = loki_rs::presets::parse_env(&text);
|
||||
let mut store = st.store.lock().unwrap();
|
||||
let cur = store.entry("config".into()).or_default();
|
||||
for k in loki_rs::presets::PRESERVED_KEYS {
|
||||
if let Some(v) = cur.get(*k) {
|
||||
next.insert(k.to_string(), String::from_utf8_lossy(v).into_owned());
|
||||
}
|
||||
}
|
||||
if let Some(bin) = cur.get("BIN").filter(|v| !v.is_empty()) {
|
||||
next.insert("BIN".into(), String::from_utf8_lossy(bin).into_owned());
|
||||
}
|
||||
*cur = next.into_iter().map(|(k, v)| (k, v.into_bytes())).collect();
|
||||
}
|
||||
st.preset_applies.lock().unwrap().push((path, mode));
|
||||
resp(204, "")
|
||||
}
|
||||
@@ -642,6 +660,7 @@ pub struct Opts {
|
||||
pub mcp: Option<Arc<loki_rs::mcp::Mcp>>,
|
||||
pub update: bool,
|
||||
pub backends: Option<Arc<loki_rs::backends::Backends>>,
|
||||
pub engineimg: Option<Arc<loki_rs::engineimg::EngineImg>>,
|
||||
}
|
||||
|
||||
/// Comme [`start`], avec la conversation tenue par la façade
|
||||
@@ -744,6 +763,7 @@ pub async fn start_with(
|
||||
mcp: opts.mcp,
|
||||
update: opts.update,
|
||||
backends: opts.backends,
|
||||
engineimg: opts.engineimg,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod fakego;
|
||||
pub mod parity;
|
||||
pub mod toolsfx;
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Aides des rejeux de parité qui posent des fichiers et lisent une
|
||||
//! arborescence (`parityApplyFiles`, `parityTarGz`, `parityTree` du Go).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn s(v: &Value) -> &str {
|
||||
v.as_str().unwrap_or("")
|
||||
}
|
||||
|
||||
/// Une archive .tar.gz ; les noms sont écrits tels quels (« ../x » compris).
|
||||
pub fn tar_gz(entries: &[Value]) -> Vec<u8> {
|
||||
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
let mut b = tar::Builder::new(gz);
|
||||
for e in entries {
|
||||
let mut h = tar::Header::new_ustar();
|
||||
let name = s(&e["name"]).as_bytes();
|
||||
h.as_old_mut().name[..name.len()].copy_from_slice(name);
|
||||
let link = s(&e["link"]).as_bytes();
|
||||
h.as_old_mut().linkname[..link.len()].copy_from_slice(link);
|
||||
h.set_mtime(1_700_000_000);
|
||||
h.set_mode(e["mode"].as_u64().unwrap_or(0) as u32);
|
||||
let body = s(&e["body"]).as_bytes().to_vec();
|
||||
match s(&e["type"]) {
|
||||
"dir" => h.set_entry_type(tar::EntryType::Directory),
|
||||
"symlink" => {
|
||||
h.set_entry_type(tar::EntryType::Symlink);
|
||||
h.set_mode(0o777);
|
||||
}
|
||||
"hardlink" => h.set_entry_type(tar::EntryType::Link),
|
||||
_ => {
|
||||
h.set_entry_type(tar::EntryType::Regular);
|
||||
h.set_size(body.len() as u64);
|
||||
}
|
||||
}
|
||||
if s(&e["type"]) != "file" {
|
||||
h.set_size(0);
|
||||
}
|
||||
h.set_cksum();
|
||||
let data: &[u8] = if s(&e["type"]) == "file" { &body } else { &[] };
|
||||
b.append(&h, data).unwrap();
|
||||
}
|
||||
b.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
|
||||
pub fn write_file(p: &Path, content: &str, mode: u32) {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let _ = std::fs::remove_file(p);
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(mode)
|
||||
.open(p)
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut f, content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
pub fn remove_all(p: &Path) {
|
||||
match std::fs::symlink_metadata(p) {
|
||||
Ok(m) if m.is_dir() => std::fs::remove_dir_all(p).unwrap(),
|
||||
Ok(_) => std::fs::remove_file(p).unwrap(),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Les fichiers d'une étape (`parityApplyFiles`).
|
||||
pub fn apply_files(home: &Path, files: &Value) {
|
||||
for (n, v) in files.as_object().into_iter().flatten() {
|
||||
let v = s(v);
|
||||
let p = home.join(n.trim_end_matches('/'));
|
||||
match v {
|
||||
"-" => {
|
||||
remove_all(&p);
|
||||
continue;
|
||||
}
|
||||
"/" => {
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
if let Some(target) = v.strip_prefix("-> ") {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
std::os::unix::fs::symlink(target, &p).unwrap();
|
||||
continue;
|
||||
}
|
||||
write_file(&p, v, if v.starts_with("#!") { 0o755 } else { 0o644 });
|
||||
}
|
||||
}
|
||||
|
||||
/// `info.Mode().Perm().String()`.
|
||||
pub fn perm_string(mode: u32) -> String {
|
||||
let mut out = String::from("-");
|
||||
for shift in [6, 3, 0] {
|
||||
let b = (mode >> shift) & 7;
|
||||
out.push(if b & 4 != 0 { 'r' } else { '-' });
|
||||
out.push(if b & 2 != 0 { 'w' } else { '-' });
|
||||
out.push(if b & 1 != 0 { 'x' } else { '-' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `parityTree`.
|
||||
pub fn tree(home: &Path) -> Vec<String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fn walk(home: &Path, p: &Path, out: &mut Vec<String>) {
|
||||
let Ok(m) = std::fs::symlink_metadata(p) else {
|
||||
return;
|
||||
};
|
||||
let rel = p.strip_prefix(home).unwrap().to_string_lossy().into_owned();
|
||||
if rel.contains("/.git/") || rel.ends_with("/.git") {
|
||||
return;
|
||||
}
|
||||
if m.file_type().is_symlink() {
|
||||
let t = std::fs::read_link(p).unwrap();
|
||||
out.push(format!("l {rel} -> {}", t.to_string_lossy()));
|
||||
} else if m.is_dir() {
|
||||
out.push(format!("d {rel}"));
|
||||
for e in std::fs::read_dir(p).unwrap().flatten() {
|
||||
walk(home, &e.path(), out);
|
||||
}
|
||||
} else {
|
||||
let b = std::fs::read(p).unwrap();
|
||||
out.push(format!(
|
||||
"f {rel} {} {}",
|
||||
perm_string(m.permissions().mode() & 0o777),
|
||||
String::from_utf8_lossy(&b)
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for root in ["backends", "engine"] {
|
||||
walk(home, &home.join(root), &mut out);
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
//! Lot 24 : panneau « moteur de l'image » tenu par la façade
|
||||
//! (`LOKI_ENGINE_IMAGE=rust`, avec `LOKI_BACKENDS=rust`).
|
||||
//!
|
||||
//! Le journal tiré du Go (`internal/loki/parity_engineimg_test.go` →
|
||||
//! `tests/parity_engineimg/engineimg.json`) est rejoué par une façade, contre
|
||||
//! un faux registre OCI (jeton, index multi-architecture, manifeste annoté,
|
||||
//! couches .tar.gz) et un faux moteur local (`/health`, `/props`,
|
||||
//! `/apply-template` : le `fakeTpl` du Go) pour la sonde de gabarit et la
|
||||
//! vérification du rendu avant/après la bascule : statut, dernier build
|
||||
//! publié, plan (dernière ou recommandée, risque de rendu du raisonnement),
|
||||
//! mise à jour (couches, liens, essai à blanc, REASONING_PRESERVE=off dans le
|
||||
//! preset actif ou la configuration, ménage, rendu comparé), bascules,
|
||||
//! suppression, retour à la version précédente, et les échecs.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use common::fakego;
|
||||
use common::parity::{apply_files, tar_gz, tree};
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Request, Response};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use loki_rs::backends::Backends;
|
||||
use loki_rs::backends::probe::Probe;
|
||||
use loki_rs::chat::tplprobe::TplProbe;
|
||||
use loki_rs::engineimg::{EngineImg, oci};
|
||||
use regex::Regex;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
fn fixture() -> Value {
|
||||
serde_json::from_str(include_str!("parity_engineimg/engineimg.json")).expect("journal")
|
||||
}
|
||||
|
||||
fn s(v: &Value) -> &str {
|
||||
v.as_str().unwrap_or("")
|
||||
}
|
||||
|
||||
const TUNE_BUSY: &str = "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la";
|
||||
|
||||
/// Sert un handler HTTP sur un port libre ; renvoie l'adresse.
|
||||
async fn serve<F>(h: F) -> SocketAddr
|
||||
where
|
||||
F: Fn(String, String, Option<String>, Vec<u8>) -> (u16, Vec<u8>) + Send + Sync + 'static,
|
||||
{
|
||||
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = l.local_addr().unwrap();
|
||||
let h = Arc::new(h);
|
||||
tokio::spawn(async move {
|
||||
while let Ok((st, _)) = l.accept().await {
|
||||
let h = h.clone();
|
||||
tokio::spawn(async move {
|
||||
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let method = req.method().to_string();
|
||||
let path = req.uri().path().to_string();
|
||||
let auth = req
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = req
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.map(|b| b.to_bytes().to_vec())
|
||||
.unwrap_or_default();
|
||||
let (code, out) = h(method, path, auth, body);
|
||||
let mut r = Response::new(Full::new(Bytes::from(out)));
|
||||
*r.status_mut() = hyper::StatusCode::from_u16(code).unwrap();
|
||||
Ok::<_, Infallible>(r)
|
||||
}
|
||||
});
|
||||
let _ = hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(st), svc)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// Le faux registre : `{token, token_body, manifest, manifest_body, no_arch,
|
||||
/// blob, version, fixed, layers}`.
|
||||
async fn fake_registry(reg: Arc<Mutex<Value>>) -> String {
|
||||
let addr = serve(move |_, path, auth, _| {
|
||||
let cur = reg.lock().unwrap().clone();
|
||||
let version = s(&cur["version"]).to_string();
|
||||
let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();
|
||||
let mut layers = Vec::new();
|
||||
for (i, l) in cur["layers"].as_array().into_iter().flatten().enumerate() {
|
||||
let data = tar_gz(l["entries"].as_array().map_or(&[][..], Vec::as_slice));
|
||||
let digest = format!("sha256:couche{i}");
|
||||
let size = l["size"].as_i64().unwrap_or(0);
|
||||
let size = if size > 0 { size } else { data.len() as i64 };
|
||||
layers.push(json!({
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
"digest": digest, "size": size,
|
||||
}));
|
||||
blobs.insert(digest, data);
|
||||
}
|
||||
let annotations = json!({ "org.opencontainers.image.version": version });
|
||||
let manifest = json!({ "schemaVersion": 2, "layers": layers, "annotations": annotations });
|
||||
let man_dig = "sha256:manifeste";
|
||||
let arch = if cur["no_arch"] == json!(true) {
|
||||
"s390x"
|
||||
} else {
|
||||
oci::oci_arch(cfg!(target_arch = "aarch64"))
|
||||
};
|
||||
let index = json!({
|
||||
"schemaVersion": 2,
|
||||
"manifests": [
|
||||
{ "digest": "sha256:autre", "platform": { "os": "linux", "architecture": "ppc64le" } },
|
||||
{ "digest": man_dig, "platform": { "os": "linux", "architecture": arch } },
|
||||
],
|
||||
});
|
||||
let last = path.rsplit('/').next().unwrap_or("").to_string();
|
||||
if path == "/token" {
|
||||
return match cur["token"].as_u64() {
|
||||
Some(code) if code != 0 => (code as u16, Vec::new()),
|
||||
_ if !s(&cur["token_body"]).is_empty() => {
|
||||
(200, s(&cur["token_body"]).as_bytes().to_vec())
|
||||
}
|
||||
_ => (200, br#"{"token":"jeton"}"#.to_vec()),
|
||||
};
|
||||
}
|
||||
if path.contains("/manifests/") {
|
||||
if auth.as_deref() != Some("Bearer jeton") {
|
||||
return (401, Vec::new());
|
||||
}
|
||||
if let Some(code) = cur["manifest"].as_u64().filter(|c| *c != 0) {
|
||||
return (code as u16, Vec::new());
|
||||
}
|
||||
if !s(&cur["manifest_body"]).is_empty() {
|
||||
return (200, s(&cur["manifest_body"]).as_bytes().to_vec());
|
||||
}
|
||||
let fixed = cur["fixed"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|f| s(f) == last);
|
||||
return if last == man_dig {
|
||||
(200, manifest.to_string().into_bytes())
|
||||
} else if last == "server-cuda" || fixed {
|
||||
(200, index.to_string().into_bytes())
|
||||
} else {
|
||||
(404, Vec::new())
|
||||
};
|
||||
}
|
||||
if path.contains("/blobs/") {
|
||||
if let Some(code) = cur["blob"].as_u64().filter(|c| *c != 0) {
|
||||
return (code as u16, Vec::new());
|
||||
}
|
||||
return match blobs.remove(&last) {
|
||||
Some(d) => (200, d),
|
||||
None => (404, Vec::new()),
|
||||
};
|
||||
}
|
||||
(404, Vec::new())
|
||||
})
|
||||
.await;
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
/// Le faux moteur local : le `fakeTpl` du Go (mode, gabarit, build), ou
|
||||
/// arrêté (503 partout).
|
||||
#[derive(Clone, Default)]
|
||||
struct Local {
|
||||
mode: String,
|
||||
build: String,
|
||||
tpl: String,
|
||||
down: bool,
|
||||
/// Statut imposé à `/apply-template` (0 : rendu).
|
||||
apply: u16,
|
||||
}
|
||||
|
||||
/// `fakeTpl.render`.
|
||||
fn render(mode: &str, body: &Value) -> (u16, Vec<u8>) {
|
||||
let id_re = Regex::new("^[A-Za-z0-9]{9}$").unwrap();
|
||||
let empty = Vec::new();
|
||||
let msgs = body["messages"].as_array().unwrap_or(&empty);
|
||||
let last_user = msgs.iter().rposition(|m| m["role"] == "user");
|
||||
let mut b = String::new();
|
||||
if let Some(tools) = body["tools"].as_array() {
|
||||
b.push_str(&format!("<tools n={}>\n", tools.len()));
|
||||
}
|
||||
for (i, m) in msgs.iter().enumerate() {
|
||||
let role = s(&m["role"]);
|
||||
b.push_str(&format!("<|im_start|>{role}\n"));
|
||||
let r = s(&m["reasoning_content"]);
|
||||
if !r.is_empty()
|
||||
&& role == "assistant"
|
||||
&& mode != "drop"
|
||||
&& (mode != "qwen3" || last_user.is_none_or(|u| i > u))
|
||||
{
|
||||
b.push_str(&format!("<think>{r}</think>"));
|
||||
}
|
||||
b.push_str(s(&m["content"]));
|
||||
for call in m["tool_calls"].as_array().into_iter().flatten() {
|
||||
let id = s(&call["id"]);
|
||||
if !id_re.is_match(id) || i + 1 >= msgs.len() || msgs[i + 1]["tool_call_id"] != id {
|
||||
return (
|
||||
500,
|
||||
br#"{"error":{"code":500,"message":"Jinja Exception: tool call id invalide"}}"#
|
||||
.to_vec(),
|
||||
);
|
||||
}
|
||||
b.push_str(&format!(
|
||||
"<tool_call>{}</tool_call>",
|
||||
s(&call["function"]["name"])
|
||||
));
|
||||
}
|
||||
b.push_str("<|im_end|>\n");
|
||||
}
|
||||
if body["add_generation_prompt"] == json!(true) || mode == "noagp" {
|
||||
b.push_str("<|im_start|>assistant\n");
|
||||
}
|
||||
(200, json!({ "prompt": b }).to_string().into_bytes())
|
||||
}
|
||||
|
||||
async fn fake_engine(local: Arc<Mutex<Local>>) -> u16 {
|
||||
serve(move |_, path, _, body| {
|
||||
let l = local.lock().unwrap().clone();
|
||||
if l.down {
|
||||
return (503, Vec::new());
|
||||
}
|
||||
match path.as_str() {
|
||||
"/health" => (200, br#"{"status":"ok"}"#.to_vec()),
|
||||
"/props" => {
|
||||
let tpl = if l.tpl.is_empty() { &l.mode } else { &l.tpl };
|
||||
let build = if l.build.is_empty() {
|
||||
"b7000-abc"
|
||||
} else {
|
||||
&l.build
|
||||
};
|
||||
let v = json!({
|
||||
"build_info": build,
|
||||
"model_path": "/models/Qwen3-8B-Q8_0.gguf",
|
||||
"chat_template": tpl,
|
||||
});
|
||||
(200, v.to_string().into_bytes())
|
||||
}
|
||||
"/apply-template" if l.apply != 0 => (
|
||||
l.apply,
|
||||
r#"{"error":{"code":500,"message":"Jinja Exception: gabarit cassé"}}"#
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
),
|
||||
"/apply-template" => {
|
||||
let v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
render(&l.mode, &v)
|
||||
}
|
||||
_ => (404, Vec::new()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.port()
|
||||
}
|
||||
|
||||
struct Bench {
|
||||
facade: SocketAddr,
|
||||
go: Arc<fakego::FakeGo>,
|
||||
_stop: tokio::sync::oneshot::Sender<()>,
|
||||
home: PathBuf,
|
||||
b: Arc<Backends>,
|
||||
reg: Arc<Mutex<Value>>,
|
||||
reg_base: String,
|
||||
local: Arc<Mutex<Local>>,
|
||||
port: u16,
|
||||
_tmp: Vec<tempfile::TempDir>,
|
||||
}
|
||||
|
||||
async fn bench() -> Bench {
|
||||
bench_with(Duration::from_secs(10)).await
|
||||
}
|
||||
|
||||
async fn bench_with(render_wait: Duration) -> Bench {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().to_path_buf();
|
||||
let reg = Arc::new(Mutex::new(json!({})));
|
||||
let reg_base = fake_registry(reg.clone()).await;
|
||||
let local = Arc::new(Mutex::new(Local::default()));
|
||||
let port = fake_engine(local.clone()).await;
|
||||
let b = Arc::new(Backends::with(
|
||||
home.clone(),
|
||||
home.join("image/llama-server")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
Probe {
|
||||
path: Some(vec![]),
|
||||
root: root.path().to_path_buf(),
|
||||
},
|
||||
String::new(),
|
||||
));
|
||||
let mut probe = TplProbe::new();
|
||||
probe.auto = false;
|
||||
probe.backoff = Duration::from_millis(1);
|
||||
let mut e = EngineImg::new(
|
||||
home.clone(),
|
||||
oci::Registry::new(reg_base.clone(), cfg!(target_arch = "aarch64")),
|
||||
true,
|
||||
Arc::new(probe),
|
||||
);
|
||||
e.render_poll = Duration::from_millis(1);
|
||||
e.render_wait = render_wait;
|
||||
e.env = Some(Default::default());
|
||||
let (facade, go, stop, _) = fakego::start_with(
|
||||
home.clone(),
|
||||
fakego::Opts {
|
||||
backends: Some(b.clone()),
|
||||
engineimg: Some(Arc::new(e)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
go.set("config", "PORT", &port.to_string());
|
||||
*go.apply_presets.lock().unwrap() = true;
|
||||
Bench {
|
||||
facade,
|
||||
go,
|
||||
_stop: stop,
|
||||
home,
|
||||
b,
|
||||
reg,
|
||||
reg_base,
|
||||
local,
|
||||
port,
|
||||
_tmp: vec![tmp, root],
|
||||
}
|
||||
}
|
||||
|
||||
impl Bench {
|
||||
fn mask(&self, v: &str) -> String {
|
||||
let ts = Regex::new(r#""(started_at|ended_at)":[1-9][0-9]*"#).unwrap();
|
||||
let at = Regex::new(r#""at":"[^"]*""#).unwrap();
|
||||
let v = v
|
||||
.replace(&self.reg_base, "{{REG}}")
|
||||
.replace(&oci::host_of(&self.reg_base), "{{REGHOST}}")
|
||||
.replace(&self.home.to_string_lossy().into_owned(), "{{HOME}}");
|
||||
let v = ts.replace_all(&v, r#""$1":"{{T}}""#);
|
||||
at.replace_all(&v, r#""at":"{{T}}""#).into_owned()
|
||||
}
|
||||
|
||||
fn unmask(&self, v: &str) -> String {
|
||||
v.replace("{{HOME}}", &self.home.to_string_lossy())
|
||||
.replace("{{PORT}}", &self.port.to_string())
|
||||
}
|
||||
|
||||
async fn call(&self, method: &str, path: &str, body: &str) -> (u16, Value) {
|
||||
let (code, b) = fakego::send(self.facade, method, path, &[], body).await;
|
||||
let out = serde_json::from_str(&self.mask(&String::from_utf8_lossy(&b)))
|
||||
.unwrap_or_else(|_| panic!("{path} : corps illisible {b:?}"));
|
||||
(code.as_u16(), out)
|
||||
}
|
||||
|
||||
fn set_local(&self, v: &Value) {
|
||||
*self.local.lock().unwrap() = Local {
|
||||
mode: s(&v["mode"]).into(),
|
||||
build: s(&v["build"]).into(),
|
||||
tpl: s(&v["template"]).into(),
|
||||
down: v["down"] == json!(true),
|
||||
apply: v["apply"].as_u64().unwrap_or(0) as u16,
|
||||
};
|
||||
}
|
||||
|
||||
async fn wait_job(&self) {
|
||||
for _ in 0..1500 {
|
||||
let (_, v) = self.call("GET", "/api/llamacpp/job", "").await;
|
||||
if v["running"] != json!(true) {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
panic!("le job ne finit pas");
|
||||
}
|
||||
|
||||
async fn wait_render(&self) -> Value {
|
||||
for _ in 0..1500 {
|
||||
let (_, v) = self.call("GET", "/api/engine", "").await;
|
||||
let c = &v["render_check"];
|
||||
if !c.is_null() && c["status"] != "pending" {
|
||||
return c.clone();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("la vérification du rendu ne finit pas");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn parite_du_panneau() {
|
||||
let fx = fixture();
|
||||
if (fx["goarch"] == "arm64") != cfg!(target_arch = "aarch64") {
|
||||
eprintln!("journal tiré sur une autre architecture : rejeu sauté");
|
||||
return;
|
||||
}
|
||||
let bn = bench().await;
|
||||
for st in fx["steps"].as_array().unwrap() {
|
||||
let name = s(&st["name"]);
|
||||
let files: serde_json::Map<String, Value> = st["files"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v)))))
|
||||
.collect();
|
||||
apply_files(&bn.home, &Value::Object(files));
|
||||
for (k, v) in st["config"].as_object().into_iter().flatten() {
|
||||
let v = bn.unmask(s(v));
|
||||
if v.is_empty() {
|
||||
bn.go.del("config", k);
|
||||
} else {
|
||||
bn.go.set("config", k, &v);
|
||||
}
|
||||
}
|
||||
if !st["registry"].is_null() {
|
||||
*bn.reg.lock().unwrap() = st["registry"].clone();
|
||||
}
|
||||
if !st["local"].is_null() {
|
||||
bn.set_local(&st["local"]);
|
||||
}
|
||||
if let Some(e) = st["engine_env"].as_str() {
|
||||
bn.b.set_engine_bin_env(&bn.unmask(e));
|
||||
}
|
||||
*bn.go.tune_busy.lock().unwrap() =
|
||||
(st["tune"].as_bool() == Some(true)).then(|| TUNE_BUSY.to_string());
|
||||
let (code, out) = bn
|
||||
.call(s(&st["method"]), s(&st["path"]), &bn.unmask(s(&st["body"])))
|
||||
.await;
|
||||
*bn.go.tune_busy.lock().unwrap() = None;
|
||||
assert_eq!(code as i64, st["status"].as_i64().unwrap(), "{name}");
|
||||
assert_eq!(out, st["out"], "{name}");
|
||||
if st["wait"].as_bool() == Some(true) {
|
||||
bn.wait_job().await;
|
||||
let (_, job) = bn.call("GET", "/api/llamacpp/job?from=0", "").await;
|
||||
assert_eq!(job, st["job"], "{name} : job");
|
||||
if !st["after_job"].is_null() {
|
||||
bn.set_local(&st["after_job"]);
|
||||
}
|
||||
}
|
||||
if st["wait_render"].as_bool() == Some(true) {
|
||||
assert_eq!(bn.wait_render().await, st["render"], "{name} : rendu");
|
||||
}
|
||||
let mut after = BTreeMap::new();
|
||||
for k in ["BIN", "REASONING_PRESERVE"] {
|
||||
if let Some(v) = bn.go.get("config", k).filter(|v| !v.is_empty()) {
|
||||
after.insert(format!("config/{k}"), Value::String(bn.mask(&v)));
|
||||
}
|
||||
}
|
||||
if let Ok(b) = std::fs::read(bn.home.join("engine/previous-bin")) {
|
||||
after.insert(
|
||||
"previous-bin".to_string(),
|
||||
Value::String(bn.mask(&String::from_utf8_lossy(&b))),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
Value::Object(after.into_iter().collect()),
|
||||
st["after"],
|
||||
"{name} : après"
|
||||
);
|
||||
let t: Vec<Value> = tree(&bn.home)
|
||||
.into_iter()
|
||||
.map(|l| Value::String(bn.mask(&l)))
|
||||
.collect();
|
||||
let t = if t.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
Value::Array(t)
|
||||
};
|
||||
assert_eq!(t, st["tree"], "{name} : arborescence");
|
||||
}
|
||||
}
|
||||
|
||||
/// Autour de la bascule d'une mise à jour, le moteur qui tournait est arrêté
|
||||
/// puis relancé (le Go pilote le service) ; une bascule sans réseau le
|
||||
/// redémarre s'il tourne.
|
||||
#[tokio::test]
|
||||
async fn service_arrete_puis_relance() {
|
||||
let fx = fixture();
|
||||
let steps = fx["steps"].as_array().unwrap();
|
||||
let step = |n: &str| steps.iter().find(|s| s["name"] == n).unwrap().clone();
|
||||
let bn = bench().await;
|
||||
let first = step("statut, moteur de l'image");
|
||||
let files: serde_json::Map<String, Value> = first["files"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v)))))
|
||||
.collect();
|
||||
apply_files(&bn.home, &Value::Object(files));
|
||||
*bn.reg.lock().unwrap() = first["registry"].clone();
|
||||
bn.set_local(&json!({ "down": true }));
|
||||
*bn.go.service_active.lock().unwrap() = true;
|
||||
|
||||
assert_eq!(bn.call("POST", "/api/engine/update", "{}").await.0, 200);
|
||||
bn.wait_job().await;
|
||||
let (_, job) = bn.call("GET", "/api/llamacpp/job", "").await;
|
||||
let lines: Vec<&str> = job["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap())
|
||||
.collect();
|
||||
let bin = "BIN → {{HOME}}/engine/server-cuda-b10900/llama-server";
|
||||
let at = lines.iter().position(|l| *l == bin).expect("BIN posé");
|
||||
assert_eq!(lines[at - 1], "▶ arrêt du moteur…");
|
||||
assert_eq!(lines[at + 1], "▶ redémarrage du moteur…");
|
||||
assert_eq!(
|
||||
*bn.go.service_actions.lock().unwrap(),
|
||||
vec![("stop".to_string(), true), ("start".to_string(), false)]
|
||||
);
|
||||
|
||||
bn.go.service_actions.lock().unwrap().clear();
|
||||
let (c, out) = bn
|
||||
.call("POST", "/api/engine/use", r#"{"tag":"image"}"#)
|
||||
.await;
|
||||
assert_eq!((c, out["ok"].clone()), (200, json!(true)));
|
||||
assert_eq!(
|
||||
*bn.go.service_actions.lock().unwrap(),
|
||||
vec![("restart".to_string(), true)]
|
||||
);
|
||||
|
||||
// Arrêté : la mise à jour ne le relance pas.
|
||||
*bn.go.service_active.lock().unwrap() = false;
|
||||
bn.go.service_actions.lock().unwrap().clear();
|
||||
let (c, _) = bn
|
||||
.call(
|
||||
"POST",
|
||||
"/api/engine/update",
|
||||
r#"{"tag":"server-cuda-b10900"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(c, 200);
|
||||
bn.wait_job().await;
|
||||
assert_eq!(
|
||||
*bn.go.service_actions.lock().unwrap(),
|
||||
vec![("stop".to_string(), true)]
|
||||
);
|
||||
let (_, job) = bn.call("GET", "/api/llamacpp/job", "").await;
|
||||
assert!(
|
||||
!job["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|l| l.as_str().unwrap().contains("arrêt du moteur")),
|
||||
"{job}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sans les backends dans la façade (pas de job), le panneau répond 503.
|
||||
#[tokio::test]
|
||||
async fn sans_backends() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let e = EngineImg::new(
|
||||
tmp.path().to_path_buf(),
|
||||
oci::Registry::new("http://127.0.0.1:9".into(), false),
|
||||
true,
|
||||
Arc::new(TplProbe::new()),
|
||||
);
|
||||
let (facade, _go, _stop, _) = fakego::start_with(
|
||||
tmp.path().to_path_buf(),
|
||||
fakego::Opts {
|
||||
engineimg: Some(Arc::new(e)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let (c, b) = fakego::send(facade, "GET", "/api/engine", &[], "").await;
|
||||
assert_eq!(c.as_u16(), 503, "{b:?}");
|
||||
}
|
||||
|
||||
/// Le nouveau moteur ne répond pas à temps (build inchangé) : relevé
|
||||
/// « unknown », sans comparaison.
|
||||
#[tokio::test]
|
||||
async fn rendu_non_verifie_a_temps() {
|
||||
let fx = fixture();
|
||||
let steps = fx["steps"].as_array().unwrap();
|
||||
let first = steps[0].clone();
|
||||
let bn = bench_with(Duration::from_millis(200)).await;
|
||||
let files: serde_json::Map<String, Value> = first["files"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v)))))
|
||||
.collect();
|
||||
apply_files(&bn.home, &Value::Object(files));
|
||||
*bn.reg.lock().unwrap() = first["registry"].clone();
|
||||
bn.set_local(&first["local"]);
|
||||
assert_eq!(bn.call("POST", "/api/engine/update", "{}").await.0, 200);
|
||||
bn.wait_job().await;
|
||||
let c = bn.wait_render().await;
|
||||
assert_eq!(c["status"], "unknown", "{c}");
|
||||
assert_eq!(
|
||||
c["detail"],
|
||||
"le nouveau moteur n'a pas répondu à temps : rendu non vérifié"
|
||||
);
|
||||
assert_eq!(c["tag"], "server-cuda-b10900");
|
||||
}
|
||||
@@ -107,6 +107,7 @@ async fn banc(cle: Option<&str>) -> Banc {
|
||||
mcp: None,
|
||||
update: false,
|
||||
backends: None,
|
||||
engineimg: None,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -113,6 +113,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
|
||||
mcp: None,
|
||||
update: false,
|
||||
backends: None,
|
||||
engineimg: None,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
Reference in new issue
Block a user