Skip to content
Open
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
/target
/crates/*/target

# Staged emberd binary for image builds (built with `make emberd-image`)
/images/emberd

# Coverage
.coverage

# Swift build artifacts
/ember-vz/.build
/ember-vz/.swiftpm
Expand Down
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
4 changes: 4 additions & 0 deletions crates/ember-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ pub enum Error {
#[error("image: {0}")]
Image(String),

/// Vsock CID allocation error.
#[error("vsock: {0}")]
Vsock(String),

/// SSH connection or command error.
#[error("ssh: {0}")]
Ssh(String),
Expand Down
1 change: 1 addition & 0 deletions crates/ember-core/src/state/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod store;
pub mod vm;
pub mod vsock;
59 changes: 59 additions & 0 deletions crates/ember-core/src/state/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ use crate::error::{Error, Result};
/// ├── vms/
/// │ └── <vm-name>/
/// │ ├── vm.json
/// │ ├── vsock.sock
/// │ ├── firecracker.sock
/// │ ├── firecracker.log
/// │ ├── console.log
/// │ └── firecracker.pid
/// ├── vsock/
/// │ └── cids.json
/// └── network/
/// └── allocations.json
/// ```
Expand Down Expand Up @@ -65,6 +68,7 @@ impl StateStore {
self.kernel_dir(),
self.root.join("images"),
self.root.join("vms"),
self.root.join("vsock"),
self.root.join("network"),
];
for dir in &dirs {
Expand Down Expand Up @@ -101,6 +105,11 @@ impl StateStore {
self.root.join("network").join("allocations.json")
}

/// Path to vsock CID allocation tracking.
pub fn vsock_allocations_path(&self) -> PathBuf {
self.root.join("vsock").join("cids.json")
}

/// Path to the global config file.
pub fn config_path(&self) -> PathBuf {
self.root.join("config.json")
Expand Down Expand Up @@ -149,8 +158,53 @@ impl StateStore {
})?;
}

let _lock = FileLock::exclusive(path)?;
self.write_inner(path, data)
}

/// Atomically read, modify, and write a JSON file under a single
/// exclusive lock.
///
/// If the file doesn't exist, `default` is used as the initial value.
/// The closure receives a mutable reference and returns a result.
/// The modified value is written back while the lock is still held,
/// preventing TOCTOU races between concurrent processes.
pub fn update<T, R, F>(&self, path: &Path, default: T, f: F) -> Result<R>
where
T: Serialize + DeserializeOwned,
F: FnOnce(&mut T) -> Result<R>,
{
// Ensure parent directory exists.
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::Io {
path: parent.to_path_buf(),
source: e,
})?;
}

// Hold exclusive lock for the entire read-modify-write cycle.
let _lock = FileLock::exclusive(path)?;

let mut value: T = match fs::read_to_string(path) {
Ok(contents) => serde_json::from_str(&contents)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => default,
Err(e) => {
return Err(Error::Io {
path: path.to_path_buf(),
source: e,
})
}
};

let result = f(&mut value)?;
self.write_inner(path, &value)?;
Ok(result)
}

/// Write data to a JSON file atomically (temp file + rename).
///
/// Caller must hold the appropriate lock.
fn write_inner<T: Serialize>(&self, path: &Path, data: &T) -> Result<()> {
// Write to temp file in the same directory (same filesystem for rename).
let tmp_path = tmp_path_for(path);
let json = serde_json::to_string_pretty(data)?;
Expand Down Expand Up @@ -354,6 +408,7 @@ mod tests {
assert!(root.join("kernels").is_dir());
assert!(root.join("images").is_dir());
assert!(root.join("vms").is_dir());
assert!(root.join("vsock").is_dir());
assert!(root.join("network").is_dir());
}

Expand Down Expand Up @@ -422,6 +477,10 @@ mod tests {
store.network_allocations_path(),
PathBuf::from("/var/lib/ember/network/allocations.json")
);
assert_eq!(
store.vsock_allocations_path(),
PathBuf::from("/var/lib/ember/vsock/cids.json")
);
assert_eq!(
store.config_path(),
PathBuf::from("/var/lib/ember/config.json")
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
Loading
Loading