#![cfg(feature = "sandbox-it")]
#[allow(dead_code)]
mod support;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use rightsize::{Container, MountableFile, Wait};
macro_rules! require_backend {
() => {
if !support::requested_backend_available() {
support::skip_notice();
return;
}
};
}
fn read_only_mount_enforced() -> bool {
!matches!(
std::env::var("RIGHTSIZE_BACKEND"),
Ok(v) if v.eq_ignore_ascii_case("microsandbox") || v.eq_ignore_ascii_case("msb")
)
}
#[tokio::test]
async fn container_publishes_tcp_port_to_host_loopback() {
require_backend!();
let c = Container::new("python:3.12-alpine")
.with_command(&["python", "-m", "http.server", "8000"])
.with_exposed_ports(&[8000])
.waiting_for(
Wait::for_http("/")
.for_port(8000)
.with_startup_timeout(Duration::from_secs(120)),
);
let guard = c.start().await.expect("container must start");
let url = format!("http://127.0.0.1:{}/", guard.get_mapped_port(8000).unwrap());
let status = support::http_agent()
.get(&url)
.call()
.expect("GET / must succeed")
.status();
assert_eq!(status.as_u16(), 200);
guard.stop().await.unwrap();
}
#[tokio::test]
async fn env_vars_are_visible_to_the_workload() {
require_backend!();
let c = Container::new("alpine:3.19")
.with_env("RZ_PROBE", "hello-rz")
.with_command(&["sh", "-c", "sleep 120"])
.waiting_for(Wait::for_log_message(".*", 0).with_startup_timeout(Duration::from_secs(30)));
let guard = c.start().await.expect("container must start");
let r = guard
.exec(&["sh", "-c", "echo $RZ_PROBE"])
.await
.expect("exec must run");
assert_eq!(r.exit_code, 0);
assert!(r.stdout.contains("hello-rz"), "stdout was: {}", r.stdout);
guard.stop().await.unwrap();
}
#[tokio::test]
async fn exec_returns_real_exit_codes_and_stderr() {
require_backend!();
let c = Container::new("alpine:3.19")
.with_command(&["sleep", "120"])
.waiting_for(Wait::for_log_message(".*", 0).with_startup_timeout(Duration::from_secs(30)));
let guard = c.start().await.expect("container must start");
let r = guard
.exec(&["sh", "-c", "echo oops >&2; exit 7"])
.await
.expect("exec must run");
assert_eq!(r.exit_code, 7);
assert!(r.stderr.contains("oops"), "stderr was: {}", r.stderr);
guard.stop().await.unwrap();
}
#[tokio::test]
async fn logs_capture_workload_stdout_and_for_log_message_waits_on_them() {
require_backend!();
let c = Container::new("alpine:3.19")
.with_command(&["sh", "-c", "echo BOOT-MARKER; sleep 120"])
.waiting_for(Wait::for_log_message(".*BOOT-MARKER.*", 1));
let guard = c.start().await.expect("container must start");
let logs = guard.logs().await.expect("logs must be readable");
assert!(logs.contains("BOOT-MARKER"), "logs were: {logs}");
guard.stop().await.unwrap();
}
#[tokio::test]
async fn stop_terminates_and_frees_the_container() {
require_backend!();
let c = Container::new("alpine:3.19")
.with_command(&[
"sh",
"-c",
"while true; do printf 'HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok' | nc -l -p 8000; done",
])
.with_exposed_ports(&[8000])
.waiting_for(
Wait::for_http("/").for_port(8000).with_startup_timeout(Duration::from_secs(120)),
);
let guard = c.start().await.expect("container must start");
assert!(guard.is_running());
let host_port = guard.get_mapped_port(8000).unwrap();
assert!(
std::net::TcpStream::connect(("127.0.0.1", host_port)).is_ok(),
"port must be reachable while running"
);
guard.stop().await.expect("stop must succeed");
tokio::time::sleep(Duration::from_millis(500)).await;
assert!(
std::net::TcpStream::connect(("127.0.0.1", host_port)).is_err(),
"port must no longer be reachable after stop() — container/sandbox must be torn down"
);
}
#[tokio::test]
async fn with_copy_file_to_container_round_trips_a_bundled_resource_and_a_host_path() {
require_backend!();
let bundled_bytes = "rightsize-mount-fixture-payload\n";
let host_file =
std::env::temp_dir().join(format!("rightsize-hostpath-{}.txt", std::process::id()));
let host_bytes = "rightsize-host-path-payload\n";
std::fs::write(&host_file, host_bytes).unwrap();
let bundled = MountableFile::for_classpath_resource("tests/fixtures/rightsize-fixture.txt")
.expect("bundled fixture must resolve");
let host_mount = MountableFile::for_host_path(host_file.to_str().unwrap());
let c = Container::new("alpine:3.19")
.with_copy_file_to_container(bundled, "/mnt-a/from-classpath.txt")
.with_copy_file_to_container(host_mount, "/mnt-b/from-host.txt")
.with_command(&["sleep", "120"])
.waiting_for(Wait::for_log_message(".*", 0).with_startup_timeout(Duration::from_secs(30)));
let guard = c.start().await.expect("container must start");
let from_bundled = guard
.exec(&["cat", "/mnt-a/from-classpath.txt"])
.await
.expect("exec must run");
assert_eq!(
from_bundled.exit_code, 0,
"cat bundled mount failed: {}",
from_bundled.stderr
);
assert_eq!(from_bundled.stdout, bundled_bytes);
let from_host = guard
.exec(&["cat", "/mnt-b/from-host.txt"])
.await
.expect("exec must run");
assert_eq!(
from_host.exit_code, 0,
"cat host-path mount failed: {}",
from_host.stderr
);
assert_eq!(from_host.stdout, host_bytes);
guard.stop().await.unwrap();
std::fs::remove_file(&host_file).ok();
}
#[tokio::test]
async fn default_read_only_mount_rejects_an_in_guest_write() {
require_backend!();
let host_file = std::env::temp_dir().join(format!("rightsize-ro-{}.txt", std::process::id()));
std::fs::write(&host_file, "seed\n").unwrap();
let c = Container::new("alpine:3.19")
.with_copy_file_to_container(
MountableFile::for_host_path(host_file.to_str().unwrap()),
"/tmp/ro.txt",
)
.with_command(&["sleep", "120"])
.waiting_for(Wait::for_log_message(".*", 0).with_startup_timeout(Duration::from_secs(30)));
let guard = c.start().await.expect("container must start");
let write = guard
.exec(&["sh", "-c", "echo overwritten > /tmp/ro.txt"])
.await
.expect("exec must run");
if read_only_mount_enforced() {
assert_ne!(
write.exit_code, 0,
"expected read-only mount to reject the write; stderr={}",
write.stderr
);
} else {
assert_eq!(
write.exit_code, 0,
"msb read-only-mount write behavior changed — update read_only_mount_enforced pin"
);
}
guard.stop().await.unwrap();
std::fs::remove_file(&host_file).ok();
}
#[tokio::test]
async fn follow_output_streams_lines_in_order_and_close_halts_delivery() {
require_backend!();
let c = Container::new("alpine:3.19")
.with_command(&[
"sh",
"-c",
"i=1; while [ $i -le 20 ]; do echo LINE-$i; i=$((i+1)); sleep 0.2; done; sleep 120",
])
.waiting_for(Wait::for_log_message("LINE-1", 0));
let guard = c.start().await.expect("container must start");
let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let received_clone = received.clone();
let saw_three = Arc::new(tokio::sync::Notify::new());
let saw_three_notify = saw_three.clone();
let saw_three_fired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let saw_three_fired_writer = saw_three_fired.clone();
let follow = guard
.follow_output(move |line| {
let mut lines = received_clone.lock().unwrap();
lines.push(line);
if lines.iter().filter(|l| l.contains("LINE-")).count() >= 3
&& !saw_three_fired_writer.swap(true, std::sync::atomic::Ordering::SeqCst)
{
saw_three_notify.notify_one();
}
})
.await
.expect("follow_output must start");
let waited = tokio::time::timeout(Duration::from_secs(20), saw_three.notified()).await;
assert!(
waited.is_ok() || saw_three_fired.load(std::sync::atomic::Ordering::SeqCst),
"did not observe 3 streamed lines in time; received so far: {:?}",
received.lock().unwrap()
);
let line_numbers: Vec<i32> = received
.lock()
.unwrap()
.iter()
.filter(|l| l.contains("LINE-"))
.filter_map(|l| l.strip_prefix("LINE-").and_then(|n| n.parse().ok()))
.collect();
let mut sorted = line_numbers.clone();
sorted.sort_unstable();
assert_eq!(
line_numbers, sorted,
"streamed lines arrived out of order: {line_numbers:?}"
);
follow.close();
let count_after_close = received.lock().unwrap().len();
tokio::time::sleep(Duration::from_secs(1)).await; assert_eq!(
count_after_close,
received.lock().unwrap().len(),
"follow_output kept delivering after close()"
);
guard.stop().await.unwrap();
}
#[tokio::test]
async fn follow_output_delivers_a_final_unterminated_line_after_the_workload_exits_exactly_once() {
require_backend!();
let c = Container::new("alpine:3.19")
.with_command(&[
"sh",
"-c",
"echo BOOT-MARKER; sleep 2; \
i=1; while [ $i -le 5 ]; do echo LINE-$i; i=$((i+1)); done; \
printf 'LINE-END-NO-NEWLINE'",
])
.waiting_for(Wait::for_log_message(".*BOOT-MARKER.*", 1));
let guard = c.start().await.expect("container must start");
let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let received_clone = received.clone();
let saw_final = Arc::new(tokio::sync::Notify::new());
let saw_final_notify = saw_final.clone();
let saw_final_fired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let saw_final_fired_writer = saw_final_fired.clone();
let follow = guard
.follow_output(move |line| {
let mut lines = received_clone.lock().unwrap();
let is_final = line.contains("LINE-END-NO-NEWLINE");
lines.push(line);
if is_final && !saw_final_fired_writer.swap(true, std::sync::atomic::Ordering::SeqCst) {
saw_final_notify.notify_one();
}
})
.await
.expect("follow_output must start");
let waited = tokio::time::timeout(Duration::from_secs(20), saw_final.notified()).await;
assert!(
waited.is_ok() || saw_final_fired.load(std::sync::atomic::Ordering::SeqCst),
"final unterminated line was never delivered; received so far: {:?}",
received.lock().unwrap()
);
tokio::time::sleep(Duration::from_secs(1)).await;
let lines = received.lock().unwrap().clone();
let final_count = lines
.iter()
.filter(|l| l.contains("LINE-END-NO-NEWLINE"))
.count();
assert_eq!(
final_count, 1,
"final unterminated line was delivered more than once: {lines:?}"
);
for n in 1..=5 {
let marker = format!("LINE-{n}");
let count = lines.iter().filter(|l| *l == &marker).count();
assert_eq!(
count, 1,
"LINE-{n} was not delivered exactly once: {lines:?}"
);
}
follow.close();
guard.stop().await.unwrap();
}