use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
pub use pelagos::image::HealthConfig;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum HealthStatus {
#[default]
None,
Starting,
Healthy,
Unhealthy,
}
pub mod auth;
pub mod build;
pub mod cleanup;
pub mod compose;
pub mod exec;
pub mod health;
pub mod image;
pub mod logs;
pub mod network;
pub mod prune;
pub mod ps;
pub mod relay;
pub mod restart;
pub mod rm;
pub mod rootfs;
pub mod run;
pub mod start;
pub mod stop;
pub mod volume;
pub fn containers_dir() -> PathBuf {
pelagos::paths::containers_dir()
}
pub fn container_dir(name: &str) -> PathBuf {
containers_dir().join(name)
}
pub fn state_path(name: &str) -> PathBuf {
container_dir(name).join("state.json")
}
pub fn rootfs_store() -> PathBuf {
pelagos::paths::rootfs_store_dir()
}
pub fn rootfs_path(name: &str) -> std::io::Result<PathBuf> {
let as_path = PathBuf::from(name);
if as_path.is_dir() {
return as_path.canonicalize();
}
let link = rootfs_store().join(name);
if link.is_dir() && !link.is_symlink() {
return Ok(link);
}
std::fs::read_link(&link).map_err(|e| {
std::io::Error::other(format!(
"rootfs '{}' not found ({}): {}",
name,
link.display(),
e
))
})
}
pub(crate) fn check_rootless_bridge(
is_rootless: bool,
network_mode: &pelagos::network::NetworkMode,
nat: bool,
has_ports: bool,
) -> Option<String> {
if !is_rootless {
return None;
}
if network_mode.is_bridge() || nat || has_ports {
Some(
"pelagos: bridge networking requires root (CAP_NET_ADMIN / nftables).\n\
Use --network pasta for rootless internet access, or run with sudo."
.to_string(),
)
} else {
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ContainerStatus {
Running,
Exited,
}
impl std::fmt::Display for ContainerStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ContainerStatus::Running => write!(f, "running"),
ContainerStatus::Exited => write!(f, "exited"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SpawnConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
pub exe: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub bind: Vec<String>,
#[serde(default)]
pub bind_ro: Vec<String>,
#[serde(default)]
pub volume: Vec<String>,
#[serde(default)]
pub network: Vec<String>,
#[serde(default)]
pub publish: Vec<String>,
#[serde(default)]
pub dns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(default)]
pub cap_drop: Vec<String>,
#[serde(default)]
pub cap_add: Vec<String>,
#[serde(default)]
pub security_opt: Vec<String>,
#[serde(default)]
pub read_only: bool,
#[serde(default)]
pub rm: bool,
#[serde(default)]
pub nat: bool,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tmpfs: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerState {
pub name: String,
pub rootfs: String,
pub status: ContainerStatus,
pub pid: i32,
pub watcher_pid: i32,
pub started_at: String,
pub exit_code: Option<i32>,
pub command: Vec<String>,
pub stdout_log: Option<String>,
pub stderr_log: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bridge_ip: Option<String>,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub network_ips: std::collections::HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health: Option<HealthStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_config: Option<HealthConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spawn_config: Option<SpawnConfig>,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub labels: std::collections::HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mnt_ns_inode: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upper_dir: Option<std::path::PathBuf>,
}
pub fn now_iso8601() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let s = secs;
let (y, mo, d, h, mi, sec) = epoch_to_datetime(s);
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
}
fn epoch_to_datetime(epoch: u64) -> (u64, u64, u64, u64, u64, u64) {
let secs_per_day = 86400u64;
let days = epoch / secs_per_day;
let time = epoch % secs_per_day;
let h = time / 3600;
let mi = (time % 3600) / 60;
let s = time % 60;
let z = days + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
(y, mo, d, h, mi, s)
}
pub fn write_state(state: &ContainerState) -> std::io::Result<()> {
let dir = container_dir(&state.name);
std::fs::create_dir_all(&dir)?;
let json =
serde_json::to_string_pretty(state).map_err(|e| std::io::Error::other(e.to_string()))?;
std::fs::write(state_path(&state.name), json)
}
pub fn container_state_exists(name: &str) -> bool {
state_path(name).exists()
}
pub fn read_state(name: &str) -> std::io::Result<ContainerState> {
let data = std::fs::read_to_string(state_path(name))?;
serde_json::from_str(&data).map_err(|e| std::io::Error::other(e.to_string()))
}
pub fn list_containers() -> Vec<ContainerState> {
let dir = containers_dir();
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut states = Vec::new();
for entry in entries.flatten() {
let state_file = entry.path().join("state.json");
if let Ok(data) = std::fs::read_to_string(&state_file) {
if let Ok(s) = serde_json::from_str::<ContainerState>(&data) {
states.push(s);
}
}
}
states
}
pub fn check_liveness(pid: i32) -> bool {
if pid <= 0 {
return false;
}
unsafe { libc::kill(pid, 0) == 0 }
}
pub fn generate_name() -> std::io::Result<String> {
let counter = pelagos::paths::counter_file();
if let Some(parent) = counter.parent() {
std::fs::create_dir_all(parent)?;
}
let n: u64 = std::fs::read_to_string(&counter)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let next = n + 1;
std::fs::write(&counter, next.to_string())?;
Ok(format!("pelagos-{}", next))
}
pub fn parse_memory(s: &str) -> Result<i64, String> {
let s = s.trim();
let (num, mult): (&str, i64) = if let Some(n) = s.strip_suffix(['k', 'K']) {
(n, 1024)
} else if let Some(n) = s.strip_suffix(['m', 'M']) {
(n, 1024 * 1024)
} else if let Some(n) = s.strip_suffix(['g', 'G']) {
(n, 1024 * 1024 * 1024)
} else if let Some(n) = s.strip_suffix(['b', 'B']) {
(n, 1)
} else {
(s, 1)
};
num.trim()
.parse::<i64>()
.map(|n| n * mult)
.map_err(|e| format!("invalid memory value '{}': {}", s, e))
}
pub fn parse_cpus(s: &str) -> Result<(i64, u64), String> {
let cpus: f64 = s
.trim()
.parse()
.map_err(|e| format!("invalid --cpus '{}': {}", s, e))?;
let period_us: u64 = 100_000;
let quota_us = (cpus * period_us as f64) as i64;
Ok((quota_us, period_us))
}
pub fn parse_user(s: &str) -> Result<(u32, Option<u32>), String> {
if let Some((u, g)) = s.split_once(':') {
let uid = resolve_uid(u.trim())?;
let gid = resolve_gid(g.trim())?;
Ok((uid, Some(gid)))
} else {
let uid = resolve_uid(s.trim())?;
Ok((uid, None))
}
}
pub fn parse_user_in_layers(
s: &str,
layer_dirs: &[std::path::PathBuf],
) -> Result<(u32, Option<u32>), String> {
if let Some((u, g)) = s.split_once(':') {
let uid = resolve_uid_in_layers(u.trim(), layer_dirs)?;
let gid = resolve_gid_in_layers(g.trim(), layer_dirs)?;
Ok((uid, Some(gid)))
} else if let Ok(n) = s.trim().parse::<u32>() {
Ok((n, None))
} else {
lookup_user_in_layers(s.trim(), layer_dirs)
}
}
fn lookup_user_in_layers(
name: &str,
layer_dirs: &[std::path::PathBuf],
) -> Result<(u32, Option<u32>), String> {
for layer_dir in layer_dirs.iter().rev() {
let passwd_path = layer_dir.join("etc/passwd");
if let Ok(contents) = std::fs::read_to_string(&passwd_path) {
for line in contents.lines() {
let fields: Vec<&str> = line.split(':').collect();
if fields.len() >= 4 && fields[0] == name {
if let (Ok(uid), Ok(gid)) = (fields[2].parse::<u32>(), fields[3].parse::<u32>())
{
return Ok((uid, Some(gid)));
}
}
}
}
}
use std::ffi::CString;
let cname = CString::new(name).map_err(|_| format!("invalid user name: {}", name))?;
let pw = unsafe { libc::getpwnam(cname.as_ptr()) };
if pw.is_null() {
Err(format!(
"unknown user '{}': not found in container or host /etc/passwd",
name
))
} else {
Ok(unsafe { ((*pw).pw_uid, Some((*pw).pw_gid)) })
}
}
fn resolve_uid_in_layers(s: &str, layer_dirs: &[std::path::PathBuf]) -> Result<u32, String> {
if let Ok(n) = s.parse::<u32>() {
return Ok(n);
}
for layer_dir in layer_dirs.iter().rev() {
let passwd_path = layer_dir.join("etc/passwd");
if let Ok(contents) = std::fs::read_to_string(&passwd_path) {
for line in contents.lines() {
let fields: Vec<&str> = line.split(':').collect();
if fields.len() >= 3 && fields[0] == s {
if let Ok(uid) = fields[2].parse::<u32>() {
return Ok(uid);
}
}
}
}
}
use std::ffi::CString;
let cname = CString::new(s).map_err(|_| format!("invalid user name: {}", s))?;
let pw = unsafe { libc::getpwnam(cname.as_ptr()) };
if pw.is_null() {
Err(format!(
"unknown user '{}': not found in container or host /etc/passwd",
s
))
} else {
Ok(unsafe { (*pw).pw_uid })
}
}
fn resolve_gid_in_layers(s: &str, layer_dirs: &[std::path::PathBuf]) -> Result<u32, String> {
if let Ok(n) = s.parse::<u32>() {
return Ok(n);
}
for layer_dir in layer_dirs.iter().rev() {
let group_path = layer_dir.join("etc/group");
if let Ok(contents) = std::fs::read_to_string(&group_path) {
for line in contents.lines() {
let fields: Vec<&str> = line.split(':').collect();
if fields.len() >= 3 && fields[0] == s {
if let Ok(gid) = fields[2].parse::<u32>() {
return Ok(gid);
}
}
}
}
}
use std::ffi::CString;
let cname = CString::new(s).map_err(|_| format!("invalid group name: {}", s))?;
let gr = unsafe { libc::getgrnam(cname.as_ptr()) };
if gr.is_null() {
Err(format!(
"unknown group '{}': not found in container or host /etc/group",
s
))
} else {
Ok(unsafe { (*gr).gr_gid })
}
}
fn resolve_uid(s: &str) -> Result<u32, String> {
if let Ok(n) = s.parse::<u32>() {
return Ok(n);
}
use std::ffi::CString;
let name = CString::new(s).map_err(|_| format!("invalid user name: {}", s))?;
let pw = unsafe { libc::getpwnam(name.as_ptr()) };
if pw.is_null() {
Err(format!("unknown user '{}': not found in /etc/passwd", s))
} else {
Ok(unsafe { (*pw).pw_uid })
}
}
fn resolve_gid(s: &str) -> Result<u32, String> {
if let Ok(n) = s.parse::<u32>() {
return Ok(n);
}
use std::ffi::CString;
let name = CString::new(s).map_err(|_| format!("invalid group name: {}", s))?;
let gr = unsafe { libc::getgrnam(name.as_ptr()) };
if gr.is_null() {
Err(format!("unknown group '{}': not found in /etc/group", s))
} else {
Ok(unsafe { (*gr).gr_gid })
}
}
pub fn parse_ulimit(
s: &str,
) -> Result<
(
pelagos::container::RlimitResource,
libc::rlim_t,
libc::rlim_t,
),
String,
> {
let (name, limits) = s
.split_once('=')
.ok_or_else(|| format!("invalid --ulimit '{}': expected RESOURCE=SOFT:HARD", s))?;
let (soft_s, hard_s) = limits
.split_once(':')
.ok_or_else(|| format!("invalid --ulimit '{}': expected SOFT:HARD", s))?;
let soft = soft_s
.trim()
.parse::<libc::rlim_t>()
.map_err(|e| format!("invalid soft limit '{}': {}", soft_s, e))?;
let hard = hard_s
.trim()
.parse::<libc::rlim_t>()
.map_err(|e| format!("invalid hard limit '{}': {}", hard_s, e))?;
let resource = match name.trim().to_ascii_lowercase().as_str() {
"nofile" | "openfiles" => libc::RLIMIT_NOFILE,
"nproc" | "maxproc" => libc::RLIMIT_NPROC,
"as" | "vmem" => libc::RLIMIT_AS,
"cpu" => libc::RLIMIT_CPU,
"fsize" => libc::RLIMIT_FSIZE,
"memlock" => libc::RLIMIT_MEMLOCK,
"stack" => libc::RLIMIT_STACK,
"core" => libc::RLIMIT_CORE,
"rss" => libc::RLIMIT_RSS,
"msgqueue" => libc::RLIMIT_MSGQUEUE,
"nice" => libc::RLIMIT_NICE,
"rtprio" => libc::RLIMIT_RTPRIO,
other => return Err(format!("unknown ulimit resource '{}'", other)),
};
Ok((resource, soft, hard))
}
pub fn parse_capability(s: &str) -> Result<pelagos::container::Capability, String> {
use pelagos::container::Capability;
let name = s.trim().to_ascii_uppercase().replace('-', "_");
let name = name.strip_prefix("CAP_").unwrap_or(&name);
Capability::from_name(name).ok_or_else(|| {
format!(
"unknown capability '{s}' (use Linux capability names, e.g. NET_RAW, SYS_ADMIN, CHOWN)"
)
})
}
pub fn format_age(started_at_iso: &str) -> String {
if let Some(secs) = iso8601_to_epoch(started_at_iso) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let diff = now.saturating_sub(secs);
if diff < 60 {
return format!("{} seconds ago", diff);
} else if diff < 3600 {
return format!("{} minutes ago", diff / 60);
} else if diff < 86400 {
return format!("{} hours ago", diff / 3600);
} else {
return format!("{} days ago", diff / 86400);
}
}
started_at_iso.to_string()
}
fn iso8601_to_epoch(s: &str) -> Option<u64> {
let s = s.trim_end_matches('Z');
let parts: Vec<&str> = s.splitn(2, 'T').collect();
if parts.len() != 2 {
return None;
}
let date_parts: Vec<u64> = parts[0].split('-').filter_map(|x| x.parse().ok()).collect();
let time_parts: Vec<u64> = parts[1].split(':').filter_map(|x| x.parse().ok()).collect();
if date_parts.len() != 3 || time_parts.len() != 3 {
return None;
}
let (y, mo, d) = (date_parts[0], date_parts[1], date_parts[2]);
let (h, mi, s) = (time_parts[0], time_parts[1], time_parts[2]);
let y = if mo <= 2 { y - 1 } else { y };
let era = y / 400;
let yoe = y - era * 400;
let mo_adj = if mo > 2 { mo - 3 } else { mo + 9 };
let doy = (153 * mo_adj + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe;
let epoch_days = days.checked_sub(719468)?;
Some(epoch_days * 86400 + h * 3600 + mi * 60 + s)
}
pub fn parse_capability_mask(names: &[String]) -> pelagos::container::Capability {
use pelagos::container::Capability;
let mut mask = Capability::empty();
for name in names {
let normalised = name
.to_uppercase()
.replace('-', "_")
.trim_start_matches("CAP_")
.to_string();
match Capability::from_name(&normalised) {
Some(cap) => mask |= cap,
None => log::warn!("cap-add: unknown capability '{}' — skipping", name),
}
}
mask
}
pub fn read_mnt_ns_inode(pid: i32) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
std::fs::metadata(format!("/proc/{}/ns/mnt", pid))
.ok()
.map(|m| m.ino())
}
pub fn verify_pid_not_recycled(pid: i32, state: &ContainerState) -> Result<(), String> {
let expected = match state.mnt_ns_inode {
Some(inode) => inode,
None => return Ok(()), };
match read_mnt_ns_inode(pid) {
Some(current) if current == expected => Ok(()),
Some(_) => Err(format!(
"container '{}' process (pid {}) is no longer running \
(PID was reused by another process) — \
use 'pelagos start {}' to restart it",
state.name, pid, state.name
)),
None => Err(format!(
"container '{}' process (pid {}) is no longer running — \
use 'pelagos start {}' to restart it",
state.name, pid, state.name
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spawn_config_serde_roundtrip() {
let sc = SpawnConfig {
image: Some("docker.io/library/alpine:latest".to_string()),
exe: "/bin/sh".to_string(),
args: vec!["-c".to_string(), "echo hello".to_string()],
env: vec!["FOO=bar".to_string()],
bind: vec!["/tmp:/tmp".to_string()],
bind_ro: vec!["/etc:/etc:ro".to_string()],
volume: vec!["mydata:/data".to_string()],
network: vec!["bridge".to_string()],
publish: vec!["8080:80".to_string()],
dns: vec!["1.1.1.1".to_string()],
working_dir: Some("/app".to_string()),
user: Some("1000:1000".to_string()),
hostname: Some("myhost".to_string()),
cap_drop: vec!["ALL".to_string()],
cap_add: vec!["NET_BIND_SERVICE".to_string()],
security_opt: vec!["no-new-privileges".to_string()],
read_only: true,
rm: false,
nat: true,
labels: vec!["env=staging".to_string(), "managed=true".to_string()],
tmpfs: vec!["/run".to_string(), "/tmp:size=64m".to_string()],
};
let json = serde_json::to_string(&sc).unwrap();
let decoded: SpawnConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.image, sc.image);
assert_eq!(decoded.exe, sc.exe);
assert_eq!(decoded.args, sc.args);
assert_eq!(decoded.env, sc.env);
assert_eq!(decoded.bind, sc.bind);
assert_eq!(decoded.bind_ro, sc.bind_ro);
assert_eq!(decoded.network, sc.network);
assert_eq!(decoded.publish, sc.publish);
assert_eq!(decoded.dns, sc.dns);
assert_eq!(decoded.working_dir, sc.working_dir);
assert_eq!(decoded.user, sc.user);
assert_eq!(decoded.hostname, sc.hostname);
assert_eq!(decoded.cap_drop, sc.cap_drop);
assert_eq!(decoded.cap_add, sc.cap_add);
assert_eq!(decoded.security_opt, sc.security_opt);
assert!(decoded.read_only);
assert!(!decoded.rm);
assert!(decoded.nat);
assert_eq!(decoded.labels, sc.labels);
assert_eq!(decoded.tmpfs, sc.tmpfs);
}
#[test]
fn test_spawn_config_labels_roundtrip() {
let sc = SpawnConfig {
exe: "/bin/sh".to_string(),
labels: vec!["project=myapp".to_string(), "env=prod".to_string()],
..Default::default()
};
let json = serde_json::to_string(&sc).unwrap();
let decoded: SpawnConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.labels, vec!["project=myapp", "env=prod"]);
let old_json = r#"{"exe":"/bin/sh"}"#;
let old: SpawnConfig = serde_json::from_str(old_json).unwrap();
assert!(old.labels.is_empty());
}
#[test]
fn test_label_filter() {
use super::ps::apply_filters;
fn make_state(name: &str, labels: &[(&str, &str)]) -> ContainerState {
ContainerState {
name: name.to_string(),
rootfs: "alpine".to_string(),
status: ContainerStatus::Running,
pid: 1,
watcher_pid: 0,
started_at: "2026-01-01T00:00:00Z".to_string(),
exit_code: None,
command: vec!["/bin/sh".to_string()],
stdout_log: None,
stderr_log: None,
bridge_ip: None,
network_ips: std::collections::HashMap::new(),
health: None,
health_config: None,
spawn_config: None,
labels: labels
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
mnt_ns_inode: None,
}
}
let mut states = vec![
make_state("web", &[("env", "staging"), ("managed", "true")]),
make_state("db", &[("env", "prod"), ("managed", "true")]),
make_state("cache", &[("tier", "infra")]),
];
let mut s = states.clone();
apply_filters(&mut s, &["label=managed".to_string()]);
assert_eq!(s.len(), 2);
assert!(s.iter().any(|c| c.name == "web"));
assert!(s.iter().any(|c| c.name == "db"));
let mut s = states.clone();
apply_filters(&mut s, &["label=env=staging".to_string()]);
assert_eq!(s.len(), 1);
assert_eq!(s[0].name, "web");
let mut s = states.clone();
apply_filters(&mut s, &["label=env=dev".to_string()]);
assert!(s.is_empty());
apply_filters(&mut states, &["unknown=foo".to_string()]);
assert_eq!(states.len(), 3);
}
#[test]
fn test_container_state_labels_serde() {
let mut labels = std::collections::HashMap::new();
labels.insert("env".to_string(), "prod".to_string());
labels.insert("managed".to_string(), "true".to_string());
let state = ContainerState {
name: "test".to_string(),
rootfs: "alpine".to_string(),
status: ContainerStatus::Running,
pid: 42,
watcher_pid: 0,
started_at: "2026-01-01T00:00:00Z".to_string(),
exit_code: None,
command: vec!["/bin/sh".to_string()],
stdout_log: None,
stderr_log: None,
bridge_ip: None,
network_ips: std::collections::HashMap::new(),
health: None,
health_config: None,
spawn_config: None,
labels,
mnt_ns_inode: None,
};
let json = serde_json::to_string(&state).unwrap();
let decoded: ContainerState = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.labels.get("env").map(|s| s.as_str()), Some("prod"));
assert_eq!(
decoded.labels.get("managed").map(|s| s.as_str()),
Some("true")
);
let old_json = r#"{
"name": "old", "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
}"#;
let old: ContainerState = serde_json::from_str(old_json).unwrap();
assert!(old.labels.is_empty());
}
#[test]
fn test_spawn_config_missing_from_state() {
let json = r#"{
"name": "test",
"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
}"#;
let state: ContainerState = serde_json::from_str(json).unwrap();
assert!(state.spawn_config.is_none());
}
#[test]
fn test_rootfs_path_accepts_filesystem_dir() {
let result = rootfs_path("/tmp").expect("rootfs_path should accept /tmp");
assert!(result.is_absolute(), "result should be absolute");
assert!(result.is_dir(), "result should point to a directory");
}
#[test]
fn test_rootfs_path_rejects_nonexistent() {
let result = rootfs_path("nonexistent-rootfs-xyz-12345");
assert!(
result.is_err(),
"rootfs_path should fail for nonexistent name"
);
}
#[test]
fn test_health_config_serde_roundtrip() {
let hc = HealthConfig {
cmd: vec![
"/bin/sh".into(),
"-c".into(),
"curl -f http://localhost/".into(),
],
interval_secs: 15,
timeout_secs: 5,
start_period_secs: 10,
retries: 2,
};
let json = serde_json::to_string(&hc).unwrap();
let decoded: HealthConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.cmd, hc.cmd);
assert_eq!(decoded.interval_secs, 15);
assert_eq!(decoded.timeout_secs, 5);
assert_eq!(decoded.start_period_secs, 10);
assert_eq!(decoded.retries, 2);
}
#[test]
fn test_health_status_missing_field() {
let json = r#"{
"name": "test",
"rootfs": "alpine",
"status": "running",
"pid": 1234,
"watcher_pid": 0,
"started_at": "2026-01-01T00:00:00Z",
"exit_code": null,
"command": ["/bin/sh"],
"stdout_log": null,
"stderr_log": null
}"#;
let state: ContainerState = serde_json::from_str(json).unwrap();
assert_eq!(state.health, None);
assert!(state.health_config.is_none());
}
#[test]
fn rootless_bridge_errors() {
use pelagos::network::NetworkMode;
assert!(check_rootless_bridge(true, &NetworkMode::Bridge, false, false).is_some());
assert!(
check_rootless_bridge(true, &NetworkMode::BridgeNamed("foo".into()), false, false)
.is_some()
);
assert!(
check_rootless_bridge(true, &NetworkMode::Bridge, false, false)
.unwrap()
.contains("requires root")
);
}
#[test]
fn rootless_nat_or_ports_errors() {
use pelagos::network::NetworkMode;
assert!(check_rootless_bridge(true, &NetworkMode::None, true, false).is_some());
assert!(check_rootless_bridge(true, &NetworkMode::None, false, true).is_some());
assert!(check_rootless_bridge(true, &NetworkMode::Bridge, true, true).is_some());
}
#[test]
fn rootless_pasta_and_loopback_ok() {
use pelagos::network::NetworkMode;
assert!(check_rootless_bridge(true, &NetworkMode::Pasta, false, false).is_none());
assert!(check_rootless_bridge(true, &NetworkMode::Loopback, false, false).is_none());
assert!(check_rootless_bridge(true, &NetworkMode::None, false, false).is_none());
}
#[test]
fn root_bridge_ok() {
use pelagos::network::NetworkMode;
assert!(check_rootless_bridge(false, &NetworkMode::Bridge, true, true).is_none());
assert!(
check_rootless_bridge(false, &NetworkMode::BridgeNamed("net".into()), true, true)
.is_none()
);
}
}