Lot 4 : /api/config et /api/engine/cmdline servis par Rust

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-14 17:43:15 +02:00
1 parent bea29bc6a9
commit 7017b66c08
4 files changed
+291 -2

No files matched your search

+14 -2
View File
@@ -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<Incoming>) -> Response<Body> {
@@ -53,6 +54,17 @@ pub async fn serve(ctx: &Ctx, req: Request<Incoming>) -> Response<Body> {
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<String, String> = 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(),
}
}
+208
View File
@@ -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:<chemin>` 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<HashMap<String, BTreeMap<String, Vec<u8>>>>,
/// Base illisible : toute l'API interne répond 503.
pub broken: Mutex<bool>,
/// Chemins relayés (hors API interne).
pub relayed: Mutex<Vec<String>>,
}
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<String> {
let s = self.store.lock().unwrap();
s.get(bucket)?
.get(key)
.map(|v| String::from_utf8_lossy(v).into_owned())
}
}
fn query(req: &Request<Incoming>) -> HashMap<String, String> {
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<Bytes>) -> Response<Full<Bytes>> {
let mut r = Response::new(Full::new(body.into()));
*r.status_mut() = StatusCode::from_u16(code).unwrap();
r
}
async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>> {
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<String, String> = 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<FakeGo>, 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<Bytes>> = 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(),
)
}
+2
View File
@@ -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};
+67
View File
@@ -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}");
}
}