Files
Loki/rust/loki-rs/tests/strata.rs
T
MichaelandClaude Opus 5.5 615e58dd2f Strata : seconde carte en partage des couches au choix, l'aide aux experts reste le défaut
Repris d'AJEAN 38919238 (#121 amont) et de la partie moteur de 6db3f3d0 :
STRATA_SPLIT=layers dans le preset fait lister les deux cartes dans la clé
« gpu » de la config (principale d'abord, numéros nvidia-smi) ; le serveur de
Strata en déduit --layer-split. Rien du mode d'aide ne subsiste alors : ni
--expert-cache-device1 / --remote-expert-opt, ni source d'aide à la lecture
des prompts, ni encodeur d'images déplacé ; la réserve de VRAM de
l'installeur reste. Sans la clé, ou la seconde carte coupée, rien ne change.

Propre à Loki : le placement des experts selon la RAM disponible ne choisit
jamais le mode hors RAM en partage des couches (la seconde carte n'y garde
pas d'experts, la marge calculée pour elle serait fausse).

Réglages : « split » (helper ou layers ; absent = inchangé pour un ancien
client), détail split_layers. Fenêtre Strata : la case « Carte d'appoint »
devient un choix « Seconde carte » (non utilisée, aide aux experts, partage
des couches) ; la carte de configuration du moteur actif l'affiche.

Façade Rust alignée (strata/mod.rs, strata/api.rs, tests portés) ; journal
de parité régénéré sous Linux avec les réglages et l'affichage du partage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:16:37 +02:00

358 lines
12 KiB
Rust

//! Lot 25 : Strata tenu par la façade (`LOKI_STRATA=rust`, avec
//! `LOKI_BACKENDS=rust`).
//!
//! Le journal tiré du Go (`internal/loki/parity_strata_test.go` →
//! `tests/parity_strata/strata.json`) est rejoué par une façade : fausses
//! machines dans le PATH des sondes (nvidia-smi, python3), faux /proc/meminfo,
//! place libre imposée, fausse release (sources, moteur, empreintes) et
//! installeur factice ; la bascule finale part au faux Go, qui applique le
//! preset comme `applyPresetFile`.
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, 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::Backends;
use loki_rs::backends::probe::Probe;
use loki_rs::strata::api::Strata;
use regex::Regex;
use serde_json::{Value, json};
use sha2::Digest;
use tokio::net::TcpListener;
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_strata/strata.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";
/// La fausse release : `{status, src, engine}`.
async fn fake_release(rel: Arc<Mutex<Value>>) -> 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<hyper::body::Incoming>| {
let cur = rel.lock().unwrap().clone();
async move {
let path = req.uri().path().trim_start_matches('/').to_string();
let (code, body) = match cur["status"].as_u64() {
Some(c) if c != 0 => (c as u16, Vec::new()),
_ if path == loki_rs::strata::SRC_ASSET => (200, src_bytes(&cur)),
_ if path == loki_rs::strata::ENGINE_ASSET => {
(200, s(&cur["engine"]).as_bytes().to_vec())
}
_ => (404, Vec::new()),
};
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}")
}
fn src_bytes(rel: &Value) -> Vec<u8> {
tar_gz(rel["src"].as_array().map_or(&[][..], Vec::as_slice))
}
fn sha(b: &[u8]) -> String {
hex::encode(sha2::Sha256::digest(b))
}
struct Bench {
facade: SocketAddr,
go: Arc<fakego::FakeGo>,
_stop: tokio::sync::oneshot::Sender<()>,
home: PathBuf,
tools: PathBuf,
root: PathBuf,
strata: Arc<Strata>,
rel: Arc<Mutex<Value>>,
rel_base: String,
_tmp: Vec<tempfile::TempDir>,
}
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 probe = || Probe {
path: Some(vec![tools.path().to_path_buf()]),
root: root.path().to_path_buf(),
};
std::fs::create_dir_all(root.path().join("proc")).unwrap();
let rel = Arc::new(Mutex::new(json!({})));
let rel_base = fake_release(rel.clone()).await;
let b = Arc::new(Backends::with(
home.clone(),
String::new(),
probe(),
String::new(),
));
let mut st = Strata::new(home.clone(), probe());
st.release_base = format!("{rel_base}/");
st.platform_ok = true;
let strata = Arc::new(st);
let (facade, go, stop, _) = fakego::start_with(
home.clone(),
fakego::Opts {
backends: Some(b),
strata: Some(strata.clone()),
..Default::default()
},
)
.await;
*go.apply_presets.lock().unwrap() = true;
Bench {
facade,
go,
_stop: stop,
home,
tools: tools.path().to_path_buf(),
root: root.path().to_path_buf(),
strata,
rel,
rel_base,
_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 v = v
.replace(&self.rel_base, "{{REL}}")
.replace(&self.home.to_string_lossy().into_owned(), "{{HOME}}");
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");
}
/// Les outils d'une machine (bash, mkdir et tr toujours).
fn set_machine(&self, machines: &Value, name: &str) {
for e in std::fs::read_dir(&self.tools).unwrap().flatten() {
std::fs::remove_file(e.path()).unwrap();
}
for tool in ["bash", "mkdir", "tr"] {
let p = loki_rs::paths::look_path(tool).expect(tool);
std::os::unix::fs::symlink(p, self.tools.join(tool)).unwrap();
}
for (tool, script) in machines[name].as_object().into_iter().flatten() {
write_file(&self.tools.join(tool), s(script), 0o755);
}
}
fn set_release(&self, r: &Value) {
*self.rel.lock().unwrap() = r.clone();
let mut want = std::collections::HashMap::from([
(loki_rs::strata::SRC_ASSET.to_string(), sha(&src_bytes(r))),
(
loki_rs::strata::ENGINE_ASSET.to_string(),
sha(s(&r["engine"]).as_bytes()),
),
]);
if r["bad_sha"] == json!(true) {
want.insert(loki_rs::strata::ENGINE_ASSET.to_string(), "0".repeat(64));
}
*self.strata.asset_sha.lock().unwrap() = want;
}
/// presets/ et strata/, sans le contenu des archives.
fn tree(&self) -> Vec<String> {
use std::os::unix::fs::PermissionsExt;
fn walk(b: &Bench, p: &Path, out: &mut Vec<String>) {
let Ok(m) = std::fs::symlink_metadata(p) else {
return;
};
let rel = p
.strip_prefix(&b.home)
.unwrap()
.to_string_lossy()
.into_owned();
if m.is_dir() {
out.push(format!("d {rel}"));
for e in std::fs::read_dir(p).unwrap().flatten() {
walk(b, &e.path(), out);
}
} else if rel.starts_with("strata/dl/") || rel.starts_with("strata/prebuilt/") {
out.push(format!("f {rel}"));
} else {
let c = std::fs::read(p).unwrap();
out.push(format!(
"f {rel} {} {}",
common::parity::perm_string(m.permissions().mode() & 0o777),
b.mask(&String::from_utf8_lossy(&c))
));
}
}
let mut out = Vec::new();
for root in ["presets", "strata"] {
walk(self, &self.home.join(root), &mut out);
}
out.sort();
out
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn parite_de_strata() {
if !cfg!(target_arch = "x86_64") {
eprintln!("Strata n'est proposé que sous Linux x86_64 : rejeu sauté");
return;
}
let fx = fixture();
let bn = bench().await;
let machines = &fx["machines"];
for st in fx["steps"].as_array().unwrap() {
let name = s(&st["name"]);
if let Some(m) = st["machine"].as_str() {
bn.set_machine(machines, m);
}
if let Some(m) = st["meminfo"].as_str() {
std::fs::write(bn.root.join("proc/meminfo"), m).unwrap();
}
if let Some(d) = st["disk_free"].as_i64() {
*bn.strata.disk_free.lock().unwrap() = Some(d);
}
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);
}
}
for (k, v) in st["state"].as_object().into_iter().flatten() {
if s(v).is_empty() {
bn.go.del("state", k);
} else {
bn.go.set("state", k, s(v));
}
}
if !st["release"].is_null() {
bn.set_release(&st["release"]);
}
bn.go.preset_applies.lock().unwrap().clear();
*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");
}
// Une bascule faite par le Go : application du preset puis
// redémarrage du service.
let actions: Vec<Value> = bn
.go
.preset_applies
.lock()
.unwrap()
.iter()
.filter(|(_, mode)| mode == "switch")
.map(|_| json!("restart"))
.collect();
assert_eq!(Value::Array(actions), st["actions"], "{name} : service");
let mut after = BTreeMap::new();
for k in [
"ENGINE",
"STRATA_CONFIG",
"CTX",
"STRATA_KV",
"STRATA_HELPER",
"STRATA_SPLIT",
] {
if let Some(v) = bn.go.get("config", k).filter(|v| !v.is_empty()) {
after.insert(format!("config/{k}"), Value::String(bn.mask(&v)));
}
}
assert_eq!(
Value::Object(after.into_iter().collect()),
st["after"],
"{name} : après"
);
let t: Vec<Value> = bn.tree().into_iter().map(Value::String).collect();
let t = if t.is_empty() {
Value::Null
} else {
Value::Array(t)
};
assert_eq!(t, st["tree"], "{name} : arborescence");
}
}
/// Sans les backends dans la façade (pas de job), Strata répond 503.
#[tokio::test]
async fn sans_backends() {
let tmp = tempfile::tempdir().unwrap();
let st = Strata::new(tmp.path().to_path_buf(), Probe::system());
let (facade, _go, _stop, _) = fakego::start_with(
tmp.path().to_path_buf(),
fakego::Opts {
strata: Some(Arc::new(st)),
..Default::default()
},
)
.await;
let (c, b) = fakego::send(facade, "GET", "/api/strata", &[], "").await;
assert_eq!(c.as_u16(), 503, "{b:?}");
}