photoncloud-monorepo/plasmavmc/crates/plasmavmc-firecracker/src/api.rs
centra a7ec7e2158 Add T026 practical test + k8shost to flake + workspace files
- Created T026-practical-test task.yaml for MVP smoke testing
- Added k8shost-server to flake.nix (packages, apps, overlays)
- Staged all workspace directories for nix flake build
- Updated flake.nix shellHook to include k8shost

Resolves: T026.S1 blocker (R8 - nix submodule visibility)
2025-12-09 06:07:50 +09:00

254 lines
7.4 KiB
Rust

//! FireCracker REST API client over Unix socket
use plasmavmc_types::{Error, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
/// FireCracker REST API client
pub struct FireCrackerClient {
stream: UnixStream,
}
impl FireCrackerClient {
/// Connect to FireCracker API socket
pub async fn connect(path: impl AsRef<Path>) -> Result<Self> {
let stream = UnixStream::connect(path.as_ref())
.await
.map_err(|e| Error::HypervisorError(format!("Failed to connect FireCracker API: {e}")))?;
Ok(Self { stream })
}
/// Send HTTP request over Unix socket
async fn request(
&mut self,
method: &str,
path: &str,
body: Option<&[u8]>,
) -> Result<Vec<u8>> {
let mut request = format!("{} {} HTTP/1.1\r\n", method, path);
request.push_str("Host: localhost\r\n");
request.push_str("Content-Type: application/json\r\n");
if let Some(body) = body {
request.push_str(&format!("Content-Length: {}\r\n", body.len()));
} else {
request.push_str("Content-Length: 0\r\n");
}
request.push_str("\r\n");
if let Some(body) = body {
request.push_str(&String::from_utf8_lossy(body));
}
self.stream
.write_all(request.as_bytes())
.await
.map_err(|e| Error::HypervisorError(format!("Failed to send request: {e}")))?;
self.stream
.flush()
.await
.map_err(|e| Error::HypervisorError(format!("Failed to flush request: {e}")))?;
// Read response
let mut response = Vec::new();
self.stream
.read_to_end(&mut response)
.await
.map_err(|e| Error::HypervisorError(format!("Failed to read response: {e}")))?;
// Parse HTTP response
let response_str = String::from_utf8_lossy(&response);
let mut lines = response_str.lines();
// Parse status line
let status_line = lines.next().ok_or_else(|| {
Error::HypervisorError("Empty response from FireCracker API".to_string())
})?;
if !status_line.contains("200") && !status_line.contains("204") {
return Err(Error::HypervisorError(format!(
"FireCracker API error: {status_line}"
)));
}
// Skip headers
let mut body_start = 0;
for (idx, line) in response_str.lines().enumerate() {
if line.is_empty() {
body_start = response_str
.lines()
.take(idx + 1)
.map(|l| l.len() + 2) // +2 for \r\n
.sum();
break;
}
}
Ok(response[body_start..].to_vec())
}
/// PUT /machine-config
pub async fn put_machine_config(&mut self, vcpu_count: u32, mem_size_mib: u64) -> Result<()> {
#[derive(Serialize)]
struct MachineConfig {
vcpu_count: u32,
mem_size_mib: u64,
ht_enabled: bool,
}
let config = MachineConfig {
vcpu_count,
mem_size_mib,
ht_enabled: false,
};
let body = serde_json::to_vec(&config)
.map_err(|e| Error::HypervisorError(format!("Failed to serialize config: {e}")))?;
self.request("PUT", "/machine-config", Some(&body))
.await?;
Ok(())
}
/// PUT /boot-source
pub async fn put_boot_source(
&mut self,
kernel_image_path: &str,
initrd_path: Option<&str>,
boot_args: &str,
) -> Result<()> {
#[derive(Serialize)]
struct BootSource {
kernel_image_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
initrd_path: Option<String>,
boot_args: String,
}
let boot_source = BootSource {
kernel_image_path: kernel_image_path.to_string(),
initrd_path: initrd_path.map(|s| s.to_string()),
boot_args: boot_args.to_string(),
};
let body = serde_json::to_vec(&boot_source)
.map_err(|e| Error::HypervisorError(format!("Failed to serialize boot source: {e}")))?;
self.request("PUT", "/boot-source", Some(&body))
.await?;
Ok(())
}
/// PUT /drives/{drive_id}
pub async fn put_drive(
&mut self,
drive_id: &str,
path_on_host: &str,
is_root_device: bool,
is_read_only: bool,
) -> Result<()> {
#[derive(Serialize)]
struct Drive {
path_on_host: String,
is_root_device: bool,
is_read_only: bool,
}
let drive = Drive {
path_on_host: path_on_host.to_string(),
is_root_device,
is_read_only,
};
let body = serde_json::to_vec(&drive)
.map_err(|e| Error::HypervisorError(format!("Failed to serialize drive: {e}")))?;
self.request("PUT", &format!("/drives/{}", drive_id), Some(&body))
.await?;
Ok(())
}
/// PUT /network-interfaces/{iface_id}
pub async fn put_network_interface(
&mut self,
iface_id: &str,
guest_mac: &str,
host_dev_name: &str,
) -> Result<()> {
#[derive(Serialize)]
struct NetworkInterface {
guest_mac: String,
host_dev_name: String,
}
let iface = NetworkInterface {
guest_mac: guest_mac.to_string(),
host_dev_name: host_dev_name.to_string(),
};
let body = serde_json::to_vec(&iface)
.map_err(|e| Error::HypervisorError(format!("Failed to serialize network interface: {e}")))?;
self.request(
"PUT",
&format!("/network-interfaces/{}", iface_id),
Some(&body),
)
.await?;
Ok(())
}
/// PUT /actions
pub async fn instance_start(&mut self) -> Result<()> {
#[derive(Serialize)]
struct Action {
action_type: String,
}
let action = Action {
action_type: "InstanceStart".to_string(),
};
let body = serde_json::to_vec(&action)
.map_err(|e| Error::HypervisorError(format!("Failed to serialize action: {e}")))?;
self.request("PUT", "/actions", Some(&body))
.await?;
Ok(())
}
/// PUT /actions (SendCtrlAltDel)
pub async fn send_ctrl_alt_del(&mut self) -> Result<()> {
#[derive(Serialize)]
struct Action {
action_type: String,
}
let action = Action {
action_type: "SendCtrlAltDel".to_string(),
};
let body = serde_json::to_vec(&action)
.map_err(|e| Error::HypervisorError(format!("Failed to serialize action: {e}")))?;
self.request("PUT", "/actions", Some(&body))
.await?;
Ok(())
}
/// GET /vm
pub async fn get_vm_info(&mut self) -> Result<VmInfo> {
let body = self.request("GET", "/vm", None).await?;
let info: VmInfo = serde_json::from_slice(&body)
.map_err(|e| Error::HypervisorError(format!("Failed to parse VM info: {e}")))?;
Ok(info)
}
}
#[derive(Debug, Deserialize)]
pub struct VmInfo {
#[serde(default)]
#[allow(dead_code)]
pub state: String,
}