All checks were successful
CI / Format (push) Successful in 41s
CI / CUDA type-check (push) Successful in 1m34s
CI / Clippy (push) Successful in 2m23s
CI / Test (push) Successful in 5m19s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The router is a TLS client to cortexes; the router->cortex hop crosses the helexa->operator boundary carrying the client's bearer. This pins that hop to an enrolled cert. Trust mechanism (the open question): per-cortex enrolled trust anchor. Each [[cortexes]] entry gets an optional `tls_ca` — a PEM CA (or self-signed cert) the cortex's TLS cert must chain to. When set, the router builds a client that trusts ONLY that anchor (platform roots disabled), so the cortex must present the expected cert and a rogue endpoint with any other (even publicly-valid) cert is rejected at the handshake. Enrolment = the operator hands helexa the cortex's cert, referenced by path in router config. This is the natural model for self-hosted operators behind their own nginx/private CA, and reuses the reqwest public API (no custom rustls verifier, no new TLS backend). - `RouterState` now holds a per-cortex `reqwest::Client` map (`client_for`), replacing the single shared client; poller and dispatch use the per-cortex client. `build_client(tls_ca)` is the builder. - Fail closed: a `tls_ca` that can't load omits the cortex from the client map — it's never polled or routed to, rather than silently degrading to unpinned TLS. The poller treats a missing client (and a rejected handshake) as a failed poll, so #72's existing reachability debounce excludes it. Tests (`tls.rs`, 4): a live tokio-rustls HTTPS server proves a client enrolled with the server's cert is accepted (200) while clients pinned to a different cert — or using default roots — are rejected; the poller marks a wrong-cert cortex unreachable while a correctly-enrolled one is reachable; a missing pin file disables the cortex (fail closed); garbage PEM is rejected at build. Existing suites updated for the per-cortex client + new config field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
98 lines
2.7 KiB
Rust
98 lines
2.7 KiB
Rust
//! Skeleton acceptance tests for #70: the router builds, serves `/health`
|
|
//! and `/v1/models` on a plaintext port, and loads its cortex-endpoint list
|
|
//! from TOML with env overrides.
|
|
|
|
use helexa_router::config::{CortexEndpoint, RouterConfig};
|
|
use helexa_router::state::RouterState;
|
|
use std::sync::Arc;
|
|
use tokio::net::TcpListener;
|
|
|
|
/// Bind the router app on an ephemeral port and return its base URL.
|
|
async fn spawn_router(cortexes: Vec<CortexEndpoint>) -> String {
|
|
let cfg = RouterConfig {
|
|
cortexes,
|
|
..Default::default()
|
|
};
|
|
let state = Arc::new(RouterState::from_config(&cfg));
|
|
let app = helexa_router::build_app(state);
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.unwrap();
|
|
});
|
|
|
|
format!("http://{addr}")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn health_reports_configured_cortex_count() {
|
|
let base = spawn_router(vec![
|
|
CortexEndpoint {
|
|
name: "a".into(),
|
|
endpoint: "https://a.example.com".into(),
|
|
region: None,
|
|
tls_ca: None,
|
|
},
|
|
CortexEndpoint {
|
|
name: "b".into(),
|
|
endpoint: "https://b.example.com".into(),
|
|
region: None,
|
|
tls_ca: None,
|
|
},
|
|
])
|
|
.await;
|
|
|
|
let body: serde_json::Value = reqwest::get(format!("{base}/health"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(body["status"], "ok");
|
|
assert_eq!(body["cortexes"]["configured"], 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn models_returns_empty_openai_list() {
|
|
let base = spawn_router(vec![]).await;
|
|
|
|
let resp = reqwest::get(format!("{base}/v1/models")).await.unwrap();
|
|
assert!(resp.status().is_success());
|
|
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
assert_eq!(body["object"], "list");
|
|
assert_eq!(body["data"].as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
#[allow(clippy::result_large_err)]
|
|
fn config_loads_from_toml_with_env_override() {
|
|
figment::Jail::expect_with(|jail| {
|
|
jail.create_file(
|
|
"helexa-router.toml",
|
|
r#"
|
|
[router]
|
|
listen = "127.0.0.1:8088"
|
|
|
|
[[cortexes]]
|
|
name = "lair-cafe"
|
|
endpoint = "https://cortex.lair.cafe"
|
|
"#,
|
|
)?;
|
|
|
|
// Env override wins over the TOML value.
|
|
jail.set_env("HELEXA_ROUTER_ROUTER__LISTEN", "0.0.0.0:9099");
|
|
|
|
let cfg = RouterConfig::load("helexa-router.toml").expect("load config");
|
|
|
|
assert_eq!(cfg.router.listen, "0.0.0.0:9099");
|
|
assert_eq!(cfg.cortexes.len(), 1);
|
|
assert_eq!(cfg.cortexes[0].name, "lair-cafe");
|
|
assert_eq!(cfg.cortexes[0].endpoint, "https://cortex.lair.cafe");
|
|
|
|
Ok(())
|
|
});
|
|
}
|