use chrono::{DateTime, Utc};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fmt;
use std::hash::Hash;
pub use crate::litebox::{BoxState, BoxStatus, HealthStatus};
use crate::runtime::id::BoxID;
use crate::runtime::options::{NetworkConfig, NetworkMode, PortProtocol};
pub use crate::volumes::VolumeInfo;
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(transparent)]
pub struct Bytes(pub u64);
impl Bytes {
#[inline]
pub const fn from_bytes(bytes: u64) -> Self {
Self(bytes)
}
#[inline]
pub const fn from_kib(kib: u64) -> Self {
Self(kib * 1024)
}
#[inline]
pub const fn from_mib(mib: u64) -> Self {
Self(mib * 1024 * 1024)
}
#[inline]
pub const fn from_gib(gib: u64) -> Self {
Self(gib * 1024 * 1024 * 1024)
}
#[inline]
pub const fn as_bytes(&self) -> u64 {
self.0
}
#[inline]
pub const fn as_kib(&self) -> u64 {
self.0 / 1024
}
#[inline]
pub const fn as_mib(&self) -> u64 {
self.0 / (1024 * 1024)
}
}
impl From<u64> for Bytes {
fn from(bytes: u64) -> Self {
Self(bytes)
}
}
impl From<Bytes> for u64 {
fn from(b: Bytes) -> Self {
b.0
}
}
impl fmt::Display for Bytes {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.0 >= 1024 * 1024 * 1024 && self.0.is_multiple_of(1024 * 1024 * 1024) {
write!(f, "{} GiB", self.0 / (1024 * 1024 * 1024))
} else if self.0 >= 1024 * 1024 && self.0.is_multiple_of(1024 * 1024) {
write!(f, "{} MiB", self.0 / (1024 * 1024))
} else if self.0 >= 1024 && self.0.is_multiple_of(1024) {
write!(f, "{} KiB", self.0 / 1024)
} else {
write!(f, "{} bytes", self.0)
}
}
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(transparent)]
pub struct Seconds(pub u64);
impl Seconds {
#[inline]
pub const fn from_seconds(s: u64) -> Self {
Self(s)
}
#[inline]
pub const fn from_minutes(m: u64) -> Self {
Self(m * 60)
}
#[inline]
pub const fn from_hours(h: u64) -> Self {
Self(h * 60 * 60)
}
#[inline]
pub const fn as_seconds(&self) -> u64 {
self.0
}
#[inline]
pub const fn as_minutes(&self) -> u64 {
self.0 / 60
}
}
impl From<u64> for Seconds {
fn from(s: u64) -> Self {
Self(s)
}
}
impl From<Seconds> for u64 {
fn from(s: Seconds) -> Self {
s.0
}
}
impl fmt::Display for Seconds {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.0 >= 3600 && self.0.is_multiple_of(3600) {
write!(f, "{} hours", self.0 / 3600)
} else if self.0 >= 60 && self.0.is_multiple_of(60) {
write!(f, "{} minutes", self.0 / 60)
} else {
write!(f, "{} seconds", self.0)
}
}
}
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ContainerID(String);
impl ContainerID {
pub const FULL_LENGTH: usize = 64;
pub const SHORT_LENGTH: usize = 12;
pub fn new() -> Self {
let mut random_bytes = [0u8; 32];
rand::rng().fill_bytes(&mut random_bytes);
let mut hasher = Sha256::new();
hasher.update(random_bytes);
let result = hasher.finalize();
Self(hex::encode(result))
}
pub fn parse(s: &str) -> Option<Self> {
if Self::is_valid(s) {
Some(Self(s.to_string()))
} else {
None
}
}
pub fn is_valid(s: &str) -> bool {
s.len() == Self::FULL_LENGTH
&& s.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn short(&self) -> &str {
&self.0[..Self::SHORT_LENGTH]
}
}
impl Default for ContainerID {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for ContainerID {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl fmt::Debug for ContainerID {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ContainerID({})", self.short())
}
}
impl AsRef<str> for ContainerID {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct BoxLifecyclePolicy {
pub auto_stop: u32,
pub auto_delete: u32,
pub auto_resume: bool,
}
impl BoxLifecyclePolicy {
pub fn validate(&self) -> boxlite_shared::errors::BoxliteResult<()> {
if self.auto_delete > 0 && self.auto_delete <= self.auto_stop {
return Err(boxlite_shared::errors::BoxliteError::Config(
"auto_delete must be greater than auto_stop".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublishedPort {
pub guest_port: u16,
pub host_ip: String,
pub host_port: u16,
pub protocol: PortProtocol,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutboundNetworkInfo {
pub mode: NetworkMode,
#[serde(default)]
pub allow_net: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InboundNetworkInfo {
pub mode: NetworkMode,
#[serde(default)]
pub allow_net: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "NetworkInfoWire")]
pub struct NetworkInfo {
pub outbound: OutboundNetworkInfo,
pub inbound: InboundNetworkInfo,
#[serde(default)]
pub published_ports: Option<Vec<PublishedPort>>,
pub mode: NetworkMode,
pub allow_net: Vec<String>,
}
impl NetworkInfo {
pub fn new(
outbound: OutboundNetworkInfo,
inbound: InboundNetworkInfo,
published_ports: Option<Vec<PublishedPort>>,
) -> Self {
Self {
mode: outbound.mode.clone(),
allow_net: outbound.allow_net.clone(),
outbound,
inbound,
published_ports,
}
}
}
#[derive(Deserialize)]
struct NetworkInfoWire {
#[serde(default)]
outbound: Option<OutboundNetworkInfo>,
#[serde(default)]
inbound: Option<InboundNetworkInfo>,
#[serde(default)]
published_ports: Option<Vec<PublishedPort>>,
#[serde(default)]
mode: Option<NetworkMode>,
#[serde(default)]
allow_net: Option<Vec<String>>,
}
impl From<NetworkInfoWire> for NetworkInfo {
fn from(wire: NetworkInfoWire) -> Self {
let outbound = wire.outbound.unwrap_or_else(|| OutboundNetworkInfo {
mode: wire.mode.unwrap_or_default(),
allow_net: wire.allow_net.unwrap_or_default(),
});
Self::new(
outbound,
wire.inbound.unwrap_or_default(),
wire.published_ports,
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoxInfo {
pub id: BoxID,
pub name: Option<String>,
pub status: BoxStatus,
pub created_at: DateTime<Utc>,
pub last_updated: DateTime<Utc>,
pub pid: Option<u32>,
pub image: String,
pub cpus: u8,
pub memory_mib: u32,
#[serde(default)]
pub network: Option<NetworkInfo>,
pub labels: HashMap<String, String>,
pub auto_stop: u32,
pub auto_delete: u32,
pub auto_resume: bool,
pub health_status: HealthStatus,
pub exit_code: Option<i32>,
#[serde(default)]
pub started_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_activity_at: Option<DateTime<Utc>>,
}
impl BoxInfo {
pub fn new(config: &crate::litebox::config::BoxConfig, state: &BoxState) -> Self {
use crate::runtime::constants::vm_defaults::{DEFAULT_CPUS, DEFAULT_MEMORY_MIB};
use crate::runtime::options::RootfsSpec;
let network_config =
NetworkConfig::from_specs(&config.options.network, &config.options.inbound_network);
Self {
id: config.id.clone(),
name: config.name.clone(),
status: state.status,
created_at: config.created_at,
last_updated: state.last_updated,
pid: state.pid,
image: match &config.options.rootfs {
RootfsSpec::Image(r) => r.clone(),
RootfsSpec::RootfsPath(p) => format!("rootfs:{}", p),
},
cpus: config.options.cpus.unwrap_or(DEFAULT_CPUS),
memory_mib: config.options.memory_mib.unwrap_or(DEFAULT_MEMORY_MIB),
network: Some(NetworkInfo::new(
OutboundNetworkInfo {
mode: network_config.outbound.mode,
allow_net: network_config.outbound.allow_net,
},
InboundNetworkInfo {
mode: network_config.inbound.mode,
allow_net: network_config.inbound.allow_net,
},
crate::litebox::ports::resolved_from_state(config, state),
)),
labels: HashMap::new(),
auto_stop: config.options.auto_stop.unwrap_or(0),
auto_delete: config.options.effective_auto_delete(),
auto_resume: config.options.auto_resume.unwrap_or(true),
health_status: state.health_status,
exit_code: state.exit_code,
started_at: state.started_at,
last_activity_at: None,
}
}
}
impl PartialEq for BoxInfo {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
&& self.status == other.status
&& self.created_at == other.created_at
&& self.pid == other.pid
&& self.image == other.image
&& self.cpus == other.cpus
&& self.memory_mib == other.memory_mib
&& self.network == other.network
&& self.labels == other.labels
&& self.auto_stop == other.auto_stop
&& self.auto_delete == other.auto_delete
&& self.auto_resume == other.auto_resume
&& self.health_status == other.health_status
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoxStateInfo {
pub status: BoxStatus,
pub running: bool,
pub pid: Option<u32>,
pub exit_code: Option<i32>,
}
impl BoxStateInfo {
pub fn new(state: &BoxState) -> Self {
Self {
status: state.status,
running: state.status.is_running(),
pid: state.pid,
exit_code: state.exit_code,
}
}
}
impl From<&BoxInfo> for BoxStateInfo {
fn from(info: &BoxInfo) -> Self {
Self {
status: info.status,
running: info.status.is_running(),
pid: info.pid,
exit_code: info.exit_code,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageInfo {
pub reference: String,
pub repository: String,
pub tag: String,
pub id: String,
pub cached_at: DateTime<Utc>,
pub size: Option<Bytes>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::litebox::config::{BoxConfig, ContainerRuntimeConfig};
use crate::runtime::options::{BoxOptions, RootfsSpec};
use std::path::PathBuf;
fn published_ports(info: &BoxInfo) -> Option<&[PublishedPort]> {
info.network
.as_ref()
.and_then(|network| network.published_ports.as_deref())
}
#[test]
fn network_info_deserializes_the_pre_split_flat_shape() {
let network: NetworkInfo = serde_json::from_str(
r#"{"mode":"disabled","allow_net":["api.example.com"],"published_ports":null}"#,
)
.expect("pre-split network metadata must still deserialize");
assert_eq!(network.outbound.mode, NetworkMode::Disabled);
assert_eq!(
network.outbound.allow_net,
vec!["api.example.com".to_string()]
);
assert_eq!(network.inbound, InboundNetworkInfo::default());
assert_eq!(network.mode, NetworkMode::Disabled);
assert_eq!(network.allow_net, vec!["api.example.com".to_string()]);
}
#[test]
fn network_info_serializes_both_shapes() {
let network = NetworkInfo::new(
OutboundNetworkInfo {
mode: NetworkMode::Enabled,
allow_net: vec!["api.example.com".to_string()],
},
InboundNetworkInfo {
mode: NetworkMode::Disabled,
allow_net: Vec::new(),
},
None,
);
let value = serde_json::to_value(&network).unwrap();
assert_eq!(value["outbound"]["mode"], "enabled");
assert_eq!(value["inbound"]["mode"], "disabled");
assert_eq!(value["mode"], "enabled");
assert_eq!(value["allow_net"][0], "api.example.com");
}
#[test]
fn network_info_prefers_the_nested_shape_over_the_flat_mirrors() {
let network: NetworkInfo = serde_json::from_str(
r#"{"outbound":{"mode":"disabled","allow_net":[]},
"inbound":{"mode":"enabled","allow_net":[]},
"mode":"enabled","allow_net":["stale.example.com"]}"#,
)
.unwrap();
assert_eq!(network.outbound.mode, NetworkMode::Disabled);
assert_eq!(network.inbound.mode, NetworkMode::Enabled);
assert_eq!(network.mode, NetworkMode::Disabled);
assert!(network.allow_net.is_empty());
}
#[test]
fn lifecycle_policy_enforces_public_sentinels_and_ordering() {
assert!(
BoxLifecyclePolicy {
auto_stop: 0,
auto_delete: 0,
auto_resume: true,
}
.validate()
.is_ok()
);
assert!(
BoxLifecyclePolicy {
auto_stop: 900,
auto_delete: 0,
auto_resume: true,
}
.validate()
.is_ok()
);
assert!(
BoxLifecyclePolicy {
auto_stop: 900,
auto_delete: 900,
auto_resume: true,
}
.validate()
.is_err()
);
assert!(
BoxLifecyclePolicy {
auto_stop: 900,
auto_delete: 901,
auto_resume: true,
}
.validate()
.is_ok()
);
}
#[test]
fn test_config_state_to_info() {
let now = Utc::now();
let box_id = BoxID::parse("01HJK4TNRPQSXYZ8WM6NCVT9R5").unwrap();
let config = BoxConfig {
id: box_id,
name: None,
created_at: now,
container: ContainerRuntimeConfig {
id: ContainerID::new(),
},
options: BoxOptions {
rootfs: RootfsSpec::Image("python:3.11".to_string()),
cpus: Some(4),
memory_mib: Some(1024),
..Default::default()
},
engine_kind: crate::vmm::VmmKind::Libkrun,
box_home: PathBuf::from("/tmp/box"),
};
let mut state = BoxState::new();
state.set_pid(Some(12345));
let _ = state.transition_to(BoxStatus::Running);
let info = BoxInfo::new(&config, &state);
assert_eq!(info.id, config.id);
assert_eq!(info.status, state.status);
assert_eq!(info.created_at, config.created_at);
assert_eq!(info.pid, state.pid);
assert_eq!(info.image, "python:3.11");
assert_eq!(info.cpus, 4);
assert_eq!(info.memory_mib, 1024);
assert!(
published_ports(&info).is_some_and(<[PublishedPort]>::is_empty),
"a box with no requested mappings is known-empty"
);
}
#[test]
fn box_info_reports_network_configuration_and_resolved_empty_publications() {
let config = BoxConfig {
id: BoxID::parse("01HJK4TNRPQSXYZ8WM6NCVT9R5").unwrap(),
name: None,
created_at: Utc::now(),
container: ContainerRuntimeConfig {
id: ContainerID::new(),
},
options: BoxOptions {
network: crate::runtime::options::NetworkSpec::Enabled {
allow_net: vec!["api.example.com".to_string()],
},
..Default::default()
},
engine_kind: crate::vmm::VmmKind::Libkrun,
box_home: PathBuf::from("/tmp/box"),
};
let info = BoxInfo::new(&config, &BoxState::new());
let network = info.network.as_ref().expect("local network metadata");
assert_eq!(
network.outbound.mode,
crate::runtime::options::NetworkMode::Enabled
);
assert_eq!(
network.outbound.allow_net,
vec!["api.example.com".to_string()]
);
assert_eq!(
network.inbound.mode,
crate::runtime::options::NetworkMode::Enabled
);
assert_eq!(network.published_ports, Some(Vec::new()));
let serialized = serde_json::to_value(&info).unwrap();
assert_eq!(serialized["network"]["outbound"]["mode"], "enabled");
assert_eq!(serialized["network"]["inbound"]["mode"], "enabled");
assert_eq!(
serialized["network"]["published_ports"],
serde_json::json!([])
);
assert!(serialized.get("ports").is_none());
assert!(serialized.get("ports_resolved").is_none());
let mut legacy = serialized;
legacy.as_object_mut().unwrap().remove("network");
let legacy: BoxInfo = serde_json::from_value(legacy).unwrap();
assert!(legacy.network.is_none());
}
#[test]
fn box_info_port_resolution_tracks_lifecycle_certainty() {
let temp_dir = tempfile::tempdir().unwrap();
let pid = std::process::id();
let start_time = crate::util::process_start_time(pid).expect("current process start time");
std::fs::write(
temp_dir.path().join("shim.pid"),
format!("{pid}\n{start_time}\nservices-mux-v1\n"),
)
.unwrap();
let config = BoxConfig {
id: BoxID::parse("01HJK4TNRPQSXYZ8WM6NCVT9R5").unwrap(),
name: None,
created_at: Utc::now(),
container: ContainerRuntimeConfig {
id: ContainerID::new(),
},
options: BoxOptions {
ports: vec![crate::runtime::options::PortSpec {
host_port: None,
guest_port: 3000,
protocol: crate::runtime::options::PortProtocol::Tcp,
host_ip: Some("0.0.0.0".to_string()),
}],
..Default::default()
},
engine_kind: crate::vmm::VmmKind::Libkrun,
box_home: temp_dir.path().to_path_buf(),
};
let mut state = BoxState::new();
state.set_pid(Some(pid));
state.transition_to(BoxStatus::Running).unwrap();
let info = BoxInfo::new(&config, &state);
assert!(published_ports(&info).is_none());
assert_eq!(
serde_json::to_value(&info).unwrap()["network"]["published_ports"],
serde_json::Value::Null,
"BoxInfo must distinguish unresolved bindings from confirmed-empty bindings"
);
state.transition_to(BoxStatus::Stopping).unwrap();
let stopping = BoxInfo::new(&config, &state);
assert!(
published_ports(&stopping).is_none(),
"a stopping lifecycle may still own active mappings"
);
state.transition_to(BoxStatus::Stopped).unwrap();
let stopped_with_live_shim = BoxInfo::new(&config, &state);
assert!(published_ports(&stopped_with_live_shim).is_none());
std::fs::write(temp_dir.path().join("shim.pid"), format!("{pid}\n")).unwrap();
let stopped_with_legacy_shim = BoxInfo::new(&config, &state);
assert!(
published_ports(&stopped_with_legacy_shim).is_none(),
"an alive legacy shim is uncertain, never confirmed-empty"
);
std::fs::remove_file(temp_dir.path().join("shim.pid")).unwrap();
let stopped = BoxInfo::new(&config, &state);
assert!(
published_ports(&stopped).is_some_and(<[PublishedPort]>::is_empty),
"a stopped box is known-empty"
);
state.status = BoxStatus::Unknown;
let unknown = BoxInfo::new(&config, &state);
assert!(
published_ports(&unknown).is_none(),
"unknown lifecycle state cannot prove that mappings are absent"
);
}
#[test]
fn test_container_id_new() {
let id1 = ContainerID::new();
let id2 = ContainerID::new();
assert_eq!(id1.as_str().len(), ContainerID::FULL_LENGTH);
assert_eq!(id2.as_str().len(), ContainerID::FULL_LENGTH);
assert_ne!(id1, id2);
assert!(
id1.as_str()
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
);
}
#[test]
fn test_container_id_short() {
let id = ContainerID::new();
assert_eq!(id.short().len(), ContainerID::SHORT_LENGTH);
assert!(id.as_str().starts_with(id.short()));
}
#[test]
fn test_container_id_from_str() {
let valid = "a".repeat(64);
assert!(ContainerID::parse(&valid).is_some());
assert!(ContainerID::parse("abc123").is_none());
let uppercase = "A".repeat(64);
assert!(ContainerID::parse(&uppercase).is_none());
let non_hex = "g".repeat(64);
assert!(ContainerID::parse(&non_hex).is_none());
}
#[test]
fn test_container_id_display() {
let id = ContainerID::new();
let display = format!("{}", id);
assert_eq!(display, id.as_str());
}
#[test]
fn test_container_id_debug() {
let id = ContainerID::new();
let debug = format!("{:?}", id);
assert!(debug.contains(id.short()));
assert!(debug.starts_with("ContainerID("));
}
#[test]
fn test_bytes_constructors() {
assert_eq!(Bytes::from_bytes(1000).as_bytes(), 1000);
assert_eq!(Bytes::from_kib(1).as_bytes(), 1024);
assert_eq!(Bytes::from_mib(1).as_bytes(), 1024 * 1024);
assert_eq!(Bytes::from_gib(1).as_bytes(), 1024 * 1024 * 1024);
}
#[test]
fn test_bytes_conversions() {
let size = Bytes::from_mib(512);
assert_eq!(size.as_kib(), 512 * 1024);
assert_eq!(size.as_mib(), 512);
let odd_size = Bytes::from_bytes(1500);
assert_eq!(odd_size.as_kib(), 1); }
#[test]
fn test_bytes_from_u64() {
let b: Bytes = 1024u64.into();
assert_eq!(b.as_bytes(), 1024);
let raw: u64 = Bytes::from_mib(1).into();
assert_eq!(raw, 1024 * 1024);
}
#[test]
fn test_bytes_display() {
assert_eq!(format!("{}", Bytes::from_gib(2)), "2 GiB");
assert_eq!(format!("{}", Bytes::from_mib(512)), "512 MiB");
assert_eq!(format!("{}", Bytes::from_kib(64)), "64 KiB");
assert_eq!(format!("{}", Bytes::from_bytes(500)), "500 bytes");
assert_eq!(format!("{}", Bytes::from_bytes(1500)), "1500 bytes");
}
#[test]
fn test_bytes_ordering() {
assert!(Bytes::from_mib(1) < Bytes::from_mib(2));
assert!(Bytes::from_gib(1) > Bytes::from_mib(512));
}
#[test]
fn test_bytes_default() {
assert_eq!(Bytes::default().as_bytes(), 0);
}
#[test]
fn test_seconds_constructors() {
assert_eq!(Seconds::from_seconds(30).as_seconds(), 30);
assert_eq!(Seconds::from_minutes(5).as_seconds(), 300);
assert_eq!(Seconds::from_hours(1).as_seconds(), 3600);
}
#[test]
fn test_seconds_conversions() {
let duration = Seconds::from_hours(2);
assert_eq!(duration.as_minutes(), 120);
assert_eq!(duration.as_seconds(), 7200);
let odd_duration = Seconds::from_seconds(90);
assert_eq!(odd_duration.as_minutes(), 1); }
#[test]
fn test_seconds_from_u64() {
let s: Seconds = 300u64.into();
assert_eq!(s.as_seconds(), 300);
let raw: u64 = Seconds::from_minutes(5).into();
assert_eq!(raw, 300);
}
#[test]
fn test_seconds_display() {
assert_eq!(format!("{}", Seconds::from_hours(2)), "2 hours");
assert_eq!(format!("{}", Seconds::from_minutes(30)), "30 minutes");
assert_eq!(format!("{}", Seconds::from_seconds(45)), "45 seconds");
assert_eq!(format!("{}", Seconds::from_seconds(90)), "90 seconds");
}
#[test]
fn test_seconds_ordering() {
assert!(Seconds::from_seconds(30) < Seconds::from_minutes(1));
assert!(Seconds::from_hours(1) > Seconds::from_minutes(30));
}
#[test]
fn test_seconds_default() {
assert_eq!(Seconds::default().as_seconds(), 0);
}
}