Skip to content

Commit 7f12f9b

Browse files
committed
feat: implement cold boot and snapshot-based warm start in node daemon
1 parent d4b5ce6 commit 7f12f9b

9 files changed

Lines changed: 945 additions & 74 deletions

File tree

Cargo.lock

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/sandchest-node/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ tracing = "0.1"
1515
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
1616
tokio-stream = "0.1"
1717
libc = "0.2"
18+
hyper = { version = "1", features = ["client", "http1"] }
19+
hyper-util = { version = "0.1", features = ["client-legacy", "tokio"] }
20+
http-body-util = "0.1"
21+
tower = "0.5"
22+
hyper-unix-socket = "0.3"
1823

1924
[build-dependencies]
2025
tonic-build = "0.12"

crates/sandchest-node/build.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
fn main() -> Result<(), Box<dyn std::error::Error>> {
2+
// Node proto — server stubs (control plane connects to us)
23
tonic_build::configure()
34
.build_server(true)
45
.build_client(false)
56
.compile_protos(
67
&["sandchest/node/v1/node.proto"],
78
&["../../packages/contract/proto"],
89
)?;
10+
11+
// Agent proto — client stubs (we connect to guest agents)
12+
tonic_build::configure()
13+
.build_server(false)
14+
.build_client(true)
15+
.compile_protos(
16+
&["sandchest/agent/v1/agent.proto"],
17+
&["../../packages/contract/proto"],
18+
)?;
19+
920
Ok(())
1021
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
use std::time::Duration;
2+
3+
use tracing::{info, warn};
4+
5+
pub mod agent_proto {
6+
tonic::include_proto!("sandchest.agent.v1");
7+
}
8+
9+
/// Client for communicating with the guest agent inside a Firecracker microVM.
10+
///
11+
/// In production, connects via vsock. In dev mode (TCP), connects to localhost.
12+
pub struct AgentClient {
13+
endpoint: String,
14+
}
15+
16+
impl AgentClient {
17+
/// Create a new agent client.
18+
///
19+
/// `endpoint` is a gRPC endpoint URI, e.g. `http://127.0.0.1:8052` for TCP dev mode.
20+
/// Vsock connections will be added when vsock support is wired up.
21+
pub fn new(endpoint: &str) -> Self {
22+
Self {
23+
endpoint: endpoint.to_string(),
24+
}
25+
}
26+
27+
/// Construct the TCP dev-mode endpoint from a vsock path.
28+
///
29+
/// In dev mode, the guest agent listens on TCP instead of vsock. We connect
30+
/// to localhost on the agent's dev port (default 8052).
31+
pub fn dev_endpoint() -> String {
32+
let port = std::env::var("SANDCHEST_AGENT_DEV_PORT")
33+
.ok()
34+
.and_then(|s| s.parse::<u16>().ok())
35+
.unwrap_or(8052);
36+
format!("http://127.0.0.1:{}", port)
37+
}
38+
39+
/// Poll the guest agent's Health RPC until it reports ready.
40+
///
41+
/// Retries every 100ms up to `timeout`. Used after VM boot to confirm
42+
/// the guest agent is running and accepting requests.
43+
pub async fn wait_for_health(
44+
endpoint: &str,
45+
timeout: Duration,
46+
) -> Result<(), AgentClientError> {
47+
let start = tokio::time::Instant::now();
48+
let interval = Duration::from_millis(100);
49+
50+
info!(endpoint = %endpoint, timeout_ms = timeout.as_millis(), "waiting for guest agent health");
51+
52+
while start.elapsed() < timeout {
53+
match Self::check_health_once(endpoint).await {
54+
Ok(true) => {
55+
let elapsed = start.elapsed().as_millis();
56+
info!(endpoint = %endpoint, elapsed_ms = elapsed, "guest agent is healthy");
57+
return Ok(());
58+
}
59+
Ok(false) => {
60+
warn!(endpoint = %endpoint, "agent responded but not ready");
61+
}
62+
Err(_) => {
63+
// Connection refused or timeout — agent not ready yet
64+
}
65+
}
66+
tokio::time::sleep(interval).await;
67+
}
68+
69+
Err(AgentClientError::HealthTimeout(format!(
70+
"guest agent at {} did not become healthy within {:?}",
71+
endpoint, timeout
72+
)))
73+
}
74+
75+
async fn check_health_once(endpoint: &str) -> Result<bool, AgentClientError> {
76+
let channel = tonic::transport::Channel::from_shared(endpoint.to_string())
77+
.map_err(|e| AgentClientError::Connection(format!("invalid endpoint: {}", e)))?
78+
.connect_timeout(Duration::from_secs(2))
79+
.timeout(Duration::from_secs(5))
80+
.connect()
81+
.await
82+
.map_err(|e| AgentClientError::Connection(format!("connect failed: {}", e)))?;
83+
84+
let mut client = agent_proto::guest_agent_client::GuestAgentClient::new(channel);
85+
let response = client.health(()).await.map_err(|e| {
86+
AgentClientError::Rpc(format!("health RPC failed: {}", e))
87+
})?;
88+
89+
Ok(response.into_inner().ready)
90+
}
91+
92+
/// Connect and return a reusable gRPC client handle.
93+
pub async fn connect(
94+
&self,
95+
) -> Result<agent_proto::guest_agent_client::GuestAgentClient<tonic::transport::Channel>, AgentClientError>
96+
{
97+
let channel = tonic::transport::Channel::from_shared(self.endpoint.clone())
98+
.map_err(|e| AgentClientError::Connection(format!("invalid endpoint: {}", e)))?
99+
.connect_timeout(Duration::from_secs(5))
100+
.timeout(Duration::from_secs(300))
101+
.connect()
102+
.await
103+
.map_err(|e| {
104+
AgentClientError::Connection(format!(
105+
"failed to connect to agent at {}: {}",
106+
self.endpoint, e
107+
))
108+
})?;
109+
110+
Ok(agent_proto::guest_agent_client::GuestAgentClient::new(channel))
111+
}
112+
}
113+
114+
#[derive(Debug)]
115+
pub enum AgentClientError {
116+
HealthTimeout(String),
117+
Connection(String),
118+
Rpc(String),
119+
}
120+
121+
impl std::fmt::Display for AgentClientError {
122+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123+
match self {
124+
AgentClientError::HealthTimeout(msg) => write!(f, "health timeout: {}", msg),
125+
AgentClientError::Connection(msg) => write!(f, "connection error: {}", msg),
126+
AgentClientError::Rpc(msg) => write!(f, "RPC error: {}", msg),
127+
}
128+
}
129+
}
130+
131+
impl std::error::Error for AgentClientError {}

crates/sandchest-node/src/disk.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
use std::path::Path;
2+
3+
use tracing::{info, warn};
4+
5+
/// Clone a base ext4 image into a per-sandbox ext4 file using reflink copy.
6+
///
7+
/// On XFS/btrfs this is an instant CoW clone. On other filesystems it falls
8+
/// back to a regular copy. The cloned file is passed directly to Firecracker
9+
/// as the drive's `path_on_host`.
10+
pub async fn clone_disk(src_ext4: &str, sandbox_id: &str, data_dir: &str) -> Result<String, DiskError> {
11+
let sandbox_dir = format!("{}/sandboxes/{}", data_dir, sandbox_id);
12+
let dest = format!("{}/rootfs.ext4", sandbox_dir);
13+
14+
// Create sandbox directory
15+
tokio::fs::create_dir_all(&sandbox_dir).await.map_err(|e| {
16+
DiskError::Io(format!("failed to create sandbox directory {}: {}", sandbox_dir, e))
17+
})?;
18+
19+
if !Path::new(src_ext4).exists() {
20+
return Err(DiskError::SourceNotFound(src_ext4.to_string()));
21+
}
22+
23+
info!(
24+
src = %src_ext4,
25+
dest = %dest,
26+
sandbox_id = %sandbox_id,
27+
"cloning disk with reflink"
28+
);
29+
30+
let src = src_ext4.to_string();
31+
let dst = dest.clone();
32+
33+
// Use --reflink=auto on Linux for instant CoW clones on XFS/btrfs.
34+
// On macOS/other platforms, fall back to regular cp.
35+
let output = if cfg!(target_os = "linux") {
36+
tokio::process::Command::new("cp")
37+
.arg("--reflink=auto")
38+
.arg(&src)
39+
.arg(&dst)
40+
.output()
41+
.await
42+
.map_err(|e| DiskError::Io(format!("failed to run cp: {}", e)))?
43+
} else {
44+
tokio::process::Command::new("cp")
45+
.arg(&src)
46+
.arg(&dst)
47+
.output()
48+
.await
49+
.map_err(|e| DiskError::Io(format!("failed to run cp: {}", e)))?
50+
};
51+
52+
if !output.status.success() {
53+
let stderr = String::from_utf8_lossy(&output.stderr);
54+
return Err(DiskError::Io(format!("cp failed: {}", stderr)));
55+
}
56+
57+
info!(sandbox_id = %sandbox_id, dest = %dest, "disk clone complete");
58+
Ok(dest)
59+
}
60+
61+
/// Remove a sandbox's data directory and its contents.
62+
pub async fn cleanup_disk(sandbox_id: &str, data_dir: &str) -> Result<(), DiskError> {
63+
let sandbox_dir = format!("{}/sandboxes/{}", data_dir, sandbox_id);
64+
65+
if !Path::new(&sandbox_dir).exists() {
66+
warn!(sandbox_id = %sandbox_id, "sandbox directory already absent");
67+
return Ok(());
68+
}
69+
70+
tokio::fs::remove_dir_all(&sandbox_dir).await.map_err(|e| {
71+
DiskError::Io(format!("failed to remove {}: {}", sandbox_dir, e))
72+
})?;
73+
74+
info!(sandbox_id = %sandbox_id, "disk cleanup complete");
75+
Ok(())
76+
}
77+
78+
#[derive(Debug)]
79+
pub enum DiskError {
80+
SourceNotFound(String),
81+
Io(String),
82+
}
83+
84+
impl std::fmt::Display for DiskError {
85+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86+
match self {
87+
DiskError::SourceNotFound(path) => write!(f, "source image not found: {}", path),
88+
DiskError::Io(msg) => write!(f, "disk I/O error: {}", msg),
89+
}
90+
}
91+
}
92+
93+
impl std::error::Error for DiskError {}
94+
95+
#[cfg(test)]
96+
mod tests {
97+
use super::*;
98+
99+
#[tokio::test]
100+
async fn clone_disk_fails_for_missing_source() {
101+
let result = clone_disk("/nonexistent/rootfs.ext4", "sb_test", "/tmp/sandchest-test").await;
102+
assert!(result.is_err());
103+
let err = result.unwrap_err();
104+
assert!(matches!(err, DiskError::SourceNotFound(_)));
105+
}
106+
107+
#[tokio::test]
108+
async fn clone_disk_creates_sandbox_dir_and_copies() {
109+
let tmp = std::env::temp_dir().join("sandchest-disk-test");
110+
let _ = std::fs::remove_dir_all(&tmp);
111+
112+
// Create a small source file
113+
let src_dir = tmp.join("images");
114+
std::fs::create_dir_all(&src_dir).unwrap();
115+
let src_file = src_dir.join("rootfs.ext4");
116+
std::fs::write(&src_file, b"fake-ext4-data").unwrap();
117+
118+
let data_dir = tmp.to_str().unwrap();
119+
let result = clone_disk(src_file.to_str().unwrap(), "sb_clone_test", data_dir).await;
120+
assert!(result.is_ok());
121+
122+
let dest = result.unwrap();
123+
assert!(Path::new(&dest).exists());
124+
let content = std::fs::read(&dest).unwrap();
125+
assert_eq!(content, b"fake-ext4-data");
126+
127+
// Cleanup
128+
let _ = std::fs::remove_dir_all(&tmp);
129+
}
130+
131+
#[tokio::test]
132+
async fn cleanup_disk_removes_directory() {
133+
let tmp = std::env::temp_dir().join("sandchest-cleanup-test");
134+
let sandbox_dir = tmp.join("sandboxes").join("sb_cleanup");
135+
std::fs::create_dir_all(&sandbox_dir).unwrap();
136+
std::fs::write(sandbox_dir.join("rootfs.ext4"), b"data").unwrap();
137+
138+
let data_dir = tmp.to_str().unwrap();
139+
let result = cleanup_disk("sb_cleanup", data_dir).await;
140+
assert!(result.is_ok());
141+
assert!(!sandbox_dir.exists());
142+
143+
let _ = std::fs::remove_dir_all(&tmp);
144+
}
145+
146+
#[tokio::test]
147+
async fn cleanup_disk_is_idempotent() {
148+
let result = cleanup_disk("sb_nonexistent", "/tmp/sandchest-idempotent-test").await;
149+
assert!(result.is_ok());
150+
}
151+
}

crates/sandchest-node/src/firecracker.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,23 @@ pub struct FirecrackerVm {
1616
}
1717

1818
impl FirecrackerVm {
19+
/// Construct a FirecrackerVm from pre-existing parts (used for snapshot warm start).
20+
pub fn from_parts(
21+
sandbox_id: String,
22+
api_socket_path: String,
23+
vsock_path: String,
24+
data_dir: String,
25+
child: Child,
26+
) -> Self {
27+
Self {
28+
sandbox_id,
29+
api_socket_path,
30+
vsock_path,
31+
data_dir,
32+
child,
33+
}
34+
}
35+
1936
/// Start a new Firecracker VM with the given configuration.
2037
///
2138
/// 1. Creates the sandbox data directory

0 commit comments

Comments
 (0)