use anyhow::{Context, Result, bail};
use http_body_util::{BodyExt, Full};
use hyper::body::Bytes;
use hyper::{Method, Request};
use hyper_util::rt::TokioIo;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::net::UnixStream;
pub struct FirecrackerClient {
socket_path: String,
}
#[derive(Debug, Deserialize)]
pub struct ApiError {
pub fault_message: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct BootSource {
pub kernel_image_path: String,
pub boot_args: String,
}
#[derive(Debug, Serialize)]
pub struct Drive {
pub drive_id: String,
pub path_on_host: String,
pub is_root_device: bool,
pub is_read_only: bool,
}
#[derive(Debug, Serialize)]
pub struct MachineConfig {
pub vcpu_count: u32,
pub mem_size_mib: u64,
}
#[derive(Debug, Serialize)]
pub struct VsockDevice {
pub guest_cid: u32,
pub uds_path: String,
}
#[derive(Debug, Serialize)]
pub struct InstanceAction {
pub action_type: String,
}
#[derive(Debug, Serialize)]
pub struct NetworkInterface {
pub iface_id: String,
pub guest_mac: Option<String>,
pub host_dev_name: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct InstanceInfo {
pub id: Option<String>,
pub state: String,
pub vmm_version: String,
}
impl FirecrackerClient {
pub fn new(socket_path: impl AsRef<Path>) -> Self {
Self {
socket_path: socket_path.as_ref().to_string_lossy().to_string(),
}
}
#[allow(dead_code)]
pub async fn is_available(&self) -> bool {
std::path::Path::new(&self.socket_path).exists()
}
#[allow(dead_code)]
pub async fn get_instance_info(&self) -> Result<InstanceInfo> {
let response = self.request(Method::GET, "/", None::<&()>).await?;
serde_json::from_slice(&response).context("Failed to parse instance info")
}
pub async fn set_boot_source(&self, boot_source: &BootSource) -> Result<()> {
self.put("/boot-source", boot_source).await
}
pub async fn set_drive(&self, drive_id: &str, drive: &Drive) -> Result<()> {
self.put(&format!("/drives/{}", drive_id), drive).await
}
pub async fn set_machine_config(&self, config: &MachineConfig) -> Result<()> {
self.put("/machine-config", config).await
}
pub async fn set_vsock(&self, vsock: &VsockDevice) -> Result<()> {
self.put("/vsock", vsock).await
}
#[allow(dead_code)]
pub async fn set_network_interface(
&self,
iface_id: &str,
iface: &NetworkInterface,
) -> Result<()> {
self.put(&format!("/network-interfaces/{}", iface_id), iface)
.await
}
pub async fn start_instance(&self) -> Result<()> {
let action = InstanceAction {
action_type: "InstanceStart".to_string(),
};
self.put("/actions", &action).await
}
pub async fn send_ctrl_alt_del(&self) -> Result<()> {
let action = InstanceAction {
action_type: "SendCtrlAltDel".to_string(),
};
self.put("/actions", &action).await
}
#[allow(dead_code)]
pub async fn pause(&self) -> Result<()> {
let action = InstanceAction {
action_type: "Pause".to_string(),
};
self.put("/actions", &action).await
}
#[allow(dead_code)]
pub async fn resume(&self) -> Result<()> {
let action = InstanceAction {
action_type: "Resume".to_string(),
};
self.put("/actions", &action).await
}
async fn put<T: Serialize>(&self, path: &str, body: &T) -> Result<()> {
let _ = self.request(Method::PUT, path, Some(body)).await?;
Ok(())
}
async fn request<T: Serialize>(
&self,
method: Method,
path: &str,
body: Option<&T>,
) -> Result<Bytes> {
let stream = UnixStream::connect(&self.socket_path)
.await
.with_context(|| {
format!(
"Failed to connect to Firecracker socket: {}",
self.socket_path
)
})?;
let io = TokioIo::new(stream);
let (mut sender, conn) = hyper::client::conn::http1::handshake(io)
.await
.context("Failed to create HTTP connection")?;
tokio::spawn(async move {
if let Err(e) = conn.await {
eprintln!("Firecracker connection error: {:?}", e);
}
});
let body_bytes = if let Some(b) = body {
serde_json::to_vec(b)?
} else {
Vec::new()
};
let req = Request::builder()
.method(method)
.uri(format!("http://localhost{}", path))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.body(Full::new(Bytes::from(body_bytes)))
.context("Failed to build request")?;
let response = sender
.send_request(req)
.await
.context("Failed to send request to Firecracker")?;
let status = response.status();
let body = response
.into_body()
.collect()
.await
.context("Failed to read response body")?
.to_bytes();
if !status.is_success() {
if let Ok(error) = serde_json::from_slice::<ApiError>(&body)
&& let Some(msg) = error.fault_message
{
bail!("Firecracker API error ({}): {}", status, msg);
}
let body_str = String::from_utf8_lossy(&body);
bail!("Firecracker API error ({}): {}", status, body_str);
}
if !body.is_empty()
&& let Ok(error) = serde_json::from_slice::<ApiError>(&body)
&& let Some(msg) = error.fault_message
{
bail!("Firecracker API error: {}", msg);
}
Ok(body)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_boot_source_serialize() {
let boot = BootSource {
kernel_image_path: "/path/to/kernel".to_string(),
boot_args: "console=ttyS0".to_string(),
};
let json = serde_json::to_string(&boot).unwrap();
assert!(json.contains("kernel_image_path"));
assert!(json.contains("boot_args"));
}
#[test]
fn test_machine_config_serialize() {
let config = MachineConfig {
vcpu_count: 2,
mem_size_mib: 512,
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("vcpu_count"));
assert!(json.contains("mem_size_mib"));
}
}