mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 05:07:20 +02:00
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:
1 parent
bea29bc6a9
commit
7017b66c08
4 files changed
+291
-2
No files matched your search
+14
-2
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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};
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user