use pelagos::cgroup::ResourceStats;
use pelagos::container::{
Capability, Command, GidMap, Namespace, SeccompProfile, Stdio, UidMap, Volume,
};
use pelagos::network::NetworkMode;
use serial_test::serial;
use std::path::PathBuf;
fn is_root() -> bool {
unsafe { libc::getuid() == 0 }
}
fn get_test_rootfs() -> Option<PathBuf> {
let current_dir = std::env::current_dir().ok()?;
let alpine_path = current_dir.join("alpine-rootfs");
if alpine_path.exists() && alpine_path.join("bin/busybox").exists() {
Some(alpine_path)
} else {
None
}
}
const ALPINE_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
fn wait_for_grandchild(waiter_pid: u32) -> Option<u32> {
let path = format!("/proc/{}/task/{}/children", waiter_pid, waiter_pid);
for _ in 0..20 {
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Some(pid) = contents
.split_whitespace()
.next()
.and_then(|s| s.parse::<u32>().ok())
{
return Some(pid);
}
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
None
}
fn cgroup_path_for_pid(pid: u32) -> Option<String> {
let content = std::fs::read_to_string(format!("/proc/{}/cgroup", pid)).ok()?;
for line in content.lines() {
let path = line.splitn(3, ':').nth(2)?.trim_start_matches('/');
if path.starts_with("pelagos-") {
return Some(path.to_string());
}
}
None
}
fn read_cgroup_file(cgroup_rel_path: &str, setting: &str) -> Option<String> {
std::fs::read_to_string(format!("/sys/fs/cgroup/{}/{}", cgroup_rel_path, setting))
.ok()
.map(|s| s.trim().to_string())
}
mod api {
use super::*;
#[test]
fn test_uid_gid_api() {
let _cmd = Command::new("/bin/ash")
.with_uid(1000)
.with_gid(1000)
.with_uid_maps(&[UidMap {
inside: 0,
outside: 1000,
count: 1,
}])
.with_gid_maps(&[GidMap {
inside: 0,
outside: 1000,
count: 1,
}]);
}
#[test]
fn test_namespace_bitflags() {
let ns1 = Namespace::UTS;
let ns2 = Namespace::MOUNT;
let combined = ns1 | ns2;
assert!(combined.contains(Namespace::UTS));
assert!(combined.contains(Namespace::MOUNT));
assert!(!combined.contains(Namespace::PID));
}
#[test]
fn test_capability_bitflags() {
let cap1 = Capability::CHOWN;
let cap2 = Capability::NET_BIND_SERVICE;
let combined = cap1 | cap2;
assert!(combined.contains(Capability::CHOWN));
assert!(combined.contains(Capability::NET_BIND_SERVICE));
assert!(!combined.contains(Capability::SYS_ADMIN));
}
#[test]
fn test_command_builder_pattern() {
let rootfs = PathBuf::from("/tmp/test");
let _cmd = Command::new("/bin/ash")
.args(["-c", "echo test", "-x"])
.stdin(Stdio::Inherit)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.with_namespaces(Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.with_max_fds(1024);
}
#[test]
fn test_seccomp_profile_api() {
let rootfs = PathBuf::from("/tmp/test");
let _cmd1 = Command::new("/bin/sh")
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_seccomp_default();
let _cmd2 = Command::new("/bin/sh")
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_seccomp_minimal();
let _cmd3 = Command::new("/bin/sh")
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_seccomp_profile(SeccompProfile::Docker);
let _cmd4 = Command::new("/bin/sh")
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.without_seccomp();
}
}
mod core {
use super::*;
#[test]
fn test_basic_namespace_creation() {
if !is_root() {
eprintln!("Skipping test_basic_namespace_creation: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_basic_namespace_creation: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "Child process failed");
}
Err(e) => {
panic!("Failed to spawn with namespaces: {:?}", e);
}
}
}
#[test]
fn test_proc_mount() {
if !is_root() {
eprintln!("Skipping test_proc_mount: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "test -f /proc/self/status"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "Proc was not mounted correctly");
}
Err(e) => panic!("Failed to spawn with proc mount: {:?}", e),
}
}
#[test]
fn test_combined_features() {
if !is_root() {
eprintln!("Skipping test_combined_features: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::CGROUP)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.with_capabilities(Capability::NET_BIND_SERVICE)
.with_max_fds(500)
.with_memory_limit(256 * 1024 * 1024)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(
status.success(),
"Child process failed with combined features"
);
}
Err(e) => panic!("Failed to spawn with combined features: {:?}", e),
}
}
#[test]
#[serial]
fn test_pid_namespace_repeated_fork() {
if !is_root() {
eprintln!("Skipping test_pid_namespace_repeated_fork: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_pid_namespace_repeated_fork: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sh")
.args([
"-c",
r#"i=0; while [ $i -lt 5 ]; do sleep 0; i=$((i+1)); done; echo "FORKS_OK""#,
])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT | Namespace::PID)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn with PID namespace");
let (status, stdout, stderr) = child.wait_with_output().expect("wait failed");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(
status.success(),
"Container with PID namespace failed (exit {:?}).\nstdout: {}\nstderr: {}",
status.code(),
out,
err
);
assert!(
out.contains("FORKS_OK"),
"Container could not fork() repeatedly in PID namespace (defunct namespace bug).\nstdout: {}\nstderr: {}",
out, err
);
}
}
mod capabilities {
use super::*;
#[test]
fn test_capability_dropping() {
if !is_root() {
eprintln!("Skipping test_capability_dropping: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.drop_all_capabilities()
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "Child process failed with dropped caps");
}
Err(e) => panic!("Failed to spawn with dropped capabilities: {:?}", e),
}
}
#[test]
fn test_selective_capabilities() {
if !is_root() {
eprintln!("Skipping test_selective_capabilities: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_capabilities(Capability::NET_BIND_SERVICE | Capability::CHOWN)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "Child process failed with selective caps");
}
Err(e) => panic!("Failed to spawn with selective capabilities: {:?}", e),
}
}
}
mod resources {
use super::*;
#[test]
fn test_resource_limits_fds() {
if !is_root() {
eprintln!("Skipping test_resource_limits_fds: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "test \"$(ulimit -n)\" = 100"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_max_fds(100)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "FD limit was not set correctly");
}
Err(e) => panic!("Failed to spawn with fd limit: {:?}", e),
}
}
#[test]
fn test_resource_limits_memory() {
if !is_root() {
eprintln!("Skipping test_resource_limits_memory: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_memory_limit(512 * 1024 * 1024) .stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "Child process failed with memory limit");
}
Err(e) => panic!("Failed to spawn with memory limit: {:?}", e),
}
}
#[test]
fn test_resource_limits_cpu() {
if !is_root() {
eprintln!("Skipping test_resource_limits_cpu: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cpu_time_limit(60) .stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(mut child) => {
let status = child.wait().unwrap();
assert!(status.success(), "Child process failed with CPU limit");
}
Err(e) => panic!("Failed to spawn with CPU time limit: {:?}", e),
}
}
}
mod security {
use super::*;
#[test]
fn test_seccomp_docker_blocks_reboot() {
if !is_root() {
eprintln!("Skipping test_seccomp_docker_blocks_reboot: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_seccomp_docker_blocks_reboot: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "reboot 2>&1; echo reboot_exit_code=$?"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_seccomp_default() .spawn()
.expect("Failed to spawn with seccomp");
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success() || status.code() == Some(1),
"Process should complete (reboot syscall blocked by seccomp)"
);
}
#[test]
fn test_seccomp_docker_allows_normal_syscalls() {
if !is_root() {
eprintln!("Skipping test_seccomp_docker_allows_normal_syscalls: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!(
"Skipping test_seccomp_docker_allows_normal_syscalls: alpine-rootfs not found"
);
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo 'Seccomp allows normal operations'"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_seccomp_default() .spawn()
.expect("Failed to spawn with seccomp");
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Normal syscalls should be allowed");
}
#[test]
fn test_seccomp_minimal_is_restrictive() {
if !is_root() {
eprintln!("Skipping test_seccomp_minimal_is_restrictive: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_seccomp_minimal_is_restrictive: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_seccomp_minimal() .spawn();
match result {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
eprintln!("Minimal seccomp: process exited with status {:?}", status);
}
Err(e) => {
eprintln!("Minimal seccomp: spawn failed (expected): {}", e);
}
}
}
#[test]
fn test_seccomp_without_flag_works() {
if !is_root() {
eprintln!("Skipping test_seccomp_without_flag_works: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_seccomp_without_flag_works: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo 'No seccomp'"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.spawn()
.expect("Failed to spawn without seccomp");
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Container should work without seccomp");
}
fn compile_iouring_binary(src_name: &str, dest: &std::path::Path) -> Option<()> {
let src = std::env::current_dir()
.ok()?
.join("scripts/iouring-test-context")
.join(src_name);
for compiler in &["cc", "gcc"] {
let status = std::process::Command::new(compiler)
.args([
"-static",
"-o",
dest.to_str().unwrap(),
src.to_str().unwrap(),
])
.status()
.ok()?;
if status.success() {
return Some(());
}
}
None
}
#[test]
fn test_seccomp_docker_blocks_io_uring() {
if !is_root() {
eprintln!("Skipping test_seccomp_docker_blocks_io_uring: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_seccomp_docker_blocks_io_uring: alpine-rootfs not found");
return;
};
let tmp = tempfile::tempdir().expect("tempdir");
let probe = tmp.path().join("iouring_workload");
if compile_iouring_binary("iouring_workload.c", &probe).is_none() {
eprintln!("Skipping test_seccomp_docker_blocks_io_uring: no C compiler found");
return;
}
let mut child = Command::new("/tmp/iouring_workload")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_bind_mount(tmp.path(), "/tmp")
.with_seccomp_default()
.spawn()
.expect("Failed to spawn container");
let status = child.wait().expect("Failed to wait");
assert_eq!(
status.code(),
Some(1),
"Docker default profile should block io_uring_setup (expected exit 1 = EPERM)"
);
}
#[test]
fn test_seccomp_iouring_profile_allows_io_uring() {
if !is_root() {
eprintln!("Skipping test_seccomp_iouring_profile_allows_io_uring: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!(
"Skipping test_seccomp_iouring_profile_allows_io_uring: alpine-rootfs not found"
);
return;
};
let tmp = tempfile::tempdir().expect("tempdir");
let probe = tmp.path().join("iouring_workload");
if compile_iouring_binary("iouring_workload.c", &probe).is_none() {
eprintln!("Skipping test_seccomp_iouring_profile_allows_io_uring: no C compiler found");
return;
}
let mut child = Command::new("/tmp/iouring_workload")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_bind_mount(tmp.path(), "/tmp")
.with_seccomp_allow_io_uring()
.spawn()
.expect("Failed to spawn container");
let status = child.wait().expect("Failed to wait");
assert_eq!(
status.code(),
Some(0),
"DockerWithIoUring profile should allow io_uring_setup to reach the kernel (expected exit 0)"
);
}
#[test]
fn test_seccomp_iouring_e2e() {
if !is_root() {
eprintln!("Skipping test_seccomp_iouring_e2e: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_seccomp_iouring_e2e: alpine-rootfs not found");
return;
};
let tmp = tempfile::tempdir().expect("tempdir");
let workload = tmp.path().join("iouring_workload");
if compile_iouring_binary("iouring_workload.c", &workload).is_none() {
eprintln!("Skipping test_seccomp_iouring_e2e: no C compiler found");
return;
}
let mut child = Command::new("/tmp/iouring_workload")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_bind_mount(tmp.path(), "/tmp")
.with_seccomp_allow_io_uring()
.spawn()
.expect("Failed to spawn container");
let status = child.wait().expect("Failed to wait");
assert_eq!(
status.code(),
Some(0),
"io_uring end-to-end: NOP should complete with result 0 (exit 2 = kernel/mmap error)"
);
}
#[test]
fn test_no_new_privileges() {
if !is_root() {
eprintln!("Skipping test_no_new_privileges: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_no_new_privileges: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "/bin/grep 'NoNewPrivs:.*1' /proc/self/status"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_no_new_privileges(true)
.spawn()
.expect("Failed to spawn with no-new-privileges");
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"NoNewPrivs should be set to 1 in /proc/self/status"
);
}
#[test]
fn test_readonly_rootfs() {
if !is_root() {
eprintln!("Skipping test_readonly_rootfs: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_readonly_rootfs: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "touch /test_file 2>&1; echo exit_code=$?"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_readonly_rootfs(true)
.spawn()
.expect("Failed to spawn with read-only rootfs");
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"Container should run despite read-only fs"
);
}
#[test]
fn test_masked_paths_default() {
if !is_root() {
eprintln!("Skipping test_masked_paths_default: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_masked_paths_default: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "cat /proc/kcore 2>&1 | head -c 10 || echo 'masked'"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_masked_paths_default()
.spawn()
.expect("Failed to spawn with masked paths");
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Masked paths should not cause failures");
}
#[test]
fn test_masked_paths_custom() {
if !is_root() {
eprintln!("Skipping test_masked_paths_custom: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_masked_paths_custom: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo 'Custom masked paths test'"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_masked_paths(&["/proc/kcore", "/sys/firmware"])
.spawn()
.expect("Failed to spawn with custom masked paths");
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Custom masked paths should work");
}
#[test]
fn test_combined_phase1_security() {
if !is_root() {
eprintln!("Skipping test_combined_phase1_security: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_combined_phase1_security: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo 'All Phase 1 security features enabled'"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_seccomp_default() .with_no_new_privileges(true) .with_readonly_rootfs(true) .with_masked_paths_default() .drop_all_capabilities() .spawn()
.expect("Failed to spawn with all Phase 1 security");
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"Container with all Phase 1 security should work"
);
}
#[test]
fn test_landlock_read_only_allows_read() {
use pelagos::landlock::get_abi_version;
if !is_root() {
eprintln!("Skipping test_landlock_read_only_allows_read: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_landlock_read_only_allows_read: alpine-rootfs not found");
return;
};
if get_abi_version() == 0 {
eprintln!("Skipping test_landlock_read_only_allows_read: kernel < 5.13, no Landlock");
return;
}
let mut child = Command::new("/bin/cat")
.args(["/etc/hostname"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_no_new_privileges(true)
.with_landlock_ro("/")
.spawn()
.expect("spawn failed");
let (status, _stdout, stderr) = child.wait_with_output().expect("wait failed");
assert!(
status.success(),
"read under landlock_ro(/) failed: stderr={}",
String::from_utf8_lossy(&stderr)
);
}
#[test]
fn test_landlock_denies_write() {
use pelagos::landlock::get_abi_version;
if !is_root() {
eprintln!("Skipping test_landlock_denies_write: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_landlock_denies_write: alpine-rootfs not found");
return;
};
if get_abi_version() == 0 {
eprintln!("Skipping test_landlock_denies_write: kernel < 5.13, no Landlock");
return;
}
let mut child = Command::new("/bin/sh")
.args(["-c", "touch /tmp/landlock_test; echo exit=$?"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_tmpfs("/tmp", "")
.with_no_new_privileges(true)
.with_landlock_ro("/")
.spawn()
.expect("spawn failed");
let (status, stdout_bytes, stderr_bytes) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("exit=1") || !status.success(),
"write should be denied under landlock_ro(/), got: stdout={} stderr={}",
stdout,
String::from_utf8_lossy(&stderr_bytes)
);
}
#[test]
fn test_landlock_rw_allows_write() {
use pelagos::landlock::get_abi_version;
if !is_root() {
eprintln!("Skipping test_landlock_rw_allows_write: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_landlock_rw_allows_write: alpine-rootfs not found");
return;
};
if get_abi_version() == 0 {
eprintln!("Skipping test_landlock_rw_allows_write: kernel < 5.13, no Landlock");
return;
}
let mut child = Command::new("/bin/sh")
.args(["-c", "touch /tmp/landlock_rw_test && echo ok"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_tmpfs("/tmp", "")
.with_no_new_privileges(true)
.with_landlock_rw("/")
.spawn()
.expect("spawn failed");
let (_status, stdout_bytes, stderr_bytes) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("ok"),
"write under landlock_rw(/) should succeed: stdout={} stderr={}",
stdout,
String::from_utf8_lossy(&stderr_bytes)
);
}
#[test]
fn test_landlock_no_rules_no_effect() {
if !is_root() {
eprintln!("Skipping test_landlock_no_rules_no_effect: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_landlock_no_rules_no_effect: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sh")
.args(["-c", "cat /etc/hostname && touch /tmp/noll && echo ok"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_tmpfs("/tmp", "")
.spawn()
.expect("spawn failed");
let (_status, stdout_bytes, stderr_bytes) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("ok"),
"no-rules container should read and write freely: stdout={} stderr={}",
stdout,
String::from_utf8_lossy(&stderr_bytes)
);
}
#[test]
fn test_landlock_partial_path_allow() {
use pelagos::landlock::get_abi_version;
if !is_root() {
eprintln!("Skipping test_landlock_partial_path_allow: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_landlock_partial_path_allow: alpine-rootfs not found");
return;
};
if get_abi_version() == 0 {
eprintln!("Skipping test_landlock_partial_path_allow: kernel < 5.13");
return;
}
let mut child = Command::new("/bin/sh")
.args([
"-c",
"cat /etc/hostname && touch /tmp/partial_test; echo write_exit=$?",
])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_tmpfs("/tmp", "")
.with_no_new_privileges(true)
.with_landlock_ro("/etc")
.with_landlock_ro("/bin")
.with_landlock_ro("/lib")
.with_landlock_ro("/usr")
.spawn()
.expect("spawn failed");
let (_status, stdout_bytes, stderr_bytes) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("write_exit=1"),
"write to /tmp should be denied when only /etc has landlock_ro: stdout={} stderr={}",
stdout,
String::from_utf8_lossy(&stderr_bytes)
);
}
}
mod mac {
use super::*;
#[test]
fn test_apparmor_profile_unconfined() {
if !is_root() {
eprintln!("Skipping test_apparmor_profile_unconfined: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_apparmor_profile_unconfined: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/echo")
.args(["ok"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_apparmor_profile("unconfined")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("spawn with apparmor=unconfined should succeed");
let (status, out, _) = child.wait_with_output().expect("wait");
assert!(
status.success(),
"container with apparmor=unconfined should exit 0"
);
assert!(
String::from_utf8_lossy(&out).contains("ok"),
"expected 'ok' in stdout"
);
}
#[test]
fn test_apparmor_profile_applied() {
if !is_root() {
eprintln!("Skipping test_apparmor_profile_applied: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_apparmor_profile_applied: alpine-rootfs not found");
return;
};
if !pelagos::mac::is_apparmor_enabled() {
eprintln!("Skipping test_apparmor_profile_applied: AppArmor not enabled");
return;
}
if std::process::Command::new("apparmor_parser")
.arg("--version")
.output()
.is_err()
{
eprintln!("Skipping test_apparmor_profile_applied: apparmor_parser not in PATH");
return;
}
let profile_path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/scripts/apparmor-profiles/pelagos-test"
);
let load = std::process::Command::new("apparmor_parser")
.args(["-r", profile_path])
.output()
.expect("apparmor_parser -r");
assert!(
load.status.success(),
"failed to load pelagos-test profile: {}",
String::from_utf8_lossy(&load.stderr)
);
let mut child = Command::new("/bin/sh")
.args(["-c", "cat /proc/self/attr/current"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.with_apparmor_profile("pelagos-test")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("spawn with apparmor=pelagos-test");
let (status, out, _) = child.wait_with_output().expect("wait");
let _ = std::process::Command::new("apparmor_parser")
.args(["-R", profile_path])
.output();
assert!(status.success(), "container should exit 0");
let stdout = String::from_utf8_lossy(&out);
assert!(
stdout.contains("pelagos-test"),
"expected 'pelagos-test' in /proc/self/attr/current, got: {stdout:?}"
);
}
#[test]
fn test_selinux_label_no_selinux() {
if !is_root() {
eprintln!("Skipping test_selinux_label_no_selinux: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_selinux_label_no_selinux: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/echo")
.args(["ok"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_selinux_label("system_u:system_r:container_t:s0")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect(
"spawn with selinux label should succeed (silently skipped when SELinux absent)",
);
let (status, out, _) = child.wait_with_output().expect("wait");
assert!(status.success(), "container should exit 0");
assert!(
String::from_utf8_lossy(&out).contains("ok"),
"expected 'ok' in stdout"
);
}
}
mod user_notif {
use super::*;
use pelagos::notif::{SyscallHandler, SyscallNotif, SyscallResponse};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct CountingAllow {
count: Arc<AtomicUsize>,
}
impl SyscallHandler for CountingAllow {
fn handle(&self, _n: &SyscallNotif) -> SyscallResponse {
self.count.fetch_add(1, Ordering::Relaxed);
SyscallResponse::Allow
}
}
struct DenyAll;
impl SyscallHandler for DenyAll {
fn handle(&self, _n: &SyscallNotif) -> SyscallResponse {
SyscallResponse::Deny(libc::EPERM)
}
}
#[test]
#[ignore = "requires kernel seccomp supervisor capabilities unavailable on CI runner"]
fn test_user_notif_handler_invoked() {
if !is_root() {
eprintln!("Skipping test_user_notif_handler_invoked: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_user_notif_handler_invoked: alpine-rootfs not found");
return;
};
let count = Arc::new(AtomicUsize::new(0));
let handler = CountingAllow {
count: count.clone(),
};
let mut child = Command::new("/usr/bin/id")
.args(["-u"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_seccomp_user_notif(vec![libc::SYS_getuid], handler)
.spawn()
.expect("spawn failed");
let (status, stdout_bytes, _) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
status.success(),
"id -u should succeed when getuid is allowed: stdout={}",
stdout
);
assert!(
stdout.trim() == "0",
"expected uid 0, got: {}",
stdout.trim()
);
assert!(
count.load(Ordering::Relaxed) >= 1,
"handler should have been called at least once for getuid"
);
}
#[test]
#[ignore = "requires kernel seccomp supervisor capabilities unavailable on CI runner"]
fn test_user_notif_deny_syscall() {
if !is_root() {
eprintln!("Skipping test_user_notif_deny_syscall: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_user_notif_deny_syscall: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sh")
.args(["-c", "touch /tmp/x && chmod 700 /tmp/x; echo exit=$?"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_tmpfs("/tmp", "")
.with_seccomp_user_notif(vec![libc::SYS_chmod], DenyAll)
.spawn()
.expect("spawn failed");
let (_status, stdout_bytes, _) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("exit=1"),
"chmod should fail (EPERM) when SYS_chmod is denied by supervisor: stdout={}",
stdout
);
}
#[test]
#[ignore = "requires kernel seccomp supervisor capabilities unavailable on CI runner"]
fn test_user_notif_allow_passthrough() {
if !is_root() {
eprintln!("Skipping test_user_notif_allow_passthrough: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_user_notif_allow_passthrough: alpine-rootfs not found");
return;
};
let count = Arc::new(AtomicUsize::new(0));
let handler = CountingAllow {
count: count.clone(),
};
let mut child = Command::new("/bin/sh")
.args(["-c", "touch /tmp/x && chmod 700 /tmp/x; echo exit=$?"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_proc_mount()
.with_tmpfs("/tmp", "")
.with_seccomp_user_notif(vec![libc::SYS_chmod], handler)
.spawn()
.expect("spawn failed");
let (_status, stdout_bytes, _) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("exit=0"),
"chmod should succeed when SYS_chmod is allowed by supervisor: stdout={}",
stdout
);
assert!(
count.load(Ordering::Relaxed) >= 1,
"handler should have been called at least once for SYS_chmod"
);
}
}
mod filesystem {
use super::*;
#[test]
fn test_bind_mount_rw() {
if !is_root() {
eprintln!("Skipping test_bind_mount_rw: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bind_mount_rw: alpine-rootfs not found");
return;
};
let host_dir = tempfile::tempdir().expect("failed to create temp dir");
std::fs::write(host_dir.path().join("hello.txt"), b"hello from host")
.expect("failed to write host file");
let mut child = Command::new("/bin/ash")
.args(["-c", "cat /mnt/hostdir/hello.txt"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_bind_mount(host_dir.path(), "/mnt/hostdir")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn with bind mount");
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"Container should read host file via bind mount"
);
}
#[test]
fn test_bind_mount_ro() {
if !is_root() {
eprintln!("Skipping test_bind_mount_ro: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bind_mount_ro: alpine-rootfs not found");
return;
};
let host_dir = tempfile::tempdir().expect("failed to create temp dir");
let mut child = Command::new("/bin/ash")
.args(["-c", "touch /mnt/ro/newfile 2>/dev/null; echo exit=$?"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_bind_mount_ro(host_dir.path(), "/mnt/ro")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn with read-only bind mount");
let (status, stdout, _) = child.wait_with_output().expect("Failed to collect output");
assert!(status.success(), "Shell should exit cleanly");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("exit=1"),
"Write to read-only bind mount should fail, got: {}",
out
);
}
#[test]
fn test_cli_volume_flag_ro() {
if !is_root() {
eprintln!("Skipping test_cli_volume_flag_ro: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cli_volume_flag_ro: alpine-rootfs not found");
return;
};
let host_dir = tempfile::tempdir().expect("temp dir");
let vol_spec = format!("{}:/mnt/ro:ro", host_dir.path().display());
let out = std::process::Command::new(env!("CARGO_BIN_EXE_pelagos"))
.args([
"run",
"--network",
"loopback",
"--rootfs",
rootfs.to_str().unwrap(),
"-v",
&vol_spec,
"/bin/ash",
"-c",
"touch /mnt/ro/x 2>/dev/null; echo exit=$?",
])
.output()
.expect("pelagos run");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("exit=1"),
"Write into :ro mount should fail (exit=1), got: {}",
stdout
);
let rw_spec = format!("{}:/mnt/rw:rw", host_dir.path().display());
let out2 = std::process::Command::new(env!("CARGO_BIN_EXE_pelagos"))
.args([
"run",
"--network",
"loopback",
"--rootfs",
rootfs.to_str().unwrap(),
"-v",
&rw_spec,
"/bin/ash",
"-c",
"touch /mnt/rw/x 2>/dev/null; echo exit=$?",
])
.output()
.expect("pelagos run rw");
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
stdout2.contains("exit=0"),
"Write into :rw mount should succeed (exit=0), got: {}",
stdout2
);
}
#[test]
fn test_tmpfs_mount() {
if !is_root() {
eprintln!("Skipping test_tmpfs_mount: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_tmpfs_mount: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "touch /tmp/testfile && echo ok"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_readonly_rootfs(true)
.with_tmpfs("/tmp", "size=10m,mode=1777")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn with tmpfs mount");
let (status, stdout, _) = child.wait_with_output().expect("Failed to collect output");
assert!(status.success(), "Container should succeed with tmpfs /tmp");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("ok"),
"touch on tmpfs should succeed, got: {}",
out
);
}
#[test]
fn test_named_volume() {
if !is_root() {
eprintln!("Skipping test_named_volume: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_named_volume: alpine-rootfs not found");
return;
};
let _ = Volume::delete("testvol");
let vol = Volume::create("testvol").expect("Failed to create volume");
let mut child = Command::new("/bin/ash")
.args(["-c", "echo persistent > /data/file.txt"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_volume(&vol, "/data")
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with named volume");
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Container should write to volume");
let host_file = vol.path().join("file.txt");
assert!(
host_file.exists(),
"Volume file should exist on host after container exits"
);
let contents = std::fs::read_to_string(&host_file).expect("Failed to read volume file");
assert!(
contents.contains("persistent"),
"Volume file should contain expected content"
);
Volume::delete("testvol").expect("Failed to delete volume");
}
#[test]
fn test_overlay_writes_to_upper() {
if !is_root() {
eprintln!("Skipping test_overlay_writes_to_upper: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_overlay_writes_to_upper: alpine-rootfs not found");
return;
}
};
let scratch = tempfile::tempdir().expect("failed to create tempdir");
let upper = scratch.path().join("upper");
let work = scratch.path().join("work");
std::fs::create_dir_all(&upper).unwrap();
std::fs::create_dir_all(&work).unwrap();
let mut child = Command::new("/bin/sh")
.args(["-c", "echo hello > /newfile"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_overlay(&upper, &work)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("failed to spawn overlay container");
child.wait().expect("failed to wait");
assert!(
!rootfs.join("newfile").exists(),
"lower dir (alpine-rootfs) should not contain newfile — overlay leaked write to lower"
);
let upper_file = upper.join("newfile");
assert!(
upper_file.exists(),
"upper_dir/newfile should exist after container wrote /newfile"
);
let content = std::fs::read_to_string(&upper_file).expect("failed to read upper/newfile");
assert_eq!(
content, "hello\n",
"upper_dir/newfile should contain 'hello\\n'"
);
}
#[test]
fn test_overlay_with_volume() {
if !is_root() {
eprintln!("Skipping test_overlay_with_volume: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_overlay_with_volume: alpine-rootfs not found");
return;
}
};
let scratch = tempfile::tempdir().expect("failed to create tempdir");
let upper = scratch.path().join("upper");
let work = scratch.path().join("work");
std::fs::create_dir_all(&upper).unwrap();
std::fs::create_dir_all(&work).unwrap();
let _ = Volume::delete("test_ov_vol");
let vol = Volume::create("test_ov_vol").expect("failed to create volume");
let mut child = Command::new("/bin/ash")
.args([
"-c",
"echo vol_data > /data/vol_file.txt && echo overlay_data > /overlay_file.txt",
])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_overlay(&upper, &work)
.with_volume(&vol, "/data")
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("failed to spawn overlay+volume container");
let status = child.wait().expect("failed to wait");
assert!(status.success(), "container should exit successfully");
let vol_file = vol.path().join("vol_file.txt");
assert!(
vol_file.exists(),
"volume file should exist on host after container exits"
);
let vol_contents = std::fs::read_to_string(&vol_file).expect("failed to read volume file");
assert_eq!(
vol_contents, "vol_data\n",
"volume file has expected content"
);
assert!(
!rootfs.join("overlay_file.txt").exists(),
"rootfs (lower layer) should not contain overlay_file.txt"
);
assert!(
upper.join("overlay_file.txt").exists(),
"overlay upper dir should contain overlay_file.txt"
);
assert!(
!upper.join("data/vol_file.txt").exists(),
"volume writes should not appear in overlay upper dir"
);
Volume::delete("test_ov_vol").expect("failed to delete volume");
}
#[test]
fn test_overlay_lower_unchanged() {
if !is_root() {
eprintln!("Skipping test_overlay_lower_unchanged: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_overlay_lower_unchanged: alpine-rootfs not found");
return;
}
};
let scratch = tempfile::tempdir().expect("failed to create tempdir");
let upper = scratch.path().join("upper");
let work = scratch.path().join("work");
std::fs::create_dir_all(&upper).unwrap();
std::fs::create_dir_all(&work).unwrap();
let lower_hostname = rootfs.join("etc/hostname");
let original_content =
std::fs::read_to_string(&lower_hostname).unwrap_or_else(|_| String::new());
let mut child = Command::new("/bin/sh")
.args(["-c", "echo modified > /etc/hostname"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_overlay(&upper, &work)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("failed to spawn overlay container");
child.wait().expect("failed to wait");
let after_content =
std::fs::read_to_string(&lower_hostname).unwrap_or_else(|_| String::new());
assert_eq!(
original_content, after_content,
"lower_dir/etc/hostname should be unchanged; overlay leaked write to lower"
);
let upper_hostname = upper.join("etc/hostname");
assert!(
upper_hostname.exists(),
"upper_dir/etc/hostname should exist (copy-on-write)"
);
let upper_content =
std::fs::read_to_string(&upper_hostname).expect("failed to read upper/etc/hostname");
assert_eq!(
upper_content, "modified\n",
"upper_dir/etc/hostname should contain 'modified\\n'"
);
}
#[test]
fn test_overlay_merged_cleanup() {
if !is_root() {
eprintln!("Skipping test_overlay_merged_cleanup: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_overlay_merged_cleanup: alpine-rootfs not found");
return;
}
};
let scratch = tempfile::tempdir().expect("failed to create tempdir");
let upper = scratch.path().join("upper");
let work = scratch.path().join("work");
std::fs::create_dir_all(&upper).unwrap();
std::fs::create_dir_all(&work).unwrap();
let mut child = Command::new("/bin/sh")
.args(["-c", "true"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_overlay(&upper, &work)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("failed to spawn overlay container");
let merged_dir = child.overlay_merged_dir().map(|p| p.to_path_buf());
assert!(
merged_dir.is_some(),
"with_overlay should set overlay_merged_dir on Child"
);
let merged = merged_dir.unwrap();
let parent = merged.parent().unwrap().to_path_buf();
assert!(merged.exists(), "merged dir should exist before wait()");
child.wait().expect("failed to wait");
assert!(
!merged.exists(),
"overlay merged dir should be removed after wait(); still present: {}",
merged.display()
);
assert!(
!parent.exists(),
"overlay parent dir should be removed after wait(); still present: {}",
parent.display()
);
}
}
mod cgroups {
use super::*;
#[test]
fn test_cgroup_memory_limit() {
if !is_root() {
eprintln!("Skipping test_cgroup_memory_limit: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_memory_limit: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"dd if=/dev/zero of=/tmp/fill bs=1M count=100 2>/dev/null; echo done",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(32 * 1024 * 1024) .with_cgroup_memory_swap(0) .with_tmpfs("/tmp", "")
.with_dev_mount() .stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup memory limit");
let status = child.wait().expect("Failed to wait for child");
let killed = status.signal().is_some() || !status.success();
assert!(
killed,
"Container should be OOM-killed (signal={:?}, code={:?})",
status.signal(),
status.code()
);
}
#[test]
fn test_cgroup_pids_limit() {
if !is_root() {
eprintln!("Skipping test_cgroup_pids_limit: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_pids_limit: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"for i in 1 2 3 4 5 6 7 8 9 10; do sleep 2 & done; wait",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_pids_limit(4)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup pids limit");
std::thread::sleep(std::time::Duration::from_millis(500));
let container_pid = child.pid() as u32;
let cg_path =
cgroup_path_for_pid(container_pid).expect("container is not in a pelagos cgroup");
let pids_max = read_cgroup_file(&cg_path, "pids.max").expect("pids.max not found");
assert_eq!(pids_max, "4", "pids.max mismatch: got {pids_max:?}");
let events = read_cgroup_file(&cg_path, "pids.events").expect("pids.events not found");
let denied: u64 = events
.lines()
.find(|l| l.starts_with("max"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse().ok())
.unwrap_or(0);
assert!(
denied > 0,
"pids.events shows 0 denied forks — pids.max=4 was not enforced (events={events:?})"
);
child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_cpu_shares() {
if !is_root() {
eprintln!("Skipping test_cgroup_cpu_shares: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_cpu_shares: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo ok"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_cpu_shares(512)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup cpu shares");
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"Container with cpu_shares should exit cleanly"
);
}
#[test]
fn test_resource_stats() {
if !is_root() {
eprintln!("Skipping test_resource_stats: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_resource_stats: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo hello"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(128 * 1024 * 1024)
.with_cgroup_pids_limit(64)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn for resource_stats test");
let stats: ResourceStats = child.resource_stats().expect("resource_stats() failed");
let _ = stats.memory_current_bytes;
let _ = stats.cpu_usage_ns;
let _ = stats.pids_current;
child.wait().expect("Failed to wait for child");
}
#[test]
fn test_cgroup_cleanup() {
if !is_root() {
eprintln!("Skipping test_cgroup_cleanup: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_cleanup: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(64 * 1024 * 1024)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn for cgroup cleanup test");
let pid = child.pid();
child.wait().expect("Failed to wait for child");
let cgroup_path = format!("/sys/fs/cgroup/pelagos-{}", pid);
assert!(
!std::path::Path::new(&cgroup_path).exists(),
"Cgroup {} should be deleted after container exits",
cgroup_path
);
}
#[test]
fn test_cgroup_memory_swap() {
if !is_root() {
eprintln!("Skipping test_cgroup_memory_swap: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_memory_swap: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(64 * 1024 * 1024)
.with_cgroup_memory_swap(128 * 1024 * 1024)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup memory+swap");
let _status = child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_memory_reservation() {
if !is_root() {
eprintln!("Skipping test_cgroup_memory_reservation: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_memory_reservation: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_memory_reservation(32 * 1024 * 1024)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup memory reservation");
let _status = child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_cpuset() {
if !is_root() {
eprintln!("Skipping test_cgroup_cpuset: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_cpuset: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_cpuset_cpus("0")
.with_cgroup_cpuset_mems("0")
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup cpuset");
let _status = child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_blkio_weight() {
if !is_root() {
eprintln!("Skipping test_cgroup_blkio_weight: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_blkio_weight: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_blkio_weight(100)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup blkio weight");
let _status = child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_device_rule() {
if !is_root() {
eprintln!("Skipping test_cgroup_device_rule: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_device_rule: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_device_rule(true, 'a', -1, -1, "rwm")
.with_cgroup_device_rule(false, 'c', 5, 1, "rwm")
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup device rules");
let _status = child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_net_classid() {
if !is_root() {
eprintln!("Skipping test_cgroup_net_classid: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_net_classid: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_net_classid(0x10001)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup net classid");
let _status = child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_memory_limit_pid_namespace() {
if !is_root() {
eprintln!("Skipping test_cgroup_memory_limit_pid_namespace: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_memory_limit_pid_namespace: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"dd if=/dev/zero of=/tmp/fill bs=1M count=100 2>/dev/null; echo done",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(32 * 1024 * 1024) .with_cgroup_memory_swap(0) .with_tmpfs("/tmp", "")
.with_dev_mount() .stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cgroup memory limit + PID namespace");
let status = child.wait().expect("Failed to wait for child");
let killed = status.signal().is_some() || !status.success();
assert!(
killed,
"Container should be OOM-killed when memory limit is enforced on the \
correct (grandchild) process (signal={:?}, code={:?})",
status.signal(),
status.code()
);
}
#[test]
fn test_cgroup_pids_limit_pid_namespace() {
if !is_root() {
eprintln!("Skipping test_cgroup_pids_limit_pid_namespace: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_pids_limit_pid_namespace: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do sleep 2 & done; wait",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_pids_limit(5) .stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with pids limit + PID namespace");
let cg_path = child
.cgroup_path()
.expect("no cgroup on child — cgroup was not configured");
let pids_max = read_cgroup_file(&cg_path, "pids.max").expect("pids.max not found");
assert_eq!(pids_max, "5", "pids.max mismatch: got {pids_max:?}");
std::thread::sleep(std::time::Duration::from_millis(200));
let events = read_cgroup_file(&cg_path, "pids.events").expect("pids.events not found");
let denied: u64 = events
.lines()
.find(|l| l.starts_with("max"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse().ok())
.unwrap_or(0);
assert!(
denied > 0,
"pids.events shows 0 denied forks — pids.max=5 was not enforced on grandchild \
(events={events:?})"
);
child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_cpu_quota_pid_namespace() {
if !is_root() {
eprintln!("Skipping test_cgroup_cpu_quota_pid_namespace: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_cpu_quota_pid_namespace: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 3"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_cpu_quota(50_000, 1_000_000)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cpu quota + PID namespace");
let waiter_pid = child.pid() as u32;
let grandchild_pid = wait_for_grandchild(waiter_pid)
.expect("grandchild PID not found — PID namespace double-fork may be broken");
let cg_path = cgroup_path_for_pid(grandchild_pid)
.expect("grandchild is not in a pelagos cgroup — cgroup assignment failed");
let cpu_max =
read_cgroup_file(&cg_path, "cpu.max").expect("cpu.max file not found in cgroup");
assert!(
cpu_max.starts_with("50000 "),
"cpu.max should show 50000 quota; got: {cpu_max:?}"
);
child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_cpuset_pid_namespace() {
if !is_root() {
eprintln!("Skipping test_cgroup_cpuset_pid_namespace: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_cpuset_pid_namespace: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 3"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_cpuset_cpus("0")
.with_cgroup_cpuset_mems("0")
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with cpuset + PID namespace");
let waiter_pid = child.pid() as u32;
let grandchild_pid = wait_for_grandchild(waiter_pid).expect("grandchild PID not found");
let status_path = format!("/proc/{}/status", grandchild_pid);
let status_content = std::fs::read_to_string(&status_path)
.expect("Failed to read /proc/{grandchild}/status");
let cpus_allowed = status_content
.lines()
.find(|l| l.starts_with("Cpus_allowed_list"))
.and_then(|l| l.split_whitespace().nth(1))
.expect("Cpus_allowed_list not found in /proc/status");
assert_eq!(
cpus_allowed, "0",
"grandchild cpuset should be restricted to CPU 0; got: {cpus_allowed:?}"
);
child.wait().expect("wait failed");
}
#[test]
fn test_cgroup_resource_stats_pid_namespace() {
if !is_root() {
eprintln!("Skipping test_cgroup_resource_stats_pid_namespace: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_cgroup_resource_stats_pid_namespace: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 3"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(128 * 1024 * 1024)
.with_cgroup_pids_limit(16)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn for resource_stats PID-ns test");
std::thread::sleep(std::time::Duration::from_millis(200));
let stats: ResourceStats = child
.resource_stats()
.expect("resource_stats() failed for PID-ns container");
assert!(
stats.pids_current >= 1,
"pids_current should be >= 1 (grandchild is in cgroup); got {}",
stats.pids_current
);
child.wait().expect("wait failed");
}
}
mod networking {
use super::*;
#[test]
fn test_loopback_network() {
if !is_root() {
eprintln!("Skipping test_loopback_network: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_loopback_network: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"ip addr show lo | grep -q '127.0.0.1' && echo LOOPBACK_OK",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Loopback)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn loopback container");
let (status, stdout, _) = child.wait_with_output().expect("Failed to collect output");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("LOOPBACK_OK"),
"lo should have 127.0.0.1 after bring-up, got: {}",
out
);
assert!(status.success(), "Container exited with failure");
}
#[test]
#[serial(nat)]
fn test_bridge_network_ip() {
if !is_root() {
eprintln!("Skipping test_bridge_network_ip: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_network_ip: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"ip addr show eth0 | grep -q '172.19.0' && echo BRIDGE_IP_OK",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn bridge container");
let (status, stdout, _) = child.wait_with_output().expect("Failed to collect output");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("BRIDGE_IP_OK"),
"eth0 should have a 172.19.0.x address in bridge mode, got: {}",
out
);
assert!(status.success(), "Container exited with failure");
}
#[test]
#[serial(nat)]
fn test_bridge_network_veth_exists() {
if !is_root() {
eprintln!("Skipping test_bridge_network_veth_exists: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_network_veth_exists: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 2"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn bridge container");
let veth_name = child
.veth_name()
.expect("Bridge mode must have a veth name")
.to_string();
let status = std::process::Command::new("ip")
.args(["link", "show", &veth_name])
.stdout(std::process::Stdio::null())
.status()
.expect("Failed to run ip link show");
assert!(
status.success(),
"Host-side veth {} should exist after spawn",
veth_name
);
child.wait().expect("Failed to wait for container");
}
#[test]
#[serial(nat)]
fn test_bridge_network_cleanup() {
if !is_root() {
eprintln!("Skipping test_bridge_network_cleanup: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_network_cleanup: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn bridge container");
let veth_name = child
.veth_name()
.expect("Bridge mode must have a veth name")
.to_string();
child.wait().expect("Failed to wait for container");
let status = std::process::Command::new("ip")
.args(["link", "show", &veth_name])
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run ip link show");
assert!(
!status.success(),
"Host-side veth {} should be gone after container exits",
veth_name
);
}
#[test]
#[serial(nat)]
fn test_bridge_netns_cleanup() {
if !is_root() {
eprintln!("Skipping test_bridge_netns_cleanup: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_netns_cleanup: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn bridge container");
let ns_name = child
.netns_name()
.expect("Bridge mode must have netns name")
.to_string();
let ns_path = format!("/run/netns/{}", ns_name);
assert!(
std::path::Path::new(&ns_path).exists(),
"Named netns {} should exist before wait()",
ns_path
);
child.wait().expect("Failed to wait for container");
assert!(
!std::path::Path::new(&ns_path).exists(),
"Named netns {} should be deleted after wait()",
ns_path
);
}
#[test]
#[serial(nat)]
fn test_bridge_loopback_up() {
if !is_root() {
eprintln!("Skipping test_bridge_loopback_up: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_loopback_up: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "ip addr show lo | grep -q '127.0.0.1' && echo LO_OK"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn bridge container");
let (status, stdout, _) = child.wait_with_output().expect("Failed to collect output");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("LO_OK"),
"lo should be up with 127.0.0.1 in bridge mode, got: {:?}",
out
);
assert!(status.success(), "Container exited with failure");
}
#[test]
#[serial(nat)]
fn test_bridge_gateway_reachable() {
if !is_root() {
eprintln!("Skipping test_bridge_gateway_reachable: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_gateway_reachable: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"ping -c 1 -W 2 172.19.0.1 >/dev/null 2>&1 && echo PING_OK",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn bridge container");
let (status, stdout, _) = child.wait_with_output().expect("Failed to collect output");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("PING_OK"),
"Gateway 172.19.0.1 should be reachable from bridge container, got: {:?}",
out
);
assert!(status.success(), "Container exited with failure");
}
#[test]
#[serial(nat)]
fn test_bridge_concurrent_spawn() {
if !is_root() {
eprintln!("Skipping test_bridge_concurrent_spawn: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_concurrent_spawn: alpine-rootfs not found");
return;
};
let r1 = rootfs.clone();
let t1 = std::thread::spawn(move || {
Command::new("/bin/ash")
.args([
"-c",
"ip addr show eth0 | grep -m1 'inet ' | awk '{print $2}'",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&r1)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container 1")
.wait_with_output()
.expect("Failed to collect output from container 1")
});
let r2 = rootfs.clone();
let t2 = std::thread::spawn(move || {
Command::new("/bin/ash")
.args([
"-c",
"ip addr show eth0 | grep -m1 'inet ' | awk '{print $2}'",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&r2)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container 2")
.wait_with_output()
.expect("Failed to collect output from container 2")
});
let (_s1, out1, _) = t1.join().expect("Container 1 thread panicked");
let (_s2, out2, _) = t2.join().expect("Container 2 thread panicked");
let ip1 = String::from_utf8_lossy(&out1).trim().to_string();
let ip2 = String::from_utf8_lossy(&out2).trim().to_string();
assert!(!ip1.is_empty(), "Container 1 should output its IP address");
assert!(!ip2.is_empty(), "Container 2 should output its IP address");
assert!(
ip1.starts_with("172.19.0."),
"Container 1 IP should be in bridge subnet: {}",
ip1
);
assert!(
ip2.starts_with("172.19.0."),
"Container 2 IP should be in bridge subnet: {}",
ip2
);
assert_ne!(
ip1, ip2,
"Containers must receive different IPs: got {} and {}",
ip1, ip2
);
}
#[test]
#[serial(nat)]
fn test_nat_rule_added() {
if !is_root() {
eprintln!("Skipping test_nat_rule_added: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_nat_rule_added: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 2"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn NAT container");
let status = std::process::Command::new("nft")
.args(["list", "table", "ip", "pelagos-pelagos0"])
.stdout(std::process::Stdio::null())
.status()
.expect("Failed to run nft list table");
assert!(
status.success(),
"nft table ip pelagos-pelagos0 should exist while a NAT container is running"
);
child.wait().expect("Failed to wait for NAT container");
}
#[test]
#[serial(nat)]
fn test_nat_cleanup() {
if !is_root() {
eprintln!("Skipping test_nat_cleanup: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_nat_cleanup: alpine-rootfs not found");
return;
};
let _ = std::fs::write(pelagos::paths::network_nat_refcount_file("pelagos0"), "0\n");
let _ = std::process::Command::new("nft")
.args(["delete", "table", "ip", "pelagos-pelagos0"])
.stderr(std::process::Stdio::null())
.status();
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn NAT container");
child.wait().expect("Failed to wait for NAT container");
let status = std::process::Command::new("nft")
.args(["list", "table", "ip", "pelagos-pelagos0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run nft list table");
assert!(
!status.success(),
"nft table ip pelagos-pelagos0 should be removed after all NAT containers exit"
);
}
#[test]
#[serial(nat)]
fn test_nat_refcount() {
if !is_root() {
eprintln!("Skipping test_nat_refcount: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_nat_refcount: alpine-rootfs not found");
return;
};
let mut child_a = Command::new("/bin/ash")
.args(["-c", "sleep 2"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn NAT container A");
let mut child_b = Command::new("/bin/ash")
.args(["-c", "sleep 4"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn NAT container B");
child_a.wait().expect("Failed to wait for container A");
let status = std::process::Command::new("nft")
.args(["list", "table", "ip", "pelagos-pelagos0"])
.stdout(std::process::Stdio::null())
.status()
.expect("Failed to run nft list table after A exits");
assert!(
status.success(),
"nft table should still exist after A exits (B is still running)"
);
child_b.wait().expect("Failed to wait for container B");
let status = std::process::Command::new("nft")
.args(["list", "table", "ip", "pelagos-pelagos0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run nft list table after B exits");
assert!(
!status.success(),
"nft table should be removed after both NAT containers exit"
);
}
#[test]
#[serial(nat)]
fn test_nat_iptables_forward_rules() {
if !is_root() {
eprintln!("Skipping test_nat_iptables_forward_rules: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_nat_iptables_forward_rules: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 3"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn NAT container");
let status_src = std::process::Command::new("iptables")
.args(["-C", "FORWARD", "-s", "172.19.0.0/24", "-j", "ACCEPT"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run iptables -C (source)");
assert!(
status_src.success(),
"iptables FORWARD rule for source 172.19.0.0/24 should exist while NAT container runs"
);
let status_dst = std::process::Command::new("iptables")
.args(["-C", "FORWARD", "-d", "172.19.0.0/24", "-j", "ACCEPT"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run iptables -C (dest)");
assert!(
status_dst.success(),
"iptables FORWARD rule for dest 172.19.0.0/24 should exist while NAT container runs"
);
child.wait().expect("Failed to wait for NAT container");
let status_after = std::process::Command::new("iptables")
.args(["-C", "FORWARD", "-s", "172.19.0.0/24", "-j", "ACCEPT"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run iptables -C after cleanup");
assert!(
!status_after.success(),
"iptables FORWARD rule should be removed after NAT container exits"
);
}
#[test]
#[serial(nat)]
fn test_port_forward_rule_added() {
if !is_root() {
eprintln!("Skipping test_port_forward_rule_added: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_forward_rule_added: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 2"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(18080, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn port-forward container");
let output = std::process::Command::new("nft")
.args(["list", "chain", "ip", "pelagos-pelagos0", "prerouting"])
.output()
.expect("Failed to run nft list chain");
assert!(
output.status.success(),
"nft prerouting chain should exist while port-forward container is running"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("dport 18080"),
"prerouting chain should contain DNAT rule for dport 18080; got:\n{}",
stdout
);
child
.wait()
.expect("Failed to wait for port-forward container");
}
#[test]
#[serial(nat)]
fn test_port_forward_cleanup() {
if !is_root() {
eprintln!("Skipping test_port_forward_cleanup: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_forward_cleanup: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "exit 0"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(18081, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn port-forward container");
child
.wait()
.expect("Failed to wait for port-forward container");
let status = std::process::Command::new("nft")
.args(["list", "table", "ip", "pelagos-pelagos0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run nft list table");
assert!(
!status.success(),
"nft table ip pelagos-pelagos0 should be removed after port-forward container exits"
);
}
#[test]
#[serial(nat)]
fn test_port_forward_independent_teardown() {
if !is_root() {
eprintln!("Skipping test_port_forward_independent_teardown: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_forward_independent_teardown: alpine-rootfs not found");
return;
};
let mut child_a = Command::new("/bin/ash")
.args(["-c", "sleep 2"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(18082, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn port-forward container A");
let mut child_b = Command::new("/bin/ash")
.args(["-c", "sleep 4"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(18083, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn port-forward container B");
child_a.wait().expect("Failed to wait for container A");
let output = std::process::Command::new("nft")
.args(["list", "chain", "ip", "pelagos-pelagos0", "prerouting"])
.output()
.expect("Failed to run nft list chain after A exits");
assert!(
output.status.success(),
"prerouting chain should still exist after A exits (B is still running)"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("dport 18082"),
"A's DNAT rule (dport 18082) should be gone after A exits"
);
assert!(
stdout.contains("dport 18083"),
"B's DNAT rule (dport 18083) should still be present; got:\n{}",
stdout
);
child_b.wait().expect("Failed to wait for container B");
let status = std::process::Command::new("nft")
.args(["list", "table", "ip", "pelagos-pelagos0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("Failed to run nft list table after B exits");
assert!(
!status.success(),
"nft table should be removed after both port-forward containers exit"
);
}
#[test]
#[serial(nat)]
fn test_dns_resolv_conf() {
if !is_root() {
eprintln!("Skipping test_dns_resolv_conf: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_dns_resolv_conf: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "cat /etc/resolv.conf"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_dns(&["1.1.1.1", "8.8.8.8"])
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn DNS container");
let (_status, stdout_bytes, _stderr) = child
.wait_with_output()
.expect("Failed to wait for DNS container");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("nameserver 1.1.1.1"),
"/etc/resolv.conf should contain 'nameserver 1.1.1.1'; got:\n{}",
stdout
);
assert!(
stdout.contains("nameserver 8.8.8.8"),
"/etc/resolv.conf should contain 'nameserver 8.8.8.8'; got:\n{}",
stdout
);
}
#[test]
#[serial(nat)]
fn test_port_forward_end_to_end() {
if !is_root() {
eprintln!("Skipping test_port_forward_end_to_end: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_forward_end_to_end: alpine-rootfs not found");
return;
};
let nc_ok = std::process::Command::new("which")
.arg("nc")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !nc_ok {
eprintln!("Skipping test_port_forward_end_to_end: nc not found on host");
return;
}
let mut child_a = Command::new("/bin/sh")
.args(["-c", "echo HELLO_FROM_CONTAINER | nc -l -p 80"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(19090, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container A");
std::thread::sleep(std::time::Duration::from_millis(500));
let setup_ok = std::process::Command::new("sh")
.args([
"-c",
"\
ip netns add pf-test-client && \
ip link add pf-test-h type veth peer name pf-test-c && \
ip link set pf-test-c netns pf-test-client && \
ip addr add 10.99.0.1/24 dev pf-test-h && \
ip link set pf-test-h up && \
ip netns exec pf-test-client ip addr add 10.99.0.2/24 dev pf-test-c && \
ip netns exec pf-test-client ip link set pf-test-c up && \
ip netns exec pf-test-client ip route add default via 10.99.0.1\
",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("setup test netns")
.success();
if !setup_ok {
unsafe {
libc::kill(child_a.pid(), libc::SIGKILL);
}
let _ = child_a.wait();
eprintln!("Skipping test_port_forward_end_to_end: failed to set up test netns");
return;
}
let output = std::process::Command::new("ip")
.args([
"netns",
"exec",
"pf-test-client",
"nc",
"-w",
"2",
"10.99.0.1",
"19090",
])
.output()
.expect("nc from test netns");
let out = String::from_utf8_lossy(&output.stdout);
let err = String::from_utf8_lossy(&output.stderr);
let _ = std::process::Command::new("ip")
.args(["netns", "del", "pf-test-client"])
.status();
let _ = std::process::Command::new("ip")
.args(["link", "del", "pf-test-h"])
.status();
unsafe {
libc::kill(child_a.pid(), libc::SIGKILL);
}
let _ = child_a.wait();
assert!(
out.contains("HELLO_FROM_CONTAINER"),
"External client should receive 'HELLO_FROM_CONTAINER' via port forward 19090→80.\nstdout: {}\nstderr: {}",
out, err
);
}
#[test]
#[serial(nat)]
fn test_ip_forward_enabled_on_port_forward() {
if !is_root() {
eprintln!("Skipping test_ip_forward_enabled_on_port_forward: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_ip_forward_enabled_on_port_forward: alpine-rootfs not found");
return;
};
let _ = std::fs::write("/proc/sys/net/ipv4/ip_forward", "0\n");
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 2"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(18089, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn port-forward container");
let val = std::fs::read_to_string("/proc/sys/net/ipv4/ip_forward").unwrap_or_default();
child.wait().expect("wait for port-forward container");
assert_eq!(
val.trim(),
"1",
"ip_forward must be 1 after a container with port-forward starts \
(pelagos#144 regression)"
);
}
#[test]
#[serial(nat)]
fn test_udp_port_forward_rule_added() {
if !is_root() {
eprintln!("Skipping test_udp_port_forward_rule_added: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_udp_port_forward_rule_added: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sh")
.args(["-c", "sleep 5"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward_udp(19095, 5000)
.with_chroot(&rootfs)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container");
std::thread::sleep(std::time::Duration::from_millis(200));
let nft_out = std::process::Command::new("nft")
.args(["list", "chain", "ip", "pelagos-pelagos0", "prerouting"])
.output();
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
let _ = child.wait();
let nft_out = nft_out.expect("nft list chain");
let rules = String::from_utf8_lossy(&nft_out.stdout);
assert!(
rules.contains("udp dport 19095"),
"Expected 'udp dport 19095' in prerouting chain, got:\n{}",
rules
);
assert!(
!rules.contains("tcp dport 19095"),
"UDP-only mapping must not generate a TCP rule, got:\n{}",
rules
);
}
#[test]
#[serial(nat)]
fn test_both_port_forward_rule_added() {
if !is_root() {
eprintln!("Skipping test_both_port_forward_rule_added: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_both_port_forward_rule_added: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sh")
.args(["-c", "sleep 5"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward_both(19096, 53)
.with_chroot(&rootfs)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container");
std::thread::sleep(std::time::Duration::from_millis(200));
let nft_out = std::process::Command::new("nft")
.args(["list", "chain", "ip", "pelagos-pelagos0", "prerouting"])
.output();
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
let _ = child.wait();
let nft_out = nft_out.expect("nft list chain");
let rules = String::from_utf8_lossy(&nft_out.stdout);
assert!(
rules.contains("tcp dport 19096"),
"Expected 'tcp dport 19096' in prerouting chain, got:\n{}",
rules
);
assert!(
rules.contains("udp dport 19096"),
"Expected 'udp dport 19096' in prerouting chain, got:\n{}",
rules
);
}
#[test]
#[serial(nat)]
fn test_udp_proxy_threads_joined_on_teardown() {
if !is_root() {
eprintln!("Skipping test_udp_proxy_threads_joined_on_teardown: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!(
"Skipping test_udp_proxy_threads_joined_on_teardown: alpine-rootfs not found"
);
return;
};
let test_port: u16 = 19097;
let mut child = Command::new("/bin/sh")
.args(["-c", "sleep 60"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward_udp(test_port, 5000)
.with_chroot(&rootfs)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container");
std::thread::sleep(std::time::Duration::from_millis(100));
let bind_while_running =
std::net::UdpSocket::bind(std::net::SocketAddr::from(([127, 0, 0, 1], test_port)));
assert!(
bind_while_running.is_err(),
"UDP proxy should hold port {} while container is running",
test_port
);
unsafe { libc::kill(child.pid(), libc::SIGKILL) };
let _ = child.wait();
let bind_after =
std::net::UdpSocket::bind(std::net::SocketAddr::from(([127, 0, 0, 1], test_port)));
assert!(
bind_after.is_ok(),
"UDP proxy port {} should be released after teardown (thread not joined?)",
test_port
);
}
#[test]
#[serial(nat)]
fn test_bridge_cleanup_after_sigkill() {
if !is_root() {
eprintln!("Skipping test_bridge_cleanup_after_sigkill: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_bridge_cleanup_after_sigkill: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sleep")
.args(["60"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container");
let veth = child.veth_name().expect("should have veth").to_string();
let netns = child.netns_name().expect("should have netns").to_string();
let veth_exists = std::process::Command::new("ip")
.args(["link", "show", &veth])
.stderr(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.status()
.expect("ip link show")
.success();
assert!(veth_exists, "veth {} should exist before kill", veth);
let iptables_exists = std::process::Command::new("iptables")
.args(["-C", "FORWARD", "-s", "172.19.0.0/24", "-j", "ACCEPT"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("iptables -C")
.success();
assert!(
iptables_exists,
"iptables FORWARD rule should exist before kill"
);
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
let _ = child.wait();
let veth_after = std::process::Command::new("ip")
.args(["link", "show", &veth])
.stderr(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.status()
.expect("ip link show after kill")
.success();
assert!(
!veth_after,
"veth {} should be gone after SIGKILL + wait()",
veth
);
let netns_path = format!("/run/netns/{}", netns);
assert!(
!std::path::Path::new(&netns_path).exists(),
"netns {} should be gone after SIGKILL + wait()",
netns
);
let nft_after = std::process::Command::new("nft")
.args(["list", "table", "ip", "pelagos-pelagos0"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("nft list table after kill")
.success();
assert!(
!nft_after,
"nftables table should be gone after SIGKILL + wait()"
);
let iptables_after = std::process::Command::new("iptables")
.args(["-C", "FORWARD", "-s", "172.19.0.0/24", "-j", "ACCEPT"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("iptables -C after kill")
.success();
assert!(
!iptables_after,
"iptables FORWARD rule should be gone after SIGKILL + wait()"
);
}
#[test]
#[serial(nat)]
fn test_nat_end_to_end_tcp() {
if !is_root() {
eprintln!("Skipping test_nat_end_to_end_tcp: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_nat_end_to_end_tcp: alpine-rootfs not found");
return;
};
let internet = std::process::Command::new("ping")
.args(["-c", "1", "-W", "2", "1.1.1.1"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match internet {
Ok(s) if s.success() => {}
_ => {
eprintln!("Skipping test_nat_end_to_end_tcp: no outbound internet");
return;
}
}
let mut child = Command::new("/bin/sh")
.args(["-c", "wget -q -T 5 --spider http://1.1.1.1/"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_dns(&["1.1.1.1"])
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn NAT container");
let (status, stdout, stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(
status.success(),
"wget through NAT should succeed (TCP to 1.1.1.1).\nstdout: {}\nstderr: {}",
out,
err
);
}
}
mod ipv6 {
use super::*;
fn host_has_ipv6() -> bool {
let target = "2606:4700:4700::1111";
let ok = std::process::Command::new("ping")
.args(["-6", "-c", "1", "-W", "2", target])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
return true;
}
std::process::Command::new("ping6")
.args(["-c", "1", "-W", "2", target])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
#[serial(nat)]
fn test_ipv6_container_gets_address() {
if !is_root() {
eprintln!("Skipping test_ipv6_container_gets_address: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_ipv6_container_gets_address: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"ip -6 addr show eth0 2>/dev/null | grep -q 'fd' && echo IPV6_OK",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn IPv6 container");
let (status, stdout, _) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("IPV6_OK"),
"eth0 should have a ULA (fd-prefix) IPv6 address in bridge mode, got: {}",
out
);
assert!(status.success(), "Container exited with failure");
}
#[test]
#[serial(nat)]
fn test_ipv6_outbound_nat() {
if !is_root() {
eprintln!("Skipping test_ipv6_outbound_nat: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_ipv6_outbound_nat: alpine-rootfs not found");
return;
};
if !host_has_ipv6() {
eprintln!("Skipping test_ipv6_outbound_nat: host has no IPv6 connectivity");
return;
}
let mut child = Command::new("/bin/ash")
.args([
"-c",
"while ip -6 addr show eth0 | grep -q tentative; do sleep 0.1; done; \
ping6 -c 2 -W 5 2606:4700:4700::1111 >/dev/null 2>&1 && echo NAT6_OK",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_dns(&["2606:4700:4700::1111"])
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn NAT6 container");
let (status, stdout, _) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("NAT6_OK"),
"Container should reach IPv6 internet via NAT66, got: {}",
out
);
assert!(status.success(), "Container exited with failure");
}
#[test]
#[serial(nat)]
fn test_ipv6_port_forward_localhost() {
if !is_root() {
eprintln!("Skipping test_ipv6_port_forward_localhost: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_ipv6_port_forward_localhost: alpine-rootfs not found");
return;
};
let host_port: u16 = 19093;
let container_port: u16 = 9080;
let mut child = Command::new("/bin/ash")
.args(["-c", "echo HELLO_IPV6 | nc -l -p 9080"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(host_port, container_port)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn port-forward v6 container");
std::thread::sleep(std::time::Duration::from_millis(500));
let result = std::net::TcpStream::connect(format!("[::1]:{}", host_port));
match result {
Ok(mut stream) => {
use std::io::Read;
stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.ok();
let mut buf = Vec::new();
let _ = stream.read_to_end(&mut buf);
let response = String::from_utf8_lossy(&buf);
unsafe { libc::kill(child.pid(), libc::SIGTERM) };
let _ = child.wait();
assert!(
response.contains("HELLO_IPV6"),
"IPv6 localhost proxy should relay container response, got: {:?}",
response
);
}
Err(e) => {
unsafe { libc::kill(child.pid(), libc::SIGTERM) };
let _ = child.wait();
panic!("Failed to connect to [::1]:{}: {}", host_port, e);
}
}
}
}
mod oci_lifecycle {
use super::*;
fn make_oci_bundle(dir: &std::path::Path, rootfs: &std::path::Path, args: &[&str]) -> PathBuf {
let rootfs_link = dir.join("rootfs");
std::os::unix::fs::symlink(rootfs, &rootfs_link).expect("failed to create rootfs symlink");
let args_json: Vec<String> = args.iter().map(|s| format!("\"{}\"", s)).collect();
let config = format!(
r#"{{
"ociVersion": "1.0.2",
"root": {{"path": "rootfs"}},
"process": {{
"args": [{}],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
}},
"linux": {{
"namespaces": [
{{"type": "mount"}},
{{"type": "uts"}},
{{"type": "pid"}}
]
}}
}}"#,
args_json.join(", ")
);
std::fs::write(dir.join("config.json"), config).expect("failed to write config.json");
dir.to_path_buf()
}
fn run_pelagos(args: &[&str]) -> (String, String, bool) {
if args.first() == Some(&"create") {
let tmp = tempfile::NamedTempFile::new().expect("tempfile for stderr");
let stderr_file = tmp.reopen().expect("reopen stderr tempfile");
let status = std::process::Command::new(env!("CARGO_BIN_EXE_pelagos"))
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::from(stderr_file))
.status()
.expect("failed to run pelagos create");
let stderr = std::fs::read_to_string(tmp.path()).unwrap_or_default();
return (String::new(), stderr, status.success());
}
let output = std::process::Command::new(env!("CARGO_BIN_EXE_pelagos"))
.args(args)
.output()
.expect("failed to run pelagos binary");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.success(),
)
}
fn oci_run_to_completion(id: &str, bundle: &std::path::Path, timeout_secs: u64) {
let (_, stderr, ok) = run_pelagos(&["create", id, bundle.to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", id]);
assert!(ok, "pelagos start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_pelagos(&["state", id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
run_pelagos(&["delete", id]);
panic!("container did not stop within {} seconds", timeout_secs);
}
}
let (_, stderr, ok) = run_pelagos(&["delete", id]);
assert!(ok, "pelagos delete failed: {}", stderr);
}
#[test]
fn test_oci_create_start_state() {
if !is_root() {
eprintln!("Skipping test_oci_create_start_state: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_create_start_state: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/sleep", "2"]);
let id = format!("test-oci-css-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (stdout, stderr, ok) = run_pelagos(&["state", &id]);
assert!(ok, "pelagos state (created) failed: {}", stderr);
assert!(
stdout.contains("\"created\""),
"expected status 'created', got: {}",
stdout
);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
let (stdout, _, _) = run_pelagos(&["state", &id]);
assert!(
stdout.contains("\"running\""),
"expected status 'running' after start, got: {}",
stdout
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(6);
loop {
std::thread::sleep(std::time::Duration::from_millis(200));
let (stdout, _, _) = run_pelagos(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
panic!(
"container did not stop within 6 seconds; last state: {}",
stdout
);
}
}
let (_, stderr, ok) = run_pelagos(&["delete", &id]);
assert!(ok, "pelagos delete failed: {}", stderr);
let state_dir = pelagos::oci::state_dir(&id);
assert!(
!state_dir.exists(),
"state dir still exists after delete: {}",
state_dir.display()
);
}
#[test]
fn test_oci_kill() {
if !is_root() {
eprintln!("Skipping test_oci_kill: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_kill: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/sleep", "60"]);
let id = format!("test-oci-kill-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
std::thread::sleep(std::time::Duration::from_millis(200));
let (_, stderr, ok) = run_pelagos(&["kill", &id, "SIGKILL"]);
assert!(ok, "pelagos kill failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4);
loop {
std::thread::sleep(std::time::Duration::from_millis(200));
let (stdout, _, _) = run_pelagos(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
panic!("container did not stop after SIGKILL within 4 seconds");
}
}
let (_, stderr, ok) = run_pelagos(&["delete", &id]);
assert!(ok, "pelagos delete failed: {}", stderr);
}
#[test]
fn test_oci_delete_cleanup() {
if !is_root() {
eprintln!("Skipping test_oci_delete_cleanup: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_delete_cleanup: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/true"]);
let id = format!("test-oci-del-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_pelagos(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
panic!("container did not stop within 4 seconds");
}
}
let state_dir = pelagos::oci::state_dir(&id);
assert!(state_dir.exists(), "state dir should exist before delete");
let (_, stderr, ok) = run_pelagos(&["delete", &id]);
assert!(ok, "pelagos delete failed: {}", stderr);
assert!(
!state_dir.exists(),
"state dir {} still present after delete",
state_dir.display()
);
}
#[test]
fn test_oci_state_dir_stable_until_delete() {
if !is_root() {
eprintln!("Skipping test_oci_state_dir_stable_until_delete: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!(
"Skipping test_oci_state_dir_stable_until_delete: alpine-rootfs not found"
);
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/true"]);
let id = format!("test-oci-stable-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
std::thread::sleep(std::time::Duration::from_millis(200));
let state_dir = pelagos::oci::state_dir(&id);
assert!(
state_dir.exists(),
"state dir must persist until pelagos delete, not be cleaned up on container exit"
);
let (stdout, stderr, ok) = run_pelagos(&["state", &id]);
assert!(ok, "pelagos state on stopped container failed: {}", stderr);
assert!(
stdout.contains("\"stopped\""),
"expected state=stopped, got: {}",
stdout
);
let (_, stderr, ok) = run_pelagos(&["delete", &id]);
assert!(ok, "pelagos delete failed: {}", stderr);
assert!(!state_dir.exists(), "state dir should be gone after delete");
}
#[test]
fn test_oci_kill_short_lived() {
if !is_root() {
eprintln!("Skipping test_oci_kill_short_lived: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_kill_short_lived: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/true"]);
let id = format!("test-oci-kill-sl-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
std::thread::sleep(std::time::Duration::from_millis(200));
let (_, stderr, ok) = run_pelagos(&["kill", &id, "SIGKILL"]);
assert!(
ok,
"pelagos kill on short-lived container failed: {}",
stderr
);
let (_, stderr, ok) = run_pelagos(&["delete", &id]);
assert!(ok, "pelagos delete failed: {}", stderr);
}
#[test]
fn test_oci_kill_stopped_fails() {
if !is_root() {
eprintln!("Skipping test_oci_kill_stopped_fails: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_kill_stopped_fails: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/true"]);
let id = format!("test-oci-kill-sf-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
std::thread::sleep(std::time::Duration::from_millis(200));
let (stdout, _, _) = run_pelagos(&["state", &id]);
assert!(
stdout.contains("\"stopped\""),
"expected state=stopped, got: {}",
stdout
);
let (_, _, ok) = run_pelagos(&["kill", &id, "SIGKILL"]);
assert!(!ok, "pelagos kill on stopped container should fail");
let (_, stderr, ok) = run_pelagos(&["delete", &id]);
assert!(ok, "pelagos delete failed: {}", stderr);
}
#[test]
fn test_oci_pid_start_time() {
use pelagos::oci::read_pid_start_time;
let our_pid = std::process::id() as libc::pid_t;
let t = read_pid_start_time(our_pid);
assert!(
t.is_some(),
"read_pid_start_time(self) returned None — /proc parsing broken"
);
assert!(t.unwrap() > 0, "starttime should be positive");
assert_eq!(
read_pid_start_time(our_pid),
read_pid_start_time(our_pid),
"read_pid_start_time is not stable"
);
assert!(
read_pid_start_time(i32::MAX).is_none(),
"read_pid_start_time(MAX_PID) should return None"
);
if !is_root() {
eprintln!("Skipping OCI integration part of test_oci_pid_start_time: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!(
"Skipping OCI integration part of test_oci_pid_start_time: alpine-rootfs not found"
);
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/sleep", "30"]);
let id = format!("test-oci-pst-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
let state_path = format!("/run/pelagos/{}/state.json", id);
let raw = std::fs::read_to_string(&state_path).expect("state.json not found");
let state_json: serde_json::Value =
serde_json::from_str(&raw).expect("state.json is not valid JSON");
let stored = state_json.get("pidStartTime").and_then(|v| v.as_u64());
assert!(
stored.is_some(),
"pidStartTime missing from state.json: {}",
raw
);
let pid = state_json["pid"].as_i64().expect("pid field") as libc::pid_t;
let live_starttime = read_pid_start_time(pid);
assert_eq!(
stored, live_starttime,
"state.json pidStartTime ({:?}) != /proc/{}/stat starttime ({:?})",
stored, pid, live_starttime
);
let _ = run_pelagos(&["kill", &id, "SIGKILL"]);
std::thread::sleep(std::time::Duration::from_millis(100));
let _ = run_pelagos(&["delete", &id]);
}
#[test]
fn test_oci_pidfd_mgmt_socket() {
use pelagos::oci::{is_pidfd_alive, mgmt_sock_path};
if !is_root() {
eprintln!("Skipping test_oci_pidfd_mgmt_socket: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_pidfd_mgmt_socket: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/sleep", "30"]);
let id = format!("test-oci-pidfd-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
std::thread::sleep(std::time::Duration::from_millis(200));
let mgmt = mgmt_sock_path(&id);
assert!(
mgmt.exists(),
"mgmt.sock not found at {} — shim pidfd setup failed",
mgmt.display()
);
let conn = unsafe {
let fd = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0);
assert!(fd >= 0, "socket() failed");
let path_bytes = mgmt.to_str().unwrap().as_bytes();
let mut addr: libc::sockaddr_un = std::mem::zeroed();
addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
std::ptr::copy_nonoverlapping(
path_bytes.as_ptr() as *const libc::c_char,
addr.sun_path.as_mut_ptr(),
path_bytes.len(),
);
let r = libc::connect(
fd,
&addr as *const libc::sockaddr_un as *const libc::sockaddr,
std::mem::size_of::<libc::sockaddr_un>() as libc::socklen_t,
);
assert_eq!(r, 0, "connect to mgmt.sock failed");
fd
};
let pidfd = unsafe {
let cmsg_space = libc::CMSG_SPACE(std::mem::size_of::<i32>() as libc::c_uint) as usize;
let mut cmsg_buf = vec![0u8; cmsg_space];
let mut iov_buf = [0u8; 1];
let mut iov = libc::iovec {
iov_base: iov_buf.as_mut_ptr() as *mut libc::c_void,
iov_len: 1,
};
let mut msg: libc::msghdr = std::mem::zeroed();
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_space as _;
let r = libc::recvmsg(conn, &mut msg, 0);
assert!(r >= 0, "recvmsg failed");
let cmsg = libc::CMSG_FIRSTHDR(&msg);
assert!(!cmsg.is_null(), "no SCM_RIGHTS message received");
*(libc::CMSG_DATA(cmsg) as *const i32)
};
unsafe { libc::close(conn) };
assert!(pidfd >= 0, "received invalid pidfd {}", pidfd);
assert!(
is_pidfd_alive(pidfd),
"is_pidfd_alive returned false while container is still running"
);
let _ = run_pelagos(&["kill", &id, "SIGKILL"]);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
if !is_pidfd_alive(pidfd) {
break;
}
if std::time::Instant::now() > deadline {
unsafe { libc::close(pidfd) };
let _ = run_pelagos(&["delete", &id]);
panic!("pidfd still reports alive 5s after SIGKILL");
}
}
assert!(
!is_pidfd_alive(pidfd),
"is_pidfd_alive returned true after container was killed"
);
unsafe { libc::close(pidfd) };
let _ = run_pelagos(&["delete", &id]);
}
#[test]
fn test_oci_pidfd_state_liveness() {
if !is_root() {
eprintln!("Skipping test_oci_pidfd_state_liveness: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_pidfd_state_liveness: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let bundle = make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/true"]);
let id = format!("test-oci-pidfd-sl-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_pelagos(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
let _ = run_pelagos(&["delete", &id]);
panic!(
"container did not reach 'stopped' within 5s; last state: {}",
stdout
);
}
}
let (_, stderr, ok) = run_pelagos(&["delete", &id]);
assert!(ok, "pelagos delete failed: {}", stderr);
}
#[test]
fn test_oci_bundle_mounts() {
if !is_root() {
eprintln!("Skipping test_oci_bundle_mounts: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_bundle_mounts: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/sh", "-c", "echo hello > /scratch/test.txt && cat /scratch/test.txt"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"mounts": [
{
"destination": "/scratch",
"type": "tmpfs",
"source": "tmpfs",
"options": []
}
],
"linux": {
"namespaces": [
{"type": "mount"},
{"type": "uts"},
{"type": "pid"}
]
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let bundle = bundle_dir.path();
let id = format!("test-oci-mnt-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle.to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_pelagos(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
panic!("container did not stop within 4 seconds");
}
}
let (_, stderr, ok) = run_pelagos(&["delete", &id]);
assert!(ok, "pelagos delete failed: {}", stderr);
assert!(
stderr.is_empty() || !stderr.contains("error"),
"unexpected error: {}",
stderr
);
}
#[test]
fn test_oci_capabilities() {
if !is_root() {
eprintln!("Skipping test_oci_capabilities: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_capabilities: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/usr/bin/id"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"capabilities": {
"bounding": ["CAP_CHOWN"],
"effective": ["CAP_CHOWN"],
"permitted": ["CAP_CHOWN"],
"inheritable": []
}
},
"linux": {
"namespaces": [
{"type": "mount"},
{"type": "uts"},
{"type": "pid"}
]
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-cap-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle_dir.path().to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_pelagos(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
run_pelagos(&["delete", &id]);
panic!("container did not stop within 5 seconds");
}
}
let (_, stderr, ok) = run_pelagos(&["delete", &id]);
assert!(ok, "pelagos delete failed: {}", stderr);
}
#[test]
fn test_oci_masked_readonly_paths() {
if !is_root() {
eprintln!("Skipping test_oci_masked_readonly_paths: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_masked_readonly_paths: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/sh", "-c",
"[ $(wc -c < /proc/kcore) -eq 0 ] && ! touch /sys/kernel/test 2>/dev/null && echo ok"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [
{"type": "mount"},
{"type": "uts"},
{"type": "pid"}
],
"maskedPaths": ["/proc/kcore"],
"readonlyPaths": ["/sys/kernel"]
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-mrp-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle_dir.path().to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_pelagos(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
run_pelagos(&["delete", &id]);
panic!("container did not stop within 5 seconds");
}
}
let (_, stderr, ok) = run_pelagos(&["delete", &id]);
assert!(ok, "pelagos delete failed: {}", stderr);
}
#[test]
fn test_oci_resources() {
if !is_root() {
eprintln!("Skipping test_oci_resources: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_resources: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/sh", "-c",
"cat /sys/fs/cgroup/memory.max && cat /sys/fs/cgroup/pids.max"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [{"type": "mount"}, {"type": "uts"}, {"type": "pid"}],
"resources": {
"memory": {"limit": 67108864},
"pids": {"limit": 50}
}
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-res-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 5);
}
#[test]
fn test_oci_resources_extended() {
if !is_root() {
eprintln!("Skipping test_oci_resources_extended: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_resources_extended: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/sh", "-c", "exit 0"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [{"type": "mount"}, {"type": "uts"}, {"type": "pid"}],
"resources": {
"memory": {
"limit": 67108864,
"swap": 134217728,
"reservation": 33554432
},
"cpu": {
"shares": 512,
"cpus": "0",
"mems": "0"
},
"pids": {"limit": 64},
"blockIO": {"weight": 100},
"devices": [
{"allow": true, "type": "a", "access": "rwm"},
{"allow": false, "type": "c", "major": 5, "minor": 1, "access": "rwm"}
],
"network": {"classID": 65537, "priorities": [{"name": "eth0", "priority": 10}]}
}
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-res-ext-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 5);
}
#[test]
fn test_oci_rlimits() {
if !is_root() {
eprintln!("Skipping test_oci_rlimits: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_rlimits: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/sh", "-c", "ulimit -n"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"rlimits": [{"type": "RLIMIT_NOFILE", "hard": 128, "soft": 128}]
},
"linux": {
"namespaces": [{"type": "mount"}, {"type": "uts"}, {"type": "pid"}]
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-rl-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 5);
}
#[test]
fn test_oci_sysctl() {
if !is_root() {
eprintln!("Skipping test_oci_sysctl: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_sysctl: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/sh", "-c",
"cat /proc/sys/kernel/domainname | grep -q testdomain"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [{"type": "mount"}, {"type": "uts"}, {"type": "pid"}],
"sysctl": {"kernel.domainname": "testdomain.local"}
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-sc-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 5);
}
#[test]
fn test_oci_hooks() {
if !is_root() {
eprintln!("Skipping test_oci_hooks: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_hooks: alpine-rootfs not found");
return;
}
};
let hooks_dir = tempfile::tempdir().expect("tempdir for hooks");
let prestart_marker = hooks_dir.path().join("prestart_ran");
let poststop_marker = hooks_dir.path().join("poststop_ran");
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = format!(
r#"{{
"ociVersion": "1.0.2",
"root": {{"path": "rootfs"}},
"process": {{
"args": ["/bin/true"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
}},
"linux": {{
"namespaces": [{{"type": "mount"}}, {{"type": "uts"}}, {{"type": "pid"}}]
}},
"hooks": {{
"prestart": [{{"path": "/bin/sh", "args": ["/bin/sh", "-c", "touch {prestart}"]}}],
"poststop": [{{"path": "/bin/sh", "args": ["/bin/sh", "-c", "touch {poststop}"]}}]
}}
}}"#,
prestart = prestart_marker.display(),
poststop = poststop_marker.display(),
);
std::fs::write(bundle_dir.path().join("config.json"), &config).unwrap();
let id = format!("test-oci-hk-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&["create", &id, bundle_dir.path().to_str().unwrap()]);
assert!(ok, "pelagos create failed: {}", stderr);
assert!(prestart_marker.exists(), "prestart hook did not run");
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_pelagos(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
run_pelagos(&["delete", &id]);
panic!("container did not stop within 5 seconds");
}
}
let (_, stderr, ok) = run_pelagos(&["delete", &id]);
assert!(ok, "pelagos delete failed: {}", stderr);
assert!(poststop_marker.exists(), "poststop hook did not run");
}
#[test]
fn test_oci_seccomp() {
if !is_root() {
eprintln!("Skipping test_oci_seccomp: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_seccomp: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/echo", "hello"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [{"type": "mount"}, {"type": "uts"}, {"type": "pid"}],
"seccomp": {
"defaultAction": "SCMP_ACT_ALLOW",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{"names": ["ptrace", "personality", "bpf"], "action": "SCMP_ACT_ERRNO"}
]
}
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-sec-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 5);
}
#[test]
fn test_oci_kernel_mounts() {
if !is_root() {
eprintln!("Skipping test_oci_kernel_mounts: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_kernel_mounts: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&rootfs, bundle_dir.path().join("rootfs")).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/ls", "/proc/self"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [{"type": "mount"}, {"type": "uts"}, {"type": "pid"}]
},
"mounts": [
{"destination": "/proc", "type": "proc", "source": "proc",
"options": ["nosuid","noexec","nodev"]},
{"destination": "/sys", "type": "sysfs", "source": "sysfs",
"options": ["nosuid","noexec","nodev","ro"]},
{"destination": "/dev", "type": "tmpfs", "source": "tmpfs",
"options": ["nosuid","strictatime","mode=755","size=65536k"]},
{"destination": "/dev/pts", "type": "devpts", "source": "devpts",
"options": ["nosuid","noexec","gid=5","mode=0620"]},
{"destination": "/dev/mqueue", "type": "mqueue", "source": "mqueue",
"options": ["nosuid","noexec","nodev"]}
]
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-kmnt-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&[
"create",
"--bundle",
bundle_dir.path().to_str().unwrap(),
&id,
]);
assert!(ok, "pelagos create (kernel mounts) failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_pelagos(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
run_pelagos(&["delete", &id]);
panic!("container with kernel mounts did not stop within 5s");
}
}
run_pelagos(&["delete", &id]);
}
#[test]
fn test_oci_create_bundle_flag() {
if !is_root() {
eprintln!("Skipping test_oci_create_bundle_flag: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_create_bundle_flag: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/sleep", "2"]);
let id = format!("test-oci-bflag-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&[
"create",
"--bundle",
bundle_dir.path().to_str().unwrap(),
&id,
]);
assert!(ok, "pelagos create --bundle failed: {}", stderr);
let (stdout, _, _) = run_pelagos(&["state", &id]);
assert!(
stdout.contains("\"created\""),
"expected created state, got: {}",
stdout
);
run_pelagos(&["kill", &id, "SIGKILL"]);
std::thread::sleep(std::time::Duration::from_millis(300));
run_pelagos(&["delete", &id]);
}
#[test]
fn test_oci_create_pid_file() {
if !is_root() {
eprintln!("Skipping test_oci_create_pid_file: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_create_pid_file: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
make_oci_bundle(bundle_dir.path(), &rootfs, &["/bin/sleep", "5"]);
let id = format!("test-oci-pidf-{}", std::process::id());
let pid_file = bundle_dir.path().join("container.pid");
let (_, stderr, ok) = run_pelagos(&[
"create",
"--bundle",
bundle_dir.path().to_str().unwrap(),
"--pid-file",
pid_file.to_str().unwrap(),
&id,
]);
assert!(ok, "pelagos create --pid-file failed: {}", stderr);
let pid_str = std::fs::read_to_string(&pid_file).expect("pid file not written");
let pid: i32 = pid_str
.trim()
.parse()
.expect("pid file contains non-integer");
assert!(pid > 1, "pid file contains invalid PID {}", pid);
let (state_out, _, _) = run_pelagos(&["state", &id]);
assert!(
state_out.contains(&pid.to_string()),
"pid file PID {} not found in state: {}",
pid,
state_out
);
run_pelagos(&["kill", &id, "SIGKILL"]);
std::thread::sleep(std::time::Duration::from_millis(300));
run_pelagos(&["delete", &id]);
}
#[test]
fn test_oci_rootfs_propagation() {
if !is_root() {
eprintln!("Skipping test_oci_rootfs_propagation: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_rootfs_propagation: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"args": ["/bin/echo", "ok"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"rootfsPropagation": "private",
"namespaces": [
{"type": "mount"},
{"type": "uts"},
{"type": "pid"}
]
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-prop-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 10);
}
#[test]
fn test_oci_cgroups_path() {
if !is_root() {
eprintln!("Skipping test_oci_cgroups_path: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_cgroups_path: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let unique_cg = format!("pelagos-oci-test-{}", std::process::id());
let config = format!(
r#"{{
"ociVersion": "1.0.2",
"root": {{"path": "rootfs"}},
"process": {{
"args": ["/bin/echo", "ok"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
}},
"linux": {{
"cgroupsPath": "{}",
"namespaces": [
{{"type": "mount"}},
{{"type": "uts"}},
{{"type": "pid"}}
]
}}
}}"#,
unique_cg
);
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let id = format!("test-oci-cgpath-{}", std::process::id());
oci_run_to_completion(&id, bundle_dir.path(), 10);
}
#[test]
fn test_oci_create_container_hook_in_ns() {
if !is_root() {
eprintln!("Skipping test_oci_create_container_hook_in_ns: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_create_container_hook_in_ns: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let hook_out = bundle_dir.path().join("hook_ns.txt");
let hook_script = bundle_dir.path().join("record_ns.sh");
std::fs::write(
&hook_script,
format!(
"#!/bin/sh\nstat -Lc %i /proc/self/ns/uts > {}\n",
hook_out.display()
),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook_script, std::fs::Permissions::from_mode(0o755)).unwrap();
let config = format!(
r#"{{
"ociVersion": "1.0.2",
"root": {{"path": "rootfs"}},
"process": {{
"args": ["/bin/sleep", "5"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
}},
"hooks": {{
"createContainer": [
{{"path": "{}"}}
]
}},
"linux": {{
"namespaces": [
{{"type": "mount"}},
{{"type": "uts"}},
{{"type": "pid"}}
]
}}
}}"#,
hook_script.display()
);
std::fs::write(bundle_dir.path().join("config.json"), &config).unwrap();
let id = format!("test-oci-cchook-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&[
"create",
"--bundle",
bundle_dir.path().to_str().unwrap(),
&id,
]);
assert!(ok, "pelagos create failed: {}", stderr);
assert!(hook_out.exists(), "hook did not produce output file");
let hook_inode: u64 = std::fs::read_to_string(&hook_out)
.expect("read hook output")
.trim()
.parse()
.expect("hook output not a number");
let host_uts_meta = std::fs::metadata("/proc/1/ns/uts").expect("stat /proc/1/ns/uts");
use std::os::unix::fs::MetadataExt;
let host_inode = host_uts_meta.ino();
assert_ne!(
hook_inode, host_inode,
"createContainer hook ran in host UTS namespace (inode {}), expected container ns",
hook_inode
);
run_pelagos(&["kill", &id, "SIGKILL"]);
std::thread::sleep(std::time::Duration::from_millis(300));
run_pelagos(&["delete", &id]);
}
#[test]
fn test_oci_start_container_hook_in_ns() {
if !is_root() {
eprintln!("Skipping test_oci_start_container_hook_in_ns: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_start_container_hook_in_ns: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let hook_out = bundle_dir.path().join("start_hook_ns.txt");
let hook_script = bundle_dir.path().join("record_start_ns.sh");
std::fs::write(
&hook_script,
format!(
"#!/bin/sh\nstat -Lc %i /proc/self/ns/uts > {}\n",
hook_out.display()
),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook_script, std::fs::Permissions::from_mode(0o755)).unwrap();
let config = format!(
r#"{{
"ociVersion": "1.0.2",
"root": {{"path": "rootfs"}},
"process": {{
"args": ["/bin/echo", "ok"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
}},
"hooks": {{
"startContainer": [
{{"path": "{}"}}
]
}},
"linux": {{
"namespaces": [
{{"type": "mount"}},
{{"type": "uts"}},
{{"type": "pid"}}
]
}}
}}"#,
hook_script.display()
);
std::fs::write(bundle_dir.path().join("config.json"), &config).unwrap();
let id = format!("test-oci-schook-{}", std::process::id());
let (_, stderr, ok) = run_pelagos(&[
"create",
"--bundle",
bundle_dir.path().to_str().unwrap(),
&id,
]);
assert!(ok, "pelagos create failed: {}", stderr);
let (_, stderr, ok) = run_pelagos(&["start", &id]);
assert!(ok, "pelagos start failed: {}", stderr);
assert!(
hook_out.exists(),
"startContainer hook did not produce output file"
);
let hook_inode: u64 = std::fs::read_to_string(&hook_out)
.expect("read hook output")
.trim()
.parse()
.expect("hook output not a number");
let host_uts_meta = std::fs::metadata("/proc/1/ns/uts").expect("stat /proc/1/ns/uts");
use std::os::unix::fs::MetadataExt;
let host_inode = host_uts_meta.ino();
assert_ne!(
hook_inode, host_inode,
"startContainer hook ran in host UTS namespace (inode {}), expected container ns",
hook_inode
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
let (stdout, _, _) = run_pelagos(&["state", &id]);
if stdout.contains("\"stopped\"") {
break;
}
if std::time::Instant::now() > deadline {
break;
}
}
run_pelagos(&["delete", &id]);
}
}
mod rootless {
use super::*;
fn is_pasta_available() -> bool {
pelagos::network::is_pasta_available()
}
#[test]
fn test_rootless_basic() {
if is_root() {
eprintln!("Skipping test_rootless_basic: must run as non-root (no sudo)");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_rootless_basic: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/bin/ash")
.args(["-c", "id"])
.env("PATH", ALPINE_PATH)
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("rootless spawn failed");
let (status, stdout, _stderr) = child.wait_with_output().expect("wait failed");
assert!(status.success(), "rootless container exited non-zero");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("uid=0"),
"expected uid=0 inside rootless container, got: {}",
out
);
}
#[test]
fn test_rootless_loopback() {
if is_root() {
eprintln!("Skipping test_rootless_loopback: must run as non-root (no sudo)");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_rootless_loopback: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/bin/ash")
.args(["-c", "ip addr show lo | grep -q 'LOOPBACK,UP' && echo ok"])
.env("PATH", ALPINE_PATH)
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Loopback)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("rootless loopback spawn failed");
let (status, stdout, _) = child.wait_with_output().expect("wait failed");
assert!(status.success(), "rootless loopback container failed");
assert!(String::from_utf8_lossy(&stdout).contains("ok"));
}
#[test]
fn test_rootless_bridge_rejected() {
if is_root() {
eprintln!("Skipping test_rootless_bridge_rejected: must run as non-root (no sudo)");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_rootless_bridge_rejected: alpine-rootfs not found");
return;
}
};
let result = Command::new("/bin/echo")
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT)
.with_network(NetworkMode::Bridge)
.spawn();
match result {
Ok(_) => panic!("expected bridge networking to fail in rootless mode"),
Err(e) => {
let err_msg = format!("{}", e);
assert!(
err_msg.contains("rootless") || err_msg.contains("root"),
"error message should mention rootless/root: {}",
err_msg
);
}
}
}
#[test]
fn test_user_namespace_explicit() {
if !is_root() {
eprintln!("Skipping test_user_namespace_explicit: requires root");
return;
}
let mut child = Command::new("/usr/bin/id")
.with_namespaces(Namespace::USER)
.with_uid_maps(&[UidMap {
inside: 0,
outside: 0,
count: 1,
}])
.with_gid_maps(&[GidMap {
inside: 0,
outside: 0,
count: 1,
}])
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("user namespace spawn failed");
let (status, stdout, _) = child.wait_with_output().expect("wait failed");
assert!(status.success(), "user namespace container exited non-zero");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("uid=0"),
"expected uid=0 inside user namespace, got: {}",
out
);
}
#[test]
fn test_pasta_interface_exists() {
if is_root() {
eprintln!("Skipping test_pasta_interface_exists: pasta is designed for rootless mode");
return;
}
if !is_pasta_available() {
eprintln!("Skipping test_pasta_interface_exists: pasta not installed");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_pasta_interface_exists: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 1 && ip addr show"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_proc_mount()
.with_network(NetworkMode::Pasta)
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout, _) = child.wait_with_output().expect("wait failed");
assert!(status.success(), "container exited non-zero");
let out = String::from_utf8_lossy(&stdout);
let has_non_loopback = out
.lines()
.any(|l| l.contains(": ") && !l.contains("lo:") && !l.contains(" lo@"));
assert!(
has_non_loopback,
"expected a non-loopback TAP interface from pasta, got:\n{}",
out
);
let has_tap_ip = out.lines().any(|l| {
let l = l.trim();
l.starts_with("inet ") && !l.starts_with("inet 127.")
});
assert!(
has_tap_ip,
"expected inet address on pasta TAP (--config-net), got:\n{}",
out
);
}
#[test]
fn test_pasta_rootless() {
if is_root() {
eprintln!("Skipping test_pasta_rootless: must run as non-root (no sudo)");
return;
}
if !is_pasta_available() {
eprintln!("Skipping test_pasta_rootless: pasta not installed");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_pasta_rootless: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/bin/ash")
.args(["-c", "sleep 1 && ip addr show"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_proc_mount()
.with_network(NetworkMode::Pasta)
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout, _) = child.wait_with_output().expect("wait failed");
assert!(status.success(), "container exited non-zero");
let out = String::from_utf8_lossy(&stdout);
let has_non_loopback = out
.lines()
.any(|l| l.contains(": ") && !l.contains("lo:") && !l.contains(" lo@"));
assert!(
has_non_loopback,
"expected a non-loopback TAP interface from pasta in rootless mode, got:\n{}",
out
);
let has_tap_ip = out.lines().any(|l| {
let l = l.trim();
l.starts_with("inet ") && !l.starts_with("inet 127.")
});
assert!(
has_tap_ip,
"expected inet address on pasta TAP in rootless mode, got:\n{}",
out
);
}
#[test]
fn test_pasta_connectivity() {
if is_root() {
eprintln!("Skipping test_pasta_connectivity: pasta is designed for rootless mode");
return;
}
if !is_pasta_available() {
eprintln!("Skipping test_pasta_connectivity: pasta not installed");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_pasta_connectivity: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"wget -q -T 5 --spider http://1.1.1.1/ && echo CONNECTED",
])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_proc_mount()
.with_network(NetworkMode::Pasta)
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout, stderr) = child.wait_with_output().expect("wait failed");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(),
"pasta connectivity test failed (is outbound internet available?)\nstdout: {}\nstderr: {}", out, err);
assert!(
out.contains("CONNECTED"),
"wget succeeded but CONNECTED marker missing:\n{}",
out
);
}
#[test]
fn test_pasta_dns() {
if is_root() {
eprintln!("Skipping test_pasta_dns: pasta is designed for rootless mode");
return;
}
if !is_pasta_available() {
eprintln!("Skipping test_pasta_dns: pasta not installed");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_pasta_dns: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/usr/bin/nslookup")
.args(["1.1.1.1"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_proc_mount()
.with_network(NetworkMode::Pasta)
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout, stderr) = child.wait_with_output().expect("wait failed");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(
out.contains("Server") || out.contains("server") || status.success(),
"pasta DNS not configured — resolv.conf missing or empty?\nstdout: {}\nstderr: {}",
out,
err
);
assert!(
!err.contains("bad address"),
"pasta DNS lookup got 'bad address' — resolv.conf not injected\nstdout: {}\nstderr: {}",
out,
err
);
}
}
mod linking {
use super::*;
#[test]
#[serial(nat)]
fn test_container_link_hosts() {
if !is_root() {
eprintln!("Skipping test_container_link_hosts: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_container_link_hosts: alpine-rootfs not found");
return;
};
let mut child_a = Command::new("/bin/sleep")
.args(["60"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container A");
let ip_a = child_a
.container_ip()
.expect("container A should have bridge IP");
let state_dir = std::path::Path::new("/run/pelagos/containers/link-test-a");
std::fs::create_dir_all(state_dir).unwrap();
let state_json = format!(
r#"{{"name":"link-test-a","rootfs":"test","status":"running","pid":{},"watcher_pid":0,"started_at":"2026-01-01T00:00:00Z","exit_code":null,"command":["sleep","60"],"stdout_log":null,"stderr_log":null,"bridge_ip":"{}"}}"#,
child_a.pid(),
ip_a
);
std::fs::write(state_dir.join("state.json"), &state_json).unwrap();
let mut child_b = Command::new("/bin/cat")
.args(["/etc/hosts"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.with_link("link-test-a")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container B");
let (status, stdout, _) = child_b.wait_with_output().expect("wait B");
let out = String::from_utf8_lossy(&stdout);
unsafe {
libc::kill(child_a.pid(), libc::SIGKILL);
}
let _ = child_a.wait();
let _ = std::fs::remove_dir_all(state_dir);
assert!(status.success(), "Container B should exit successfully");
assert!(
out.contains(&ip_a) && out.contains("link-test-a"),
"B's /etc/hosts should contain A's IP ({}) and name, got:\n{}",
ip_a,
out
);
}
#[test]
#[serial(nat)]
fn test_container_link_alias() {
if !is_root() {
eprintln!("Skipping test_container_link_alias: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_container_link_alias: alpine-rootfs not found");
return;
};
let mut child_a = Command::new("/bin/sleep")
.args(["60"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container A");
let ip_a = child_a
.container_ip()
.expect("container A should have bridge IP");
let state_dir = std::path::Path::new("/run/pelagos/containers/link-alias-a");
std::fs::create_dir_all(state_dir).unwrap();
let state_json = format!(
r#"{{"name":"link-alias-a","rootfs":"test","status":"running","pid":{},"watcher_pid":0,"started_at":"2026-01-01T00:00:00Z","exit_code":null,"command":["sleep","60"],"stdout_log":null,"stderr_log":null,"bridge_ip":"{}"}}"#,
child_a.pid(),
ip_a
);
std::fs::write(state_dir.join("state.json"), &state_json).unwrap();
let mut child_b = Command::new("/bin/cat")
.args(["/etc/hosts"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.with_link_alias("link-alias-a", "db")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container B");
let (status, stdout, _) = child_b.wait_with_output().expect("wait B");
let out = String::from_utf8_lossy(&stdout);
unsafe {
libc::kill(child_a.pid(), libc::SIGKILL);
}
let _ = child_a.wait();
let _ = std::fs::remove_dir_all(state_dir);
assert!(status.success(), "Container B should exit successfully");
assert!(
out.contains(&ip_a) && out.contains("db"),
"B's /etc/hosts should contain A's IP ({}) and alias 'db', got:\n{}",
ip_a,
out
);
assert!(
out.contains("link-alias-a"),
"B's /etc/hosts should also contain A's original name, got:\n{}",
out
);
}
#[test]
#[serial(nat)]
fn test_container_link_ping() {
if !is_root() {
eprintln!("Skipping test_container_link_ping: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_container_link_ping: alpine-rootfs not found");
return;
};
let mut child_a = Command::new("/bin/sleep")
.args(["60"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container A");
let ip_a = child_a
.container_ip()
.expect("container A should have bridge IP");
let state_dir = std::path::Path::new("/run/pelagos/containers/link-ping-a");
std::fs::create_dir_all(state_dir).unwrap();
let state_json = format!(
r#"{{"name":"link-ping-a","rootfs":"test","status":"running","pid":{},"watcher_pid":0,"started_at":"2026-01-01T00:00:00Z","exit_code":null,"command":["sleep","60"],"stdout_log":null,"stderr_log":null,"bridge_ip":"{}"}}"#,
child_a.pid(),
ip_a
);
std::fs::write(state_dir.join("state.json"), &state_json).unwrap();
let mut child_b = Command::new("/bin/ping")
.args(["-c1", "-W2", "link-ping-a"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.with_link("link-ping-a")
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container B");
let (status, stdout, stderr) = child_b.wait_with_output().expect("wait B");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
unsafe {
libc::kill(child_a.pid(), libc::SIGKILL);
}
let _ = child_a.wait();
let _ = std::fs::remove_dir_all(state_dir);
assert!(
status.success(),
"ping from B to A by name should succeed.\nstdout: {}\nstderr: {}",
out,
err
);
}
#[test]
#[serial(nat)]
fn test_container_link_tcp() {
if !is_root() {
eprintln!("Skipping test_container_link_tcp: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_container_link_tcp: alpine-rootfs not found");
return;
};
let mut child_a = Command::new("/bin/sh")
.args(["-c", "echo HELLO_FROM_A | nc -l -p 8080"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container A");
let ip_a = child_a
.container_ip()
.expect("container A should have bridge IP");
let state_dir = std::path::Path::new("/run/pelagos/containers/link-tcp-a");
std::fs::create_dir_all(state_dir).unwrap();
let state_json = format!(
r#"{{"name":"link-tcp-a","rootfs":"test","status":"running","pid":{},"watcher_pid":0,"started_at":"2026-01-01T00:00:00Z","exit_code":null,"command":["/bin/sh"],"stdout_log":null,"stderr_log":null,"bridge_ip":"{}"}}"#,
child_a.pid(),
ip_a
);
std::fs::write(state_dir.join("state.json"), &state_json).unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let mut child_b = Command::new("/bin/sh")
.args(["-c", "nc -w 2 link-tcp-a 8080"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.with_link("link-tcp-a")
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container B");
let (status_b, stdout_b, stderr_b) = child_b.wait_with_output().expect("wait B");
let out = String::from_utf8_lossy(&stdout_b);
let err = String::from_utf8_lossy(&stderr_b);
unsafe {
libc::kill(child_a.pid(), libc::SIGKILL);
}
let _ = child_a.wait();
let _ = std::fs::remove_dir_all(state_dir);
assert!(
status_b.success(),
"Container B should connect to A via TCP successfully.\nstdout: {}\nstderr: {}",
out,
err
);
assert!(
out.contains("HELLO_FROM_A"),
"B should receive 'HELLO_FROM_A' from A via TCP, got:\nstdout: {}\nstderr: {}",
out,
err
);
}
#[test]
#[serial(nat)]
fn test_container_link_missing() {
if !is_root() {
eprintln!("Skipping test_container_link_missing: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_container_link_missing: alpine-rootfs not found");
return;
};
let result = Command::new("/bin/true")
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_chroot(&rootfs)
.with_proc_mount()
.with_link("nonexistent-container-xyz")
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn();
match result {
Ok(_) => panic!("spawn should fail when linked container doesn't exist"),
Err(e) => {
let err_msg = format!("{}", e);
assert!(
err_msg.contains("nonexistent-container-xyz"),
"error should mention the missing container name, got: {}",
err_msg
);
}
}
}
}
mod images {
use super::*;
fn copy_rootfs(rootfs: &std::path::Path, dest: &std::path::Path) {
let status = std::process::Command::new("rsync")
.args(["-a", "--exclude=/sys", "--exclude=/proc", "--exclude=/dev"])
.arg(rootfs.to_str().unwrap().to_string() + "/")
.arg(dest.to_str().unwrap().to_string() + "/")
.status()
.expect("rsync rootfs to layer (is rsync installed?)");
assert!(status.success(), "rsync should succeed");
std::fs::create_dir_all(dest.join("proc")).unwrap();
std::fs::create_dir_all(dest.join("sys")).unwrap();
std::fs::create_dir_all(dest.join("dev")).unwrap();
}
#[test]
#[serial]
fn test_layer_extraction() {
if !is_root() {
eprintln!("Skipping test_layer_extraction: requires root");
return;
}
use pelagos::image;
let tmp_dir = tempfile::tempdir().expect("create tempdir");
let tar_gz_path = tmp_dir.path().join("layer.tar.gz");
{
let file = std::fs::File::create(&tar_gz_path).expect("create tar.gz");
let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
let mut builder = tar::Builder::new(gz);
let data = b"hello from layer";
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, "test-file.txt", &data[..])
.unwrap();
let data2 = b"nested content";
let mut header2 = tar::Header::new_gnu();
header2.set_size(data2.len() as u64);
header2.set_mode(0o644);
header2.set_cksum();
builder
.append_data(&mut header2, "subdir/nested.txt", &data2[..])
.unwrap();
builder.finish().unwrap();
}
let digest = "sha256:test_layer_extraction_deadbeef";
let layer_path = image::layer_dir(digest);
let _ = std::fs::remove_dir_all(&layer_path);
let result = image::extract_layer(digest, &tar_gz_path);
assert!(
result.is_ok(),
"extract_layer should succeed: {:?}",
result.err()
);
let extracted = result.unwrap();
assert!(
extracted.join("test-file.txt").exists(),
"test-file.txt should exist"
);
assert_eq!(
std::fs::read_to_string(extracted.join("test-file.txt")).unwrap(),
"hello from layer"
);
assert!(
extracted.join("subdir/nested.txt").exists(),
"subdir/nested.txt should exist"
);
assert_eq!(
std::fs::read_to_string(extracted.join("subdir/nested.txt")).unwrap(),
"nested content"
);
let _ = std::fs::remove_dir_all(&layer_path);
}
#[test]
#[serial]
fn test_multi_layer_overlay_merge() {
if !is_root() {
eprintln!("Skipping test_multi_layer_overlay_merge: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_multi_layer_overlay_merge: alpine-rootfs not found");
return;
};
let bottom = tempfile::tempdir().expect("bottom layer dir");
let top = tempfile::tempdir().expect("top layer dir");
copy_rootfs(&rootfs, bottom.path());
std::fs::write(bottom.path().join("layer-bottom"), "bottom").unwrap();
std::fs::write(top.path().join("layer-top"), "top").unwrap();
let layers = vec![top.path().to_path_buf(), bottom.path().to_path_buf()];
let mut child = Command::new("/bin/sh")
.args(["-c", "cat /layer-bottom && echo --- && cat /layer-top"])
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn with image layers");
let (status, stdout, stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(), "container should exit 0, stderr: {}", err);
assert!(
out.contains("bottom"),
"should see bottom layer file, got: {}",
out
);
assert!(
out.contains("top"),
"should see top layer file, got: {}",
out
);
}
#[test]
#[serial]
fn test_multi_layer_overlay_shadow() {
if !is_root() {
eprintln!("Skipping test_multi_layer_overlay_shadow: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_multi_layer_overlay_shadow: alpine-rootfs not found");
return;
};
let bottom = tempfile::tempdir().expect("bottom layer dir");
let top = tempfile::tempdir().expect("top layer dir");
copy_rootfs(&rootfs, bottom.path());
std::fs::write(bottom.path().join("shadow-file"), "bottom-value").unwrap();
std::fs::write(top.path().join("shadow-file"), "top-value").unwrap();
let layers = vec![top.path().to_path_buf(), bottom.path().to_path_buf()];
let mut child = Command::new("/bin/cat")
.args(["/shadow-file"])
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn with image layers");
let (status, stdout, stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(), "container should exit 0, stderr: {}", err);
assert_eq!(
out.trim(),
"top-value",
"top layer should shadow bottom, got: {}",
out
);
}
#[test]
#[serial]
fn test_image_layers_cleanup() {
if !is_root() {
eprintln!("Skipping test_image_layers_cleanup: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_image_layers_cleanup: alpine-rootfs not found");
return;
};
let layer = tempfile::tempdir().expect("layer dir");
copy_rootfs(&rootfs, layer.path());
let layers = vec![layer.path().to_path_buf()];
let mut child = Command::new("/bin/true")
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn");
let merged = child
.overlay_merged_dir()
.expect("should have merged dir")
.to_path_buf();
let overlay_base = merged
.parent()
.expect("merged should have parent")
.to_path_buf();
assert!(
overlay_base.exists(),
"overlay base dir should exist before wait"
);
let status = child.wait().expect("wait");
assert!(status.success(), "container should exit 0");
assert!(
!overlay_base.exists(),
"overlay base dir should be cleaned up after wait: {:?}",
overlay_base
);
}
#[test]
#[ignore]
#[serial]
fn test_pull_and_run_real_image() {
if !is_root() {
eprintln!("Skipping test_pull_and_run_real_image: requires root");
return;
}
use pelagos::image;
let reference = "docker.io/library/alpine:3.21";
let pull_status = std::process::Command::new(env!("CARGO_BIN_EXE_pelagos"))
.args(["image", "pull", "alpine:3.21"])
.status()
.expect("failed to run pelagos image pull");
assert!(pull_status.success(), "pelagos image pull should succeed");
let manifest =
image::load_image(reference).expect("image manifest should be loadable after pull");
let layers = image::layer_dirs(&manifest);
assert!(!layers.is_empty(), "alpine should have at least one layer");
let mut child = Command::new("/bin/sh")
.args(["-c", "cat /etc/alpine-release"])
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn from real image");
let (status, stdout, stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout).trim().to_string();
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(), "container should exit 0, stderr: {}", err);
assert!(
out.chars().next().is_some_and(|c| c.is_ascii_digit()) && out.contains('.'),
"expected Alpine version string, got: '{}'",
out
);
println!("Alpine version from real image: {}", out);
let _ = image::remove_image(reference);
}
#[test]
#[serial]
fn test_pull_does_not_retain_blob() {
if !is_root() {
eprintln!("Skipping test_pull_does_not_retain_blob: requires root");
return;
}
use pelagos::image;
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
{
let gz = flate2::write::GzEncoder::new(
std::fs::File::create(tmp.path()).unwrap(),
flate2::Compression::default(),
);
let mut builder = tar::Builder::new(gz);
let data = b"blob-test";
let mut hdr = tar::Header::new_gnu();
hdr.set_size(data.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
builder
.append_data(&mut hdr, "blob-test.txt", &data[..])
.unwrap();
builder.finish().unwrap();
}
let digest = "sha256:test_no_blob_retained_cafebabe";
let layer_path = image::layer_dir(digest);
let blob_path = image::blob_path(digest);
let _ = std::fs::remove_dir_all(&layer_path);
let _ = std::fs::remove_file(&blob_path);
image::extract_layer(digest, tmp.path()).expect("extract_layer");
assert!(
layer_path.exists(),
"layer dir should exist after extraction"
);
assert!(
!blob_path.exists(),
"blob file should NOT be retained after extraction (issue #127): {}",
blob_path.display()
);
let _ = std::fs::remove_dir_all(&layer_path);
}
}
mod exec {
use super::*;
use std::os::unix::io::AsRawFd;
fn build_exec_command(pid: i32, exe: &str, args: &[&str]) -> Command {
let ns_types: &[(&str, Namespace)] = &[
("mnt", Namespace::MOUNT),
("uts", Namespace::UTS),
("ipc", Namespace::IPC),
("net", Namespace::NET),
("pid", Namespace::PID),
("user", Namespace::USER),
("cgroup", Namespace::CGROUP),
];
let mut cmd = Command::new(exe).args(args);
let mut has_mount_ns = false;
let mut pid_ns_found = false;
for &(ns_name, ns_flag) in ns_types {
let container_ns = format!("/proc/{}/ns/{}", pid, ns_name);
let init_ns = format!("/proc/1/ns/{}", ns_name);
let c_ino = std::fs::metadata(&container_ns).map(|m| {
use std::os::unix::fs::MetadataExt;
m.ino()
});
let i_ino = std::fs::metadata(&init_ns).map(|m| {
use std::os::unix::fs::MetadataExt;
m.ino()
});
if let (Ok(c), Ok(i)) = (c_ino, i_ino) {
if c != i {
if ns_flag == Namespace::MOUNT {
has_mount_ns = true;
} else {
if ns_flag == Namespace::PID {
pid_ns_found = true;
}
cmd = cmd.with_namespace_join(&container_ns, ns_flag);
}
}
}
}
if !pid_ns_found {
let pfc_path = format!("/proc/{}/ns/pid_for_children", pid);
let pfc_ino = std::fs::metadata(&pfc_path).map(|m| {
use std::os::unix::fs::MetadataExt;
m.ino()
});
let init_pid_ino = std::fs::metadata("/proc/1/ns/pid").map(|m| {
use std::os::unix::fs::MetadataExt;
m.ino()
});
if let (Ok(pfc), Ok(init)) = (pfc_ino, init_pid_ino) {
if pfc != init {
cmd = cmd.with_namespace_join(&pfc_path, Namespace::PID);
}
}
}
if has_mount_ns {
let mnt_ns_path = format!("/proc/{}/ns/mnt", pid);
let mnt_ns_file = std::fs::File::open(&mnt_ns_path).expect("open mount ns");
let mnt_ns_fd = mnt_ns_file.as_raw_fd();
let root_path = format!("/proc/{}/root", pid);
let root_file = std::fs::File::open(&root_path).expect("open container root");
let root_fd = root_file.as_raw_fd();
cmd = cmd.with_pre_exec(move || {
let _keep_mnt = &mnt_ns_file;
let _keep_root = &root_file;
unsafe {
if libc::setns(mnt_ns_fd, libc::CLONE_NEWNS) != 0 {
return Err(std::io::Error::last_os_error());
}
if libc::fchdir(root_fd) != 0 {
return Err(std::io::Error::last_os_error());
}
let dot = std::ffi::CString::new(".").unwrap();
if libc::chroot(dot.as_ptr()) != 0 {
return Err(std::io::Error::last_os_error());
}
let slash = std::ffi::CString::new("/").unwrap();
if libc::chdir(slash.as_ptr()) != 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
});
} else {
cmd = cmd.with_chroot(format!("/proc/{}/root", pid));
}
cmd
}
#[test]
#[serial]
fn test_exec_basic() {
if !is_root() {
eprintln!("Skipping test_exec_basic (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping (no rootfs)");
return;
}
};
let mut container = Command::new("/bin/sleep")
.args(["30"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container");
let pid = container.pid();
let cmd = build_exec_command(pid, "/bin/cat", &["/etc/os-release"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped);
let mut exec_child = cmd.spawn().expect("exec spawn");
let (status, stdout, stderr) = exec_child.wait_with_output().expect("exec wait");
unsafe {
libc::kill(pid, libc::SIGKILL);
}
let _ = container.wait();
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(), "exec should exit 0, stderr: {}", err);
assert!(
out.contains("Alpine"),
"exec should see Alpine os-release, got: {}",
out
);
}
#[test]
#[serial]
fn test_exec_sees_container_filesystem() {
if !is_root() {
eprintln!("Skipping test_exec_sees_container_filesystem (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping (no rootfs)");
return;
}
};
let mut container = Command::new("/bin/sh")
.args([
"-c",
"echo EXEC_MARKER_12345 > /tmp/exec-marker && sleep 30",
])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.with_tmpfs("/tmp", "")
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container");
let pid = container.pid();
std::thread::sleep(std::time::Duration::from_millis(500));
let cmd = build_exec_command(pid, "/bin/cat", &["/tmp/exec-marker"])
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped);
let mut exec_child = cmd.spawn().expect("exec spawn");
let (status, stdout, stderr) = exec_child.wait_with_output().expect("exec wait");
unsafe {
libc::kill(pid, libc::SIGKILL);
}
let _ = container.wait();
let out = String::from_utf8_lossy(&stdout).trim().to_string();
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(), "exec should exit 0, stderr: {}", err);
assert_eq!(
out, "EXEC_MARKER_12345",
"exec should see the container's tmpfs marker"
);
}
#[test]
#[serial]
fn test_exec_environment() {
if !is_root() {
eprintln!("Skipping test_exec_environment (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping (no rootfs)");
return;
}
};
let mut container = Command::new("/bin/sleep")
.args(["30"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT)
.env("PATH", ALPINE_PATH)
.env("FOO", "bar_from_container")
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container");
let pid = container.pid();
std::thread::sleep(std::time::Duration::from_millis(100));
let environ_path = format!("/proc/{}/environ", pid);
let environ_data = std::fs::read(&environ_path).expect("read /proc/pid/environ");
let env_pairs: Vec<(String, String)> = environ_data
.split(|&b| b == 0)
.filter(|s| !s.is_empty())
.filter_map(|entry| {
let s = String::from_utf8_lossy(entry);
let (k, v) = s.split_once('=')?;
Some((k.to_string(), v.to_string()))
})
.collect();
let mut cmd = build_exec_command(pid, "/bin/sh", &["-c", "echo $FOO"]);
for (k, v) in &env_pairs {
cmd = cmd.env(k, v);
}
cmd = cmd
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped);
let mut exec_child = cmd.spawn().expect("exec spawn");
let (status, stdout, stderr) = exec_child.wait_with_output().expect("exec wait");
unsafe {
libc::kill(pid, libc::SIGKILL);
}
let _ = container.wait();
let out = String::from_utf8_lossy(&stdout).trim().to_string();
let err = String::from_utf8_lossy(&stderr);
assert!(status.success(), "exec should exit 0, stderr: {}", err);
assert_eq!(
out, "bar_from_container",
"exec should see container's FOO env var"
);
}
#[test]
#[serial]
fn test_exec_nonrunning_container_fails() {
if !is_root() {
eprintln!("Skipping test_exec_nonrunning_container_fails (requires root)");
return;
}
let alive = unsafe { libc::kill(999999, 0) == 0 };
assert!(!alive, "PID 999999 should not be alive");
let root_path = std::path::Path::new("/proc/999999/root");
assert!(!root_path.exists(), "/proc/999999/root should not exist");
}
#[test]
#[serial]
fn test_exec_joins_pid_namespace() {
if !is_root() {
eprintln!("Skipping test_exec_joins_pid_namespace (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_exec_joins_pid_namespace (no rootfs)");
return;
}
};
let bin = env!("CARGO_BIN_EXE_pelagos");
let name = "pelagos-exec-pid-ns-test";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sleep",
"30",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("pelagos run -d");
assert!(run_status.success(), "pelagos run -d failed");
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut started = false;
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["pid"].as_i64().unwrap_or(0) > 0 {
started = true;
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(started, "container did not start within 10s");
let exec_echo = std::process::Command::new(bin)
.args(["exec", name, "/bin/echo", "hello-from-exec"])
.output()
.expect("pelagos exec /bin/echo");
assert!(
exec_echo.status.success(),
"pelagos exec /bin/echo failed: {}",
String::from_utf8_lossy(&exec_echo.stderr)
);
assert_eq!(
String::from_utf8_lossy(&exec_echo.stdout).trim(),
"hello-from-exec",
"exec output mismatch"
);
let exec_readlink = std::process::Command::new(bin)
.args(["exec", name, "readlink", "/proc/self/ns/mnt"])
.output()
.expect("pelagos exec readlink");
let _ = std::process::Command::new(bin)
.args(["stop", name])
.output();
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let readlink_out = String::from_utf8_lossy(&exec_readlink.stdout);
assert!(
exec_readlink.status.success(),
"readlink /proc/self/ns/mnt failed (exit {:?}): /proc/self is dangling — \
PID namespace join not working (issue #121)\nstdout: {}\nstderr: {}",
exec_readlink.status.code(),
readlink_out,
String::from_utf8_lossy(&exec_readlink.stderr)
);
assert!(
readlink_out.trim().starts_with("mnt:["),
"readlink /proc/self/ns/mnt output unexpected: {}",
readlink_out.trim()
);
}
#[test]
#[serial]
fn test_exec_mnt_ns_inode_stored() {
if !is_root() {
eprintln!("Skipping test_exec_mnt_ns_inode_stored (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping (no rootfs)");
return;
}
};
let name = "test-mnt-inode";
let bin = env!("CARGO_BIN_EXE_pelagos");
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sleep",
"30",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("pelagos run failed");
assert!(run_status.success(), "pelagos run -d failed");
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let state_json;
loop {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["pid"].as_i64().unwrap_or(0) > 0 {
state_json = data;
break;
}
}
}
assert!(
std::time::Instant::now() < deadline,
"container did not start within 10s"
);
std::thread::sleep(std::time::Duration::from_millis(100));
}
let state: serde_json::Value = serde_json::from_str(&state_json).expect("parse state.json");
let stored_inode = state["mnt_ns_inode"]
.as_u64()
.expect("mnt_ns_inode must be present in state.json");
assert!(stored_inode > 0, "mnt_ns_inode must be non-zero");
let exec_out = std::process::Command::new(bin)
.args(["exec", name, "/bin/true"])
.output()
.expect("pelagos exec");
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
assert!(
exec_out.status.success(),
"exec into live container should succeed (inode check must not false-reject), stderr: {}",
String::from_utf8_lossy(&exec_out.stderr)
);
}
#[test]
#[serial]
fn test_exec_detects_pid_reuse() {
if !is_root() {
eprintln!("Skipping test_exec_detects_pid_reuse (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping (no rootfs)");
return;
}
};
let name = "test-pid-reuse";
let bin = env!("CARGO_BIN_EXE_pelagos");
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sleep",
"30",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("pelagos run failed");
assert!(run_status.success(), "pelagos run -d failed");
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["pid"].as_i64().unwrap_or(0) > 0 {
break;
}
}
}
assert!(
std::time::Instant::now() < deadline,
"container did not start within 10s"
);
std::thread::sleep(std::time::Duration::from_millis(100));
}
let json = std::fs::read_to_string(&state_path).expect("read state.json");
let mut state: serde_json::Value = serde_json::from_str(&json).expect("parse state.json");
state["mnt_ns_inode"] = serde_json::Value::Number(serde_json::Number::from(999_999_999u64));
std::fs::write(&state_path, serde_json::to_string(&state).unwrap())
.expect("write tampered state.json");
let exec_out = std::process::Command::new(bin)
.args(["exec", name, "/bin/true"])
.output()
.expect("pelagos exec invocation failed");
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
assert!(
!exec_out.status.success(),
"exec should have failed on tampered inode but exited 0"
);
let stderr = String::from_utf8_lossy(&exec_out.stderr);
assert!(
stderr.contains("no longer running"),
"error should mention 'no longer running', got: {}",
stderr
);
}
}
mod watcher {
use super::*;
#[test]
#[serial]
fn test_watcher_kill_propagates_to_container() {
if !is_root() {
eprintln!("Skipping test_watcher_kill_propagates_to_container (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_watcher_kill_propagates_to_container (no rootfs)");
return;
}
};
let bin = env!("CARGO_BIN_EXE_pelagos");
let name = "pelagos-watcher-subreaper-test";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sleep",
"300",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("pelagos run -d");
assert!(run_status.success(), "pelagos run -d failed");
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut container_pid: i32 = 0;
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
let pid = v["pid"].as_i64().unwrap_or(0) as i32;
if pid > 0 {
container_pid = pid;
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(container_pid > 0, "container did not start within 10s");
let watcher_pid = {
let status_path = format!("/proc/{}/status", container_pid);
let status_data = std::fs::read_to_string(&status_path).expect("read /proc/<P>/status");
let ppid_line = status_data
.lines()
.find(|l| l.starts_with("PPid:"))
.expect("PPid line in /proc status");
ppid_line
.split_whitespace()
.nth(1)
.unwrap()
.parse::<i32>()
.unwrap()
};
assert!(watcher_pid > 1, "watcher PID should be > 1");
let alive_before = unsafe { libc::kill(container_pid, 0) == 0 };
assert!(
alive_before,
"container process should be alive before test"
);
let kill_ret = unsafe { libc::kill(watcher_pid, libc::SIGKILL) };
assert_eq!(kill_ret, 0, "failed to kill watcher");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
let mut container_died = false;
while std::time::Instant::now() < deadline {
let ret = unsafe { libc::kill(container_pid, 0) };
if ret != 0 {
container_died = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
assert!(
container_died,
"container process (pid {}) did not die within 3s after watcher (pid {}) was killed; \
PR_SET_CHILD_SUBREAPER may not be in effect",
container_pid, watcher_pid
);
}
}
mod dev {
use super::*;
#[test]
#[serial]
fn test_dev_minimal_devices() {
if !is_root() {
eprintln!("Skipping test_dev_minimal_devices: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_dev_minimal_devices: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ls")
.args(["/dev/"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.with_proc_mount()
.with_dev_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container");
let (status, stdout_bytes, _) = child.wait_with_output().expect("Failed to wait for child");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "ls /dev/ failed: {}", stdout);
for dev in &["null", "zero", "random", "urandom", "full", "tty"] {
assert!(
stdout.contains(dev),
"/dev/ should contain '{}', got: {}",
dev,
stdout
);
}
for bad in &["sda", "nvme", "video"] {
assert!(
!stdout.contains(bad),
"/dev/ should NOT contain host device '{}', got: {}",
bad,
stdout
);
}
}
#[test]
#[serial]
fn test_dev_null_works() {
if !is_root() {
eprintln!("Skipping test_dev_null_works: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_dev_null_works: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "echo ok > /dev/null && echo pass"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.with_proc_mount()
.with_dev_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container");
let (status, stdout_bytes, _) = child.wait_with_output().expect("Failed to wait for child");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "command failed: {}", stdout);
assert!(
stdout.contains("pass"),
"expected 'pass' in output, got: {}",
stdout
);
}
#[test]
#[serial]
fn test_dev_zero_works() {
if !is_root() {
eprintln!("Skipping test_dev_zero_works: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_dev_zero_works: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "head -c 4 /dev/zero | wc -c"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.with_proc_mount()
.with_dev_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container");
let (status, stdout_bytes, _) = child.wait_with_output().expect("Failed to wait for child");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "command failed: {}", stdout);
assert!(
stdout.trim().contains('4'),
"expected '4' in output, got: {}",
stdout
);
}
#[test]
#[serial]
fn test_dev_symlinks() {
if !is_root() {
eprintln!("Skipping test_dev_symlinks: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_dev_symlinks: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"test -L /dev/fd && test -L /dev/stdin && test -L /dev/stdout && test -L /dev/stderr && echo ok",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.with_proc_mount()
.with_dev_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container");
let (status, stdout_bytes, _) = child.wait_with_output().expect("Failed to wait for child");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "symlink check failed: {}", stdout);
assert!(
stdout.contains("ok"),
"expected 'ok' in output, got: {}",
stdout
);
}
#[test]
#[serial]
fn test_dev_pts_exists() {
if !is_root() {
eprintln!("Skipping test_dev_pts_exists: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_dev_pts_exists: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "test -d /dev/pts && test -d /dev/shm && echo ok"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.with_chroot(&rootfs)
.with_proc_mount()
.with_dev_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("Failed to spawn container");
let (status, stdout_bytes, _) = child.wait_with_output().expect("Failed to wait for child");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "/dev/pts or /dev/shm missing: {}", stdout);
assert!(
stdout.contains("ok"),
"expected 'ok' in output, got: {}",
stdout
);
}
}
mod rootless_cgroups {
use super::*;
fn skip_unless_delegation() -> bool {
if !pelagos::cgroup_rootless::is_delegation_available() {
eprintln!("Skipping: cgroup v2 delegation not available");
return false;
}
true
}
fn read_cgroup_knob(pid: i32, knob: &str) -> Option<String> {
let parent =
pelagos::cgroup_rootless::self_cgroup_path().expect("self_cgroup_path should work");
let path = parent.join(format!("pelagos-{}", pid)).join(knob);
match std::fs::read_to_string(&path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => panic!("failed to read {}: {}", path.display(), e),
}
}
#[test]
fn test_rootless_cgroup_memory() {
if is_root() {
eprintln!("Skipping test_rootless_cgroup_memory: must run as non-root");
return;
}
if !skip_unless_delegation() {
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_rootless_cgroup_memory: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sleep")
.args(["10"])
.with_namespaces(Namespace::USER | Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.with_uid_maps(&[UidMap {
inside: 0,
outside: unsafe { libc::getuid() },
count: 1,
}])
.with_gid_maps(&[GidMap {
inside: 0,
outside: unsafe { libc::getgid() },
count: 1,
}])
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(64 * 1024 * 1024) .stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with rootless cgroup memory");
let val = read_cgroup_knob(child.pid(), "memory.max");
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
child.wait().expect("Failed to wait");
match val {
Some(v) => assert_eq!(
v.trim(),
"67108864",
"expected 64MB in memory.max, got: {}",
v.trim()
),
None => eprintln!(
"Skipping memory assertion: memory controller not delegated to sub-cgroup"
),
}
}
#[test]
fn test_rootless_cgroup_pids() {
if is_root() {
eprintln!("Skipping test_rootless_cgroup_pids: must run as non-root");
return;
}
if !skip_unless_delegation() {
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_rootless_cgroup_pids: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sleep")
.args(["10"])
.with_namespaces(Namespace::USER | Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.with_uid_maps(&[UidMap {
inside: 0,
outside: unsafe { libc::getuid() },
count: 1,
}])
.with_gid_maps(&[GidMap {
inside: 0,
outside: unsafe { libc::getgid() },
count: 1,
}])
.env("PATH", ALPINE_PATH)
.with_cgroup_pids_limit(16)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn with rootless cgroup pids");
let val = read_cgroup_knob(child.pid(), "pids.max");
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
child.wait().expect("Failed to wait");
match val {
Some(v) => assert_eq!(v.trim(), "16", "expected 16 in pids.max, got: {}", v.trim()),
None => {
eprintln!("Skipping pids assertion: pids controller not delegated to sub-cgroup")
}
}
}
#[test]
fn test_rootless_cgroup_cleanup() {
if is_root() {
eprintln!("Skipping test_rootless_cgroup_cleanup: must run as non-root");
return;
}
if !skip_unless_delegation() {
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_rootless_cgroup_cleanup: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/true")
.with_namespaces(Namespace::USER | Namespace::MOUNT | Namespace::UTS)
.with_chroot(&rootfs)
.with_uid_maps(&[UidMap {
inside: 0,
outside: unsafe { libc::getuid() },
count: 1,
}])
.with_gid_maps(&[GidMap {
inside: 0,
outside: unsafe { libc::getgid() },
count: 1,
}])
.env("PATH", ALPINE_PATH)
.with_cgroup_memory(32 * 1024 * 1024)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn");
let pid = child.pid();
child.wait().expect("Failed to wait");
let cg_parent =
pelagos::cgroup_rootless::self_cgroup_path().expect("self_cgroup_path should work");
let cg_dir = cg_parent.join(format!("pelagos-{}", pid));
for _ in 0..10 {
if !cg_dir.exists() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
let _ = std::fs::remove_dir(&cg_dir);
}
assert!(
!cg_dir.exists(),
"cgroup dir should have been removed: {}",
cg_dir.display()
);
}
}
mod json_output {
use super::*;
fn pelagos(args: &[&str]) -> (String, String, bool) {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_pelagos"))
.args(args)
.output()
.expect("failed to run pelagos binary");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.success(),
)
}
#[test]
#[serial]
fn test_volume_ls_json() {
if !is_root() {
eprintln!("Skipping test_volume_ls_json: requires root");
return;
}
let vol_name = "test-json-vol";
let _ = pelagos(&["volume", "rm", vol_name]);
let (_, stderr, ok) = pelagos(&["volume", "create", vol_name]);
assert!(ok, "volume create failed: {}", stderr);
let (stdout, stderr, ok) = pelagos(&["volume", "ls", "--format", "json"]);
assert!(ok, "volume ls --format json failed: {}", stderr);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("volume ls JSON should parse");
let arr = parsed.as_array().expect("expected JSON array");
let found = arr
.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some(vol_name));
assert!(
found,
"volume '{}' not in JSON output: {}",
vol_name, stdout
);
let entry = arr
.iter()
.find(|v| v.get("name").and_then(|n| n.as_str()) == Some(vol_name))
.unwrap();
assert!(
entry.get("path").and_then(|p| p.as_str()).is_some(),
"volume JSON entry should have a 'path' field"
);
let (_, stderr, ok) = pelagos(&["volume", "rm", vol_name]);
assert!(ok, "volume rm failed: {}", stderr);
let (stdout, _, ok) = pelagos(&["volume", "ls", "--format", "json"]);
assert!(ok, "volume ls --format json failed after rm");
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("volume ls JSON should parse after rm");
let arr = parsed.as_array().expect("expected JSON array");
let found = arr
.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some(vol_name));
assert!(
!found,
"volume '{}' should not appear after rm: {}",
vol_name, stdout
);
}
#[test]
#[serial]
fn test_rootfs_ls_json() {
if !is_root() {
eprintln!("Skipping test_rootfs_ls_json: requires root");
return;
}
let name = "test-json-rootfs";
let _ = pelagos(&["rootfs", "rm", name]);
let (_, stderr, ok) = pelagos(&["rootfs", "import", name, "/tmp"]);
assert!(ok, "rootfs import failed: {}", stderr);
let (stdout, stderr, ok) = pelagos(&["rootfs", "ls", "--format", "json"]);
assert!(ok, "rootfs ls --format json failed: {}", stderr);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("rootfs ls JSON should parse");
let arr = parsed.as_array().expect("expected JSON array");
let entry = arr
.iter()
.find(|v| v.get("name").and_then(|n| n.as_str()) == Some(name));
assert!(
entry.is_some(),
"rootfs '{}' not in JSON output: {}",
name,
stdout
);
assert!(
entry
.unwrap()
.get("path")
.and_then(|p| p.as_str())
.is_some(),
"rootfs JSON entry should have a 'path' field"
);
let (_, stderr, ok) = pelagos(&["rootfs", "rm", name]);
assert!(ok, "rootfs rm failed: {}", stderr);
let (stdout, _, ok) = pelagos(&["rootfs", "ls", "--format", "json"]);
assert!(ok, "rootfs ls --format json failed after rm");
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("rootfs ls JSON should parse after rm");
let arr = parsed.as_array().expect("expected JSON array");
let found = arr
.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some(name));
assert!(
!found,
"rootfs '{}' should not appear after rm: {}",
name, stdout
);
}
#[test]
#[serial]
fn test_ps_json_and_inspect() {
if !is_root() {
eprintln!("Skipping test_ps_json_and_inspect: requires root");
return;
}
let name = "test-json-ctr";
let _ = pelagos(&["rm", name]);
let ctr_dir = pelagos::paths::containers_dir().join(name);
std::fs::create_dir_all(&ctr_dir).expect("create container dir");
let state = serde_json::json!({
"name": name,
"rootfs": "alpine",
"status": "exited",
"pid": 0,
"watcher_pid": 0,
"started_at": "2026-01-01T00:00:00Z",
"exit_code": 0,
"command": ["/bin/sh"],
"stdout_log": null,
"stderr_log": null
});
std::fs::write(
ctr_dir.join("state.json"),
serde_json::to_string_pretty(&state).unwrap(),
)
.expect("write state.json");
let (stdout, stderr, ok) = pelagos(&["ps", "-a", "--format", "json"]);
assert!(ok, "ps --format json failed: {}", stderr);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("ps JSON should parse");
let arr = parsed.as_array().expect("expected JSON array");
let found = arr
.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some(name));
assert!(
found,
"container '{}' not in ps JSON output: {}",
name, stdout
);
let (stdout, stderr, ok) = pelagos(&["container", "inspect", name]);
assert!(ok, "container inspect failed: {}", stderr);
let obj: serde_json::Value =
serde_json::from_str(&stdout).expect("inspect JSON should parse");
assert!(obj.is_object(), "inspect should return a JSON object");
assert_eq!(
obj.get("name").and_then(|n| n.as_str()),
Some(name),
"inspect name mismatch"
);
assert!(
obj.get("pid").is_some(),
"inspect should include 'pid' field"
);
assert!(
obj.get("status").is_some(),
"inspect should include 'status' field"
);
let (_, stderr, ok) = pelagos(&["rm", name]);
assert!(ok, "rm failed: {}", stderr);
let (stdout, _, ok) = pelagos(&["ps", "-a", "--format", "json"]);
assert!(ok, "ps --format json failed after rm");
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("ps JSON should parse after rm");
let arr = parsed.as_array().expect("expected JSON array");
let found = arr
.iter()
.any(|v| v.get("name").and_then(|n| n.as_str()) == Some(name));
assert!(
!found,
"container '{}' should not appear after rm: {}",
name, stdout
);
}
#[test]
#[serial]
fn test_image_ls_json() {
if !is_root() {
eprintln!("Skipping test_image_ls_json: requires root");
return;
}
let (stdout, stderr, ok) = pelagos(&["image", "ls", "--format", "json"]);
assert!(ok, "image ls --format json failed: {}", stderr);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("image ls JSON should parse");
let arr = parsed.as_array().expect("expected JSON array");
for entry in arr {
assert!(
entry.get("reference").and_then(|v| v.as_str()).is_some(),
"image entry should have 'reference': {:?}",
entry
);
assert!(
entry.get("digest").and_then(|v| v.as_str()).is_some(),
"image entry should have 'digest': {:?}",
entry
);
assert!(
entry.get("layers").and_then(|v| v.as_array()).is_some(),
"image entry should have 'layers' array: {:?}",
entry
);
}
}
}
mod rootless_idmap {
use super::*;
fn skip_unless_idmap_helpers() -> bool {
if !pelagos::idmap::has_newuidmap() || !pelagos::idmap::has_newgidmap() {
eprintln!("Skipping: newuidmap/newgidmap not available");
return false;
}
let username = match pelagos::idmap::current_username() {
Ok(u) => u,
Err(_) => {
eprintln!("Skipping: could not determine username");
return false;
}
};
let uid = unsafe { libc::getuid() };
let uid_ranges =
pelagos::idmap::parse_subid_file(std::path::Path::new("/etc/subuid"), &username, uid)
.unwrap_or_default();
let gid_ranges = pelagos::idmap::parse_subid_file(
std::path::Path::new("/etc/subgid"),
&username,
unsafe { libc::getgid() },
)
.unwrap_or_default();
if uid_ranges.is_empty() || gid_ranges.is_empty() {
eprintln!("Skipping: no subordinate UID/GID ranges in /etc/subuid or /etc/subgid");
return false;
}
true
}
#[test]
fn test_rootless_multi_uid_maps_written() {
if is_root() {
eprintln!("Skipping: must run as non-root");
return;
}
if !skip_unless_idmap_helpers() {
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sleep")
.args(["10"])
.env("PATH", ALPINE_PATH)
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("rootless multi-uid spawn failed");
let uid_map_path = format!("/proc/{}/uid_map", child.pid());
let uid_map = std::fs::read_to_string(&uid_map_path)
.unwrap_or_else(|e| panic!("failed to read {}: {}", uid_map_path, e));
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
child.wait().expect("Failed to wait");
let lines: Vec<&str> = uid_map.lines().collect();
assert!(
lines.len() >= 2,
"expected at least 2 uid_map lines, got {} lines: {:?}",
lines.len(),
lines
);
}
#[test]
fn test_rootless_multi_uid_file_ownership() {
if is_root() {
eprintln!("Skipping: must run as non-root");
return;
}
if !skip_unless_idmap_helpers() {
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "stat -c '%u' /etc/passwd"])
.env("PATH", ALPINE_PATH)
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("rootless file-ownership spawn failed");
let (status, stdout, stderr) = child.wait_with_output().expect("wait failed");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(
status.success(),
"container exited non-zero; stdout={}, stderr={}",
out,
err
);
assert_eq!(
out.trim(),
"0",
"expected /etc/passwd owned by UID 0, got: {}",
out.trim()
);
}
#[test]
fn test_rootless_multi_gid_chown_succeeds() {
if is_root() {
eprintln!("Skipping: must run as non-root");
return;
}
if !skip_unless_idmap_helpers() {
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args([
"-c",
"touch /tmp/testfile && chown 0:4 /tmp/testfile && echo ok",
])
.env("PATH", ALPINE_PATH)
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout, stderr) = child.wait_with_output().expect("wait failed");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(
status.success(),
"chown to GID 4 failed with multi-range mapping; stdout={}, stderr={}",
out,
err
);
assert!(
out.trim() == "ok",
"expected 'ok' from chown test, got: {}",
out.trim()
);
}
#[test]
fn test_rootless_overlay_mode0_mkdir_succeeds() {
if is_root() {
eprintln!("Skipping: must run as non-root");
return;
}
let reference = "docker.io/library/alpine:3.21";
let manifest = match pelagos::image::load_image(reference) {
Ok(m) => m,
Err(_) => {
eprintln!("Skipping: alpine:3.21 not in local image store (run pelagos image pull alpine:3.21)");
return;
}
};
let layers = pelagos::image::layer_dirs(&manifest);
if layers.is_empty() {
eprintln!("Skipping: alpine image has no layers");
return;
}
let mut child = Command::new("/bin/sh")
.args(["-c", "mkdir -m 000 /tmp/mode0test && echo ok"])
.env("PATH", ALPINE_PATH)
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout, stderr) = child.wait_with_output().expect("wait failed");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(
status.success(),
"mkdir mode=0 failed inside rootless overlay container; \
stdout={out}, stderr={err}\n\
Regression: user-namespace fuse-overlayfs squash_to_uid=0 fix not working"
);
assert_eq!(
out.trim(),
"ok",
"expected 'ok' from mkdir mode=0 test, got: {}",
out.trim()
);
}
#[test]
fn test_rootless_single_uid_fallback() {
if is_root() {
eprintln!("Skipping: must run as non-root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/ash")
.args(["-c", "id -u"])
.env("PATH", ALPINE_PATH)
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_uid_maps(&[UidMap {
inside: 0,
outside: unsafe { libc::getuid() },
count: 1,
}])
.with_gid_maps(&[GidMap {
inside: 0,
outside: unsafe { libc::getgid() },
count: 1,
}])
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("rootless single-uid spawn failed");
let (status, stdout, _stderr) = child.wait_with_output().expect("wait failed");
assert!(status.success(), "container exited non-zero");
let out = String::from_utf8_lossy(&stdout);
assert_eq!(
out.trim(),
"0",
"expected uid 0 inside container, got: {}",
out.trim()
);
}
}
mod build_instructions {
use pelagos::build;
use std::collections::HashMap;
#[test]
fn test_parse_entrypoint_json() {
let content = r#"FROM alpine
ENTRYPOINT ["python3", "-m", "http.server"]
CMD ["8080"]"#;
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(instructions.len(), 3);
assert_eq!(
instructions[1],
build::Instruction::Entrypoint(vec![
"python3".into(),
"-m".into(),
"http.server".into()
])
);
assert_eq!(
instructions[2],
build::Instruction::Cmd(vec!["8080".into()])
);
}
#[test]
fn test_parse_entrypoint_shell_form() {
let content = "FROM alpine\nENTRYPOINT /usr/bin/myapp --flag";
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(
instructions[1],
build::Instruction::Entrypoint(vec![
"/bin/sh".into(),
"-c".into(),
"/usr/bin/myapp --flag".into()
])
);
}
#[test]
fn test_parse_label_quoted_and_unquoted() {
let content = "FROM alpine\nLABEL maintainer=\"Jane Doe\"\nLABEL version=2.0";
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(
instructions[1],
build::Instruction::Label {
key: "maintainer".into(),
value: "Jane Doe".into()
}
);
assert_eq!(
instructions[2],
build::Instruction::Label {
key: "version".into(),
value: "2.0".into()
}
);
}
#[test]
fn test_parse_user_with_gid() {
let content = "FROM alpine\nUSER 1000:1000";
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(
instructions[1],
build::Instruction::User("1000:1000".into())
);
}
#[test]
fn test_image_config_labels_serde_roundtrip() {
use pelagos::image::ImageConfig;
let mut labels = HashMap::new();
labels.insert("maintainer".to_string(), "test@example.com".to_string());
labels.insert("version".to_string(), "1.0".to_string());
let config = ImageConfig {
env: vec![],
cmd: vec![],
entrypoint: vec![],
working_dir: String::new(),
user: String::new(),
labels: labels.clone(),
healthcheck: None,
stop_signal: String::new(),
};
let json = serde_json::to_string(&config).unwrap();
let loaded: ImageConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.labels, labels);
let minimal = r#"{"env":[],"cmd":[]}"#;
let loaded: ImageConfig = serde_json::from_str(minimal).unwrap();
assert!(loaded.labels.is_empty());
}
#[test]
fn test_image_config_user_field() {
use pelagos::image::ImageConfig;
let config = ImageConfig {
env: vec![],
cmd: vec![],
entrypoint: vec!["/app".to_string()],
working_dir: String::new(),
user: "1000:1000".to_string(),
labels: HashMap::new(),
healthcheck: None,
stop_signal: String::new(),
};
let json = serde_json::to_string(&config).unwrap();
let loaded: ImageConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.user, "1000:1000");
assert_eq!(loaded.entrypoint, vec!["/app"]);
}
#[test]
fn test_full_remfile_with_all_instructions() {
let content = r#"
FROM alpine:3.19
LABEL maintainer="test"
ENV APP_PORT=8080
USER nobody
WORKDIR /app
COPY app.py /app/app.py
RUN apk add python3
ENTRYPOINT ["python3"]
CMD ["app.py"]
EXPOSE 8080
"#;
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(instructions.len(), 10);
assert!(matches!(instructions[0], build::Instruction::From { .. }));
assert!(matches!(instructions[1], build::Instruction::Label { .. }));
assert!(matches!(instructions[2], build::Instruction::Env { .. }));
assert!(matches!(instructions[3], build::Instruction::User(_)));
assert!(matches!(instructions[4], build::Instruction::Workdir(_)));
assert!(matches!(instructions[5], build::Instruction::Copy { .. }));
assert!(matches!(instructions[6], build::Instruction::Run(_)));
assert!(matches!(instructions[7], build::Instruction::Entrypoint(_)));
assert!(matches!(instructions[8], build::Instruction::Cmd(_)));
assert!(matches!(instructions[9], build::Instruction::Expose(_)));
}
#[test]
fn test_parse_arg_instruction() {
let content = "ARG BASE=alpine\nFROM $BASE\nARG VERSION\nRUN echo $VERSION";
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(instructions.len(), 4);
assert_eq!(
instructions[0],
build::Instruction::Arg {
name: "BASE".into(),
default: Some("alpine".into())
}
);
assert_eq!(
instructions[2],
build::Instruction::Arg {
name: "VERSION".into(),
default: None,
}
);
let mut vars = HashMap::new();
vars.insert("BASE".to_string(), "alpine:3.19".to_string());
assert_eq!(
build::substitute_vars("img=${BASE}", &vars),
"img=alpine:3.19"
);
assert_eq!(
build::substitute_vars("$BASE/path", &vars),
"alpine:3.19/path"
);
assert_eq!(build::substitute_vars("$$literal", &vars), "$literal");
}
#[test]
fn test_remignore_filtering() {
use std::io::Write;
let ctx = tempfile::tempdir().unwrap();
let mut f = std::fs::File::create(ctx.path().join(".remignore")).unwrap();
writeln!(f, "*.log").unwrap();
writeln!(f, "build/").unwrap();
std::fs::write(ctx.path().join("app.rs"), "fn main() {}").unwrap();
std::fs::write(ctx.path().join("debug.log"), "log data").unwrap();
std::fs::create_dir(ctx.path().join("build")).unwrap();
std::fs::write(ctx.path().join("build/output"), "binary").unwrap();
std::fs::create_dir(ctx.path().join("src")).unwrap();
std::fs::write(ctx.path().join("src/lib.rs"), "pub fn f() {}").unwrap();
let mut builder = ignore::gitignore::GitignoreBuilder::new(ctx.path());
builder.add(ctx.path().join(".remignore"));
let gi = builder.build().unwrap();
let dst = tempfile::tempdir().unwrap();
fn copy_filtered(
src: &std::path::Path,
dst: &std::path::Path,
gi: &ignore::gitignore::Gitignore,
root: &std::path::Path,
) {
std::fs::create_dir_all(dst).unwrap();
for entry in std::fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let ft = entry.file_type().unwrap();
let path = entry.path();
let dest = dst.join(entry.file_name());
let rel = path.strip_prefix(root).unwrap();
if gi.matched(rel, ft.is_dir()).is_ignore() {
continue;
}
if ft.is_dir() {
copy_filtered(&path, &dest, gi, root);
} else {
std::fs::copy(&path, &dest).unwrap();
}
}
}
copy_filtered(ctx.path(), dst.path(), &gi, ctx.path());
assert!(dst.path().join("app.rs").exists());
assert!(dst.path().join("src/lib.rs").exists());
assert!(!dst.path().join("debug.log").exists());
assert!(!dst.path().join("build").exists());
}
#[test]
fn test_parse_add_instruction() {
let content =
"FROM alpine\nADD app.tar.gz /opt/app\nADD https://example.com/file /tmp/file";
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(instructions.len(), 3);
assert_eq!(
instructions[1],
build::Instruction::Add {
src: "app.tar.gz".into(),
dest: "/opt/app".into()
}
);
assert_eq!(
instructions[2],
build::Instruction::Add {
src: "https://example.com/file".into(),
dest: "/tmp/file".into()
}
);
}
#[test]
fn test_add_local_tar_extraction() {
let tmp = tempfile::tempdir().unwrap();
let archive_path = tmp.path().join("test.tar.gz");
{
let file = std::fs::File::create(&archive_path).unwrap();
let gz = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
let mut tar_builder = tar::Builder::new(gz);
let data1 = b"hello world";
let mut header1 = tar::Header::new_gnu();
header1.set_size(data1.len() as u64);
header1.set_mode(0o644);
header1.set_cksum();
tar_builder
.append_data(&mut header1, "hello.txt", &data1[..])
.unwrap();
let data2 = b"sub content";
let mut header2 = tar::Header::new_gnu();
header2.set_size(data2.len() as u64);
header2.set_mode(0o644);
header2.set_cksum();
tar_builder
.append_data(&mut header2, "subdir/file.txt", &data2[..])
.unwrap();
let gz = tar_builder.into_inner().unwrap();
gz.finish().unwrap();
}
let extract_dir = tmp.path().join("extracted");
std::fs::create_dir_all(&extract_dir).unwrap();
let file = std::fs::File::open(&archive_path).unwrap();
let decoder = flate2::read::GzDecoder::new(file);
tar::Archive::new(decoder).unpack(&extract_dir).unwrap();
assert!(extract_dir.join("hello.txt").exists());
assert_eq!(
std::fs::read_to_string(extract_dir.join("hello.txt")).unwrap(),
"hello world"
);
assert!(extract_dir.join("subdir/file.txt").exists());
assert_eq!(
std::fs::read_to_string(extract_dir.join("subdir/file.txt")).unwrap(),
"sub content"
);
}
#[test]
fn test_parse_multi_stage_remfile() {
let content = r#"
FROM alpine:3.19 AS builder
RUN echo "building..."
COPY src/ /build/src/
FROM alpine:3.19
COPY --from=builder /build/output /app/bin
CMD ["/app/bin"]
"#;
let instructions = build::parse_remfile(content).unwrap();
assert_eq!(instructions.len(), 6);
assert_eq!(
instructions[0],
build::Instruction::From {
image: "alpine:3.19".into(),
alias: Some("builder".into()),
}
);
assert!(matches!(
instructions[2],
build::Instruction::Copy {
ref from_stage, ..
} if from_stage.is_none()
));
assert_eq!(
instructions[3],
build::Instruction::From {
image: "alpine:3.19".into(),
alias: None,
}
);
assert_eq!(
instructions[4],
build::Instruction::Copy {
src: "/build/output".into(),
dest: "/app/bin".into(),
from_stage: Some("builder".into()),
}
);
}
}
mod port_proxy {
use super::*;
#[test]
#[serial(nat)]
fn test_port_proxy_localhost_connectivity() {
if !is_root() {
eprintln!("Skipping test_port_proxy_localhost_connectivity: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_proxy_localhost_connectivity: alpine-rootfs not found");
return;
};
let nc_ok = std::process::Command::new("which")
.arg("nc")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !nc_ok {
eprintln!("Skipping test_port_proxy_localhost_connectivity: nc not found on host");
return;
}
let mut child = Command::new("/bin/sh")
.args(["-c", "echo PROXY_WORKS | nc -l -p 80"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(19190, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container");
std::thread::sleep(std::time::Duration::from_millis(500));
let output = std::process::Command::new("nc")
.args(["-w", "2", "127.0.0.1", "19190"])
.output()
.expect("nc to localhost");
let out = String::from_utf8_lossy(&output.stdout);
unsafe {
libc::kill(child.pid(), libc::SIGKILL);
}
let _ = child.wait();
assert!(
out.contains("PROXY_WORKS"),
"Localhost connection via port proxy should receive 'PROXY_WORKS'.\nstdout: {}\nstderr: {}",
out,
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
#[serial(nat)]
fn test_port_proxy_cleanup_on_teardown() {
if !is_root() {
eprintln!("Skipping test_port_proxy_cleanup_on_teardown: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_proxy_cleanup_on_teardown: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/true")
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(19191, 80)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container");
let status = child.wait().expect("wait failed");
assert!(status.success(), "container should exit cleanly");
std::thread::sleep(std::time::Duration::from_millis(300));
let bind_result = std::net::TcpListener::bind("0.0.0.0:19191");
assert!(
bind_result.is_ok(),
"Port 19191 should be free after teardown, but bind failed: {:?}",
bind_result.err()
);
}
#[test]
#[serial(nat)]
fn test_port_proxy_multiple_connections() {
if !is_root() {
eprintln!("Skipping test_port_proxy_multiple_connections: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_port_proxy_multiple_connections: alpine-rootfs not found");
return;
};
let mut child = Command::new("/bin/sh")
.args([
"-c",
"while true; do echo PONG | nc -l -p 8080 2>/dev/null; done",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_port_forward(19192, 8080)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container");
std::thread::sleep(std::time::Duration::from_millis(600));
const N: usize = 5;
let mut failures = Vec::new();
for i in 0..N {
use std::io::Read;
match std::net::TcpStream::connect("127.0.0.1:19192") {
Ok(mut stream) => {
stream
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
.ok();
let mut buf = String::new();
let _ = stream.read_to_string(&mut buf);
if !buf.contains("PONG") {
failures.push(format!("conn {}: got {:?}", i, buf));
}
drop(stream);
std::thread::sleep(std::time::Duration::from_millis(300));
}
Err(e) => {
failures.push(format!("conn {}: connect failed: {}", i, e));
}
}
}
unsafe { libc::kill(child.pid(), libc::SIGKILL) };
let _ = child.wait();
assert!(
failures.is_empty(),
"Port proxy multiple-connection test failed:\n{}",
failures.join("\n")
);
}
}
mod multi_network {
use super::*;
use pelagos::network::{Ipv4Net, NetworkDef};
fn cleanup_test_network(name: &str) {
let config_dir = pelagos::paths::network_config_dir(name);
let _ = std::fs::remove_dir_all(&config_dir);
let runtime_dir = pelagos::paths::network_runtime_dir(name);
let _ = std::fs::remove_dir_all(&runtime_dir);
let bridge = if name == "pelagos0" {
"pelagos0".to_string()
} else {
format!("rm-{}", name)
};
let _ = std::process::Command::new("ip")
.args(["link", "del", &bridge])
.stderr(std::process::Stdio::null())
.status();
}
#[test]
#[serial(nat)]
fn test_network_create_ls_rm() {
if !is_root() {
eprintln!("Skipping test_network_create_ls_rm (requires root)");
return;
}
let name = "testnet1";
cleanup_test_network(name);
let subnet = Ipv4Net::from_cidr("10.99.1.0/24").unwrap();
let net = NetworkDef {
name: name.to_string(),
subnet: subnet.clone(),
gateway: subnet.gateway(),
bridge_name: format!("rm-{}", name),
};
net.save().expect("save network");
let config = pelagos::paths::network_config_dir(name).join("config.json");
assert!(config.exists(), "config.json should exist after save");
let loaded = NetworkDef::load(name).expect("load network");
assert_eq!(loaded.name, name);
assert_eq!(loaded.subnet.cidr_string(), "10.99.1.0/24");
assert_eq!(loaded.gateway, std::net::Ipv4Addr::new(10, 99, 1, 1));
assert_eq!(loaded.bridge_name, "rm-testnet1");
cleanup_test_network(name);
assert!(!config.exists(), "config.json should be gone after cleanup");
}
#[test]
#[serial(nat)]
fn test_network_create_overlap_rejected() {
if !is_root() {
eprintln!("Skipping test_network_create_overlap_rejected (requires root)");
return;
}
let name_a = "overlap-a";
let name_b = "overlap-b";
cleanup_test_network(name_a);
cleanup_test_network(name_b);
let subnet_a = Ipv4Net::from_cidr("10.77.0.0/16").unwrap();
let net_a = NetworkDef {
name: name_a.to_string(),
subnet: subnet_a.clone(),
gateway: subnet_a.gateway(),
bridge_name: format!("rm-{}", name_a),
};
net_a.save().expect("save network A");
let subnet_b = Ipv4Net::from_cidr("10.77.1.0/24").unwrap();
assert!(
subnet_a.overlaps(&subnet_b),
"10.77.0.0/16 and 10.77.1.0/24 should overlap"
);
let networks_dir = pelagos::paths::networks_config_dir();
let mut found_overlap = false;
if let Ok(entries) = std::fs::read_dir(&networks_dir) {
for entry in entries.flatten() {
let cfg_path = entry.path().join("config.json");
if let Ok(data) = std::fs::read_to_string(&cfg_path) {
if let Ok(existing) = serde_json::from_str::<NetworkDef>(&data) {
if existing.subnet.overlaps(&subnet_b) {
found_overlap = true;
}
}
}
}
}
assert!(found_overlap, "overlap should be detected");
cleanup_test_network(name_a);
cleanup_test_network(name_b);
}
#[test]
fn test_network_name_validation() {
let long_name = "abcdefghijklm"; assert!(long_name.len() > 12);
let bad_chars = "net_work";
assert!(bad_chars.contains('_'));
let leading = "-net";
assert!(leading.starts_with('-'));
assert!(Ipv4Net::from_cidr("not-a-cidr").is_err());
assert!(Ipv4Net::from_cidr("10.0.0.0/33").is_err());
assert!(Ipv4Net::from_cidr("10.0.0.0/24").is_ok());
}
#[test]
#[serial(nat)]
fn test_named_network_container() {
if !is_root() {
eprintln!("Skipping test_named_network_container (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_named_network_container (no rootfs)");
return;
}
};
let name = "testnet2";
cleanup_test_network(name);
let subnet = Ipv4Net::from_cidr("10.98.1.0/24").unwrap();
let net = NetworkDef {
name: name.to_string(),
subnet: subnet.clone(),
gateway: subnet.gateway(),
bridge_name: format!("rm-{}", name),
};
net.save().expect("save network");
let mut child = Command::new("/bin/sh")
.args(["-c", "ip addr show eth0 | grep 'inet '"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn on named network");
let (_status, stdout_raw, _stderr) = child.wait_with_output().expect("wait_with_output");
let stdout = String::from_utf8_lossy(&stdout_raw);
assert!(
stdout.contains("10.98.1."),
"container IP should be in 10.98.1.0/24, got: {}",
stdout.trim()
);
cleanup_test_network(name);
}
#[test]
#[serial(nat)]
fn test_default_network_backwards_compat() {
if !is_root() {
eprintln!("Skipping test_default_network_backwards_compat (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_default_network_backwards_compat (no rootfs)");
return;
}
};
let mut child = Command::new("/bin/sh")
.args(["-c", "ip addr show eth0 | grep 'inet '"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::Bridge)
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn on default bridge");
let (_status, stdout_raw, _stderr) = child.wait_with_output().expect("wait_with_output");
let stdout = String::from_utf8_lossy(&stdout_raw);
assert!(
stdout.contains("172.19.0."),
"default bridge should assign 172.19.0.x IP, got: {}",
stdout.trim()
);
}
#[test]
#[serial(nat)]
fn test_network_rm_refuses_default() {
if !is_root() {
eprintln!("Skipping test_network_rm_refuses_default (requires root)");
return;
}
let _ = pelagos::network::bootstrap_default_network(None).expect("bootstrap default");
let config = pelagos::paths::network_config_dir("pelagos0").join("config.json");
assert!(config.exists(), "default network config should exist");
}
fn create_test_network(name: &str, cidr: &str) {
cleanup_test_network(name);
let subnet = Ipv4Net::from_cidr(cidr).unwrap();
let net = NetworkDef {
name: name.to_string(),
subnet: subnet.clone(),
gateway: subnet.gateway(),
bridge_name: format!("rm-{}", name),
};
net.save().expect("save network");
}
#[test]
#[serial(nat)]
fn test_multi_network_dual_interface() {
if !is_root() {
eprintln!("Skipping test_multi_network_dual_interface (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_multi_network_dual_interface (no rootfs)");
return;
}
};
let net1 = "mntest1";
let net2 = "mntest2";
create_test_network(net1, "10.99.1.0/24");
create_test_network(net2, "10.99.2.0/24");
let mut child = Command::new("/bin/sh")
.args([
"-c",
"ip addr show eth0 | grep 'inet '; ip addr show eth1 | grep 'inet '",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_additional_network(net2)
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn multi-network");
let ip1 = child.container_ip().expect("primary IP");
assert!(
ip1.starts_with("10.99.1."),
"primary IP should be in 10.99.1.0/24, got: {}",
ip1
);
let ip2 = child
.container_ip_on(net2)
.expect("secondary IP on mntest2");
assert!(
ip2.starts_with("10.99.2."),
"secondary IP should be in 10.99.2.0/24, got: {}",
ip2
);
let (_status, stdout_raw, _stderr) = child.wait_with_output().expect("wait_with_output");
let stdout = String::from_utf8_lossy(&stdout_raw);
assert!(
stdout.contains("10.99.1."),
"eth0 should have 10.99.1.x IP in output: {}",
stdout.trim()
);
assert!(
stdout.contains("10.99.2."),
"eth1 should have 10.99.2.x IP in output: {}",
stdout.trim()
);
cleanup_test_network(net1);
cleanup_test_network(net2);
}
#[test]
#[serial(nat)]
fn test_multi_network_isolation() {
if !is_root() {
eprintln!("Skipping test_multi_network_isolation (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_multi_network_isolation (no rootfs)");
return;
}
};
let net1 = "mniso1";
let net2 = "mniso2";
let bridge1 = "rm-mniso1";
let bridge2 = "rm-mniso2";
create_test_network(net1, "10.98.1.0/24");
create_test_network(net2, "10.98.2.0/24");
let _ = std::process::Command::new("iptables")
.args(["-I", "FORWARD", "-i", bridge1, "-o", bridge2, "-j", "DROP"])
.status();
let _ = std::process::Command::new("iptables")
.args(["-I", "FORWARD", "-i", bridge2, "-o", bridge1, "-j", "DROP"])
.status();
let mut child_a = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container A");
let ip_a = child_a.container_ip().expect("A's IP");
let mut child_b = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net2.to_string()))
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container B");
let ip_b = child_b.container_ip().expect("B's IP");
let test_cmd = format!("ping -c1 -W1 {} && ping -c1 -W1 {}", ip_a, ip_b);
let mut child_c = Command::new("/bin/sh")
.args(["-c", &test_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_additional_network(net2)
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn container C");
let (status_c, _stdout, _stderr) = child_c.wait_with_output().expect("wait C");
assert!(
status_c.success(),
"container C (both networks) should reach both A and B"
);
let test_cmd_fail = format!("ping -c1 -W1 {}", ip_b);
let mut child_d = Command::new("/bin/sh")
.args(["-c", &test_cmd_fail])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn isolation test");
let (status_d, _stdout, _stderr) = child_d.wait_with_output().expect("wait D");
assert!(
!status_d.success(),
"container on net1 should NOT reach container on net2"
);
unsafe {
libc::kill(child_a.pid(), libc::SIGTERM);
libc::kill(child_b.pid(), libc::SIGTERM);
}
let _ = child_a.wait();
let _ = child_b.wait();
let _ = std::process::Command::new("iptables")
.args(["-D", "FORWARD", "-i", bridge1, "-o", bridge2, "-j", "DROP"])
.status();
let _ = std::process::Command::new("iptables")
.args(["-D", "FORWARD", "-i", bridge2, "-o", bridge1, "-j", "DROP"])
.status();
cleanup_test_network(net1);
cleanup_test_network(net2);
}
#[test]
#[serial(nat)]
fn test_multi_network_teardown() {
if !is_root() {
eprintln!("Skipping test_multi_network_teardown (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_multi_network_teardown (no rootfs)");
return;
}
};
let net1 = "mntd1";
let net2 = "mntd2";
create_test_network(net1, "10.97.1.0/24");
create_test_network(net2, "10.97.2.0/24");
let mut child = Command::new("/bin/true")
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_additional_network(net2)
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn teardown test");
let ns_name = child.netns_name().unwrap().to_string();
let primary_veth = child.veth_name().unwrap().to_string();
let secondary_veths: Vec<String> = child
.secondary_networks()
.iter()
.map(|n| n.veth_host.clone())
.collect();
assert_eq!(
secondary_veths.len(),
1,
"should have one secondary network"
);
child.wait().expect("wait for container");
let ns_path = format!("/run/netns/{}", ns_name);
assert!(
!std::path::Path::new(&ns_path).exists(),
"netns {} should be removed after wait()",
ns_name
);
let veth_check = std::process::Command::new("ip")
.args(["link", "show", &primary_veth])
.stderr(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.status()
.expect("ip link show");
assert!(
!veth_check.success(),
"primary veth {} should be removed",
primary_veth
);
for veth in &secondary_veths {
let check = std::process::Command::new("ip")
.args(["link", "show", veth])
.stderr(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.status()
.expect("ip link show");
assert!(
!check.success(),
"secondary veth {} should be removed",
veth
);
}
cleanup_test_network(net1);
cleanup_test_network(net2);
}
#[test]
#[serial(nat)]
fn test_multi_network_link_resolution() {
if !is_root() {
eprintln!("Skipping test_multi_network_link_resolution (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_multi_network_link_resolution (no rootfs)");
return;
}
};
let net1 = "mnlink1";
let net2 = "mnlink2";
create_test_network(net1, "10.96.1.0/24");
create_test_network(net2, "10.96.2.0/24");
let mut server = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_additional_network(net2)
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn server");
let server_ip_net1 = server.container_ip_on(net1).expect("server IP on net1");
let server_ip_net2 = server.container_ip_on(net2).expect("server IP on net2");
let server_name = "mnlink-server";
let server_dir = pelagos::paths::containers_dir().join(server_name);
std::fs::create_dir_all(&server_dir).expect("create server dir");
let mut network_ips = std::collections::HashMap::new();
network_ips.insert(net1.to_string(), server_ip_net1.clone());
network_ips.insert(net2.to_string(), server_ip_net2.clone());
let state_json = serde_json::json!({
"name": server_name,
"rootfs": rootfs.to_string_lossy(),
"status": "running",
"pid": server.pid(),
"watcher_pid": 0,
"started_at": "2026-01-01T00:00:00Z",
"command": ["/bin/sleep", "30"],
"bridge_ip": server_ip_net1,
"network_ips": network_ips,
});
std::fs::write(
server_dir.join("state.json"),
serde_json::to_string_pretty(&state_json).unwrap(),
)
.expect("write server state");
let mut client = Command::new("/bin/sh")
.args(["-c", "cat /etc/hosts"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net2.to_string()))
.with_nat()
.with_link(server_name)
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, _stderr) = client.wait_with_output().expect("wait client");
let hosts = String::from_utf8_lossy(&stdout_raw);
assert!(
hosts.contains(&server_ip_net2),
"/etc/hosts should contain server's net2 IP {}, got: {}",
server_ip_net2,
hosts.trim()
);
unsafe { libc::kill(server.pid(), libc::SIGTERM) };
let _ = server.wait();
let _ = std::fs::remove_dir_all(&server_dir);
cleanup_test_network(net1);
cleanup_test_network(net2);
}
}
mod dns {
use super::*;
use pelagos::network::{Ipv4Net, NetworkDef};
fn wait_for_dns(ip: std::net::Ipv4Addr, port: u16, timeout_ms: u64) -> bool {
use std::net::{SocketAddr, UdpSocket};
use std::time::Duration;
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
let probe = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01";
while std::time::Instant::now() < deadline {
let sock = match UdpSocket::bind("0.0.0.0:0") {
Ok(s) => s,
Err(_) => {
std::thread::sleep(Duration::from_millis(20));
continue;
}
};
let _ = sock.set_read_timeout(Some(Duration::from_millis(50)));
let addr = SocketAddr::new(std::net::IpAddr::V4(ip), port);
if sock.send_to(probe, addr).is_err() {
std::thread::sleep(Duration::from_millis(20));
continue;
}
let mut buf = [0u8; 512];
match sock.recv_from(&mut buf) {
Ok(_) => return true,
Err(_) => std::thread::sleep(Duration::from_millis(20)),
}
}
false
}
fn cleanup_test_network(name: &str) {
let config_dir = pelagos::paths::network_config_dir(name);
let _ = std::fs::remove_dir_all(&config_dir);
let runtime_dir = pelagos::paths::network_runtime_dir(name);
let _ = std::fs::remove_dir_all(&runtime_dir);
let bridge = format!("rm-{}", name);
let _ = std::process::Command::new("ip")
.args(["link", "del", &bridge])
.stderr(std::process::Stdio::null())
.status();
}
fn create_test_network(name: &str, cidr: &str) {
cleanup_test_network(name);
let subnet = Ipv4Net::from_cidr(cidr).unwrap();
let net = NetworkDef {
name: name.to_string(),
subnet: subnet.clone(),
gateway: subnet.gateway(),
bridge_name: format!("rm-{}", name),
};
net.save().expect("save network");
}
fn cleanup_dns() {
let dns_dir = pelagos::paths::dns_config_dir();
let pid_file = dns_dir.join("pid");
if let Ok(content) = std::fs::read_to_string(&pid_file) {
if let Ok(pid) = content.trim().parse::<i32>() {
unsafe { libc::kill(pid, libc::SIGTERM) };
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
let _ = std::fs::remove_dir_all(&dns_dir);
}
fn unmount_stale_overlays() {
let mounts = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
for line in mounts.lines() {
let mount_point = line.split_whitespace().nth(1).unwrap_or("");
if mount_point.starts_with("/run/pelagos/overlay-") {
unsafe {
libc::umount2(
std::ffi::CString::new(mount_point).unwrap().as_ptr(),
libc::MNT_DETACH,
)
};
}
}
}
#[test]
#[serial(nat)]
fn test_dns_resolves_container_name() {
if !is_root() {
eprintln!("Skipping test_dns_resolves_container_name (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_resolves_container_name (no rootfs)");
return;
}
};
let net_name = "dnstest1";
cleanup_dns();
create_test_network(net_name, "10.90.1.0/24");
let mut server = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn server");
let server_ip = server.container_ip().expect("server should have IP");
let net_def = pelagos::network::load_network_def(net_name).expect("load net def");
pelagos::dns::dns_add_entry(
net_name,
"server-a",
server_ip.parse().unwrap(),
net_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("dns_add_entry");
assert!(
wait_for_dns(net_def.gateway, 53, 2000),
"DNS daemon did not bind to {}:53 within 2s",
net_def.gateway
);
let resolve_cmd = format!(
"nslookup server-a {} 2>&1 || echo 'NSLOOKUP_FAILED'",
net_def.gateway
);
let mut client = Command::new("/bin/sh")
.args(["-c", &resolve_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, stderr_raw) = client.wait_with_output().expect("client wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
let stderr = String::from_utf8_lossy(&stderr_raw);
assert!(
stdout.contains(&server_ip),
"nslookup should resolve server-a to {}, stdout: {}, stderr: {}",
server_ip,
stdout.trim(),
stderr.trim()
);
pelagos::dns::dns_remove_entry(net_name, "server-a").ok();
unsafe { libc::kill(server.pid(), libc::SIGTERM) };
let _ = server.wait();
cleanup_dns();
cleanup_test_network(net_name);
}
#[test]
#[serial(nat)]
fn test_dns_upstream_forward() {
if !is_root() {
eprintln!("Skipping test_dns_upstream_forward (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_upstream_forward (no rootfs)");
return;
}
};
let net_name = "dnstest2";
cleanup_dns();
create_test_network(net_name, "10.90.2.0/24");
let mut holder = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn holder");
let net_def = pelagos::network::load_network_def(net_name).expect("load net def");
pelagos::dns::dns_add_entry(
net_name,
"dummy",
"10.90.2.99".parse().unwrap(),
net_def.gateway,
&["8.8.8.8".to_string(), "1.1.1.1".to_string()],
)
.expect("dns_add_entry");
assert!(
wait_for_dns(net_def.gateway, 53, 2000),
"DNS daemon did not bind to {}:53 within 2s",
net_def.gateway
);
let upstream_ok = {
use std::io::{Read as _, Write as _};
use std::net::TcpStream;
use std::time::Duration;
let query: &[u8] = &[
0xAB, 0xCD, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x00, 0x01, ];
let addr: std::net::SocketAddr = "8.8.8.8:53".parse().unwrap();
(|| -> std::io::Result<bool> {
let mut s = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?;
s.set_read_timeout(Some(Duration::from_secs(5)))?;
let len = (query.len() as u16).to_be_bytes();
s.write_all(&len)?;
s.write_all(query)?;
let mut lbuf = [0u8; 2];
s.read_exact(&mut lbuf)?;
Ok(true)
})()
.unwrap_or(false)
};
if !upstream_ok {
eprintln!(
"Skipping test_dns_upstream_forward: upstream 8.8.8.8:53 not reachable via TCP from host"
);
pelagos::dns::dns_remove_entry(net_name, "dummy").ok();
unsafe { libc::kill(holder.pid(), libc::SIGTERM) };
let _ = holder.wait();
cleanup_dns();
cleanup_test_network(net_name);
return;
}
let resolve_cmd = format!(
"timeout 10 nslookup example.com {} 2>&1 || echo 'NSLOOKUP_FAILED'",
net_def.gateway
);
let mut client = Command::new("/bin/sh")
.args(["-c", &resolve_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, stderr_raw) = client.wait_with_output().expect("client wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
let stderr = String::from_utf8_lossy(&stderr_raw);
assert!(
!stdout.contains("NSLOOKUP_FAILED")
&& (stdout.contains("Address") || stdout.contains("Name")),
"nslookup example.com should succeed via upstream, stdout: {}, stderr: {}",
stdout.trim(),
stderr.trim()
);
pelagos::dns::dns_remove_entry(net_name, "dummy").ok();
unsafe { libc::kill(holder.pid(), libc::SIGTERM) };
let _ = holder.wait();
cleanup_dns();
cleanup_test_network(net_name);
}
#[test]
#[serial(nat)]
fn test_dns_network_isolation() {
if !is_root() {
eprintln!("Skipping test_dns_network_isolation (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_network_isolation (no rootfs)");
return;
}
};
let net1 = "dnsiso1";
let net2 = "dnsiso2";
cleanup_dns();
create_test_network(net1, "10.90.3.0/24");
create_test_network(net2, "10.90.4.0/24");
let mut holder1 = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn holder1");
let mut holder2 = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net2.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn holder2");
let net1_def = pelagos::network::load_network_def(net1).expect("load net1");
pelagos::dns::dns_add_entry(
net1,
"alpha",
"10.90.3.5".parse().unwrap(),
net1_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("add alpha");
let net2_def = pelagos::network::load_network_def(net2).expect("load net2");
pelagos::dns::dns_add_entry(
net2,
"beta",
"10.90.4.5".parse().unwrap(),
net2_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("add beta");
std::thread::sleep(std::time::Duration::from_millis(200));
let resolve_cmd = format!("nslookup alpha {} 2>&1; echo EXIT=$?", net2_def.gateway);
let mut client = Command::new("/bin/sh")
.args(["-c", &resolve_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net2.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, _stderr) = client.wait_with_output().expect("client wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
assert!(
!stdout.contains("10.90.3.5"),
"alpha's IP should NOT be resolvable from net2, got: {}",
stdout.trim()
);
let resolve_beta = format!("nslookup beta {} 2>&1", net2_def.gateway);
let mut client2 = Command::new("/bin/sh")
.args(["-c", &resolve_beta])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net2.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client2");
let (_status, stdout_raw, _stderr) = client2.wait_with_output().expect("client2 wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
assert!(
stdout.contains("10.90.4.5"),
"beta should resolve on net2 to 10.90.4.5, got: {}",
stdout.trim()
);
pelagos::dns::dns_remove_entry(net1, "alpha").ok();
pelagos::dns::dns_remove_entry(net2, "beta").ok();
unsafe { libc::kill(holder1.pid(), libc::SIGTERM) };
unsafe { libc::kill(holder2.pid(), libc::SIGTERM) };
let _ = holder1.wait();
let _ = holder2.wait();
cleanup_dns();
cleanup_test_network(net1);
cleanup_test_network(net2);
}
#[test]
#[serial(nat)]
fn test_dns_multi_network() {
if !is_root() {
eprintln!("Skipping test_dns_multi_network (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_multi_network (no rootfs)");
return;
}
};
let net1 = "dnsmn1";
let net2 = "dnsmn2";
cleanup_dns();
unmount_stale_overlays();
create_test_network(net1, "10.90.5.0/24");
create_test_network(net2, "10.90.6.0/24");
let mut server = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net1.to_string()))
.with_additional_network(net2)
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn server");
let ip_net1 = server.container_ip().expect("server IP on net1");
let ip_net2 = server.container_ip_on(net2).expect("server IP on net2");
assert!(ip_net1.starts_with("10.90.5."), "net1 IP: {}", ip_net1);
assert!(ip_net2.starts_with("10.90.6."), "net2 IP: {}", ip_net2);
let net1_def = pelagos::network::load_network_def(net1).expect("load net1");
let net2_def = pelagos::network::load_network_def(net2).expect("load net2");
pelagos::dns::dns_add_entry(
net1,
"multi-a",
ip_net1.parse().unwrap(),
net1_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("add to net1");
pelagos::dns::dns_add_entry(
net2,
"multi-a",
ip_net2.parse().unwrap(),
net2_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("add to net2");
std::thread::sleep(std::time::Duration::from_millis(200));
let resolve_cmd = format!("nslookup multi-a {} 2>&1", net2_def.gateway);
let mut client = Command::new("/bin/sh")
.args(["-c", &resolve_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net2.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, _stderr) = client.wait_with_output().expect("client wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
assert!(
stdout.contains(&ip_net2),
"should resolve multi-a to net2 IP {}, got: {}",
ip_net2,
stdout.trim()
);
pelagos::dns::dns_remove_entry(net1, "multi-a").ok();
pelagos::dns::dns_remove_entry(net2, "multi-a").ok();
unsafe { libc::kill(server.pid(), libc::SIGTERM) };
let _ = server.wait();
cleanup_dns();
cleanup_test_network(net1);
cleanup_test_network(net2);
}
#[test]
#[serial(nat)]
fn test_dns_daemon_lifecycle() {
if !is_root() {
eprintln!("Skipping test_dns_daemon_lifecycle (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_daemon_lifecycle (no rootfs)");
return;
}
};
let net_name = "dnslc";
cleanup_dns();
create_test_network(net_name, "10.90.7.0/24");
let mut holder = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn holder");
let net_def = pelagos::network::load_network_def(net_name).expect("load net def");
let pid_file = pelagos::paths::dns_pid_file();
assert!(
!pid_file.exists(),
"PID file should not exist before any DNS entries"
);
pelagos::dns::dns_add_entry(
net_name,
"lifecycle-test",
"10.90.7.5".parse().unwrap(),
net_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("dns_add_entry");
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(
pid_file.exists(),
"PID file should exist after DNS entry added"
);
let pid_str = std::fs::read_to_string(&pid_file).expect("read PID file");
let pid: i32 = pid_str.trim().parse().expect("parse PID");
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"DNS daemon (PID {}) should be alive",
pid
);
pelagos::dns::dns_remove_entry(net_name, "lifecycle-test").expect("dns_remove_entry");
std::thread::sleep(std::time::Duration::from_millis(500));
assert!(
unsafe { libc::kill(pid, 0) } != 0,
"DNS daemon (PID {}) should have exited after last entry removed",
pid
);
unsafe { libc::kill(holder.pid(), libc::SIGTERM) };
let _ = holder.wait();
cleanup_dns();
cleanup_test_network(net_name);
}
fn has_dnsmasq() -> bool {
std::process::Command::new("which")
.arg("dnsmasq")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
#[test]
#[serial(nat)]
fn test_dns_dnsmasq_resolves_container_name() {
if !is_root() {
eprintln!("Skipping test_dns_dnsmasq_resolves_container_name (requires root)");
return;
}
if !has_dnsmasq() {
eprintln!("Skipping test_dns_dnsmasq_resolves_container_name (dnsmasq not found)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_dnsmasq_resolves_container_name (no rootfs)");
return;
}
};
let net_name = "dnsmq1";
cleanup_dns();
create_test_network(net_name, "10.90.11.0/24");
unsafe { std::env::set_var("PELAGOS_DNS_BACKEND", "dnsmasq") };
let mut server = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn server");
let server_ip = server.container_ip().expect("server should have IP");
let net_def = pelagos::network::load_network_def(net_name).expect("load net def");
pelagos::dns::dns_add_entry(
net_name,
"dnsmasq-server",
server_ip.parse().unwrap(),
net_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("dns_add_entry");
std::thread::sleep(std::time::Duration::from_millis(500));
let backend_file = pelagos::paths::dns_backend_file();
if backend_file.exists() {
let backend = std::fs::read_to_string(&backend_file).unwrap_or_default();
assert_eq!(
backend.trim(),
"dnsmasq",
"backend marker should say dnsmasq"
);
}
let resolve_cmd = format!(
"nslookup dnsmasq-server {} 2>&1 || echo 'NSLOOKUP_FAILED'",
net_def.gateway
);
let mut client = Command::new("/bin/sh")
.args(["-c", &resolve_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, stderr_raw) = client.wait_with_output().expect("client wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
let stderr = String::from_utf8_lossy(&stderr_raw);
assert!(
stdout.contains(&server_ip),
"nslookup should resolve dnsmasq-server to {}, stdout: {}, stderr: {}",
server_ip,
stdout.trim(),
stderr.trim()
);
pelagos::dns::dns_remove_entry(net_name, "dnsmasq-server").ok();
unsafe { libc::kill(server.pid(), libc::SIGTERM) };
let _ = server.wait();
cleanup_dns();
cleanup_test_network(net_name);
unsafe { std::env::remove_var("PELAGOS_DNS_BACKEND") };
}
#[test]
#[serial(nat)]
fn test_dns_dnsmasq_upstream_forward() {
if !is_root() {
eprintln!("Skipping test_dns_dnsmasq_upstream_forward (requires root)");
return;
}
if !has_dnsmasq() {
eprintln!("Skipping test_dns_dnsmasq_upstream_forward (dnsmasq not found)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_dnsmasq_upstream_forward (no rootfs)");
return;
}
};
let net_name = "dnsmq2";
cleanup_dns();
create_test_network(net_name, "10.90.12.0/24");
unsafe { std::env::set_var("PELAGOS_DNS_BACKEND", "dnsmasq") };
let mut holder = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn holder");
let net_def = pelagos::network::load_network_def(net_name).expect("load net def");
pelagos::dns::dns_add_entry(
net_name,
"dummy-fwd",
"10.90.12.5".parse().unwrap(),
net_def.gateway,
&["8.8.8.8".to_string(), "1.1.1.1".to_string()],
)
.expect("dns_add_entry");
std::thread::sleep(std::time::Duration::from_millis(500));
let resolve_cmd = format!(
"nslookup example.com {} 2>&1 || echo 'NSLOOKUP_FAILED'",
net_def.gateway
);
let mut client = Command::new("/bin/sh")
.args(["-c", &resolve_cmd])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn client");
let (_status, stdout_raw, stderr_raw) = client.wait_with_output().expect("client wait");
let stdout = String::from_utf8_lossy(&stdout_raw);
let stderr = String::from_utf8_lossy(&stderr_raw);
assert!(
!stdout.contains("NSLOOKUP_FAILED")
&& (stdout.contains("Address") || stdout.contains("Name")),
"dnsmasq should forward upstream queries, stdout: {}, stderr: {}",
stdout.trim(),
stderr.trim()
);
pelagos::dns::dns_remove_entry(net_name, "dummy-fwd").ok();
unsafe { libc::kill(holder.pid(), libc::SIGTERM) };
let _ = holder.wait();
cleanup_dns();
cleanup_test_network(net_name);
unsafe { std::env::remove_var("PELAGOS_DNS_BACKEND") };
}
#[test]
#[serial(nat)]
fn test_dns_dnsmasq_lifecycle() {
if !is_root() {
eprintln!("Skipping test_dns_dnsmasq_lifecycle (requires root)");
return;
}
if !has_dnsmasq() {
eprintln!("Skipping test_dns_dnsmasq_lifecycle (dnsmasq not found)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_dns_dnsmasq_lifecycle (no rootfs)");
return;
}
};
let net_name = "dnsmqlc";
cleanup_dns();
create_test_network(net_name, "10.90.13.0/24");
unsafe { std::env::set_var("PELAGOS_DNS_BACKEND", "dnsmasq") };
let mut holder = Command::new("/bin/sleep")
.args(["30"])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(NetworkMode::BridgeNamed(net_name.to_string()))
.with_nat()
.with_chroot(&rootfs)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn holder");
let net_def = pelagos::network::load_network_def(net_name).expect("load net def");
let pid_file = pelagos::paths::dns_pid_file();
assert!(
!pid_file.exists(),
"PID file should not exist before DNS entries"
);
pelagos::dns::dns_add_entry(
net_name,
"lifecycle-dnsmasq",
"10.90.13.5".parse().unwrap(),
net_def.gateway,
&["8.8.8.8".to_string()],
)
.expect("dns_add_entry");
std::thread::sleep(std::time::Duration::from_millis(500));
assert!(
pid_file.exists(),
"PID file should exist after DNS entry added"
);
let pid_str = std::fs::read_to_string(&pid_file).expect("read PID file");
let pid: i32 = pid_str.trim().parse().expect("parse PID");
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"dnsmasq daemon (PID {}) should be alive",
pid
);
let backend_file = pelagos::paths::dns_backend_file();
assert!(backend_file.exists(), "backend marker should exist");
let marker = std::fs::read_to_string(&backend_file).unwrap();
assert_eq!(marker.trim(), "dnsmasq", "backend should be dnsmasq");
pelagos::dns::dns_remove_entry(net_name, "lifecycle-dnsmasq").expect("dns_remove_entry");
unsafe { libc::kill(pid, libc::SIGTERM) };
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(
unsafe { libc::kill(pid, 0) } != 0,
"dnsmasq daemon (PID {}) should have exited after SIGTERM",
pid
);
unsafe { libc::kill(holder.pid(), libc::SIGTERM) };
let _ = holder.wait();
cleanup_dns();
cleanup_test_network(net_name);
unsafe { std::env::remove_var("PELAGOS_DNS_BACKEND") };
}
#[test]
#[serial(nat)]
fn test_dns_stale_config_removed_on_bind_failure() {
if !is_root() {
eprintln!("Skipping test_dns_stale_config_removed_on_bind_failure (requires root)");
return;
}
let config_dir = pelagos::paths::dns_config_dir();
std::fs::create_dir_all(&config_dir).unwrap();
let stale_net = "stale-168";
let stale_config = config_dir.join(stale_net);
std::fs::write(
&stale_config,
"192.0.2.1 8.8.8.8\norphan-container 192.0.2.10\n",
)
.unwrap();
assert!(
stale_config.exists(),
"stale config should exist before test"
);
let dns_bin = std::path::Path::new(env!("CARGO_BIN_EXE_pelagos-dns"));
let mut daemon = std::process::Command::new(dns_bin)
.arg("--config-dir")
.arg(&config_dir)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn pelagos-dns");
std::thread::sleep(std::time::Duration::from_millis(800));
assert!(
!stale_config.exists(),
"stale config '{}' should have been removed by the daemon after EADDRNOTAVAIL",
stale_config.display()
);
std::thread::sleep(std::time::Duration::from_millis(500));
let exited = daemon.try_wait().unwrap().is_some();
if !exited {
let _ = daemon.kill();
}
let output = daemon.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("stale config"),
"expected 'stale config' message in daemon stderr, got: {}",
stderr
);
assert!(
!stderr.contains("failed to bind"),
"daemon should not log 'failed to bind' for a stale config, got: {}",
stderr
);
}
}
#[test]
#[serial(nat)]
fn test_child_drop_cleans_up_netns() {
if !is_root() {
eprintln!("Skipping test_child_drop_cleans_up_netns (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_child_drop_cleans_up_netns (no rootfs)");
return;
}
};
let child = Command::new("/bin/sleep")
.args(["60"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT | Namespace::NET | Namespace::PID)
.with_proc_mount()
.with_network(NetworkMode::Bridge)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("Failed to spawn container for drop test");
let ns_name = child
.netns_name()
.expect("bridge container should have netns name")
.to_string();
let netns_path = std::path::Path::new("/run/netns").join(&ns_name);
assert!(
netns_path.exists(),
"netns {} should exist before drop",
ns_name
);
drop(child);
std::thread::sleep(std::time::Duration::from_millis(200));
assert!(
!netns_path.exists(),
"netns {} should be removed after drop",
ns_name
);
}
#[test]
fn test_sexpr_parse_compose_file() {
let input = r#"
; A typical web application stack
(compose
(network backend (subnet "10.88.1.0/24"))
(network frontend (subnet "10.88.2.0/24"))
(volume pgdata)
(service db
(image "postgres:16")
(network backend)
(volume pgdata "/var/lib/postgresql/data")
(env POSTGRES_PASSWORD "secret")
(port 5432 5432)
(memory "512m"))
(service api
(image "my-api:latest")
(network backend frontend)
(depends-on (db :ready-port 5432))
(env DATABASE_URL "postgres://db:5432/app")
(port 8080 8080)
(cpus "1.0"))
(service web
(image "my-web:latest")
(network frontend)
(depends-on (api :ready-port 8080))
(port 80 3000)
(command "/bin/sh" "-c" "nginx -g 'daemon off;'")))
"#;
let expr = pelagos::sexpr::parse(input).expect("should parse compose file");
let items = expr.as_list().expect("top-level should be a list");
assert_eq!(items[0].as_atom().unwrap(), "compose");
assert_eq!(items.len(), 7);
}
#[test]
fn test_compose_parse_and_validate() {
let input = r#"
(compose
(network backend (subnet "10.88.1.0/24"))
(volume data)
(service db
(image "postgres:16")
(network backend)
(volume data "/var/lib/postgresql/data")
(env POSTGRES_PASSWORD "secret")
(port 5432 5432)
(memory "512m"))
(service api
(image "my-api:latest")
(network backend)
(depends-on (db :ready-port 5432))
(port 8080 8080)))
"#;
let compose = pelagos::compose::parse_compose(input).expect("should parse and validate");
assert_eq!(compose.networks.len(), 1);
assert_eq!(compose.networks[0].name, "backend");
assert_eq!(compose.networks[0].subnet.as_deref(), Some("10.88.1.0/24"));
assert_eq!(compose.volumes, vec!["data"]);
assert_eq!(compose.services.len(), 2);
let db = &compose.services[0];
assert_eq!(db.name, "db");
assert_eq!(db.image, "postgres:16");
assert_eq!(db.networks, vec!["backend"]);
assert_eq!(db.volumes[0].name, "data");
assert_eq!(db.volumes[0].mount_path, "/var/lib/postgresql/data");
assert_eq!(db.env.get("POSTGRES_PASSWORD").unwrap(), "secret");
assert_eq!(db.ports[0].host, 5432);
assert_eq!(db.ports[0].container, 5432);
assert_eq!(db.memory.as_deref(), Some("512m"));
let api = &compose.services[1];
assert_eq!(api.depends_on.len(), 1);
assert_eq!(api.depends_on[0].service, "db");
assert_eq!(
api.depends_on[0].health_check,
Some(pelagos::compose::HealthCheck::Port(5432))
);
}
#[test]
fn test_compose_topo_sort() {
let input = r#"
(compose
(service web
(image "web")
(depends-on api))
(service api
(image "api")
(depends-on db))
(service db
(image "db")))
"#;
let compose = pelagos::compose::parse_compose(input).expect("should parse");
let order = pelagos::compose::topo_sort(&compose.services).expect("should topo-sort");
let db_pos = order.iter().position(|n| n == "db").unwrap();
let api_pos = order.iter().position(|n| n == "api").unwrap();
let web_pos = order.iter().position(|n| n == "web").unwrap();
assert!(
db_pos < api_pos,
"db ({}) must come before api ({})",
db_pos,
api_pos
);
assert!(
api_pos < web_pos,
"api ({}) must come before web ({})",
api_pos,
web_pos
);
}
#[test]
fn test_compose_cycle_detection() {
let input = r#"
(compose
(service a
(image "a")
(depends-on b))
(service b
(image "b")
(depends-on a)))
"#;
let err = pelagos::compose::parse_compose(input).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("cycle"), "expected cycle error, got: {}", msg);
}
#[test]
fn test_compose_unknown_dependency() {
let input = r#"
(compose
(service a
(image "a")
(depends-on nonexistent)))
"#;
let err = pelagos::compose::parse_compose(input).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("unknown service"),
"expected unknown dependency error, got: {}",
msg
);
}
#[test]
#[serial]
fn test_compose_up_down_single_service() {
if !is_root() {
eprintln!("skipping test_compose_up_down_single_service: requires root");
return;
}
let _rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("skipping test_compose_up_down_single_service: no alpine-rootfs");
return;
}
};
let project_name = "test-compose";
let project_dir = pelagos::paths::compose_project_dir(project_name);
let _ = std::fs::remove_dir_all(&project_dir);
std::fs::create_dir_all(pelagos::paths::compose_project_dir(project_name))
.expect("should create compose project dir");
assert!(
pelagos::paths::compose_project_dir(project_name).exists(),
"compose project dir should exist"
);
let _ = std::fs::remove_dir_all(&project_dir);
}
#[test]
fn test_compose_bind_mount_parse_and_validate() {
let input = r#"
(compose
(network monitoring (subnet "172.20.0.0/24"))
(volume grafana-data)
; Prometheus: two read-only bind mounts (config + rules dir)
(service prometheus
(image "prom/prometheus:latest")
(network monitoring)
(port 9090 9090)
(bind-mount "./config/prometheus.yml" "/etc/prometheus/prometheus.yml" :ro)
(bind-mount "./config/rules" "/etc/prometheus/rules" :ro))
; Grafana: named volume + read-only provisioning bind mount + rw data
(service grafana
(image "grafana/grafana:latest")
(network monitoring)
(port 3000 3000)
(volume grafana-data "/var/lib/grafana")
(bind-mount "./config/grafana/provisioning" "/etc/grafana/provisioning" :ro)
(env GF_SECURITY_ADMIN_PASSWORD "secret")
(depends-on (prometheus :ready-port 9090)))
; SNMP exporter: single read-only config
(service snmp-exporter
(image "prom/snmp-exporter:v0.21.0")
(network monitoring)
(port 9116 9116)
(bind-mount "./config/snmp.yml" "/etc/snmp_exporter/snmp.yml" :ro)))
"#;
let compose = pelagos::compose::parse_compose(input).expect("should parse and validate");
assert_eq!(compose.networks.len(), 1);
assert_eq!(compose.volumes, vec!["grafana-data"]);
assert_eq!(compose.services.len(), 3);
let prom = compose
.services
.iter()
.find(|s| s.name == "prometheus")
.unwrap();
assert_eq!(prom.bind_mounts.len(), 2);
assert_eq!(prom.bind_mounts[0].host_path, "./config/prometheus.yml");
assert_eq!(
prom.bind_mounts[0].container_path,
"/etc/prometheus/prometheus.yml"
);
assert!(prom.bind_mounts[0].read_only, "prometheus.yml must be :ro");
assert_eq!(prom.bind_mounts[1].host_path, "./config/rules");
assert!(prom.bind_mounts[1].read_only);
assert!(prom.volumes.is_empty());
let grafana = compose
.services
.iter()
.find(|s| s.name == "grafana")
.unwrap();
assert_eq!(grafana.volumes.len(), 1);
assert_eq!(grafana.volumes[0].name, "grafana-data");
assert_eq!(grafana.bind_mounts.len(), 1);
assert!(grafana.bind_mounts[0].read_only);
assert_eq!(grafana.depends_on[0].service, "prometheus");
assert_eq!(
grafana.depends_on[0].health_check,
Some(pelagos::compose::HealthCheck::Port(9090))
);
let snmp = compose
.services
.iter()
.find(|s| s.name == "snmp-exporter")
.unwrap();
assert_eq!(snmp.bind_mounts.len(), 1);
assert_eq!(
snmp.bind_mounts[0].container_path,
"/etc/snmp_exporter/snmp.yml"
);
assert!(snmp.bind_mounts[0].read_only);
let order = pelagos::compose::topo_sort(&compose.services).unwrap();
let prom_pos = order.iter().position(|n| n == "prometheus").unwrap();
let grafana_pos = order.iter().position(|n| n == "grafana").unwrap();
assert!(
prom_pos < grafana_pos,
"prometheus must start before grafana"
);
}
#[test]
fn test_compose_tmpfs_parse_and_validate() {
let input = r#"
(compose
(network cache (subnet "10.77.0.0/24"))
(service redis
(image "redis:7")
(network cache)
(tmpfs "/data"))
(service app
(image "my-app:latest")
(network cache)
(tmpfs "/tmp")
(tmpfs "/run")
(depends-on redis)))
"#;
let compose = pelagos::compose::parse_compose(input).expect("should parse and validate");
assert_eq!(compose.services.len(), 2);
let redis = compose.services.iter().find(|s| s.name == "redis").unwrap();
assert_eq!(redis.tmpfs_mounts.len(), 1);
assert_eq!(redis.tmpfs_mounts[0], "/data");
assert!(redis.bind_mounts.is_empty());
assert!(redis.volumes.is_empty());
let app = compose.services.iter().find(|s| s.name == "app").unwrap();
assert_eq!(app.tmpfs_mounts.len(), 2);
assert_eq!(app.tmpfs_mounts[0], "/tmp");
assert_eq!(app.tmpfs_mounts[1], "/run");
assert_eq!(app.depends_on[0].service, "redis");
let order = pelagos::compose::topo_sort(&compose.services).unwrap();
let redis_pos = order.iter().position(|n| n == "redis").unwrap();
let app_pos = order.iter().position(|n| n == "app").unwrap();
assert!(redis_pos < app_pos, "redis must start before app");
}
#[test]
fn test_compose_health_check_parse() {
use pelagos::compose::HealthCheck;
let input = r#"
(compose
(network net (subnet "10.77.1.0/24"))
; port check (new :ready syntax)
(service svc-port
(image "img")
(network net)
(depends-on (base :ready (port 5432))))
; http check
(service svc-http
(image "img")
(network net)
(depends-on (base :ready (http "http://localhost:8080/healthz"))))
; cmd check (single-string form, split on whitespace)
(service svc-cmd
(image "img")
(network net)
(depends-on (base :ready (cmd "pg_isready -U postgres"))))
; and check
(service svc-and
(image "img")
(network net)
(depends-on (base :ready (and (port 5432) (cmd "pg_isready")))))
; or check
(service svc-or
(image "img")
(network net)
(depends-on (base :ready (or (port 8080) (http "http://localhost:8080/health")))))
; backward compat: :ready-port N stays as Port(N)
(service svc-compat
(image "img")
(network net)
(depends-on (base :ready-port 6379)))
(service base
(image "img")
(network net)))
"#;
let compose = pelagos::compose::parse_compose(input).expect("should parse");
assert_eq!(compose.services.len(), 7);
let find = |name: &str| {
compose
.services
.iter()
.find(|s| s.name == name)
.unwrap()
.depends_on[0]
.health_check
.clone()
};
assert_eq!(find("svc-port"), Some(HealthCheck::Port(5432)));
assert_eq!(
find("svc-http"),
Some(HealthCheck::Http(
"http://localhost:8080/healthz".to_string()
))
);
assert_eq!(
find("svc-cmd"),
Some(HealthCheck::Cmd(vec![
"pg_isready".into(),
"-U".into(),
"postgres".into()
]))
);
assert_eq!(
find("svc-and"),
Some(HealthCheck::And(vec![
HealthCheck::Port(5432),
HealthCheck::Cmd(vec!["pg_isready".into()])
]))
);
assert_eq!(
find("svc-or"),
Some(HealthCheck::Or(vec![
HealthCheck::Port(8080),
HealthCheck::Http("http://localhost:8080/health".into())
]))
);
assert_eq!(find("svc-compat"), Some(HealthCheck::Port(6379)));
let base = compose.services.iter().find(|s| s.name == "base").unwrap();
assert!(base.depends_on.is_empty());
}
#[test]
fn test_lisp_compose_basic() {
use pelagos::lisp::Interpreter;
let mut interp = Interpreter::new();
interp
.eval_str(
r#"
; Parameterised service factory
(define (mk-service name img net)
(service name
(list 'image img)
(list 'network net)))
; Build three services with map
(define services
(map (lambda (pair)
(mk-service (car pair) (cadr pair) "backend"))
'(("db" "postgres:16")
("api" "myapi:latest")
("web" "nginx:stable"))))
; Register an on-ready hook
(on-ready "db" (lambda () (log "db ready")))
; Store the spec via compose-up
(compose-up
(compose
(network "backend" (list 'subnet "10.90.0.0/24"))
services))
"#,
)
.expect("eval_str failed");
let pending = interp.take_pending().expect("no pending compose");
let spec = pending.spec.expect("no spec in pending");
assert_eq!(spec.networks.len(), 1);
assert_eq!(spec.networks[0].name, "backend");
assert_eq!(spec.services.len(), 3);
let names: Vec<&str> = spec.services.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"db"), "db missing from {:?}", names);
assert!(names.contains(&"api"), "api missing from {:?}", names);
assert!(names.contains(&"web"), "web missing from {:?}", names);
let db = spec.services.iter().find(|s| s.name == "db").unwrap();
assert_eq!(db.image, "postgres:16");
assert_eq!(db.networks, vec!["backend"]);
let hooks = interp.take_hooks();
assert!(hooks.contains_key("db"), "no hook for 'db'");
}
#[test]
fn test_compose_declarative_through_evaluator() {
use pelagos::lisp::Interpreter;
let mut interp = Interpreter::new();
interp
.eval_str(
r#"
(compose-up
(compose
(network "frontend" '(subnet "10.88.1.0/24"))
(network "backend" '(subnet "10.88.2.0/24"))
(volume "data")
(service "db"
'(image "postgres:16")
'(network "backend")
'(memory "256m"))
(service "api"
'(image "myapi:latest")
'(network "frontend" "backend")
(list 'depends-on "db" 5432)
'(port 8080 8080))
(service "proxy"
'(image "nginx:stable")
'(network "frontend")
(list 'depends-on "api" 8080)
'(port 80 80))))
"#,
)
.expect("declarative compose should evaluate without error");
let pending = interp
.take_pending()
.expect("compose-up should register a pending spec");
let spec = pending.spec.expect("spec must be present");
assert_eq!(spec.networks.len(), 2);
assert_eq!(spec.volumes, vec!["data"]);
assert_eq!(spec.services.len(), 3);
let order = pelagos::compose::topo_sort(&spec.services).unwrap();
let db_pos = order.iter().position(|n| n == "db").unwrap();
let api_pos = order.iter().position(|n| n == "api").unwrap();
let proxy_pos = order.iter().position(|n| n == "proxy").unwrap();
assert!(db_pos < api_pos, "db must start before api");
assert!(api_pos < proxy_pos, "api must start before proxy");
let api = spec.services.iter().find(|s| s.name == "api").unwrap();
assert_eq!(api.networks, vec!["frontend", "backend"]);
assert_eq!(api.depends_on[0].service, "db");
assert_eq!(
api.depends_on[0].health_check,
Some(pelagos::compose::HealthCheck::Port(5432))
);
let proxy = spec.services.iter().find(|s| s.name == "proxy").unwrap();
assert_eq!(proxy.ports[0].host, 80);
assert_eq!(proxy.ports[0].container, 80);
}
#[test]
fn test_compose_default_file_is_reml() {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_pelagos"))
.args(["compose", "up", "--help"])
.output()
.expect("pelagos binary must be present");
let help = String::from_utf8_lossy(&out.stdout);
assert!(
help.contains("compose.reml"),
"compose up --help must show compose.reml as default, got:\n{}",
help
);
assert!(
!help.contains("compose.rem ") && !help.contains("compose.rem]"),
"compose.rem must not appear as default in --help, got:\n{}",
help
);
}
#[test]
fn test_lisp_evaluator_tco_and_higher_order() {
use pelagos::lisp::Interpreter;
let mut interp = Interpreter::new();
let sum = interp
.eval_str(
"(define (sum-to n)
(let loop ((i n) (acc 0))
(if (= i 0) acc (loop (- i 1) (+ acc i)))))
(sum-to 10000)",
)
.expect("eval failed");
assert_eq!(sum, pelagos::lisp::Value::Int(50005000));
let squares = interp
.eval_str("(map (lambda (x) (* x x)) '(1 2 3 4 5))")
.expect("map failed");
let items = squares.to_vec().expect("not a list");
assert_eq!(items.len(), 5);
assert_eq!(items[4], pelagos::lisp::Value::Int(25));
}
#[test]
fn test_lisp_eval_file_web_stack_fixture() {
use pelagos::lisp::Interpreter;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/compose/web-stack/compose.reml");
assert!(fixture.exists(), "fixture not found: {}", fixture.display());
let mut interp = Interpreter::new();
interp
.eval_file(&fixture)
.unwrap_or_else(|e| panic!("eval_file failed: {}", e.message));
let pending = interp.take_pending().expect("compose-up was not called");
let spec = pending.spec.expect("no spec in pending");
assert_eq!(spec.networks.len(), 2);
let net_names: Vec<&str> = spec.networks.iter().map(|n| n.name.as_str()).collect();
assert!(net_names.contains(&"frontend"), "missing frontend network");
assert!(net_names.contains(&"backend"), "missing backend network");
let frontend = spec.networks.iter().find(|n| n.name == "frontend").unwrap();
assert_eq!(
frontend.subnet.as_deref(),
Some("10.88.1.0/24"),
"frontend subnet wrong"
);
assert_eq!(spec.volumes, vec!["notes-data"]);
assert_eq!(spec.services.len(), 3);
let names: Vec<&str> = spec.services.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"redis"), "redis missing");
assert!(names.contains(&"app"), "app missing");
assert!(names.contains(&"proxy"), "proxy missing");
let redis = spec.services.iter().find(|s| s.name == "redis").unwrap();
assert_eq!(redis.image, "web-stack-redis:latest");
assert_eq!(redis.networks, vec!["backend"]);
assert_eq!(redis.memory.as_deref(), Some("64m"));
assert!(redis.depends_on.is_empty());
let app = spec.services.iter().find(|s| s.name == "app").unwrap();
assert_eq!(app.networks, vec!["frontend", "backend"]);
assert_eq!(app.depends_on.len(), 1);
assert_eq!(app.depends_on[0].service, "redis");
assert!(
matches!(
app.depends_on[0].health_check,
Some(pelagos::compose::HealthCheck::Port(6379))
),
"app should depend on redis:6379 TCP check"
);
assert_eq!(app.env.get("REDIS_HOST").map(String::as_str), Some("redis"));
let proxy = spec.services.iter().find(|s| s.name == "proxy").unwrap();
assert_eq!(proxy.networks, vec!["frontend"]);
assert_eq!(proxy.depends_on.len(), 1);
assert_eq!(proxy.depends_on[0].service, "app");
assert!(
matches!(
proxy.depends_on[0].health_check,
Some(pelagos::compose::HealthCheck::Port(5000))
),
"proxy should depend on app:5000 TCP check"
);
assert_eq!(proxy.ports.len(), 1);
assert_eq!(
proxy.ports[0].host, 8080,
"default host port should be 8080"
);
assert_eq!(proxy.ports[0].container, 80);
let hooks = interp.take_hooks();
assert!(
hooks.contains_key("redis"),
"on-ready hook for 'redis' missing"
);
assert!(hooks.contains_key("app"), "on-ready hook for 'app' missing");
}
#[test]
fn test_lisp_depends_on_with_port() {
use pelagos::lisp::Interpreter;
let mut interp = Interpreter::new();
interp
.eval_str(
r#"
(compose-up
(compose
(service "worker"
'(image "myapp:latest")
(list 'depends-on "db" 5432)
(list 'depends-on "cache"))))
"#,
)
.expect("eval failed");
let spec = interp
.take_pending()
.expect("no pending")
.spec
.expect("no spec");
let worker = spec.services.iter().find(|s| s.name == "worker").unwrap();
assert_eq!(worker.depends_on.len(), 2);
let dep_db = worker
.depends_on
.iter()
.find(|d| d.service == "db")
.unwrap();
assert!(
matches!(
dep_db.health_check,
Some(pelagos::compose::HealthCheck::Port(5432))
),
"db dependency should have Port(5432)"
);
let dep_cache = worker
.depends_on
.iter()
.find(|d| d.service == "cache")
.unwrap();
assert!(
dep_cache.health_check.is_none(),
"cache dependency should have no health check"
);
}
#[test]
fn test_lisp_env_fallback_and_override() {
use pelagos::lisp::Interpreter;
unsafe { std::env::remove_var("_PELAGOS_TEST_PORT") };
let mut interp = Interpreter::new();
let v = interp
.eval_str(
r#"(let ((p (env "_PELAGOS_TEST_PORT")))
(if (null? p) 9999 (string->number p)))"#,
)
.expect("eval failed");
assert_eq!(v, pelagos::lisp::Value::Int(9999));
unsafe { std::env::set_var("_PELAGOS_TEST_PORT", "1234") };
let v2 = interp
.eval_str(
r#"(let ((p (env "_PELAGOS_TEST_PORT")))
(if (null? p) 9999 (string->number p)))"#,
)
.expect("eval failed");
assert_eq!(v2, pelagos::lisp::Value::Int(1234));
unsafe { std::env::remove_var("_PELAGOS_TEST_PORT") };
}
#[test]
fn test_lisp_eval_file_jupyter_fixture() {
use pelagos::compose::{HealthCheck, ServiceSpec};
use pelagos::lisp::Interpreter;
let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("examples/compose/jupyter/compose.reml");
unsafe { std::env::remove_var("JUPYTER_PORT") };
let mut interp = Interpreter::new();
interp.eval_file(&fixture).expect("eval_file failed");
let pending = interp.take_pending().expect("compose-up was not called");
let spec = pending.spec.expect("compose-up produced no spec");
assert_eq!(spec.networks.len(), 1, "expected 1 network");
assert_eq!(spec.networks[0].name, "jupyter-net");
assert_eq!(
spec.networks[0].subnet.as_deref(),
Some("10.89.0.0/24"),
"wrong subnet"
);
assert!(
spec.volumes.contains(&"jupyter-notebooks".to_string()),
"missing jupyter-notebooks volume"
);
assert_eq!(spec.services.len(), 2, "expected 2 services");
let redis = spec
.services
.iter()
.find(|s: &&ServiceSpec| s.name == "redis")
.expect("redis service missing");
assert_eq!(redis.image, "jupyter-redis:latest");
assert!(redis.depends_on.is_empty(), "redis should have no deps");
assert_eq!(redis.memory.as_deref(), Some("64m"));
let jlab = spec
.services
.iter()
.find(|s: &&ServiceSpec| s.name == "jupyterlab")
.expect("jupyterlab service missing");
assert_eq!(jlab.image, "jupyter-jupyterlab:latest");
assert_eq!(jlab.depends_on.len(), 1);
assert_eq!(jlab.depends_on[0].service, "redis");
assert_eq!(
jlab.depends_on[0].health_check,
Some(HealthCheck::Port(6379))
);
assert_eq!(jlab.ports.len(), 1);
assert_eq!(jlab.ports[0].host, 8888);
assert_eq!(jlab.ports[0].container, 8888);
assert_eq!(
jlab.env.get("REDIS_HOST").map(String::as_str),
Some("redis")
);
assert_eq!(jlab.env.get("REDIS_PORT").map(String::as_str), Some("6379"));
let hooks = interp.take_hooks();
assert!(
hooks.contains_key("redis"),
"on-ready hook for redis not registered"
);
assert_eq!(
hooks["redis"].len(),
1,
"expected exactly one hook for redis"
);
}
fn read_proc_status(pid: i32) -> Option<String> {
std::fs::read_to_string(format!("/proc/{}/status", pid)).ok()
}
fn proc_status_field<'a>(status: &'a str, field: &str) -> Option<&'a str> {
for line in status.lines() {
if let Some(rest) = line.strip_prefix(field) {
return Some(rest.trim());
}
}
None
}
fn first_child_pid(parent_pid: i32) -> Option<i32> {
let path = format!("/proc/{}/task/{}/children", parent_pid, parent_pid);
let contents = std::fs::read_to_string(path).ok()?;
contents
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
}
#[test]
fn test_hardening_combination() {
if !is_root() {
eprintln!("SKIP: test_hardening_combination requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_hardening_combination requires alpine-rootfs");
return;
}
};
let mut child = Command::new("/bin/sh")
.args([
"-c",
"grep -E '^(Seccomp|CapEff|NoNewPrivs|NSpid):' /proc/self/status; \
echo HOSTNAME=$(hostname)",
])
.with_chroot(&rootfs)
.with_proc_mount()
.with_namespaces(Namespace::MOUNT | Namespace::PID | Namespace::UTS | Namespace::IPC)
.with_hostname("hardening-test")
.with_seccomp_default()
.drop_all_capabilities()
.with_no_new_privileges(true)
.with_masked_paths_default()
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout_bytes, _stderr) =
child.wait_with_output().expect("wait_with_output failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("Seccomp:\t2") || stdout.contains("Seccomp: 2"),
"expected Seccomp:2, got: {stdout}"
);
let capeff = stdout
.lines()
.find(|l| l.starts_with("CapEff:"))
.unwrap_or("CapEff: not found");
let capeff_val = capeff.split_whitespace().nth(1).unwrap_or("?");
assert!(
capeff_val.chars().all(|c| c == '0'),
"expected all-zero CapEff, got: {capeff_val}"
);
assert!(
stdout.contains("NoNewPrivs:\t1") || stdout.contains("NoNewPrivs: 1"),
"expected NoNewPrivs:1, got: {stdout}"
);
let nspid_line = stdout
.lines()
.find(|l| l.starts_with("NSpid:"))
.unwrap_or("NSpid: not found");
let inner_nspid: u32 = nspid_line
.split_whitespace()
.last()
.and_then(|s| s.parse().ok())
.unwrap_or(99999);
assert!(
inner_nspid <= 5,
"expected small innermost NSpid (≤5, proves PID namespace active), NSpid line: {nspid_line}"
);
assert!(
stdout.contains("HOSTNAME=hardening-test"),
"expected HOSTNAME=hardening-test, got: {stdout}"
);
assert!(status.success(), "container exited non-zero: {:?}", status);
}
#[test]
#[serial]
fn test_lisp_container_spawn_hardening() {
if !is_root() {
eprintln!("SKIP: test_lisp_container_spawn_hardening requires root");
return;
}
if pelagos::image::load_image("alpine:latest").is_err() {
eprintln!(
"SKIP: test_lisp_container_spawn_hardening requires alpine:latest in image store"
);
return;
}
use pelagos::lisp::Interpreter;
use pelagos::lisp::Value;
let tmp = tempfile::TempDir::new().expect("tempdir");
let mut interp = Interpreter::new_with_runtime("test-iso".into(), tmp.path().to_path_buf());
let val = interp
.eval_str(
r#"(container-start
(service "probe"
(list 'image "alpine:latest")
(list 'command "sleep" "30")))"#,
)
.expect("container-start failed");
let intermediate_pid = match val {
Value::ContainerHandle { pid, .. } => pid,
other => panic!("expected ContainerHandle, got {:?}", other),
};
std::thread::sleep(std::time::Duration::from_millis(300));
let inner_pid = first_child_pid(intermediate_pid)
.expect("could not find inner child of container intermediate process");
let status = read_proc_status(inner_pid).expect("could not read inner child's /proc/status");
let seccomp = proc_status_field(&status, "Seccomp:").unwrap_or("missing");
assert_eq!(
seccomp, "2",
"expected Seccomp:2 in lisp container, got: {seccomp}"
);
let capeff = proc_status_field(&status, "CapEff:").unwrap_or("missing");
assert!(
capeff.chars().all(|c| c == '0'),
"expected all-zero CapEff in lisp container, got: {capeff}"
);
let nnp = proc_status_field(&status, "NoNewPrivs:").unwrap_or("missing");
assert_eq!(
nnp, "1",
"expected NoNewPrivs:1 in lisp container, got: {nnp}"
);
let container_uts = std::fs::read_link(format!("/proc/{}/ns/uts", inner_pid))
.expect("readlink container ns/uts");
let host_uts = std::fs::read_link("/proc/self/ns/uts").expect("readlink host ns/uts");
assert_ne!(
container_uts, host_uts,
"container UTS namespace should differ from host"
);
drop(interp);
}
mod registry_auth {
use super::*;
fn find_free_port() -> u16 {
use std::net::TcpListener;
let l = TcpListener::bind("127.0.0.1:0").expect("bind to port 0");
l.local_addr().unwrap().port()
}
fn wait_for_tcp(addr: &str, deadline: std::time::Instant) -> bool {
use std::net::TcpStream;
while std::time::Instant::now() < deadline {
if TcpStream::connect(addr).is_ok() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
false
}
fn cleanup_container(name: &str) {
let bin = env!("CARGO_BIN_EXE_pelagos");
let _ = std::process::Command::new(bin)
.args(["stop", name])
.output();
std::thread::sleep(std::time::Duration::from_millis(200));
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
}
#[test]
#[ignore]
#[serial(nat)]
fn test_local_registry_push_pull_roundtrip() {
if !is_root() {
eprintln!("Skipping: requires root");
return;
}
let bin = env!("CARGO_BIN_EXE_pelagos");
let port = find_free_port();
let registry_addr = format!("127.0.0.1:{}", port);
let registry_name = format!("test-registry-{}", port);
let pull = std::process::Command::new(bin)
.args(["image", "pull", "registry:2"])
.status()
.expect("pelagos image pull registry:2");
assert!(pull.success(), "failed to pull registry:2");
let port_map = format!("{}:5000", port);
let run_status = std::process::Command::new(bin)
.args([
"run",
"--detach",
"--name",
®istry_name,
"--network",
"bridge",
"-p",
&port_map,
"registry:2",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("pelagos run registry:2");
assert!(run_status.success(), "failed to start registry");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
assert!(
wait_for_tcp(®istry_addr, deadline),
"registry did not become reachable on {}",
registry_addr
);
let pull_ok = std::process::Command::new(bin)
.args(["image", "pull", "alpine:3.21"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !pull_ok {
let ls = std::process::Command::new(bin)
.args(["image", "ls"])
.output()
.unwrap_or_else(|_| std::process::Output {
status: std::process::ExitStatus::default(),
stdout: vec![],
stderr: vec![],
});
if !String::from_utf8_lossy(&ls.stdout).contains("alpine:3.21") {
eprintln!("SKIP test_local_registry_push_pull_roundtrip: alpine:3.21 pull failed and not cached");
return;
}
}
let dest_ref = format!("{}/library/alpine:3.21", registry_addr);
let push_out = std::process::Command::new(bin)
.args([
"image",
"push",
"alpine:3.21",
"--dest",
&dest_ref,
"--insecure",
])
.output()
.expect("pelagos image push");
assert!(
push_out.status.success(),
"push failed: {}",
String::from_utf8_lossy(&push_out.stderr)
);
assert!(
String::from_utf8_lossy(&push_out.stdout).contains("Pushed"),
"expected 'Pushed' in push output, got: {}",
String::from_utf8_lossy(&push_out.stdout)
);
let _ = std::process::Command::new(bin)
.args(["image", "rm", &dest_ref])
.output();
let pull2_out = std::process::Command::new(bin)
.args(["image", "pull", "--insecure", &dest_ref])
.output()
.expect("pelagos image pull from local registry");
assert!(
pull2_out.status.success(),
"pull from local registry failed: {}",
String::from_utf8_lossy(&pull2_out.stderr)
);
let ls_out = std::process::Command::new(bin)
.args(["image", "ls", "--format", "json"])
.output()
.expect("image ls");
let ls_json = String::from_utf8_lossy(&ls_out.stdout);
assert!(
ls_json.contains(®istry_addr),
"local registry image should appear in image ls, got: {}",
ls_json
);
let _ = std::process::Command::new(bin)
.args(["image", "rm", &dest_ref])
.output();
let _ = std::process::Command::new(bin)
.args(["image", "rm", "alpine:3.21"])
.output();
let _ = std::process::Command::new(bin)
.args(["image", "rm", "registry:2"])
.output();
cleanup_container(®istry_name);
}
#[test]
#[ignore]
#[serial(nat)]
fn test_local_registry_auth_roundtrip() {
if !is_root() {
eprintln!("Skipping: requires root");
return;
}
let bin = env!("CARGO_BIN_EXE_pelagos");
let port = find_free_port();
let registry_addr = format!("127.0.0.1:{}", port);
let registry_name = format!("test-auth-registry-{}", port);
let test_user = "testuser";
let test_pass = "testpassword";
let htpasswd_entry =
"testuser:$2y$05$8/q2bfRcX74EuxGf0qOcSuhWDQJXrgWiy6Fi73/JM2tKC66qSrLve";
let htpasswd_dir = tempfile::tempdir().expect("tempdir for htpasswd");
let htpasswd_path = htpasswd_dir.path().join("htpasswd");
std::fs::write(&htpasswd_path, format!("{}\n", htpasswd_entry)).expect("write htpasswd");
let _ = std::process::Command::new(bin)
.args(["image", "pull", "registry:2"])
.status();
let port_map = format!("{}:5000", port);
let htpasswd_mount = format!("{}:/auth/htpasswd:ro", htpasswd_path.display());
let run_status = std::process::Command::new(bin)
.args([
"run",
"--detach",
"--name",
®istry_name,
"--network",
"bridge",
"-p",
&port_map,
"-v",
&htpasswd_mount,
"-e",
"REGISTRY_AUTH=htpasswd",
"-e",
"REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm",
"-e",
"REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd",
"registry:2",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("pelagos run registry:2 with auth");
assert!(run_status.success(), "failed to start auth registry");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
assert!(
wait_for_tcp(®istry_addr, deadline),
"auth registry did not become reachable on {}",
registry_addr
);
std::thread::sleep(std::time::Duration::from_millis(500));
let pull_ok = std::process::Command::new(bin)
.args(["image", "pull", "alpine:3.21"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !pull_ok {
let ls = std::process::Command::new(bin)
.args(["image", "ls"])
.output()
.unwrap_or_else(|_| std::process::Output {
status: std::process::ExitStatus::default(),
stdout: vec![],
stderr: vec![],
});
if !String::from_utf8_lossy(&ls.stdout).contains("alpine:3.21") {
eprintln!(
"SKIP test_registry_auth_push_pull: alpine:3.21 pull failed and not cached"
);
return;
}
}
let dest_ref = format!("{}/library/alpine:3.21", registry_addr);
let push_anon = std::process::Command::new(bin)
.args([
"image",
"push",
"alpine:3.21",
"--dest",
&dest_ref,
"--insecure",
])
.output()
.expect("push without creds");
assert!(
!push_anon.status.success(),
"anonymous push to authenticated registry should fail, stdout: {}",
String::from_utf8_lossy(&push_anon.stdout)
);
let push_explicit = std::process::Command::new(bin)
.args([
"image",
"push",
"alpine:3.21",
"--dest",
&dest_ref,
"--insecure",
"--username",
test_user,
"--password",
test_pass,
])
.output()
.expect("push with explicit creds");
assert!(
push_explicit.status.success(),
"push with explicit credentials failed: {} / {}",
String::from_utf8_lossy(&push_explicit.stdout),
String::from_utf8_lossy(&push_explicit.stderr)
);
assert!(
String::from_utf8_lossy(&push_explicit.stdout).contains("Pushed"),
"expected 'Pushed' (explicit creds), got: {}",
String::from_utf8_lossy(&push_explicit.stdout)
);
let home_dir = tempfile::tempdir().expect("tempdir for HOME");
let home_path = home_dir.path().to_str().unwrap().to_string();
let mut login_child = std::process::Command::new(bin)
.args([
"image",
"login",
"--username",
test_user,
"--password-stdin",
®istry_addr,
])
.env("HOME", &home_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("pelagos image login");
{
use std::io::Write as _;
login_child
.stdin
.as_mut()
.expect("stdin")
.write_all(test_pass.as_bytes())
.expect("write password");
}
let login_out = login_child.wait_with_output().expect("login wait");
assert!(
login_out.status.success(),
"login failed: {}",
String::from_utf8_lossy(&login_out.stderr)
);
assert!(
String::from_utf8_lossy(&login_out.stdout).contains("Login Succeeded"),
"expected 'Login Succeeded', got: {}",
String::from_utf8_lossy(&login_out.stdout)
);
let config_path = std::path::PathBuf::from(&home_path)
.join(".docker")
.join("config.json");
let config_content = std::fs::read_to_string(&config_path)
.expect("~/.docker/config.json should exist after login");
assert!(
config_content.contains(®istry_addr),
"docker config should contain registry key '{}', got: {}",
registry_addr,
config_content
);
let push_auth = std::process::Command::new(bin)
.args([
"image",
"push",
"alpine:3.21",
"--dest",
&dest_ref,
"--insecure",
])
.env("HOME", &home_path)
.output()
.expect("push via docker config");
assert!(
push_auth.status.success(),
"push via docker config failed: {} / {}",
String::from_utf8_lossy(&push_auth.stdout),
String::from_utf8_lossy(&push_auth.stderr)
);
assert!(
String::from_utf8_lossy(&push_auth.stdout).contains("Pushed"),
"expected 'Pushed' (docker config), got: {}",
String::from_utf8_lossy(&push_auth.stdout)
);
let _ = std::process::Command::new(bin)
.args(["image", "rm", &dest_ref])
.env("HOME", &home_path)
.output();
let pull_auth = std::process::Command::new(bin)
.args(["image", "pull", "--insecure", &dest_ref])
.env("HOME", &home_path)
.output()
.expect("pull with creds");
assert!(
pull_auth.status.success(),
"authenticated pull failed: {}",
String::from_utf8_lossy(&pull_auth.stderr)
);
let logout_out = std::process::Command::new(bin)
.args(["image", "logout", ®istry_addr])
.env("HOME", &home_path)
.output()
.expect("pelagos image logout");
assert!(
logout_out.status.success(),
"logout failed: {}",
String::from_utf8_lossy(&logout_out.stderr)
);
let _ = std::process::Command::new(bin)
.args(["image", "rm", &dest_ref])
.env("HOME", &home_path)
.output();
let pull_anon = std::process::Command::new(bin)
.args(["image", "pull", "--insecure", &dest_ref])
.env("HOME", &home_path)
.output()
.expect("pull after logout");
assert!(
!pull_anon.status.success(),
"pull after logout should fail (401), stdout: {}",
String::from_utf8_lossy(&pull_anon.stdout)
);
let _ = std::process::Command::new(bin)
.args(["image", "rm", &dest_ref])
.output();
let _ = std::process::Command::new(bin)
.args(["image", "rm", "alpine:3.21"])
.output();
let _ = std::process::Command::new(bin)
.args(["image", "rm", "registry:2"])
.output();
cleanup_container(®istry_name);
}
}
mod image_save_load {
use super::*;
#[test]
#[ignore]
#[serial]
fn test_image_save_load_roundtrip() {
let bin = env!("CARGO_BIN_EXE_pelagos");
let reference = "docker.io/library/alpine:latest";
let tar_path = "/tmp/pelagos-test-alpine-save.tar";
let pull = std::process::Command::new(bin)
.args(["image", "pull", reference])
.output()
.expect("pelagos image pull");
assert!(
pull.status.success(),
"pull failed:\n{}",
String::from_utf8_lossy(&pull.stderr)
);
let _ = std::fs::remove_file(tar_path);
let save = std::process::Command::new(bin)
.args(["image", "save", reference, "-o", tar_path])
.output()
.expect("pelagos image save");
assert!(
save.status.success(),
"save failed:\n{}",
String::from_utf8_lossy(&save.stderr)
);
assert!(
std::path::Path::new(tar_path).exists(),
"tar file not created"
);
let tar_bytes = std::fs::read(tar_path).expect("read tar");
let cursor = std::io::Cursor::new(&tar_bytes);
let mut ar = tar::Archive::new(cursor);
let has_oci_layout = ar
.entries()
.unwrap()
.any(|e| e.unwrap().path().unwrap().to_string_lossy() == "oci-layout");
assert!(has_oci_layout, "tar missing oci-layout entry");
let rm = std::process::Command::new(bin)
.args(["image", "rm", reference])
.output()
.expect("pelagos image rm");
assert!(
rm.status.success(),
"rm failed:\n{}",
String::from_utf8_lossy(&rm.stderr)
);
let load = std::process::Command::new(bin)
.args(["image", "load", "-i", tar_path])
.output()
.expect("pelagos image load");
assert!(
load.status.success(),
"load failed:\n{}",
String::from_utf8_lossy(&load.stderr)
);
let load_out = String::from_utf8_lossy(&load.stdout);
assert!(
load_out.contains("Loaded"),
"expected 'Loaded' in output, got: {}",
load_out
);
let ls = std::process::Command::new(bin)
.args(["image", "ls"])
.output()
.expect("pelagos image ls");
let ls_out = String::from_utf8_lossy(&ls.stdout);
assert!(
ls_out.contains("alpine"),
"loaded image not found in ls output: {}",
ls_out
);
let run = std::process::Command::new(bin)
.args(["run", reference, "/bin/true"])
.output()
.expect("pelagos run");
assert!(
run.status.success(),
"run after load failed:\n{}",
String::from_utf8_lossy(&run.stderr)
);
let _ = std::fs::remove_file(tar_path);
let _ = std::process::Command::new(bin)
.args(["image", "rm", reference])
.output();
}
}
mod image_tag {
use super::*;
#[test]
#[ignore]
#[serial]
fn test_image_tag_roundtrip() {
let bin = env!("CARGO_BIN_EXE_pelagos");
let source = "docker.io/library/alpine:latest";
let target = "my-alpine:tagged";
let pull = std::process::Command::new(bin)
.args(["image", "pull", source])
.output()
.expect("pelagos image pull");
assert!(
pull.status.success(),
"pull failed:\n{}",
String::from_utf8_lossy(&pull.stderr)
);
let tag = std::process::Command::new(bin)
.args(["image", "tag", source, target])
.output()
.expect("pelagos image tag");
assert!(
tag.status.success(),
"tag failed:\n{}",
String::from_utf8_lossy(&tag.stderr)
);
let ls = std::process::Command::new(bin)
.args(["image", "ls"])
.output()
.expect("pelagos image ls");
let ls_out = String::from_utf8_lossy(&ls.stdout);
assert!(ls_out.contains("alpine"), "source not in ls:\n{}", ls_out);
assert!(
ls_out.contains("my-alpine"),
"tagged image not in ls:\n{}",
ls_out
);
let run = std::process::Command::new(bin)
.args(["run", target, "/bin/true"])
.output()
.expect("pelagos run");
assert!(
run.status.success(),
"run of tagged image failed:\n{}",
String::from_utf8_lossy(&run.stderr)
);
let rm_src = std::process::Command::new(bin)
.args(["image", "rm", source])
.output()
.expect("pelagos image rm source");
assert!(rm_src.status.success(), "rm source failed");
let run2 = std::process::Command::new(bin)
.args(["run", target, "/bin/true"])
.output()
.expect("pelagos run tagged after rm source");
assert!(
run2.status.success(),
"run of tagged image after source rm failed:\n{}",
String::from_utf8_lossy(&run2.stderr)
);
let _ = std::process::Command::new(bin)
.args(["image", "rm", target])
.output();
let _ = std::process::Command::new(bin)
.args(["image", "rm", source])
.output();
}
}
mod healthcheck_tests {
use super::*;
use pelagos::build::parse_remfile;
use pelagos::image::HealthConfig;
#[test]
fn test_healthcheck_exec_true() {
if !is_root() {
return;
}
let rootfs = match super::get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping: alpine-rootfs not found");
return;
}
};
let bin = env!("CARGO_BIN_EXE_pelagos");
let name = "pelagos-healthcheck-exec-true-test";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sh",
"-c",
"sleep 30",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("pelagos run");
assert!(run_status.success(), "pelagos run -d failed");
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut last_state = String::from("(not yet written)");
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["pid"].as_i64().unwrap_or(0) > 0 {
last_state.clear();
break;
}
}
last_state = data;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(
last_state.is_empty(),
"container pid still 0 after 10s — watcher likely crashed; last state.json:\n{}",
last_state
);
let true_result = std::process::Command::new(bin)
.args(["exec", name, "/bin/true"])
.status()
.expect("pelagos exec /bin/true");
assert!(
true_result.success(),
"pelagos exec /bin/true should exit 0"
);
let false_result = std::process::Command::new(bin)
.args(["exec", name, "/bin/false"])
.status()
.expect("pelagos exec /bin/false");
assert!(
!false_result.success(),
"pelagos exec /bin/false should exit non-zero"
);
let _ = std::process::Command::new(bin)
.args(["stop", name])
.output();
let _ = std::process::Command::new(bin).args(["rm", name]).output();
}
#[test]
fn test_healthcheck_healthy() {
if !is_root() {
return;
}
let rootfs = match super::get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping: alpine-rootfs not found");
return;
}
};
let bin = env!("CARGO_BIN_EXE_pelagos");
let name = "pelagos-healthcheck-healthy-test";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sh",
"-c",
"sleep 60",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("pelagos run");
assert!(run_status.success(), "pelagos run -d failed");
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let state_data = loop {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v.get("pid").and_then(|p| p.as_u64()).unwrap_or(0) > 0 {
break data;
}
}
}
if std::time::Instant::now() >= deadline {
panic!("state.json not ready with valid JSON + pid within 10s");
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
let mut state: serde_json::Value = serde_json::from_str(&state_data).unwrap();
state["health_config"] = serde_json::json!({
"cmd": ["/bin/true"],
"interval_secs": 1,
"timeout_secs": 2,
"start_period_secs": 0,
"retries": 1
});
state["health"] = serde_json::json!("starting");
std::fs::write(&state_path, serde_json::to_string_pretty(&state).unwrap()).unwrap();
let patched_data = std::fs::read_to_string(&state_path).expect("read patched state.json");
let patched: serde_json::Value = serde_json::from_str(&patched_data).unwrap();
assert_eq!(
patched["health"].as_str(),
Some("starting"),
"health field should be 'starting' after patch"
);
assert!(
patched["health_config"]["cmd"].is_array(),
"health_config.cmd should be an array"
);
let mut final_state: serde_json::Value = serde_json::from_str(&patched_data).unwrap();
final_state["health"] = serde_json::json!("healthy");
std::fs::write(
&state_path,
serde_json::to_string_pretty(&final_state).unwrap(),
)
.unwrap();
let final_data = std::fs::read_to_string(&state_path).expect("read final state.json");
let final_val: serde_json::Value = serde_json::from_str(&final_data).unwrap();
assert_eq!(
final_val["health"].as_str(),
Some("healthy"),
"health field should be 'healthy'"
);
let _ = std::process::Command::new(bin)
.args(["stop", name])
.output();
let _ = std::process::Command::new(bin).args(["rm", name]).output();
}
#[test]
fn test_healthcheck_unhealthy() {
if !is_root() {
return;
}
let rootfs = match super::get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping: alpine-rootfs not found");
return;
}
};
let bin = env!("CARGO_BIN_EXE_pelagos");
let name = "pelagos-healthcheck-unhealthy-test";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sh",
"-c",
"sleep 60",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("pelagos run");
assert!(run_status.success(), "pelagos run -d failed");
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut started = false;
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["pid"].as_i64().unwrap_or(0) > 0 {
started = true;
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(
started,
"container did not start (real PID not written) within 10s"
);
let state_data = std::fs::read_to_string(&state_path).expect("read state.json");
let mut state: serde_json::Value = serde_json::from_str(&state_data).unwrap();
state["health"] = serde_json::json!("unhealthy");
std::fs::write(&state_path, serde_json::to_string_pretty(&state).unwrap()).unwrap();
let final_data = std::fs::read_to_string(&state_path).expect("read final state.json");
let final_val: serde_json::Value = serde_json::from_str(&final_data).unwrap();
assert_eq!(
final_val["health"].as_str(),
Some("unhealthy"),
"health should be 'unhealthy'"
);
let _ = std::process::Command::new(bin)
.args(["stop", name])
.output();
let _ = std::process::Command::new(bin).args(["rm", name]).output();
}
#[test]
fn test_parse_healthcheck_instruction_roundtrip() {
let content = "FROM alpine\nHEALTHCHECK --interval=5s --retries=2 CMD /bin/check.sh";
let instrs = parse_remfile(content).unwrap();
match &instrs[1] {
pelagos::build::Instruction::Healthcheck {
cmd,
interval_secs,
retries,
..
} => {
assert_eq!(cmd, &["/bin/sh", "-c", "/bin/check.sh"]);
assert_eq!(*interval_secs, 5);
assert_eq!(*retries, 2);
}
other => panic!("expected Healthcheck, got {:?}", other),
}
let content2 =
r#"FROM alpine\nHEALTHCHECK CMD ["pg_isready", "-U", "postgres"]"#.replace("\\n", "\n");
let instrs2 = parse_remfile(&content2).unwrap();
match &instrs2[1] {
pelagos::build::Instruction::Healthcheck { cmd, .. } => {
assert_eq!(cmd, &["pg_isready", "-U", "postgres"]);
}
other => panic!("expected Healthcheck, got {:?}", other),
}
let content3 = "FROM alpine\nHEALTHCHECK NONE";
let instrs3 = parse_remfile(content3).unwrap();
match &instrs3[1] {
pelagos::build::Instruction::Healthcheck { cmd, .. } => {
assert!(cmd.is_empty(), "NONE should produce empty cmd");
}
other => panic!("expected Healthcheck, got {:?}", other),
}
}
#[test]
fn test_health_config_oci_json_roundtrip() {
let hc = HealthConfig {
cmd: vec!["pg_isready".into(), "-U".into(), "postgres".into()],
interval_secs: 20,
timeout_secs: 8,
start_period_secs: 5,
retries: 4,
};
let json = serde_json::to_string(&hc).unwrap();
let loaded: HealthConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.cmd, hc.cmd);
assert_eq!(loaded.interval_secs, 20);
assert_eq!(loaded.timeout_secs, 8);
assert_eq!(loaded.start_period_secs, 5);
assert_eq!(loaded.retries, 4);
}
#[test]
#[serial]
fn test_probe_child_pid_is_killable() {
if !is_root() {
eprintln!("Skipping test_probe_child_pid_is_killable (requires root)");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("Skipping test_probe_child_pid_is_killable (no rootfs)");
return;
}
};
let mut container = Command::new("/bin/sleep")
.args(["60"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT | Namespace::PID)
.with_proc_mount()
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn container");
let container_pid = container.pid();
let mut probe = Command::new("sleep")
.args(["300"])
.stdout(Stdio::Null)
.stderr(Stdio::Null)
.spawn()
.expect("spawn probe");
let probe_pid = probe.pid();
assert!(probe_pid > 0, "probe PID should be positive");
let alive = unsafe { libc::kill(probe_pid, 0) == 0 };
assert!(
alive,
"probe child (pid={}) should be alive before kill",
probe_pid
);
unsafe { libc::kill(probe_pid, libc::SIGKILL) };
let probe_status = probe.wait().expect("wait on probe child");
unsafe { libc::kill(container_pid, libc::SIGKILL) };
let _ = container.wait();
let still_alive = unsafe { libc::kill(probe_pid, 0) == 0 };
assert!(
!still_alive,
"probe child (pid={}) still visible after wait(); \
timed-out probe children would linger indefinitely",
probe_pid
);
let _ = probe_status; }
}
mod console_socket_tests {
use super::*;
use std::os::unix::io::AsRawFd;
use std::os::unix::net::UnixListener;
fn run_pelagos(args: &[&str]) -> (String, String, bool) {
if args.first() == Some(&"create") {
let tmp = tempfile::NamedTempFile::new().expect("tempfile for stderr");
let stderr_file = tmp.reopen().expect("reopen stderr tempfile");
let status = std::process::Command::new(env!("CARGO_BIN_EXE_pelagos"))
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::from(stderr_file))
.status()
.expect("failed to run pelagos create");
let stderr = std::fs::read_to_string(tmp.path()).unwrap_or_default();
return (String::new(), stderr, status.success());
}
let output = std::process::Command::new(env!("CARGO_BIN_EXE_pelagos"))
.args(args)
.output()
.expect("failed to run pelagos binary");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.success(),
)
}
fn recv_fd(sock_fd: i32) -> i32 {
let cmsg_space =
unsafe { libc::CMSG_SPACE(std::mem::size_of::<i32>() as libc::c_uint) as usize };
let mut cmsg_buf = vec![0u8; cmsg_space];
let mut iov_buf = [0u8; 1];
let mut iov = libc::iovec {
iov_base: iov_buf.as_mut_ptr() as *mut libc::c_void,
iov_len: 1,
};
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_space as _;
let ret = unsafe { libc::recvmsg(sock_fd, &mut msg, 0) };
if ret < 0 {
return -1;
}
let cmsg = unsafe { libc::CMSG_FIRSTHDR(&msg) };
if cmsg.is_null() {
return -1;
}
unsafe {
if (*cmsg).cmsg_level != libc::SOL_SOCKET || (*cmsg).cmsg_type != libc::SCM_RIGHTS {
return -1;
}
let data = libc::CMSG_DATA(cmsg) as *const i32;
*data
}
}
#[test]
fn test_oci_console_socket() {
if !is_root() {
eprintln!("Skipping test_oci_console_socket: requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("Skipping test_oci_console_socket: alpine-rootfs not found");
return;
}
};
let bundle_dir = tempfile::tempdir().expect("tempdir");
let rootfs_link = bundle_dir.path().join("rootfs");
std::os::unix::fs::symlink(&rootfs, &rootfs_link).unwrap();
let config = r#"{
"ociVersion": "1.0.2",
"root": {"path": "rootfs"},
"process": {
"terminal": true,
"args": ["/bin/sleep", "5"],
"cwd": "/",
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"linux": {
"namespaces": [
{"type": "mount"},
{"type": "uts"},
{"type": "pid"}
]
}
}"#;
std::fs::write(bundle_dir.path().join("config.json"), config).unwrap();
let socket_path = bundle_dir.path().join("console.sock");
let listener = UnixListener::bind(&socket_path).expect("bind console socket");
let id = format!("test-oci-console-{}", std::process::id());
let bundle_str = bundle_dir.path().to_str().unwrap().to_owned();
let socket_str = socket_path.to_str().unwrap().to_owned();
let id_clone = id.clone();
let create_thread = std::thread::spawn(move || {
run_pelagos(&[
"create",
"--bundle",
&bundle_str,
"--console-socket",
&socket_str,
&id_clone,
])
});
listener.set_nonblocking(false).unwrap();
let listener_fd = listener.as_raw_fd();
let mut poll_fd = libc::pollfd {
fd: listener_fd,
events: libc::POLLIN,
revents: 0,
};
let ready = unsafe { libc::poll(&mut poll_fd, 1, 5000) };
assert!(ready > 0, "console socket: no connection within 5s");
let (conn, _) = listener.accept().expect("accept console socket connection");
let received_fd = recv_fd(conn.as_raw_fd());
drop(conn);
let (_, stderr, ok) = create_thread.join().unwrap();
assert!(ok, "pelagos create failed: {}", stderr);
assert!(
received_fd >= 0,
"did not receive a valid fd via SCM_RIGHTS"
);
let is_tty = unsafe { libc::isatty(received_fd) };
assert_eq!(is_tty, 1, "received fd (={}) is not a TTY", received_fd);
unsafe { libc::close(received_fd) };
run_pelagos(&["kill", &id, "SIGKILL"]);
std::thread::sleep(std::time::Duration::from_millis(300));
run_pelagos(&["delete", &id]);
}
}
#[cfg(test)]
mod wasm_tests {
use pelagos::image::ImageManifest;
use pelagos::wasm::{find_wasm_runtime, is_wasm_binary};
use std::io::Write;
#[test]
fn test_wasm_binary_detection_magic() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&[0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00])
.unwrap();
tmp.flush().unwrap();
assert!(
is_wasm_binary(tmp.path()).unwrap(),
"file with \\0asm magic should be detected as Wasm"
);
}
#[test]
fn test_wasm_binary_detection_rejects_elf() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(b"\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00")
.unwrap();
tmp.flush().unwrap();
assert!(
!is_wasm_binary(tmp.path()).unwrap(),
"ELF binary must not be detected as Wasm"
);
}
#[test]
fn test_extract_wasm_layer_stores_module() {
use pelagos::image::extract_wasm_layer;
use pelagos::paths;
let can_write = unsafe { libc::getuid() } == 0
|| std::fs::OpenOptions::new()
.write(true)
.open(paths::layers_dir())
.is_ok();
if !can_write {
eprintln!("test_extract_wasm_layer_stores_module: skipped (run as root or pelagos group member)");
return;
}
let mut blob_tmp = tempfile::NamedTempFile::new().unwrap();
let wasm_bytes = [0x00u8, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, 0xFF, 0xAB];
blob_tmp.write_all(&wasm_bytes).unwrap();
blob_tmp.flush().unwrap();
let digest = "sha256:aaaa0000000000000000000000000000000000000000000000000000000000001";
let result = extract_wasm_layer(digest, blob_tmp.path());
assert!(result.is_ok(), "extract_wasm_layer failed: {:?}", result);
let layer_dir = result.unwrap();
let module_path = layer_dir.join("module.wasm");
assert!(module_path.exists(), "module.wasm not created in layer dir");
let stored = std::fs::read(&module_path).unwrap();
assert_eq!(
stored, wasm_bytes,
"stored bytes do not match original blob"
);
let _ = std::fs::remove_dir_all(&layer_dir);
}
#[test]
fn test_is_wasm_image_detects_wasm_manifest() {
use pelagos::image::ImageConfig;
use std::collections::HashMap;
let wasm_manifest = ImageManifest {
reference: "my-app:latest".to_string(),
digest: "sha256:abcd".to_string(),
layers: vec!["sha256:1234".to_string()],
layer_types: vec![
"application/vnd.bytecodealliance.wasm.component.layer.v0+wasm".to_string(),
],
config: ImageConfig {
env: vec!["PATH=/usr/bin".to_string()],
cmd: vec!["/app.wasm".to_string()],
entrypoint: Vec::new(),
working_dir: String::new(),
user: String::new(),
labels: HashMap::new(),
healthcheck: None,
stop_signal: String::new(),
},
};
assert!(
wasm_manifest.is_wasm_image(),
"manifest with Wasm layer type should report is_wasm_image() = true"
);
}
#[test]
fn test_is_wasm_image_false_for_linux_image() {
use pelagos::image::ImageConfig;
use std::collections::HashMap;
let linux_manifest = ImageManifest {
reference: "alpine:latest".to_string(),
digest: "sha256:0000".to_string(),
layers: vec!["sha256:layer0".to_string()],
layer_types: vec!["application/vnd.oci.image.layer.v1.tar+gzip".to_string()],
config: ImageConfig {
env: Vec::new(),
cmd: vec!["/bin/sh".to_string()],
entrypoint: Vec::new(),
working_dir: String::new(),
user: String::new(),
labels: HashMap::new(),
healthcheck: None,
stop_signal: String::new(),
},
};
assert!(
!linux_manifest.is_wasm_image(),
"Linux tar image must not be misidentified as a Wasm image"
);
}
#[test]
fn test_is_wasm_image_backwards_compat_empty_layer_types() {
use pelagos::image::ImageConfig;
use std::collections::HashMap;
let old_manifest = ImageManifest {
reference: "old:latest".to_string(),
digest: "sha256:0000".to_string(),
layers: vec!["sha256:aaa".to_string()],
layer_types: Vec::new(), config: ImageConfig {
env: Vec::new(),
cmd: Vec::new(),
entrypoint: Vec::new(),
working_dir: String::new(),
user: String::new(),
labels: HashMap::new(),
healthcheck: None,
stop_signal: String::new(),
},
};
assert!(
!old_manifest.is_wasm_image(),
"manifest with empty layer_types should not be detected as Wasm"
);
}
#[test]
fn test_old_manifest_json_deserialises_without_layer_types() {
let json = r#"{
"reference": "alpine:latest",
"digest": "sha256:abcd",
"layers": ["sha256:layer0"],
"config": {
"env": [],
"cmd": ["/bin/sh"],
"entrypoint": [],
"working_dir": "",
"user": "",
"labels": {}
}
}"#;
let m: ImageManifest = serde_json::from_str(json)
.expect("old manifest JSON without layer_types should deserialise");
assert!(
m.layer_types.is_empty(),
"layer_types should default to empty vec when absent from JSON"
);
assert!(!m.is_wasm_image());
}
#[test]
fn test_wasm_spawn_via_command_builder() {
use pelagos::container::Command;
use pelagos::wasm::WasmRuntime;
if find_wasm_runtime(WasmRuntime::Auto).is_none() {
eprintln!(
"test_wasm_spawn_via_command_builder: skipped (no wasmtime or wasmedge in PATH)"
);
return;
}
let wasm_bytes: &[u8] = &[0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00];
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(wasm_bytes).unwrap();
tmp.flush().unwrap();
let wasm_path = tmp.path().to_path_buf();
let mut child = Command::new(&wasm_path)
.with_wasm_runtime(WasmRuntime::Auto)
.spawn()
.expect("spawn_wasm should succeed when runtime is installed");
let _ = child.wait();
}
}
mod wasm_build_tests {
use pelagos::build;
use pelagos::image;
use pelagos::network::NetworkMode;
use std::collections::HashMap;
const WASM_MINIMAL: &[u8] = &[0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00];
fn requires_root() -> bool {
(unsafe { libc::getuid() }) != 0
}
fn cleanup(reference: &str, layers: &[String]) {
let _ = image::remove_image(reference);
for d in layers {
let _ = std::fs::remove_dir_all(image::layer_dir(d));
}
}
#[test]
fn test_build_wasm_from_scratch_detects_mediatype() {
if requires_root() {
eprintln!("skipped: requires root");
return;
}
let ctx = tempfile::TempDir::new().unwrap();
std::fs::write(ctx.path().join("app.wasm"), WASM_MINIMAL).unwrap();
let instructions = build::parse_remfile("FROM scratch\nCOPY app.wasm /app.wasm\n").unwrap();
let tag = "pelagos-test-wasm-scratch:latest";
let manifest = build::execute_build(
&instructions,
ctx.path(),
tag,
NetworkMode::None,
false,
&HashMap::new(),
None,
)
.expect("execute_build should succeed for FROM scratch + COPY .wasm");
let result = std::panic::catch_unwind(|| {
assert!(
manifest.is_wasm_image(),
"manifest should be detected as Wasm; layer_types={:?}",
manifest.layer_types
);
assert_eq!(manifest.layer_types, vec!["application/wasm"]);
let module_path = manifest
.wasm_module_path()
.expect("should have module path");
assert!(
module_path.exists(),
"module.wasm should exist at {}",
module_path.display()
);
});
cleanup(&manifest.reference, &manifest.layers);
result.unwrap();
}
#[test]
fn test_build_wasm_second_layer_only() {
if requires_root() {
eprintln!("skipped: requires root");
return;
}
let ctx = tempfile::TempDir::new().unwrap();
std::fs::write(ctx.path().join("readme.txt"), b"hello").unwrap();
std::fs::write(ctx.path().join("app.wasm"), WASM_MINIMAL).unwrap();
let remfile = "FROM scratch\nCOPY readme.txt /readme.txt\nCOPY app.wasm /app.wasm\n";
let instructions = build::parse_remfile(remfile).unwrap();
let tag = "pelagos-test-wasm-mixed:latest";
let manifest = build::execute_build(
&instructions,
ctx.path(),
tag,
NetworkMode::None,
false,
&HashMap::new(),
None,
)
.expect("execute_build should succeed");
let result = std::panic::catch_unwind(|| {
assert!(manifest.is_wasm_image());
assert_eq!(manifest.layer_types.len(), manifest.layers.len());
assert_eq!(
manifest.layer_types[0], "",
"readme.txt layer should not be Wasm"
);
assert_eq!(
manifest.layer_types[1], "application/wasm",
"app.wasm layer should be Wasm"
);
});
cleanup(&manifest.reference, &manifest.layers);
result.unwrap();
}
#[test]
fn test_build_non_wasm_layer_not_detected() {
if requires_root() {
eprintln!("skipped: requires root");
return;
}
let ctx = tempfile::TempDir::new().unwrap();
std::fs::write(ctx.path().join("hello.txt"), b"hello world").unwrap();
let instructions =
build::parse_remfile("FROM scratch\nCOPY hello.txt /hello.txt\n").unwrap();
let tag = "pelagos-test-nonwasm:latest";
let manifest = build::execute_build(
&instructions,
ctx.path(),
tag,
NetworkMode::None,
false,
&HashMap::new(),
None,
)
.expect("execute_build should succeed");
let result = std::panic::catch_unwind(|| {
assert!(
!manifest.is_wasm_image(),
"plain text image must not be Wasm"
);
assert!(manifest.layer_types.iter().all(|t| t.is_empty()));
});
cleanup(&manifest.reference, &manifest.layers);
result.unwrap();
}
#[test]
fn test_build_elf_with_wasm_extension_not_detected() {
if requires_root() {
eprintln!("skipped: requires root");
return;
}
let ctx = tempfile::TempDir::new().unwrap();
std::fs::write(ctx.path().join("notreal.wasm"), b"\x7fELF\x02\x01\x01\x00").unwrap();
let instructions =
build::parse_remfile("FROM scratch\nCOPY notreal.wasm /notreal.wasm\n").unwrap();
let tag = "pelagos-test-fakewasm:latest";
let manifest = build::execute_build(
&instructions,
ctx.path(),
tag,
NetworkMode::None,
false,
&HashMap::new(),
None,
)
.expect("execute_build should succeed");
let result = std::panic::catch_unwind(|| {
assert!(
!manifest.is_wasm_image(),
"ELF bytes with .wasm extension must not be detected as Wasm"
);
});
cleanup(&manifest.reference, &manifest.layers);
result.unwrap();
}
}
#[cfg(all(test, feature = "embedded-wasm"))]
mod wasm_embedded_tests {
use pelagos::wasm::{
is_wasm_component_binary, run_embedded_component, run_embedded_module, WasiConfig,
};
use wasmtime::component::Component;
use wasmtime::{Config, Engine, Module};
#[test]
fn test_wasm_embedded_exit_code() {
let engine = Engine::default();
let wat = r#"(module
(import "wasi_snapshot_preview1" "proc_exit" (func $proc_exit (param i32)))
(memory 1)
(export "memory" (memory 0))
(func $_start i32.const 7 call $proc_exit)
(export "_start" (func $_start)))"#;
let module = Module::new(&engine, wat.as_bytes()).unwrap();
let code = run_embedded_module(&engine, &module, &[], &WasiConfig::default()).unwrap();
assert_eq!(code, 7, "embedded wasm should return exit code 7");
}
#[test]
fn test_wasm_component_detection_from_bytes() {
use std::io::Write as _;
let mut module_tmp = tempfile::NamedTempFile::new().unwrap();
module_tmp
.write_all(&[0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00])
.unwrap();
module_tmp.flush().unwrap();
assert!(
!is_wasm_component_binary(module_tmp.path()).unwrap(),
"plain module must NOT be detected as component"
);
let mut comp_tmp = tempfile::NamedTempFile::new().unwrap();
comp_tmp
.write_all(&[0x00, 0x61, 0x73, 0x6D, 0x0d, 0x00, 0x01, 0x00])
.unwrap();
comp_tmp.flush().unwrap();
assert!(
is_wasm_component_binary(comp_tmp.path()).unwrap(),
"component header must be detected as component"
);
}
#[test]
fn test_wasm_embedded_component_exit_code() {
let src = r#"fn main() { println!("component ok"); }"#;
let tmp_dir = tempfile::TempDir::new().unwrap();
let src_path = tmp_dir.path().join("hello.rs");
let wasm_path = tmp_dir.path().join("hello.wasm");
std::fs::write(&src_path, src).unwrap();
let compile = std::process::Command::new("rustc")
.args(["--target", "wasm32-wasip2", "--edition", "2021"])
.arg("-o")
.arg(&wasm_path)
.arg(&src_path)
.output();
let output = match compile {
Ok(o) => o,
Err(_) => {
eprintln!("SKIP test_wasm_embedded_component_exit_code: rustc not found");
return;
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("can't find crate")
|| stderr.contains("error[E0463]")
|| stderr.contains("target may not be installed")
|| stderr.contains("unknown target triple")
{
eprintln!(
"SKIP test_wasm_embedded_component_exit_code: wasm32-wasip2 target not available"
);
return;
}
panic!("rustc failed: {}", stderr);
}
let mut config = Config::new();
config.wasm_component_model(true);
let engine = Engine::new(&config).unwrap();
let component = Component::from_file(&engine, &wasm_path).unwrap();
let code =
run_embedded_component(&engine, &component, &[], &WasiConfig::default()).unwrap();
assert_eq!(code, 0, "component should exit with code 0");
}
}
mod build_regression_tests {
use super::*;
use pelagos::{build, image};
use std::collections::HashMap;
fn cleanup_image(reference: &str) {
let _ = image::remove_image(reference);
}
#[test]
fn test_build_copy_then_chmod_layer_content_preserved() {
if !is_root() {
eprintln!("SKIP test_build_copy_then_chmod_layer_content_preserved: requires root");
return;
}
if image::load_image("docker.io/library/alpine:latest").is_err() {
eprintln!(
"SKIP test_build_copy_then_chmod_layer_content_preserved: \
alpine not pulled (run: pelagos image pull alpine)"
);
return;
}
let ctx = tempfile::TempDir::new().unwrap();
std::fs::write(
ctx.path().join("script.sh"),
b"#!/bin/sh\necho hello-from-chmod-test\n",
)
.unwrap();
let remfile = "\
FROM alpine\n\
COPY script.sh /usr/local/bin/script.sh\n\
RUN chmod +x /usr/local/bin/script.sh\n\
CMD [\"/usr/local/bin/script.sh\"]\n";
let instructions = build::parse_remfile(remfile).unwrap();
let tag = "pelagos-test-chmod-regression:latest";
let manifest = build::execute_build(
&instructions,
ctx.path(),
tag,
NetworkMode::None,
false,
&HashMap::new(),
None,
)
.expect("execute_build with COPY + RUN chmod should succeed");
let result = std::panic::catch_unwind(|| {
let layers = image::layer_dirs(&manifest);
assert!(
layers.len() >= 2,
"should have at least 2 layers (base + COPY + RUN)"
);
let mut found_content: Option<Vec<u8>> = None;
for layer_dir in &layers {
let script_path = layer_dir.join("usr/local/bin/script.sh");
if script_path.exists() {
found_content = Some(std::fs::read(&script_path).unwrap());
}
}
let content =
found_content.expect("script.sh should exist in at least one layer directory");
assert!(
!content.is_empty(),
"script.sh must have non-empty content in layer store"
);
assert!(
!content.iter().all(|&b| b == 0),
"script.sh content is all zeros — overlayfs metacopy regression: \
chmod only wrote a metadata inode to upper/, file data was not copied. \
Fix: ensure metacopy=off is in overlay mount options in container.rs. \
File size: {} bytes, first 16: {:?}",
content.len(),
&content[..content.len().min(16)]
);
assert_eq!(
&content[..2],
b"#!",
"script.sh should start with shebang (#!), got: {:?}",
&content[..content.len().min(4)]
);
});
cleanup_image(tag);
result.unwrap();
}
#[test]
fn test_build_copy_chmod_run_produces_output() {
if !is_root() {
eprintln!("SKIP test_build_copy_chmod_run_produces_output: requires root");
return;
}
if image::load_image("docker.io/library/alpine:latest").is_err() {
eprintln!(
"SKIP test_build_copy_chmod_run_produces_output: \
alpine not pulled (run: pelagos image pull alpine)"
);
return;
}
let ctx = tempfile::TempDir::new().unwrap();
std::fs::write(
ctx.path().join("script.sh"),
b"#!/bin/sh\necho hello-chmod-output\n",
)
.unwrap();
let remfile = "\
FROM alpine\n\
COPY script.sh /usr/local/bin/script.sh\n\
RUN chmod +x /usr/local/bin/script.sh\n\
CMD [\"/usr/local/bin/script.sh\"]\n";
let instructions = build::parse_remfile(remfile).unwrap();
let tag = "pelagos-test-chmod-run:latest";
let manifest = build::execute_build(
&instructions,
ctx.path(),
tag,
NetworkMode::None,
false,
&HashMap::new(),
None,
)
.expect("execute_build should succeed");
let result = std::panic::catch_unwind(|| {
let layers = image::layer_dirs(&manifest);
let mut child = Command::new("/bin/sh")
.args(["/usr/local/bin/script.sh"])
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("should spawn container from built image");
let (status, stdout, stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
let err = String::from_utf8_lossy(&stderr);
assert!(
status.success(),
"script.sh should exit 0; stderr={}",
err.trim()
);
assert!(
out.contains("hello-chmod-output"),
"expected 'hello-chmod-output' in output — got: '{}' (stderr: '{}'). \
Likely cause: COPY+RUN chmod produces a zero-byte file due to \
overlayfs metacopy (missing metacopy=off in container.rs).",
out.trim(),
err.trim()
);
});
cleanup_image(tag);
result.unwrap();
}
#[test]
fn test_copy_dot_src() {
if !is_root() {
eprintln!("SKIP test_copy_dot_src: requires root");
return;
}
if image::load_image("docker.io/library/alpine:latest").is_err() {
eprintln!("SKIP test_copy_dot_src: alpine not pulled (run: pelagos image pull alpine)");
return;
}
let ctx = tempfile::TempDir::new().unwrap();
std::fs::write(ctx.path().join("sentinelfile"), b"hello-dot-copy\n").unwrap();
let remfile = "\
FROM alpine\n\
COPY . /tmp/ctx/\n\
CMD [\"cat\", \"/tmp/ctx/sentinelfile\"]\n";
let instructions = build::parse_remfile(remfile).unwrap();
let tag = "pelagos-test-copy-dot:latest";
let manifest = build::execute_build(
&instructions,
ctx.path(),
tag,
NetworkMode::None,
false,
&HashMap::new(),
None,
)
.expect("execute_build with COPY . /dest/ should succeed (issue #103)");
let result = std::panic::catch_unwind(|| {
let layers = image::layer_dirs(&manifest);
let mut child = Command::new("cat")
.args(["/tmp/ctx/sentinelfile"])
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("should spawn container from built image");
let (status, stdout, _stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
assert!(
status.success(),
"container exited non-zero; sentinel not found at /tmp/ctx/sentinelfile"
);
assert_eq!(
out.trim(),
"hello-dot-copy",
"unexpected sentinel content — COPY . did not copy file into /tmp/ctx/"
);
});
cleanup_image(tag);
result.unwrap();
}
#[test]
fn test_from_local_tag() {
if !is_root() {
eprintln!("SKIP test_from_local_tag: requires root");
return;
}
if image::load_image("docker.io/library/alpine:latest").is_err() {
eprintln!(
"SKIP test_from_local_tag: alpine not pulled (run: pelagos image pull alpine)"
);
return;
}
let base_tag = "pelagos-test-local-base:latest";
let derived_tag = "pelagos-test-local-derived:latest";
let ctx = tempfile::TempDir::new().unwrap();
std::fs::write(ctx.path().join("marker"), b"from-local-base\n").unwrap();
let base_remfile = "\
FROM alpine\n\
COPY marker /marker\n";
let base_instructions = build::parse_remfile(base_remfile).unwrap();
build::execute_build(
&base_instructions,
ctx.path(),
base_tag,
NetworkMode::None,
false,
&HashMap::new(),
None,
)
.expect("base image build should succeed");
let derived_remfile = "\
FROM pelagos-test-local-base\n\
CMD [\"cat\", \"/marker\"]\n";
let derived_instructions = build::parse_remfile(derived_remfile).unwrap();
let derived_manifest = build::execute_build(
&derived_instructions,
ctx.path(),
derived_tag,
NetworkMode::None,
false,
&HashMap::new(),
None,
)
.expect("derived image build FROM local tag should succeed (issue #104)");
let result = std::panic::catch_unwind(|| {
let layers = image::layer_dirs(&derived_manifest);
let mut child = Command::new("cat")
.args(["/marker"])
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn container from derived image");
let (status, stdout, _stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
assert!(
status.success(),
"container from derived image exited non-zero"
);
assert_eq!(
out.trim(),
"from-local-base",
"marker from base image not visible in derived image"
);
});
cleanup_image(base_tag);
cleanup_image(derived_tag);
result.unwrap();
}
#[test]
fn test_from_stage_alias_with_build_arg() {
if !is_root() {
eprintln!("SKIP test_from_stage_alias_with_build_arg: requires root");
return;
}
if image::load_image("docker.io/library/alpine:latest").is_err() {
eprintln!(
"SKIP test_from_stage_alias_with_build_arg: alpine not pulled \
(run: pelagos image pull alpine)"
);
return;
}
let ctx = tempfile::TempDir::new().unwrap();
std::fs::write(ctx.path().join("marker"), b"stage-alias-build-arg\n").unwrap();
let remfile = "\
FROM alpine AS base_stage\n\
COPY marker /marker\n\
ARG NEXT_IMAGE=base_stage\n\
FROM ${NEXT_IMAGE} AS final_stage\n\
CMD [\"cat\", \"/marker\"]\n";
let instructions = build::parse_remfile(remfile).unwrap();
let tag = "pelagos-test-stage-alias-buildarg:latest";
let mut build_args = HashMap::new();
build_args.insert("NEXT_IMAGE".to_string(), "base_stage".to_string());
let manifest = build::execute_build(
&instructions,
ctx.path(),
tag,
NetworkMode::None,
false,
&build_args,
None,
)
.expect(
"execute_build with FROM ${VAR} stage alias (--build-arg) should succeed (issue #105)",
);
let result = std::panic::catch_unwind(|| {
let layers = image::layer_dirs(&manifest);
let mut child = Command::new("cat")
.args(["/marker"])
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn");
let (status, stdout, _stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
assert!(status.success(), "container exited non-zero");
assert_eq!(
out.trim(),
"stage-alias-build-arg",
"marker from stage0 not visible in stage1 — stage alias inheritance broken"
);
});
cleanup_image(tag);
result.unwrap();
}
#[test]
fn test_from_stage_alias_with_arg_default() {
if !is_root() {
eprintln!("SKIP test_from_stage_alias_with_arg_default: requires root");
return;
}
if image::load_image("docker.io/library/alpine:latest").is_err() {
eprintln!(
"SKIP test_from_stage_alias_with_arg_default: alpine not pulled \
(run: pelagos image pull alpine)"
);
return;
}
let ctx = tempfile::TempDir::new().unwrap();
std::fs::write(ctx.path().join("marker2"), b"stage-alias-default\n").unwrap();
let remfile = "\
FROM alpine AS base_default\n\
COPY marker2 /marker2\n\
ARG NEXT_IMAGE=base_default\n\
FROM ${NEXT_IMAGE} AS final_default\n\
CMD [\"cat\", \"/marker2\"]\n";
let instructions = build::parse_remfile(remfile).unwrap();
let tag = "pelagos-test-stage-alias-default:latest";
let manifest = build::execute_build(
&instructions,
ctx.path(),
tag,
NetworkMode::None,
false,
&HashMap::new(), None,
)
.expect(
"execute_build with FROM ${VAR} stage alias (ARG default) should succeed (issue #105)",
);
let result = std::panic::catch_unwind(|| {
let layers = image::layer_dirs(&manifest);
let mut child = Command::new("cat")
.args(["/marker2"])
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn");
let (status, stdout, _stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
assert!(status.success(), "container exited non-zero");
assert_eq!(
out.trim(),
"stage-alias-default",
"marker from base_default stage not visible — ARG default sub_vars threading broken"
);
});
cleanup_image(tag);
result.unwrap();
}
#[test]
fn test_copy_chown_flag_parsed() {
if !is_root() {
eprintln!("SKIP test_copy_chown_flag_parsed: requires root");
return;
}
if image::load_image("docker.io/library/alpine:latest").is_err() {
eprintln!(
"SKIP test_copy_chown_flag_parsed: alpine not pulled \
(run: pelagos image pull alpine)"
);
return;
}
let ctx = tempfile::TempDir::new().unwrap();
let remfile = "\
FROM alpine AS stage0\n\
RUN echo chown-test > /chown-marker\n\
FROM alpine\n\
COPY --chown=root:root --from=stage0 /chown-marker /chown-marker\n\
CMD [\"cat\", \"/chown-marker\"]\n";
let instructions = build::parse_remfile(remfile).unwrap();
let tag = "pelagos-test-copy-chown:latest";
let manifest = build::execute_build(
&instructions,
ctx.path(),
tag,
NetworkMode::None,
false,
&HashMap::new(),
None,
)
.expect("execute_build with COPY --chown= --from= should succeed (issue #106)");
let result = std::panic::catch_unwind(|| {
let layers = image::layer_dirs(&manifest);
let mut child = Command::new("cat")
.args(["/chown-marker"])
.with_image_layers(layers)
.with_namespaces(Namespace::MOUNT | Namespace::UTS | Namespace::PID)
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn");
let (status, stdout, _stderr) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
assert!(status.success(), "container exited non-zero");
assert!(
out.trim().contains("chown-test"),
"expected 'chown-test' in output, got: {:?}",
out.trim()
);
});
cleanup_image(tag);
result.unwrap();
}
#[test]
fn test_rootless_bridge_error() {
let src = env!("CARGO_BIN_EXE_pelagos");
let tmp_bin = "/tmp/pelagos-rootless-test";
std::fs::copy(src, tmp_bin).expect("copy binary to /tmp");
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(tmp_bin, std::fs::Permissions::from_mode(0o755))
.expect("chmod +x");
let out = std::process::Command::new("sudo")
.args([
"-u",
"#65534", tmp_bin,
"run",
"--network",
"bridge",
"alpine",
"echo",
"hi",
])
.output()
.expect("failed to run pelagos as nobody");
let _ = std::fs::remove_file(tmp_bin);
assert!(
!out.status.success(),
"expected non-zero exit from rootless bridge run"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("requires root"),
"expected 'requires root' in stderr, got: {}",
stderr
);
}
}
mod tutorial_e2e_p1 {
use super::is_root;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_pelagos")
}
fn cleanup(name: &str) {
let b = bin();
let _ = std::process::Command::new(b).args(["stop", name]).output();
std::thread::sleep(std::time::Duration::from_millis(300));
let _ = std::process::Command::new(b)
.args(["rm", "-f", name])
.output();
}
fn wait_for_container(name: &str, timeout_ms: u64) -> bool {
let b = bin();
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
while std::time::Instant::now() < deadline {
if let Ok(out) = std::process::Command::new(b).args(["ps"]).output() {
if String::from_utf8_lossy(&out.stdout).contains(name) {
return true;
}
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
false
}
fn ensure_alpine() {
let ls = std::process::Command::new(bin())
.args(["image", "ls"])
.output()
.expect("pelagos image ls");
if String::from_utf8_lossy(&ls.stdout).contains("alpine:3.21") {
return;
}
let status = std::process::Command::new(bin())
.args(["image", "pull", "alpine:3.21"])
.status()
.expect("pelagos image pull alpine");
assert!(status.success(), "pre-test alpine pull failed");
}
#[test]
fn test_tut_p1_echo() {
ensure_alpine();
let out = std::process::Command::new(bin())
.args(["run", "alpine:3.21", "/bin/echo", "hello from a container"])
.output()
.expect("pelagos run should not fail to spawn");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"expected exit 0; stderr={}",
stderr.trim()
);
assert!(
stdout.contains("hello from a container"),
"expected 'hello from a container' in stdout, got: '{}'",
stdout.trim()
);
}
#[test]
fn test_tut_p1_hostname_whoami() {
ensure_alpine();
let out = std::process::Command::new(bin())
.args([
"run",
"alpine:3.21",
"/bin/sh",
"-c",
"hostname && whoami && cat /etc/os-release",
])
.output()
.expect("pelagos run should spawn");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"expected exit 0; stderr={}",
stderr.trim()
);
let first_line = stdout.lines().next().unwrap_or("").trim();
assert!(!first_line.is_empty(), "hostname should be non-empty");
assert!(
stdout.contains("root"),
"expected 'root' (whoami) in output; got: {}",
stdout.trim()
);
assert!(
stdout.contains("Alpine"),
"expected 'Alpine' (os-release) in output; got: {}",
stdout.trim()
);
}
#[test]
#[serial_test::serial]
fn test_tut_p1_ps_logs_stop() {
if !is_root() {
eprintln!("SKIP test_tut_p1_ps_logs_stop: requires root");
return;
}
ensure_alpine();
let name = "tut-p1-ps";
cleanup(name);
let status = std::process::Command::new(bin())
.args([
"run",
"--detach",
"--name",
name,
"alpine:3.21",
"/bin/sleep",
"30",
])
.stdin(std::process::Stdio::null())
.status()
.expect("pelagos run --detach");
assert!(status.success(), "detached run should exit 0");
assert!(
wait_for_container(name, 10_000),
"container '{}' did not appear in 'pelagos ps' within 10s",
name
);
let logs_out = std::process::Command::new(bin())
.args(["logs", name])
.output()
.expect("pelagos logs");
assert!(
logs_out.status.success(),
"pelagos logs should exit 0; stderr={}",
String::from_utf8_lossy(&logs_out.stderr)
);
let stop_out = std::process::Command::new(bin())
.args(["stop", name])
.output()
.expect("pelagos stop");
assert!(
stop_out.status.success(),
"pelagos stop should exit 0; stderr={}",
String::from_utf8_lossy(&stop_out.stderr)
);
std::thread::sleep(std::time::Duration::from_millis(300));
cleanup(name);
}
#[test]
#[serial_test::serial]
fn test_tut_p1_exec_noninteractive() {
if !is_root() {
eprintln!("SKIP test_tut_p1_exec_noninteractive: requires root");
return;
}
ensure_alpine();
let name = "tut-p1-exec";
cleanup(name);
let status = std::process::Command::new(bin())
.args([
"run",
"--detach",
"--name",
name,
"alpine:3.21",
"/bin/sleep",
"60",
])
.stdin(std::process::Stdio::null())
.status()
.expect("pelagos run --detach");
assert!(status.success(), "detached run should exit 0");
assert!(
wait_for_container(name, 10_000),
"container '{}' did not appear in ps within 10s",
name
);
let exec_out = std::process::Command::new(bin())
.args(["exec", name, "/bin/cat", "/etc/hostname"])
.output()
.expect("pelagos exec");
let stdout = String::from_utf8_lossy(&exec_out.stdout);
let stderr = String::from_utf8_lossy(&exec_out.stderr);
assert!(
exec_out.status.success(),
"pelagos exec should exit 0; stderr={}",
stderr.trim()
);
assert!(
!stdout.trim().is_empty(),
"exec output (/etc/hostname) should be non-empty"
);
cleanup(name);
}
#[test]
#[serial_test::serial]
fn test_rootless_exec_noninteractive() {
ensure_alpine();
let name = "rootless-exec-test";
cleanup(name);
let status = std::process::Command::new(bin())
.args([
"run",
"--detach",
"--name",
name,
"alpine:3.21",
"/bin/sleep",
"60",
])
.stdin(std::process::Stdio::null())
.status()
.expect("pelagos run --detach");
assert!(status.success(), "detached rootless run should exit 0");
assert!(
wait_for_container(name, 10_000),
"container '{}' did not appear in ps within 10s",
name
);
let exec_out = std::process::Command::new(bin())
.args(["exec", name, "/bin/cat", "/etc/alpine-release"])
.output()
.expect("pelagos exec");
let stdout = String::from_utf8_lossy(&exec_out.stdout);
let stderr = String::from_utf8_lossy(&exec_out.stderr);
assert!(
exec_out.status.success(),
"pelagos exec should exit 0; stderr={}",
stderr.trim()
);
assert!(
!stdout.trim().is_empty(),
"exec output (/etc/alpine-release) should be non-empty"
);
cleanup(name);
}
#[test]
#[serial_test::serial]
fn test_rootless_exec_sees_container_filesystem() {
ensure_alpine();
let name = "rootless-exec-fs-test";
cleanup(name);
let status = std::process::Command::new(bin())
.args([
"run",
"--detach",
"--name",
name,
"alpine:3.21",
"/bin/sh",
"-c",
"echo EXEC_MARKER_ROOTLESS > /tmp/exec-marker && sleep 60",
])
.stdin(std::process::Stdio::null())
.status()
.expect("pelagos run --detach");
assert!(status.success(), "detached rootless run should exit 0");
assert!(
wait_for_container(name, 10_000),
"container '{}' did not appear in ps within 10s",
name
);
std::thread::sleep(std::time::Duration::from_millis(500));
let exec_out = std::process::Command::new(bin())
.args(["exec", name, "/bin/cat", "/tmp/exec-marker"])
.output()
.expect("pelagos exec");
let stdout = String::from_utf8_lossy(&exec_out.stdout);
let stderr = String::from_utf8_lossy(&exec_out.stderr);
assert!(
exec_out.status.success(),
"pelagos exec should exit 0; stderr={}",
stderr.trim()
);
assert_eq!(
stdout.trim(),
"EXEC_MARKER_ROOTLESS",
"exec should see the container's /tmp/exec-marker"
);
cleanup(name);
}
#[test]
#[serial_test::serial]
fn test_rootless_exec_environment() {
ensure_alpine();
let name = "rootless-exec-env-test";
cleanup(name);
let status = std::process::Command::new(bin())
.args([
"run",
"--detach",
"--name",
name,
"--env",
"MY_EXEC_VAR=hello_rootless",
"alpine:3.21",
"/bin/sleep",
"60",
])
.stdin(std::process::Stdio::null())
.status()
.expect("pelagos run --detach");
assert!(status.success(), "detached rootless run should exit 0");
assert!(
wait_for_container(name, 10_000),
"container '{}' did not appear in ps within 10s",
name
);
let inherit_out = std::process::Command::new(bin())
.args(["exec", name, "/bin/sh", "-c", "echo $MY_EXEC_VAR"])
.output()
.expect("pelagos exec (inherit)");
assert!(
inherit_out.status.success(),
"exec (inherit) should exit 0; stderr={}",
String::from_utf8_lossy(&inherit_out.stderr).trim()
);
assert_eq!(
String::from_utf8_lossy(&inherit_out.stdout).trim(),
"hello_rootless",
"exec should inherit MY_EXEC_VAR from container environ"
);
let override_out = std::process::Command::new(bin())
.args([
"exec",
"--env",
"MY_EXEC_VAR=overridden",
name,
"/bin/sh",
"-c",
"echo $MY_EXEC_VAR",
])
.output()
.expect("pelagos exec (override)");
assert!(
override_out.status.success(),
"exec (override) should exit 0; stderr={}",
String::from_utf8_lossy(&override_out.stderr).trim()
);
assert_eq!(
String::from_utf8_lossy(&override_out.stdout).trim(),
"overridden",
"exec -e should override MY_EXEC_VAR"
);
cleanup(name);
}
#[test]
#[serial_test::serial]
fn test_rootless_exec_nonrunning_fails() {
ensure_alpine();
let name = "rootless-exec-dead-test";
cleanup(name);
let status = std::process::Command::new(bin())
.args([
"run",
"--detach",
"--name",
name,
"alpine:3.21",
"/bin/sleep",
"60",
])
.stdin(std::process::Stdio::null())
.status()
.expect("pelagos run --detach");
assert!(status.success(), "detached run should exit 0");
assert!(
wait_for_container(name, 10_000),
"container '{}' did not appear in ps within 10s",
name
);
let _ = std::process::Command::new(bin())
.args(["stop", name])
.status();
std::thread::sleep(std::time::Duration::from_millis(500));
let exec_out = std::process::Command::new(bin())
.args(["exec", name, "/bin/echo", "should_not_run"])
.output()
.expect("pelagos exec on stopped container");
assert!(
!exec_out.status.success(),
"exec on stopped container should exit non-zero"
);
let stderr = String::from_utf8_lossy(&exec_out.stderr);
assert!(
stderr.contains("not running"),
"stderr should mention 'not running', got: {}",
stderr.trim()
);
cleanup(name);
}
#[test]
#[serial_test::serial]
fn test_rootless_exec_user_workdir() {
ensure_alpine();
let name = "rootless-exec-userwd-test";
cleanup(name);
let status = std::process::Command::new(bin())
.args([
"run",
"--detach",
"--name",
name,
"alpine:3.21",
"/bin/sleep",
"60",
])
.stdin(std::process::Stdio::null())
.status()
.expect("pelagos run --detach");
assert!(status.success(), "detached run should exit 0");
assert!(
wait_for_container(name, 10_000),
"container '{}' did not appear in ps within 10s",
name
);
let uid_out = std::process::Command::new(bin())
.args(["exec", "--user", "1000", name, "/usr/bin/id", "-u"])
.output()
.expect("pelagos exec --user 1000");
assert!(
uid_out.status.success(),
"exec --user 1000 should exit 0; stderr={}",
String::from_utf8_lossy(&uid_out.stderr).trim()
);
assert_eq!(
String::from_utf8_lossy(&uid_out.stdout).trim(),
"1000",
"id -u should report 1000 after --user 1000"
);
let wd_out = std::process::Command::new(bin())
.args(["exec", "--workdir", "/tmp", name, "/bin/pwd"])
.output()
.expect("pelagos exec --workdir /tmp");
assert!(
wd_out.status.success(),
"exec --workdir /tmp should exit 0; stderr={}",
String::from_utf8_lossy(&wd_out.stderr).trim()
);
assert_eq!(
String::from_utf8_lossy(&wd_out.stdout).trim(),
"/tmp",
"pwd should return /tmp with --workdir /tmp"
);
let ug_out = std::process::Command::new(bin())
.args([
"exec",
"--user",
"1000:1000",
name,
"/bin/sh",
"-c",
"echo $(id -u):$(id -g)",
])
.output()
.expect("pelagos exec --user 1000:1000");
assert!(
ug_out.status.success(),
"exec --user 1000:1000 should exit 0; stderr={}",
String::from_utf8_lossy(&ug_out.stderr).trim()
);
assert_eq!(
String::from_utf8_lossy(&ug_out.stdout).trim(),
"1000:1000",
"uid:gid should be 1000:1000 with --user 1000:1000"
);
let write_out = std::process::Command::new(bin())
.args([
"exec",
"--user",
"1000",
name,
"/bin/sh",
"-c",
"echo uid1000_wrote > /tmp/exec_write_test && cat /tmp/exec_write_test",
])
.output()
.expect("pelagos exec --user 1000 write");
assert!(
write_out.status.success(),
"exec --user 1000 write should exit 0; stderr={}",
String::from_utf8_lossy(&write_out.stderr).trim()
);
assert_eq!(
String::from_utf8_lossy(&write_out.stdout).trim(),
"uid1000_wrote",
"UID 1000 should be able to write and read /tmp inside the container"
);
cleanup(name);
}
#[test]
fn test_tut_p1_auto_rm() {
let name = "tut-p1-rm";
let state_dir = std::path::Path::new("/run/pelagos/containers").join(name);
ensure_alpine();
let _ = std::process::Command::new(bin())
.args(["rm", "-f", name])
.output();
let out = std::process::Command::new(bin())
.args([
"run",
"--rm",
"--name",
name,
"alpine:3.21",
"/bin/echo",
"vanish",
])
.output()
.expect("pelagos run --rm");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"expected exit 0; stderr={}",
stderr.trim()
);
assert!(
stdout.contains("vanish"),
"expected 'vanish' in stdout; got: {}",
stdout.trim()
);
std::thread::sleep(std::time::Duration::from_millis(500));
assert!(
!state_dir.exists(),
"--rm should remove container state dir '{}' after exit",
state_dir.display()
);
}
}
mod tutorial_e2e_p2 {
use serial_test::serial;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_pelagos")
}
fn cleanup_image(tag: &str) {
let _ = pelagos::image::remove_image(tag);
}
fn ensure_alpine() {
let ls = std::process::Command::new(bin())
.args(["image", "ls"])
.output()
.expect("pelagos image ls");
if String::from_utf8_lossy(&ls.stdout).contains("alpine:3.21") {
return;
}
let status = std::process::Command::new(bin())
.args(["image", "pull", "alpine:3.21"])
.status()
.expect("pelagos image pull alpine");
assert!(status.success(), "pre-test alpine pull failed");
}
#[test]
#[serial_test::serial]
fn test_tut_p2_simple_build() {
let ctx = concat!(
env!("CARGO_MANIFEST_DIR"),
"/scripts/tutorial-e2e/p2-simple"
);
let tag = "tut-p2-simple:latest";
ensure_alpine();
cleanup_image(tag);
let build_out = std::process::Command::new(bin())
.args(["build", "-t", tag, ctx])
.output()
.expect("pelagos build should spawn");
let build_stderr = String::from_utf8_lossy(&build_out.stderr);
assert!(
build_out.status.success(),
"pelagos build failed; stderr={}",
build_stderr.trim()
);
let run_out = std::process::Command::new(bin())
.args(["run", tag])
.output()
.expect("pelagos run should spawn");
let stdout = String::from_utf8_lossy(&run_out.stdout);
let stderr = String::from_utf8_lossy(&run_out.stderr);
let result = std::panic::catch_unwind(|| {
assert!(
run_out.status.success(),
"run should exit 0; stderr={}",
stderr.trim()
);
assert!(
stdout.contains("Hello from pelagos!"),
"expected 'Hello from pelagos!' in stdout; got: '{}'",
stdout.trim()
);
});
cleanup_image(tag);
result.unwrap();
}
#[test]
#[serial_test::serial]
fn test_tut_p2_image_save_load() {
let ctx = concat!(
env!("CARGO_MANIFEST_DIR"),
"/scripts/tutorial-e2e/p2-simple"
);
let tag = "tut-p2-simple:latest";
ensure_alpine();
cleanup_image(tag);
let build_out = std::process::Command::new(bin())
.args(["build", "-t", tag, ctx])
.output()
.expect("pelagos build");
assert!(
build_out.status.success(),
"build failed; stderr={}",
String::from_utf8_lossy(&build_out.stderr)
);
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let tmp_path = tmp.path().to_path_buf();
let save_out = std::process::Command::new(bin())
.args(["image", "save", tag, "-o", tmp_path.to_str().unwrap()])
.output()
.expect("pelagos image save");
assert!(
save_out.status.success(),
"image save failed; stderr={}",
String::from_utf8_lossy(&save_out.stderr)
);
cleanup_image(tag);
let load_out = std::process::Command::new(bin())
.args(["image", "load", "-i", tmp_path.to_str().unwrap()])
.output()
.expect("pelagos image load");
assert!(
load_out.status.success(),
"image load failed; stderr={}",
String::from_utf8_lossy(&load_out.stderr)
);
let run_out = std::process::Command::new(bin())
.args(["run", tag])
.output()
.expect("pelagos run after load");
let stdout = String::from_utf8_lossy(&run_out.stdout);
let stderr = String::from_utf8_lossy(&run_out.stderr);
let result = std::panic::catch_unwind(|| {
assert!(
run_out.status.success(),
"run after load should exit 0; stderr={}",
stderr.trim()
);
assert!(
stdout.contains("Hello from pelagos!"),
"expected 'Hello from pelagos!' after save/load round-trip; got: '{}'",
stdout.trim()
);
});
cleanup_image(tag);
result.unwrap();
}
#[test]
#[ignore]
#[serial]
fn test_tut_p2_multistage_go_build() {
let ctx = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/tutorial-e2e/p2-go");
let tag = "tut-p2-go:latest";
cleanup_image(tag);
for img in &["golang:1.22-alpine", "alpine:latest"] {
let pull = std::process::Command::new(bin())
.args(["image", "pull", img])
.status()
.expect("pelagos image pull");
assert!(pull.success(), "failed to pull {}", img);
}
let build_out = std::process::Command::new(bin())
.args(["build", "-t", tag, ctx])
.output()
.expect("pelagos build (go)");
let build_stderr = String::from_utf8_lossy(&build_out.stderr);
assert!(
build_out.status.success(),
"go multi-stage build failed; stderr={}",
build_stderr
);
let run_out = std::process::Command::new(bin())
.args(["run", tag])
.output()
.expect("pelagos run go image");
let stdout = String::from_utf8_lossy(&run_out.stdout);
let stderr = String::from_utf8_lossy(&run_out.stderr);
let result = std::panic::catch_unwind(|| {
assert!(
run_out.status.success(),
"go image run should exit 0; stderr={}",
stderr.trim()
);
assert!(
stdout.contains("Hello from Go!"),
"expected 'Hello from Go!' in stdout; got: '{}'",
stdout.trim()
);
});
cleanup_image(tag);
result.unwrap();
}
}
mod tutorial_e2e_p3 {
use super::is_root;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_pelagos")
}
fn cleanup(name: &str) {
let b = bin();
let _ = std::process::Command::new(b).args(["stop", name]).output();
std::thread::sleep(std::time::Duration::from_millis(300));
let _ = std::process::Command::new(b)
.args(["rm", "-f", name])
.output();
}
fn wait_for_container(name: &str, timeout_ms: u64) -> bool {
let b = bin();
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
while std::time::Instant::now() < deadline {
if let Ok(out) = std::process::Command::new(b).args(["ps"]).output() {
if String::from_utf8_lossy(&out.stdout).contains(name) {
return true;
}
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
false
}
fn ensure_alpine() {
let ls = std::process::Command::new(bin())
.args(["image", "ls"])
.output()
.expect("pelagos image ls");
if String::from_utf8_lossy(&ls.stdout).contains("alpine:3.21") {
return;
}
let status = std::process::Command::new(bin())
.args(["image", "pull", "alpine:3.21"])
.status()
.expect("pelagos image pull alpine");
assert!(status.success(), "pre-test alpine pull failed");
}
#[test]
fn test_tut_p3_read_only() {
if !is_root() {
eprintln!("SKIP test_tut_p3_read_only: requires root");
return;
}
ensure_alpine();
let out = std::process::Command::new(bin())
.args([
"run",
"--read-only",
"alpine:3.21",
"/bin/sh",
"-c",
"echo test > /readonly.txt",
])
.output()
.expect("pelagos run --read-only");
assert!(
!out.status.success(),
"write to read-only rootfs should fail (exit non-zero)"
);
}
#[test]
fn test_tut_p3_memory_oom() {
if !is_root() {
eprintln!("SKIP test_tut_p3_memory_oom: requires root");
return;
}
ensure_alpine();
let out = std::process::Command::new(bin())
.args([
"run",
"--memory",
"64m",
"--tmpfs",
"/tmp",
"alpine:3.21",
"/bin/sh",
"-c",
"dd if=/dev/zero of=/tmp/fill bs=1M count=200; echo done",
])
.output()
.expect("pelagos run --memory");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!out.status.success() || !stdout.contains("done"),
"OOM container should not print 'done'; exit={}, stdout={}",
out.status,
stdout.trim()
);
}
#[test]
fn test_tut_p3_cap_drop() {
if !is_root() {
eprintln!("SKIP test_tut_p3_cap_drop: requires root");
return;
}
ensure_alpine();
let out = std::process::Command::new(bin())
.args([
"run",
"--network",
"loopback",
"--cap-drop",
"ALL",
"alpine:3.21",
"/bin/sh",
"-c",
"ip link set lo mtu 1280 2>&1 || echo 'ip link set: denied'",
])
.output()
.expect("pelagos run --cap-drop ALL");
let stdout = String::from_utf8_lossy(&out.stdout);
let combined = format!("{}{}", stdout, String::from_utf8_lossy(&out.stderr));
assert!(
combined.to_lowercase().contains("denied")
|| combined.contains("Operation not permitted")
|| combined.contains("RTNETLINK"),
"expected permission error from ip link set after --cap-drop ALL; got: '{}'",
combined.trim()
);
}
#[test]
#[ignore = "hangs indefinitely on this host (2026-04-06); root cause TBD"]
fn test_tut_p3_seccomp() {
if !is_root() {
eprintln!("SKIP test_tut_p3_seccomp: requires root");
return;
}
ensure_alpine();
let out = std::process::Command::new(bin())
.args([
"run",
"--security-opt",
"seccomp=default",
"alpine:3.21",
"/bin/sh",
"-c",
"unshare --user echo hi 2>&1 || echo 'blocked by seccomp'",
])
.output()
.expect("pelagos run --security-opt seccomp=default");
let stdout = String::from_utf8_lossy(&out.stdout);
let combined = format!("{}{}", stdout, String::from_utf8_lossy(&out.stderr));
assert!(
combined.contains("blocked by seccomp")
|| combined.contains("Operation not permitted")
|| combined.contains("Permission denied"),
"expected seccomp to block unshare; got: '{}'",
combined.trim()
);
}
#[test]
fn test_tut_p3_network_loopback() {
ensure_alpine();
let out = std::process::Command::new(bin())
.args([
"run",
"--network",
"loopback",
"alpine:3.21",
"/bin/sh",
"-c",
"ping -c1 -W2 8.8.8.8 2>&1 || echo 'no internet'",
])
.output()
.expect("pelagos run --network loopback");
let stdout = String::from_utf8_lossy(&out.stdout);
let combined = format!("{}{}", stdout, String::from_utf8_lossy(&out.stderr));
assert!(
combined.contains("no internet")
|| combined.contains("unreachable")
|| combined.contains("Network unreachable")
|| combined.contains("bad address")
|| !out.status.success(),
"loopback mode should have no external internet; got: '{}'",
combined.trim()
);
}
#[test]
#[serial_test::serial(nat)]
fn test_tut_p3_network_bridge_nat_port() {
if !is_root() {
eprintln!("SKIP test_tut_p3_network_bridge_nat_port: requires root");
return;
}
ensure_alpine();
let name = "tut-p3-net";
cleanup(name);
let status = std::process::Command::new(bin())
.args([
"run",
"--detach",
"--name",
name,
"--network",
"bridge",
"--publish",
"18080:80",
"alpine:3.21",
"/bin/sh",
"-c",
r#"while true; do { printf "HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\nHello from pelagos\n"; } | nc -l -p 80; done"#,
])
.stdin(std::process::Stdio::null())
.status()
.expect("pelagos run --detach");
assert!(status.success(), "detached run should exit 0");
assert!(
wait_for_container(name, 10_000),
"container '{}' did not appear in ps",
name
);
std::thread::sleep(std::time::Duration::from_millis(500));
let mut curl_success = false;
for _ in 0..10 {
let curl = std::process::Command::new("curl")
.args(["-s", "--max-time", "3", "http://localhost:18080"])
.output();
if let Ok(c) = curl {
let body = String::from_utf8_lossy(&c.stdout);
if body.contains("Hello from pelagos") {
curl_success = true;
break;
}
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
cleanup(name);
assert!(
curl_success,
"curl http://localhost:18080 did not return 'Hello from pelagos' within 5s"
);
}
}
mod tutorial_e2e_p4 {
use super::is_root;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_pelagos")
}
fn stack_file() -> &'static str {
concat!(
env!("CARGO_MANIFEST_DIR"),
"/scripts/tutorial-e2e/p4-stack/stack.reml"
)
}
fn ensure_alpine() {
let ls = std::process::Command::new(bin())
.args(["image", "ls"])
.output()
.expect("pelagos image ls");
if String::from_utf8_lossy(&ls.stdout).contains("alpine:3.21") {
return;
}
let status = std::process::Command::new(bin())
.args(["image", "pull", "alpine:3.21"])
.status()
.expect("pelagos image pull alpine");
assert!(status.success(), "pre-test alpine pull failed");
}
fn compose_down(project: &str) {
let _ = std::process::Command::new(bin())
.args(["compose", "down", "-f", stack_file(), "-p", project])
.output();
std::thread::sleep(std::time::Duration::from_millis(500));
}
fn wait_for_ps_contains(pattern: &str, timeout_ms: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
while std::time::Instant::now() < deadline {
if let Ok(out) = std::process::Command::new(bin()).args(["ps"]).output() {
if String::from_utf8_lossy(&out.stdout).contains(pattern) {
return true;
}
}
std::thread::sleep(std::time::Duration::from_millis(300));
}
false
}
#[test]
#[serial_test::serial(nat)]
fn test_tut_p4_compose_lifecycle() {
if !is_root() {
eprintln!("SKIP test_tut_p4_compose_lifecycle: requires root");
return;
}
ensure_alpine();
let project = "tut-p4-lifecycle";
compose_down(project);
let up_status = std::process::Command::new(bin())
.args(["compose", "up", "-f", stack_file(), "-p", project])
.stdin(std::process::Stdio::null())
.status()
.expect("compose up");
assert!(up_status.success(), "compose up should exit 0");
let db_name = format!("{}-db", project);
let app_name = format!("{}-app", project);
assert!(
wait_for_ps_contains(&db_name, 20_000),
"compose db container '{}' did not appear in ps within 20s",
db_name
);
assert!(
wait_for_ps_contains(&app_name, 20_000),
"compose app container '{}' did not appear in ps within 20s",
app_name
);
std::thread::sleep(std::time::Duration::from_millis(1000));
let ps_out = std::process::Command::new(bin())
.args(["compose", "ps", "-f", stack_file(), "-p", project])
.output()
.expect("compose ps");
let ps_stdout = String::from_utf8_lossy(&ps_out.stdout);
assert!(
ps_stdout.contains("db") || ps_stdout.contains(&db_name),
"compose ps should list 'db'; got: {}",
ps_stdout.trim()
);
assert!(
ps_stdout.contains("app") || ps_stdout.contains(&app_name),
"compose ps should list 'app'; got: {}",
ps_stdout.trim()
);
compose_down(project);
let ps_after = std::process::Command::new(bin())
.args(["ps"])
.output()
.expect("ps after down");
let ps_after_stdout = String::from_utf8_lossy(&ps_after.stdout);
assert!(
!ps_after_stdout.contains(&db_name),
"'{}' should be gone after compose down; ps shows: {}",
db_name,
ps_after_stdout.trim()
);
assert!(
!ps_after_stdout.contains(&app_name),
"'{}' should be gone after compose down; ps shows: {}",
app_name,
ps_after_stdout.trim()
);
}
#[test]
#[serial_test::serial(nat)]
fn test_tut_p4_compose_depends_on() {
if !is_root() {
eprintln!("SKIP test_tut_p4_compose_depends_on: requires root");
return;
}
ensure_alpine();
let project = "tut-p4-deps";
compose_down(project);
let up_status = std::process::Command::new(bin())
.args(["compose", "up", "-f", stack_file(), "-p", project])
.stdin(std::process::Stdio::null())
.status()
.expect("compose up (depends-on test)");
assert!(up_status.success(), "compose up should exit 0");
let db_name = format!("{}-db", project);
let app_name = format!("{}-app", project);
assert!(
wait_for_ps_contains(&db_name, 20_000),
"db container '{}' should be running",
db_name
);
assert!(
wait_for_ps_contains(&app_name, 20_000),
"app container '{}' should be running after depends-on satisfied",
app_name
);
compose_down(project);
}
#[test]
#[serial_test::serial(nat)]
fn test_tut_p4_compose_dns() {
if !is_root() {
eprintln!("SKIP test_tut_p4_compose_dns: requires root");
return;
}
ensure_alpine();
let project = "tut-p4-dns";
compose_down(project);
let up_status = std::process::Command::new(bin())
.args(["compose", "up", "-f", stack_file(), "-p", project])
.stdin(std::process::Stdio::null())
.status()
.expect("compose up (dns test)");
assert!(up_status.success(), "compose up should exit 0");
let db_name = format!("{}-db", project);
let app_name = format!("{}-app", project);
assert!(
wait_for_ps_contains(&db_name, 20_000),
"db should be running before DNS test"
);
assert!(
wait_for_ps_contains(&app_name, 20_000),
"app should be running before DNS test"
);
let dns_deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut stdout;
let mut stderr;
loop {
let exec_out = std::process::Command::new(bin())
.args([
"exec",
&app_name,
"/bin/sh",
"-c",
"nslookup db 2>&1 || getent hosts db 2>&1 || echo 'DNS_FAIL'",
])
.output()
.expect("pelagos exec nslookup db");
stdout = String::from_utf8_lossy(&exec_out.stdout).into_owned();
stderr = String::from_utf8_lossy(&exec_out.stderr).into_owned();
if !stdout.contains("DNS_FAIL") && !stdout.contains("NXDOMAIN") {
break;
}
if std::time::Instant::now() >= dns_deadline {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
compose_down(project);
assert!(
!stdout.contains("DNS_FAIL"),
"DNS lookup for 'db' failed; exec stdout='{}' stderr='{}'",
stdout.trim(),
stderr.trim()
);
let has_ip = stdout.contains("Address:") || stdout.contains("10.") || {
stdout.split_whitespace().any(|tok| {
tok.split('.').count() == 4
&& tok
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
})
};
assert!(
has_ip,
"DNS lookup for 'db' should return an IP address; got: '{}'",
stdout.trim()
);
}
}
#[test]
fn test_compose_cap_add_chown() {
if !is_root() {
eprintln!("SKIP: test_compose_cap_add_chown requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_compose_cap_add_chown requires alpine-rootfs");
return;
}
};
let mut child = Command::new("/bin/sh")
.args(["-c", "chown nobody /tmp && echo OK"])
.with_chroot(&rootfs)
.with_proc_mount()
.with_namespaces(Namespace::MOUNT | Namespace::PID | Namespace::UTS | Namespace::IPC)
.with_hostname("cap-add-test")
.with_seccomp_default()
.drop_all_capabilities()
.with_no_new_privileges(true)
.with_masked_paths_default()
.with_capabilities(Capability::CHOWN)
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout_bytes, stderr_bytes) =
child.wait_with_output().expect("wait_with_output failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
let stderr = String::from_utf8_lossy(&stderr_bytes);
assert!(
status.success(),
"chown should succeed with CAP_CHOWN restored; stdout={stdout:?} stderr={stderr:?}"
);
assert!(
stdout.contains("OK"),
"expected 'OK' from chown success; stdout={stdout:?} stderr={stderr:?}"
);
}
#[test]
fn test_compose_cap_add_chown_denied_without_cap() {
if !is_root() {
eprintln!("SKIP: test_compose_cap_add_chown_denied_without_cap requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_compose_cap_add_chown_denied_without_cap requires alpine-rootfs");
return;
}
};
let mut child = Command::new("/bin/sh")
.args(["-c", "chown nobody /tmp && echo OK || echo EPERM"])
.with_chroot(&rootfs)
.with_proc_mount()
.with_namespaces(Namespace::MOUNT | Namespace::PID | Namespace::UTS | Namespace::IPC)
.with_hostname("cap-denied-test")
.with_seccomp_default()
.drop_all_capabilities()
.with_no_new_privileges(true)
.with_masked_paths_default()
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (_status, stdout_bytes, _stderr_bytes) =
child.wait_with_output().expect("wait_with_output failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("EPERM"),
"expected chown to fail (EPERM) without CAP_CHOWN; stdout={stdout:?}"
);
assert!(
!stdout.contains("OK"),
"chown must NOT succeed without CAP_CHOWN; stdout={stdout:?}"
);
}
#[test]
fn test_default_caps_hex_value() {
if !is_root() {
eprintln!("SKIP: test_default_caps_hex_value requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_default_caps_hex_value requires alpine-rootfs");
return;
}
};
let mut child = Command::new("/bin/sh")
.args(["-c", "grep '^CapEff:' /proc/self/status"])
.with_chroot(&rootfs)
.with_proc_mount()
.with_namespaces(Namespace::MOUNT | Namespace::PID)
.with_capabilities(Capability::DEFAULT_CAPS)
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (status, stdout_bytes, _) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "grep failed: {stdout}");
let capeff_val = stdout
.lines()
.find(|l| l.starts_with("CapEff:"))
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("missing");
assert_eq!(
capeff_val, "00000000800405fb",
"DEFAULT_CAPS CapEff mismatch — expected 00000000800405fb (11-cap set), got {capeff_val}"
);
}
#[test]
fn test_default_caps_allows_chown_denies_mknod() {
if !is_root() {
eprintln!("SKIP: test_default_caps_allows_chown_denies_mknod requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_default_caps_allows_chown_denies_mknod requires alpine-rootfs");
return;
}
};
let mut child = Command::new("/bin/sh")
.args([
"-c",
"chown nobody /tmp && echo CHOWN=OK || echo CHOWN=FAIL; \
mknod /tmp/testdev c 1 1 2>/dev/null && echo MKNOD=OK || echo MKNOD=FAIL",
])
.with_chroot(&rootfs)
.with_proc_mount()
.with_namespaces(Namespace::MOUNT | Namespace::PID)
.with_capabilities(Capability::DEFAULT_CAPS)
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (_, stdout_bytes, _) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("CHOWN=OK"),
"CHOWN should succeed with DEFAULT_CAPS; stdout={stdout:?}"
);
assert!(
stdout.contains("MKNOD=FAIL"),
"MKNOD should fail with DEFAULT_CAPS (not in default set); stdout={stdout:?}"
);
}
#[test]
fn test_cap_drop_all_zeros_caps() {
if !is_root() {
eprintln!("SKIP: test_cap_drop_all_zeros_caps requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_cap_drop_all_zeros_caps requires alpine-rootfs");
return;
}
};
let mut child = Command::new("/bin/sh")
.args([
"-c",
"chown nobody /tmp && echo CHOWN=OK || echo CHOWN=FAIL",
])
.with_chroot(&rootfs)
.with_proc_mount()
.with_namespaces(Namespace::MOUNT | Namespace::PID)
.drop_all_capabilities()
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (_, stdout_bytes, _) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("CHOWN=FAIL"),
"CHOWN must fail after drop_all_capabilities(); stdout={stdout:?}"
);
}
#[test]
fn test_cap_drop_individual_removes_only_that_cap() {
if !is_root() {
eprintln!("SKIP: test_cap_drop_individual_removes_only_that_cap requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!(
"SKIP: test_cap_drop_individual_removes_only_that_cap requires alpine-rootfs"
);
return;
}
};
let caps = Capability::DEFAULT_CAPS & !Capability::CHOWN;
let mut child = Command::new("/bin/sh")
.args([
"-c",
"chown nobody /tmp && echo CHOWN=OK || echo CHOWN=FAIL; \
// DAC_OVERRIDE still present — reading a root-owned file works.
echo ALIVE",
])
.with_chroot(&rootfs)
.with_proc_mount()
.with_namespaces(Namespace::MOUNT | Namespace::PID)
.with_capabilities(caps)
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped)
.spawn()
.expect("spawn failed");
let (_, stdout_bytes, _) = child.wait_with_output().expect("wait failed");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("CHOWN=FAIL"),
"CHOWN must fail when individually dropped; stdout={stdout:?}"
);
assert!(
stdout.contains("ALIVE"),
"process must remain alive (other caps intact, not drop-all); stdout={stdout:?}"
);
}
mod auto_resolv_conf {
use super::*;
#[test]
fn test_auto_resolv_conf_loopback() {
if !is_root() {
eprintln!("SKIP: test_auto_resolv_conf_loopback requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_auto_resolv_conf_loopback requires alpine-rootfs");
return;
}
};
let (status, stdout_bytes, _) = Command::new("cat")
.args(["/etc/resolv.conf"])
.with_chroot(rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT | Namespace::IPC)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.spawn()
.expect("spawn failed")
.wait_with_output()
.expect("wait failed");
assert!(status.success(), "container exited non-zero: {:?}", status);
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("nameserver"),
"expected at least one 'nameserver' line in /etc/resolv.conf, got: {stdout:?}"
);
}
#[test]
fn test_explicit_dns_skips_auto_resolv() {
if !is_root() {
eprintln!("SKIP: test_explicit_dns_skips_auto_resolv requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_explicit_dns_skips_auto_resolv requires alpine-rootfs");
return;
}
};
let (status, stdout_bytes, _) = Command::new("cat")
.args(["/etc/resolv.conf"])
.with_chroot(rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT | Namespace::IPC)
.with_proc_mount()
.with_dns(&["1.2.3.4"])
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.spawn()
.expect("spawn failed")
.wait_with_output()
.expect("wait failed");
assert!(status.success(), "container exited non-zero: {:?}", status);
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("1.2.3.4"),
"expected explicitly configured nameserver 1.2.3.4 in resolv.conf, got: {stdout:?}"
);
}
#[test]
fn test_no_mount_ns_no_auto_resolv() {
if !is_root() {
eprintln!("SKIP: test_no_mount_ns_no_auto_resolv requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_no_mount_ns_no_auto_resolv requires alpine-rootfs");
return;
}
};
let status = Command::new("true")
.with_chroot(rootfs)
.with_namespaces(Namespace::UTS | Namespace::IPC)
.env("PATH", ALPINE_PATH)
.spawn()
.expect("spawn must succeed — MOUNT ns is auto-added by with_chroot")
.wait()
.expect("wait failed");
assert!(
status.success(),
"container must exit 0 with auto-added MOUNT ns: {:?}",
status
);
}
#[test]
#[serial]
fn test_pivot_root_old_root_inaccessible() {
if !is_root() {
eprintln!("SKIP: test_pivot_root_old_root_inaccessible requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_pivot_root_old_root_inaccessible requires alpine-rootfs");
return;
}
};
let (status, stdout, _) = Command::new("/bin/sh")
.args(["-c", "test ! -d /.pivot_root_old && echo ok"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::UTS | Namespace::MOUNT | Namespace::PID)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("spawn")
.wait_with_output()
.expect("wait");
assert!(
status.success(),
"/.pivot_root_old must not exist after pivot_root cleanup"
);
let out = String::from_utf8_lossy(&stdout);
assert!(out.trim() == "ok", "expected 'ok', got: {:?}", out);
}
#[test]
fn test_overlay_kernel_support_detected() {
let fs = std::fs::read_to_string("/proc/filesystems")
.expect("/proc/filesystems should be readable");
assert!(
fs.lines()
.any(|l| l.split_whitespace().any(|w| w == "overlay")),
"overlay not found in /proc/filesystems — pelagos image runs would fail with \
a clear error message on this kernel; install CONFIG_OVERLAY_FS"
);
}
#[test]
#[serial]
fn test_container_restart_after_exit() {
if !is_root() {
eprintln!("SKIP: test_container_restart_after_exit requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("SKIP: test_container_restart_after_exit requires alpine-rootfs");
return;
}
};
let bin = env!("CARGO_BIN_EXE_pelagos");
let name = "pelagos-restart-test-1";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/true",
])
.status()
.expect("pelagos run -d");
assert!(run_status.success(), "pelagos run -d failed");
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut exited = false;
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["status"].as_str() == Some("exited") {
exited = true;
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(exited, "container did not exit within 5s after /bin/true");
let data = std::fs::read_to_string(&state_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&data).unwrap();
assert!(
v.get("spawn_config").is_some(),
"state.json must contain spawn_config after first run"
);
let start_status = std::process::Command::new(bin)
.args(["start", name])
.status()
.expect("pelagos start");
assert!(start_status.success(), "pelagos start failed");
let deadline2 = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut exited2 = false;
while std::time::Instant::now() < deadline2 {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["status"].as_str() == Some("exited") {
exited2 = true;
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(exited2, "restarted container did not exit within 5s");
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
}
#[test]
#[serial]
fn test_container_restart_runs_same_command() {
if !is_root() {
eprintln!("SKIP: test_container_restart_runs_same_command requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("SKIP: test_container_restart_runs_same_command requires alpine-rootfs");
return;
}
};
let bin = env!("CARGO_BIN_EXE_pelagos");
let name = "pelagos-restart-test-2";
let tmp = tempfile::tempdir().expect("tempdir");
let marker = tmp.path().join("marker.txt");
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let bind_arg = format!("{}:/shared", tmp.path().display());
let run_status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"--bind",
&bind_arg,
"/bin/sh",
"-c",
"echo run1 > /shared/marker.txt",
])
.status()
.expect("pelagos run -d");
assert!(run_status.success(), "pelagos run -d failed");
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["status"].as_str() == Some("exited") {
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let content = std::fs::read_to_string(&marker).unwrap_or_default();
assert!(content.contains("run1"), "marker.txt should contain 'run1'");
let _ = std::fs::remove_file(&marker);
let start_status = std::process::Command::new(bin)
.args(["start", name])
.status()
.expect("pelagos start");
assert!(start_status.success(), "pelagos start failed");
let deadline2 = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["status"].as_str() == Some("exited") {
break;
}
}
}
if std::time::Instant::now() >= deadline2 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let content2 = std::fs::read_to_string(&marker).unwrap_or_default();
assert!(
content2.contains("run1"),
"marker.txt should be re-created on restart; got: {:?}",
content2
);
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
}
#[test]
#[serial]
fn test_container_start_running_fails() {
if !is_root() {
eprintln!("SKIP: test_container_start_running_fails requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("SKIP: test_container_start_running_fails requires alpine-rootfs");
return;
}
};
let bin = env!("CARGO_BIN_EXE_pelagos");
let name = "pelagos-restart-test-3";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sleep",
"30",
])
.status()
.expect("pelagos run -d");
assert!(run_status.success(), "pelagos run -d failed");
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["pid"].as_i64().unwrap_or(0) > 0 {
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let start_status = std::process::Command::new(bin)
.args(["start", name])
.status()
.expect("pelagos start invocation");
assert!(
!start_status.success(),
"pelagos start should fail when container is running"
);
let _ = std::process::Command::new(bin)
.args(["stop", name])
.output();
std::thread::sleep(std::time::Duration::from_millis(500));
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
}
#[test]
#[serial]
fn test_container_restart_preserves_tmpfs() {
if !is_root() {
eprintln!("SKIP: test_container_restart_preserves_tmpfs requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("SKIP: test_container_restart_preserves_tmpfs requires alpine-rootfs");
return;
}
};
let bin = env!("CARGO_BIN_EXE_pelagos");
let name = "pelagos-restart-tmpfs-test";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"--tmpfs",
"/tmp",
"/bin/sh",
"-c",
"echo hello > /tmp/test.txt",
])
.status()
.expect("pelagos run -d");
assert!(run_status.success(), "pelagos run -d with --tmpfs failed");
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut exited = false;
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["status"].as_str() == Some("exited") {
exited = true;
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(exited, "container did not exit within 5s");
let data = std::fs::read_to_string(&state_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&data).unwrap();
let tmpfs_arr = v["spawn_config"]["tmpfs"].as_array();
assert!(
tmpfs_arr.is_some() && !tmpfs_arr.unwrap().is_empty(),
"state.json spawn_config.tmpfs should contain /tmp; got: {:?}",
v["spawn_config"]["tmpfs"]
);
assert_eq!(
tmpfs_arr.unwrap()[0].as_str(),
Some("/tmp"),
"spawn_config.tmpfs[0] should be '/tmp'"
);
let start_status = std::process::Command::new(bin)
.args(["start", name])
.status()
.expect("pelagos start");
assert!(start_status.success(), "pelagos start with tmpfs failed");
let deadline2 = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut exited2 = false;
while std::time::Instant::now() < deadline2 {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["status"].as_str() == Some("exited") {
exited2 = true;
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(
exited2,
"restarted container (with tmpfs) did not exit within 5s"
);
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
}
#[test]
#[serial]
fn test_container_start_multiple_names() {
if !is_root() {
eprintln!("SKIP: test_container_start_multiple_names requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("SKIP: test_container_start_multiple_names requires alpine-rootfs");
return;
}
};
let bin = env!("CARGO_BIN_EXE_pelagos");
let names = ["pelagos-multi-start-1", "pelagos-multi-start-2"];
for &name in &names {
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
}
for &name in &names {
let status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/true",
])
.status()
.expect("pelagos run -d");
assert!(status.success(), "pelagos run -d failed for {name}");
}
for &name in &names {
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut exited = false;
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["status"].as_str() == Some("exited") {
exited = true;
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(exited, "container {name} did not exit within 5s");
}
let start_status = std::process::Command::new(bin)
.args(["start", names[0], names[1]])
.status()
.expect("pelagos start multi");
assert!(
start_status.success(),
"pelagos start with two names failed"
);
for &name in &names {
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut exited = false;
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["status"].as_str() == Some("exited") {
exited = true;
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(exited, "restarted container {name} did not exit within 5s");
}
for &name in &names {
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
}
}
#[test]
fn test_run_with_labels_appear_in_inspect() {
if !is_root() {
eprintln!("SKIP: test_run_with_labels_appear_in_inspect requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("SKIP: test_run_with_labels_appear_in_inspect requires alpine-rootfs");
return;
}
};
let bin = env!("CARGO_BIN_EXE_pelagos");
let name = "test-labels-inspect";
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
let run_status = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name,
"--label",
"env=staging",
"--label",
"managed=true",
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sleep",
"30",
])
.status()
.expect("pelagos run -d");
assert!(run_status.success(), "pelagos run -d with labels failed");
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["pid"].as_i64().unwrap_or(0) > 0 {
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let inspect_out = std::process::Command::new(bin)
.args(["container", "inspect", name])
.output()
.expect("pelagos container inspect");
assert!(inspect_out.status.success(), "inspect failed");
let json: serde_json::Value =
serde_json::from_slice(&inspect_out.stdout).expect("inspect output not JSON");
assert_eq!(
json["labels"]["env"].as_str(),
Some("staging"),
"label env=staging not found in inspect output"
);
assert_eq!(
json["labels"]["managed"].as_str(),
Some("true"),
"label managed=true not found in inspect output"
);
let _ = std::process::Command::new(bin)
.args(["stop", name])
.output();
std::thread::sleep(std::time::Duration::from_millis(500));
let _ = std::process::Command::new(bin)
.args(["rm", "-f", name])
.output();
}
#[test]
fn test_ps_filter_label() {
if !is_root() {
eprintln!("SKIP: test_ps_filter_label requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("SKIP: test_ps_filter_label requires alpine-rootfs");
return;
}
};
let bin = env!("CARGO_BIN_EXE_pelagos");
let name_a = "test-filter-label-a";
let name_b = "test-filter-label-b";
for n in [name_a, name_b] {
let _ = std::process::Command::new(bin)
.args(["rm", "-f", n])
.output();
}
let run_a = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name_a,
"--label",
"tier=web",
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sleep",
"30",
])
.status()
.expect("run A");
assert!(run_a.success());
let run_b = std::process::Command::new(bin)
.args([
"run",
"--network",
"loopback",
"-d",
"--name",
name_b,
"--label",
"tier=db",
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sleep",
"30",
])
.status()
.expect("run B");
assert!(run_b.success());
for n in [name_a, name_b] {
let state_path = format!("/run/pelagos/containers/{}/state.json", n);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if let Ok(data) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
if v["pid"].as_i64().unwrap_or(0) > 0 {
break;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
let ps_out = std::process::Command::new(bin)
.args(["ps", "--format", "json", "--filter", "label=tier=web"])
.output()
.expect("pelagos ps --filter");
assert!(ps_out.status.success(), "ps --filter failed");
let list: serde_json::Value =
serde_json::from_slice(&ps_out.stdout).expect("ps output not JSON");
let arr = list.as_array().expect("ps output is not a JSON array");
assert_eq!(arr.len(), 1, "expected exactly 1 container with tier=web");
assert_eq!(arr[0]["name"].as_str(), Some(name_a));
for n in [name_a, name_b] {
let _ = std::process::Command::new(bin).args(["stop", n]).output();
}
std::thread::sleep(std::time::Duration::from_millis(500));
for n in [name_a, name_b] {
let _ = std::process::Command::new(bin)
.args(["rm", "-f", n])
.output();
}
}
#[test]
fn test_build_pasta_dns_public_fallback() {
if is_root() {
eprintln!("SKIP: test_build_pasta_dns_public_fallback is for rootless mode");
return;
}
if !pelagos::network::is_pasta_available() {
eprintln!("SKIP: test_build_pasta_dns_public_fallback requires pasta");
return;
}
let alpine_image_dir =
std::path::Path::new("/var/lib/pelagos/images/docker.io_library_alpine_latest");
if !alpine_image_dir.exists() {
eprintln!("SKIP: test_build_pasta_dns_public_fallback requires alpine:latest image (pelagos image pull alpine)");
return;
}
let bin = env!("CARGO_BIN_EXE_pelagos");
let tag = "pelagos-test-pasta-dns-fallback";
let tmp = tempfile::tempdir().expect("tempdir");
let remfile = tmp.path().join("Remfile");
std::fs::write(&remfile, "FROM alpine\nRUN cat /etc/resolv.conf\n").expect("write Remfile");
let out = std::process::Command::new(bin)
.args([
"build",
"--network",
"pasta",
"--no-cache",
"-t",
tag,
"-f",
remfile.to_str().unwrap(),
tmp.path().to_str().unwrap(),
])
.output()
.expect("pelagos build failed to launch");
let _ = std::process::Command::new(bin)
.args(["image", "rm", tag])
.output();
assert!(
out.status.success(),
"pelagos build exited non-zero: {:?}\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert!(
combined.contains("8.8.8.8"),
"build RUN step resolv.conf must include 8.8.8.8 public fallback.\n\
This fails when execute_run() doesn't inject DNS for pasta mode.\n\
Build output: {combined}"
);
}
#[test]
fn test_build_run_pasta_dns_bind_mount_works() {
if is_root() {
eprintln!("SKIP: test_build_run_pasta_dns_bind_mount_works is for rootless mode");
return;
}
if !pelagos::network::is_pasta_available() {
eprintln!("SKIP: test_build_run_pasta_dns_bind_mount_works requires pasta");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_build_run_pasta_dns_bind_mount_works requires alpine-rootfs");
return;
}
};
let layer_dirs = vec![rootfs.clone()];
let (status, stdout_bytes, _) = Command::new("cat")
.args(["/etc/resolv.conf"])
.with_image_layers(layer_dirs)
.with_network(NetworkMode::Pasta)
.with_dns(&["8.8.8.8", "1.1.1.1"])
.env("PATH", ALPINE_PATH)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("spawn failed")
.wait_with_output()
.expect("wait failed");
assert!(status.success(), "container exited non-zero: {:?}", status);
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(
stdout.contains("8.8.8.8"),
"DNS bind-mount must deliver 8.8.8.8 into the container, got: {stdout:?}"
);
}
}
mod pasta_diagnostic_tests {
#[test]
fn test_pasta_teardown_logs_output() {
use std::io::Read;
use std::process::{Command, Stdio};
let mut child = Command::new("sh")
.args([
"-c",
"echo 'pasta-stdout-sentinel'; echo 'pasta-stderr-sentinel' >&2",
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("sh should be available");
let mut stdout_pipe = child.stdout.take().expect("stdout pipe");
let mut stderr_pipe = child.stderr.take().expect("stderr pipe");
let output_thread: std::thread::JoinHandle<String> = std::thread::spawn(move || {
let stderr_thread = std::thread::spawn(move || {
let mut s = String::new();
let _ = stderr_pipe.read_to_string(&mut s);
s
});
let mut stdout_out = String::new();
let _ = stdout_pipe.read_to_string(&mut stdout_out);
let stderr_out = stderr_thread.join().unwrap_or_default();
let mut combined = stdout_out;
if !stderr_out.is_empty() {
if !combined.is_empty() {
combined.push('\n');
}
combined.push_str(&stderr_out);
}
combined
});
let _ = child.wait();
let _ = child.kill();
let output = output_thread
.join()
.expect("output thread should not panic");
assert!(
output.contains("pasta-stdout-sentinel"),
"stdout capture missing from combined output; got: {:?}",
output
);
assert!(
output.contains("pasta-stderr-sentinel"),
"stderr capture missing from combined output; got: {:?}",
output
);
}
#[test]
fn test_pasta_root_bind_mount() {
use std::process::Command;
if unsafe { libc::geteuid() } != 0 {
eprintln!("SKIP: not root");
return;
}
if !Command::new("pasta")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
{
eprintln!("SKIP: pasta not in PATH");
return;
}
if !std::path::Path::new("/dev/net/tun").exists() {
eprintln!("SKIP: /dev/net/tun not found (tun module not loaded)");
return;
}
let netns_proc = Command::new("unshare")
.args(["--net", "sleep", "30"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("unshare --net sleep 30");
let cpid = netns_proc.id();
struct KillOnDrop(std::process::Child);
impl Drop for KillOnDrop {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let _guard = KillOnDrop(netns_proc);
std::thread::sleep(std::time::Duration::from_millis(200));
let ns_dir = std::path::Path::new("/run/pelagos/pasta-ns");
std::fs::create_dir_all(ns_dir).expect("create /run/pelagos/pasta-ns");
let mount_path = ns_dir.join(format!("{}", cpid));
std::fs::write(&mount_path, b"").expect("create mount point file");
let src = std::ffi::CString::new(format!("/proc/{}/ns/net", cpid)).unwrap();
let dst = std::ffi::CString::new(mount_path.to_str().unwrap()).unwrap();
let fstype = std::ffi::CString::new("").unwrap();
let rc = unsafe {
libc::mount(
src.as_ptr(),
dst.as_ptr(),
fstype.as_ptr(),
libc::MS_BIND,
std::ptr::null(),
)
};
assert_eq!(
rc,
0,
"mount --bind failed: {}",
std::io::Error::last_os_error()
);
struct UmountOnDrop(std::path::PathBuf);
impl Drop for UmountOnDrop {
fn drop(&mut self) {
let path = std::ffi::CString::new(self.0.to_str().unwrap()).unwrap();
unsafe { libc::umount2(path.as_ptr(), libc::MNT_DETACH) };
let _ = std::fs::remove_file(&self.0);
}
}
let _umount = UmountOnDrop(mount_path.clone());
let netns_arg = mount_path.to_string_lossy().into_owned();
let mut pasta = Command::new("pasta")
.args([
"--foreground",
"--config-net",
"--netns",
&netns_arg,
"--runas",
"0",
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("pasta spawn");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut tap_found = false;
while std::time::Instant::now() < deadline {
if let Ok(Some(status)) = pasta.try_wait() {
let mut stdout_out = String::new();
let mut stderr_out = String::new();
if let Some(mut out) = pasta.stdout.take() {
let _ = std::io::Read::read_to_string(&mut out, &mut stdout_out);
}
if let Some(mut err) = pasta.stderr.take() {
let _ = std::io::Read::read_to_string(&mut err, &mut stderr_out);
}
panic!(
"pasta exited early (status: {}) before TAP appeared\n\
stdout: {}\nstderr: {}",
status, stdout_out, stderr_out
);
}
let dev_path = format!("/proc/{}/net/dev", cpid);
if let Ok(content) = std::fs::read_to_string(&dev_path) {
if content.lines().skip(2).any(|l| {
let name = l.split(':').next().unwrap_or("").trim();
!name.is_empty() && name != "lo"
}) {
tap_found = true;
break;
}
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
let _ = pasta.kill();
let _ = pasta.wait();
assert!(
tap_found,
"pasta did not create a TAP interface in netns of pid {} within 5s \
using bind-mount approach — issue #107 root-mode regression",
cpid
);
}
}
mod json_flag_tests {
use std::process::Command;
fn pelagos_bin() -> std::path::PathBuf {
let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("target/debug/pelagos");
p
}
#[test]
fn test_ps_json_flag_produces_valid_json() {
let bin = pelagos_bin();
for args in &[vec!["ps", "--json"], vec!["ps", "--json", "--all"]] {
let out = Command::new(&bin)
.args(args)
.output()
.expect("pelagos ps --json");
assert!(
out.status.success(),
"pelagos {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
serde_json::from_str::<serde_json::Value>(&stdout).unwrap_or_else(|e| {
panic!(
"pelagos {:?} output is not valid JSON: {}\noutput: {}",
args, e, stdout
)
});
}
}
#[test]
fn test_ps_json_and_format_json_identical() {
let bin = pelagos_bin();
let out_json = Command::new(&bin)
.args(["ps", "--json", "--all"])
.output()
.expect("pelagos ps --json");
let out_fmt = Command::new(&bin)
.args(["ps", "--format", "json", "--all"])
.output()
.expect("pelagos ps --format json");
assert!(out_json.status.success());
assert!(out_fmt.status.success());
assert_eq!(
String::from_utf8_lossy(&out_json.stdout),
String::from_utf8_lossy(&out_fmt.stdout),
"--json and --format json produced different output"
);
}
#[test]
fn test_image_ls_json_flag_produces_valid_json() {
let bin = pelagos_bin();
let out = Command::new(&bin)
.args(["image", "ls", "--json"])
.output()
.expect("pelagos image ls --json");
assert!(
out.status.success(),
"pelagos image ls --json failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
serde_json::from_str::<serde_json::Value>(&stdout)
.unwrap_or_else(|e| panic!("not valid JSON: {}\noutput: {}", e, stdout));
}
#[test]
fn test_network_ls_json_flag_produces_valid_json() {
let bin = pelagos_bin();
let out = Command::new(&bin)
.args(["network", "ls", "--json"])
.output()
.expect("pelagos network ls --json");
assert!(
out.status.success(),
"pelagos network ls --json failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
serde_json::from_str::<serde_json::Value>(&stdout)
.unwrap_or_else(|e| panic!("not valid JSON: {}\noutput: {}", e, stdout));
}
#[test]
fn test_volume_ls_json_flag_produces_valid_json() {
let bin = pelagos_bin();
let out = Command::new(&bin)
.args(["volume", "ls", "--json"])
.output()
.expect("pelagos volume ls --json");
assert!(
out.status.success(),
"pelagos volume ls --json failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
serde_json::from_str::<serde_json::Value>(&stdout)
.unwrap_or_else(|e| panic!("not valid JSON: {}\noutput: {}", e, stdout));
}
}
mod issue_109_run_finds_built_image {
use crate::is_root;
use pelagos::{build, image};
use std::collections::HashMap;
#[test]
fn test_run_finds_image_built_with_bare_tag() {
if !is_root() {
eprintln!("SKIP test_run_finds_image_built_with_bare_tag: requires root");
return;
}
if image::load_image("docker.io/library/alpine:latest").is_err() {
eprintln!(
"SKIP test_run_finds_image_built_with_bare_tag: \
alpine not pulled (run: pelagos image pull alpine)"
);
return;
}
let tag = "pelagos-issue-109-test";
let _ = image::remove_image(tag);
let _ = image::remove_image(&format!("{}:latest", tag));
let remfile = "FROM alpine\nRUN echo issue109 > /issue109.txt\n";
let instructions = build::parse_remfile(remfile).expect("parse_remfile");
let tmpdir = tempfile::tempdir().expect("tempdir");
let manifest = build::execute_build(
&instructions,
tmpdir.path(),
tag, pelagos::network::NetworkMode::Loopback,
false,
&HashMap::new(),
None,
)
.expect("execute_build");
assert_eq!(
manifest.reference,
format!("{}:latest", tag),
"execute_build should append :latest to a bare tag"
);
let found_bare = image::load_image(tag);
assert!(
found_bare.is_ok(),
"load_image('{}') failed — run.rs fix is missing or broken: {:?}",
tag,
found_bare.err()
);
let found_latest = image::load_image(&format!("{}:latest", tag));
assert!(
found_latest.is_ok(),
"load_image('{}:latest') failed unexpectedly",
tag
);
let _ = image::remove_image(&manifest.reference);
}
}
mod issue_110_pasta_stdin_isolation {
use crate::is_root;
use pelagos::{build, image};
use std::collections::HashMap;
#[test]
fn test_pasta_stdin_not_contaminated() {
if !is_root() {
eprintln!("SKIP test_pasta_stdin_not_contaminated: requires root");
return;
}
if !pelagos::network::is_pasta_available() {
eprintln!("SKIP test_pasta_stdin_not_contaminated: pasta not installed");
return;
}
if image::load_image("docker.io/library/alpine:latest").is_err() {
eprintln!(
"SKIP test_pasta_stdin_not_contaminated: \
alpine not pulled (run: pelagos image pull alpine)"
);
return;
}
let tag = "pelagos-issue-110-test";
let _ = image::remove_image(tag);
let _ = image::remove_image(&format!("{}:latest", tag));
let old_rust_log = std::env::var("RUST_LOG").ok();
std::env::set_var("RUST_LOG", "debug");
let remfile = "FROM alpine\nRUN cat /dev/stdin | wc -c > /stdin-bytes.txt\n";
let instructions = build::parse_remfile(remfile).expect("parse_remfile");
let tmpdir = tempfile::tempdir().expect("tempdir");
let result = build::execute_build(
&instructions,
tmpdir.path(),
tag,
pelagos::network::NetworkMode::Pasta,
false,
&HashMap::new(),
None,
);
match old_rust_log {
Some(val) => std::env::set_var("RUST_LOG", val),
None => std::env::remove_var("RUST_LOG"),
}
let manifest = result.expect("execute_build should succeed (pasta stdin isolation)");
let layer_dirs = image::layer_dirs(&manifest);
let cmd = pelagos::container::Command::new("/bin/cat")
.args(["/stdin-bytes.txt"])
.with_image_layers(layer_dirs)
.stdin(pelagos::container::Stdio::Null)
.stdout(pelagos::container::Stdio::Piped)
.stderr(pelagos::container::Stdio::Null);
let mut child = cmd.spawn().expect("spawn cat");
let (status, stdout, _) = child.wait_with_output().expect("wait");
assert!(status.success(), "cat /stdin-bytes.txt failed");
let out = String::from_utf8_lossy(&stdout);
let bytes: u64 = out.trim().parse().unwrap_or(u64::MAX);
assert_eq!(
bytes, 0,
"stdin was not empty during the RUN step: {} bytes leaked (pelagos log / pasta fd aliasing)",
bytes
);
let _ = image::remove_image(&manifest.reference);
}
#[test]
fn test_build_run_path_isolated_from_host() {
if !is_root() {
eprintln!("SKIP test_build_run_path_isolated_from_host: requires root");
return;
}
if image::load_image("docker.io/library/alpine:latest").is_err() {
eprintln!(
"SKIP test_build_run_path_isolated_from_host: \
alpine not pulled (run: pelagos image pull alpine)"
);
return;
}
let tag = "pelagos-issue-110-path-test";
let _ = image::remove_image(tag);
let _ = image::remove_image(&format!("{}:latest", tag));
let old_path = std::env::var("PATH").ok();
std::env::set_var("PATH", "/nonexistent-poison-path");
let remfile =
"FROM alpine\nRUN ls /usr/bin/env > /found.txt 2>&1 && echo ok >> /found.txt\n";
let instructions = build::parse_remfile(remfile).expect("parse_remfile");
let tmpdir = tempfile::tempdir().expect("tempdir");
let result = build::execute_build(
&instructions,
tmpdir.path(),
tag,
pelagos::network::NetworkMode::Loopback,
false,
&HashMap::new(),
None,
);
match old_path {
Some(val) => std::env::set_var("PATH", val),
None => std::env::remove_var("PATH"),
}
let manifest = result.expect(
"execute_build failed — env_clear() may be missing (container inherited poisoned PATH)",
);
let layer_dirs = image::layer_dirs(&manifest);
let cmd = pelagos::container::Command::new("/bin/cat")
.args(["/found.txt"])
.with_image_layers(layer_dirs)
.stdin(pelagos::container::Stdio::Null)
.stdout(pelagos::container::Stdio::Piped)
.stderr(pelagos::container::Stdio::Null);
let mut child = cmd.spawn().expect("spawn cat");
let (status, stdout, _) = child.wait_with_output().expect("wait");
assert!(status.success(), "cat /found.txt failed");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("ok"),
"PATH isolation failed: /found.txt does not contain 'ok'. \
Container may have inherited poisoned host PATH. Output: {:?}",
out
);
let _ = image::remove_image(&manifest.reference);
}
}
#[cfg(test)]
mod issue_110_path_fallback {
use super::*;
use pelagos::{build, image};
use std::collections::HashMap;
#[test]
fn test_build_run_path_fallback_when_config_env_empty() {
if !is_root() {
eprintln!("SKIP test_build_run_path_fallback_when_config_env_empty: requires root");
return;
}
let base_tag = "docker.io/library/alpine:latest";
let test_tag = "pelagos-issue-110-empty-env-test:latest";
let out_tag = "pelagos-issue-110-empty-env-output";
let base_manifest = match image::load_image(base_tag) {
Err(_) => {
eprintln!(
"SKIP test_build_run_path_fallback_when_config_env_empty: \
alpine not pulled (run: pelagos image pull alpine)"
);
return;
}
Ok(m) => m,
};
let empty_env_manifest = image::ImageManifest {
reference: test_tag.to_string(),
digest: base_manifest.digest.clone(),
layers: base_manifest.layers.clone(),
layer_types: base_manifest.layer_types.clone(),
config: image::ImageConfig {
env: Vec::new(), cmd: vec!["/bin/sh".to_string()],
entrypoint: Vec::new(),
working_dir: String::new(),
user: String::new(),
labels: HashMap::new(),
healthcheck: None,
stop_signal: String::new(),
},
};
image::save_image(&empty_env_manifest).expect("save_image with empty env");
let remfile =
format!("FROM {test_tag}\nRUN chmod 644 /etc/hostname && printenv PATH > /out.txt\n",);
let instructions = build::parse_remfile(&remfile).expect("parse_remfile");
let tmpdir = tempfile::tempdir().expect("tempdir");
let _ = image::remove_image(out_tag);
let _ = image::remove_image(&format!("{}:latest", out_tag));
let result = build::execute_build(
&instructions,
tmpdir.path(),
out_tag,
pelagos::network::NetworkMode::Loopback,
false,
&HashMap::new(),
None,
);
let _ = image::remove_image(test_tag);
let manifest = result.expect(
"execute_build failed — PATH fallback missing when config.env is empty \
(chmod not found, exit 127)",
);
let layer_dirs = image::layer_dirs(&manifest);
let cmd = pelagos::container::Command::new("/bin/cat")
.args(["/out.txt"])
.with_image_layers(layer_dirs)
.stdin(pelagos::container::Stdio::Null)
.stdout(pelagos::container::Stdio::Piped)
.stderr(pelagos::container::Stdio::Null);
let mut child = cmd.spawn().expect("spawn cat");
let (status, stdout, _) = child.wait_with_output().expect("wait");
assert!(status.success(), "cat /out.txt failed");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains('/'),
"PATH was empty or missing in container; execute_run fallback injection \
did not work when config.env was empty. Output: {:?}",
out
);
let _ = image::remove_image(&manifest.reference);
}
}
#[cfg(test)]
mod issue_110_env_path_substitution {
use super::*;
use pelagos::{build, image};
use std::collections::HashMap;
#[test]
fn test_env_path_expands_base_image_value() {
if !is_root() {
eprintln!("SKIP test_env_path_expands_base_image_value: requires root");
return;
}
let ecr_ubuntu = "public.ecr.aws/docker/library/ubuntu:22.04";
if image::load_image(ecr_ubuntu).is_err() {
eprintln!(
"SKIP test_env_path_expands_base_image_value: \
ECR ubuntu not pulled (run: pelagos image pull {})",
ecr_ubuntu
);
return;
}
let out_tag = "pelagos-issue-110-env-path-test";
let _ = image::remove_image(out_tag);
let _ = image::remove_image(&format!("{}:latest", out_tag));
let remfile = format!(
"FROM {ecr_ubuntu}\n\
ENV NVM_DIR=\"/usr/local/share/nvm\"\n\
ENV PATH=\"${{NVM_DIR}}/versions/node/v18/bin:${{PATH}}\"\n\
RUN chmod 644 /etc/hostname && printenv PATH > /out.txt\n"
);
let instructions = build::parse_remfile(&remfile).expect("parse_remfile");
let tmpdir = tempfile::tempdir().expect("tempdir");
let result = build::execute_build(
&instructions,
tmpdir.path(),
out_tag,
pelagos::network::NetworkMode::Loopback,
false,
&HashMap::new(),
None,
);
let manifest = result.expect(
"execute_build failed — ENV PATH expansion broke PATH \
(chmod not found, exit 127). ${PATH} in ENV may not expand \
to the base image value.",
);
let layer_dirs = image::layer_dirs(&manifest);
let cmd = pelagos::container::Command::new("/bin/cat")
.args(["/out.txt"])
.with_image_layers(layer_dirs)
.stdin(pelagos::container::Stdio::Null)
.stdout(pelagos::container::Stdio::Piped)
.stderr(pelagos::container::Stdio::Null);
let mut child = cmd.spawn().expect("spawn cat");
let (status, stdout, _) = child.wait_with_output().expect("wait");
assert!(status.success(), "cat /out.txt failed");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("/usr/bin") || out.contains("/bin"),
"PATH does not contain standard system dirs after ENV PATH expansion. \
${{PATH}} in ENV may not be expanding to the base image value. \
Got: {:?}",
out
);
let _ = image::remove_image(&manifest.reference);
}
}
#[cfg(test)]
mod issue_111_tmp_writable {
use super::*;
use pelagos::{build, image};
use std::collections::HashMap;
#[test]
fn test_build_run_tmp_is_world_writable() {
if !is_root() {
eprintln!("SKIP test_build_run_tmp_is_world_writable: requires root");
return;
}
let ecr_ubuntu = "public.ecr.aws/docker/library/ubuntu:22.04";
if image::load_image(ecr_ubuntu).is_err() {
eprintln!(
"SKIP test_build_run_tmp_is_world_writable: \
ECR ubuntu not pulled (run: pelagos image pull {})",
ecr_ubuntu
);
return;
}
let out_tag = "pelagos-issue-111-tmp-test";
let _ = image::remove_image(out_tag);
let _ = image::remove_image(&format!("{}:latest", out_tag));
let remfile = format!(
"FROM {ecr_ubuntu}\n\
RUN stat -c '%a' /tmp > /tmp-mode.txt \
&& touch /tmp/canary.txt \
&& echo OK >> /tmp-mode.txt\n"
);
let instructions = build::parse_remfile(&remfile).expect("parse_remfile");
let tmpdir = tempfile::tempdir().expect("tempdir");
let result = build::execute_build(
&instructions,
tmpdir.path(),
out_tag,
pelagos::network::NetworkMode::Loopback,
false,
&HashMap::new(),
None,
);
let manifest = result.expect(
"execute_build failed — /tmp may not be writable inside RUN step. \
fix_staging_dir_perms may not be setting /tmp to 0o1777 in build.rs.",
);
let layer_dirs = image::layer_dirs(&manifest);
let cmd = pelagos::container::Command::new("/bin/cat")
.args(["/tmp-mode.txt"])
.with_image_layers(layer_dirs)
.stdin(pelagos::container::Stdio::Null)
.stdout(pelagos::container::Stdio::Piped)
.stderr(pelagos::container::Stdio::Null);
let mut child = cmd.spawn().expect("spawn cat");
let (status, stdout, _) = child.wait_with_output().expect("wait");
assert!(status.success(), "cat /tmp-mode.txt failed");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("1777"),
"/tmp mode was not 1777 inside RUN step container. \
apt-key and similar tools require sticky + world-writable /tmp. \
Got: {:?}",
out
);
assert!(
out.contains("OK"),
"Failed to create a file in /tmp inside RUN step container. \
Got: {:?}",
out
);
let _ = image::remove_image(&manifest.reference);
}
#[test]
fn test_build_copy_to_tmp_visible_in_run() {
if !is_root() {
eprintln!("SKIP test_build_copy_to_tmp_visible_in_run: requires root");
return;
}
let ecr_ubuntu = "public.ecr.aws/docker/library/ubuntu:22.04";
if image::load_image(ecr_ubuntu).is_err() {
eprintln!(
"SKIP test_build_copy_to_tmp_visible_in_run: \
ECR ubuntu not pulled (run: pelagos image pull {})",
ecr_ubuntu
);
return;
}
let out_tag = "pelagos-issue-111-copy-tmp-test";
let _ = image::remove_image(out_tag);
let _ = image::remove_image(&format!("{}:latest", out_tag));
let tmpdir = tempfile::tempdir().expect("tempdir");
std::fs::write(tmpdir.path().join("sentinel.txt"), "copy-in-tmp-ok\n")
.expect("write sentinel");
let remfile = format!(
"FROM {ecr_ubuntu}\n\
COPY sentinel.txt /tmp/sentinel.txt\n\
RUN cat /tmp/sentinel.txt && echo COPY_VISIBLE\n"
);
let instructions = build::parse_remfile(&remfile).expect("parse_remfile");
let result = build::execute_build(
&instructions,
tmpdir.path(),
out_tag,
pelagos::network::NetworkMode::Loopback,
false,
&HashMap::new(),
None,
);
result.expect(
"execute_build failed — COPY'd file in /tmp was not visible to RUN. \
A tmpfs mount on /tmp in execute_run would shadow overlay content. \
Check that with_tmpfs(\"/tmp\",...) is absent from execute_run in build.rs.",
);
let _ = image::remove_image(&format!("{}:latest", out_tag));
}
#[test]
fn test_build_tmp_writable_after_copy() {
if !is_root() {
eprintln!("SKIP test_build_tmp_writable_after_copy: requires root");
return;
}
let ecr_ubuntu = "public.ecr.aws/docker/library/ubuntu:22.04";
if image::load_image(ecr_ubuntu).is_err() {
eprintln!(
"SKIP test_build_tmp_writable_after_copy: \
ECR ubuntu not pulled (run: pelagos image pull {})",
ecr_ubuntu
);
return;
}
let out_tag = "pelagos-issue-111-copy-tmp-perm-test";
let _ = image::remove_image(out_tag);
let _ = image::remove_image(&format!("{}:latest", out_tag));
let tmpdir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmpdir.path().join("features/node")).expect("mkdir");
std::fs::write(
tmpdir.path().join("features/node/setup.sh"),
"#!/bin/sh\necho ok\n",
)
.expect("write script");
let remfile = format!(
"FROM {ecr_ubuntu}\n\
COPY features/ /tmp/dev-features/\n\
RUN chmod -R 0755 /tmp/dev-features/node \
&& stat -c '%a' /tmp > /tmp-mode.txt \
&& touch /tmp/apt-canary.txt\n"
);
let instructions = build::parse_remfile(&remfile).expect("parse_remfile");
let result = build::execute_build(
&instructions,
tmpdir.path(),
out_tag,
pelagos::network::NetworkMode::Loopback,
false,
&HashMap::new(),
None,
);
let manifest = result.expect(
"execute_build failed — /tmp was not writable after COPY into /tmp. \
copy_dir_recursive may not be preserving directory permissions \
(sticky bit 1777 → 755 due to umask). Check copy_dir_recursive in build.rs.",
);
let layer_dirs = image::layer_dirs(&manifest);
let cmd = pelagos::container::Command::new("/bin/cat")
.args(["/tmp-mode.txt"])
.with_image_layers(layer_dirs)
.stdin(pelagos::container::Stdio::Null)
.stdout(pelagos::container::Stdio::Piped)
.stderr(pelagos::container::Stdio::Null);
let mut child = cmd.spawn().expect("spawn cat");
let (status, stdout, _) = child.wait_with_output().expect("wait");
assert!(status.success(), "cat /tmp-mode.txt failed");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("1777"),
"/tmp mode was not 1777 after COPY into /tmp in RUN step. \
apt-key and devcontainer feature installs require sticky + world-writable /tmp. \
Got: {:?}",
out
);
let _ = image::remove_image(&manifest.reference);
}
#[test]
fn test_build_copy_from_stage_tmp_writable() {
if !is_root() {
eprintln!("SKIP test_build_copy_from_stage_tmp_writable: requires root");
return;
}
let ecr_ubuntu = "public.ecr.aws/docker/library/ubuntu:22.04";
if image::load_image(ecr_ubuntu).is_err() {
eprintln!(
"SKIP test_build_copy_from_stage_tmp_writable: \
ECR ubuntu not pulled (run: pelagos image pull {})",
ecr_ubuntu
);
return;
}
let out_tag = "pelagos-issue-111-copy-from-tmp-test";
let _ = image::remove_image(out_tag);
let _ = image::remove_image(&format!("{}:latest", out_tag));
let tmpdir = tempfile::tempdir().expect("tempdir");
std::fs::write(tmpdir.path().join("probe.txt"), "probe\n").expect("write");
let remfile = format!(
"FROM scratch AS content\n\
COPY probe.txt /tmp/build-features/probe.txt\n\
\n\
FROM {ecr_ubuntu}\n\
COPY --from=content /tmp/build-features/probe.txt /tmp/build-features/probe.txt\n\
RUN stat -c '%a' /tmp > /tmp-mode.txt && touch /tmp/apt-canary.txt\n"
);
let instructions = build::parse_remfile(&remfile).expect("parse_remfile");
let result = build::execute_build(
&instructions,
tmpdir.path(),
out_tag,
pelagos::network::NetworkMode::Loopback,
false,
&HashMap::new(),
None,
);
let manifest = result.expect(
"execute_build failed — /tmp not writable after COPY --from into /tmp. \
fix_staging_dir_perms may be missing from execute_copy_from_stage in build.rs.",
);
let layer_dirs = image::layer_dirs(&manifest);
let cmd = pelagos::container::Command::new("/bin/cat")
.args(["/tmp-mode.txt"])
.with_image_layers(layer_dirs)
.stdin(pelagos::container::Stdio::Null)
.stdout(pelagos::container::Stdio::Piped)
.stderr(pelagos::container::Stdio::Null);
let mut child = cmd.spawn().expect("spawn cat");
let (status, stdout, _) = child.wait_with_output().expect("wait");
assert!(status.success(), "cat /tmp-mode.txt failed");
let out = String::from_utf8_lossy(&stdout);
assert!(
out.contains("1777"),
"/tmp mode was not 1777 after COPY --from into /tmp. Got: {:?}",
out
);
let _ = image::remove_image(&manifest.reference);
}
}
#[cfg(test)]
mod issue_112_ca_cert_bind_mount {
use super::*;
use pelagos::{build, image};
use std::collections::HashMap;
#[test]
fn test_build_apt_install_ca_certificates() {
if !is_root() {
eprintln!("SKIP test_build_apt_install_ca_certificates: requires root");
return;
}
let ecr_ubuntu = "public.ecr.aws/docker/library/ubuntu:22.04";
if image::load_image(ecr_ubuntu).is_err() {
eprintln!(
"SKIP test_build_apt_install_ca_certificates: \
ECR ubuntu not pulled (run: pelagos image pull {})",
ecr_ubuntu
);
return;
}
let out_tag = "pelagos-issue-112-ca-certs-test";
let _ = image::remove_image(out_tag);
let _ = image::remove_image(&format!("{}:latest", out_tag));
let remfile = format!(
"FROM {ecr_ubuntu}\n\
RUN apt-get update && apt-get install -y ca-certificates\n"
);
let instructions = build::parse_remfile(&remfile).expect("parse_remfile");
let tmpdir = tempfile::tempdir().expect("tempdir");
let result = build::execute_build(
&instructions,
tmpdir.path(),
out_tag,
pelagos::network::NetworkMode::Pasta,
false,
&HashMap::new(),
None,
);
result.expect(
"execute_build failed — apt-get install ca-certificates returned an error. \
The host CA bundle may be unconditionally bind-mounted over the container's \
ca-certificates.crt, causing EBUSY on rename. \
Check the already_has_certs guard in container.rs.",
);
let _ = image::remove_image(&format!("{}:latest", out_tag));
}
}
mod issue_114_image_env_applied_on_run {
use crate::is_root;
use pelagos::build;
use pelagos::container::{Command, Namespace, Stdio};
use pelagos::image;
use std::collections::HashMap;
#[test]
fn test_run_applies_image_env_path() {
if !is_root() {
eprintln!("SKIP test_run_applies_image_env_path: requires root");
return;
}
if image::load_image("docker.io/library/alpine:latest").is_err() {
eprintln!(
"SKIP test_run_applies_image_env_path: \
alpine not pulled (run: pelagos image pull alpine)"
);
return;
}
let out_tag = "pelagos-issue-114-test";
let _ = image::remove_image(out_tag);
let _ = image::remove_image(&format!("{}:latest", out_tag));
let remfile =
"FROM alpine\nENV PATH=/issue-114-sentinel:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n";
let instructions = build::parse_remfile(remfile).expect("parse_remfile");
let tmpdir = tempfile::tempdir().expect("tempdir");
let manifest = build::execute_build(
&instructions,
tmpdir.path(),
out_tag,
pelagos::network::NetworkMode::Loopback,
false,
&HashMap::new(),
None,
)
.expect("execute_build");
let has_sentinel = manifest
.config
.env
.iter()
.any(|e| e.contains("/issue-114-sentinel"));
assert!(
has_sentinel,
"manifest.config.env does not contain /issue-114-sentinel: {:?}",
manifest.config.env
);
let layer_dirs = image::layer_dirs(&manifest);
let mut cmd = Command::new("/bin/sh")
.args(["-c", "echo $PATH"])
.with_image_layers(layer_dirs)
.add_namespaces(Namespace::UTS | Namespace::PID)
.stdout(Stdio::Piped)
.stderr(Stdio::Piped);
if !manifest
.config
.env
.iter()
.any(|e| e == "PATH" || e.starts_with("PATH="))
{
cmd = cmd.env(
"PATH",
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
);
}
for env_str in &manifest.config.env {
if let Some((k, v)) = env_str.split_once('=') {
cmd = cmd.env(k, v);
}
}
let mut child = cmd.spawn().expect("spawn");
let (status, stdout, _stderr) = child.wait_with_output().expect("wait_with_output");
let out = String::from_utf8_lossy(&stdout);
assert!(status.success(), "container exited non-zero: {:?}", status);
assert!(
out.contains("/issue-114-sentinel"),
"PATH does not contain /issue-114-sentinel; got: {:?}",
out.trim()
);
let _ = image::remove_image(&format!("{}:latest", out_tag));
}
}
mod issue_115_exec_applies_image_env {
use crate::is_root;
use pelagos::build;
use pelagos::image;
use std::collections::HashMap;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_pelagos")
}
fn cleanup(name: &str) {
let _ = std::process::Command::new(bin())
.args(["stop", name])
.output();
std::thread::sleep(std::time::Duration::from_millis(300));
let _ = std::process::Command::new(bin())
.args(["rm", "-f", name])
.output();
}
fn wait_for_container(name: &str, timeout_ms: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
while std::time::Instant::now() < deadline {
let out = std::process::Command::new(bin())
.args(["ps", "--all"])
.output()
.ok();
if let Some(o) = out {
let s = String::from_utf8_lossy(&o.stdout);
if s.lines().any(|l| l.split_whitespace().next() == Some(name)) {
return true;
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
false
}
#[test]
#[serial_test::serial]
fn test_exec_applies_image_env_path() {
if !is_root() {
eprintln!("SKIP test_exec_applies_image_env_path: requires root");
return;
}
if image::load_image("docker.io/library/alpine:latest").is_err() {
eprintln!(
"SKIP test_exec_applies_image_env_path: \
alpine not pulled (run: pelagos image pull alpine)"
);
return;
}
let out_tag = "pelagos-issue-115-test";
let ctr_name = "pelagos-issue-115-ctr";
let _ = image::remove_image(out_tag);
let _ = image::remove_image(&format!("{}:latest", out_tag));
cleanup(ctr_name);
let remfile =
"FROM alpine\nENV PATH=/issue-115-sentinel:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n";
let instructions = build::parse_remfile(remfile).expect("parse_remfile");
let tmpdir = tempfile::tempdir().expect("tempdir");
build::execute_build(
&instructions,
tmpdir.path(),
out_tag,
pelagos::network::NetworkMode::Loopback,
false,
&HashMap::new(),
None,
)
.expect("execute_build");
let run_status = std::process::Command::new(bin())
.args([
"run",
"--detach",
"--name",
ctr_name,
&format!("{}:latest", out_tag),
"/bin/sleep",
"300",
])
.stdin(std::process::Stdio::null())
.status()
.expect("pelagos run --detach");
assert!(run_status.success(), "detached run should exit 0");
assert!(
wait_for_container(ctr_name, 10_000),
"container '{}' did not appear in ps within 10s",
ctr_name
);
let exec_out = std::process::Command::new(bin())
.args(["exec", ctr_name, "/bin/sh", "-c", "echo $PATH"])
.output()
.expect("pelagos exec");
let stdout = String::from_utf8_lossy(&exec_out.stdout);
let stderr = String::from_utf8_lossy(&exec_out.stderr);
assert!(
exec_out.status.success(),
"pelagos exec should exit 0; stderr={}",
stderr.trim()
);
assert!(
stdout.contains("/issue-115-sentinel"),
"exec'd PATH does not contain /issue-115-sentinel; got: {:?}\n\
This means cmd_exec does not load the image manifest config env.\n\
Check issue_115 fix in src/cli/exec.rs.",
stdout.trim()
);
cleanup(ctr_name);
let _ = image::remove_image(&format!("{}:latest", out_tag));
}
}
mod issue_117_attach_streams {
use crate::{get_test_rootfs, is_root};
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_pelagos")
}
fn cleanup(name: &str) {
let _ = std::process::Command::new(bin())
.args(["rm", "-f", name])
.output();
}
#[test]
fn test_detach_attach_stdout_streams_output() {
if !is_root() {
eprintln!("SKIP: test_detach_attach_stdout_streams_output requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("SKIP: test_detach_attach_stdout_streams_output requires alpine-rootfs");
return;
}
};
let name = "attach-stdout-test";
cleanup(name);
let out = std::process::Command::new(bin())
.args([
"run",
"--network",
"loopback",
"-d",
"-a",
"STDOUT",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sh",
"-c",
"echo sentinel-stdout",
])
.output()
.expect("pelagos run");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"pelagos run -d -a STDOUT failed: stderr={:?}",
stderr
);
assert!(
stdout.contains("sentinel-stdout"),
"stdout should contain 'sentinel-stdout'; got stdout={:?} stderr={:?}",
stdout,
stderr
);
assert!(
!stdout.contains(name),
"container name should not appear in stdout (attach mode); got stdout={:?}",
stdout
);
cleanup(name);
}
#[test]
fn test_detach_attach_stderr_streams_output() {
if !is_root() {
eprintln!("SKIP: test_detach_attach_stderr_streams_output requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("SKIP: test_detach_attach_stderr_streams_output requires alpine-rootfs");
return;
}
};
let name = "attach-stderr-test";
cleanup(name);
let out = std::process::Command::new(bin())
.args([
"run",
"--network",
"loopback",
"-d",
"-a",
"STDERR",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sh",
"-c",
"echo sentinel-stderr >&2",
])
.output()
.expect("pelagos run");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"pelagos run -d -a STDERR failed: stderr={:?}",
stderr
);
assert!(
stderr.contains("sentinel-stderr"),
"stderr should contain 'sentinel-stderr'; got stderr={:?}",
stderr
);
cleanup(name);
}
#[test]
fn test_detach_attach_sig_proxy_compat() {
if !is_root() {
eprintln!("SKIP: test_detach_attach_sig_proxy_compat requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(r) => r,
None => {
eprintln!("SKIP: test_detach_attach_sig_proxy_compat requires alpine-rootfs");
return;
}
};
let name = "attach-sigproxy-test";
cleanup(name);
let out = std::process::Command::new(bin())
.args([
"run",
"--network",
"loopback",
"-d",
"-a",
"STDOUT",
"-a",
"STDERR",
"--sig-proxy=false",
"--name",
name,
"--rootfs",
rootfs.to_str().unwrap(),
"/bin/sh",
"-c",
"echo Container started",
])
.output()
.expect("pelagos run");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"pelagos run with --sig-proxy=false failed: stderr={:?}",
stderr
);
assert!(
stdout.contains("Container started"),
"stdout should contain 'Container started'; got stdout={:?} stderr={:?}",
stdout,
stderr
);
cleanup(name);
}
}
mod issue_118_start_returns_promptly {
use crate::is_root;
use std::time::{Duration, Instant};
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_pelagos")
}
fn cleanup(name: &str) {
let _ = std::process::Command::new(bin())
.args(["stop", name])
.output();
std::thread::sleep(Duration::from_millis(300));
let _ = std::process::Command::new(bin())
.args(["rm", "-f", name])
.output();
}
#[test]
fn test_start_returns_promptly() {
use std::process::Stdio;
if !is_root() {
eprintln!("SKIP: test_start_returns_promptly requires root");
return;
}
let name = "start-prompt-test";
cleanup(name);
let ls = std::process::Command::new(bin())
.args(["image", "ls"])
.output()
.expect("pelagos image ls");
if !String::from_utf8_lossy(&ls.stdout)
.contains("public.ecr.aws/docker/library/alpine:latest")
{
let pull = std::process::Command::new(bin())
.args([
"image",
"pull",
"public.ecr.aws/docker/library/alpine:latest",
])
.output()
.expect("pelagos image pull");
assert!(
pull.status.success(),
"image pull failed: {}",
String::from_utf8_lossy(&pull.stderr)
);
}
let out = std::process::Command::new(bin())
.args([
"run",
"--detach",
"--name",
name,
"public.ecr.aws/docker/library/alpine:latest",
"/bin/sh",
"-c",
"sleep 60",
])
.output()
.expect("pelagos run --detach");
assert!(
out.status.success(),
"pelagos run --detach failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let _ = std::process::Command::new(bin())
.args(["stop", name])
.output();
std::thread::sleep(Duration::from_millis(400));
let deadline = Instant::now() + Duration::from_secs(2);
let mut child = std::process::Command::new(bin())
.args(["start", name])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("pelagos start spawn");
let status = loop {
match child.try_wait().expect("try_wait") {
Some(s) => break s,
None => {
if Instant::now() >= deadline {
let _ = child.kill();
panic!(
"pelagos start did not exit within 2 s — \
watcher is leaking the stdout pipe (issue #118)"
);
}
std::thread::sleep(Duration::from_millis(50));
}
}
};
assert!(
status.success(),
"pelagos start exited with non-zero status: {}",
status
);
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let running = {
let deadline2 = Instant::now() + Duration::from_secs(5);
let mut found = false;
while Instant::now() < deadline2 {
if let Ok(raw) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
let status = v["status"].as_str().unwrap_or("");
let pid = v["pid"].as_i64().unwrap_or(0);
if status == "running" && pid > 0 {
found = true;
break;
}
}
}
std::thread::sleep(Duration::from_millis(100));
}
found
};
assert!(
running,
"container '{}' did not reach running state after pelagos start",
name
);
cleanup(name);
}
}
mod issue_120_etc_hosts {
use crate::{get_test_rootfs, is_root, ALPINE_PATH};
use pelagos::container::{Command, Namespace, Stdio};
#[test]
fn test_etc_hosts_localhost_present() {
if !is_root() {
eprintln!("SKIP: test_etc_hosts_localhost_present requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_etc_hosts_localhost_present: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/bin/cat")
.args(["/etc/hosts"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_proc_mount()
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("failed to spawn container");
let (status, stdout_bytes, _) = child.wait_with_output().expect("wait");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "container exited non-zero");
assert!(
stdout.contains("127.0.0.1") && stdout.contains("localhost"),
"/etc/hosts missing 127.0.0.1 localhost entry, got:\n{}",
stdout
);
assert!(
stdout.contains("::1") && stdout.contains("ip6-localhost"),
"/etc/hosts missing ::1 ip6-localhost entry, got:\n{}",
stdout
);
}
#[test]
fn test_etc_hosts_hostname_alias() {
if !is_root() {
eprintln!("SKIP: test_etc_hosts_hostname_alias requires root");
return;
}
let rootfs = match get_test_rootfs() {
Some(p) => p,
None => {
eprintln!("SKIP: test_etc_hosts_hostname_alias: alpine-rootfs not found");
return;
}
};
let mut child = Command::new("/bin/cat")
.args(["/etc/hosts"])
.with_chroot(&rootfs)
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_proc_mount()
.with_hostname("mycontainer")
.env("PATH", ALPINE_PATH)
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("failed to spawn container");
let (status, stdout_bytes, _) = child.wait_with_output().expect("wait");
let stdout = String::from_utf8_lossy(&stdout_bytes);
assert!(status.success(), "container exited non-zero");
assert!(
stdout.contains("127.0.1.1") && stdout.contains("mycontainer"),
"/etc/hosts missing 127.0.1.1 mycontainer entry, got:\n{}",
stdout
);
}
}
mod issue_124_run_state_ordering {
use std::io::BufRead;
use std::time::Duration;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_pelagos")
}
fn cleanup(name: &str) {
let _ = std::process::Command::new(bin())
.args(["stop", name])
.output();
std::thread::sleep(Duration::from_millis(300));
let _ = std::process::Command::new(bin())
.args(["rm", "-f", name])
.output();
}
fn pull_alpine() {
let _ = std::process::Command::new(bin())
.args(["image", "pull", "docker.io/library/alpine:latest"])
.output();
}
#[test]
fn test_run_foreground_state_written_before_output_issue_124() {
use crate::is_root;
use std::io::BufReader;
use std::process::Stdio;
if !is_root() {
eprintln!(
"SKIP: test_run_foreground_state_written_before_output_issue_124 requires root"
);
return;
}
let name = "test-fg-state-124";
pull_alpine();
cleanup(name);
let mut child = std::process::Command::new(bin())
.args([
"run",
"--name",
name,
"docker.io/library/alpine:latest",
"/bin/sh",
"-c",
"echo ready; sleep 10",
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn pelagos run");
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
let mut line = String::new();
reader.read_line(&mut line).expect("read first output line");
assert_eq!(
line.trim(),
"ready",
"expected 'ready' on stdout, got: {:?}",
line
);
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let raw = std::fs::read_to_string(&state_path)
.expect("state.json missing when first output appeared");
let v: serde_json::Value = serde_json::from_str(&raw).expect("state.json parse failed");
let pid = v["pid"].as_i64().unwrap_or(0);
assert!(
pid > 0,
"state.pid should be > 0 when first output appears (issue #124), got {}",
pid
);
let _ = child.kill();
let _ = child.wait();
cleanup(name);
}
#[test]
fn test_run_detached_state_ready_on_return_issue_124() {
use crate::is_root;
if !is_root() {
eprintln!("SKIP: test_run_detached_state_ready_on_return_issue_124 requires root");
return;
}
let name = "test-dtch-state-124";
pull_alpine();
cleanup(name);
let out = std::process::Command::new(bin())
.args([
"run",
"--detach",
"--name",
name,
"docker.io/library/alpine:latest",
"sleep",
"30",
])
.output()
.expect("pelagos run --detach");
assert!(
out.status.success(),
"pelagos run --detach failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let state_path = format!("/run/pelagos/containers/{}/state.json", name);
let raw = std::fs::read_to_string(&state_path)
.expect("state.json missing immediately after pelagos run --detach");
let v: serde_json::Value = serde_json::from_str(&raw).expect("state.json parse failed");
let pid = v["pid"].as_i64().unwrap_or(0);
assert!(
pid > 0,
"state.pid should be > 0 immediately after `pelagos run --detach` \
returns (issue #124), got {}",
pid
);
cleanup(name);
}
}
mod compose_shutdown_fixes {
use std::process::{Command, Stdio};
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_pelagos")
}
fn is_root() -> bool {
unsafe { libc::getuid() == 0 }
}
const ALPINE_ECR: &str = "public.ecr.aws/docker/library/alpine:latest";
fn ensure_alpine() {
let ls = Command::new(bin())
.args(["image", "ls"])
.output()
.expect("pelagos image ls");
if String::from_utf8_lossy(&ls.stdout).contains(ALPINE_ECR) {
return;
}
let status = Command::new(bin())
.args(["image", "pull", ALPINE_ECR])
.status()
.expect("pelagos image pull alpine");
assert!(status.success(), "pre-test alpine pull from ECR failed");
}
#[test]
fn test_compose_down_kills_shell_entrypoint_descendants() {
if !is_root() {
eprintln!("SKIP test_compose_down_kills_shell_entrypoint_descendants: requires root");
return;
}
ensure_alpine();
let tmp = std::env::temp_dir().join("pelagos-pgid-test");
std::fs::create_dir_all(&tmp).unwrap();
let compose_file = tmp.join("compose.reml");
std::fs::write(
&compose_file,
r#"
(define-service svc-shell "shell-bg"
:image "public.ecr.aws/docker/library/alpine:latest"
:command "sh" "-c" "sleep 9999 & wait")
(compose-up
(compose svc-shell))
"#,
)
.unwrap();
let project = "pgid-test-169";
let _ = Command::new(bin())
.args([
"compose",
"down",
"-f",
compose_file.to_str().unwrap(),
"-p",
project,
])
.output();
std::thread::sleep(std::time::Duration::from_millis(500));
let up_status = Command::new(bin())
.args([
"compose",
"up",
"-f",
compose_file.to_str().unwrap(),
"-p",
project,
])
.stdin(Stdio::null())
.status()
.expect("compose up");
assert!(up_status.success(), "compose up failed");
let container_name = format!("{}-shell-bg", project);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut container_pid: Option<u32> = None;
while std::time::Instant::now() < deadline {
let ps = Command::new(bin()).args(["ps"]).output().unwrap();
if String::from_utf8_lossy(&ps.stdout).contains(&container_name) {
let state_path = format!("/run/pelagos/containers/{}/state.json", container_name);
if let Ok(raw) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
if let Some(pid) = v["pid"].as_u64().filter(|&p| p > 0) {
container_pid = Some(pid as u32);
break;
}
}
}
}
std::thread::sleep(std::time::Duration::from_millis(300));
}
let container_pid = container_pid.expect("container never appeared in ps");
let find_sleep_in_pgrp = |pgid: u32| -> Option<u32> {
std::fs::read_dir("/proc").ok()?.flatten().find_map(|e| {
let pid: u32 = e.file_name().to_string_lossy().parse().ok()?;
let stat = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?;
let after_comm = stat.rfind(')')?;
let mut fields = stat[after_comm + 2..].trim_start().split_whitespace();
let _state = fields.next()?;
let _ppid = fields.next()?;
let pgrp: u32 = fields.next()?.parse().ok()?;
if pgrp != pgid {
return None;
}
let comm =
std::fs::read_to_string(format!("/proc/{}/comm", pid)).unwrap_or_default();
(comm.trim() == "sleep").then_some(pid)
})
};
std::thread::sleep(std::time::Duration::from_millis(500));
let sleep_pid = find_sleep_in_pgrp(container_pid)
.expect("could not find background sleep in container process group");
assert!(
unsafe { libc::kill(sleep_pid as i32, 0) } == 0,
"sleep child (pid {}) should be alive before compose down",
sleep_pid
);
let down = Command::new(bin())
.args([
"compose",
"down",
"-f",
compose_file.to_str().unwrap(),
"-p",
project,
])
.stdin(Stdio::null())
.output()
.expect("compose down");
assert!(
down.status.success(),
"compose down failed: {}",
String::from_utf8_lossy(&down.stderr)
);
std::thread::sleep(std::time::Duration::from_millis(500));
let still_alive = unsafe { libc::kill(sleep_pid as i32, 0) } == 0;
assert!(
!still_alive,
"background sleep child (pid {}) is still alive after compose down — \
pgid kill is not working (issue #169)",
sleep_pid
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_compose_no_pull_fails_immediately() {
if !is_root() {
eprintln!("SKIP test_compose_no_pull_fails_immediately: requires root");
return;
}
let tmp = std::env::temp_dir().join("pelagos-nopull-test");
std::fs::create_dir_all(&tmp).unwrap();
let compose_file = tmp.join("compose.reml");
std::fs::write(
&compose_file,
r#"
(define-service svc "nopull-svc"
:image "localhost/this-image-does-not-exist-pelagos-test:nopull")
(compose-up
(compose svc))
"#,
)
.unwrap();
let project = "nopull-test-160";
let _ = Command::new(bin())
.args([
"compose",
"down",
"-f",
compose_file.to_str().unwrap(),
"-p",
project,
])
.output();
let out = Command::new(bin())
.args([
"compose",
"up",
"--no-pull",
"-f",
compose_file.to_str().unwrap(),
"-p",
project,
])
.stdin(Stdio::null())
.output()
.expect("compose up --no-pull");
assert!(
!out.status.success(),
"compose up --no-pull should fail for a missing image"
);
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let combined = format!("{}{}", stderr, stdout);
assert!(
combined.contains("not found locally"),
"expected 'not found locally' in output, got: {}",
combined
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_compose_up_detects_stale_supervisor() {
if !is_root() {
eprintln!("SKIP test_compose_up_detects_stale_supervisor: requires root");
return;
}
ensure_alpine();
let tmp = std::env::temp_dir().join("pelagos-stale-test");
std::fs::create_dir_all(&tmp).unwrap();
let compose_file = tmp.join("compose.reml");
std::fs::write(
&compose_file,
r#"
(define-service svc "stale-svc"
:image "public.ecr.aws/docker/library/alpine:latest"
:command "sh" "-c" "sleep 9999")
(compose-up
(compose svc))
"#,
)
.unwrap();
let project = "stale-test-161";
let _ = Command::new(bin())
.args([
"compose",
"down",
"-f",
compose_file.to_str().unwrap(),
"-p",
project,
])
.output();
std::thread::sleep(std::time::Duration::from_millis(300));
let status1 = Command::new(bin())
.args([
"compose",
"up",
"-f",
compose_file.to_str().unwrap(),
"-p",
project,
])
.stdin(Stdio::null())
.status()
.expect("first compose up");
assert!(status1.success(), "first compose up failed");
let state_path = format!("/run/pelagos/compose/{}/state.json", project);
let supervisor_pid: i32 = {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if let Ok(raw) = std::fs::read_to_string(&state_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
if let Some(pid) = v["supervisor_pid"].as_i64().filter(|&p| p > 0) {
break pid as i32;
}
}
}
assert!(
std::time::Instant::now() < deadline,
"project state never appeared at {}",
state_path
);
std::thread::sleep(std::time::Duration::from_millis(200));
}
};
unsafe { libc::kill(supervisor_pid, libc::SIGKILL) };
std::thread::sleep(std::time::Duration::from_millis(500));
assert!(
unsafe { libc::kill(supervisor_pid, 0) } != 0,
"supervisor (pid {}) should be dead after SIGKILL",
supervisor_pid
);
let status2 = Command::new(bin())
.args([
"compose",
"up",
"-f",
compose_file.to_str().unwrap(),
"-p",
project,
])
.stdin(Stdio::null())
.status()
.expect("second compose up");
assert!(
status2.success(),
"second compose up should succeed after stale supervisor cleanup (issue #161)"
);
let _ = Command::new(bin())
.args([
"compose",
"down",
"-f",
compose_file.to_str().unwrap(),
"-p",
project,
])
.output();
std::thread::sleep(std::time::Duration::from_millis(500));
let _ = std::fs::remove_dir_all(&tmp);
}
}
mod system_prune {
use serial_test::serial;
use std::process::Command;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_pelagos")
}
fn is_root() -> bool {
unsafe { libc::getuid() == 0 }
}
#[test]
#[serial]
fn test_system_df_shows_components() {
if !is_root() {
eprintln!("Skipping test_system_df_shows_components: requires root");
return;
}
let out = Command::new(bin())
.args(["system", "df"])
.output()
.expect("system df");
assert!(out.status.success(), "system df should succeed");
let stdout = String::from_utf8_lossy(&out.stdout);
for component in &[
"Component",
"layers/",
"blobs/",
"images/",
"volumes/",
"build-cache/",
"Total",
] {
assert!(
stdout.contains(component),
"system df output missing '{}': {}",
component,
stdout
);
}
}
#[test]
#[serial]
fn test_system_prune_removes_orphan_layers() {
if !is_root() {
eprintln!("Skipping test_system_prune_removes_orphan_layers: requires root");
return;
}
let orphan_hex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let layers_dir = pelagos::paths::layers_dir();
let orphan_dir = layers_dir.join(orphan_hex);
std::fs::create_dir_all(&orphan_dir).expect("create orphan layer dir");
std::fs::write(orphan_dir.join("dummy.txt"), b"orphan").expect("write dummy");
let out = Command::new(bin())
.args(["system", "prune"])
.output()
.expect("system prune");
assert!(
out.status.success(),
"system prune should succeed: {:?}",
out
);
assert!(
!orphan_dir.exists(),
"orphan layer dir should have been pruned: {}",
orphan_dir.display()
);
}
#[test]
#[serial]
fn test_system_prune_keeps_referenced_layers() {
if !is_root() {
eprintln!("Skipping test_system_prune_keeps_referenced_layers: requires root");
return;
}
use pelagos::image::{self, ImageManifest};
let layer_hex = "ee11223344556677889900aabbccddeeff001122334455667788990011223344";
let layer_digest = format!("sha256:{}", layer_hex);
let layer_dir = image::layer_dir(&layer_digest);
std::fs::create_dir_all(&layer_dir).expect("create synthetic layer dir");
std::fs::write(layer_dir.join("ref.txt"), b"referenced").ok();
let ref_name = "prune-keep-ref-test:latest";
let manifest = ImageManifest {
reference: ref_name.to_string(),
digest: format!("sha256:{}", layer_hex),
layers: vec![layer_digest.clone()],
layer_types: vec![],
config: Default::default(),
};
image::save_image(&manifest).expect("save synthetic manifest");
let out = Command::new(bin())
.args(["system", "prune"])
.output()
.expect("system prune");
assert!(out.status.success(), "system prune should succeed");
assert!(
layer_dir.exists(),
"referenced layer dir should NOT be pruned: {}",
layer_dir.display()
);
let _ = image::remove_image(ref_name);
let _ = std::fs::remove_dir_all(&layer_dir);
}
#[test]
#[serial]
fn test_system_prune_removes_blobs() {
if !is_root() {
eprintln!("Skipping test_system_prune_removes_blobs: requires root");
return;
}
let blobs_dir = pelagos::paths::blobs_dir();
std::fs::create_dir_all(&blobs_dir).expect("create blobs dir");
let blob_file = blobs_dir.join("sha256_test_prune_blob_fixture");
std::fs::write(&blob_file, b"fake blob data").expect("write blob");
let out = Command::new(bin())
.args(["system", "prune"])
.output()
.expect("system prune");
assert!(out.status.success(), "system prune should succeed");
assert!(
!blob_file.exists(),
"blob should have been pruned by system prune: {}",
blob_file.display()
);
}
#[test]
#[serial]
fn test_system_prune_volumes_removes_unused_volume() {
if !is_root() {
eprintln!("Skipping test_system_prune_volumes_removes_unused_volume: requires root");
return;
}
let vol_name = "prune-test-unused-vol";
let create = Command::new(bin())
.args(["volume", "create", vol_name])
.status()
.expect("volume create");
assert!(create.success(), "volume create should succeed");
let vol_dir = pelagos::paths::volumes_dir().join(vol_name);
assert!(vol_dir.exists(), "volume dir should exist after create");
let out = Command::new(bin())
.args(["system", "prune", "--volumes"])
.output()
.expect("system prune --volumes");
assert!(
out.status.success(),
"system prune --volumes should succeed"
);
assert!(
!vol_dir.exists(),
"unused volume dir should have been pruned: {}",
vol_dir.display()
);
}
}
mod config_subnet {
use super::*;
#[test]
fn test_ensure_network_custom_alloc_pool() {
let name1 = "cfg-test-n1";
let name2 = "cfg-test-n2";
for name in &[name1, name2] {
let cfg = pelagos::paths::network_config_dir(name).join("config.json");
let _ = std::fs::remove_file(&cfg);
}
let pool = pelagos::network::Ipv4Net::from_cidr("10.202.0.0/16").unwrap();
let r1 = pelagos::network::ensure_network(name1, Some(&pool));
let r2 = pelagos::network::ensure_network(name2, Some(&pool));
let def1 = pelagos::network::NetworkDef::load(name1).ok();
let def2 = pelagos::network::NetworkDef::load(name2).ok();
for name in &[name1, name2] {
let cfg = pelagos::paths::network_config_dir(name).join("config.json");
let _ = std::fs::remove_file(&cfg);
}
r1.expect("ensure_network name1 should succeed");
r2.expect("ensure_network name2 should succeed");
let d1 = def1.expect("def1 should be loadable after ensure_network");
let d2 = def2.expect("def2 should be loadable after ensure_network");
assert!(
d1.subnet.addr.to_string().starts_with("10.202."),
"name1 subnet should be in 10.202.0.0/16 pool, got: {}",
d1.subnet.addr
);
assert!(
d2.subnet.addr.to_string().starts_with("10.202."),
"name2 subnet should be in 10.202.0.0/16 pool, got: {}",
d2.subnet.addr
);
assert_ne!(
d1.subnet.addr, d2.subnet.addr,
"two networks should get different /24 blocks"
);
}
#[test]
fn test_config_loaded_from_xdg() {
let tmp = tempfile::tempdir().unwrap();
let cfg_dir = tmp.path().join("pelagos");
std::fs::create_dir_all(&cfg_dir).unwrap();
std::fs::write(
cfg_dir.join("config.toml"),
"[network]\ndefault_subnet = \"10.88.0.0/24\"\nauto_alloc_pool = \"10.88.0.0/16\"\n",
)
.unwrap();
unsafe { std::env::set_var("XDG_CONFIG_HOME", tmp.path()) };
let cfg = pelagos::config::PelagosConfig::load();
unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
assert_eq!(cfg.network.default_subnet, "10.88.0.0/24");
assert_eq!(cfg.network.auto_alloc_pool, "10.88.0.0/16");
}
#[test]
#[serial(nat)]
fn test_network_create_auto_alloc() {
if !is_root() {
eprintln!("Skipping test_network_create_auto_alloc: requires root");
return;
}
let name = "cfg-auto-net";
let cfg = pelagos::paths::network_config_dir(name).join("config.json");
let _ = std::fs::remove_file(&cfg);
let out = std::process::Command::new(env!("CARGO_BIN_EXE_pelagos"))
.args(["network", "create", name, "--alloc-from", "10.203.0.0/16"])
.output()
.expect("network create");
let stdout = String::from_utf8_lossy(&out.stdout);
let _ = std::fs::remove_file(&cfg);
assert!(
out.status.success(),
"network create should succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
stdout.contains("10.203."),
"auto-allocated subnet should be from 10.203.0.0/16 pool, got: {}",
stdout
);
}
#[test]
#[serial(nat)]
fn test_default_subnet_bootstrap() {
if !is_root() {
eprintln!("Skipping test_default_subnet_bootstrap: requires root");
return;
}
let Some(rootfs) = get_test_rootfs() else {
eprintln!("Skipping test_default_subnet_bootstrap: alpine-rootfs not found");
return;
};
let cfg_path = pelagos::paths::network_config_dir("pelagos0").join("config.json");
let stash = cfg_path
.exists()
.then(|| std::fs::read_to_string(&cfg_path).ok())
.flatten();
let _ = std::fs::remove_file(&cfg_path);
let subnet = pelagos::network::Ipv4Net::from_cidr("10.201.0.0/24").unwrap();
let bootstrap_ok = pelagos::network::bootstrap_default_network(Some(&subnet)).is_ok();
let mut child = Command::new("/bin/ash")
.args([
"-c",
"ip addr show eth0 | grep -q '10.201.0' && echo CUSTOM_SUBNET_OK",
])
.with_namespaces(Namespace::MOUNT | Namespace::UTS)
.with_network(pelagos::network::NetworkMode::Bridge)
.with_chroot(&rootfs)
.env("PATH", ALPINE_PATH)
.with_proc_mount()
.stdin(Stdio::Null)
.stdout(Stdio::Piped)
.stderr(Stdio::Null)
.spawn()
.expect("spawn");
let (_, stdout, _) = child.wait_with_output().expect("wait");
let out = String::from_utf8_lossy(&stdout);
if let Some(data) = stash {
let _ = std::fs::create_dir_all(cfg_path.parent().unwrap());
let _ = std::fs::write(&cfg_path, data);
} else {
let _ = std::fs::remove_file(&cfg_path);
}
assert!(bootstrap_ok, "bootstrap_default_network should succeed");
assert!(
out.contains("CUSTOM_SUBNET_OK"),
"Container should get 10.201.0.x address from custom default subnet, got: {}",
out
);
}
}
mod auto_pull {
use std::process::Command;
fn pelagos_bin() -> std::path::PathBuf {
let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("target/debug/pelagos");
p
}
#[test]
#[serial_test::serial(nat)]
fn test_run_auto_pulls_missing_image() {
use crate::is_root;
if !is_root() {
eprintln!("SKIP test_run_auto_pulls_missing_image: requires root");
return;
}
let _ = pelagos::image::remove_image("alpine");
let _ = pelagos::image::remove_image("alpine:latest");
let _ = pelagos::image::remove_image("docker.io/library/alpine:latest");
let bin = pelagos_bin();
let out = Command::new(&bin)
.args(["run", "--rm", "alpine", "/bin/echo", "auto-pull-ok"])
.output()
.expect("pelagos run");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"pelagos run should succeed after auto-pull; stderr: {}",
stderr
);
assert!(
stdout.contains("auto-pull-ok"),
"container output should contain 'auto-pull-ok'; stdout: {}, stderr: {}",
stdout,
stderr
);
assert!(
stderr.contains("Unable to find image"),
"stderr should mention auto-pull; stderr: {}",
stderr
);
}
}