use std::path::PathBuf;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use crate::disk::DiskFormat;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Status {
Running,
Stopping,
Stopped,
}
impl Status {
#[must_use]
pub const fn is_active(self) -> bool {
matches!(self, Self::Running | Self::Stopping)
}
#[must_use]
pub const fn can_exec(self) -> bool {
matches!(self, Self::Running)
}
#[must_use]
pub const fn can_stop(self) -> bool {
matches!(self, Self::Running)
}
#[must_use]
pub const fn can_remove(self) -> bool {
matches!(self, Self::Stopped)
}
#[must_use]
pub const fn can_transition_to(self, target: Self) -> bool {
matches!(
(self, target),
(Self::Stopped, Self::Running)
| (Self::Running | Self::Stopping, Self::Stopped)
| (Self::Running, Self::Stopping)
)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct VirtioFs {
pub tag: String,
pub path: String,
#[serde(default)]
pub guest_path: String,
#[serde(default)]
pub read_only: bool,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct VsockPort {
pub port: u32,
pub path: String,
pub listen: bool,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct VmConfig {
pub vcpus: u8,
pub ram_mib: u32,
#[serde(default)]
pub rootfs: Option<String>,
#[serde(default)]
pub root_disk: Option<String>,
#[serde(default)]
pub disk_format: DiskFormat,
#[serde(default)]
pub base_disk: Option<String>,
#[serde(default)]
pub exec_path: Option<String>,
#[serde(default)]
pub exec_args: Vec<String>,
#[serde(default)]
pub env: Option<Vec<String>>,
#[serde(default)]
pub ports: Vec<String>,
#[serde(default)]
pub published_ports: Vec<crate::ports::PublishedPort>,
#[serde(default)]
pub virtiofs: Vec<VirtioFs>,
#[serde(default)]
pub vsock_ports: Vec<VsockPort>,
#[serde(default)]
pub network: crate::options::NetworkSpec,
#[serde(default)]
pub secrets_required: bool,
#[serde(default)]
pub workload_env: Vec<String>,
#[serde(default)]
pub workload_workdir: Option<String>,
#[serde(default)]
pub workload_user: Option<String>,
#[serde(default)]
pub workload_cmd: Vec<String>,
#[serde(default)]
pub security: crate::security::SecurityOptions,
#[serde(default)]
pub security_status: crate::security::SecurityStatus,
#[serde(default)]
pub auto_remove: bool,
#[serde(default)]
pub auto_stop_secs: Option<u64>,
#[serde(default)]
pub auto_delete_secs: Option<u64>,
#[serde(default, with = "crate::state::opt_system_time")]
pub last_activity_at: Option<SystemTime>,
#[serde(default)]
pub last_error: Option<String>,
#[serde(default)]
pub detach: bool,
#[serde(default)]
pub agent_id: Option<String>,
#[serde(default)]
pub tenant_id: Option<String>,
}
impl Default for VmConfig {
fn default() -> Self {
Self {
vcpus: 1,
ram_mib: 512,
rootfs: None,
root_disk: None,
disk_format: DiskFormat::default(),
base_disk: None,
exec_path: None,
exec_args: Vec::new(),
env: None,
ports: Vec::new(),
published_ports: Vec::new(),
virtiofs: Vec::new(),
vsock_ports: Vec::new(),
network: crate::options::NetworkSpec::default(),
secrets_required: false,
workload_env: Vec::new(),
workload_workdir: None,
workload_user: None,
workload_cmd: Vec::new(),
security: crate::security::SecurityOptions::default(),
security_status: crate::security::SecurityStatus::default(),
auto_remove: false,
auto_stop_secs: None,
auto_delete_secs: None,
last_activity_at: None,
last_error: None,
detach: false,
agent_id: None,
tenant_id: None,
}
}
}
pub(crate) mod opt_system_time {
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[allow(clippy::ref_option, reason = "serde with signature")]
pub(crate) fn serialize<S>(t: &Option<SystemTime>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match t {
Some(st) => {
let secs = st
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
Some(secs).serialize(s)
}
None => None::<f64>.serialize(s),
}
}
pub(crate) fn deserialize<'de, D>(d: D) -> Result<Option<SystemTime>, D::Error>
where
D: Deserializer<'de>,
{
let v: Option<f64> = Option::deserialize(d)?;
Ok(v.map(|secs| UNIX_EPOCH + Duration::from_secs_f64(secs.max(0.0))))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub(crate) struct VmState {
pub id: String,
pub name: Option<String>,
pub pid: i32,
pub image: Option<String>,
pub socket: PathBuf,
pub status: Status,
pub config: VmConfig,
pub created_at: SystemTime,
}
#[cfg(unix)]
pub(crate) fn gen_id() -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
use std::time::UNIX_EPOCH;
let mut h = RandomState::new().build_hasher();
h.write_u64(u64::from(std::process::id()));
h.write_u128(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
);
format!("{:012x}", h.finish() & 0xffff_ffff_ffff_u64)
}
#[cfg(unix)]
mod db;
#[cfg(unix)]
pub(crate) use db::{SnapshotRow, StateDb};
#[cfg(test)]
#[cfg(unix)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::shadow_unrelated,
clippy::indexing_slicing,
reason = "test assertions use unwrap/indexing for clarity"
)]
mod tests {
use std::time::SystemTime;
use super::*;
fn test_vm(id: &str, name: Option<&str>) -> VmState {
VmState {
id: id.to_owned(),
name: name.map(ToOwned::to_owned),
pid: 1234,
image: Some("alpine:latest".to_owned()),
socket: format!("/tmp/{id}.sock").into(),
status: Status::Running,
config: VmConfig {
vcpus: 2,
exec_path: Some("/bin/sh".to_owned()),
..VmConfig::default()
},
created_at: SystemTime::now(),
}
}
fn open_test_db() -> StateDb {
StateDb::open(":memory:").expect("open in-memory db")
}
#[test]
fn insert_and_list() {
let db = open_test_db();
let vm = test_vm("aaa111bbb222", Some("myvm"));
db.insert(&vm).unwrap();
let all = db.list().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].id, "aaa111bbb222");
assert_eq!(all[0].name.as_deref(), Some("myvm"));
assert_eq!(all[0].pid, 1234);
assert_eq!(all[0].status, Status::Running);
}
#[test]
fn get_by_name() {
let db = open_test_db();
db.insert(&test_vm("aaa111", Some("alpha"))).unwrap();
db.insert(&test_vm("bbb222", Some("beta"))).unwrap();
let found = db.get_by_name("alpha").unwrap().unwrap();
assert_eq!(found.id, "aaa111");
assert!(db.get_by_name("nonexistent").unwrap().is_none());
}
#[test]
fn get_by_id_prefix() {
let db = open_test_db();
db.insert(&test_vm("abc123def456", None)).unwrap();
db.insert(&test_vm("xyz789000111", None)).unwrap();
let found = db.get_by_id_prefix("abc123def456").unwrap();
assert_eq!(found.id, "abc123def456");
let found = db.get_by_id_prefix("abc").unwrap();
assert_eq!(found.id, "abc123def456");
assert!(db.get_by_id_prefix("zzz").is_err());
}
#[test]
fn get_by_id_is_exact_only() {
let db = open_test_db();
db.insert(&test_vm("abc123def456", Some("alpha"))).unwrap();
db.insert(&test_vm("abc999000111", None)).unwrap();
assert_eq!(db.get_by_id("abc123def456").unwrap().id, "abc123def456");
let ambiguous_prefix = db.get_by_id("abc").unwrap_err();
assert!(
matches!(ambiguous_prefix, crate::Error::NotFound(_)),
"exact id lookup must not prefix-match, got {ambiguous_prefix:?}"
);
assert!(
!matches!(ambiguous_prefix, crate::Error::Ambiguous(_)),
"exact id lookup must not be Ambiguous, got {ambiguous_prefix:?}"
);
let unique_prefix = db.get_by_id("abc123def").unwrap_err();
assert!(
matches!(unique_prefix, crate::Error::NotFound(_)),
"unique prefix must not match, got {unique_prefix:?}"
);
assert_eq!(
db.get_by_id_prefix("abc123def").unwrap().id,
"abc123def456",
"prefix API still resolves a unique prefix"
);
let by_name = db.get_by_id("alpha").unwrap_err();
assert!(
matches!(by_name, crate::Error::NotFound(_)),
"exact id lookup must not use vms.name, got {by_name:?}"
);
}
#[test]
fn ambiguous_prefix() {
let db = open_test_db();
db.insert(&test_vm("abc111", None)).unwrap();
db.insert(&test_vm("abc222", None)).unwrap();
let err = db.get_by_id_prefix("abc").unwrap_err();
assert!(
matches!(err, crate::Error::Ambiguous(_)),
"expected Ambiguous, got {err:?}"
);
}
#[test]
fn update_status() {
let db = open_test_db();
db.insert(&test_vm("aaa111", None)).unwrap();
db.update_status("aaa111", Status::Stopped).unwrap();
let vm = db.get_by_id_prefix("aaa111").unwrap();
assert_eq!(vm.status, Status::Stopped);
}
#[test]
fn update_pid_status_persists_new_pid() {
let db = open_test_db();
db.insert(&test_vm("aaa111", None)).unwrap();
db.update_pid_status("aaa111", 5678, Status::Running)
.unwrap();
let vm = db.get_by_id_prefix("aaa111").unwrap();
assert_eq!(vm.pid, 5678);
assert_eq!(vm.status, Status::Running);
}
#[test]
fn update_name() {
let db = open_test_db();
db.insert(&test_vm("aaa111", Some("old"))).unwrap();
db.update_name("aaa111", Some("new")).unwrap();
assert!(db.get_by_name("old").unwrap().is_none());
assert!(db.get_by_name("new").unwrap().is_some());
}
#[test]
fn delete() {
let db = open_test_db();
db.insert(&test_vm("aaa111", None)).unwrap();
assert_eq!(db.list().unwrap().len(), 1);
db.delete("aaa111").unwrap();
assert_eq!(db.list().unwrap().len(), 0);
}
#[test]
fn duplicate_name_rejected() {
let db = open_test_db();
db.insert(&test_vm("aaa111", Some("dup"))).unwrap();
let result = db.insert(&test_vm("bbb222", Some("dup")));
assert!(result.is_err(), "duplicate name should be rejected");
}
#[test]
fn pid_stored_as_i32() {
let db = open_test_db();
let mut vm = test_vm("aaa111", None);
vm.pid = -1; db.insert(&vm).unwrap();
let loaded = db.get_by_id_prefix("aaa111").unwrap();
assert_eq!(loaded.pid, -1);
}
#[test]
fn status_transitions() {
assert!(Status::Stopped.can_transition_to(Status::Running));
assert!(Status::Running.can_transition_to(Status::Stopping));
assert!(Status::Running.can_transition_to(Status::Stopped));
assert!(Status::Stopping.can_transition_to(Status::Stopped));
assert!(!Status::Stopped.can_transition_to(Status::Stopping));
assert!(!Status::Stopping.can_transition_to(Status::Running));
assert!(!Status::Stopping.can_transition_to(Status::Stopping));
assert!(Status::Running.can_stop());
assert!(!Status::Stopping.can_stop());
assert!(!Status::Stopped.can_stop());
assert!(Status::Running.is_active());
assert!(Status::Stopping.is_active());
assert!(!Status::Stopped.is_active());
}
#[test]
fn snapshot_crud() {
let db = open_test_db();
db.insert(&test_vm("vm1", Some("myvm"))).unwrap();
let snap = SnapshotRow {
id: "snap1".to_owned(),
vm_id: "vm1".to_owned(),
name: Some("backup1".to_owned()),
disk_path: "/tmp/snap1.qcow2".to_owned(),
disk_bytes: 1024 * 1024,
created_at: SystemTime::now(),
};
db.insert_snapshot(&snap).unwrap();
let snaps = db.list_snapshots("vm1").unwrap();
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].id, "snap1");
assert_eq!(snaps[0].name.as_deref(), Some("backup1"));
assert_eq!(snaps[0].disk_bytes, 1024 * 1024);
let loaded = db.get_snapshot("snap1").unwrap();
assert_eq!(loaded.vm_id, "vm1");
db.delete_snapshot("snap1").unwrap();
assert_eq!(db.list_snapshots("vm1").unwrap().len(), 0);
}
#[test]
fn base_disk_ref_counting() {
let db = open_test_db();
db.upsert_base_disk("bd1", "sha256:abc", "/tmp/base.raw")
.unwrap();
let bd = db.get_base_disk_by_digest("sha256:abc").unwrap().unwrap();
assert_eq!(bd.ref_count, 0);
db.incr_base_disk_ref("sha256:abc").unwrap();
db.incr_base_disk_ref("sha256:abc").unwrap();
let bd = db.get_base_disk_by_digest("sha256:abc").unwrap().unwrap();
assert_eq!(bd.ref_count, 2);
db.decr_base_disk_ref("sha256:abc").unwrap();
db.decr_base_disk_ref("sha256:abc").unwrap();
let orphans = db.orphaned_base_disks().unwrap();
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].digest, "sha256:abc");
db.delete_base_disk("bd1").unwrap();
assert!(db.get_base_disk_by_digest("sha256:abc").unwrap().is_none());
}
#[test]
fn vmconfig_json_defaults_identity_fields() {
let cfg: VmConfig = serde_json::from_str(r#"{"vcpus":1,"ram_mib":512}"#).unwrap();
assert!(
cfg.agent_id.is_none(),
"missing agent_id must deserialize as None"
);
assert!(
cfg.tenant_id.is_none(),
"missing tenant_id must deserialize as None"
);
}
#[test]
fn gen_id_is_exactly_12_lowercase_hex() {
for _ in 0..256 {
let id = gen_id();
assert_eq!(
id.len(),
12,
"gen_id must emit exactly 12 hex chars, got {id:?}"
);
assert!(
id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')),
"gen_id must be lowercase hex, got {id:?}"
);
}
}
#[test]
fn format_012x_of_2_pow_48_is_length_13() {
assert_eq!(
format!("{:012x}", 1u64 << 48).len(),
13,
"unmasked 2^48 must format wider than 12 so the mask stays required"
);
}
}