mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-01 07:53:42 +08:00
402 lines
13 KiB
Rust
402 lines
13 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Small standalone Firecracker launcher owned by this driver crate.
|
|
|
|
#![allow(unsafe_code)]
|
|
|
|
use std::fs::{File, OpenOptions};
|
|
use std::io::{self, Read, Write};
|
|
use std::os::unix::net::UnixStream;
|
|
use std::os::unix::process::CommandExt as _;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, ExitStatus, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use nix::sys::signal::Signal;
|
|
use serde_json::{Value, json};
|
|
|
|
const API_START_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const API_IO_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const MAX_API_RESPONSE_BYTES: usize = 1024 * 1024;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FirecrackerLaunchConfig {
|
|
pub firecracker_binary: PathBuf,
|
|
pub kernel_image: PathBuf,
|
|
pub root_disk: PathBuf,
|
|
pub run_dir: PathBuf,
|
|
pub console_output: PathBuf,
|
|
pub guest_init: String,
|
|
pub vcpus: u8,
|
|
pub mem_mib: u32,
|
|
pub vsock_cid: u32,
|
|
}
|
|
|
|
/// A configured Firecracker child. Dropping it terminates the VMM.
|
|
pub struct FirecrackerVm {
|
|
child: Child,
|
|
api_socket: PathBuf,
|
|
vsock_socket: PathBuf,
|
|
}
|
|
|
|
impl FirecrackerVm {
|
|
pub fn launch(config: &FirecrackerLaunchConfig) -> Result<Self, String> {
|
|
validate_config(config)?;
|
|
check_kvm_access()?;
|
|
std::fs::create_dir_all(&config.run_dir).map_err(|error| {
|
|
format!(
|
|
"create Firecracker run dir {}: {error}",
|
|
config.run_dir.display()
|
|
)
|
|
})?;
|
|
let api_socket = config.run_dir.join("firecracker-api.sock");
|
|
let vsock_socket = config.run_dir.join("firecracker-vsock.sock");
|
|
remove_stale_socket(&api_socket)?;
|
|
remove_stale_socket(&vsock_socket)?;
|
|
|
|
let console = File::create(&config.console_output).map_err(|error| {
|
|
format!(
|
|
"create Firecracker console log {}: {error}",
|
|
config.console_output.display()
|
|
)
|
|
})?;
|
|
let stderr = console
|
|
.try_clone()
|
|
.map_err(|error| format!("clone Firecracker console log: {error}"))?;
|
|
let mut command = Command::new(&config.firecracker_binary);
|
|
command
|
|
.arg("--api-sock")
|
|
.arg(&api_socket)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::from(console))
|
|
.stderr(Stdio::from(stderr));
|
|
unsafe {
|
|
command.pre_exec(|| {
|
|
nix::sys::prctl::set_pdeathsig(Signal::SIGKILL)
|
|
.map_err(|error| io::Error::other(format!("pdeathsig: {error}")))
|
|
});
|
|
}
|
|
let mut child = command
|
|
.spawn()
|
|
.map_err(|error| format!("start Firecracker: {error}"))?;
|
|
if let Err(error) = configure(&mut child, &api_socket, &vsock_socket, config) {
|
|
terminate_child(&mut child);
|
|
return Err(error);
|
|
}
|
|
Ok(Self {
|
|
child,
|
|
api_socket,
|
|
vsock_socket,
|
|
})
|
|
}
|
|
|
|
pub fn vsock_uds_path(&self) -> &Path {
|
|
&self.vsock_socket
|
|
}
|
|
|
|
pub fn wait(&mut self) -> Result<ExitStatus, String> {
|
|
self.child
|
|
.wait()
|
|
.map_err(|error| format!("wait for Firecracker: {error}"))
|
|
}
|
|
|
|
pub fn terminate(&mut self) -> Result<(), String> {
|
|
terminate_child(&mut self.child);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for FirecrackerVm {
|
|
fn drop(&mut self) {
|
|
if self.child.try_wait().ok().flatten().is_none() {
|
|
terminate_child(&mut self.child);
|
|
}
|
|
let _ = std::fs::remove_file(&self.api_socket);
|
|
let _ = std::fs::remove_file(&self.vsock_socket);
|
|
}
|
|
}
|
|
|
|
fn validate_config(config: &FirecrackerLaunchConfig) -> Result<(), String> {
|
|
for (label, path) in [
|
|
("Firecracker binary", &config.firecracker_binary),
|
|
("kernel image", &config.kernel_image),
|
|
("root disk", &config.root_disk),
|
|
] {
|
|
if !path.is_file() {
|
|
return Err(format!("{label} not found: {}", path.display()));
|
|
}
|
|
}
|
|
if config.vcpus == 0 {
|
|
return Err("Firecracker vCPU count must be nonzero".to_string());
|
|
}
|
|
if config.mem_mib < 128 {
|
|
return Err("Firecracker memory must be at least 128 MiB".to_string());
|
|
}
|
|
if config.vsock_cid < 3 {
|
|
return Err("Firecracker guest CID must be at least 3".to_string());
|
|
}
|
|
if !config.guest_init.starts_with('/') {
|
|
return Err("Firecracker guest init path must be absolute".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn check_kvm_access() -> Result<(), String> {
|
|
OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.open("/dev/kvm")
|
|
.map(|_| ())
|
|
.map_err(|error| {
|
|
format!(
|
|
"open /dev/kvm read/write: {error}; start a login session whose supplementary groups include kvm"
|
|
)
|
|
})
|
|
}
|
|
|
|
fn configure(
|
|
child: &mut Child,
|
|
api_socket: &Path,
|
|
vsock_socket: &Path,
|
|
config: &FirecrackerLaunchConfig,
|
|
) -> Result<(), String> {
|
|
wait_for_api_socket(child, api_socket)?;
|
|
for request in configuration_requests(config, vsock_socket) {
|
|
put_json(api_socket, request.path, &request.body)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
struct ApiRequest {
|
|
path: &'static str,
|
|
body: Value,
|
|
}
|
|
|
|
fn configuration_requests(
|
|
config: &FirecrackerLaunchConfig,
|
|
vsock_socket: &Path,
|
|
) -> Vec<ApiRequest> {
|
|
vec![
|
|
ApiRequest {
|
|
path: "/machine-config",
|
|
body: json!({
|
|
"vcpu_count": config.vcpus,
|
|
"mem_size_mib": config.mem_mib,
|
|
"smt": false
|
|
}),
|
|
},
|
|
ApiRequest {
|
|
path: "/boot-source",
|
|
body: json!({
|
|
"kernel_image_path": config.kernel_image,
|
|
"boot_args": kernel_command_line(&config.guest_init)
|
|
}),
|
|
},
|
|
ApiRequest {
|
|
path: "/drives/rootfs",
|
|
body: json!({
|
|
"drive_id": "rootfs",
|
|
"path_on_host": config.root_disk,
|
|
"is_root_device": true,
|
|
"is_read_only": false
|
|
}),
|
|
},
|
|
ApiRequest {
|
|
path: "/vsock",
|
|
body: json!({
|
|
"guest_cid": config.vsock_cid,
|
|
"uds_path": vsock_socket
|
|
}),
|
|
},
|
|
ApiRequest {
|
|
path: "/actions",
|
|
body: json!({ "action_type": "InstanceStart" }),
|
|
},
|
|
]
|
|
}
|
|
|
|
fn kernel_command_line(init: &str) -> String {
|
|
format!("console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw init={init}")
|
|
}
|
|
|
|
fn wait_for_api_socket(child: &mut Child, socket: &Path) -> Result<(), String> {
|
|
let deadline = Instant::now() + API_START_TIMEOUT;
|
|
loop {
|
|
if UnixStream::connect(socket).is_ok() {
|
|
return Ok(());
|
|
}
|
|
if let Some(status) = child
|
|
.try_wait()
|
|
.map_err(|error| format!("check Firecracker process: {error}"))?
|
|
{
|
|
return Err(format!(
|
|
"Firecracker exited before its API socket was ready: {status}"
|
|
));
|
|
}
|
|
if Instant::now() >= deadline {
|
|
return Err(format!(
|
|
"timed out waiting for Firecracker API socket {}",
|
|
socket.display()
|
|
));
|
|
}
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
}
|
|
|
|
fn put_json(socket: &Path, path: &str, body: &Value) -> Result<(), String> {
|
|
let body = serde_json::to_vec(body).map_err(|error| format!("encode {path}: {error}"))?;
|
|
let mut stream = UnixStream::connect(socket)
|
|
.map_err(|error| format!("connect to Firecracker API {}: {error}", socket.display()))?;
|
|
stream
|
|
.set_read_timeout(Some(API_IO_TIMEOUT))
|
|
.map_err(|error| format!("set API read timeout: {error}"))?;
|
|
stream
|
|
.set_write_timeout(Some(API_IO_TIMEOUT))
|
|
.map_err(|error| format!("set API write timeout: {error}"))?;
|
|
write!(
|
|
stream,
|
|
"PUT {path} HTTP/1.1\r\nHost: localhost\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
body.len()
|
|
)
|
|
.and_then(|()| stream.write_all(&body))
|
|
.map_err(|error| format!("write Firecracker API request {path}: {error}"))?;
|
|
let response = read_http_response(&mut stream, path)?;
|
|
check_http_status(path, &response)
|
|
}
|
|
|
|
fn read_http_response(stream: &mut UnixStream, path: &str) -> Result<Vec<u8>, String> {
|
|
let mut response = Vec::with_capacity(1024);
|
|
let mut buffer = [0_u8; 4096];
|
|
loop {
|
|
let count = stream
|
|
.read(&mut buffer)
|
|
.map_err(|error| format!("read Firecracker API response {path}: {error}"))?;
|
|
if count == 0 {
|
|
return if response.is_empty() {
|
|
Err(format!("empty Firecracker API response for {path}"))
|
|
} else {
|
|
Ok(response)
|
|
};
|
|
}
|
|
response.extend_from_slice(&buffer[..count]);
|
|
if response.len() > MAX_API_RESPONSE_BYTES {
|
|
return Err(format!(
|
|
"Firecracker API response for {path} exceeds {MAX_API_RESPONSE_BYTES} bytes"
|
|
));
|
|
}
|
|
let Some(header_end) = response.windows(4).position(|window| window == b"\r\n\r\n") else {
|
|
continue;
|
|
};
|
|
let body_start = header_end + 4;
|
|
let headers = String::from_utf8_lossy(&response[..header_end]);
|
|
let content_length = headers.lines().find_map(|line| {
|
|
let (name, value) = line.split_once(':')?;
|
|
name.eq_ignore_ascii_case("content-length")
|
|
.then(|| value.trim().parse::<usize>().ok())
|
|
.flatten()
|
|
});
|
|
match content_length {
|
|
Some(length) if response.len() >= body_start + length => {
|
|
response.truncate(body_start + length);
|
|
return Ok(response);
|
|
}
|
|
Some(_) => {}
|
|
None => return Ok(response),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn check_http_status(path: &str, response: &[u8]) -> Result<(), String> {
|
|
let text = String::from_utf8_lossy(response);
|
|
let status_line = text
|
|
.lines()
|
|
.next()
|
|
.ok_or_else(|| format!("empty Firecracker API response for {path}"))?;
|
|
let status = status_line
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.and_then(|value| value.parse::<u16>().ok())
|
|
.ok_or_else(|| format!("invalid Firecracker API response for {path}: {status_line}"))?;
|
|
if (200..300).contains(&status) {
|
|
Ok(())
|
|
} else {
|
|
let body = text.split_once("\r\n\r\n").map_or("", |(_, body)| body);
|
|
Err(format!(
|
|
"Firecracker API request {path} failed with status {status}: {}",
|
|
body.trim()
|
|
))
|
|
}
|
|
}
|
|
|
|
fn remove_stale_socket(path: &Path) -> Result<(), String> {
|
|
match std::fs::remove_file(path) {
|
|
Ok(()) => Ok(()),
|
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
|
Err(error) => Err(format!("remove stale socket {}: {error}", path.display())),
|
|
}
|
|
}
|
|
|
|
fn terminate_child(child: &mut Child) {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn config() -> FirecrackerLaunchConfig {
|
|
FirecrackerLaunchConfig {
|
|
firecracker_binary: PathBuf::from("/firecracker"),
|
|
kernel_image: PathBuf::from("/vmlinux"),
|
|
root_disk: PathBuf::from("/root.ext4"),
|
|
run_dir: PathBuf::from("/run/firecracker"),
|
|
console_output: PathBuf::from("/run/firecracker/console.log"),
|
|
guest_init: "/opt/openshell/bin/openshell-driver-firecracker".to_string(),
|
|
vcpus: 2,
|
|
mem_mib: 512,
|
|
vsock_cid: 4,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn configures_no_network_device() {
|
|
let config = config();
|
|
let requests = configuration_requests(&config, Path::new("/tmp/vsock.sock"));
|
|
let paths = requests
|
|
.iter()
|
|
.map(|request| request.path)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
paths,
|
|
[
|
|
"/machine-config",
|
|
"/boot-source",
|
|
"/drives/rootfs",
|
|
"/vsock",
|
|
"/actions"
|
|
]
|
|
);
|
|
assert!(
|
|
!requests
|
|
.iter()
|
|
.any(|request| request.path == "/network-interfaces")
|
|
);
|
|
assert_eq!(requests[2].body["is_read_only"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn boot_source_uses_driver_as_init() {
|
|
let command_line = kernel_command_line("/opt/openshell/bin/openshell-driver-firecracker");
|
|
assert!(command_line.contains("root=/dev/vda rw"));
|
|
assert!(command_line.contains("init=/opt/openshell/bin/openshell-driver-firecracker"));
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_only_successful_api_statuses() {
|
|
assert!(check_http_status("/actions", b"HTTP/1.1 204 No Content\r\n\r\n").is_ok());
|
|
assert!(check_http_status("/actions", b"HTTP/1.1 400 Bad\r\n\r\nnope").is_err());
|
|
}
|
|
}
|