Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131use anyhow::{Context, Result};use serde::{Deserialize, Serialize};use serde_json::json;use std::collections::BTreeMap;use std::fs;use std::net::{IpAddr, Ipv4Addr, SocketAddr};use std::path::PathBuf;
pub use crate::backend::RunningInstance;
use crate::backend::{BackendState, ChState, SerialEndpoint};use crate::target;
/// Parse a port mapping string like "8080:80" or ":443"./// Returns (host_port_or_zero, guest_port) — host=0 means auto-allocate.pub fn parse_port_mapping(s: &str) -> Result<(u16, u16)> { let s = s.trim(); if let Some(rest) = s.strip_prefix(':') { let guest: u16 = rest .parse() .with_context(|| format!("invalid guest port in '{s}'"))?; Ok((0, guest)) } else if let Some((host_str, guest_str)) = s.split_once(':') { let host: u16 = host_str .parse() .with_context(|| format!("invalid host port in '{s}'"))?; let guest: u16 = guest_str .parse() .with_context(|| format!("invalid guest port in '{s}'"))?; Ok((host, guest)) } else { anyhow::bail!("invalid port mapping '{s}' — expected HOST:GUEST or :GUEST") }}
/// Parse a host path with an optional guest destination and `:ro` suffix.pub fn parse_mount_spec(s: &str) -> Result<(String, Option<String>, bool)> { let s = s.trim(); let (mount, read_only) = match s.strip_suffix(":ro") { Some(mount) => (mount, true), None => (s, false), }; match mount.rsplit_once(':') { None => Ok((mount.to_string(), None, read_only)), Some((src, dst)) if dst.starts_with('/') || dst == "~" || dst.starts_with("~/") => { Ok((src.to_string(), Some(dst.to_string()), read_only)) } Some(_) => { anyhow::bail!( "invalid mount destination in '{s}' — expected an absolute path, ~, ~/path, or ro" ) } }}
fn default_cpus() -> u32 { 1}
fn default_memory_mib() -> u32 { 1024}
fn default_disk_size() -> String { "40G".into()}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]#[serde(default, deny_unknown_fields)]pub struct HostHooks { #[serde(rename = "post-launch")] pub post_launch: BTreeMap<String, String>, #[serde(rename = "post-start")] pub post_start: BTreeMap<String, String>, #[serde(rename = "pre-stop")] pub pre_stop: BTreeMap<String, String>,}
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct InstanceState { pub target: String, #[serde(default)] pub runtime: Option<RunningInstance>, #[serde(default)] pub mounts: Vec<String>, #[serde(default)] pub project_dir: Option<String>, #[serde(default = "default_disk_size")] pub disk_size: String, #[serde(default = "default_cpus")] pub cpus: u32, #[serde(default = "default_memory_mib")] pub memory_mib: u32, #[serde(default)] pub port_specs: Vec<String>, #[serde(default)] pub ssh_extra_config: Vec<String>, #[serde(default)] pub hooks: HostHooks, #[serde(default)] pub descriptor: Option<target::Descriptor>, #[serde(default)] pub declarative: bool,}
pub fn state_dir() -> PathBuf { let path = if let Ok(dir) = std::env::var("EPI_STATE_DIR") { PathBuf::from(dir) } else if let Ok(home) = std::env::var("HOME") { PathBuf::from(home).join(".local/state/epi") } else { PathBuf::from(".epi-state") }; std::path::absolute(&path).unwrap_or(path)}
pub fn instance_dir(name: &str) -> PathBuf { state_dir().join(name)}
pub fn instance_path(name: &str, file: &str) -> PathBuf { instance_dir(name).join(file)}
pub fn ensure_instance_dir(name: &str) -> Result<PathBuf> { let dir = instance_dir(name); fs::create_dir_all(&dir) .with_context(|| format!("failed to create instance dir: {}", dir.display()))?; Ok(dir)}
fn state_path(name: &str) -> PathBuf { instance_path(name, "state.json")}
pub fn load_state(name: &str) -> Result<Option<InstanceState>> { let path = state_path(name); if !path.exists() { return Ok(None); } let content = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; let mut value: serde_json::Value = serde_json::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
let migrated = migrate_legacy_runtime(&mut value, name);
let state: InstanceState = serde_json::from_value(value) .with_context(|| format!("deserializing {}", path.display()))?;
if migrated { let new_content = serde_json::to_string_pretty(&state)?; fs::write(&path, new_content).with_context(|| format!("rewriting {}", path.display()))?; }
Ok(Some(state))}
/// Migrate a pre-epi-17 state.json shape (flat CH/systemd fields on `runtime`)/// to the tagged `RunningInstance` shape. Returns true if a migration happened.fn migrate_legacy_runtime(value: &mut serde_json::Value, name: &str) -> bool { let Some(runtime) = value.get_mut("runtime") else { return false; }; if runtime.is_null() { return false; } let Some(obj) = runtime.as_object_mut() else { return false; }; // Legacy shape has flat `unit_id` and lacks the new tagged `backend`. if !obj.contains_key("unit_id") || obj.contains_key("backend") { return false; }
let unit_id = obj .remove("unit_id") .and_then(|v| v.as_str().map(String::from)) .unwrap_or_default(); let serial_socket = obj .remove("serial_socket") .and_then(|v| v.as_str().map(String::from)) .unwrap_or_default(); let ssh_port = obj.remove("ssh_port").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
obj.insert("id".into(), json!(name)); obj.insert("ssh".into(), json!(format!("127.0.0.1:{ssh_port}"))); obj.insert( "serial".into(), json!({"kind": "unix_socket", "path": serial_socket}), ); obj.insert( "backend".into(), json!({"kind": "cloud_hypervisor", "unit_id": unit_id}), ); true}
pub fn save_state(name: &str, state: &InstanceState) -> Result<()> { ensure_instance_dir(name)?; let path = state_path(name); let content = serde_json::to_string_pretty(state)?; fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?; Ok(())}
pub fn canonicalize_mounts(mounts: &[String]) -> Vec<String> { mounts .iter() .map(|m| { let Ok((src, dst, read_only)) = parse_mount_spec(m) else { return m.clone(); }; let canonical_src = std::path::Path::new(&src) .canonicalize() .map(|p| p.to_string_lossy().to_string()) .unwrap_or(src); match (dst, read_only) { (Some(dst), true) => format!("{canonical_src}:{dst}:ro"), (Some(dst), false) => format!("{canonical_src}:{dst}"), (None, true) => format!("{canonical_src}:ro"), (None, false) => canonical_src, } }) .collect()}
pub fn set_partial_runtime(name: &str, unit_id: &str) -> Result<()> { let mut state = load_state(name)?.ok_or_else(|| anyhow::anyhow!("instance {name} does not exist"))?; state.runtime = Some(RunningInstance { id: name.to_string(), ssh: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), serial: SerialEndpoint::UnixSocket { path: PathBuf::new(), }, backend: BackendState::CloudHypervisor(ChState { unit_id: unit_id.to_string(), }), disk: String::new(), ssh_key_path: String::new(), ports: vec![], }); save_state(name, &state)}
pub fn set_provisioned( name: &str, runtime: RunningInstance, descriptor: Option<target::Descriptor>,) -> Result<()> { let mut state = load_state(name)?.ok_or_else(|| anyhow::anyhow!("instance {name} does not exist"))?; state.runtime = Some(runtime); if let Some(desc) = descriptor { state.descriptor = Some(desc); } save_state(name, &state)}
pub fn update_descriptor(name: &str, descriptor: target::Descriptor) -> Result<()> { let mut state = load_state(name)?.ok_or_else(|| anyhow::anyhow!("instance {name} does not exist"))?; state.descriptor = Some(descriptor); save_state(name, &state)}
pub fn clear_runtime(name: &str) -> Result<()> { if let Some(mut state) = load_state(name)? { state.runtime = None; save_state(name, &state)?; } Ok(())}
pub fn find(name: &str) -> Result<Option<String>> { Ok(load_state(name)?.map(|s| s.target))}
pub fn find_runtime(name: &str) -> Result<Option<RunningInstance>> { Ok(load_state(name)?.and_then(|s| s.runtime))}
pub fn list() -> Result<Vec<(String, String, Option<String>)>> { let dir = state_dir(); if !dir.exists() { return Ok(vec![]); } let mut instances = vec![]; for entry in fs::read_dir(&dir)? { let entry = entry?; if entry.file_type()?.is_dir() { let name = entry.file_name().to_string_lossy().to_string(); if let Some(state) = load_state(&name)? { instances.push((name, state.target, state.project_dir)); } } } instances.sort_by(|a, b| { let a_has_project = a.2.is_some(); let b_has_project = b.2.is_some(); b_has_project.cmp(&a_has_project).then(a.0.cmp(&b.0)) }); Ok(instances)}
pub fn remove(name: &str) -> Result<()> { let dir = instance_dir(name); if dir.exists() { fs::remove_dir_all(&dir) .with_context(|| format!("removing instance dir: {}", dir.display()))?; } Ok(())}
pub fn console_log_path(name: &str) -> PathBuf { instance_path(name, "console.log")}
#[cfg(test)]mod tests { use super::*; use crate::backend::PortMapping; use std::path::Path; use tempfile::TempDir;
fn write_state(dir: &Path, name: &str, state: &InstanceState) { let inst_dir = dir.join(name); fs::create_dir_all(&inst_dir).unwrap(); let json = serde_json::to_string_pretty(state).unwrap(); fs::write(inst_dir.join("state.json"), json).unwrap(); }
fn read_state(dir: &Path, name: &str) -> Option<InstanceState> { let path = dir.join(name).join("state.json"); if !path.exists() { return None; } let content = fs::read_to_string(path).unwrap(); Some(serde_json::from_str(&content).unwrap()) }
/// Construct a `RunningInstance` for tests with the given unit_id and ssh port. fn test_runtime(name: &str, unit_id: &str, ssh_port: u16) -> RunningInstance { RunningInstance { id: name.to_string(), ssh: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), ssh_port), serial: SerialEndpoint::UnixSocket { path: PathBuf::from("/s"), }, backend: BackendState::CloudHypervisor(ChState { unit_id: unit_id.to_string(), }), disk: "/d".into(), ssh_key_path: "/k".into(), ports: vec![], } }
#[test] fn configured_hooks_persist_in_instance_state() { let dir = TempDir::new().unwrap(); let hooks = json!({ "post-launch": {"ready": "/nix/store/ready/bin/ready"}, "post-start": {"resume": "/nix/store/resume/bin/resume"}, "pre-stop": {"flush": "/nix/store/flush/bin/flush"} }); let state: InstanceState = serde_json::from_value(json!({ "target": ".#dev", "hooks": hooks })) .unwrap(); write_state(dir.path(), "vm1", &state); let restored = read_state(dir.path(), "vm1").unwrap(); let serialized = serde_json::to_value(restored).unwrap(); assert_eq!(serialized["hooks"], hooks); }
#[test] fn state_json_roundtrip() { let state = InstanceState { declarative: false, target: ".#test".into(), runtime: Some(test_runtime("vm1", "aabb", 3333)), mounts: vec!["/a".into(), "/b".into()], project_dir: None, disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec![], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }; let json = serde_json::to_string(&state).unwrap(); let parsed: InstanceState = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.target, ".#test"); assert_eq!(parsed.runtime.unwrap().ssh.port(), 3333); assert_eq!(parsed.mounts.len(), 2); }
#[test] fn state_without_runtime() { let state = InstanceState { declarative: false, target: ".#dev".into(), runtime: None, mounts: vec![], project_dir: None, disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec![], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }; let json = serde_json::to_string(&state).unwrap(); let parsed: InstanceState = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.target, ".#dev"); assert!(parsed.runtime.is_none()); }
#[test] fn state_with_mounts() { let state = InstanceState { declarative: false, target: ".#dev".into(), runtime: None, mounts: vec!["/home".into(), "/opt:/workspace:ro".into()], project_dir: None, disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec![], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }; let json = serde_json::to_string(&state).unwrap(); let parsed: InstanceState = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.mounts, vec!["/home", "/opt:/workspace:ro"]); }
#[test] fn write_and_read_state_on_disk() { let dir = TempDir::new().unwrap(); let state = InstanceState { declarative: false, target: ".#test".into(), runtime: None, mounts: vec!["/home".into()], project_dir: None, disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec![], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }; write_state(dir.path(), "myvm", &state);
let loaded = read_state(dir.path(), "myvm").unwrap(); assert_eq!(loaded.target, ".#test"); assert!(loaded.runtime.is_none()); assert_eq!(loaded.mounts, vec!["/home"]); }
#[test] fn read_nonexistent_returns_none() { let dir = TempDir::new().unwrap(); assert!(read_state(dir.path(), "nope").is_none()); }
#[test] fn set_provisioned_preserves_target_and_mounts() { let dir = TempDir::new().unwrap(); let state = InstanceState { declarative: false, target: ".#dev".into(), runtime: None, mounts: vec!["/mnt".into()], project_dir: None, disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec![], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }; write_state(dir.path(), "vm1", &state);
// Simulate set_provisioned let mut loaded = read_state(dir.path(), "vm1").unwrap(); loaded.runtime = Some(test_runtime("vm1", "abcd1234", 2222)); write_state(dir.path(), "vm1", &loaded);
let result = read_state(dir.path(), "vm1").unwrap(); assert_eq!(result.target, ".#dev"); assert_eq!(result.mounts, vec!["/mnt"]); let rt = result.runtime.unwrap(); let BackendState::CloudHypervisor(ch) = &rt.backend else { panic!("expected cloud_hypervisor backend state"); }; assert_eq!(ch.unit_id, "abcd1234"); assert_eq!(rt.ssh.port(), 2222); }
#[test] fn clear_runtime_preserves_target() { let dir = TempDir::new().unwrap(); let state = InstanceState { declarative: false, target: ".#dev".into(), runtime: Some(test_runtime("vm1", "abcd", 2222)), mounts: vec![], project_dir: None, disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec![], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }; write_state(dir.path(), "vm1", &state);
// Simulate clear_runtime let mut loaded = read_state(dir.path(), "vm1").unwrap(); loaded.runtime = None; write_state(dir.path(), "vm1", &loaded);
let result = read_state(dir.path(), "vm1").unwrap(); assert_eq!(result.target, ".#dev"); assert!(result.runtime.is_none()); }
#[test] fn set_partial_runtime_writes_unit_id() { let dir = TempDir::new().unwrap(); let state = InstanceState { declarative: false, target: ".#dev".into(), runtime: None, mounts: vec!["/mnt".into()], project_dir: None, disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec![], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }; write_state(dir.path(), "vm1", &state);
// Simulate set_partial_runtime let mut loaded = read_state(dir.path(), "vm1").unwrap(); loaded.runtime = Some(RunningInstance { id: "vm1".into(), ssh: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), serial: SerialEndpoint::UnixSocket { path: PathBuf::new(), }, backend: BackendState::CloudHypervisor(ChState { unit_id: "abc12345".into(), }), disk: String::new(), ssh_key_path: String::new(), ports: vec![], }); write_state(dir.path(), "vm1", &loaded);
let result = read_state(dir.path(), "vm1").unwrap(); assert_eq!(result.target, ".#dev"); assert_eq!(result.mounts, vec!["/mnt"]); let rt = result.runtime.unwrap(); let BackendState::CloudHypervisor(ch) = &rt.backend else { panic!("expected cloud_hypervisor backend state"); }; assert_eq!(ch.unit_id, "abc12345"); assert_eq!(rt.ssh.port(), 0); match &rt.serial { SerialEndpoint::UnixSocket { path } => assert!(path.as_os_str().is_empty()), SerialEndpoint::Pty { .. } => panic!("expected unix socket"), } }
#[test] fn list_from_dir_returns_sorted() { let dir = TempDir::new().unwrap(); let mk = |name: &str, target: &str| { write_state( dir.path(), name, &InstanceState { declarative: false, target: target.into(), runtime: None, mounts: vec![], project_dir: None, disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec![], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }, ); }; mk("beta", ".#b"); mk("alpha", ".#a"); mk("gamma", ".#g");
let mut instances = vec![]; for entry in fs::read_dir(dir.path()).unwrap() { let entry = entry.unwrap(); if entry.file_type().unwrap().is_dir() { let name = entry.file_name().to_string_lossy().to_string(); if let Some(state) = read_state(dir.path(), &name) { instances.push((name, state.target)); } } } instances.sort_by(|a, b| a.0.cmp(&b.0));
let names: Vec<&str> = instances.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!(names, vec!["alpha", "beta", "gamma"]); }
#[test] fn remove_deletes_dir() { let dir = TempDir::new().unwrap(); write_state( dir.path(), "vm1", &InstanceState { declarative: false, target: ".#dev".into(), runtime: None, mounts: vec![], project_dir: None, disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec![], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }, ); assert!(dir.path().join("vm1").exists());
fs::remove_dir_all(dir.path().join("vm1")).unwrap(); assert!(!dir.path().join("vm1").exists()); }
#[test] fn deserialize_missing_optional_fields() { let json = r#"{"target": ".#test"}"#; let state: InstanceState = serde_json::from_str(json).unwrap(); assert_eq!(state.target, ".#test"); assert!(state.runtime.is_none()); assert!(state.mounts.is_empty()); assert!(state.project_dir.is_none()); }
#[test] fn state_with_project_dir_roundtrip() { let state = InstanceState { declarative: false, target: ".#dev".into(), runtime: None, mounts: vec![], project_dir: Some("/home/user/myproject".into()), disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec![], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }; let json = serde_json::to_string(&state).unwrap(); let parsed: InstanceState = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.project_dir.as_deref(), Some("/home/user/myproject")); }
#[test] fn state_without_project_dir_deserializes_none() { let json = r#"{"target": ".#test", "mounts": []}"#; let state: InstanceState = serde_json::from_str(json).unwrap(); assert!(state.project_dir.is_none()); }
#[test] fn migrate_legacy_runtime_rewrites_state_json() { let dir = TempDir::new().unwrap(); unsafe { std::env::set_var("EPI_STATE_DIR", dir.path()) };
// Write a legacy-shape state.json directly (flat CH fields on runtime) let name = "legacy"; let inst_dir = dir.path().join(name); fs::create_dir_all(&inst_dir).unwrap(); let legacy = r#"{ "target": ".#dev", "runtime": { "unit_id": "u-old", "serial_socket": "/tmp/old/serial.sock", "disk": "/tmp/old/disk.img", "ssh_port": 2222, "ssh_key_path": "/tmp/old/key", "ports": [{"host":8080,"guest":80,"protocol":"tcp"}] }, "mounts": [], "disk_size": "40G", "cpus": 2, "memory_mib": 1024, "port_specs": [], "ssh_extra_config": [] }"#; fs::write(inst_dir.join("state.json"), legacy).unwrap();
let state = load_state(name).unwrap().unwrap(); let rt = state.runtime.expect("runtime missing after migration"); assert_eq!(rt.id, name); let BackendState::CloudHypervisor(ch) = &rt.backend else { panic!("expected cloud_hypervisor backend state"); }; assert_eq!(ch.unit_id, "u-old"); assert_eq!(rt.ssh.port(), 2222); assert_eq!(rt.disk, "/tmp/old/disk.img"); assert_eq!(rt.ssh_key_path, "/tmp/old/key"); assert_eq!(rt.ports.len(), 1); match &rt.serial { SerialEndpoint::UnixSocket { path } => { assert_eq!(path.to_string_lossy(), "/tmp/old/serial.sock"); } SerialEndpoint::Pty { .. } => panic!("expected unix socket"), }
// Verify the file was rewritten in the new tagged shape let rewritten = fs::read_to_string(inst_dir.join("state.json")).unwrap(); assert!(rewritten.contains(r#""kind": "cloud_hypervisor""#)); assert!(rewritten.contains(r#""kind": "unix_socket""#)); assert!(!rewritten.contains(r#""unit_id": "u-old","#)); // not at top level any more // Second load is a no-op (already migrated) let _ = load_state(name).unwrap();
unsafe { std::env::remove_var("EPI_STATE_DIR") }; }
#[test] fn deserialize_missing_new_vm_param_fields() { // epi-a93: existing state.json without cpus, memory_mib, port_specs should deserialize let json = r#"{"target": ".#test", "mounts": [], "disk_size": "40G"}"#; let state: InstanceState = serde_json::from_str(json).unwrap(); assert_eq!(state.target, ".#test"); assert_eq!(state.cpus, 1); assert_eq!(state.memory_mib, 1024); assert!(state.port_specs.is_empty()); }
#[test] fn state_with_cpus_and_memory_roundtrip() { // epi-zeq: cpus and memory_mib persist in state let state = InstanceState { declarative: false, target: ".#dev".into(), runtime: None, mounts: vec![], project_dir: None, disk_size: "40G".into(), cpus: 4, memory_mib: 2048, port_specs: vec![], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }; let json = serde_json::to_string(&state).unwrap(); let parsed: InstanceState = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.cpus, 4); assert_eq!(parsed.memory_mib, 2048); }
#[test] fn state_with_port_specs_roundtrip() { // epi-ch5: port_specs persist in state let state = InstanceState { declarative: false, target: ".#dev".into(), runtime: None, mounts: vec![], project_dir: None, disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec!["8080:80".into(), ":443".into()], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }; let json = serde_json::to_string(&state).unwrap(); let parsed: InstanceState = serde_json::from_str(&json).unwrap(); assert_eq!( parsed.port_specs, vec!["8080:80".to_string(), ":443".to_string()] ); }
#[test] fn parse_port_mapping_host_and_guest() { let (host, guest) = parse_port_mapping("8080:80").unwrap(); assert_eq!(host, 8080); assert_eq!(guest, 80); }
#[test] fn parse_port_mapping_auto_host() { let (host, guest) = parse_port_mapping(":443").unwrap(); assert_eq!(host, 0); assert_eq!(guest, 443); }
#[test] fn parse_port_mapping_invalid_no_colon() { assert!(parse_port_mapping("8080").is_err()); }
#[test] fn parse_port_mapping_invalid_guest() { assert!(parse_port_mapping(":abc").is_err()); }
#[test] fn parse_port_mapping_invalid_host() { assert!(parse_port_mapping("abc:80").is_err()); }
#[test] fn parse_mount_spec_with_dst() { let (src, dst, read_only) = parse_mount_spec("/host/path:/guest/path").unwrap(); assert_eq!(src, "/host/path"); assert_eq!(dst, Some("/guest/path".to_string())); assert!(!read_only); }
#[test] fn parse_mount_spec_guest_home_destination() { for destination in ["~", "~/.local/state/paseo"] { let spec = format!("~/.local/state/paseo/sower:{destination}"); assert_eq!( parse_mount_spec(&spec).unwrap(), ( "~/.local/state/paseo/sower".to_string(), Some(destination.to_string()), false ) ); } assert!(parse_mount_spec("/host:~someone/path").is_err()); }
#[test] fn parse_mount_spec_without_dst() { let (src, dst, read_only) = parse_mount_spec("/host/path").unwrap(); assert_eq!(src, "/host/path"); assert_eq!(dst, None); assert!(!read_only); }
#[test] fn parse_mount_spec_src_with_literal_colon() { // Last colon wins: a src containing ':' still parses correctly as // long as the trailing segment after the last ':' is absolute. let (src, dst, read_only) = parse_mount_spec("/mnt/back:up:/guest").unwrap(); assert_eq!(src, "/mnt/back:up"); assert_eq!(dst, Some("/guest".to_string())); assert!(!read_only); }
#[test] fn parse_mount_spec_read_only_without_dst() { assert_eq!( parse_mount_spec("/host/path:ro").unwrap(), ("/host/path".to_string(), None, true) ); }
#[test] fn parse_mount_spec_read_only_with_dst() { assert_eq!( parse_mount_spec("/host/path:/guest/path:ro").unwrap(), ( "/host/path".to_string(), Some("/guest/path".to_string()), true ) ); }
#[test] fn parse_mount_spec_invalid_relative_dst() { assert!(parse_mount_spec("src:relative").is_err()); }
#[test] fn parse_mount_spec_invalid_empty_dst() { assert!(parse_mount_spec("src:").is_err()); }
#[test] fn canonicalize_mounts_preserves_dst_suffix() { let dir = TempDir::new().unwrap(); let real = dir.path().join("real"); fs::create_dir(&real).unwrap(); let link = dir.path().join("link"); #[cfg(unix)] std::os::unix::fs::symlink(&real, &link).unwrap();
let spec = format!("{}:/guest/path", link.display()); let canonicalized = canonicalize_mounts(&[spec]); let expected = format!("{}:/guest/path", real.canonicalize().unwrap().display()); assert_eq!(canonicalized, vec![expected]); }
#[test] fn canonicalize_mounts_preserves_read_only_suffix() { let dir = TempDir::new().unwrap(); let real = dir.path().join("real"); fs::create_dir(&real).unwrap(); let link = dir.path().join("link"); #[cfg(unix)] std::os::unix::fs::symlink(&real, &link).unwrap();
let specs = [ format!("{}:ro", link.display()), format!("{}:/guest/path:ro", link.display()), ]; let canonicalized = canonicalize_mounts(&specs); let canonical = real.canonicalize().unwrap(); assert_eq!( canonicalized, vec![ format!("{}:ro", canonical.display()), format!("{}:/guest/path:ro", canonical.display()), ] ); }
#[test] fn canonicalize_mounts_without_dst_unchanged_behavior() { let dir = TempDir::new().unwrap(); let spec = dir.path().display().to_string(); let canonicalized = canonicalize_mounts(&[spec]); let expected = dir .path() .canonicalize() .unwrap() .to_string_lossy() .to_string(); assert_eq!(canonicalized, vec![expected]); }
#[test] fn port_mapping_serialization_roundtrip() { let pm = PortMapping { host: 8080, guest: 80, protocol: "tcp".into(), }; let json = serde_json::to_string(&pm).unwrap(); let parsed: PortMapping = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, pm); }
#[test] fn runtime_with_ports_roundtrip() { let mut rt = test_runtime("vm1", "abc", 2222); rt.ports = vec![ PortMapping { host: 8080, guest: 80, protocol: "tcp".into(), }, PortMapping { host: 4443, guest: 443, protocol: "tcp".into(), }, ]; let json = serde_json::to_string(&rt).unwrap(); let parsed: RunningInstance = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.ports.len(), 2); assert_eq!(parsed.ports[0].host, 8080); assert_eq!(parsed.ports[1].guest, 443); }
#[test] fn runtime_without_ports_deserializes_empty() { // New-shape state.json without "ports" field on runtime should still deserialize let json = r#"{ "id": "vm1", "ssh": "127.0.0.1:2222", "serial": {"kind":"unix_socket","path":"/s"}, "backend": {"kind":"cloud_hypervisor","unit_id":"abc"}, "disk": "/d", "ssh_key_path": "/k" }"#; let parsed: RunningInstance = serde_json::from_str(json).unwrap(); assert!(parsed.ports.is_empty()); }
#[test] fn state_with_descriptor_roundtrip() { use crate::target::{Descriptor, HooksDescriptor}; use std::collections::BTreeMap;
let mut post_launch = BTreeMap::new(); post_launch.insert("00-hook".into(), "/nix/store/hook1/script".into()); let post_start = BTreeMap::from([("00-resume".into(), "/nix/store/resume/script".into())]);
let desc = Descriptor { toplevel: String::new(), kernel: "/nix/store/abc-kernel/bzImage".into(), disk: "/nix/store/def-image/image.qcow2".into(), initrd: Some("/nix/store/ghi-initrd/initrd".into()), cmdline: "console=ttyS0 root=/dev/vda2 ro".into(), configured_users: vec!["root".into()], hooks: HooksDescriptor { post_launch, post_start, pre_stop: BTreeMap::new(), guest_init: BTreeMap::new(), }, };
let state = InstanceState { declarative: false, target: ".#dev".into(), runtime: None, mounts: vec![], project_dir: None, disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec![], ssh_extra_config: vec![], descriptor: Some(desc), hooks: HostHooks::default(), }; let json = serde_json::to_string(&state).unwrap(); let parsed: InstanceState = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.descriptor, state.descriptor); }
#[test] fn state_without_descriptor_deserializes_none() { for json in [ r#"{"target": ".#test", "mounts": []}"#, r#"{"target": ".#test", "hooks": {"post-launch": {}, "pre-stop": {}}}"#, ] { let state: InstanceState = serde_json::from_str(json).unwrap(); assert!(state.descriptor.is_none()); assert!(state.hooks.post_start.is_empty()); } }
#[test] fn list_sorts_projects_before_global() { let dir = TempDir::new().unwrap(); let mk = |name: &str, target: &str, project: Option<&str>| { write_state( dir.path(), name, &InstanceState { declarative: false, target: target.into(), runtime: None, mounts: vec![], project_dir: project.map(|s| s.to_string()), disk_size: String::new(), cpus: 0, memory_mib: 0, port_specs: vec![], ssh_extra_config: vec![], descriptor: None, hooks: HostHooks::default(), }, ); }; mk("global-b", ".#b", None); mk("proj-a", ".#a", Some("/home/user/proj")); mk("global-a", ".#a", None); mk("proj-b", ".#b", Some("/home/user/proj"));
// Simulate the sort logic from list() let mut instances: Vec<(String, String, Option<String>)> = vec![]; for entry in fs::read_dir(dir.path()).unwrap() { let entry = entry.unwrap(); if entry.file_type().unwrap().is_dir() { let name = entry.file_name().to_string_lossy().to_string(); if let Some(state) = read_state(dir.path(), &name) { instances.push((name, state.target, state.project_dir)); } } } instances.sort_by(|a, b| { let a_has_project = a.2.is_some(); let b_has_project = b.2.is_some(); b_has_project.cmp(&a_has_project).then(a.0.cmp(&b.0)) });
let names: Vec<&str> = instances.iter().map(|(n, _, _)| n.as_str()).collect(); assert_eq!(names, vec!["proj-a", "proj-b", "global-a", "global-b"]); }
#[test] fn state_dir_returns_absolute_path() { let dir = state_dir(); assert!( dir.is_absolute(), "state_dir() returned relative path: {dir:?}" ); }}