mod common;
use boxlite::{BoxCommand, BoxOptions, LiteBox, RootfsSpec};
use tokio_stream::StreamExt;
fn main_command_opts(cmd: &[&str], tty: bool) -> BoxOptions {
BoxOptions {
rootfs: RootfsSpec::Image("alpine:latest".into()),
auto_delete: Some(0),
cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
tty,
..Default::default()
}
}
async fn attached_stdout(opts: BoxOptions) -> String {
let home = boxlite_test_utils::home::PerTestBoxHome::new();
let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
home_dir: home.path.clone(),
image_registries: common::test_registries(),
})
.expect("create runtime");
let handle = runtime.create(opts, None).await.expect("create box");
handle.start().await.expect("start box");
let mut execution = handle
.attach(None)
.await
.expect("attach to the main command");
let mut stdout = String::new();
if let Some(mut stream) = execution.stdout() {
while let Some(chunk) = stream.next().await {
stdout.push_str(&chunk);
if stdout.contains("TTY") || stdout.contains("NOTTY") {
break;
}
}
}
let _ = handle.stop().await;
let _ = runtime.remove(handle.id().as_str(), true).await;
let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
stdout
}
async fn wait_for_file(handle: &LiteBox, path: &str) {
tokio::time::timeout(std::time::Duration::from_secs(30), async {
loop {
let execution = handle
.exec(BoxCommand::new("test").args(["-e", path]))
.await
.expect("check main command marker");
if execution
.wait()
.await
.expect("wait for marker check")
.exit_code
== 0
{
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
})
.await
.expect("main command must reach marker");
}
#[tokio::test]
async fn main_command_exits_after_large_output_without_attach() {
let home = boxlite_test_utils::home::PerTestBoxHome::new();
let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
home_dir: home.path.clone(),
image_registries: common::test_registries(),
})
.expect("create runtime");
let handle = runtime
.create(
main_command_opts(&["sh", "-c", "head -c 1048576 /dev/zero; exit 23"], false),
None,
)
.await
.expect("create box");
let completed = tokio::time::timeout(std::time::Duration::from_secs(30), async {
handle.start().await.expect("start box");
loop {
if handle.info().await.expect("get box info").status == boxlite::BoxStatus::Stopped {
return true;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
})
.await
.unwrap_or(false);
let _ = handle.stop().await;
let _ = runtime.remove(handle.id().as_str(), true).await;
let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
assert!(
completed,
"a main command that has no Attach consumer must still drain and exit"
);
}
#[tokio::test]
async fn late_attach_reports_output_gap() {
let home = boxlite_test_utils::home::PerTestBoxHome::new();
let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
home_dir: home.path.clone(),
image_registries: common::test_registries(),
})
.expect("create runtime");
let handle = runtime
.create(
main_command_opts(
&[
"sh",
"-c",
"head -c 2097152 /dev/zero; touch /tmp/main-output-ready; sleep 30",
],
false,
),
None,
)
.await
.expect("create box");
handle.start().await.expect("start box");
wait_for_file(&handle, "/tmp/main-output-ready").await;
let mut execution = handle
.attach(None)
.await
.expect("attach to the main command");
let mut stdout = execution.stdout().expect("stdout stream");
let dropped = tokio::time::timeout(std::time::Duration::from_secs(5), async {
while let Some(chunk) = stdout.next().await {
if chunk.contains("[boxlite] stdout output dropped") {
return Some(chunk);
}
}
None
})
.await
.unwrap_or(None);
let _ = handle.stop().await;
let _ = runtime.remove(handle.id().as_str(), true).await;
let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
let dropped = dropped.expect("late attach must report overwritten output");
assert!(
dropped.contains("stdout output dropped"),
"late attach must report the stdout gap: {dropped:?}"
);
}
#[tokio::test]
async fn main_command_gets_a_pty_when_tty_is_set() {
let stdout = attached_stdout(main_command_opts(
&["sh", "-c", "test -t 0 && echo TTY || echo NOTTY; sleep 30"],
true,
))
.await;
assert!(
stdout.contains("TTY") && !stdout.contains("NOTTY"),
"init's stdin must be a terminal when tty is set, got: {stdout:?}"
);
}
#[tokio::test]
async fn main_command_gets_pipes_when_tty_is_unset() {
let stdout = attached_stdout(main_command_opts(
&["sh", "-c", "test -t 0 && echo TTY || echo NOTTY; sleep 30"],
false,
))
.await;
assert!(
stdout.contains("NOTTY"),
"init's stdin must be a pipe when tty is unset, got: {stdout:?}"
);
}
#[tokio::test]
async fn a_stopped_box_without_a_main_command_still_restarts_on_exec() {
let home = boxlite_test_utils::home::PerTestBoxHome::new();
let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
home_dir: home.path.clone(),
image_registries: common::test_registries(),
})
.expect("create runtime");
let opts = BoxOptions {
rootfs: RootfsSpec::Image("alpine:latest".into()),
auto_delete: Some(0),
..Default::default()
};
let handle = runtime.create(opts, None).await.expect("create box");
handle.start().await.expect("start box");
handle.stop().await.expect("stop box");
let fresh = runtime
.get(handle.id().as_str())
.await
.expect("get box")
.expect("box exists");
drop(handle);
let execution = fresh
.exec(boxlite::BoxCommand::new("echo").args(vec!["awake".to_string()]))
.await
.expect("exec must implicitly restart a stopped box that has no main command");
let result = execution.wait().await.expect("wait");
assert_eq!(result.exit_code, 0, "the revived box must actually run it");
let _ = fresh.stop().await;
let _ = runtime.remove(fresh.id().as_str(), true).await;
let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}
#[tokio::test]
async fn a_stopped_no_command_box_refuses_to_serve_its_dead_vm() {
let home = boxlite_test_utils::home::PerTestBoxHome::new();
let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
home_dir: home.path.clone(),
image_registries: common::test_registries(),
})
.expect("create runtime");
let opts = BoxOptions {
rootfs: RootfsSpec::Image("alpine:latest".into()),
auto_delete: Some(0),
..Default::default()
};
let handle = runtime.create(opts, None).await.expect("create box");
handle.start().await.expect("start box");
let shim = handle
.info()
.await
.expect("get box info")
.pid
.expect("a running box has a shim");
let killed = std::process::Command::new("kill")
.args(["-9", &shim.to_string()])
.status()
.expect("run kill");
assert!(killed.success(), "the shim must actually be killed");
let mut status = handle.info().await.expect("get box info").status;
for _ in 0..60 {
status = handle.info().await.expect("get box info").status;
if status != boxlite::BoxStatus::Running {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
assert_ne!(
status,
boxlite::BoxStatus::Running,
"precondition: the box must be observed to have stopped"
);
let err = match handle.exec(boxlite::BoxCommand::new("echo")).await {
Ok(_) => panic!("a spent handle must refuse, not hand back the dead VM"),
Err(e) => e,
};
let msg = err.to_string();
assert!(
msg.contains("spent") || msg.contains("no longer running"),
"the refusal must say the handle is spent, got: {msg}"
);
let box_id = handle.id().to_string();
drop(handle);
let fresh = runtime
.get(&box_id)
.await
.expect("get box")
.expect("box exists");
let execution = fresh
.exec(boxlite::BoxCommand::new("echo").args(vec!["awake".to_string()]))
.await
.expect("a fresh handle must restart the box and run the exec");
let result = execution.wait().await.expect("wait");
assert_eq!(result.exit_code, 0, "the revived box must actually run it");
let _ = fresh.stop().await;
let _ = runtime.remove(&box_id, true).await;
let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}
#[tokio::test]
async fn a_failed_attach_does_not_poison_the_next_start() {
let home = boxlite_test_utils::home::PerTestBoxHome::new();
let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
home_dir: home.path.clone(),
image_registries: common::test_registries(),
})
.expect("create runtime");
let handle = runtime
.create(
main_command_opts(&["sh", "-c", "sleep 30"], false),
Some("poison".to_string()),
)
.await
.expect("create box");
let boxes_dir = home.path.join("boxes");
if boxes_dir.exists() {
std::fs::remove_dir_all(&boxes_dir).expect("clear boxes dir");
}
std::fs::write(&boxes_dir, b"").expect("plant file where the boxes dir belongs");
let failed = handle.attach(None).await;
std::fs::remove_file(&boxes_dir).expect("remove planted file");
std::fs::create_dir_all(&boxes_dir).expect("restore boxes dir");
assert!(
failed.is_err(),
"precondition: the boot must fail while the boxes path is not a directory"
);
handle
.start()
.await
.expect("a plain start must boot normally after a failed attached start");
let execution = handle
.exec(boxlite::BoxCommand::new("echo").args(vec!["ran".to_string()]))
.await
.expect("the box's init must be running, not merely created");
let result = execution.wait().await.expect("wait");
assert_eq!(
result.exit_code, 0,
"the started box must really be running"
);
let _ = handle.stop().await;
let _ = runtime.remove(handle.id().as_str(), true).await;
let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}
#[tokio::test]
async fn an_adopted_running_box_is_followed_to_its_exit() {
let home = boxlite_test_utils::home::PerTestBoxHome::new();
let opts = || boxlite::runtime::options::BoxliteOptions {
home_dir: home.path.clone(),
image_registries: common::test_registries(),
};
{
let first = boxlite::BoxliteRuntime::new(opts()).expect("create runtime");
let mut box_opts = main_command_opts(&["sh", "-c", "sleep 5; exit 9"], false);
box_opts.detach = true;
let handle = first
.create(box_opts, Some("adopted".to_string()))
.await
.expect("create box");
handle.start().await.expect("start box");
handle
.exec(BoxCommand::new("true"))
.await
.expect("run the container init before abandoning the runtime")
.wait()
.await
.expect("await the probe exec");
}
let second = boxlite::BoxliteRuntime::new(opts()).expect("create second runtime");
let adopted = second
.get("adopted")
.await
.expect("get box")
.expect("box exists");
assert_eq!(
adopted.info().await.expect("get box info").status,
boxlite::BoxStatus::Running,
"precondition: the box must still be running when we adopt it"
);
let mut info = adopted.info().await.expect("get box info");
for _ in 0..60 {
info = adopted.info().await.expect("get box info");
if info.status != boxlite::BoxStatus::Running {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
assert_ne!(
info.status,
boxlite::BoxStatus::Running,
"an adopted box's exit must be observed — otherwise it is reported Running forever"
);
assert_eq!(
info.exit_code,
Some(9),
"and its exit code must be surfaced, not just its death"
);
let _ = second.remove(adopted.id().as_str(), true).await;
let _ = second.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}
#[tokio::test]
async fn a_self_stopped_box_refuses_to_restart_on_the_spent_handle() {
let home = boxlite_test_utils::home::PerTestBoxHome::new();
let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
home_dir: home.path.clone(),
image_registries: common::test_registries(),
})
.expect("create runtime");
let handle = runtime
.create(
main_command_opts(&["sh", "-c", "exit 7"], false),
Some("self-stop".to_string()),
)
.await
.expect("create box");
handle.start().await.expect("start box");
let mut info = handle.info().await.expect("get box info");
for _ in 0..60 {
info = handle.info().await.expect("get box info");
if info.status != boxlite::BoxStatus::Running {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
assert_ne!(
info.status,
boxlite::BoxStatus::Running,
"the box must stop itself once its main command exits"
);
assert_eq!(
info.exit_code,
Some(7),
"the live watcher must surface the main command's exit code"
);
let err = handle
.start()
.await
.expect_err("starting a spent handle must fail rather than boot nothing");
let msg = err.to_string();
assert!(
msg.contains("spent") || msg.contains("fresh"),
"the refusal must tell the caller to get a fresh handle, got: {msg}"
);
drop(handle);
let fresh = runtime
.get("self-stop")
.await
.expect("get box")
.expect("box exists");
fresh.start().await.expect("a fresh handle must restart it");
assert_eq!(
fresh.info().await.expect("get box info").status,
boxlite::BoxStatus::Running,
"the restarted box must actually be running"
);
let _ = fresh.stop().await;
let _ = runtime.remove(fresh.id().as_str(), true).await;
let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}
#[tokio::test]
async fn attach_refuses_a_stopped_box() {
let home = boxlite_test_utils::home::PerTestBoxHome::new();
let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
home_dir: home.path.clone(),
image_registries: common::test_registries(),
})
.expect("create runtime");
let handle = runtime
.create(
main_command_opts(&["sh", "-c", "exit 0"], false),
Some("exit-job".to_string()),
)
.await
.expect("create box");
handle.start().await.expect("start box");
let mut info = handle.info().await.expect("get box info");
for _ in 0..60 {
info = handle.info().await.expect("get box info");
if info.status != boxlite::BoxStatus::Running {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
assert_ne!(
info.status,
boxlite::BoxStatus::Running,
"precondition: the box must stop itself once its main command exits"
);
drop(handle);
let stopped = runtime
.get("exit-job")
.await
.expect("get box")
.expect("box exists");
assert_eq!(
stopped.info().await.expect("get box info").status,
boxlite::BoxStatus::Stopped,
"precondition: a fresh handle on the box reports it Stopped"
);
let msg = match stopped.attach(None).await {
Ok(_) => panic!("attaching to a stopped box must fail, not reboot it"),
Err(e) => e.to_string(),
};
assert!(
msg.contains("attach") && msg.contains("stopped"),
"the refusal must name the operation and the state, got: {msg}"
);
let _ = runtime.remove(stopped.id().as_str(), true).await;
let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}
#[tokio::test]
async fn attach_by_exec_id_is_unsupported_on_the_local_backend() {
let home = boxlite_test_utils::home::PerTestBoxHome::new();
let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
home_dir: home.path.clone(),
image_registries: common::test_registries(),
})
.expect("create runtime");
let handle = runtime
.create(
main_command_opts(&["sh", "-c", "sleep 30"], false),
Some("job-a".to_string()),
)
.await
.expect("create box");
let msg = match handle.attach(Some("some-exec-id")).await {
Ok(_) => panic!("local attach(Some(id)) must be Unsupported, not succeed"),
Err(e) => e.to_string(),
};
assert!(
msg.contains("local") && msg.contains("reattach"),
"the error must explain local reattach is unsupported, got: {msg}"
);
let _ = runtime.remove(handle.id().as_str(), true).await;
let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}