Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/ember-core/src/config/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ pub struct VmConfig {
pub ssh: Option<VmSshConfig>,
/// Custom boot arguments for the kernel.
pub boot_args: Option<String>,
/// Enable vsock device for host-guest communication.
pub vsock: Option<bool>,
}

/// Network configuration within a VM YAML config.
Expand Down Expand Up @@ -181,6 +183,17 @@ boot_args: "console=ttyS0 reboot=k panic=1 pci=off"
assert!(config.boot_args.is_none());
}

#[test]
fn parse_vsock_config() {
let yaml = "image: alpine:latest\nvsock: true\n";
let config: VmConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.vsock, Some(true));

let yaml = "image: alpine:latest\n";
let config: VmConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.vsock.is_none());
}

#[test]
fn parse_empty_config() {
let yaml = "---\n";
Expand Down
66 changes: 66 additions & 0 deletions crates/ember-core/src/state/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ pub struct NetworkInfo {
pub wan_iface: Option<String>,
}

/// Vsock configuration for host-guest communication.
///
/// When enabled, a virtio-vsock device is attached to the VM and a
/// Unix domain socket is created on the host for communication.
/// The guest connects via `AF_VSOCK` to CID 2 (host); host programs
/// connect to the UDS at `uds_path`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VsockInfo {
/// Path to the Unix domain socket on the host.
/// e.g., `<state_dir>/vms/<name>/vsock.sock`
pub uds_path: PathBuf,
/// Guest CID (Context Identifier). Defaults to 3.
/// CID 0 and 1 are reserved; CID 2 is the host.
pub guest_cid: u32,
}

/// SSH connection configuration for a VM.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SshConfig {
Expand Down Expand Up @@ -132,6 +148,9 @@ pub struct VmMetadata {
/// is purely informational — no cleanup or deletion constraints apply.
#[serde(default, alias = "forked_from")]
pub parent_vm: Option<String>,
/// Vsock configuration, if vsock is enabled for this VM.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vsock: Option<VsockInfo>,
}

impl VmMetadata {
Expand Down Expand Up @@ -162,6 +181,7 @@ impl VmMetadata {
key: PathBuf::new(),
},
parent_vm: None,
vsock: None,
}
}
}
Expand Down Expand Up @@ -354,6 +374,7 @@ mod tests {
created_at: "2026-01-01T00:00:00Z".to_string(),
ssh: SshConfig::default(),
parent_vm: None,
vsock: None,
}
}

Expand Down Expand Up @@ -497,6 +518,51 @@ mod tests {
assert!(json["network"].is_null());
assert!(json["pid"].is_null());
assert_eq!(json["ssh"]["user"], "root");
// vsock is None, so it should be absent from JSON (skip_serializing_if)
assert!(json.get("vsock").is_none());
}

#[test]
fn vm_with_vsock_round_trip() {
let (_dir, store) = test_store();
let mut vm = sample_vm("vsockvm");
vm.vsock = Some(VsockInfo {
uds_path: PathBuf::from("/var/lib/ember/vms/vsockvm/vsock.sock"),
guest_cid: 3,
});

save(&store, &vm).unwrap();
let loaded = load(&store, "vsockvm").unwrap();
assert_eq!(loaded, vm);

let vsock = loaded.vsock.as_ref().unwrap();
assert_eq!(
vsock.uds_path,
PathBuf::from("/var/lib/ember/vms/vsockvm/vsock.sock")
);
assert_eq!(vsock.guest_cid, 3);
}

#[test]
fn vm_without_vsock_deserializes() {
// Ensure backwards compatibility: old vm.json without vsock field
// still deserializes correctly (vsock defaults to None).
let json = r#"{
"name": "oldvm",
"id": "00000000-0000-0000-0000-000000000000",
"status": "created",
"image": "alpine:latest",
"cpus": 1,
"memory_mib": 512,
"disk_size_gib": 4,
"kernel_path": "/boot/vmlinux",
"disk_path": "pool/vms/oldvm",
"api_socket": "/tmp/fc.sock",
"created_at": "2026-01-01T00:00:00Z",
"ssh": { "user": "root", "key": "/root/.ssh/id_ed25519" }
}"#;
let vm: VmMetadata = serde_json::from_str(json).unwrap();
assert!(vm.vsock.is_none());
}

#[test]
Expand Down
17 changes: 17 additions & 0 deletions crates/ember-linux/src/firecracker/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ pub enum VmState {
Resumed,
}

/// Vsock device attached to the VM.
///
/// Firecracker creates a Unix domain socket at `uds_path` on the host.
/// Guest programs connect via `AF_VSOCK` to CID 2 (host); host programs
/// connect to the UDS and specify the guest CID + port.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vsock {
pub vsock_id: String,
pub guest_cid: u32,
pub uds_path: String,
}

/// Error body returned by Firecracker on failure.
#[derive(Debug, Deserialize)]
struct FaultResponse {
Expand Down Expand Up @@ -130,6 +142,11 @@ impl FirecrackerClient {
self.put(&path, iface).await
}

/// `PUT /vsock` — attach a vsock device to the VM.
pub async fn put_vsock(&self, vsock: &Vsock) -> anyhow::Result<()> {
self.put("/vsock", vsock).await
}

/// `PUT /actions` — start the VM, send Ctrl+Alt+Del, etc.
pub async fn put_action(&self, action: &InstanceAction) -> anyhow::Result<()> {
self.put("/actions", action).await
Expand Down
45 changes: 44 additions & 1 deletion crates/ember-linux/src/firecracker/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use std::path::PathBuf;

use crate::firecracker::api::{
BootSource, Drive, FirecrackerClient, InstanceAction, MachineConfig, NetworkInterface,
BootSource, Drive, FirecrackerClient, InstanceAction, MachineConfig, NetworkInterface, Vsock,
};

/// Default boot arguments for the guest kernel.
Expand Down Expand Up @@ -52,6 +52,11 @@ pub struct VmConfig {
pub rootfs_path: PathBuf,
/// Optional network interface configuration.
pub network: Option<VmNetworkConfig>,
/// Optional vsock device. When set, configures a virtio-vsock device
/// with the given UDS path and guest CID.
pub vsock_uds_path: Option<String>,
/// Guest CID for vsock (default: 3).
pub vsock_guest_cid: u32,
}

impl VmConfig {
Expand All @@ -72,6 +77,8 @@ impl VmConfig {
boot_args: DEFAULT_BOOT_ARGS.to_string(),
rootfs_path: rootfs_path.into(),
network: None,
vsock_uds_path: None,
vsock_guest_cid: 3,
}
}

Expand All @@ -87,6 +94,13 @@ impl VmConfig {
self
}

/// Enable vsock device with the given UDS path and guest CID.
pub fn with_vsock(mut self, uds_path: impl Into<String>, guest_cid: u32) -> Self {
self.vsock_uds_path = Some(uds_path.into());
self.vsock_guest_cid = guest_cid;
self
}

/// Build the full boot_args string.
///
/// If networking is configured, appends the kernel `ip=` parameter
Expand Down Expand Up @@ -172,6 +186,17 @@ impl VmConfig {
.await?;
}

// 5. Vsock device (if configured)
if let Some(ref uds_path) = self.vsock_uds_path {
client
.put_vsock(&Vsock {
vsock_id: "vsock0".to_string(),
guest_cid: self.vsock_guest_cid,
uds_path: uds_path.clone(),
})
.await?;
}

Ok(())
}

Expand Down Expand Up @@ -257,4 +282,22 @@ mod tests {
"console=ttyS0 panic=1 ip=10.100.0.6::10.100.0.5:255.255.255.252:customvm:eth0:off:1.1.1.1"
);
}

#[test]
fn with_vsock() {
let config = VmConfig::new(2, 512, "/boot/vmlinux", "/dev/zvol/pool/vms/test")
.with_vsock("/var/lib/ember/vms/test/vsock.sock", 3);
assert_eq!(
config.vsock_uds_path.as_deref(),
Some("/var/lib/ember/vms/test/vsock.sock")
);
assert_eq!(config.vsock_guest_cid, 3);
}

#[test]
fn default_no_vsock() {
let config = VmConfig::new(1, 128, "/boot/vmlinux", "/dev/zvol/pool/vms/test");
assert!(config.vsock_uds_path.is_none());
assert_eq!(config.vsock_guest_cid, 3);
}
}
10 changes: 9 additions & 1 deletion crates/ember-linux/src/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ fn configure_and_boot(
if let Some(ref boot_args) = vm.boot_args {
vm_config = vm_config.with_boot_args(boot_args);
}
let vm_config = vm_config.with_network(firecracker::config::VmNetworkConfig {
let mut vm_config = vm_config.with_network(firecracker::config::VmNetworkConfig {
tap_device: net_info.tap_device.clone(),
guest_ip: net_info.guest_ip.clone(),
host_ip: net_info.host_ip.clone(),
Expand All @@ -195,6 +195,14 @@ fn configure_and_boot(
dns_servers,
});

// Configure vsock device if enabled.
if let Some(ref vsock) = vm.vsock {
vm_config = vm_config.with_vsock(
vsock.uds_path.to_string_lossy().to_string(),
vsock.guest_cid,
);
}

// Run the async API calls in a blocking runtime.
let rt = tokio::runtime::Runtime::new()
.map_err(|e| Error::Firecracker(format!("failed to create tokio runtime: {e}")))?;
Expand Down
5 changes: 5 additions & 0 deletions crates/ember-macos/src/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ impl VmBackend for MacosVm {
.arg("--ready-fd")
.arg(write_fd_num.to_string());

// Pass vsock UDS path if vsock is enabled.
if let Some(ref vsock) = vm.vsock {
cmd.arg("--vsock-path").arg(&vsock.uds_path);
}

// Redirect stdout/stderr to the serial log / null so the helper
// doesn't interfere with ember's terminal output.
cmd.stdin(Stdio::null());
Expand Down
Loading
Loading