#[cfg(not(feature = "serve"))]
fn main() {
eprintln!("gradatum-engine: compilé sans la feature 'serve'. Rien à faire.");
std::process::exit(1);
}
#[cfg(feature = "serve")]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
use gradatum_core::event_sink::InMemorySink;
use gradatum_engine::{
config::{EngineConfig, RuntimeKind},
health::HealthState,
metrics::EngineMetrics,
runtime::ForwardProxy,
server::{AppState, EngineServer},
sink::HttpEventSink,
supervisor::LlamaServerSupervisor,
};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
path::Path,
sync::Arc,
};
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "gradatum_engine=info".parse().unwrap()),
)
.init();
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("Usage: gradatum-engine <config-path>");
std::process::exit(1);
}
let config_path = Path::new(&args[1]);
let config = EngineConfig::load_local(config_path)
.map_err(|e| anyhow::anyhow!("EngineConfig::load_local échoué : {e}"))?;
config
.validate()
.map_err(|e| anyhow::anyhow!("config invalide : {e}"))?;
if config.runtime == RuntimeKind::Onnx {
anyhow::bail!("runtime 'onnx' non implémenté. Utiliser runtime='llamaserver' (défaut).");
}
if config.child_port <= 1024 {
anyhow::bail!(
"child_port {} invalide — doit être > 1024 (SP-P0-4)",
config.child_port
);
}
validate_loopback_url(&config.gradatum_url)?;
let api_key = read_api_key()?;
let sink: Arc<dyn gradatum_core::event_sink::EventSink> = {
match exchange_api_key_for_jwt(&api_key, &config.gradatum_url).await {
Ok(jwt) => Arc::new(HttpEventSink::new(config.gradatum_url.clone(), jwt)),
Err(e) => {
tracing::warn!(
error = %e,
"échange api-key→JWT échoué. Fallback InMemorySink (event-log non alimenté)."
);
Arc::new(InMemorySink::default())
}
}
};
let model_name = config.model_alias();
let provider = config.provider_alias();
let health = Arc::new(HealthState::new(&model_name));
let metrics = Arc::new(EngineMetrics::new());
let supervisor = LlamaServerSupervisor::new(config.clone())
.map_err(|e| anyhow::anyhow!("LlamaServerSupervisor::new échoué : {e}"))?;
supervisor
.spawn_child()
.await
.map_err(|e| anyhow::anyhow!("spawn llama-server échoué : {e}"))?;
let initial_ready_at = {
let state = supervisor.wait_ready(&health).await;
if state == gradatum_engine::supervisor::ChildState::StartupTimeout {
tracing::error!(
"llama-server n'a pas démarré dans le timeout — moteur unhealthy. \
Le fallback gateway prend le relais."
);
health.set_unhealthy();
None } else {
Some(std::time::Instant::now())
}
};
let proxy = ForwardProxy::new(supervisor.client.clone(), supervisor.child_base_url());
let state = AppState {
proxy,
health: health.clone(),
metrics: metrics.clone(),
sink,
model_name,
provider,
timeout_secs: config.timeout_secs,
body_limit_bytes: config.body_limit_bytes,
};
let supervisor_arc = supervisor.clone();
let health_arc = health.clone();
tokio::spawn(async move {
supervisor_arc
.supervise_loop(health_arc, initial_ready_at)
.await;
});
let metrics_addr = SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
config.resolved_metrics_port(),
);
let metrics_listener = tokio::net::TcpListener::bind(metrics_addr).await?;
let metrics_router = EngineServer::metrics_router(metrics);
tracing::info!(
metrics_addr = %metrics_addr,
"gradatum-engine /metrics listener loopback démarré"
);
tokio::spawn(async move {
if let Err(e) = axum::serve(metrics_listener, metrics_router).await {
tracing::error!(error = %e, "metrics listener erreur");
}
});
let bind_addr = config.resolved_bind_addr();
let addr = SocketAddr::new(bind_addr, config.port);
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(
addr = %addr,
model = %state.model_name,
child_port = config.child_port,
metrics_port = config.resolved_metrics_port(),
"gradatum-engine démarré (superviseur llama-server PIVOT v2)"
);
let router = EngineServer::router(state);
axum::serve(listener, router).await?;
Ok(())
}
#[cfg(feature = "serve")]
fn read_api_key() -> anyhow::Result<zeroize::Zeroizing<String>> {
if let Ok(key) = std::env::var("GRADATUM_ENGINE_API_KEY") {
return Ok(zeroize::Zeroizing::new(key));
}
let path = "/etc/gradatum/engine.api-key";
let key = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("FATAL: api-key introuvable ({path}): {e}"))?;
Ok(zeroize::Zeroizing::new(key.trim().to_string()))
}
#[cfg(feature = "serve")]
async fn exchange_api_key_for_jwt(
api_key: &zeroize::Zeroizing<String>,
base_url: &str,
) -> anyhow::Result<zeroize::Zeroizing<String>> {
let url = format!("{base_url}/auth/exchange");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()?;
let resp = client
.post(&url)
.bearer_auth(api_key.as_str())
.send()
.await
.map_err(|e| anyhow::anyhow!("échange api-key→JWT échoué ({url}): {e}"))?;
if !resp.status().is_success() {
anyhow::bail!("échange api-key→JWT → HTTP {} ({url})", resp.status());
}
let body: serde_json::Value = resp.json().await?;
let token = body["token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("réponse exchange sans champ 'token'"))?;
Ok(zeroize::Zeroizing::new(token.to_string()))
}
#[cfg(feature = "serve")]
fn validate_loopback_url(url: &str) -> anyhow::Result<()> {
let parsed = url::Url::parse(url)
.map_err(|e| anyhow::anyhow!("gradatum_url invalide (parsing URL) : {e}"))?;
let host = parsed
.host_str()
.ok_or_else(|| anyhow::anyhow!("gradatum_url sans host : {url}"))?;
if host == "127.0.0.1" || host == "localhost" {
Ok(())
} else {
anyhow::bail!(
"gradatum_url doit être loopback (127.0.0.1 ou localhost), host={host} : {url}"
)
}
}
#[cfg(all(test, feature = "serve"))]
mod bin_tests {
use super::*;
#[test]
fn exchange_url_ends_with_auth_exchange_not_api_v1() {
let base = "http://127.0.0.1:19090";
let url = format!("{base}/auth/exchange");
assert!(
url.ends_with("/auth/exchange"),
"URL doit se terminer par /auth/exchange : {url}"
);
assert!(
!url.contains("/api/v1/auth/exchange"),
"URL ne doit PAS contenir /api/v1/auth/exchange : {url}"
);
}
#[test]
fn validate_loopback_accepts_127_0_0_1() {
assert!(validate_loopback_url("http://127.0.0.1:19090").is_ok());
}
#[test]
fn validate_loopback_accepts_localhost() {
assert!(validate_loopback_url("http://localhost:19090").is_ok());
}
#[test]
fn validate_loopback_rejects_bypass_subdomain() {
let result = validate_loopback_url("http://127.0.0.1.evil.com:19090");
assert!(
result.is_err(),
"127.0.0.1.evil.com doit être rejeté (SSRF bypass)"
);
}
#[test]
fn validate_loopback_rejects_external_ip() {
let result = validate_loopback_url("http://203.0.113.1:19090");
assert!(result.is_err(), "IP externe doit être rejetée");
}
#[test]
fn validate_loopback_rejects_invalid_url() {
let result = validate_loopback_url("not-a-url");
assert!(result.is_err(), "URL invalide doit être rejetée");
}
}