use std::process::Output;
use std::time::{Duration, Instant};
use microsandbox::Sandbox;
use test_utils::msb_test;
use tokio::process::Command;
use tokio::time::{sleep, timeout};
const IMAGE: &str = "mirror.gcr.io/library/alpine";
const OLD_KERNEL_ENV: &str = "MSB_TEST_LIVE_RESIZE_OLD_KERNEL";
const CONVERGE_DEADLINE: Duration = Duration::from_secs(60);
async fn msb(args: &[&str]) -> Output {
timeout(
Duration::from_secs(90),
Command::new(env!("CARGO_BIN_EXE_msb")).args(args).output(),
)
.await
.unwrap_or_else(|_| panic!("msb {args:?} timed out after 90s"))
.unwrap_or_else(|e| panic!("msb {args:?} failed to spawn: {e}"))
}
async fn try_exec(name: &str, cmd: &[&str]) -> Option<String> {
let mut args = vec!["exec", name, "--"];
args.extend_from_slice(cmd);
let out = msb(&args).await;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}
async fn poll_exec(
name: &str,
cmd: &[&str],
deadline: Duration,
accept: impl Fn(&str) -> bool,
) -> Option<String> {
let started = Instant::now();
let mut last = None;
loop {
if let Some(out) = try_exec(name, cmd).await {
if accept(&out) {
return Some(out);
}
last = Some(out);
}
if started.elapsed() >= deadline {
return last;
}
sleep(Duration::from_secs(2)).await;
}
}
fn first_resize_status(json: &str) -> serde_json::Value {
let plan: serde_json::Value = serde_json::from_str(json)
.unwrap_or_else(|e| panic!("modify emitted invalid JSON: {e}\n{json}"));
plan["resize_status"]
.as_array()
.and_then(|entries| entries.first())
.unwrap_or_else(|| panic!("modify emitted no resize_status:\n{json}"))
.clone()
}
async fn cleanup(name: &str) {
if let Ok(handle) = Sandbox::get(name).await {
let _ = handle.kill().await;
let _ = handle.remove().await;
}
}
#[msb_test]
async fn live_cpu_grow_and_shrink_converge() {
let name = "live-resize-cpu";
cleanup(name).await;
let sandbox = Sandbox::builder(name)
.image(IMAGE)
.cpus(2)
.max_cpus(6)
.memory(512)
.replace()
.create()
.await
.expect("create sandbox");
let boot = poll_exec(name, &["nproc"], CONVERGE_DEADLINE, |out| out == "2").await;
assert_eq!(boot.as_deref(), Some("2"), "guest should boot with 2 CPUs");
let out = msb(&["modify", name, "--cpus", "4", "--format", "json"]).await;
assert!(
out.status.success(),
"modify --cpus 4 failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let status = first_resize_status(&String::from_utf8_lossy(&out.stdout));
assert_eq!(status["enforced"], "4", "host must enforce the new target");
let grown = poll_exec(name, &["nproc"], CONVERGE_DEADLINE, |out| out == "4").await;
assert_eq!(grown.as_deref(), Some("4"), "guest should online CPUs 2-3");
let out = msb(&["modify", name, "--cpus", "1", "--format", "json"]).await;
assert!(
out.status.success(),
"modify --cpus 1 failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let shrunk = poll_exec(name, &["nproc"], CONVERGE_DEADLINE, |out| out == "1").await;
assert_eq!(
shrunk.as_deref(),
Some("1"),
"guest should offline CPUs 1-3"
);
drop(sandbox);
cleanup(name).await;
}
#[msb_test]
async fn live_memory_grow_converges() {
let name = "live-resize-mem";
cleanup(name).await;
let sandbox = Sandbox::builder(name)
.image(IMAGE)
.cpus(1)
.memory(512)
.max_memory(1536)
.replace()
.create()
.await
.expect("create sandbox");
let mem_total_kib = |out: &str| {
out.split_whitespace()
.nth(1)
.and_then(|kb| kb.parse::<u64>().ok())
};
let baseline = poll_exec(
name,
&["grep", "MemTotal", "/proc/meminfo"],
CONVERGE_DEADLINE,
|out| mem_total_kib(out).is_some(),
)
.await
.and_then(|out| mem_total_kib(&out))
.expect("read boot MemTotal");
assert!(
baseline < 600 * 1024,
"boot MemTotal {baseline} KiB should reflect 512 MiB"
);
let out = msb(&["modify", name, "--memory", "1G", "--format", "json"]).await;
assert!(
out.status.success(),
"modify --memory 1G failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let grown = poll_exec(
name,
&["grep", "MemTotal", "/proc/meminfo"],
CONVERGE_DEADLINE,
|out| mem_total_kib(out).is_some_and(|kib| kib > 900 * 1024),
)
.await
.and_then(|out| mem_total_kib(&out));
assert!(
grown.is_some_and(|kib| kib > 900 * 1024),
"MemTotal should grow past 900 MiB, got {grown:?} KiB"
);
drop(sandbox);
cleanup(name).await;
}
#[msb_test]
async fn over_capacity_refusal_exits_nonzero() {
let name = "live-resize-cap";
cleanup(name).await;
let sandbox = Sandbox::builder(name)
.image(IMAGE)
.cpus(1)
.max_cpus(2)
.memory(512)
.replace()
.create()
.await
.expect("create sandbox");
let boot = poll_exec(name, &["nproc"], CONVERGE_DEADLINE, |out| out == "1").await;
assert_eq!(boot.as_deref(), Some("1"));
let out = msb(&["modify", name, "--cpus", "3"]).await;
assert!(
!out.status.success(),
"over-capacity modify must exit non-zero, got: {}",
String::from_utf8_lossy(&out.stdout)
);
let after = try_exec(name, &["nproc"]).await;
assert_eq!(
after.as_deref(),
Some("1"),
"refusal must not change the guest"
);
drop(sandbox);
cleanup(name).await;
}
#[msb_test]
async fn uncooperative_guest_reports_converging() {
let Some(old_kernel) = std::env::var_os(OLD_KERNEL_ENV) else {
eprintln!("skipping: {OLD_KERNEL_ENV} not set (needs a driverless libkrunfw)");
return;
};
unsafe {
std::env::set_var("MSB_LIBKRUNFW_PATH", &old_kernel);
}
let name = "live-resize-oldk";
cleanup(name).await;
let sandbox = Sandbox::builder(name)
.image(IMAGE)
.cpus(2)
.max_cpus(4)
.memory(512)
.replace()
.create()
.await
.expect("create sandbox");
let boot = poll_exec(name, &["nproc"], CONVERGE_DEADLINE, |out| out == "2").await;
assert_eq!(boot.as_deref(), Some("2"));
let out = msb(&["modify", name, "--cpus", "1", "--format", "json"]).await;
assert!(
out.status.success(),
"modify --cpus 1 failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let status = first_resize_status(&String::from_utf8_lossy(&out.stdout));
assert_eq!(status["requested"], "1");
assert_eq!(
status["actual"], "2",
"driverless guest cannot have offlined a CPU"
);
assert_eq!(
status["enforced"], "1",
"host must still enforce the shrink"
);
assert_ne!(
status["state"], "applied",
"runtime must not claim an uncooperative guest converged"
);
sleep(Duration::from_secs(8)).await;
let after = poll_exec(name, &["nproc"], CONVERGE_DEADLINE, |out| !out.is_empty()).await;
assert_eq!(
after.as_deref(),
Some("2"),
"guest view must be unchanged and exec must still work under enforcement"
);
let out = msb(&["modify", name, "--cpus", "3", "--format", "json"]).await;
assert!(
out.status.success(),
"modify --cpus 3 failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let status = first_resize_status(&String::from_utf8_lossy(&out.stdout));
assert_eq!(status["actual"], "2");
assert_eq!(status["enforced"], "3");
assert_ne!(status["state"], "applied");
drop(sandbox);
cleanup(name).await;
}