mod common;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use futures_util::StreamExt;
use heyo_sdk::{
HeyoError, Sandbox, SandboxCreateOptions, SandboxDriver, SandboxRegion, SandboxSize,
SandboxStatus, ShellOptions,
};
use tokio::time::{sleep, timeout};
const READY_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const HTTP_TIMEOUT: Duration = Duration::from_secs(180);
#[tokio::test]
#[ignore]
async fn kvm_create_shell_and_kill() {
common::load_dotenv();
let Some(mut opts) = common::client_options() else {
eprintln!("[kvm-e2e] skipping — HEYO_API_KEY not set");
return;
};
opts.timeout = Some(HTTP_TIMEOUT);
let region = resolve_region();
println!("[kvm-e2e] base URL: {}", common::base_url());
println!("[kvm-e2e] http timeout: {:?}", HTTP_TIMEOUT);
println!("[kvm-e2e] region: {:?} (override with HEYO_REGION=US|EU)", region);
let image = match resolve_kvm_image(opts.clone()).await {
Ok(Some(image)) => image,
Ok(None) => {
eprintln!(
"[kvm-e2e] skipping — no public KVM images available (set HEYO_KVM_IMAGE to override)"
);
return;
}
Err(HeyoError::Authentication) => panic!(
"list public kvm images: server at {} rejected HEYO_API_KEY (401/403). \
Check the key matches the target env. Set HEYO_ENV=production or \
HEYO_BASE_URL=… to switch.",
common::base_url()
),
Err(e) => panic!("list public kvm images: {}", e),
};
println!("[kvm-e2e] using image: {}", image);
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
println!("[kvm-e2e] creating KVM sandbox (waiting up to {:?} for ready)…", READY_TIMEOUT);
let create_started = std::time::Instant::now();
let sandbox = match Sandbox::create(
SandboxCreateOptions {
name: Some(format!("sdk-rs-kvm-e2e-{}", stamp)),
driver: Some(SandboxDriver::Kvm),
image: Some(image),
region: Some(region),
size_class: Some(SandboxSize::Micro),
ttl_seconds: Some(600),
wait_for_ready: Some(READY_TIMEOUT),
..Default::default()
},
opts,
)
.await
{
Ok(s) => s,
Err(e) => {
let elapsed = create_started.elapsed();
let hint = match &e {
HeyoError::Api { status: 504, .. } => format!(
" (elapsed {:?}; nginx upstream timeout — `post_sandbox_deploy` did not \
respond within the gateway's read budget. That handler should queue \
to NATS and return ~immediately, so this is a server-side bug, not a \
test issue. Suspect JetStream publish-ack blocking or auth-service \
entitlement roundtrip.)",
elapsed
),
_ if elapsed >= HTTP_TIMEOUT - Duration::from_secs(5) => format!(
" (elapsed {:?} ≈ HTTP_TIMEOUT — client gave up waiting; raise \
HTTP_TIMEOUT or fix the server)",
elapsed
),
_ => format!(" (elapsed {:?})", elapsed),
};
panic!("create kvm sandbox: {}{}", e, hint);
}
};
println!(
"[kvm-e2e] created {} in {:.1?}",
sandbox.sandbox_id(),
create_started.elapsed()
);
let outcome = run_exercise(&sandbox).await;
println!("[kvm-e2e] killing sandbox…");
if let Err(e) = sandbox.kill().await {
eprintln!("[kvm-e2e] ⚠ kill failed: {}", e);
}
outcome.expect("kvm e2e exercise");
println!("[kvm-e2e] done");
}
async fn run_exercise(sandbox: &Sandbox) -> Result<(), String> {
let info = sandbox.info().await.map_err(|e| format!("info: {}", e))?;
if info.status != SandboxStatus::Running {
return Err(format!(
"expected status=running after create+wait, got {:?} (error_message={:?})",
info.status, info.error_message
));
}
println!("[kvm-e2e] ✓ status=running");
println!("[kvm-e2e] opening shell…");
let shell = sandbox
.shell(ShellOptions {
cols: 100,
rows: 30,
..Default::default()
})
.await
.map_err(|e| format!("shell open: {}", e))?;
let session_id = shell
.session_id()
.await
.ok_or_else(|| "session_id should be set after open()".to_string())?;
println!("[kvm-e2e] ✓ shell ready (session_id={})", session_id);
use std::sync::{Arc, Mutex};
let buffer: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let collector_buf = buffer.clone();
let mut output = shell.output();
let collector = tokio::spawn(async move {
while let Some(chunk) = output.next().await {
if let Ok(text) = std::str::from_utf8(&chunk) {
let cleaned = strip_ansi(text);
collector_buf.lock().unwrap().push_str(&cleaned);
}
}
});
sleep(Duration::from_millis(500)).await;
drain(&buffer);
let sentinel = format!("kvm-e2e-sentinel-{}", session_id);
let cmd = format!("echo {} && uname -a", sentinel);
write_line(&shell, &cmd).await?;
sleep(Duration::from_secs(2)).await;
let out = drain(&buffer);
if !out.contains(&sentinel) {
return Err(format!(
"shell did not echo sentinel; got: {:?}",
out
));
}
if !out.contains("Linux") {
return Err(format!(
"uname -a should report Linux; got: {:?}",
out
));
}
println!("[kvm-e2e] ✓ shell executed `echo` + `uname -a` inside the guest");
shell.close().await.map_err(|e| format!("shell close: {}", e))?;
let _ = timeout(Duration::from_secs(5), async {
while !shell.is_closed() {
sleep(Duration::from_millis(50)).await;
}
})
.await;
if !shell.is_closed() {
return Err("shell did not close within 5s".into());
}
println!("[kvm-e2e] ✓ shell closed (exit={:?})", shell.exit_code().await);
collector.abort();
let _ = collector.await;
Ok(())
}
fn resolve_region() -> SandboxRegion {
match std::env::var("HEYO_REGION").ok().as_deref() {
Some(v) => match v.trim().to_ascii_uppercase().as_str() {
"US" => SandboxRegion::US,
"EU" => SandboxRegion::EU,
other => {
eprintln!(
"[kvm-e2e] HEYO_REGION={:?} not recognized; falling back to US",
other
);
SandboxRegion::US
}
},
None => SandboxRegion::US,
}
}
async fn resolve_kvm_image(
opts: heyo_sdk::HeyoClientOptions,
) -> Result<Option<String>, heyo_sdk::HeyoError> {
if let Ok(forced) = std::env::var("HEYO_KVM_IMAGE") {
if !forced.is_empty() {
return Ok(Some(forced));
}
}
let images = Sandbox::list_public_images(Some("kvm"), opts).await?;
Ok(images
.into_iter()
.next()
.map(|img| img.name.unwrap_or(img.id)))
}
async fn write_line(shell: &heyo_sdk::ShellSession, cmd: &str) -> Result<(), String> {
let mut bytes = cmd.as_bytes().to_vec();
bytes.push(b'\n');
shell.write(&bytes).await.map_err(|e| e.to_string())
}
fn drain(buf: &std::sync::Arc<std::sync::Mutex<String>>) -> String {
let mut g = buf.lock().unwrap();
std::mem::take(&mut *g)
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if let Some(&next) = chars.peek() {
if next == '[' {
chars.next();
for d in chars.by_ref() {
if d.is_ascii_alphabetic() {
break;
}
}
continue;
} else if next == ']' {
chars.next();
for d in chars.by_ref() {
if d == 0x07 as char {
break;
}
}
continue;
}
}
continue;
}
out.push(c);
}
out
}