CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
471 lines
15 KiB
Rust
471 lines
15 KiB
Rust
//! End-to-end tests for the `mirage daemon` HTTP/WebSocket server.
|
|
//!
|
|
//! Each test spawns a real `mirage daemon` subprocess against a fresh
|
|
//! temp XDG root and exercises the REST + WS surface using `reqwest`
|
|
//! and `tokio-tungstenite`. The daemon in turn spawns per-session
|
|
//! `mirage host` subprocesses via `MIRAGE_BIN` (set to the test
|
|
//! mirage binary), giving us full end-to-end coverage of the same
|
|
//! code path users hit.
|
|
//!
|
|
//! Gated on the `webui` feature: the `mirage webui` subcommand and the
|
|
//! served SPA only exist when the web UI is compiled in.
|
|
#![cfg(feature = "webui")]
|
|
|
|
use std::net::TcpListener;
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use serde_json::{Value, json};
|
|
use tempfile::TempDir;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
|
|
/// Pick an unused localhost port. Subject to a race but acceptable
|
|
/// for tests.
|
|
fn free_port() -> u16 {
|
|
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
l.local_addr().unwrap().port()
|
|
}
|
|
|
|
struct Daemon {
|
|
_dir: TempDir,
|
|
child: Child,
|
|
base: String,
|
|
config: PathBuf,
|
|
runtime: PathBuf,
|
|
}
|
|
|
|
impl Daemon {
|
|
fn spawn() -> Self {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let config = dir.path().join("config");
|
|
let runtime = dir.path().join("runtime");
|
|
let state = dir.path().join("state");
|
|
std::fs::create_dir_all(&config).unwrap();
|
|
std::fs::create_dir_all(&runtime).unwrap();
|
|
std::fs::create_dir_all(&state).unwrap();
|
|
let mirage_bin = PathBuf::from(env!("CARGO_BIN_EXE_mirage"));
|
|
let port = free_port();
|
|
let addr = format!("127.0.0.1:{port}");
|
|
let child = Command::new(&mirage_bin)
|
|
.args(["webui", "--addr", &addr])
|
|
.env("XDG_CONFIG_HOME", &config)
|
|
.env("XDG_RUNTIME_DIR", &runtime)
|
|
.env("XDG_STATE_HOME", &state)
|
|
.env("MIRAGE_BIN", &mirage_bin)
|
|
.env_remove("MIRAGE_LOG")
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn mirage daemon");
|
|
let base = format!("http://{addr}");
|
|
let d = Daemon {
|
|
_dir: dir,
|
|
child,
|
|
base,
|
|
config,
|
|
runtime,
|
|
};
|
|
d.wait_ready();
|
|
d
|
|
}
|
|
|
|
fn wait_ready(&self) {
|
|
let deadline = Instant::now() + Duration::from_secs(15);
|
|
let url = format!("{}/api/paths", self.base);
|
|
while Instant::now() < deadline {
|
|
if let Ok(resp) = reqwest::blocking::get(&url)
|
|
&& resp.status().is_success()
|
|
{
|
|
return;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
panic!("daemon never became reachable on {}", self.base);
|
|
}
|
|
|
|
fn url(&self, path: &str) -> String {
|
|
format!("{}{path}", self.base)
|
|
}
|
|
|
|
fn ws_url(&self, path: &str) -> String {
|
|
format!("ws://{}{path}", self.base.trim_start_matches("http://"))
|
|
}
|
|
|
|
fn get_json(&self, path: &str) -> (reqwest::StatusCode, Value) {
|
|
let r = reqwest::blocking::get(self.url(path)).unwrap();
|
|
let status = r.status();
|
|
let v: Value = r.json().unwrap_or(Value::Null);
|
|
(status, v)
|
|
}
|
|
|
|
fn post_json(&self, path: &str, body: &Value) -> (reqwest::StatusCode, Value) {
|
|
let c = reqwest::blocking::Client::new();
|
|
let r = c.post(self.url(path)).json(body).send().unwrap();
|
|
let status = r.status();
|
|
let v: Value = r.json().unwrap_or(Value::Null);
|
|
(status, v)
|
|
}
|
|
|
|
fn put_json(&self, path: &str, body: &Value) -> (reqwest::StatusCode, Value) {
|
|
let c = reqwest::blocking::Client::new();
|
|
let r = c.put(self.url(path)).json(body).send().unwrap();
|
|
let status = r.status();
|
|
let v: Value = r.json().unwrap_or(Value::Null);
|
|
(status, v)
|
|
}
|
|
|
|
fn delete(&self, path: &str) -> (reqwest::StatusCode, Value) {
|
|
let c = reqwest::blocking::Client::new();
|
|
let r = c.delete(self.url(path)).send().unwrap();
|
|
let status = r.status();
|
|
let v: Value = r.json().unwrap_or(Value::Null);
|
|
(status, v)
|
|
}
|
|
}
|
|
|
|
impl Drop for Daemon {
|
|
fn drop(&mut self) {
|
|
let _ = nix::sys::signal::kill(
|
|
nix::unistd::Pid::from_raw(self.child.id() as i32),
|
|
nix::sys::signal::Signal::SIGTERM,
|
|
);
|
|
let _ = self.child.wait();
|
|
// Also kill any host children still alive under our runtime dir
|
|
// (in case session_destroy wasn't called from the test).
|
|
if let Ok(rd) = std::fs::read_dir(self.runtime.join("mirage/session")) {
|
|
for ent in rd.flatten() {
|
|
let pidf = ent.path().join("node/0/pid");
|
|
if let Ok(s) = std::fs::read_to_string(&pidf)
|
|
&& let Ok(pid) = s.trim().parse::<i32>()
|
|
{
|
|
let _ = nix::sys::signal::kill(
|
|
nix::unistd::Pid::from_raw(pid),
|
|
nix::sys::signal::Signal::SIGKILL,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn create_profile(d: &Daemon, name: &str) {
|
|
let prof = json!({
|
|
"name": name,
|
|
"description": "test profile",
|
|
"emulator": {
|
|
"emulator": "noop",
|
|
"plugins": {},
|
|
"exec_mode": "Functional",
|
|
"options": {},
|
|
"topology": {"num_nodes": 1, "gpus_per_node": 1, "agent": "MI350X"}
|
|
}
|
|
});
|
|
let (s, b) = d.put_json(&format!("/api/profiles/{name}"), &prof);
|
|
assert!(s.is_success(), "put profile failed: {s} {b}");
|
|
}
|
|
|
|
#[test]
|
|
fn paths_endpoint_reports_overridden_dirs() {
|
|
let d = Daemon::spawn();
|
|
let (s, v) = d.get_json("/api/paths");
|
|
assert!(s.is_success());
|
|
assert!(
|
|
v["config"]
|
|
.as_str()
|
|
.unwrap()
|
|
.starts_with(d.config.to_str().unwrap()),
|
|
"expected config under tempdir, got {v}"
|
|
);
|
|
assert!(
|
|
v["runtime"]
|
|
.as_str()
|
|
.unwrap()
|
|
.starts_with(d.runtime.to_str().unwrap())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_crud_via_http() {
|
|
let d = Daemon::spawn();
|
|
|
|
// Preserve the auto-seeded builtin profiles as the CRUD baseline.
|
|
let (_, initial_profiles) = d.get_json("/api/profiles");
|
|
let initial_count = initial_profiles
|
|
.as_array()
|
|
.expect("profile list should be an array")
|
|
.len();
|
|
|
|
// Create.
|
|
create_profile(&d, "p1");
|
|
|
|
// List.
|
|
let (_, v) = d.get_json("/api/profiles");
|
|
let names = v.as_array().expect("profile list should be an array");
|
|
assert_eq!(names.len(), initial_count + 1);
|
|
assert!(names.contains(&json!("p1")));
|
|
|
|
// Get.
|
|
let (_, v) = d.get_json("/api/profiles/p1");
|
|
assert_eq!(v["name"], "p1");
|
|
|
|
// Delete.
|
|
let (s, _) = d.delete("/api/profiles/p1");
|
|
assert!(s.is_success());
|
|
let (_, v) = d.get_json("/api/profiles");
|
|
assert_eq!(v, initial_profiles);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_profile_returns_404() {
|
|
let d = Daemon::spawn();
|
|
let (s, _) = d.get_json("/api/profiles/nope");
|
|
assert_eq!(s, reqwest::StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_session_id_returns_400() {
|
|
let d = Daemon::spawn();
|
|
let (s, _) = d.get_json("/api/sessions/..bad..");
|
|
assert_eq!(s, reqwest::StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[test]
|
|
fn session_lifecycle_via_http() {
|
|
let d = Daemon::spawn();
|
|
create_profile(&d, "demo");
|
|
|
|
// Create session (daemon spawns the host).
|
|
let (s, v) = d.post_json(
|
|
"/api/sessions",
|
|
&json!({"profile": "demo", "ready_timeout": 10}),
|
|
);
|
|
assert!(s.is_success(), "create_session: {s} {v}");
|
|
let sid = v["id"].as_str().unwrap().to_string();
|
|
|
|
// List shows it.
|
|
let (_, list) = d.get_json("/api/sessions");
|
|
assert_eq!(list.as_array().unwrap().len(), 1);
|
|
|
|
// Health endpoint reports healthy.
|
|
let (_, state) = d.get_json(&format!("/api/sessions/{sid}"));
|
|
assert!(state["health"]["healthy"].as_bool().unwrap_or(false));
|
|
|
|
// Tear down.
|
|
let (s, _) = d.delete(&format!("/api/sessions/{sid}"));
|
|
assert!(s.is_success());
|
|
|
|
// Now empty.
|
|
let (_, list) = d.get_json("/api/sessions");
|
|
assert_eq!(list.as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn exec_attach_streams_output_via_websocket() {
|
|
// Spawn the daemon on a blocking thread so its Drop (which waits
|
|
// on the subprocess) doesn't run inside the tokio reactor.
|
|
let d = tokio::task::spawn_blocking(Daemon::spawn).await.unwrap();
|
|
let client = reqwest::Client::new();
|
|
|
|
create_profile_async(&client, &d, "demo").await;
|
|
|
|
// Create session.
|
|
let v: Value = client
|
|
.post(d.url("/api/sessions"))
|
|
.json(&json!({"profile": "demo", "ready_timeout": 10}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let sid = v["id"].as_str().unwrap().to_string();
|
|
|
|
// Create exec running a short command.
|
|
let v: Value = client
|
|
.post(d.url(&format!("/api/sessions/{sid}/execs")))
|
|
.json(&json!({
|
|
"command": "/bin/sh",
|
|
"args": ["-c", "echo hello-from-exec; echo err 1>&2; exit 7"],
|
|
"keep": true
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let exec_id = v["id"].as_str().unwrap().to_string();
|
|
|
|
// Attach via websocket and collect packets until ExecExit.
|
|
let url = d.ws_url(&format!("/api/sessions/{sid}/execs/{exec_id}/attach"));
|
|
let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
|
|
|
|
let mut stdout = String::new();
|
|
let mut stderr = String::new();
|
|
let mut exit_code: Option<i32> = None;
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(15);
|
|
while std::time::Instant::now() < deadline {
|
|
let frame = tokio::time::timeout(Duration::from_secs(5), ws.next())
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
let Some(Ok(msg)) = frame else { break };
|
|
let text = match msg {
|
|
Message::Text(t) => t.to_string(),
|
|
Message::Close(_) => break,
|
|
_ => continue,
|
|
};
|
|
let v: Value = serde_json::from_str(&text).unwrap();
|
|
if let Some(out) = v.get("Output") {
|
|
let data: Vec<u8> = serde_json::from_value(out["data"].clone()).unwrap();
|
|
let s = String::from_utf8_lossy(&data).to_string();
|
|
match out["stream"].as_str().unwrap_or("") {
|
|
"Stdout" => stdout.push_str(&s),
|
|
"Stderr" => stderr.push_str(&s),
|
|
_ => {}
|
|
}
|
|
} else if let Some(exit) = v.get("ExecExit") {
|
|
exit_code = Some(exit["exit_code"].as_i64().unwrap() as i32);
|
|
break;
|
|
}
|
|
}
|
|
let _ = ws.send(Message::Close(None)).await;
|
|
|
|
// The exec runs under a PTY, so stdout and stderr are merged onto the
|
|
// terminal (the stdout stream), exactly as in a real terminal.
|
|
let combined = format!("{stdout}{stderr}");
|
|
assert!(
|
|
combined.contains("hello-from-exec"),
|
|
"got output: {combined:?}"
|
|
);
|
|
assert!(combined.contains("err"), "got output: {combined:?}");
|
|
assert_eq!(exit_code, Some(7));
|
|
|
|
// Status now reflects ended.
|
|
let status: Value = client
|
|
.get(d.url(&format!("/api/sessions/{sid}/execs/{exec_id}")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(status["ended"], true);
|
|
assert_eq!(status["exit_code"], 7);
|
|
|
|
// Cleanup the session.
|
|
let _ = client
|
|
.delete(d.url(&format!("/api/sessions/{sid}")))
|
|
.send()
|
|
.await;
|
|
// Drop the daemon on a blocking thread for the same reason as spawn.
|
|
tokio::task::spawn_blocking(move || drop(d)).await.unwrap();
|
|
}
|
|
|
|
async fn create_profile_async(client: &reqwest::Client, d: &Daemon, name: &str) {
|
|
let prof = json!({
|
|
"name": name,
|
|
"description": "test profile",
|
|
"emulator": {
|
|
"emulator": "noop",
|
|
"plugins": {},
|
|
"exec_mode": "Functional",
|
|
"options": {},
|
|
"topology": {"num_nodes": 1, "gpus_per_node": 1, "agent": "MI350X"}
|
|
}
|
|
});
|
|
let r = client
|
|
.put(d.url(&format!("/api/profiles/{name}")))
|
|
.json(&prof)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
r.status().is_success(),
|
|
"put profile failed: {}",
|
|
r.status()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn signal_terminates_long_running_exec() {
|
|
let d = Daemon::spawn();
|
|
create_profile(&d, "demo");
|
|
|
|
let (_, v) = d.post_json(
|
|
"/api/sessions",
|
|
&json!({"profile": "demo", "ready_timeout": 10}),
|
|
);
|
|
let sid = v["id"].as_str().unwrap().to_string();
|
|
|
|
// Long-running sleep.
|
|
let (_, v) = d.post_json(
|
|
&format!("/api/sessions/{sid}/execs"),
|
|
&json!({
|
|
"command": "/bin/sh",
|
|
"args": ["-c", "sleep 30"],
|
|
"keep": true
|
|
}),
|
|
);
|
|
let exec_id = v["id"].as_str().unwrap().to_string();
|
|
|
|
// Wait for it to actually start (status.started==true).
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while Instant::now() < deadline {
|
|
let (_, st) = d.get_json(&format!("/api/sessions/{sid}/execs/{exec_id}"));
|
|
if st["started"].as_bool().unwrap_or(false) {
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
|
|
// SIGKILL it.
|
|
let (s, _) = d.post_json(
|
|
&format!("/api/sessions/{sid}/execs/{exec_id}/signal"),
|
|
&json!({"signal": libc::SIGKILL}),
|
|
);
|
|
assert!(s.is_success());
|
|
|
|
// Wait for ended.
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let mut ended = false;
|
|
while Instant::now() < deadline {
|
|
let (_, st) = d.get_json(&format!("/api/sessions/{sid}/execs/{exec_id}"));
|
|
if st["ended"].as_bool().unwrap_or(false) {
|
|
ended = true;
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
assert!(ended, "exec did not end after signal");
|
|
|
|
let _ = d.delete(&format!("/api/sessions/{sid}"));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_spa_is_served() {
|
|
let d = Daemon::spawn();
|
|
let r = reqwest::blocking::get(d.url("/")).unwrap();
|
|
assert!(r.status().is_success());
|
|
let ct = r
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
assert!(ct.starts_with("text/html"), "got content-type: {ct}");
|
|
let body = r.text().unwrap();
|
|
assert!(body.to_ascii_lowercase().contains("<html"));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_spa_route_falls_back_to_index() {
|
|
let d = Daemon::spawn();
|
|
let r = reqwest::blocking::get(d.url("/sessions/something")).unwrap();
|
|
assert!(r.status().is_success());
|
|
let body = r.text().unwrap();
|
|
assert!(body.to_ascii_lowercase().contains("<html"));
|
|
}
|