fleetforge_firecracker/
lib.rs1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use tokio::process::Command;
6use tracing::{info, warn};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct FirecrackerConfig {
10 pub vm_id: String,
11 pub socket_path: PathBuf,
12 pub kernel_image_path: PathBuf,
13 pub rootfs_path: PathBuf,
14 #[serde(default)]
15 pub kernel_args: Option<String>,
16 #[serde(default)]
17 pub log_fifo: Option<PathBuf>,
18 #[serde(default)]
19 pub metrics_fifo: Option<PathBuf>,
20}
21
22impl FirecrackerConfig {
23 pub fn validate(&self) -> Result<()> {
24 anyhow::ensure!(
25 Path::new(&self.kernel_image_path).exists(),
26 "kernel image missing: {}",
27 self.kernel_image_path.display()
28 );
29 anyhow::ensure!(
30 Path::new(&self.rootfs_path).exists(),
31 "rootfs image missing: {}",
32 self.rootfs_path.display()
33 );
34 Ok(())
35 }
36
37 pub fn to_boot_payload(&self) -> serde_json::Value {
38 serde_json::json!({
39 "boot-source": {
40 "kernel_image_path": self.kernel_image_path,
41 "boot_args": self.kernel_args.clone().unwrap_or_else(|| String::from("reboot=k panic=1 pci=off nomodules")),
42 },
43 "drives": [{
44 "drive_id": "rootfs",
45 "path_on_host": self.rootfs_path,
46 "is_root_device": true,
47 "is_read_only": false,
48 }]
49 })
50 }
51}
52
53#[derive(Debug, Clone)]
54pub struct FirecrackerLauncher {
55 firecracker_bin: PathBuf,
56}
57
58impl FirecrackerLauncher {
59 pub fn new(binary: impl Into<PathBuf>) -> Self {
60 Self {
61 firecracker_bin: binary.into(),
62 }
63 }
64
65 pub fn binary(&self) -> &Path {
66 &self.firecracker_bin
67 }
68
69 pub async fn launch(&self, cfg: &FirecrackerConfig) -> Result<FirecrackerLaunchPlan> {
70 cfg.validate()?;
71 let bin = self.firecracker_bin.canonicalize().with_context(|| {
72 format!(
73 "firecracker binary not found at {}",
74 self.firecracker_bin.display()
75 )
76 })?;
77
78 let mut command = Command::new(bin);
79 command
80 .arg("--no-api")
81 .arg("--id")
82 .arg(&cfg.vm_id)
83 .arg("--api-sock")
84 .arg(&cfg.socket_path);
85
86 info!(vm_id = %cfg.vm_id, socket = %cfg.socket_path.display(), "prepared firecracker launch command");
87 Ok(FirecrackerLaunchPlan {
88 command,
89 config: cfg.clone(),
90 })
91 }
92}
93
94pub struct FirecrackerLaunchPlan {
95 pub command: Command,
96 pub config: FirecrackerConfig,
97}
98
99impl FirecrackerLaunchPlan {
100 pub async fn dry_run(&mut self) -> Result<()> {
101 let output = self.command.arg("--version").output().await?;
102 if !output.status.success() {
103 warn!(exit = ?output.status.code(), "firecracker --version failed during dry-run");
104 }
105 Ok(())
106 }
107
108 pub fn vm_id(&self) -> &str {
109 &self.config.vm_id
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[tokio::test]
118 async fn builds_boot_payload() {
119 let cfg = FirecrackerConfig {
120 vm_id: "vm-test".into(),
121 socket_path: PathBuf::from("/tmp/api.sock"),
122 kernel_image_path: PathBuf::from("/tmp/vmlinux"),
123 rootfs_path: PathBuf::from("/tmp/rootfs.img"),
124 kernel_args: Some("console=ttyS0 reboot=k".into()),
125 log_fifo: None,
126 metrics_fifo: None,
127 };
128
129 let payload = cfg.to_boot_payload();
130 assert!(payload.get("boot-source").is_some());
131 assert!(payload.get("drives").is_some());
132 }
133}