147 lines
4.0 KiB
Rust
147 lines
4.0 KiB
Rust
use std::net::TcpListener;
|
|
use std::path::Path;
|
|
use std::process::Stdio;
|
|
use std::time::Duration;
|
|
|
|
use serde_json::{Value, json};
|
|
use tokio::process::{Child, Command};
|
|
|
|
struct GatewayProcess(Child);
|
|
|
|
impl Drop for GatewayProcess {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.start_kill();
|
|
}
|
|
}
|
|
|
|
fn available_port() -> u16 {
|
|
TcpListener::bind("127.0.0.1:0")
|
|
.unwrap()
|
|
.local_addr()
|
|
.unwrap()
|
|
.port()
|
|
}
|
|
|
|
fn config(workspace: &Path, model_id: &str) -> Value {
|
|
json!({
|
|
"providers": {
|
|
"provider": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test"
|
|
}
|
|
},
|
|
"models": { "model": { "model_id": model_id } },
|
|
"agents": { "default": { "provider": "provider", "model": "model" } },
|
|
"gateway": { "require_pairing": false },
|
|
"workspace_dir": workspace
|
|
})
|
|
}
|
|
|
|
async fn wait_for_health(client: &reqwest::Client, base: &str) {
|
|
for _ in 0..100 {
|
|
if client
|
|
.get(format!("{base}/health"))
|
|
.send()
|
|
.await
|
|
.is_ok_and(|response| response.status().is_success())
|
|
{
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
panic!("gateway did not become healthy");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_reloads_valid_config_and_keeps_serving_after_invalid_config() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let home = temp.path().join("home");
|
|
let config_dir = home.join(".picobot");
|
|
let workspace = temp.path().join("workspace");
|
|
std::fs::create_dir_all(&config_dir).unwrap();
|
|
std::fs::create_dir_all(&workspace).unwrap();
|
|
let config_path = config_dir.join("config.json");
|
|
std::fs::write(
|
|
&config_path,
|
|
serde_json::to_vec_pretty(&config(&workspace, "old-model")).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let port = available_port();
|
|
let child = Command::new(env!("CARGO_BIN_EXE_picobot"))
|
|
.args([
|
|
"gateway",
|
|
"--host",
|
|
"127.0.0.1",
|
|
"--port",
|
|
&port.to_string(),
|
|
])
|
|
.env("HOME", &home)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.kill_on_drop(true)
|
|
.spawn()
|
|
.unwrap();
|
|
let mut gateway = GatewayProcess(child);
|
|
let client = reqwest::Client::new();
|
|
let base = format!("http://127.0.0.1:{port}");
|
|
wait_for_health(&client, &base).await;
|
|
|
|
std::fs::write(
|
|
&config_path,
|
|
serde_json::to_vec_pretty(&config(&workspace, "new-model")).unwrap(),
|
|
)
|
|
.unwrap();
|
|
let accepted: Value = client
|
|
.post(format!("{base}/api/config/reload"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.error_for_status()
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(accepted["generation"], 2);
|
|
|
|
let mut active = false;
|
|
for _ in 0..100 {
|
|
let response = client
|
|
.get(format!("{base}/api/config/reload/status"))
|
|
.send()
|
|
.await;
|
|
if let Ok(response) = response
|
|
&& let Ok(status) = response.json::<Value>().await
|
|
&& status["generation"] == 2
|
|
&& status["phase"] == "active"
|
|
{
|
|
active = true;
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
assert!(active, "reloaded generation did not become active");
|
|
|
|
std::fs::write(&config_path, b"{").unwrap();
|
|
let invalid = client
|
|
.post(format!("{base}/api/config/reload"))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST);
|
|
assert!(
|
|
client
|
|
.get(format!("{base}/health"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status()
|
|
.is_success(),
|
|
"invalid candidate stopped the active generation"
|
|
);
|
|
|
|
gateway.0.start_kill().unwrap();
|
|
gateway.0.wait().await.unwrap();
|
|
}
|