From 7017b66c082229737979c60b66fe91a557d1c48b Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 14 Oct 2026 17:43:15 +0200 Subject: [PATCH] Lot 4 : /api/config et /api/engine/cmdline servis par Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux routes de pilotage de plus en Rust, lues dans la base à travers le Go. La configuration sort clés triées, comme json.Encoder le fait d'une map Go. Faux serveur Go de test à store en mémoire, réutilisable par les lots suivants. Co-Authored-By: Claude Opus 5.5 --- rust/loki-rs/src/api.rs | 16 ++- rust/loki-rs/tests/common/fakego.rs | 208 ++++++++++++++++++++++++++++ rust/loki-rs/tests/common/mod.rs | 2 + rust/loki-rs/tests/lot4.rs | 67 +++++++++ 4 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 rust/loki-rs/tests/common/fakego.rs create mode 100644 rust/loki-rs/tests/lot4.rs diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index d5237e4..f47f1d7 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -13,7 +13,8 @@ use serde_json::json; use crate::auth::{self, HostEnv, InternalClient}; use crate::engine; use crate::proxy::Body; -use crate::store::Store; +use crate::store::{BK_STATE, Store}; +use std::collections::BTreeMap; /// Ce dont les routes ont besoin, partagé par toutes les connexions. pub struct Ctx { @@ -38,7 +39,7 @@ impl Ctx { /// Cette route est-elle servie par Rust ? pub fn handles(_method: &Method, path: &str) -> bool { - matches!(path, "/api/ping") + matches!(path, "/api/ping" | "/api/config" | "/api/engine/cmdline") } pub async fn serve(ctx: &Ctx, req: Request) -> Response { @@ -53,6 +54,17 @@ pub async fn serve(ctx: &Ctx, req: Request) -> Response { StatusCode::OK, &json!({ "ok": true, "service": "loki", "version": version }), ), + // ReadConfig() du Go : json.Encoder trie les clés d'une map, d'où le + // BTreeMap (un HashMap sortirait dans un ordre aléatoire). + "/api/config" => { + let cfg: BTreeMap = ctx.store.config().await.into_iter().collect(); + auth::json(StatusCode::OK, &json!(cfg)) + } + // Commande du dernier lancement du moteur (engine_cmdline.go). + "/api/engine/cmdline" => { + let cmdline = ctx.store.get_str(BK_STATE, "engine_cmdline").await; + auth::json(StatusCode::OK, &json!({ "ok": true, "cmdline": cmdline })) + } _ => not_found(), } } diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs new file mode 100644 index 0000000..bda6e0d --- /dev/null +++ b/rust/loki-rs/tests/common/fakego.rs @@ -0,0 +1,208 @@ +//! Faux serveur Go : API interne (`/internal/v1/auth`, `/internal/v1/store`, +//! `/internal/v1/store/all`) sur un store en mémoire, et `go:` pour +//! toute autre route (ce qui prouve qu'elle a été relayée). + +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::{Method, Request, Response, StatusCode, service::service_fn}; +use hyper_util::rt::TokioIo; +use std::collections::{BTreeMap, HashMap}; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; + +pub const JETON: &str = "0123456789abcdef0123456789abcdef"; + +#[derive(Default)] +pub struct FakeGo { + /// bucket → clé → valeur. + pub store: Mutex>>>, + /// Base illisible : toute l'API interne répond 503. + pub broken: Mutex, + /// Chemins relayés (hors API interne). + pub relayed: Mutex>, +} + +impl FakeGo { + pub fn set(&self, bucket: &str, key: &str, val: &str) { + self.store + .lock() + .unwrap() + .entry(bucket.into()) + .or_default() + .insert(key.into(), val.as_bytes().to_vec()); + } + pub fn get(&self, bucket: &str, key: &str) -> Option { + let s = self.store.lock().unwrap(); + s.get(bucket)? + .get(key) + .map(|v| String::from_utf8_lossy(v).into_owned()) + } +} + +fn query(req: &Request) -> HashMap { + req.uri() + .query() + .unwrap_or("") + .split('&') + .filter_map(|kv| kv.split_once('=')) + .map(|(k, v)| (k.to_string(), decode(v))) + .collect() +} + +fn decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' && i + 2 < b.len() { + let h = std::str::from_utf8(&b[i + 1..i + 3]).unwrap(); + out.push(u8::from_str_radix(h, 16).unwrap()); + i += 3; + } else { + out.push(b[i]); + i += 1; + } + } + String::from_utf8(out).unwrap() +} + +fn resp(code: u16, body: impl Into) -> Response> { + let mut r = Response::new(Full::new(body.into())); + *r.status_mut() = StatusCode::from_u16(code).unwrap(); + r +} + +async fn handle(st: Arc, req: Request) -> Response> { + let path = req.uri().path().to_string(); + if !path.starts_with("/internal/") { + st.relayed.lock().unwrap().push(path.clone()); + return resp(200, format!("go:{path}")); + } + if req.headers().get("x-loki-internal").map(|v| v.as_bytes()) != Some(JETON.as_bytes()) { + return resp(404, ""); + } + if *st.broken.lock().unwrap() { + return resp(503, r#"{"error":"base illisible"}"#); + } + let q = query(&req); + let method = req.method().clone(); + match path.as_str() { + "/internal/v1/auth" => { + let key = st.get("state", "web_key").unwrap_or_default(); + resp(200, format!(r#"{{"web_key":"{key}","version":"0.15.0"}}"#)) + } + "/internal/v1/store/all" => { + let s = st.store.lock().unwrap(); + let m: BTreeMap = s + .get(&q["bucket"]) + .map(|b| { + b.iter() + .map(|(k, v)| (k.clone(), String::from_utf8_lossy(v).into_owned())) + .collect() + }) + .unwrap_or_default(); + resp(200, serde_json::to_string(&m).unwrap()) + } + "/internal/v1/store" => { + let (bucket, key) = (q["bucket"].clone(), q["key"].clone()); + match method { + Method::GET => match st + .store + .lock() + .unwrap() + .get(&bucket) + .and_then(|b| b.get(&key)) + { + Some(v) => resp(200, v.clone()), + None => resp(404, ""), + }, + Method::PUT => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + st.store + .lock() + .unwrap() + .entry(bucket) + .or_default() + .insert(key, body.to_vec()); + resp(204, "") + } + Method::DELETE => { + if let Some(b) = st.store.lock().unwrap().get_mut(&bucket) { + b.remove(&key); + } + resp(204, "") + } + _ => resp(405, ""), + } + } + _ => resp(404, ""), + } +} + +/// Lance le faux Go puis la façade devant lui ; rend (adresse de la façade, +/// état du faux Go, garde d'arrêt). +pub async fn start(home: std::path::PathBuf) -> (SocketAddr, Arc, oneshot::Sender<()>) { + let st = Arc::new(FakeGo::default()); + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let backend = l.local_addr().unwrap(); + let st2 = st.clone(); + tokio::spawn(async move { + while let Ok((s, _)) = l.accept().await { + let st = st2.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req| { + let st = st.clone(); + async move { Ok::<_, Infallible>(handle(st, req).await) } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(s), svc) + .await; + }); + } + }); + let fl = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let facade = fl.local_addr().unwrap(); + let (tx, rx) = oneshot::channel::<()>(); + let cfg = loki_rs::proxy::Config { + backend, + internal_token: JETON.into(), + host_env: loki_rs::auth::HostEnv::default(), + engine: loki_rs::engine::Paths { + home, + service: "loki-engine".into(), + }, + }; + tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { + let _ = rx.await; + })); + (facade, st, tx) +} + +/// GET via la façade : (code, en-têtes, corps). +pub async fn get( + facade: SocketAddr, + path: &str, + headers: &[(&str, &str)], +) -> (StatusCode, hyper::HeaderMap, Bytes) { + use hyper_util::client::legacy::Client; + use hyper_util::rt::TokioExecutor; + let mut req = Request::get(format!("http://{facade}{path}")); + for (k, v) in headers { + req = req.header(*k, *v); + } + let client: Client<_, Full> = Client::builder(TokioExecutor::new()).build_http(); + let r = client + .request(req.body(Full::new(Bytes::new())).unwrap()) + .await + .unwrap(); + let (parts, body) = r.into_parts(); + ( + parts.status, + parts.headers, + body.collect().await.unwrap().to_bytes(), + ) +} diff --git a/rust/loki-rs/tests/common/mod.rs b/rust/loki-rs/tests/common/mod.rs index 7131f6c..2d19fa4 100644 --- a/rust/loki-rs/tests/common/mod.rs +++ b/rust/loki-rs/tests/common/mod.rs @@ -2,6 +2,8 @@ //! journalise les chemins demandés. #![allow(dead_code)] +pub mod fakego; + use bytes::Bytes; use http_body_util::Full; use hyper::{Request, Response, StatusCode, body::Incoming, service::service_fn}; diff --git a/rust/loki-rs/tests/lot4.rs b/rust/loki-rs/tests/lot4.rs new file mode 100644 index 0000000..4428e96 --- /dev/null +++ b/rust/loki-rs/tests/lot4.rs @@ -0,0 +1,67 @@ +//! Lot 4 : routes /api portées en Rust, devant un faux Go à store en mémoire. + +mod common; + +use common::fakego; +use hyper::StatusCode; + +#[tokio::test] +async fn config_triee_comme_le_go() { + let t = tempfile::tempdir().unwrap(); + let (f, go, _stop) = fakego::start(t.path().into()).await; + go.set("config", "PORT", "8081"); + go.set("config", "CTX", "65536"); + go.set("config", "MODEL", "Qwen.gguf"); + let (code, h, body) = fakego::get(f, "/api/config", &[]).await; + assert_eq!(code, StatusCode::OK); + assert_eq!(h["content-type"], "application/json"); + assert_eq!( + &body[..], + b"{\"CTX\":\"65536\",\"MODEL\":\"Qwen.gguf\",\"PORT\":\"8081\"}\n" + ); + assert!(go.relayed.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn config_vide_si_base_illisible() { + let t = tempfile::tempdir().unwrap(); + let (f, go, _stop) = fakego::start(t.path().into()).await; + go.set("config", "PORT", "8081"); + // Clé lisible, puis base cassée : la garde d'auth ferme (503) — on ne sert + // jamais une route de pilotage sans avoir pu lire la clé. + *go.broken.lock().unwrap() = true; + let (code, _, _) = fakego::get(f, "/api/config", &[]).await; + assert_eq!(code, StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn commande_du_moteur() { + let t = tempfile::tempdir().unwrap(); + let (f, go, _stop) = fakego::start(t.path().into()).await; + let (_, _, body) = fakego::get(f, "/api/engine/cmdline", &[]).await; + assert_eq!(&body[..], b"{\"cmdline\":\"\",\"ok\":true}\n"); + go.set( + "state", + "engine_cmdline", + "CUDA_VISIBLE_DEVICES=0 /app/llama-server -m \"a b.gguf\"", + ); + let (_, _, body) = fakego::get(f, "/api/engine/cmdline", &[]).await; + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + v["cmdline"], + "CUDA_VISIBLE_DEVICES=0 /app/llama-server -m \"a b.gguf\"" + ); +} + +#[tokio::test] +async fn routes_portees_protegees_par_la_cle() { + let t = tempfile::tempdir().unwrap(); + let (f, go, _stop) = fakego::start(t.path().into()).await; + go.set("state", "web_key", "secret"); + for path in ["/api/config", "/api/engine/cmdline", "/api/ping"] { + let (code, _, _) = fakego::get(f, path, &[]).await; + assert_eq!(code, StatusCode::UNAUTHORIZED, "{path}"); + let (code, _, _) = fakego::get(f, path, &[("authorization", "Bearer secret")]).await; + assert_eq!(code, StatusCode::OK, "{path}"); + } +}