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
173 lines
5.6 KiB
Rust
173 lines
5.6 KiB
Rust
//! Topology-poller acceptance tests for #72: the router maintains a live
|
|
//! map of which cortexes serve which models, marks an unreachable/erroring
|
|
//! cortex unhealthy and excludes it from routing, and recovers it once
|
|
//! reachable again.
|
|
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use axum::routing::get;
|
|
use axum::{Json, Router};
|
|
use helexa_router::config::{CortexEndpoint, RouterConfig};
|
|
use helexa_router::poller::{POLL_FAILURE_THRESHOLD, poll_once};
|
|
use helexa_router::state::{RouterState, entry_feasible};
|
|
use serde_json::{Value, json};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use tokio::net::TcpListener;
|
|
|
|
/// Shared "is this mock cortex up?" flag, toggled by tests to simulate
|
|
/// outage and recovery.
|
|
#[derive(Clone)]
|
|
struct MockState {
|
|
up: Arc<AtomicBool>,
|
|
}
|
|
|
|
async fn mock_models(State(s): State<MockState>) -> Result<Json<Value>, StatusCode> {
|
|
if !s.up.load(Ordering::SeqCst) {
|
|
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
|
}
|
|
Ok(Json(json!({
|
|
"object": "list",
|
|
"data": [
|
|
{
|
|
"id": "Qwen/Qwen3-Coder-30B",
|
|
"object": "model",
|
|
"created": 0,
|
|
"owned_by": "helexa",
|
|
"loaded": true,
|
|
"feasible_on": ["beast"],
|
|
"locations": [{"node": "beast", "status": "loaded", "vram_estimate_mb": 19000}]
|
|
},
|
|
{
|
|
"id": "Qwen/Qwen3-VL-8B",
|
|
"object": "model",
|
|
"created": 0,
|
|
"owned_by": "helexa",
|
|
"loaded": false,
|
|
"feasible_on": ["beast"],
|
|
"locations": []
|
|
}
|
|
]
|
|
})))
|
|
}
|
|
|
|
async fn mock_health(State(s): State<MockState>) -> Result<Json<Value>, StatusCode> {
|
|
if !s.up.load(Ordering::SeqCst) {
|
|
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
|
}
|
|
Ok(Json(json!({
|
|
"status": "ok",
|
|
"nodes": { "healthy": 2, "total": 3 }
|
|
})))
|
|
}
|
|
|
|
/// Spawn a mock cortex; returns (base_url, up_flag).
|
|
async fn spawn_mock_cortex() -> (String, Arc<AtomicBool>) {
|
|
let up = Arc::new(AtomicBool::new(true));
|
|
let state = MockState { up: up.clone() };
|
|
let app = Router::new()
|
|
.route("/v1/models", get(mock_models))
|
|
.route("/health", get(mock_health))
|
|
.with_state(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}"), up)
|
|
}
|
|
|
|
fn state_for(name: &str, endpoint: &str) -> RouterState {
|
|
let cfg = RouterConfig {
|
|
cortexes: vec![CortexEndpoint {
|
|
name: name.into(),
|
|
endpoint: endpoint.into(),
|
|
region: None,
|
|
tls_ca: None,
|
|
}],
|
|
..Default::default()
|
|
};
|
|
RouterState::from_config(&cfg)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn poll_builds_live_topology() {
|
|
let (base, _up) = spawn_mock_cortex().await;
|
|
let state = state_for("c1", &base);
|
|
|
|
poll_once(&state).await;
|
|
|
|
let topo = state.topology.read().await;
|
|
let c1 = topo.get("c1").expect("cortex present");
|
|
assert!(c1.reachable, "should be reachable after a good poll");
|
|
assert_eq!(c1.consecutive_failures, 0);
|
|
assert!(c1.last_poll.is_some());
|
|
assert_eq!((c1.healthy_nodes, c1.total_nodes), (2, 3));
|
|
|
|
// Loaded model: loaded + feasible. Catalogue-only model: feasible only
|
|
// (not loaded, but feasible_on non-empty).
|
|
let coder = c1.models.get("Qwen/Qwen3-Coder-30B").unwrap();
|
|
assert!(coder.loaded && entry_feasible(coder));
|
|
let vl = c1.models.get("Qwen/Qwen3-VL-8B").unwrap();
|
|
assert!(!vl.loaded && entry_feasible(vl));
|
|
drop(topo);
|
|
|
|
// The routing helper sees both serveable models on the reachable cortex.
|
|
assert_eq!(
|
|
state.cortexes_serving("Qwen/Qwen3-VL-8B").await,
|
|
vec!["c1".to_string()]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unreachable_cortex_excluded_then_recovers() {
|
|
let (base, up) = spawn_mock_cortex().await;
|
|
let state = state_for("c1", &base);
|
|
|
|
// Healthy first.
|
|
poll_once(&state).await;
|
|
assert!(state.topology.read().await["c1"].reachable);
|
|
|
|
// Take it down. The first failures debounce (stay reachable) until the
|
|
// threshold; only then is it excluded.
|
|
up.store(false, Ordering::SeqCst);
|
|
for i in 1..POLL_FAILURE_THRESHOLD {
|
|
poll_once(&state).await;
|
|
assert!(
|
|
state.topology.read().await["c1"].reachable,
|
|
"still reachable after {i} failure(s) (below threshold)"
|
|
);
|
|
}
|
|
poll_once(&state).await; // crosses the threshold
|
|
{
|
|
let topo = state.topology.read().await;
|
|
assert!(!topo["c1"].reachable, "excluded after threshold failures");
|
|
assert!(topo["c1"].consecutive_failures >= POLL_FAILURE_THRESHOLD);
|
|
}
|
|
// Excluded from routing.
|
|
assert!(
|
|
state
|
|
.cortexes_serving("Qwen/Qwen3-Coder-30B")
|
|
.await
|
|
.is_empty()
|
|
);
|
|
|
|
// Bring it back: the next successful poll restores it.
|
|
up.store(true, Ordering::SeqCst);
|
|
poll_once(&state).await;
|
|
let topo = state.topology.read().await;
|
|
assert!(topo["c1"].reachable, "recovered after a good poll");
|
|
assert_eq!(topo["c1"].consecutive_failures, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unconfigured_endpoint_is_unreachable() {
|
|
// Nothing listening on this port → polls fail; below threshold it stays
|
|
// at its initial unreachable state, and never panics.
|
|
let state = state_for("dead", "http://127.0.0.1:1");
|
|
poll_once(&state).await;
|
|
let topo = state.topology.read().await;
|
|
assert!(!topo["dead"].reachable);
|
|
assert_eq!(topo["dead"].consecutive_failures, 1);
|
|
}
|