Skip to content

Commit b60c6ac

Browse files
committed
feat(unikraft): add unikraft_sandbox backend (third backend alongside wasm/js)
Adds src/unikraft_sandbox as a workspace member: a Hyperlight+Unikraft micro-VM backend, peer to wasm_sandbox/javascript_sandbox. Runs code via the argv model (kernel + initrd, '-c <code>', call_run + console capture) against the hyperlight-unikraft host crate. Includes run/polyglot/multi/hostfs examples that skip when the sibling image checkout is absent. Pulls the danbugs hyperlight 0.15.0 host, which coexists with the 0.14.0 wasm/js stack (metrics bumped to 0.24.6). Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent aa1a9cc commit b60c6ac

8 files changed

Lines changed: 1205 additions & 25 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ members = [
33
"src/hyperlight_sandbox",
44
"src/javascript_sandbox",
55
"src/wasm_sandbox",
6+
"src/unikraft_sandbox",
67
"src/sdk/python/pyo3_common",
78
"src/sdk/python/wasm_backend",
89
"src/sdk/python/hyperlight_js_backend",
@@ -20,6 +21,7 @@ license = "Apache-2.0"
2021
hyperlight-sandbox = { path = "src/hyperlight_sandbox" }
2122
hyperlight-javascript-sandbox = { path = "src/javascript_sandbox" }
2223
hyperlight-wasm-sandbox = { path = "src/wasm_sandbox" }
24+
hyperlight-unikraft-sandbox = { path = "src/unikraft_sandbox" }
2325
hyperlight-sandbox-pyo3-common = { path = "src/sdk/python/pyo3_common" }
2426
hyperlight-common = { version = "0.14.0", default-features = false }
2527
hyperlight-component-macro = { version = "0.14.0" }

src/unikraft_sandbox/Cargo.toml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[package]
2+
name = "hyperlight-unikraft-sandbox"
3+
version.workspace = true
4+
edition = "2021"
5+
rust-version.workspace = true
6+
license.workspace = true
7+
description = "hyperlight-sandbox backend that runs guests inside Unikraft micro-VMs on Hyperlight."
8+
9+
[dependencies]
10+
# Host-agnostic core of hyperlight-sandbox (the `Guest`/`GuestSandbox` traits + the shared
11+
# capability model). A direct path dep (not workspace inheritance) so `default-features =
12+
# false` actually takes effect — it drops the `wasi-http` feature (and the wasmtime stack);
13+
# the Unikraft backend never does guest-side host HTTP, so consumers like workerd stay
14+
# wasmtime-free.
15+
hyperlight-sandbox = { path = "../hyperlight_sandbox", default-features = false }
16+
17+
# The embedded Hyperlight + Unikraft host library (crate `hyperlight-unikraft-host`, lib
18+
# name `hyperlight_unikraft`). Lives in the hyperlight-unikraft repo (atop the
19+
# danbugs/hyperlight fork) and brings the snapshot-file + warm-start support this backend
20+
# relies on.
21+
hyperlight-unikraft-host = { git = "https://github.com/simongdavies/hyperlight-unikraft", branch = "feat/hyperlight-sandbox-backend" }
22+
23+
anyhow = "1"
24+
serde = { version = "1", features = ["derive"] }
25+
serde_json = "1"
26+
url = "2"
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//! hostfs demo: a guest writes files into a host directory and we read them back on the
2+
//! **host** — transparent POSIX I/O across the VM boundary via Unikraft's `lib/hostfs`.
3+
//!
4+
//! Requires the `hostfs-posix-py` image. That is a newer `plat-hyperlight` kernel which
5+
//! expects the initrd mapped just below 4 GiB, hence [`Unikraft::initrd_base`]:
6+
//! ```text
7+
//! cd hyperlight-unikraft/examples/hostfs-posix-py && just build && just rootfs
8+
//! cd ../../sandbox && cargo run --example hostfs
9+
//! ```
10+
11+
use anyhow::Result;
12+
use hyperlight_sandbox::{SandboxBuilder, ToolRegistry};
13+
use hyperlight_unikraft_sandbox::Unikraft;
14+
use std::path::PathBuf;
15+
16+
/// Newer `plat-hyperlight` kernels (e.g. hostfs-posix-py) expect the initrd just below 4 GiB.
17+
const HOSTFS_INITRD_BASE: u64 = 0xFEF0_0000;
18+
19+
/// Guest Python (single line for argv) that writes a report + a log line under /host using
20+
/// only the stdlib — no SDK, no JSON, no hcall. Files close explicitly so writes flush.
21+
const GUEST_CODE: &str = "import os; os.makedirs('/host/logs', exist_ok=True); \
22+
f=open('/host/report.txt','w'); \
23+
f.write('generated inside a Unikraft micro-VM\\nsum(0..100)=%d\\n' % sum(range(101))); \
24+
f.close(); \
25+
g=open('/host/logs/run.log','a'); g.write('ran once\\n'); g.close(); \
26+
print('guest: wrote /host/report.txt + /host/logs/run.log')";
27+
28+
fn main() -> Result<()> {
29+
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
30+
.join("../../../hyperlight-unikraft/examples")
31+
.join("hostfs-posix-py");
32+
let kernel = root.join(".unikraft/build/hostfs-posix-py-hyperlight_hyperlight-x86_64");
33+
let initrd = root.join("hostfs-posix-py-initrd.cpio");
34+
if !kernel.exists() || !initrd.exists() {
35+
eprintln!(
36+
"SKIP — build the hostfs image first: \
37+
(cd examples/hostfs-posix-py && just build && just rootfs)"
38+
);
39+
return Ok(());
40+
}
41+
42+
// A fresh host directory the guest will see (read/write) as /host.
43+
let work = std::env::temp_dir().join(format!("hl-hostfs-demo-{}", std::process::id()));
44+
let _ = std::fs::remove_dir_all(&work);
45+
std::fs::create_dir_all(&work)?;
46+
println!("host dir {} -> /host (guest)\n", work.display());
47+
48+
let mut sandbox = SandboxBuilder::new()
49+
.with_tools(ToolRegistry::new())
50+
.guest(
51+
Unikraft::new(kernel)
52+
.initrd(initrd)
53+
.initrd_base(HOSTFS_INITRD_BASE)
54+
.mount(&work, "/host"),
55+
)
56+
.build()?;
57+
58+
let out = sandbox.run(GUEST_CODE)?;
59+
print!("{}", out.stdout);
60+
eprint!("{}", out.stderr);
61+
62+
// Prove it on the HOST side: the files the guest wrote are really here.
63+
println!("\n--- host sees ---");
64+
println!(
65+
"report.txt:\n{}",
66+
std::fs::read_to_string(work.join("report.txt"))?
67+
);
68+
print!(
69+
"logs/run.log:\n{}",
70+
std::fs::read_to_string(work.join("logs/run.log"))?
71+
);
72+
73+
Ok(())
74+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//! Multi-snippet demo: run several *different* Python workloads through one sandbox, each
2+
//! immediately repeated to show the execution model.
3+
//!
4+
//! Because the code is baked into the boot argv, a *new* code string evolves a fresh VM
5+
//! (cold), while an immediate *repeat* of the same code is a warm `restore` + `call_run`.
6+
//! Each line prints `cold=<ms> warm=<ms>` followed by the snippet's captured stdout.
7+
//!
8+
//! Run (after `just build && just rootfs` in `examples/python`):
9+
//! ```text
10+
//! cargo run --example multi
11+
//! ```
12+
13+
use anyhow::Result;
14+
use hyperlight_sandbox::{SandboxBuilder, ToolRegistry};
15+
use hyperlight_unikraft_sandbox::Unikraft;
16+
use std::path::PathBuf;
17+
18+
fn main() -> Result<()> {
19+
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
20+
.join("../../../hyperlight-unikraft/examples")
21+
.join("python");
22+
let kernel = root.join(".unikraft/build/python-hyperlight_hyperlight-x86_64");
23+
let initrd = root.join("initrd.cpio");
24+
25+
if !kernel.exists() || !initrd.exists() {
26+
eprintln!(
27+
"SKIP — build the python image first: (cd examples/python && just build && just rootfs)"
28+
);
29+
return Ok(());
30+
}
31+
32+
let mut sandbox = SandboxBuilder::new()
33+
.with_tools(ToolRegistry::new())
34+
.guest(Unikraft::new(kernel).initrd(initrd))
35+
.build()?;
36+
37+
// Three distinct real workloads. Each is run twice: the first call evolves a fresh VM,
38+
// the second of the same code is a warm restore.
39+
let snippets = [
40+
(
41+
"primes",
42+
"print('primes<30:', [n for n in range(2, 30) if all(n % d for d in range(2, n))])",
43+
),
44+
(
45+
"json",
46+
"import json; print('json:', json.dumps({'a': 1, 'b': [2, 3]}, separators=(',', ':')))",
47+
),
48+
(
49+
"math",
50+
"import math; print('pi:', round(math.pi, 6), '10! =', math.factorial(10))",
51+
),
52+
];
53+
54+
for (label, code) in snippets {
55+
let t = std::time::Instant::now();
56+
let out = sandbox.run(code)?; // cold: distinct code -> fresh evolve
57+
let cold_ms = t.elapsed().as_millis();
58+
59+
let t = std::time::Instant::now();
60+
let _ = sandbox.run(code)?; // warm: same code -> restore + call_run
61+
let warm_ms = t.elapsed().as_millis();
62+
63+
eprintln!(
64+
"[{label}] cold={cold_ms}ms warm={warm_ms}ms exit={}",
65+
out.exit_code
66+
);
67+
print!(" {}", out.stdout);
68+
}
69+
70+
Ok(())
71+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//! Polyglot demo: run **Python** and **shell** snippets through the *same*
2+
//! `hyperlight-unikraft` backend.
3+
//!
4+
//! The backend is guest-agnostic: only the kernel + initrd image changes, the host code is
5+
//! identical. Each call is `interpreter -c <code>` (Python: `python3 -c`, shell: `sh -c`)
6+
//! and the guest console is captured as `stdout`.
7+
//!
8+
//! Run (after `just build && just rootfs` in the two example dirs):
9+
//! ```text
10+
//! cargo run --example polyglot
11+
//! ```
12+
//! Images that are not built are skipped with a note.
13+
14+
use anyhow::Result;
15+
use hyperlight_sandbox::{SandboxBuilder, ToolRegistry};
16+
use hyperlight_unikraft_sandbox::Unikraft;
17+
use std::path::PathBuf;
18+
19+
/// Resolve a built example image (kernel + initrd) relative to this crate, so the demo runs
20+
/// regardless of the current working directory.
21+
fn image(example: &str, kernel_name: &str) -> (PathBuf, PathBuf) {
22+
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
23+
.join("../../../hyperlight-unikraft/examples")
24+
.join(example);
25+
(
26+
root.join(".unikraft/build").join(kernel_name),
27+
root.join("initrd.cpio"),
28+
)
29+
}
30+
31+
/// Boot `code` on the given image and print the captured stdout. Skips cleanly if the image
32+
/// has not been built yet.
33+
fn demo(label: &str, kernel: PathBuf, initrd: PathBuf, heap_mib: u64, code: &str) -> Result<()> {
34+
if !kernel.exists() || !initrd.exists() {
35+
eprintln!("[{label}] SKIP — image not built ({})", kernel.display());
36+
return Ok(());
37+
}
38+
39+
let mut sandbox = SandboxBuilder::new()
40+
.with_tools(ToolRegistry::new())
41+
.guest(
42+
Unikraft::new(kernel)
43+
.initrd(initrd)
44+
.heap_size(heap_mib * 1024 * 1024),
45+
)
46+
.build()?;
47+
48+
let t = std::time::Instant::now();
49+
let out = sandbox.run(code)?;
50+
eprintln!(
51+
"[{label}] {}ms exit={}",
52+
t.elapsed().as_millis(),
53+
out.exit_code
54+
);
55+
print!("{}", out.stdout);
56+
Ok(())
57+
}
58+
59+
fn main() -> Result<()> {
60+
println!("=== Python (python3 -c) ===");
61+
let (k, i) = image("python", "python-hyperlight_hyperlight-x86_64");
62+
demo(
63+
"python",
64+
k,
65+
i,
66+
512,
67+
"import sys; print('python', sys.version.split()[0]); print('sum(0..100) =', sum(range(101)))",
68+
)?;
69+
70+
// NOTE: this unikernel shell has no `fork`, so command substitution `$(...)` and pipes
71+
// are unavailable — use builtins (arithmetic) and standalone external commands.
72+
println!("\n=== Shell (sh -c) ===");
73+
let (k, i) = image("shell", "shell-hyperlight_hyperlight-x86_64");
74+
demo(
75+
"shell",
76+
k,
77+
i,
78+
16,
79+
"echo shell-ok; echo math=$((6*7)); uname -srm; echo done",
80+
)?;
81+
82+
Ok(())
83+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//! Boot a Unikraft guest image and run a Python snippet through the
2+
//! `hyperlight-unikraft` backend of `hyperlight-sandbox`.
3+
//!
4+
//! This is the end-to-end smoke test. It needs a real Unikraft kernel + initrd whose entry
5+
//! interpreter accepts `-c <code>` (the upstream `examples/python` image is the reference)
6+
//! and a working hypervisor (`/dev/kvm` on Linux).
7+
//!
8+
//! The backend runs each call as `python3 -c <code>` (argv) — exactly like the
9+
//! `hyperlight-unikraft --exec` CLI — and captures the guest console as `stdout`.
10+
//!
11+
//! Usage:
12+
//! ```text
13+
//! cargo run --example run -- <kernel> <initrd.cpio> ["<code>"]
14+
//! ```
15+
//!
16+
//! ## Using the upstream python image (console-enabled)
17+
//! ```text
18+
//! cd hyperlight-unikraft/examples/python
19+
//! just build && just rootfs # kernel + initrd (or pull the prebuilt kernel)
20+
//! cd ../../sandbox
21+
//! cargo run --example run -- \
22+
//! ../examples/python/.unikraft/build/python-hyperlight_hyperlight-x86_64 \
23+
//! ../examples/python/initrd.cpio \
24+
//! "print('hello from a Unikraft micro-VM'); print(6 * 7)"
25+
//! ```
26+
//! The snippet's stdout is printed to your terminal. The example runs the same code twice
27+
//! so you can see the cold evolve vs the warm restore in the two `[timing]` lines.
28+
//!
29+
//! ## Optional: a host filesystem mount (hostfs images only, e.g. `hostfs-posix-py`)
30+
//! ```text
31+
//! HL_UNIKRAFT_MOUNT=/tmp/work:/host cargo run --example run -- <kernel> <initrd> \
32+
//! "open('/host/out.txt','w').write('hi')"
33+
//! ```
34+
35+
use anyhow::{anyhow, Result};
36+
use hyperlight_sandbox::{SandboxBuilder, ToolRegistry};
37+
use hyperlight_unikraft_sandbox::Unikraft;
38+
39+
fn main() -> Result<()> {
40+
let mut args = std::env::args().skip(1);
41+
let kernel = args
42+
.next()
43+
.ok_or_else(|| anyhow!("usage: run <kernel> <initrd> [code]"))?;
44+
let initrd = args
45+
.next()
46+
.ok_or_else(|| anyhow!("usage: run <kernel> <initrd> [code]"))?;
47+
let code = args
48+
.next()
49+
.unwrap_or_else(|| "print('hello from a Unikraft micro-VM')".to_string());
50+
51+
// The default 512 MiB guest heap suits the upstream `examples/python` image; override
52+
// via `HL_UNIKRAFT_HEAP_MIB` for heavier runtimes (numpy/pandas stacks want more).
53+
let mut guest = Unikraft::new(kernel).initrd(initrd);
54+
if let Ok(mib) = std::env::var("HL_UNIKRAFT_HEAP_MIB") {
55+
let mib: u64 = mib
56+
.parse()
57+
.map_err(|_| anyhow!("HL_UNIKRAFT_HEAP_MIB must be an integer number of MiB"))?;
58+
guest = guest.heap_size(mib * 1024 * 1024);
59+
}
60+
// `HL_UNIKRAFT_MOUNT=host_dir:guest_path` exposes a host directory over hostfs
61+
// (requires a hostfs-capable guest image, e.g. `hostfs-posix-py`).
62+
if let Ok(spec) = std::env::var("HL_UNIKRAFT_MOUNT") {
63+
let (host, guest_path) = spec
64+
.split_once(':')
65+
.ok_or_else(|| anyhow!("HL_UNIKRAFT_MOUNT must be 'host_dir:guest_path'"))?;
66+
guest = guest.mount(host, guest_path);
67+
}
68+
69+
let mut sandbox = SandboxBuilder::new()
70+
.with_tools(ToolRegistry::new())
71+
.guest(guest)
72+
.build()?;
73+
74+
// Run the same code twice to show the model: the first call evolves a fresh VM (kernel
75+
// boot + interpreter start-up); the second call of the *same* code is a warm restore.
76+
let t = std::time::Instant::now();
77+
let cold = sandbox.run(&code)?;
78+
eprintln!(
79+
"[timing] cold run={}ms exit={}",
80+
t.elapsed().as_millis(),
81+
cold.exit_code
82+
);
83+
print!("{}", cold.stdout);
84+
eprint!("{}", cold.stderr);
85+
86+
let t = std::time::Instant::now();
87+
let warm = sandbox.run(&code)?;
88+
eprintln!(
89+
"[timing] warm run={}ms exit={}",
90+
t.elapsed().as_millis(),
91+
warm.exit_code
92+
);
93+
print!("{}", warm.stdout);
94+
eprint!("{}", warm.stderr);
95+
96+
std::process::exit(warm.exit_code);
97+
}

0 commit comments

Comments
 (0)