Skip to content

Commit 7ab9a30

Browse files
committed
feat(0.2.0-alpha.1): VmSpec + snapshot restore + rate limiter plumbing
Adds the architectural spine for per-VM configuration that the Phase 1 and Phase 2 leaf modules (network, vsock, console, shutdown, jailer, firewall, metrics) plug into. - `VmSpec` — per-VM config override. Every field optional; None falls back to FirecrackerConfig workspace defaults. Includes `kernel`, `rootfs`, `vcpu_count`, `mem_size_mib`, `boot_args`, `rootfs_read_only`, `rootfs_rate_limit`, `network_interfaces`, `restore_from`, `track_dirty_pages`. - `RateLimiter` + `TokenBucket` — FC's rate limit shape, applicable to drives and NICs. - `NetworkInterface` — iface_id, host_dev_name, optional guest_mac, rx/tx rate limiters. - `SnapshotRef` — snapshot ref with `resume_immediately` and `network_overrides` (for FC 1.10+ warm-pool handoff). - `VmProvider::create_vm_with_spec(&str, &VmSpec)` — new method, default impl delegates to `create_vm` (back-compat for simple providers). - `VmProvider::rename_vm(&str, &str)` — new method, default returns Unsupported. Reserved for warm-pool providers. - New `VmRuntimeError::SnapshotNotFound { vm_id, snapshot_id }`. - `configure_vm` now takes `&VmSpec`, applies overrides where present. - `put_network_interface` — wires `network_interfaces` from spec. - `load_snapshot` — implements `PUT /snapshot/load` with optional `network_interfaces` override (FC 1.10+ semantic; older FC rejects the field with a 400 — caller's responsibility to match versions). - `create_vm_inner` — unified cold-boot + restore path. Returns VmStatus::Running for restored+resume, Stopped for restored-paused, Created for cold boot. - `ensure_prereqs` validates spec-resolved kernel/rootfs paths; skips both checks when restoring from snapshot (snapshot encodes its own boot source). - `rate_limiter_to_json` / `token_bucket_to_json` — FC JSON shape with `refill_time` translation from caller's `refill_time_ms`. - 5 tests total. New: 4 covering rate limiter / token bucket JSON shape. - All clippy + fmt + docs CI gates green. - No new external deps. Bumps to `0.2.0-alpha.1`. The `create_vm` and `create_vm_with_spec` boundary keeps `0.1.x` consumer compat: existing callers passing only a vm_id continue to work.
1 parent cebfb7d commit 7ab9a30

6 files changed

Lines changed: 363 additions & 48 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "microvm-runtime"
3-
version = "0.1.0-alpha.1"
3+
version = "0.2.0-alpha.1"
44
edition = "2024"
55
rust-version = "1.91"
66
description = "Firecracker microVM driver for decentralized Tangle operators — pure-Rust primitive, no service, no auth, no business logic."

src/adapters/firecracker.rs

Lines changed: 233 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
1010

1111
use crate::{
1212
error::{VmRuntimeError, VmRuntimeResult},
13-
model::{VmStatus, VmView},
13+
model::{NetworkInterface, RateLimiter, SnapshotRef, TokenBucket, VmSpec, VmStatus, VmView},
1414
provider::{VmProvider, VmQuery},
1515
};
1616

@@ -186,24 +186,31 @@ impl FirecrackerVmProvider {
186186
.collect()
187187
}
188188

189-
fn ensure_prereqs(&self) -> VmRuntimeResult<()> {
189+
fn ensure_prereqs(&self, spec: &VmSpec) -> VmRuntimeResult<()> {
190190
if !self.config.binary_path.exists() {
191191
return Err(VmRuntimeError::Unsupported(format!(
192192
"firecracker binary not found: {}",
193193
self.config.binary_path.display()
194194
)));
195195
}
196-
if !self.config.kernel_path.exists() {
197-
return Err(VmRuntimeError::Unsupported(format!(
198-
"kernel image not found: {}",
199-
self.config.kernel_path.display()
200-
)));
201-
}
202-
if !self.config.rootfs_path.exists() {
203-
return Err(VmRuntimeError::Unsupported(format!(
204-
"rootfs image not found: {}",
205-
self.config.rootfs_path.display()
206-
)));
196+
// Kernel + rootfs checks are skipped when restoring — the snapshot encodes its own
197+
// boot source. Cold boot validates the spec-resolved paths (overrides if set, else
198+
// the workspace default).
199+
if spec.restore_from.is_none() {
200+
let kernel = spec.kernel.as_ref().unwrap_or(&self.config.kernel_path);
201+
if !kernel.exists() {
202+
return Err(VmRuntimeError::Unsupported(format!(
203+
"kernel image not found: {}",
204+
kernel.display()
205+
)));
206+
}
207+
let rootfs = spec.rootfs.as_ref().unwrap_or(&self.config.rootfs_path);
208+
if !rootfs.exists() {
209+
return Err(VmRuntimeError::Unsupported(format!(
210+
"rootfs image not found: {}",
211+
rootfs.display()
212+
)));
213+
}
207214
}
208215
fs::create_dir_all(&self.config.socket_dir).map_err(|e| {
209216
VmRuntimeError::Unsupported(format!(
@@ -281,28 +288,111 @@ impl FirecrackerVmProvider {
281288
)))
282289
}
283290

284-
fn configure_vm(&self, socket_path: &Path) -> VmRuntimeResult<()> {
291+
fn configure_vm(&self, socket_path: &Path, spec: &VmSpec) -> VmRuntimeResult<()> {
292+
let vcpu_count = spec.vcpu_count.unwrap_or(self.config.vcpu_count);
293+
let mem_size_mib = spec.mem_size_mib.unwrap_or(self.config.mem_size_mib);
294+
let track_dirty_pages = spec.track_dirty_pages.unwrap_or(true);
285295
let machine = serde_json::json!({
286-
"vcpu_count": self.config.vcpu_count,
287-
"mem_size_mib": self.config.mem_size_mib,
296+
"vcpu_count": vcpu_count,
297+
"mem_size_mib": mem_size_mib,
288298
"smt": false,
289-
"track_dirty_pages": true
299+
"track_dirty_pages": track_dirty_pages
290300
});
291301
self.firecracker_request(socket_path, "PUT", "/machine-config", Some(machine))?;
292302

303+
let kernel_path = spec.kernel.as_ref().unwrap_or(&self.config.kernel_path);
304+
let boot_args = spec.boot_args.as_deref().unwrap_or(&self.config.boot_args);
293305
let boot = serde_json::json!({
294-
"kernel_image_path": self.config.kernel_path,
295-
"boot_args": self.config.boot_args
306+
"kernel_image_path": kernel_path,
307+
"boot_args": boot_args
296308
});
297309
self.firecracker_request(socket_path, "PUT", "/boot-source", Some(boot))?;
298310

299-
let root_drive = serde_json::json!({
311+
let rootfs_path = spec.rootfs.as_ref().unwrap_or(&self.config.rootfs_path);
312+
let rootfs_read_only = spec
313+
.rootfs_read_only
314+
.unwrap_or(self.config.rootfs_read_only);
315+
let mut root_drive = serde_json::json!({
300316
"drive_id": "rootfs",
301-
"path_on_host": self.config.rootfs_path,
317+
"path_on_host": rootfs_path,
302318
"is_root_device": true,
303-
"is_read_only": self.config.rootfs_read_only
319+
"is_read_only": rootfs_read_only
304320
});
321+
if let Some(limiter) = spec.rootfs_rate_limit.as_ref() {
322+
root_drive["rate_limiter"] = rate_limiter_to_json(limiter);
323+
}
305324
self.firecracker_request(socket_path, "PUT", "/drives/rootfs", Some(root_drive))?;
325+
326+
for iface in &spec.network_interfaces {
327+
self.put_network_interface(socket_path, iface)?;
328+
}
329+
330+
Ok(())
331+
}
332+
333+
fn put_network_interface(
334+
&self,
335+
socket_path: &Path,
336+
iface: &NetworkInterface,
337+
) -> VmRuntimeResult<()> {
338+
let mut body = serde_json::json!({
339+
"iface_id": iface.iface_id,
340+
"host_dev_name": iface.host_dev_name,
341+
});
342+
if let Some(mac) = &iface.guest_mac {
343+
body["guest_mac"] = serde_json::Value::String(mac.clone());
344+
}
345+
if let Some(rx) = &iface.rx_rate_limiter {
346+
body["rx_rate_limiter"] = rate_limiter_to_json(rx);
347+
}
348+
if let Some(tx) = &iface.tx_rate_limiter {
349+
body["tx_rate_limiter"] = rate_limiter_to_json(tx);
350+
}
351+
let path = format!("/network-interfaces/{}", iface.iface_id);
352+
self.firecracker_request(socket_path, "PUT", &path, Some(body))?;
353+
Ok(())
354+
}
355+
356+
fn load_snapshot(&self, socket_path: &Path, snapshot: &SnapshotRef) -> VmRuntimeResult<()> {
357+
let source_state_dir = self.vm_state_path(&snapshot.vm_id);
358+
let snap_dir = source_state_dir.join("snapshots");
359+
let vmstate_path = snap_dir.join(format!("{}.vmstate", snapshot.snapshot_id));
360+
let mem_path = snap_dir.join(format!("{}.mem", snapshot.snapshot_id));
361+
if !vmstate_path.exists() {
362+
return Err(VmRuntimeError::SnapshotNotFound {
363+
vm_id: snapshot.vm_id.clone(),
364+
snapshot_id: snapshot.snapshot_id.clone(),
365+
});
366+
}
367+
368+
let mut body = serde_json::json!({
369+
"snapshot_path": vmstate_path,
370+
"mem_backend": {
371+
"backend_type": "File",
372+
"backend_path": mem_path,
373+
},
374+
"enable_diff_snapshots": false,
375+
"resume_vm": snapshot.resume_immediately,
376+
});
377+
if !snapshot.network_overrides.is_empty() {
378+
let overrides: Vec<_> = snapshot
379+
.network_overrides
380+
.iter()
381+
.map(|iface| {
382+
let mut entry = serde_json::json!({
383+
"iface_id": iface.iface_id,
384+
"host_dev_name": iface.host_dev_name,
385+
});
386+
if let Some(mac) = &iface.guest_mac {
387+
entry["guest_mac"] = serde_json::Value::String(mac.clone());
388+
}
389+
entry
390+
})
391+
.collect();
392+
body["network_interfaces"] = serde_json::Value::Array(overrides);
393+
}
394+
395+
self.firecracker_request(socket_path, "PUT", "/snapshot/load", Some(body))?;
306396
Ok(())
307397
}
308398

@@ -455,24 +545,8 @@ impl FirecrackerVmProvider {
455545
Ok(())
456546
}
457547

458-
fn kill_process(&self, vm_id: &str) -> VmRuntimeResult<()> {
459-
let child = self
460-
.processes
461-
.lock()
462-
.map_err(|_| VmRuntimeError::StatePoisoned)?
463-
.remove(vm_id);
464-
465-
if let Some(mut child) = child {
466-
let _ = child.kill();
467-
let _ = child.wait();
468-
}
469-
Ok(())
470-
}
471-
}
472-
473-
impl VmProvider for FirecrackerVmProvider {
474-
fn create_vm(&self, vm_id: &str) -> VmRuntimeResult<()> {
475-
self.ensure_prereqs()?;
548+
fn create_vm_inner(&self, vm_id: &str, spec: &VmSpec) -> VmRuntimeResult<()> {
549+
self.ensure_prereqs(spec)?;
476550

477551
{
478552
let state = self
@@ -494,13 +568,18 @@ impl VmProvider for FirecrackerVmProvider {
494568
})?;
495569

496570
let mut child = self.spawn_firecracker(vm_id, &socket_path)?;
497-
let create_result = (|| -> VmRuntimeResult<()> {
571+
let restoring = spec.restore_from.is_some();
572+
let configure_result = (|| -> VmRuntimeResult<()> {
498573
self.wait_for_socket_ready(&socket_path)?;
499-
self.configure_vm(&socket_path)?;
574+
if let Some(snapshot) = spec.restore_from.as_ref() {
575+
self.load_snapshot(&socket_path, snapshot)?;
576+
} else {
577+
self.configure_vm(&socket_path, spec)?;
578+
}
500579
Ok(())
501580
})();
502581

503-
if let Err(err) = create_result {
582+
if let Err(err) = configure_result {
504583
let _ = child.kill();
505584
let _ = child.wait();
506585
return Err(err);
@@ -511,13 +590,22 @@ impl VmProvider for FirecrackerVmProvider {
511590
.map_err(|_| VmRuntimeError::StatePoisoned)?
512591
.insert(vm_id.to_owned(), child);
513592

593+
// Restored VMs honour the snapshot's `resume_vm` flag — if `resume_immediately`
594+
// was set, the FC API call already transitioned the VM to Running; otherwise
595+
// it stays Paused/Stopped until an explicit start_vm.
596+
let initial_status = match (restoring, spec.restore_from.as_ref()) {
597+
(true, Some(snap)) if snap.resume_immediately => VmStatus::Running,
598+
(true, _) => VmStatus::Stopped,
599+
(false, _) => VmStatus::Created,
600+
};
601+
514602
self.state
515603
.write()
516604
.map_err(|_| VmRuntimeError::StatePoisoned)?
517605
.insert(
518606
vm_id.to_owned(),
519607
VmRecord {
520-
status: VmStatus::Created,
608+
status: initial_status,
521609
snapshots: Vec::new(),
522610
socket_path,
523611
state_dir,
@@ -527,6 +615,30 @@ impl VmProvider for FirecrackerVmProvider {
527615
Ok(())
528616
}
529617

618+
fn kill_process(&self, vm_id: &str) -> VmRuntimeResult<()> {
619+
let child = self
620+
.processes
621+
.lock()
622+
.map_err(|_| VmRuntimeError::StatePoisoned)?
623+
.remove(vm_id);
624+
625+
if let Some(mut child) = child {
626+
let _ = child.kill();
627+
let _ = child.wait();
628+
}
629+
Ok(())
630+
}
631+
}
632+
633+
impl VmProvider for FirecrackerVmProvider {
634+
fn create_vm(&self, vm_id: &str) -> VmRuntimeResult<()> {
635+
self.create_vm_inner(vm_id, &VmSpec::default())
636+
}
637+
638+
fn create_vm_with_spec(&self, vm_id: &str, spec: &VmSpec) -> VmRuntimeResult<()> {
639+
self.create_vm_inner(vm_id, spec)
640+
}
641+
530642
fn start_vm(&self, vm_id: &str) -> VmRuntimeResult<()> {
531643
let mut state = self
532644
.state
@@ -671,3 +783,80 @@ impl VmQuery for FirecrackerVmProvider {
671783
Ok(state.get(vm_id).map(|record| record.snapshots.clone()))
672784
}
673785
}
786+
787+
fn rate_limiter_to_json(limiter: &RateLimiter) -> serde_json::Value {
788+
let mut obj = serde_json::Map::new();
789+
if let Some(bw) = &limiter.bandwidth {
790+
obj.insert("bandwidth".into(), token_bucket_to_json(bw));
791+
}
792+
if let Some(ops) = &limiter.ops {
793+
obj.insert("ops".into(), token_bucket_to_json(ops));
794+
}
795+
serde_json::Value::Object(obj)
796+
}
797+
798+
fn token_bucket_to_json(bucket: &TokenBucket) -> serde_json::Value {
799+
serde_json::json!({
800+
"size": bucket.size,
801+
"one_time_burst": bucket.one_time_burst.unwrap_or(bucket.size),
802+
"refill_time": bucket.refill_time_ms,
803+
})
804+
}
805+
806+
#[cfg(test)]
807+
mod tests {
808+
use super::*;
809+
use crate::model::{RateLimiter, TokenBucket};
810+
811+
#[test]
812+
fn token_bucket_default_burst_equals_size() {
813+
let json = token_bucket_to_json(&TokenBucket {
814+
size: 1_048_576,
815+
one_time_burst: None,
816+
refill_time_ms: 1_000,
817+
});
818+
assert_eq!(json["size"], 1_048_576);
819+
assert_eq!(json["one_time_burst"], 1_048_576);
820+
assert_eq!(json["refill_time"], 1_000);
821+
}
822+
823+
#[test]
824+
fn token_bucket_explicit_burst_respected() {
825+
let json = token_bucket_to_json(&TokenBucket {
826+
size: 1_048_576,
827+
one_time_burst: Some(2_097_152),
828+
refill_time_ms: 500,
829+
});
830+
assert_eq!(json["one_time_burst"], 2_097_152);
831+
}
832+
833+
#[test]
834+
fn rate_limiter_serialises_both_buckets() {
835+
let json = rate_limiter_to_json(&RateLimiter {
836+
bandwidth: Some(TokenBucket {
837+
size: 10_000,
838+
one_time_burst: None,
839+
refill_time_ms: 100,
840+
}),
841+
ops: Some(TokenBucket {
842+
size: 50,
843+
one_time_burst: None,
844+
refill_time_ms: 100,
845+
}),
846+
});
847+
assert!(json.get("bandwidth").is_some());
848+
assert!(json.get("ops").is_some());
849+
assert_eq!(json["bandwidth"]["size"], 10_000);
850+
assert_eq!(json["ops"]["size"], 50);
851+
}
852+
853+
#[test]
854+
fn rate_limiter_empty_serialises_to_empty_object() {
855+
let json = rate_limiter_to_json(&RateLimiter {
856+
bandwidth: None,
857+
ops: None,
858+
});
859+
assert!(json.is_object());
860+
assert!(json.as_object().unwrap().is_empty());
861+
}
862+
}

src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ pub enum VmRuntimeError {
2626
#[error("snapshot '{snapshot_id}' already exists for vm '{vm_id}'")]
2727
SnapshotAlreadyExists { vm_id: String, snapshot_id: String },
2828

29+
/// Attempted to restore from a snapshot that does not exist on disk.
30+
#[error("snapshot '{snapshot_id}' not found for vm '{vm_id}'")]
31+
SnapshotNotFound { vm_id: String, snapshot_id: String },
32+
2933
/// Internal lock was poisoned by a panicking thread.
3034
#[error("provider state lock poisoned")]
3135
StatePoisoned,

src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ pub use firewall::{EgressRule, Firewall, FirewallConfig, VmEgressRules};
3434
pub use jailer::{Jailer, JailerConfig, VmJail};
3535
#[cfg(feature = "firecracker")]
3636
pub use metrics::{MetricsConfig, MetricsPoller, VmMetricsSnapshot};
37-
pub use model::{VmStatus, VmView};
37+
pub use model::{
38+
NetworkInterface, RateLimiter, SnapshotRef, TokenBucket, VmSpec, VmStatus, VmView,
39+
};
3840
#[cfg(feature = "firecracker")]
3941
pub use network::{Ipv4Net, NetworkConfig, NetworkManager, VmNetwork};
4042
pub use provider::{VmProvider, VmQuery, VmRuntime};

0 commit comments

Comments
 (0)