use std::path::PathBuf;
#[cfg(target_os = "linux")]
use std::time::Duration;
#[cfg(target_os = "linux")]
use crate::api_client::{
BootSource, Drive, FirecrackerApiClient, InstanceAction, InstanceActionType, MachineConfig,
MemBackend, MemBackendType, SnapshotCreate, SnapshotLoad, SnapshotType, VmState, VmStatePatch,
};
#[cfg(target_os = "linux")]
use cellos_core::CellosError;
pub const POOL_SIZE_ENV: &str = "CELLOS_FIRECRACKER_POOL_SIZE";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PoolSlot {
Available {
snapshot_path: PathBuf,
mem_file_path: PathBuf,
vm_id: String,
},
InUse {
cell_id: String,
},
Empty,
}
pub struct FirecrackerPool {
size: usize,
slots: Vec<PoolSlot>,
}
impl FirecrackerPool {
pub fn new(size: usize) -> Self {
Self {
size,
slots: (0..size).map(|_| PoolSlot::Empty).collect(),
}
}
pub fn size(&self) -> usize {
self.size
}
pub fn available(&self) -> usize {
self.slots
.iter()
.filter(|s| matches!(s, PoolSlot::Available { .. }))
.count()
}
pub fn in_use(&self) -> usize {
self.slots
.iter()
.filter(|s| matches!(s, PoolSlot::InUse { .. }))
.count()
}
pub async fn checkout(&mut self, cell_id: &str) -> Option<PathBuf> {
for slot in self.slots.iter_mut() {
if let PoolSlot::Available { snapshot_path, .. } = slot {
let path = snapshot_path.clone();
*slot = PoolSlot::InUse {
cell_id: cell_id.to_string(),
};
return Some(path);
}
}
None
}
pub async fn checkin(&mut self, cell_id: &str) -> bool {
for slot in self.slots.iter_mut() {
if let PoolSlot::InUse { cell_id: held } = slot {
if held == cell_id {
*slot = PoolSlot::Empty;
return true;
}
}
}
false
}
#[cfg(target_os = "linux")]
pub async fn fill(&mut self, firecracker_bin: &str, kernel: &str, rootfs: &str) {
for (idx, slot) in self.slots.iter_mut().enumerate() {
if !matches!(slot, PoolSlot::Empty) {
continue;
}
match fill_one_slot(firecracker_bin, kernel, rootfs, idx).await {
Ok((snapshot_path, mem_file_path, vm_id)) => {
tracing::info!(
slot = idx,
snapshot = %snapshot_path.display(),
mem = %mem_file_path.display(),
"warm pool slot filled"
);
*slot = PoolSlot::Available {
snapshot_path,
mem_file_path,
vm_id,
};
}
Err(e) => {
tracing::warn!(slot = idx, error = %e, "warm pool fill failed; slot stays Empty");
}
}
}
}
#[cfg(not(target_os = "linux"))]
pub async fn fill(&mut self, _firecracker_bin: &str, _kernel: &str, _rootfs: &str) {
tracing::debug!(
pool_size = self.size,
"FirecrackerPool::fill no-op: target_os != linux"
);
}
}
#[cfg(target_os = "linux")]
pub async fn restore_into(
client: &FirecrackerApiClient,
snapshot_path: &std::path::Path,
mem_file_path: &std::path::Path,
) -> Result<(), CellosError> {
let status = client
.put(
"/snapshot/load",
&SnapshotLoad {
snapshot_path: snapshot_path.to_string_lossy().into_owned(),
mem_backend: MemBackend {
backend_type: MemBackendType::File,
backend_path: mem_file_path.to_string_lossy().into_owned(),
},
enable_diff_snapshots: false,
resume_vm: true,
},
)
.await?;
if !status.is_success() {
return Err(CellosError::Host(format!(
"firecracker /snapshot/load returned HTTP {status}"
)));
}
Ok(())
}
#[cfg(target_os = "linux")]
async fn fill_one_slot(
firecracker_bin: &str,
kernel: &str,
rootfs: &str,
slot_idx: usize,
) -> Result<(PathBuf, PathBuf, String), CellosError> {
use tokio::time::sleep;
use uuid::Uuid;
let vm_id = format!("pool-{}-{}", slot_idx, Uuid::new_v4().simple());
let socket_path = PathBuf::from(format!("/tmp/cellos-pool-{vm_id}.socket"));
let snapshot_path = PathBuf::from(format!("/tmp/cellos-pool-{vm_id}.snap"));
let mem_file_path = PathBuf::from(format!("/tmp/cellos-pool-{vm_id}.mem"));
let _ = std::fs::remove_file(&socket_path);
let socket_str = socket_path.to_string_lossy().into_owned();
let mut child = tokio::process::Command::new(firecracker_bin)
.args(["--api-sock", socket_str.as_str(), "--level", "Error"])
.kill_on_drop(true)
.spawn()
.map_err(|e| CellosError::Host(format!("spawn firecracker for pool fill: {e}")))?;
let fill = async {
let client = FirecrackerApiClient::new(&socket_path);
client.wait_for_ready().await?;
let mc = client
.put(
"/machine-config",
&MachineConfig {
vcpu_count: 1,
mem_size_mib: 128,
track_dirty_pages: false,
},
)
.await?;
if !mc.is_success() {
return Err(CellosError::Host(format!(
"firecracker /machine-config returned HTTP {mc}"
)));
}
let bs = client
.put(
"/boot-source",
&BootSource {
kernel_image_path: kernel.to_string(),
boot_args: Some("console=ttyS0 reboot=k panic=1 pci=off nomodules".to_string()),
},
)
.await?;
if !bs.is_success() {
return Err(CellosError::Host(format!(
"firecracker /boot-source returned HTTP {bs}"
)));
}
let drv = client
.put(
"/drives/rootfs",
&Drive {
drive_id: "rootfs".into(),
path_on_host: rootfs.to_string(),
is_root_device: true,
is_read_only: true,
},
)
.await?;
if !drv.is_success() {
return Err(CellosError::Host(format!(
"firecracker /drives/rootfs returned HTTP {drv}"
)));
}
let start = client
.put(
"/actions",
&InstanceAction {
action_type: InstanceActionType::InstanceStart,
},
)
.await?;
if !start.is_success() {
return Err(CellosError::Host(format!(
"firecracker InstanceStart returned HTTP {start}"
)));
}
sleep(Duration::from_millis(500)).await;
let pause = client
.patch(
"/vm",
&VmStatePatch {
state: VmState::Paused,
},
)
.await?;
if !pause.is_success() {
return Err(CellosError::Host(format!(
"firecracker PATCH /vm Paused returned HTTP {pause}"
)));
}
let snap = client
.put(
"/snapshot/create",
&SnapshotCreate {
snapshot_type: SnapshotType::Full,
snapshot_path: snapshot_path.to_string_lossy().into_owned(),
mem_file_path: mem_file_path.to_string_lossy().into_owned(),
},
)
.await?;
if !snap.is_success() {
return Err(CellosError::Host(format!(
"firecracker /snapshot/create returned HTTP {snap}"
)));
}
Ok::<(), CellosError>(())
};
let result = fill.await;
let _ = child.kill().await;
let _ = child.wait().await;
let _ = std::fs::remove_file(&socket_path);
result.map(|()| (snapshot_path, mem_file_path, vm_id))
}
pub fn pool_size_from_env() -> usize {
std::env::var(POOL_SIZE_ENV)
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn new_pool_starts_empty() {
let mut pool = FirecrackerPool::new(3);
assert_eq!(pool.size(), 3);
assert_eq!(pool.available(), 0);
assert_eq!(pool.in_use(), 0);
assert!(pool.checkout("cell-1").await.is_none());
}
#[tokio::test]
async fn zero_size_pool_is_inert() {
let mut pool = FirecrackerPool::new(0);
assert_eq!(pool.size(), 0);
assert!(pool.checkout("any-cell").await.is_none());
assert!(!pool.checkin("any-cell").await);
}
#[tokio::test]
async fn checkout_then_checkin_cycles_slot_through_states() {
let mut pool = FirecrackerPool::new(1);
pool.slots[0] = PoolSlot::Available {
snapshot_path: PathBuf::from("/tmp/snap-1"),
mem_file_path: PathBuf::from("/tmp/snap-1.mem"),
vm_id: "vm-1".to_string(),
};
assert_eq!(pool.available(), 1);
let path = pool.checkout("cell-1").await;
assert_eq!(path, Some(PathBuf::from("/tmp/snap-1")));
assert_eq!(pool.available(), 0);
assert_eq!(pool.in_use(), 1);
assert!(pool.checkout("cell-2").await.is_none());
assert!(pool.checkin("cell-1").await);
assert_eq!(pool.available(), 0);
assert_eq!(pool.in_use(), 0);
assert!(!pool.checkin("cell-1").await);
}
#[tokio::test]
async fn checkin_wrong_cell_id_is_noop() {
let mut pool = FirecrackerPool::new(1);
pool.slots[0] = PoolSlot::InUse {
cell_id: "real-cell".to_string(),
};
assert!(!pool.checkin("imposter-cell").await);
assert_eq!(pool.in_use(), 1);
assert!(pool.checkin("real-cell").await);
assert_eq!(pool.in_use(), 0);
}
#[tokio::test]
async fn fill_with_missing_binary_leaves_slots_empty() {
let mut pool = FirecrackerPool::new(2);
pool.fill(
"/nonexistent/firecracker",
"/nonexistent/vmlinux",
"/nonexistent/rootfs.ext4",
)
.await;
assert_eq!(pool.available(), 0);
assert_eq!(pool.in_use(), 0);
assert_eq!(
pool.slots
.iter()
.filter(|s| matches!(s, PoolSlot::Empty))
.count(),
2
);
}
#[tokio::test]
async fn checkout_checkin_cycle_two_slots() {
let mut pool = FirecrackerPool::new(2);
pool.slots[0] = PoolSlot::Available {
snapshot_path: PathBuf::from("/tmp/snap-a"),
mem_file_path: PathBuf::from("/tmp/snap-a.mem"),
vm_id: "vm-a".into(),
};
pool.slots[1] = PoolSlot::Available {
snapshot_path: PathBuf::from("/tmp/snap-b"),
mem_file_path: PathBuf::from("/tmp/snap-b.mem"),
vm_id: "vm-b".into(),
};
assert_eq!(pool.available(), 2);
let p1 = pool.checkout("cell-1").await.expect("first checkout");
let p2 = pool.checkout("cell-2").await.expect("second checkout");
assert_ne!(p1, p2, "each cell got a distinct snapshot path");
assert_eq!(pool.available(), 0);
assert_eq!(pool.in_use(), 2);
assert!(pool.checkout("cell-3").await.is_none());
assert!(pool.checkin("cell-1").await);
assert!(pool.checkin("cell-2").await);
assert_eq!(pool.in_use(), 0);
assert_eq!(
pool.slots
.iter()
.filter(|s| matches!(s, PoolSlot::Empty))
.count(),
2
);
assert!(!pool.checkin("cell-1").await);
assert!(!pool.checkin("cell-2").await);
}
#[tokio::test]
async fn checkout_returns_snapshot_path_verbatim() {
let mut pool = FirecrackerPool::new(1);
pool.slots[0] = PoolSlot::Available {
snapshot_path: PathBuf::from("/tmp/cellos-pool-X.snap"),
mem_file_path: PathBuf::from("/tmp/cellos-pool-X.mem"),
vm_id: "X".into(),
};
let got = pool.checkout("cell-X").await;
assert_eq!(got, Some(PathBuf::from("/tmp/cellos-pool-X.snap")));
match &pool.slots[0] {
PoolSlot::InUse { cell_id } => assert_eq!(cell_id, "cell-X"),
other => panic!("expected InUse after checkout, got {other:?}"),
}
}
#[test]
fn pool_size_from_env_defaults_to_zero_when_unset() {
if std::env::var(POOL_SIZE_ENV).is_err() {
assert_eq!(pool_size_from_env(), 0);
}
}
}