fleetforge_firecracker/
lib.rs

1use 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}