From 6f1201bb2cf30f4227f373803afbf028fe2f1272 Mon Sep 17 00:00:00 2001 From: danbugs Date: Tue, 22 Sep 2026 18:45:42 +0000 Subject: [PATCH 1/7] deps: bump hyperlight-unikraft to 0.14.1 0.13.0 was a ground-up rewrite of hyperlight-unikraft: the pyhl module the backend was built on is gone, the kernel is embedded in the crate, and the library API is SandboxBuilder/AppSandbox. 0.14.0 reshaped that API again (cooperative step model, typed errors), and 0.14.1 lets a restored guest take the mounts the restore names and keys snapshots by the kernel and host contract they depend on. hyperlight-host and hyperlight-common move to 0.17.0 with it. Default features are off so the hluk CLI's clap and tracing-subscriber are not pulled into mxc. Signed-off-by: danbugs --- src/Cargo.lock | 342 +++++++++++++--------- src/backends/hyperlight/common/Cargo.toml | 2 +- 2 files changed, 202 insertions(+), 142 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 9bc918aee..0e0b76be6 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -17,6 +17,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -121,6 +127,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -231,6 +257,36 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + [[package]] name = "cc" version = "1.2.64" @@ -240,7 +296,16 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] @@ -283,6 +348,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928" +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.1" @@ -575,6 +651,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "embed-manifest" version = "1.5.0" @@ -666,6 +748,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -769,6 +862,12 @@ dependencies = [ "log", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "globset" version = "0.4.18" @@ -911,9 +1010,9 @@ dependencies = [ [[package]] name = "hyperlight-common" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436dac5cc08da3db27de38b174a9b22498ab6443aaffd83b97f66a5d2f1d74cc" +checksum = "3ad5576d6ef947dc2822c2f5038ddff8325c158ddef4cbabf9d0b125294cdb0f" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -931,15 +1030,17 @@ dependencies = [ [[package]] name = "hyperlight-host" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef3edc7875e70a3b5cc291a17f78cfc352e24c9a665a5a1bbdbc46ada3df43b5" +checksum = "d85dcf5ae7a578e1b09bdeb76cc3b3ca8e6e57d6395d2f57204aec45c1bbbde9" dependencies = [ "anyhow", + "bindgen", "bitflags 2.13.0", "blake3", "built", "bytemuck", + "cc", "cfg-if", "cfg_aliases", "crossbeam-channel", @@ -957,6 +1058,7 @@ dependencies = [ "mshv-ioctls", "oci-spec", "page_size", + "parking_lot", "rand", "rust-embed", "serde", @@ -977,21 +1079,18 @@ dependencies = [ [[package]] name = "hyperlight-unikraft" -version = "0.12.1" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5252ab6a1e32dbe44cccc7fd7e73f4c891f9ce44f312c42261984a3289655d2a" +checksum = "450b70edab2988e0e755de8dc602982931f87a5db8f64608f6390a23a9b86e55" dependencies = [ - "anyhow", - "base64", - "clap", - "flate2", + "cap-std", + "hyperlight-common", "hyperlight-host", - "libc", - "nix", + "rustix", "serde_json", - "socket2 0.5.10", - "tar", - "ureq", + "sha2 0.11.0", + "thiserror", + "tracing", "windows-sys 0.61.2", ] @@ -1155,6 +1254,34 @@ dependencies = [ "serde_core", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1187,6 +1314,15 @@ dependencies = [ "wxc_common", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1393,6 +1529,12 @@ dependencies = [ "wxc_common", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.8.2" @@ -1418,6 +1560,12 @@ dependencies = [ "rapidhash", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1634,6 +1782,16 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1941,20 +2099,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "rust-embed" version = "8.11.0" @@ -1991,6 +2135,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2014,38 +2164,13 @@ dependencies = [ ] [[package]] -name = "rustls" -version = "0.23.40" +name = "rustix-linux-procfs" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" dependencies = [ - "log", "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", + "rustix", ] [[package]] @@ -2233,6 +2358,12 @@ dependencies = [ "dirs", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -2267,16 +2398,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.4" @@ -2326,12 +2447,6 @@ dependencies = [ "syn", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.118" @@ -2429,7 +2544,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2536,41 +2651,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64", - "flate2", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", - "webpki-roots", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -2583,12 +2663,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2762,15 +2836,6 @@ dependencies = [ "semver", ] -[[package]] -name = "webpki-roots" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "widestring" version = "1.2.1" @@ -2909,15 +2974,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -3063,6 +3119,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.0", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3412,12 +3478,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src/backends/hyperlight/common/Cargo.toml b/src/backends/hyperlight/common/Cargo.toml index 27fb7d098..d2b761b86 100644 --- a/src/backends/hyperlight/common/Cargo.toml +++ b/src/backends/hyperlight/common/Cargo.toml @@ -8,7 +8,7 @@ license.workspace = true wxc_common = { workspace = true } [target.'cfg(target_arch = "x86_64")'.dependencies] -hyperlight-unikraft = { version = "0.12.1", optional = true } +hyperlight-unikraft = { version = "0.14.1", default-features = false, optional = true } [target.'cfg(target_os = "windows")'.dependencies] windows = { workspace = true } From 5837f37157c67e6313e4a44bc559e895dd664fb9 Mon Sep 17 00:00:00 2001 From: danbugs Date: Tue, 22 Sep 2026 18:45:42 +0000 Subject: [PATCH 2/7] hyperlight: port the backend to the hyperlight-unikraft 0.14 API The pyhl install/runtime layer no longer exists, so the backend now drives SandboxBuilder and AppSandbox directly: - The Unikraft kernel ships inside the crate. An image home holds only the agent rootfs (initrd.cpio), the warmed snapshot directory, and a VERSION stamp naming the rootfs release. A home whose stamp is another release's, or whose snapshot was saved by a build with another snapshot key (the crate's kernel and host contract), is treated as not installed: --setup-hyperlight rebuilds it without --force, and a run warms a new snapshot by itself when only the snapshot is stale. - Setup pulls ghcr.io/hyperlight-dev/hyperlight-unikraft/agent at the pinned initrd tag, boots it once, and saves the snapshot. - Every request restores that one warm image with its own mounts and network policy; the kernel builds its mount table from the mounts the restore names. Later calls on the same runner rewind to it. - Timeouts bound each step for a blocked guest and interrupt the vCPU from a watchdog thread for a busy one; either way the guest is dropped and the next call boots from the rewind point. - CallFailed carries the guest's status, which becomes the exit code as before (sys.exit(N) -> N, uncaught exception -> 1). The rootfs is needed only to warm: a home holding just a snapshot this build loads runs, and setup keeps a rootfs of this release instead of pulling it again. A warm is saved beside the snapshot directory and swapped into place, so a failed or concurrent warm never leaves a half-written layout. The e2e workflow fetches only the agent rootfs, since the kernel is embedded in the crate. On Windows the runner takes Hyperlight's single-VM mode through the crate's configure_surrogates, skipping the 512 pre-spawned helper processes (about 3.5 s); it holds one sandbox at a time. Signed-off-by: danbugs --- .github/workflows/hyperlight-e2e.yml | 12 +- src/backends/hyperlight/common/src/lib.rs | 1121 ++++++++++++++++----- src/core/lxc/src/main.rs | 19 +- src/core/mxc_engine/src/probe.rs | 2 +- src/core/mxc_engine/src/run.rs | 12 +- src/core/wxc/src/main.rs | 12 +- 6 files changed, 888 insertions(+), 290 deletions(-) diff --git a/.github/workflows/hyperlight-e2e.yml b/.github/workflows/hyperlight-e2e.yml index 5f5cca439..c39a4cd8d 100644 --- a/.github/workflows/hyperlight-e2e.yml +++ b/.github/workflows/hyperlight-e2e.yml @@ -71,18 +71,18 @@ jobs: Invoke-WebRequest -Uri $url -OutFile crane.tar.gz -UseBasicParsing tar -xzf crane.tar.gz crane.exe - - name: Download Hyperlight kernel and initrd + - name: Download Hyperlight rootfs if: steps.whp-check.outputs.whp_available == 'true' shell: pwsh run: | $pyhlHome = Join-Path $env:LOCALAPPDATA "pyhl" New-Item -ItemType Directory -Force -Path $pyhlHome | Out-Null - $tag = "v0.12.1" - .\crane.exe export ghcr.io/hyperlight-dev/hyperlight-unikraft/python-agent-driver-kernel:$tag kernel.tar - tar -xf kernel.tar -C $pyhlHome kernel - - .\crane.exe export ghcr.io/hyperlight-dev/hyperlight-unikraft/python-agent-driver-initrd:$tag initrd.tar + # The Unikraft kernel is embedded in the hyperlight-unikraft crate; only + # the agent rootfs (initrd.cpio) is fetched, pinned to the crate release + # declared in src/backends/hyperlight/common/Cargo.toml. + $tag = "initrd-v0.14.1" + .\crane.exe export ghcr.io/hyperlight-dev/hyperlight-unikraft/agent:$tag initrd.tar tar -xf initrd.tar -C $pyhlHome initrd.cpio Write-Host "Downloaded to ${pyhlHome}:" diff --git a/src/backends/hyperlight/common/src/lib.rs b/src/backends/hyperlight/common/src/lib.rs index b313ad088..90c32c253 100644 --- a/src/backends/hyperlight/common/src/lib.rs +++ b/src/backends/hyperlight/common/src/lib.rs @@ -12,54 +12,66 @@ #![cfg(all(feature = "hyperlight", target_arch = "x86_64"))] //! `HyperlightScriptRunner` — executes Python code inside a Hyperlight + Unikraft -//! micro-VM, driven by the `hyperlight-unikraft::pyhl` library. +//! micro-VM, driven by the `hyperlight-unikraft` library. //! //! | Property | Value | //! |---------------------|-----------------------------------------------------------| //! | Backing micro-VM | Unikraft unikernel in a Hyperlight micro-VM | //! | Host platform | Linux (KVM) + Windows (WHP) | //! | Execution model | Embedded library, in-process | -//! | Script delivery | Direct `Runtime::run_code(&str)` | -//! | Cold start | Snapshot restore (~50–60 ms) | -//! | Filesystem | Host dir mounts via `Preopen` | +//! | Script delivery | `AppSandbox::run`, or `submit` + `step` under a deadline | +//! | Cold start | Snapshot restore (~50–60 ms) | +//! | Filesystem | Host dir mounts via `Mount` | //! | Networking | Host-proxied sockets via `NetworkPolicy` | -//! | Script I/O | Host's stdout/stderr (host_print) | +//! | Script I/O | Host's stdout/stderr (HostPrint) | //! | stdlib coverage | Full CPython + preloaded ML stack (numpy, pandas, etc.) | //! //! ## Image-home resolution //! //! The runner looks for a warmed image in this order, first hit wins: //! -//! 1. `$PYHL_HOME` (override — if set, must be a usable install) +//! 1. `$PYHL_HOME` (searched first when set) //! 2. `~/.local/share/pyhl/` on Linux (XDG_DATA_HOME compliant) //! `%LOCALAPPDATA%\pyhl\` on Windows //! 3. `/pyhl/` (dev build next to the target binary) -//! 4. `/.pyhl/` (dev fallback, same as pyhl's own CLI) +//! 4. `/.pyhl/` (dev fallback) //! //! Path #2 is the "default". `--setup-hyperlight` installs here when nothing //! else is already populated — so one eager install persists across //! shell sessions, across reboots, and across `cargo install` upgrades. //! +//! An image home holds the guest rootfs (`initrd.cpio`), the warmed +//! `snapshot/` directory, and a `VERSION` stamp naming the rootfs release. +//! The Unikraft kernel is embedded in the `hyperlight-unikraft` crate, so +//! nothing else is downloaded. A snapshot loads only under a build with +//! its snapshot key (the crate's kernel and host contract), and a rootfs +//! only boots on its own release's kernel, so a home whose stamp or +//! snapshot key is another build's is treated as not installed: +//! `--setup-hyperlight` rebuilds it, and a run warms a new snapshot by +//! itself when only the snapshot is stale. +//! //! ## Setup //! -//! `lxc-exec --setup-hyperlight` (or `wxc-exec --setup-hyperlight`) installs the -//! warm snapshot. It pulls the published kernel + initrd from GHCR -//! via docker or podman, warms them -//! up, and persists a snapshot to the default home — zero -//! configuration beyond having docker/podman on `$PATH`. +//! `lxc-exec --setup-hyperlight` (or `wxc-exec --setup-hyperlight`) pulls +//! the published `agent` rootfs from GHCR via docker or podman, boots it +//! once, and persists the warmed guest as a snapshot in the default home — +//! zero configuration beyond having docker/podman on `$PATH`. //! //! On first `run` (if setup was skipped) the runner also does a lazy -//! auto-install if kernel+initrd are already in the resolved home -//! but no snapshot is — cheap safety net. +//! auto-install if `initrd.cpio` is already in the resolved home but no +//! snapshot is — cheap safety net. //! //! ## Filesystem policy //! //! `policy.readwritePaths` and `policy.readonlyPaths` are translated to -//! [`Preopen`] entries — the guest sees the host directories at -//! `/host/` and can read/write through them via `lib/hostfs`. -//! `readonlyPaths` are mounted with `Preopen::read_only()`, which blocks -//! all write operations (`fs_write`, `fs_mkdir`, `fs_unlink`, etc.) at -//! the host-function level. +//! [`Mount`] entries — the guest sees the host directories at +//! `/host/`. `readonlyPaths` are mounted read-only, which the +//! guest kernel enforces (`EROFS`) and the host's `fs_*` functions enforce +//! again. +//! +//! The persisted snapshot is warmed without mounts; the kernel builds its +//! mount table from the mounts a restore names, so every request restores +//! the same warm image, mounts or not. //! //! `policy.deniedPaths` is honored: any path that appears in the denied //! list is rejected at preflight — including paths that also appear in @@ -67,31 +79,47 @@ //! //! ## I/O model //! -//! The guest's `print(...)` goes through Hyperlight's host_print callback, +//! The guest's `print(...)` goes through Hyperlight's HostPrint callback, //! which writes to the **host process's stdout**. `ScriptResponse.standard_out` //! and `standard_err` stay empty; consumers that need captured output //! redirect wxc-exec's stdout/stderr at the process level. //! //! ## Exit codes //! -//! Guest exit code on clean `run_code` completion (0 for normal exit, -//! non-zero for `sys.exit(N)` or unhandled exceptions), -1 on any -//! runner error (preflight, install, runtime, guest crash). The specific +//! Guest exit code on clean completion (0 for normal exit, non-zero for +//! `sys.exit(N)` or unhandled exceptions), -1 on any runner error +//! (preflight, install, runtime, guest crash, timeout). The specific //! failure mode is in `error_message`. use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::Arc; +use std::time::{Duration, Instant}; use wxc_common::logger::Logger; use wxc_common::models::{ExecutionRequest, NetworkPolicy, ScriptResponse}; use wxc_common::script_runner::ScriptRunner; use wxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; -use hyperlight_unikraft::pyhl; -use hyperlight_unikraft::{AllowList, BlockList, Preopen}; +use hyperlight_unikraft::hyperlight_host::HyperlightError; +use hyperlight_unikraft::{ + AllowList, AppSandbox, BlockList, Mount, SandboxBuilder, Snapshot, Yield, +}; // -- Availability probe ------------------------------------------------------- -/// WHP is delay-loaded; check before pyhl::install warms a VM. +/// Whether KVM is usable by this process: `/dev/kvm` opens for reading and +/// writing. Setup and a run check it before booting anything. +#[cfg(target_os = "linux")] +pub fn is_kvm_available() -> bool { + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/kvm") + .is_ok() +} + +/// WHP is delay-loaded; check before setup or a run boots a VM. #[cfg(target_os = "windows")] pub fn is_whp_available() -> bool { use windows::core::w; @@ -106,23 +134,23 @@ pub fn is_whp_available() -> bool { // -- Error classification ---------------------------------------------------- #[derive(Debug)] -enum PyhlError { +enum RunnerError { /// Pre-spawn validation failures (missing image, unsupported policy). Preflight(String), /// Runtime construction, install, or execution failure. Runtime(String), } -impl std::fmt::Display for PyhlError { +impl std::fmt::Display for RunnerError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - PyhlError::Preflight(msg) => write!(f, "hyperlight preflight error: {msg}"), - PyhlError::Runtime(msg) => write!(f, "hyperlight runtime error: {msg}"), + RunnerError::Preflight(msg) => write!(f, "hyperlight preflight error: {msg}"), + RunnerError::Runtime(msg) => write!(f, "hyperlight runtime error: {msg}"), } } } -impl PyhlError { +impl RunnerError { fn to_response(&self) -> ScriptResponse { ScriptResponse { exit_code: ERROR_EXIT_CODE, @@ -146,35 +174,103 @@ const CWD_RELATIVE_HOME: &str = ".pyhl"; /// Final component of the default OS-local data path. const DEFAULT_HOME_LEAF: &str = "pyhl"; -// The filenames the installer writes; duplicated here to avoid a -// compile-time dep on internal path constants. -const KERNEL_FILE: &str = "kernel"; +/// The guest rootfs (a CPIO archive) inside an image home. The kernel is +/// embedded in the `hyperlight-unikraft` crate, so this is the only +/// artifact an install needs. const INITRD_FILE: &str = "initrd.cpio"; +/// The warmed snapshot (an OCI image layout) inside an image home. const SNAPSHOT_DIR: &str = "snapshot"; +/// Stamp `setup` writes beside the rootfs, naming the release it holds. +const VERSION_FILE: &str = "VERSION"; + +/// The published rootfs: the `agent` runtime (CPython plus numpy, pandas, +/// scipy, scikit-learn, ...). Its `:initrd-` tag wraps the runnable +/// CPIO in a scratch image at [`ROOTFS_PATH_IN_IMAGE`]. +const ROOTFS_IMAGE: &str = "ghcr.io/hyperlight-dev/hyperlight-unikraft/agent"; +/// Pinned to the `hyperlight-unikraft` release in Cargo.toml: a rootfs +/// only boots on the kernel and driver protocol of its own release. +const ROOTFS_TAG: &str = "initrd-v0.14.1"; +const ROOTFS_PATH_IN_IMAGE: &str = "/initrd.cpio"; +/// Scratch memory for the agent image — upstream's own figure for it; the +/// ML stack needs far more than the library's 256 MiB default. +const SCRATCH_MB: usize = 1536; +/// Where host directories appear in the guest: `/host/`. +const GUEST_MOUNT_ROOT: &str = "/host"; const ERR_PROXY_POLICY: &str = "network proxy is not supported by the hyperlight backend"; const ERR_WORKDIR: &str = "workingDirectory is not supported by the hyperlight backend -- guest has its own filesystem namespace"; const ERR_NO_INSTALL_SOURCE: &str = - "no warmed snapshot and no kernel/initrd to install from. drop `kernel` and `initrd.cpio` \ - into the image home (or run `--setup-hyperlight`)."; + "no warmed snapshot and no rootfs to install from. drop `initrd.cpio` into the image \ + home (or run `--setup-hyperlight`)."; // -- Runner ------------------------------------------------------------------ /// Script runner that executes Python code inside a Hyperlight+Unikraft /// micro-VM. /// -/// Lazily instantiates the runtime on the first call (loading the -/// persisted snapshot, auto-installing it first if needed) and reuses -/// it across subsequent calls on the same runner instance. Every -/// `run_code` rewinds the guest to the post-warmup snapshot so -/// consecutive calls are hermetic. +/// Lazily brings up the guest on the first call (loading the persisted +/// snapshot, auto-installing it first if needed) and reuses it across +/// subsequent calls on the same runner instance. Every call rewinds the +/// guest to the post-warmup snapshot first, so consecutive calls are +/// hermetic. pub struct HyperlightScriptRunner { - runtime: Option, + guest: Option, + /// The persisted warm image, loaded once per home. Outlives `guest`: + /// a call that fails or times out drops the guest, and the next call + /// boots a fresh one from here instead of reloading it from disk. + rewind: Option>, active_home: Option, - active_preopens: Vec, - active_network_hosts: Vec, - active_network_default: NetworkPolicy, + active_mounts: Vec, + active_policy: Option, + active_network: NetworkKey, +} + +/// The request's network policy as the runner keys a booted guest on it. +/// Both host lists are kept, so an allow list and a block list of the +/// same hosts key differently. +#[derive(Clone, Debug, PartialEq, Default)] +struct NetworkKey { + allowed: Vec, + blocked: Vec, + default: NetworkPolicy, +} + +impl NetworkKey { + fn from_request(request: &ExecutionRequest) -> Self { + let sorted = |hosts: &[String]| { + let mut hosts = hosts.to_vec(); + hosts.sort(); + hosts.dedup(); + hosts + }; + Self { + allowed: sorted(&request.policy.allowed_hosts), + blocked: sorted(&request.policy.blocked_hosts), + default: request.policy.default_network_policy.clone(), + } + } +} + +/// A booted guest, parked at a boundary between calls. +struct Guest { + sandbox: AppSandbox, + /// True until the first call: the guest is already at the rewind point. + fresh: bool, +} + +/// Wall-clock split of one call. +#[derive(Debug, Default)] +struct RunTiming { + restore_ms: f64, + call_ms: f64, + exit_code: i32, +} + +/// Why a call produced no exit code. +enum RunError { + TimedOut(Duration), + Failed(String), } impl Default for HyperlightScriptRunner { @@ -187,10 +283,9 @@ impl Default for HyperlightScriptRunner { /// pays no warmup cost. Intended to be called from a tool install /// step (npm postinstall, a `--setup-hyperlight` CLI flag, CI, etc.). /// -/// Pulls the published `kernel` + `initrd.cpio` from GHCR -/// via docker or podman, runs warmup, and -/// persists the snapshot to disk. Zero configuration beyond having -/// docker/podman on `$PATH`. +/// Pulls the published rootfs from GHCR via docker or podman, boots it +/// once, and persists the warmed guest as a snapshot. Zero configuration +/// beyond having docker/podman on `$PATH`. /// /// # Destination /// @@ -203,8 +298,9 @@ impl Default for HyperlightScriptRunner { /// /// # Force /// -/// When `force` is false, an existing snapshot is a no-op. When -/// `force` is true, the snapshot is rebuilt. +/// When `force` is false, an existing install for this release is a +/// no-op; one left by another release is rebuilt. When `force` is true, +/// the snapshot is rebuilt regardless. pub fn setup(force: bool, logger: &mut Logger) -> Result { let home = match std::env::var_os(PYHL_HOME_ENV) { Some(v) => PathBuf::from(v), @@ -222,53 +318,72 @@ pub fn setup(force: bool, logger: &mut Logger) -> Result { std::fs::create_dir_all(&home).map_err(|e| format!("create image home {home:?}: {e}"))?; - logger.log_line("hyperlight setup: pulling image from GHCR (docker/podman)"); - let opts = pyhl::InstallOptions { - home: &home, - source: pyhl::InstallSource::Ghcr { - tag: Some("v0.12.1"), - }, - mounts: &[], - network: None, - listen_ports: None, - max_surrogates: None, - force, - }; - let report = pyhl::install(&opts).map_err(|e| format!("hyperlight install: {e:#}"))?; - logger.log_line(&format!( - "hyperlight: install complete (warmup={:.1}ms, snapshot at {:?})", - report.warmup_ms, report.snapshot - )); - Ok(report.snapshot) + // A rootfs of this release already in the home is kept, so a rebuild + // (a stale snapshot, or --force after replacing it) only warms. + if has_install_source(&home) { + logger.log_line(&format!( + "hyperlight setup: rootfs present at {:?}", + home.join(INITRD_FILE) + )); + } else { + logger.log_line(&format!( + "hyperlight setup: pulling {ROOTFS_IMAGE}:{ROOTFS_TAG} (docker/podman)" + )); + pull_rootfs(&home.join(INITRD_FILE), logger)?; + std::fs::write(home.join(VERSION_FILE), version_stamp()) + .map_err(|e| format!("write {VERSION_FILE}: {e}"))?; + } + + let (_, persisted) = warm_snapshot(&home, logger).map_err(|e| e.to_string())?; + if !persisted { + return Err(format!( + "the snapshot could not be put in place at {:?}; the log above says why", + home.join(SNAPSHOT_DIR) + )); + } + Ok(home.join(SNAPSHOT_DIR)) } impl HyperlightScriptRunner { pub fn new() -> Self { Self { - runtime: None, + guest: None, + rewind: None, active_home: None, - active_preopens: Vec::new(), - active_network_hosts: Vec::new(), - active_network_default: NetworkPolicy::default(), + active_mounts: Vec::new(), + active_policy: None, + active_network: NetworkKey::default(), } } /// Resolve the image home for a normal run. Walks the /// discovery chain (see module doc) and returns the first - /// location that has at least kernel + initrd — snapshot may be + /// location that has at least the rootfs — snapshot may be /// missing, the runner will install it. - fn resolve_home() -> Result { + fn resolve_home() -> Result { + let mut stale = None; for cand in Self::search_paths() { - if has_install_source(&cand) || is_installed(&cand) { + if is_installed(&cand) || has_install_source(&cand) { return Ok(cand); } + if stale.is_none() && cand.join(INITRD_FILE).is_file() { + stale = Some(cand); + } } let default = Self::default_home(); - Err(PyhlError::Preflight(format!( + let hint = match stale { + Some(home) => format!( + "{home:?} holds a rootfs from another hyperlight-unikraft release; \ + run `lxc-exec --setup-hyperlight` to rebuild it." + ), + None => format!( + "run `lxc-exec --setup-hyperlight` \ + (or drop `{INITRD_FILE}` into {default:?})." + ), + }; + Err(RunnerError::Preflight(format!( "no hyperlight image found. searched ${PYHL_HOME_ENV}, {default:?}, \ - /{EXE_RELATIVE_HOME}/, /{CWD_RELATIVE_HOME}/. \ - run `lxc-exec --setup-hyperlight` \ - (or drop `{KERNEL_FILE}` and `{INITRD_FILE}` into {default:?})." + /{EXE_RELATIVE_HOME}/, /{CWD_RELATIVE_HOME}/. {hint}" ))) } @@ -302,22 +417,20 @@ impl HyperlightScriptRunner { /// Reject only policies that the hyperlight backend genuinely cannot honor. /// Filesystem mounts and network policies ARE supported. - fn validate_policies(request: &ExecutionRequest) -> Result<(), PyhlError> { + fn validate_policies(request: &ExecutionRequest) -> Result<(), RunnerError> { if request.policy.network_proxy.is_enabled() { - return Err(PyhlError::Preflight(ERR_PROXY_POLICY.to_string())); + return Err(RunnerError::Preflight(ERR_PROXY_POLICY.to_string())); } if !request.working_directory.is_empty() { - return Err(PyhlError::Preflight(ERR_WORKDIR.to_string())); + return Err(RunnerError::Preflight(ERR_WORKDIR.to_string())); } if !request.policy.allowed_hosts.is_empty() && !request.policy.blocked_hosts.is_empty() { - return Err(PyhlError::Preflight( + return Err(RunnerError::Preflight( "allowedHosts and blockedHosts are mutually exclusive".to_string(), )); } // Denied paths: block early if any appears in the allow lists. - // Also reject a config that only specifies denies — there's no - // positive policy to apply and an attacker might be probing. for denied in &request.policy.denied_paths { if request .policy @@ -330,7 +443,7 @@ impl HyperlightScriptRunner { .iter() .any(|p| same_path(p, denied)) { - return Err(PyhlError::Preflight(format!( + return Err(RunnerError::Preflight(format!( "path {denied:?} appears in both deniedPaths and an allow list" ))); } @@ -339,44 +452,40 @@ impl HyperlightScriptRunner { Ok(()) } - /// Translate MXC's network policy fields into a pyhl `NetworkPolicy`. + /// Translate MXC's network policy fields into a guest `NetworkPolicy`. /// /// - `allowed_hosts` non-empty → `AllowList` (only listed hosts reachable) /// - `blocked_hosts` non-empty → `BlockList` (listed hosts denied, rest allowed) /// - `default_network_policy == Block`, no host lists → `None` (networking disabled) /// - `default_network_policy == Allow`, no host lists → `AllowAll` - fn network_policy_from_request( - request: &ExecutionRequest, - ) -> Result, PyhlError> { - if !request.policy.allowed_hosts.is_empty() { - let allow_list = AllowList::from_hosts(&request.policy.allowed_hosts) - .map_err(|e| PyhlError::Preflight(format!("resolve allowed_hosts: {e:#}")))?; + fn network_policy_from_key( + key: &NetworkKey, + ) -> Result, RunnerError> { + if !key.allowed.is_empty() { + let allow_list = AllowList::from_hosts(&key.allowed) + .map_err(|e| RunnerError::Preflight(format!("resolve allowed_hosts: {e}")))?; return Ok(Some(hyperlight_unikraft::NetworkPolicy::AllowList( allow_list, ))); } - if !request.policy.blocked_hosts.is_empty() { - let block_list = BlockList::from_hosts(&request.policy.blocked_hosts) - .map_err(|e| PyhlError::Preflight(format!("resolve blocked_hosts: {e:#}")))?; + if !key.blocked.is_empty() { + let block_list = BlockList::from_hosts(&key.blocked) + .map_err(|e| RunnerError::Preflight(format!("resolve blocked_hosts: {e}")))?; return Ok(Some(hyperlight_unikraft::NetworkPolicy::BlockList( block_list, ))); } - if request.policy.default_network_policy == NetworkPolicy::Block { + if key.default == NetworkPolicy::Block { return Ok(None); } Ok(Some(hyperlight_unikraft::NetworkPolicy::AllowAll)) } /// Translate `ContainerPolicy.{readwrite,readonly}Paths` into - /// `Preopen` entries. Each host path is exposed inside the guest at - /// `/host/` — matches the `pyhl` CLI's `--mount ` default - /// shape so scripts can find mounts predictably. - /// - /// `readonlyPaths` are mounted with `Preopen::read_only()`, blocking - /// all write operations at the host-function level. - fn preopens_from_policy(request: &ExecutionRequest) -> Result, PyhlError> { - let mut preopens = Vec::new(); + /// [`Mount`] entries. Each host path is exposed inside the guest at + /// `/host/` so scripts can find mounts predictably. + fn mounts_from_policy(request: &ExecutionRequest) -> Result, RunnerError> { + let mut mounts = Vec::new(); let mut seen_guest_paths = std::collections::HashSet::new(); let rw_iter = request.policy.readwrite_paths.iter().map(|p| (p, false)); @@ -385,13 +494,13 @@ impl HyperlightScriptRunner { for (host, read_only) in rw_iter.chain(ro_iter) { let host_path = PathBuf::from(host); - // Auto-create the mount dir if it doesn't exist yet. - // `Preopen::new` canonicalizes the host path, which fails on - // ENOENT — so without this, a relative path like - // "../tmp/foo" fails silently just because the dir wasn't - // pre-created. The guest's hostfs still needs a real dir to - // read/write against; mkdir-ing now matches the "config is - // declaratively requesting this mount" semantics. + // Auto-create the mount dir if it doesn't exist yet. The + // host path is canonicalized below, which fails on ENOENT — + // so without this, a relative path like "../tmp/foo" fails + // silently just because the dir wasn't pre-created. The + // guest still needs a real dir to read/write against; + // mkdir-ing now matches the "config is declaratively + // requesting this mount" semantics. // // We only create if the parent already exists — prevents // accidentally materializing arbitrary paths on a typo. @@ -401,118 +510,172 @@ impl HyperlightScriptRunner { .map(|p| p.as_os_str().is_empty() || p.exists()) .unwrap_or(false); if !parent_ok { - return Err(PyhlError::Preflight(format!( + return Err(RunnerError::Preflight(format!( "mount path {host:?} does not exist and its parent doesn't either; \ refusing to auto-create (fix the path or `mkdir -p` manually)" ))); } std::fs::create_dir_all(&host_path).map_err(|e| { - PyhlError::Preflight(format!("auto-create mount dir {host:?}: {e}")) + RunnerError::Preflight(format!("auto-create mount dir {host:?}: {e}")) })?; } + // Canonical so the mount set compares stably across calls and + // so a relative path keeps meaning the same directory after a + // cwd change. + let host_path = std::fs::canonicalize(&host_path) + .map_err(|e| RunnerError::Preflight(format!("resolve mount path {host:?}: {e}")))?; let basename = host_path .file_name() .and_then(|n| n.to_str()) .ok_or_else(|| { - PyhlError::Preflight(format!("mount path {host:?} has no filename component")) + RunnerError::Preflight(format!("mount path {host:?} has no filename component")) })?; - let guest_path = format!("/host/{basename}"); + // The guest path travels to the kernel in its `vfs.fstab` + // list, which these characters would break; the library + // refuses them at boot, we refuse them before booting. + if basename + .chars() + .any(|c| c.is_whitespace() || matches!(c, ':' | '[' | ']')) + { + return Err(RunnerError::Preflight(format!( + "mount path {host:?}: the directory name may not contain whitespace, \ + ':' or brackets (it names the guest mount point)" + ))); + } + let guest_path = format!("{GUEST_MOUNT_ROOT}/{basename}"); if !seen_guest_paths.insert(guest_path.clone()) { - return Err(PyhlError::Preflight(format!( + return Err(RunnerError::Preflight(format!( "two mount paths collide on guest path {guest_path:?}; \ rename one of the host directories" ))); } - let mut pre = Preopen::new(&host_path, &guest_path).map_err(|e| { - PyhlError::Preflight(format!( - "build Preopen for {host:?} -> {guest_path:?}: {e:#}" - )) - })?; - if read_only { - pre = pre.read_only(); - } - preopens.push(pre); + mounts.push(if read_only { + Mount::ro(host_path, guest_path) + } else { + Mount::rw(host_path, guest_path) + }); } - Ok(preopens) + Ok(mounts) } - /// Lazily bring up the embedded Hyperlight runtime. + /// Lazily bring up the guest for this configuration. /// - /// If the persisted snapshot is missing but kernel + initrd are - /// present, run install in-line (warmup boot + persist, cost - /// ~1.5–2 s, once per image). Subsequent runners on the same home - /// go straight to restore. + /// The guest restores the persisted snapshot (warming and persisting + /// one first if only the rootfs is present, a cold boot once per + /// image) with the request's mounts and network policy; the kernel + /// builds its mount table from them on resume. Later calls on the + /// same runner rewind rather than boot. /// - /// The mount set is baked into the runtime at construction time; - /// different preopens between calls force a full teardown + rebuild. + /// The mount set and network policy are fixed at boot; a change in + /// either boots another guest from the same image. fn ensure_runtime( &mut self, home: &Path, - preopens: Vec, - network: Option, - network_hosts: &[String], - network_default: NetworkPolicy, + mounts: Vec, + network: NetworkKey, logger: &mut Logger, - ) -> Result<&mut pyhl::Runtime, PyhlError> { - let same_home = self.active_home.as_deref() == Some(home); - let same_mounts = preopens_equal(&self.active_preopens, &preopens); - let mut sorted_hosts = network_hosts.to_vec(); - sorted_hosts.sort(); - sorted_hosts.dedup(); - let same_network = self.active_network_hosts == sorted_hosts - && self.active_network_default == network_default; - // `if let Some(rt) = self.runtime.as_mut()` trips the borrow - // checker because a later branch reassigns `self.runtime`. - #[allow(clippy::unnecessary_unwrap)] - if same_home && same_mounts && same_network && self.runtime.is_some() { - return Ok(self.runtime.as_mut().unwrap()); - } - // Drop any prior runtime before rebuilding against new state. - self.runtime = None; - - // Auto-install on first use. Install is idempotent when the - // snapshot already exists (`force: false`). - if !is_installed(home) { - if !has_install_source(home) { - return Err(PyhlError::Preflight(ERR_NO_INSTALL_SOURCE.to_string())); + ) -> Result<(&mut Guest, Arc), RunnerError> { + let same_config = self.active_home.as_deref() == Some(home) + && mounts_equal(&self.active_mounts, &mounts) + && self.active_network == network; + if !same_config { + // Host lists resolve names here, once per configuration, so a + // guest that is already up is not held to the resolver on + // every call. + let policy = Self::network_policy_from_key(&network)?; + // Nothing booted so far applies to the new configuration; the + // image still does, unless the home changed. + self.guest = None; + if self.active_home.as_deref() != Some(home) { + self.rewind = None; } - logger.log_line(&format!( - "hyperlight: no snapshot at {:?}; auto-installing from kernel + initrd", - home.join(SNAPSHOT_DIR) - )); - let kernel = home.join(KERNEL_FILE); - let initrd = home.join(INITRD_FILE); - let opts = pyhl::InstallOptions { - home, - source: pyhl::InstallSource::Explicit { - kernel: &kernel, - initrd: &initrd, - }, - mounts: &preopens, - network: network.as_ref(), - listen_ports: None, - max_surrogates: None, - force: false, - }; - let report = pyhl::install(&opts) - .map_err(|e| PyhlError::Runtime(format!("hyperlight install: {e:#}")))?; - logger.log_line(&format!( - "hyperlight: install complete (warmup={:.1}ms, snapshot at {:?})", - report.warmup_ms, report.snapshot - )); + self.active_home = Some(home.to_path_buf()); + self.active_mounts = mounts; + self.active_policy = policy; + self.active_network = network; } + let rewind = match self.rewind.clone() { + Some(rewind) => rewind, + None => { + configure_surrogates(); + let rewind = load_persisted_snapshot(home, logger)?; + self.rewind = Some(rewind.clone()); + rewind + } + }; + let guest = match self.guest.take() { + Some(guest) => guest, + None => { + configure_surrogates(); + Self::boot_from_snapshot(rewind.clone(), &self.active_mounts, &self.active_policy)? + } + }; + Ok((self.guest.insert(guest), rewind)) + } + + /// Restore the warm image into a new sandbox with `mounts` and + /// `policy`: the kernel builds its mount table from them on resume, + /// and the host serves them. + fn boot_from_snapshot( + rewind: Arc, + mounts: &[Mount], + policy: &Option, + ) -> Result { + let builder = SandboxBuilder::from_snapshot(rewind).mounts(mounts.iter().cloned()); + let sandbox = with_network(builder, policy) + .boot() + .map_err(|e| RunnerError::Runtime(format!("restore hyperlight snapshot: {e}")))?; + Ok(Guest { + sandbox, + fresh: true, + }) + } - logger.log_line(&format!("hyperlight: using image home {home:?}")); - let rt = pyhl::Runtime::new(home, &preopens, network.as_ref(), None, None) - .map_err(|e| PyhlError::Runtime(format!("open hyperlight runtime: {e:#}")))?; - self.runtime = Some(rt); - self.active_home = Some(home.to_path_buf()); - self.active_preopens = preopens; - self.active_network_hosts = sorted_hosts; - self.active_network_default = network_default; - Ok(self.runtime.as_mut().unwrap()) + /// One hermetic call: rewind to the warmed state (unless the guest is + /// fresh from boot), run `code`, and report the guest's exit status. + fn run_once( + guest: &mut Guest, + rewind: &Arc, + code: &str, + timeout: Option, + ) -> Result { + let mut timing = RunTiming::default(); + if !guest.fresh { + let t = Instant::now(); + guest + .sandbox + .restore(rewind.clone()) + .map_err(|e| RunError::Failed(format!("rewind guest: {e}")))?; + timing.restore_ms = t.elapsed().as_secs_f64() * 1000.0; + } + guest.fresh = false; + + let t = Instant::now(); + let result = match timeout { + Some(timeout) => run_with_deadline(&mut guest.sandbox, code, timeout), + None => match guest.sandbox.run(code) { + Ok(()) => Ok(0), + Err(hyperlight_unikraft::Error::CallFailed { status }) => Ok(status), + Err(e) => Err(RunError::Failed(e.to_string())), + }, + }; + timing.call_ms = t.elapsed().as_secs_f64() * 1000.0; + // HostPrint already wrote the guest's output to our stdout; drop + // the copy the library keeps so it never grows across calls. + let _ = guest.sandbox.drain_output(); + + timing.exit_code = result?; + if timing.exit_code < 0 { + // The driver's own signal that it could not run the call; the + // guest's output has the details. + return Err(RunError::Failed(format!( + "the guest driver could not run the call (status {})", + timing.exit_code + ))); + } + Ok(timing) } } @@ -531,53 +694,30 @@ impl ScriptRunner for HyperlightScriptRunner { return e.to_response(); } }; - let preopens = match Self::preopens_from_policy(request) { - Ok(p) => p, - Err(e) => { - logger.log_line(&e.to_string()); - return e.to_response(); - } - }; - let network = match Self::network_policy_from_request(request) { - Ok(n) => n, - Err(e) => { - logger.log_line(&e.to_string()); - return e.to_response(); - } - }; - - let network_hosts = if !request.policy.allowed_hosts.is_empty() { - &request.policy.allowed_hosts - } else { - &request.policy.blocked_hosts - }; - let rt = match self.ensure_runtime( - &home, - preopens, - network, - network_hosts, - request.policy.default_network_policy.clone(), - logger, - ) { - Ok(rt) => rt, + let mounts = match Self::mounts_from_policy(request) { + Ok(m) => m, Err(e) => { logger.log_line(&e.to_string()); return e.to_response(); } }; + let (guest, rewind) = + match self.ensure_runtime(&home, mounts, NetworkKey::from_request(request), logger) { + Ok(pair) => pair, + Err(e) => { + logger.log_line(&e.to_string()); + return e.to_response(); + } + }; - let result = if request.script_timeout > 0 { - let timeout = std::time::Duration::from_millis(u64::from(request.script_timeout)); + let timeout = (request.script_timeout > 0).then(|| { logger.log_line(&format!( "hyperlight: timeout set to {}ms", request.script_timeout )); - rt.run_code_with_timeout(&request.script_code, timeout) - } else { - rt.run_code(&request.script_code) - }; - - match result { + Duration::from_millis(u64::from(request.script_timeout)) + }); + match Self::run_once(guest, &rewind, &request.script_code, timeout) { Ok(timing) => { logger.log_line(&format!( "hyperlight: run ok (restore={:.1}ms call={:.1}ms exit={})", @@ -588,8 +728,18 @@ impl ScriptRunner for HyperlightScriptRunner { ..Default::default() } } - Err(e) => { - let err = PyhlError::Runtime(format!("run_code: {e:#}")); + Err(failure) => { + // The guest is mid-call (blocked, killed, exited or + // deadlocked): nothing more will run in it. Drop it; the + // next call boots another from the rewind point. + self.guest = None; + let err = match failure { + RunError::TimedOut(timeout) => RunnerError::Runtime(format!( + "execution timed out after {:.1}s", + timeout.as_secs_f64() + )), + RunError::Failed(msg) => RunnerError::Runtime(format!("run: {msg}")), + }; logger.log_line(&err.to_string()); err.to_response() } @@ -597,18 +747,368 @@ impl ScriptRunner for HyperlightScriptRunner { } } +// -- Guest driving ----------------------------------------------------------- + +/// Run `code` and wait for it, giving up at `timeout`. +/// +/// The guest hands the vCPU back whenever every thread is blocked, so a +/// sleeping script is caught by bounding each `step`. A script that never +/// blocks (a busy loop) holds the vCPU inside one entry, which only an +/// interrupt from another thread can end; the watchdog fires it at the +/// deadline, and keeps firing until this thread confirms it is out, in +/// case the guest was between entries the first time. An interrupted +/// sandbox is poisoned, a merely blocked one still has the call in +/// flight: the caller discards the guest either way. +fn run_with_deadline( + sandbox: &mut AppSandbox, + code: &str, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + // Dropped by this thread once the call is over, however it ended, + // which wakes the watchdog at once instead of on its next tick. + let (finished_tx, finished_rx) = mpsc::channel::<()>(); + let watchdog = { + let handle = sandbox.interrupt_handle(); + std::thread::spawn(move || { + let mut wait = deadline.saturating_duration_since(Instant::now()); + loop { + match finished_rx.recv_timeout(wait) { + Err(RecvTimeoutError::Timeout) => {} + // The call is over: finished before the deadline, or + // the caller is gone. + _ => return, + } + // Past the deadline. `kill` breaks an entry in progress; + // between entries (in a host call, say) it only marks the + // next one cancelled and reports nothing, so retry until + // the call is over either way. + if handle.kill() { + return; + } + wait = Duration::from_millis(10); + } + }) + }; + + let outcome = drive_until(sandbox, code, deadline); + drop(finished_tx); + let _ = watchdog.join(); + + match outcome { + Ok(Some(status)) => Ok(status), + Ok(None) => Err(RunError::TimedOut(timeout)), + // Only the watchdog kills this sandbox, and Hyperlight clears a + // kill at the start of every entry, so a cancelled entry is this + // call's timeout however the kill landed. + Err(hyperlight_unikraft::Error::Hyperlight(HyperlightError::ExecutionCanceledByHost())) => { + Err(RunError::TimedOut(timeout)) + } + Err(e) => Err(RunError::Failed(e.to_string())), + } +} + +/// Submit `code` and step the guest until the call returns (`Some(status)`) +/// or `deadline` passes (`None`). +fn drive_until( + sandbox: &mut AppSandbox, + code: &str, + deadline: Instant, +) -> Result, hyperlight_unikraft::Error> { + sandbox.submit(code)?; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(None); + } + match sandbox.step(remaining)? { + Yield::CallDone => return Ok(Some(0)), + Yield::CallFailed { status } => return Ok(Some(status)), + Yield::Exited { status } => { + return Err(hyperlight_unikraft::Error::GuestExited { status }) + } + Yield::Blocked { .. } => {} + } + } +} + +// -- Install ----------------------------------------------------------------- + +/// Boot the rootfs in `home` without mounts, snapshot the warmed guest to +/// `home/snapshot` (replacing any snapshot there), and hand it back. +/// Boot the rootfs in `home`, save the warm image beside its snapshot +/// directory and publish it there. The flag says whether the new layout +/// is what now sits in the snapshot directory; when it is not, the image +/// in memory is still this warm's and the log says what happened on disk. +fn warm_snapshot(home: &Path, logger: &mut Logger) -> Result<(Arc, bool), RunnerError> { + let snapshot_dir = home.join(SNAPSHOT_DIR); + logger.log_line(&format!( + "hyperlight: booting {:?} to warm a snapshot", + home.join(INITRD_FILE) + )); + let t = Instant::now(); + configure_surrogates(); + let mut sandbox = rootfs_builder(home) + .boot() + .map_err(|e| RunnerError::Runtime(format!("boot hyperlight rootfs: {e}")))?; + // Saved beside the snapshot directory, so a failed save leaves whatever + // is there standing, then published by rename. + let staged = home.join(format!(".{SNAPSHOT_DIR}.{}.part", std::process::id())); + let _ = std::fs::remove_dir_all(&staged); + let snapshot = match sandbox.snapshot_to(&staged) { + Ok(snapshot) => snapshot, + Err(e) => { + let _ = std::fs::remove_dir_all(&staged); + return Err(RunnerError::Runtime(format!( + "save snapshot to {staged:?}: {e}" + ))); + } + }; + let persisted = publish_snapshot(home, &staged, &snapshot_dir, logger); + logger.log_line(&format!( + "hyperlight: warm-up took {:.1}ms; snapshot at {snapshot_dir:?}", + t.elapsed().as_secs_f64() * 1000.0 + )); + Ok((snapshot, persisted)) +} + +/// Move the layout saved at `staged` into `snapshot_dir`: the old layout is +/// set aside by rename first and dropped only once the new one is in place, +/// so the directory is absent only between two renames. Two warms at once +/// each save a whole layout, and the one whose move lands is the one that +/// stays. Returns whether a fresh layout is now in place: this warm's, or +/// another warm's that landed first. Otherwise the old layout is back, or, +/// if it could not be put back either, left at its `.old` path, which the +/// log names. +fn publish_snapshot(home: &Path, staged: &Path, snapshot_dir: &Path, logger: &mut Logger) -> bool { + let old = home.join(format!(".{SNAPSHOT_DIR}.{}.old", std::process::id())); + let had_old = match std::fs::rename(snapshot_dir, &old) { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => { + logger.log_line(&format!( + "hyperlight: set aside old snapshot {snapshot_dir:?}: {e}" + )); + let _ = std::fs::remove_dir_all(staged); + return false; + } + }; + let moved = std::fs::rename(staged, snapshot_dir); + if let Err(e) = &moved { + logger.log_line(&format!( + "hyperlight: move snapshot into place at {snapshot_dir:?}: {e}" + )); + let _ = std::fs::remove_dir_all(staged); + } + match (moved, had_old) { + (Ok(()), true) => { + let _ = std::fs::remove_dir_all(&old); + true + } + (Ok(()), false) => true, + (Err(_), true) => { + if std::fs::rename(&old, snapshot_dir).is_ok() { + logger.log_line(&format!( + "hyperlight: old snapshot put back at {snapshot_dir:?}" + )); + return false; + } + if hyperlight_unikraft::load_snapshot(snapshot_dir).is_ok() { + // Another warm landed in the meantime; its layout stays. + logger.log_line(&format!( + "hyperlight: another warm published {snapshot_dir:?} first" + )); + let _ = std::fs::remove_dir_all(&old); + return true; + } + logger.log_line(&format!("hyperlight: old snapshot left at {old:?}")); + false + } + (Err(_), false) => { + let landed = hyperlight_unikraft::load_snapshot(snapshot_dir).is_ok(); + if landed { + logger.log_line(&format!( + "hyperlight: another warm published {snapshot_dir:?} first" + )); + } + landed + } + } +} + +/// The persisted snapshot in `home`, warming and persisting one first +/// when there is none this build loads. +fn load_persisted_snapshot(home: &Path, logger: &mut Logger) -> Result, RunnerError> { + let snapshot_dir = home.join(SNAPSHOT_DIR); + let unloadable = match hyperlight_unikraft::load_snapshot(&snapshot_dir) { + Ok(snapshot) => { + logger.log_line(&format!("hyperlight: using image home {home:?}")); + return Ok(snapshot); + } + Err(e) => e, + }; + if !has_install_source(home) { + return Err(RunnerError::Preflight(ERR_NO_INSTALL_SOURCE.to_string())); + } + logger.log_line(&match unloadable { + hyperlight_unikraft::Error::SnapshotRelease { saved_by, .. } => format!( + "hyperlight: the snapshot at {snapshot_dir:?} was saved by hyperlight-unikraft \ + {saved_by}; warming one for this build from the rootfs" + ), + e => format!( + "hyperlight: no snapshot to load at {snapshot_dir:?} ({e}); warming one from the \ + rootfs" + ), + }); + let (snapshot, persisted) = warm_snapshot(home, logger)?; + if !persisted { + logger.log_line( + "hyperlight: running from the warm image in memory; the next run warms again", + ); + } + Ok(snapshot) +} + +/// A builder for a fresh boot of the rootfs in `home`. +fn rootfs_builder(home: &Path) -> SandboxBuilder { + SandboxBuilder::from_initrd(home.join(INITRD_FILE)).scratch_mb(SCRATCH_MB) +} + +fn with_network( + builder: SandboxBuilder, + policy: &Option, +) -> SandboxBuilder { + match policy { + Some(policy) => builder.network(policy.clone()), + None => builder, + } +} + +/// Pull the rootfs CPIO out of the published image into `dst`, staged +/// beside it and renamed into place so a failed pull leaves no +/// half-written rootfs. +fn pull_rootfs(dst: &Path, logger: &mut Logger) -> Result<(), String> { + use std::process::Command; + + let tool = find_on_path(&["docker", "podman"]).ok_or_else(|| { + format!( + "neither docker nor podman is on $PATH; install one, or drop `{INITRD_FILE}` \ + into the image home by hand" + ) + })?; + let image = format!("{ROOTFS_IMAGE}:{ROOTFS_TAG}"); + let run = |cmd: &mut Command, label: &str| -> Result<(), String> { + let out = cmd + .output() + .map_err(|e| format!("spawn {tool} {label}: {e}"))?; + if !out.status.success() { + return Err(format!( + "{tool} {label} failed (exit {:?}): {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(()) + }; + + logger.log_line(&format!("hyperlight setup: {tool} pull {image}")); + run(Command::new(tool).args(["pull", &image]), "pull")?; + + let cname = format!("mxc-hyperlight-rootfs-{}", std::process::id()); + let _ = Command::new(tool).args(["rm", "-f", &cname]).output(); + // A scratch image has no command, and `create` insists on one; any + // string does, the container is never started. + run( + Command::new(tool).args(["create", "--name", &cname, &image, "/"]), + "create", + )?; + let _cleanup = ContainerCleanup { + tool, + cname: &cname, + }; + + let staged = dst.with_file_name(format!(".{INITRD_FILE}.part")); + let staged_str = staged + .to_str() + .ok_or_else(|| format!("image home path {staged:?} is not valid UTF-8"))?; + run( + Command::new(tool).args(["cp", &format!("{cname}:{ROOTFS_PATH_IN_IMAGE}"), staged_str]), + "cp", + )?; + std::fs::rename(&staged, dst).map_err(|e| format!("move rootfs into place at {dst:?}: {e}")) +} + +/// Removes the extraction container on drop, success or failure. +struct ContainerCleanup<'a> { + tool: &'a str, + cname: &'a str, +} + +impl Drop for ContainerCleanup<'_> { + fn drop(&mut self) { + let _ = std::process::Command::new(self.tool) + .args(["rm", "-f", self.cname]) + .output(); + } +} + +/// Return the first name in `names` present as an executable on `$PATH`. +fn find_on_path(names: &[&'static str]) -> Option<&'static str> { + let path = std::env::var_os("PATH")?; + names.iter().copied().find(|name| { + std::env::split_paths(&path).any(|dir| { + let candidate = dir.join(name); + let Ok(md) = candidate.metadata() else { + return false; + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + md.is_file() && md.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + md.is_file() + } + }) + }) +} + +/// One guest per process: the runner holds at most one sandbox, and the +/// warm boot is dropped before the restore boots, so Hyperlight's +/// single-VM mode fits and skips its 512 pre-spawned helper processes on +/// Windows (about 3.5 s on first boot). Must run before any sandbox +/// exists; later calls are no-ops. +fn configure_surrogates() { + #[cfg(windows)] + hyperlight_unikraft::configure_surrogates(0); +} + // -- Helpers ----------------------------------------------------------------- -/// A home has a warmed snapshot (plus kernel + initrd) — ready to load. +/// A home has a snapshot this build loads, beside no rootfs of another +/// release. The rootfs is only needed to warm, not to run. fn is_installed(home: &Path) -> bool { - home.join(KERNEL_FILE).is_file() - && home.join(INITRD_FILE).is_file() - && home.join(SNAPSHOT_DIR).join("index.json").is_file() + stamp_matches(home) && hyperlight_unikraft::load_snapshot(home.join(SNAPSHOT_DIR)).is_ok() } -/// A home has the raw inputs we need to auto-install a snapshot. +/// A home has a rootfs of this release — enough to warm a snapshot from. +/// A rootfs with no stamp (dropped in by hand) is taken on trust; one +/// stamped for another release is not, since it will not boot on this +/// release's kernel. fn has_install_source(home: &Path) -> bool { - home.join(KERNEL_FILE).is_file() && home.join(INITRD_FILE).is_file() + home.join(INITRD_FILE).is_file() && stamp_matches(home) +} + +fn version_stamp() -> String { + format!("rootfs: {ROOTFS_IMAGE}:{ROOTFS_TAG}\n") +} + +fn stamp_matches(home: &Path) -> bool { + match std::fs::read_to_string(home.join(VERSION_FILE)) { + Ok(stamp) => stamp.trim() == version_stamp().trim(), + Err(e) => e.kind() == std::io::ErrorKind::NotFound, + } } /// Paths equal after canonicalization (best-effort). @@ -618,13 +1118,11 @@ fn same_path(a: &str, b: &str) -> bool { ap == bp } -fn preopens_equal(a: &[Preopen], b: &[Preopen]) -> bool { - if a.len() != b.len() { - return false; - } - a.iter() - .zip(b.iter()) - .all(|(x, y)| x.host_dir == y.host_dir && x.guest_path == y.guest_path) +fn mounts_equal(a: &[Mount], b: &[Mount]) -> bool { + a.len() == b.len() + && a.iter().zip(b.iter()).all(|(x, y)| { + x.host_path == y.host_path && x.guest_path == y.guest_path && x.readonly == y.readonly + }) } /// OS-local data directory (the "user Application Data" root). @@ -671,37 +1169,50 @@ mod tests { HyperlightScriptRunner::new() } - #[test] - fn is_installed_false_on_empty_dir() { - let tmp = std::env::temp_dir().join(format!("hl-runner-test-{}", std::process::id())); + fn fresh_tmp(tag: &str) -> PathBuf { + let tmp = std::env::temp_dir().join(format!("hl-runner-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); + tmp + } + + #[test] + fn is_installed_false_on_empty_dir() { + let tmp = fresh_tmp("empty"); assert!(!is_installed(&tmp)); assert!(!has_install_source(&tmp)); + let _ = std::fs::remove_dir_all(&tmp); } #[test] - fn has_install_source_true_when_kernel_and_initrd_present() { - let tmp = - std::env::temp_dir().join(format!("hl-runner-install-src-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&tmp); - std::fs::create_dir_all(&tmp).unwrap(); - std::fs::write(tmp.join(KERNEL_FILE), b"").unwrap(); + fn has_install_source_true_when_rootfs_present() { + let tmp = fresh_tmp("install-src"); std::fs::write(tmp.join(INITRD_FILE), b"").unwrap(); assert!(has_install_source(&tmp)); assert!(!is_installed(&tmp)); // snapshot still absent let _ = std::fs::remove_dir_all(&tmp); } + #[test] + fn rootfs_stamped_for_another_release_is_not_an_install_source() { + let tmp = fresh_tmp("stale"); + std::fs::write(tmp.join(INITRD_FILE), b"").unwrap(); + std::fs::write(tmp.join(VERSION_FILE), "pyhl 0.12.1\n").unwrap(); + assert!(!has_install_source(&tmp)); + assert!(!is_installed(&tmp)); + + std::fs::write(tmp.join(VERSION_FILE), version_stamp()).unwrap(); + assert!(has_install_source(&tmp)); + let _ = std::fs::remove_dir_all(&tmp); + } + #[test] fn resolve_home_errors_when_nothing_configured() { // Redirect every candidate away from any real install on the // test machine: PYHL_HOME, XDG_DATA_HOME (Linux), LOCALAPPDATA // (Windows), HOME/USERPROFILE all get pointed into an empty // tmpdir for the duration of this test. - let empty = std::env::temp_dir().join(format!("hl-resolve-empty-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&empty); - std::fs::create_dir_all(&empty).unwrap(); + let empty = fresh_tmp("resolve-empty"); let saved: Vec<(&str, Option)> = [ PYHL_HOME_ENV, @@ -745,11 +1256,10 @@ mod tests { } #[test] - fn policy_accepts_readwrite_paths_and_builds_preopens() { + fn policy_accepts_readwrite_paths_and_builds_mounts() { // We can't end-to-end test without a real image; just verify - // the policy→Preopen mapping. - let tmp = std::env::temp_dir().join(format!("hl-mount-{}", std::process::id())); - std::fs::create_dir_all(&tmp).unwrap(); + // the policy→Mount mapping. + let tmp = fresh_tmp("mount"); let request = ExecutionRequest { policy: ContainerPolicy { readwrite_paths: vec![tmp.to_string_lossy().to_string()], @@ -757,12 +1267,30 @@ mod tests { }, ..Default::default() }; - let preopens = HyperlightScriptRunner::preopens_from_policy(&request).unwrap(); - assert_eq!(preopens.len(), 1); + let mounts = HyperlightScriptRunner::mounts_from_policy(&request).unwrap(); + assert_eq!(mounts.len(), 1); assert_eq!( - preopens[0].guest_path, + mounts[0].guest_path, format!("/host/{}", tmp.file_name().unwrap().to_string_lossy()) ); + assert!(!mounts[0].readonly); + assert_eq!(mounts[0].host_path, std::fs::canonicalize(&tmp).unwrap()); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn policy_marks_readonly_paths() { + let tmp = fresh_tmp("mount-ro"); + let request = ExecutionRequest { + policy: ContainerPolicy { + readonly_paths: vec![tmp.to_string_lossy().to_string()], + ..Default::default() + }, + ..Default::default() + }; + let mounts = HyperlightScriptRunner::mounts_from_policy(&request).unwrap(); + assert_eq!(mounts.len(), 1); + assert!(mounts[0].readonly); let _ = std::fs::remove_dir_all(&tmp); } @@ -782,7 +1310,7 @@ mod tests { }, ..Default::default() }; - let err = HyperlightScriptRunner::preopens_from_policy(&request).unwrap_err(); + let err = HyperlightScriptRunner::mounts_from_policy(&request).unwrap_err(); assert!( err.to_string().contains("collide on guest path"), "got: {err}" @@ -791,6 +1319,25 @@ mod tests { let _ = std::fs::remove_dir_all(b.parent().unwrap()); } + #[test] + fn policy_rejects_mount_name_the_kernel_cannot_carry() { + let tmp = fresh_tmp("mount-bad").join("with space"); + std::fs::create_dir_all(&tmp).unwrap(); + let request = ExecutionRequest { + policy: ContainerPolicy { + readwrite_paths: vec![tmp.to_string_lossy().to_string()], + ..Default::default() + }, + ..Default::default() + }; + let err = HyperlightScriptRunner::mounts_from_policy(&request).unwrap_err(); + assert!( + err.to_string().contains("may not contain whitespace"), + "got: {err}" + ); + let _ = std::fs::remove_dir_all(tmp.parent().unwrap()); + } + #[test] fn policy_rejects_denied_overlapping_allow() { let mut r = runner(); @@ -809,6 +1356,32 @@ mod tests { assert!(resp.error_message.contains("deniedPaths")); } + #[test] + fn network_key_tells_an_allow_list_from_a_block_list() { + let allow = ExecutionRequest { + policy: ContainerPolicy { + allowed_hosts: vec!["a.example".to_string()], + ..Default::default() + }, + ..Default::default() + }; + let block = ExecutionRequest { + policy: ContainerPolicy { + blocked_hosts: vec!["a.example".to_string()], + ..Default::default() + }, + ..Default::default() + }; + assert_ne!( + NetworkKey::from_request(&allow), + NetworkKey::from_request(&block) + ); + assert_eq!( + NetworkKey::from_request(&allow), + NetworkKey::from_request(&allow) + ); + } + #[test] fn network_policy_allow_all_when_default_allow() { let request = ExecutionRequest { @@ -818,7 +1391,9 @@ mod tests { }, ..Default::default() }; - let policy = HyperlightScriptRunner::network_policy_from_request(&request).unwrap(); + let policy = + HyperlightScriptRunner::network_policy_from_key(&NetworkKey::from_request(&request)) + .unwrap(); assert!(matches!( policy, Some(hyperlight_unikraft::NetworkPolicy::AllowAll) @@ -834,7 +1409,9 @@ mod tests { }, ..Default::default() }; - let policy = HyperlightScriptRunner::network_policy_from_request(&request).unwrap(); + let policy = + HyperlightScriptRunner::network_policy_from_key(&NetworkKey::from_request(&request)) + .unwrap(); assert!(matches!( policy, Some(hyperlight_unikraft::NetworkPolicy::AllowList(_)) @@ -850,7 +1427,9 @@ mod tests { }, ..Default::default() }; - let policy = HyperlightScriptRunner::network_policy_from_request(&request).unwrap(); + let policy = + HyperlightScriptRunner::network_policy_from_key(&NetworkKey::from_request(&request)) + .unwrap(); assert!(policy.is_none()); } @@ -863,7 +1442,9 @@ mod tests { }, ..Default::default() }; - let policy = HyperlightScriptRunner::network_policy_from_request(&request).unwrap(); + let policy = + HyperlightScriptRunner::network_policy_from_key(&NetworkKey::from_request(&request)) + .unwrap(); assert!(matches!( policy, Some(hyperlight_unikraft::NetworkPolicy::BlockList(_)) diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 182857a3c..67fdc0c7d 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -64,8 +64,8 @@ struct Cli { log_file: Option, /// Install the warmed Hyperlight snapshot and exit. Pulls the - /// published kernel + initrd from GHCR (via docker or podman), - /// warms them up, and writes the snapshot into the default user + /// published rootfs from GHCR (via docker or podman), boots it + /// once, and writes the snapshot into the default user /// data dir (~/.local/share/pyhl on Linux, %LOCALAPPDATA%\pyhl on /// Windows). $PYHL_HOME overrides the destination if set. Intended /// for tool install hooks so first-run has zero warmup cost. @@ -73,8 +73,8 @@ struct Cli { setup_hyperlight: bool, /// Rebuild the snapshot even if one already exists. Use after - /// upgrading `kernel` or `initrd.cpio` so the warm state matches - /// the new bits. Requires --setup-hyperlight. + /// replacing `initrd.cpio` so the warm state matches the new + /// bits. Requires --setup-hyperlight. #[arg(long, requires = "setup_hyperlight")] force: bool, } @@ -162,7 +162,7 @@ fn main() { if cli.setup_hyperlight { #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] { - // WHP is delay-loaded; check before pyhl::install warms a VM. + // WHP is delay-loaded; check before setup boots a VM. #[cfg(target_os = "windows")] if !hyperlight_common::is_whp_available() { eprintln!( @@ -171,6 +171,15 @@ fn main() { ); process::exit(1); } + // KVM is checked before anything is pulled. + #[cfg(target_os = "linux")] + if !hyperlight_common::is_kvm_available() { + eprintln!( + "Error: --setup-hyperlight requires KVM: /dev/kvm must be readable and \ + writable by this user." + ); + process::exit(1); + } let mut logger = Logger::new(if cli.debug { Mode::Console diff --git a/src/core/mxc_engine/src/probe.rs b/src/core/mxc_engine/src/probe.rs index 7fab5565f..aace1f776 100644 --- a/src/core/mxc_engine/src/probe.rs +++ b/src/core/mxc_engine/src/probe.rs @@ -229,7 +229,7 @@ fn windows_backends( )); } - // WHP is delay-loaded; check before pyhl::install warms a VM. + // WHP is delay-loaded; check before setup boots a VM. #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] if hyperlight_common::is_whp_available() { backends.push(AvailableBackend::tierless( diff --git a/src/core/mxc_engine/src/run.rs b/src/core/mxc_engine/src/run.rs index baf8ed5c7..3f6bd6052 100644 --- a/src/core/mxc_engine/src/run.rs +++ b/src/core/mxc_engine/src/run.rs @@ -492,7 +492,8 @@ fn resolve_runner_inner( /// Construct the Hyperlight runner, shared by the Windows and Linux bodies. /// Requires x86_64 (Hyperlight needs KVM or WHP) and the `hyperlight` feature. /// On Windows, pre-checks that `winhvplatform.dll` is loadable so a missing -/// WHP becomes a typed error rather than a delay-load SEH exception. +/// WHP becomes a typed error rather than a delay-load SEH exception; on +/// Linux, that `/dev/kvm` opens for reading and writing, for the same reason. #[cfg(any(target_os = "windows", target_os = "linux"))] fn resolve_hyperlight(request: &ExecutionRequest) -> Result { #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] @@ -503,7 +504,7 @@ fn resolve_hyperlight(request: &ExecutionRequest) -> Result Result, /// Install the warmed Hyperlight snapshot and exit. Pulls the - /// published kernel + initrd from GHCR (via docker or podman), - /// warms them up, and writes the snapshot into the default user + /// published rootfs from GHCR (via docker or podman), boots it + /// once, and writes the snapshot into the default user /// data dir (~/.local/share/pyhl on Linux, %LOCALAPPDATA%\pyhl on /// Windows). $PYHL_HOME overrides the destination if set. Intended /// for tool install hooks so first-run has zero warmup cost. @@ -78,8 +78,8 @@ struct Cli { setup_hyperlight: bool, /// Rebuild the snapshot even if one already exists. Use after - /// upgrading `kernel` or `initrd.cpio` so the warm state matches - /// the new bits. Requires --setup-hyperlight. + /// replacing `initrd.cpio` so the warm state matches the new + /// bits. Requires --setup-hyperlight. #[arg(long, requires = "setup_hyperlight")] force: bool, @@ -1013,7 +1013,7 @@ fn main() { output.probes.isolation_session_available = mxc_engine::isolation_session_available(); output }; - // WHP is delay-loaded; check before pyhl::install warms a VM. + // WHP is delay-loaded; check before setup boots a VM. #[cfg(all(target_os = "windows", feature = "hyperlight", target_arch = "x86_64"))] let output = { let mut output = output; @@ -1064,7 +1064,7 @@ fn main() { if cli.setup_hyperlight { #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] { - // WHP is delay-loaded; check before pyhl::install warms a VM. + // WHP is delay-loaded; check before setup boots a VM. #[cfg(target_os = "windows")] if !hyperlight_common::is_whp_available() { eprintln!( From 309806332743c483724bce61a84502dc4a5196a8 Mon Sep 17 00:00:00 2001 From: danbugs Date: Tue, 22 Sep 2026 18:55:49 +0000 Subject: [PATCH 3/7] hyperlight: rename the image home from pyhl to mxc-hyperlight pyhl was the 0.12 library's Python tool, gone since 0.13; nothing in the backend is pyhl any more. The override is now $MXC_HYPERLIGHT_HOME (the MXC_ prefix the other backends use) and the data directory ~/.local/share/mxc-hyperlight or %LOCALAPPDATA%\mxc-hyperlight, with the dev fallbacks /mxc-hyperlight and /.mxc-hyperlight. An old pyhl home is simply no longer looked at. Signed-off-by: danbugs --- .github/workflows/hyperlight-e2e.yml | 12 +++--- docs/hyperlight-integration-plan.md | 6 ++- src/backends/hyperlight/common/src/lib.rs | 51 +++++++++++++---------- src/core/lxc/src/main.rs | 4 +- src/core/wxc/src/main.rs | 4 +- src/testing/wxc_e2e_tests/src/lib.rs | 4 +- 6 files changed, 44 insertions(+), 37 deletions(-) diff --git a/.github/workflows/hyperlight-e2e.yml b/.github/workflows/hyperlight-e2e.yml index c39a4cd8d..fb94a5e84 100644 --- a/.github/workflows/hyperlight-e2e.yml +++ b/.github/workflows/hyperlight-e2e.yml @@ -75,18 +75,18 @@ jobs: if: steps.whp-check.outputs.whp_available == 'true' shell: pwsh run: | - $pyhlHome = Join-Path $env:LOCALAPPDATA "pyhl" - New-Item -ItemType Directory -Force -Path $pyhlHome | Out-Null + $hlHome = Join-Path $env:LOCALAPPDATA "mxc-hyperlight" + New-Item -ItemType Directory -Force -Path $hlHome | Out-Null # The Unikraft kernel is embedded in the hyperlight-unikraft crate; only # the agent rootfs (initrd.cpio) is fetched, pinned to the crate release # declared in src/backends/hyperlight/common/Cargo.toml. $tag = "initrd-v0.14.1" .\crane.exe export ghcr.io/hyperlight-dev/hyperlight-unikraft/agent:$tag initrd.tar - tar -xf initrd.tar -C $pyhlHome initrd.cpio + tar -xf initrd.tar -C $hlHome initrd.cpio - Write-Host "Downloaded to ${pyhlHome}:" - Get-ChildItem $pyhlHome + Write-Host "Downloaded to ${hlHome}:" + Get-ChildItem $hlHome - name: Warm Hyperlight snapshot if: steps.whp-check.outputs.whp_available == 'true' @@ -101,7 +101,7 @@ jobs: exit 1 } Write-Host "Snapshot created:" - Get-ChildItem (Join-Path $env:LOCALAPPDATA "pyhl") + Get-ChildItem (Join-Path $env:LOCALAPPDATA "mxc-hyperlight") - name: Run Hyperlight E2E Tests if: steps.whp-check.outputs.whp_available == 'true' diff --git a/docs/hyperlight-integration-plan.md b/docs/hyperlight-integration-plan.md index 21bfa4057..d272313a6 100644 --- a/docs/hyperlight-integration-plan.md +++ b/docs/hyperlight-integration-plan.md @@ -91,8 +91,10 @@ C/C++, PowerShell, and Bash/Shell runtimes. 5. **Image artifacts are user-provided, not bundled.** The `--setup-hyperlight` flag populates the image home. The runner auto-discovers - `$PYHL_HOME` → `/pyhl/` → `/.pyhl/`; the first location - with all three files wins. + `$MXC_HYPERLIGHT_HOME` → `~/.local/share/mxc-hyperlight/` (or + `%LOCALAPPDATA%\mxc-hyperlight\`) → `/mxc-hyperlight/` → + `/.mxc-hyperlight/`; the first location holding the rootfs and a + snapshot of this build wins. 6. **Exit codes.** 0 on clean completion of `run_code`; -1 on any error (preflight, runtime, guest crash). Distinct per-error variants go diff --git a/src/backends/hyperlight/common/src/lib.rs b/src/backends/hyperlight/common/src/lib.rs index 90c32c253..b4a521f9e 100644 --- a/src/backends/hyperlight/common/src/lib.rs +++ b/src/backends/hyperlight/common/src/lib.rs @@ -30,11 +30,11 @@ //! //! The runner looks for a warmed image in this order, first hit wins: //! -//! 1. `$PYHL_HOME` (searched first when set) -//! 2. `~/.local/share/pyhl/` on Linux (XDG_DATA_HOME compliant) -//! `%LOCALAPPDATA%\pyhl\` on Windows -//! 3. `/pyhl/` (dev build next to the target binary) -//! 4. `/.pyhl/` (dev fallback) +//! 1. `$MXC_HYPERLIGHT_HOME` (searched first when set) +//! 2. `~/.local/share/mxc-hyperlight/` on Linux (XDG_DATA_HOME compliant) +//! `%LOCALAPPDATA%\mxc-hyperlight\` on Windows +//! 3. `/mxc-hyperlight/` (dev build next to the target binary) +//! 4. `/.mxc-hyperlight/` (dev fallback) //! //! Path #2 is the "default". `--setup-hyperlight` installs here when nothing //! else is already populated — so one eager install persists across @@ -164,15 +164,15 @@ const ERROR_EXIT_CODE: i32 = -1; /// Env var override for the Hyperlight image home. Set this to force a /// specific location; otherwise the runner uses a standard OS-local -/// data path (~/.local/share/pyhl on Linux, %LOCALAPPDATA%\pyhl on +/// data path (~/.local/share/mxc-hyperlight on Linux, %LOCALAPPDATA%\mxc-hyperlight on /// Windows). -const PYHL_HOME_ENV: &str = "PYHL_HOME"; +const HOME_ENV: &str = "MXC_HYPERLIGHT_HOME"; /// Subdirectory used next to the running executable (dev builds). -const EXE_RELATIVE_HOME: &str = "pyhl"; +const EXE_RELATIVE_HOME: &str = "mxc-hyperlight"; /// Subdirectory used in the cwd as a last resort (dev fallback). -const CWD_RELATIVE_HOME: &str = ".pyhl"; +const CWD_RELATIVE_HOME: &str = ".mxc-hyperlight"; /// Final component of the default OS-local data path. -const DEFAULT_HOME_LEAF: &str = "pyhl"; +const DEFAULT_HOME_LEAF: &str = "mxc-hyperlight"; /// The guest rootfs (a CPIO archive) inside an image home. The kernel is /// embedded in the `hyperlight-unikraft` crate, so this is the only @@ -289,10 +289,10 @@ impl Default for HyperlightScriptRunner { /// /// # Destination /// -/// `$PYHL_HOME` if set, otherwise the OS-local default -/// (`~/.local/share/pyhl` on Linux, `%LOCALAPPDATA%\pyhl` on +/// `$MXC_HYPERLIGHT_HOME` if set, otherwise the OS-local default +/// (`~/.local/share/mxc-hyperlight` on Linux, `%LOCALAPPDATA%\mxc-hyperlight` on /// Windows). We intentionally do NOT walk the runtime search chain -/// here — that would let a stale `/.pyhl/` from an old dev +/// here — that would let a stale `/.mxc-hyperlight/` from an old dev /// session short-circuit the install and leave the default home /// empty, which would make later runs from a different cwd fail. /// @@ -302,7 +302,7 @@ impl Default for HyperlightScriptRunner { /// no-op; one left by another release is rebuilt. When `force` is true, /// the snapshot is rebuilt regardless. pub fn setup(force: bool, logger: &mut Logger) -> Result { - let home = match std::env::var_os(PYHL_HOME_ENV) { + let home = match std::env::var_os(HOME_ENV) { Some(v) => PathBuf::from(v), None => HyperlightScriptRunner::default_home(), }; @@ -382,7 +382,7 @@ impl HyperlightScriptRunner { ), }; Err(RunnerError::Preflight(format!( - "no hyperlight image found. searched ${PYHL_HOME_ENV}, {default:?}, \ + "no hyperlight image found. searched ${HOME_ENV}, {default:?}, \ /{EXE_RELATIVE_HOME}/, /{CWD_RELATIVE_HOME}/. {hint}" ))) } @@ -390,7 +390,7 @@ impl HyperlightScriptRunner { /// Candidate locations, in priority order. fn search_paths() -> Vec { let mut paths = Vec::with_capacity(4); - if let Some(explicit) = std::env::var_os(PYHL_HOME_ENV) { + if let Some(explicit) = std::env::var_os(HOME_ENV) { paths.push(PathBuf::from(explicit)); } paths.push(Self::default_home()); @@ -407,10 +407,10 @@ impl HyperlightScriptRunner { /// The OS-local default data directory. Setup writes here /// when nothing else is already populated, and it's always second - /// in the resolution chain (after $PYHL_HOME). + /// in the resolution chain (after $MXC_HYPERLIGHT_HOME). /// - /// - Linux: `$XDG_DATA_HOME/pyhl` (or `~/.local/share/pyhl`) - /// - Windows: `%LOCALAPPDATA%\pyhl` (or `~\AppData\Local\pyhl`) + /// - Linux: `$XDG_DATA_HOME/mxc-hyperlight` (or `~/.local/share/mxc-hyperlight`) + /// - Windows: `%LOCALAPPDATA%\mxc-hyperlight` (or `~\AppData\Local\mxc-hyperlight`) fn default_home() -> PathBuf { os_data_home().join(DEFAULT_HOME_LEAF) } @@ -1132,7 +1132,7 @@ fn mounts_equal(a: &[Mount], b: &[Mount]) -> bool { /// /// Returns `PathBuf::from(".")` if no candidate env vars are set (degrades /// gracefully rather than panicking; caller can still override via -/// `$PYHL_HOME`). +/// `$MXC_HYPERLIGHT_HOME`). fn os_data_home() -> PathBuf { #[cfg(windows)] { @@ -1197,7 +1197,12 @@ mod tests { fn rootfs_stamped_for_another_release_is_not_an_install_source() { let tmp = fresh_tmp("stale"); std::fs::write(tmp.join(INITRD_FILE), b"").unwrap(); - std::fs::write(tmp.join(VERSION_FILE), "pyhl 0.12.1\n").unwrap(); + // A stamp from an earlier release than ROOTFS_TAG names. + std::fs::write( + tmp.join(VERSION_FILE), + format!("rootfs: {ROOTFS_IMAGE}:initrd-v0.13.0\n"), + ) + .unwrap(); assert!(!has_install_source(&tmp)); assert!(!is_installed(&tmp)); @@ -1209,13 +1214,13 @@ mod tests { #[test] fn resolve_home_errors_when_nothing_configured() { // Redirect every candidate away from any real install on the - // test machine: PYHL_HOME, XDG_DATA_HOME (Linux), LOCALAPPDATA + // test machine: MXC_HYPERLIGHT_HOME, XDG_DATA_HOME (Linux), LOCALAPPDATA // (Windows), HOME/USERPROFILE all get pointed into an empty // tmpdir for the duration of this test. let empty = fresh_tmp("resolve-empty"); let saved: Vec<(&str, Option)> = [ - PYHL_HOME_ENV, + HOME_ENV, "XDG_DATA_HOME", "HOME", "LOCALAPPDATA", diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 67fdc0c7d..d46541000 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -66,8 +66,8 @@ struct Cli { /// Install the warmed Hyperlight snapshot and exit. Pulls the /// published rootfs from GHCR (via docker or podman), boots it /// once, and writes the snapshot into the default user - /// data dir (~/.local/share/pyhl on Linux, %LOCALAPPDATA%\pyhl on - /// Windows). $PYHL_HOME overrides the destination if set. Intended + /// data dir (~/.local/share/mxc-hyperlight on Linux, %LOCALAPPDATA%\mxc-hyperlight on + /// Windows). $MXC_HYPERLIGHT_HOME overrides the destination if set. Intended /// for tool install hooks so first-run has zero warmup cost. #[arg(long = "setup-hyperlight")] setup_hyperlight: bool, diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 2d5fdf190..615e49be4 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -71,8 +71,8 @@ struct Cli { /// Install the warmed Hyperlight snapshot and exit. Pulls the /// published rootfs from GHCR (via docker or podman), boots it /// once, and writes the snapshot into the default user - /// data dir (~/.local/share/pyhl on Linux, %LOCALAPPDATA%\pyhl on - /// Windows). $PYHL_HOME overrides the destination if set. Intended + /// data dir (~/.local/share/mxc-hyperlight on Linux, %LOCALAPPDATA%\mxc-hyperlight on + /// Windows). $MXC_HYPERLIGHT_HOME overrides the destination if set. Intended /// for tool install hooks so first-run has zero warmup cost. #[arg(long = "setup-hyperlight")] setup_hyperlight: bool, diff --git a/src/testing/wxc_e2e_tests/src/lib.rs b/src/testing/wxc_e2e_tests/src/lib.rs index f8a42776d..618740319 100644 --- a/src/testing/wxc_e2e_tests/src/lib.rs +++ b/src/testing/wxc_e2e_tests/src/lib.rs @@ -256,7 +256,7 @@ pub fn run_lxc_config(config_file: &str, extra_args: &[&str]) -> CommandResult { } /// Return whether the Hyperlight snapshot is installed at the default -/// location (`%LOCALAPPDATA%\pyhl\snapshot\index.json`). +/// location (`%LOCALAPPDATA%\mxc-hyperlight\snapshot\index.json`). pub fn has_hyperlight_snapshot() -> bool { let home = std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) @@ -265,7 +265,7 @@ pub fn has_hyperlight_snapshot() -> bool { .map(|v| PathBuf::from(v).join("AppData").join("Local")) .unwrap_or_default() }); - let snapshot = home.join("pyhl").join("snapshot").join("index.json"); + let snapshot = home.join("mxc-hyperlight").join("snapshot").join("index.json"); if snapshot.is_file() { println!("Using Hyperlight snapshot at {}", snapshot.display()); true From bacda7881ac8372e0c371b48b0932a5bcbf8e4a4 Mon Sep 17 00:00:00 2001 From: danbugs Date: Tue, 22 Sep 2026 18:55:49 +0000 Subject: [PATCH 4/7] hyperlight: pull the rootfs over the registry API instead of docker Setup used docker or podman to pull the rootfs image and copy initrd.cpio out of it, so a host without a container runtime could not set Hyperlight up. It now speaks just enough of the OCI distribution API itself: an anonymous pull token, the manifest (through an index when the tag names one), and the layer tarballs, streamed through gunzip and tar and checked against their digests. ureq (rustls with ring, as the 0.12 crate used), flate2, tar, sha2 and serde_json come in behind the hyperlight feature. The download is bounded (30 s to resolve and connect, 60 s to a response, 30 min for a layer) so a dead link fails instead of hanging setup. The e2e workflow no longer needs crane: it runs --setup-hyperlight, which also exercises the real install path in CI. Signed-off-by: danbugs --- .github/workflows/hyperlight-e2e.yml | 38 +-- src/Cargo.lock | 139 +++++++++- src/backends/hyperlight/common/Cargo.toml | 16 +- src/backends/hyperlight/common/src/lib.rs | 303 ++++++++++++++++------ src/core/lxc/src/main.rs | 11 +- src/core/wxc/src/main.rs | 11 +- 6 files changed, 389 insertions(+), 129 deletions(-) diff --git a/.github/workflows/hyperlight-e2e.yml b/.github/workflows/hyperlight-e2e.yml index fb94a5e84..781dfa8a4 100644 --- a/.github/workflows/hyperlight-e2e.yml +++ b/.github/workflows/hyperlight-e2e.yml @@ -63,44 +63,20 @@ jobs: shell: pwsh run: ./scripts/ci/check-whp.ps1 - - name: Install crane (OCI tool) - if: steps.whp-check.outputs.whp_available == 'true' - shell: pwsh - run: | - $url = "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Windows_x86_64.tar.gz" - Invoke-WebRequest -Uri $url -OutFile crane.tar.gz -UseBasicParsing - tar -xzf crane.tar.gz crane.exe - - - name: Download Hyperlight rootfs - if: steps.whp-check.outputs.whp_available == 'true' - shell: pwsh - run: | - $hlHome = Join-Path $env:LOCALAPPDATA "mxc-hyperlight" - New-Item -ItemType Directory -Force -Path $hlHome | Out-Null - - # The Unikraft kernel is embedded in the hyperlight-unikraft crate; only - # the agent rootfs (initrd.cpio) is fetched, pinned to the crate release - # declared in src/backends/hyperlight/common/Cargo.toml. - $tag = "initrd-v0.14.1" - .\crane.exe export ghcr.io/hyperlight-dev/hyperlight-unikraft/agent:$tag initrd.tar - tar -xf initrd.tar -C $hlHome initrd.cpio - - Write-Host "Downloaded to ${hlHome}:" - Get-ChildItem $hlHome - - - name: Warm Hyperlight snapshot + - name: Set up Hyperlight (pull rootfs, warm snapshot) if: steps.whp-check.outputs.whp_available == 'true' shell: pwsh run: | + # Pulls the agent rootfs straight from GHCR (no container runtime + # needed), boots it once, and saves the snapshot under + # %LOCALAPPDATA%\mxc-hyperlight, where the e2e tests look for it. $binDir = Join-Path $env:GITHUB_WORKSPACE "src\target\x86_64-pc-windows-msvc\debug" - $json = '{"version":"0.10.0-alpha","process":{"commandLine":"print(\"snapshot warm\")"},"containment":"hyperlight"}' - $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json)) - & "$binDir\wxc-exec.exe" --experimental --config-base64 $b64 2>&1 + & "$binDir\wxc-exec.exe" --setup-hyperlight --debug 2>&1 if ($LASTEXITCODE -ne 0) { - Write-Host "::error::Hyperlight snapshot warmup failed" + Write-Host "::error::Hyperlight setup failed" exit 1 } - Write-Host "Snapshot created:" + Write-Host "Image home:" Get-ChildItem (Join-Path $env:LOCALAPPDATA "mxc-hyperlight") - name: Run Hyperlight E2E Tests diff --git a/src/Cargo.lock b/src/Cargo.lock index 0e0b76be6..acc471d61 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -127,6 +127,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bindgen" version = "0.72.1" @@ -1098,7 +1104,12 @@ dependencies = [ name = "hyperlight_common" version = "0.8.0" dependencies = [ + "flate2", "hyperlight-unikraft", + "serde_json", + "sha2 0.10.9", + "tar", + "ureq", "windows", "wxc_common", ] @@ -1303,7 +1314,7 @@ dependencies = [ name = "isolation_session_common" version = "0.8.0" dependencies = [ - "base64", + "base64 0.22.1", "isolation_session_bindings", "serde", "serde_json", @@ -2099,6 +2110,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -2173,6 +2198,41 @@ dependencies = [ "rustix", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2447,6 +2507,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.118" @@ -2651,6 +2717,41 @@ dependencies = [ "tokio", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -2663,6 +2764,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2836,6 +2943,15 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "widestring" version = "1.2.1" @@ -2974,6 +3090,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -3234,7 +3359,7 @@ name = "wslc_common" version = "0.8.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "libloading", "serde", "serde_json", @@ -3272,7 +3397,7 @@ dependencies = [ name = "wxc_common" version = "0.8.0" dependencies = [ - "base64", + "base64 0.22.1", "cidr", "filetime", "getrandom 0.2.17", @@ -3299,7 +3424,7 @@ dependencies = [ name = "wxc_e2e_tests" version = "0.8.0" dependencies = [ - "base64", + "base64 0.22.1", "bwrap_common", "serde", "serde_json", @@ -3478,6 +3603,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src/backends/hyperlight/common/Cargo.toml b/src/backends/hyperlight/common/Cargo.toml index d2b761b86..bbf81df0c 100644 --- a/src/backends/hyperlight/common/Cargo.toml +++ b/src/backends/hyperlight/common/Cargo.toml @@ -9,10 +9,24 @@ wxc_common = { workspace = true } [target.'cfg(target_arch = "x86_64")'.dependencies] hyperlight-unikraft = { version = "0.14.1", default-features = false, optional = true } +# Setup pulls the guest rootfs straight from the OCI registry, so no +# container runtime is needed on the host. +ureq = { version = "3", optional = true } +flate2 = { version = "1", optional = true } +tar = { version = "0.4", optional = true } +serde_json = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } [target.'cfg(target_os = "windows")'.dependencies] windows = { workspace = true } [features] default = [] -hyperlight = ["dep:hyperlight-unikraft"] +hyperlight = [ + "dep:hyperlight-unikraft", + "dep:ureq", + "dep:flate2", + "dep:tar", + "dep:serde_json", + "dep:sha2", +] diff --git a/src/backends/hyperlight/common/src/lib.rs b/src/backends/hyperlight/common/src/lib.rs index b4a521f9e..383cbac7d 100644 --- a/src/backends/hyperlight/common/src/lib.rs +++ b/src/backends/hyperlight/common/src/lib.rs @@ -53,9 +53,9 @@ //! ## Setup //! //! `lxc-exec --setup-hyperlight` (or `wxc-exec --setup-hyperlight`) pulls -//! the published `agent` rootfs from GHCR via docker or podman, boots it -//! once, and persists the warmed guest as a snapshot in the default home — -//! zero configuration beyond having docker/podman on `$PATH`. +//! the published `agent` rootfs straight from GHCR (no container runtime +//! needed), boots it once, and persists the warmed guest as a snapshot in +//! the default home. //! //! On first `run` (if setup was skipped) the runner also does a lazy //! auto-install if `initrd.cpio` is already in the resolved home but no @@ -283,9 +283,8 @@ impl Default for HyperlightScriptRunner { /// pays no warmup cost. Intended to be called from a tool install /// step (npm postinstall, a `--setup-hyperlight` CLI flag, CI, etc.). /// -/// Pulls the published rootfs from GHCR via docker or podman, boots it -/// once, and persists the warmed guest as a snapshot. Zero configuration -/// beyond having docker/podman on `$PATH`. +/// Pulls the published rootfs from GHCR over the registry API, boots it +/// once, and persists the warmed guest as a snapshot. /// /// # Destination /// @@ -327,7 +326,7 @@ pub fn setup(force: bool, logger: &mut Logger) -> Result { )); } else { logger.log_line(&format!( - "hyperlight setup: pulling {ROOTFS_IMAGE}:{ROOTFS_TAG} (docker/podman)" + "hyperlight setup: pulling {ROOTFS_IMAGE}:{ROOTFS_TAG}" )); pull_rootfs(&home.join(INITRD_FILE), logger)?; std::fs::write(home.join(VERSION_FILE), version_stamp()) @@ -984,94 +983,232 @@ fn with_network( } } -/// Pull the rootfs CPIO out of the published image into `dst`, staged -/// beside it and renamed into place so a failed pull leaves no +/// Pull the rootfs CPIO out of the published image into `dst`, straight +/// from the registry's distribution API: no container runtime needed. +/// Staged beside `dst` and renamed into place so a failed pull leaves no /// half-written rootfs. fn pull_rootfs(dst: &Path, logger: &mut Logger) -> Result<(), String> { - use std::process::Command; + // Staged per process, so two setups at once each pull their own copy. + let staged = dst.with_file_name(format!(".{INITRD_FILE}.{}.part", std::process::id())); + let pulled = oci::fetch_file( + ROOTFS_IMAGE, + ROOTFS_TAG, + ROOTFS_PATH_IN_IMAGE, + &staged, + logger, + ); + if pulled.is_err() { + let _ = std::fs::remove_file(&staged); + } + pulled?; + // Remove the old rootfs first: on Windows, rename cannot replace an + // existing destination. + let _ = std::fs::remove_file(dst); + if let Err(e) = std::fs::rename(&staged, dst) { + let _ = std::fs::remove_file(&staged); + return Err(format!("move rootfs into place at {dst:?}: {e}")); + } + Ok(()) +} - let tool = find_on_path(&["docker", "podman"]).ok_or_else(|| { - format!( - "neither docker nor podman is on $PATH; install one, or drop `{INITRD_FILE}` \ - into the image home by hand" - ) - })?; - let image = format!("{ROOTFS_IMAGE}:{ROOTFS_TAG}"); - let run = |cmd: &mut Command, label: &str| -> Result<(), String> { - let out = cmd - .output() - .map_err(|e| format!("spawn {tool} {label}: {e}"))?; - if !out.status.success() { - return Err(format!( - "{tool} {label} failed (exit {:?}): {}", - out.status.code(), - String::from_utf8_lossy(&out.stderr).trim() +/// Just enough of the OCI distribution API to take one file out of a +/// public image: an anonymous pull token, the manifest (through an index +/// if the tag names one), and the layer tarballs, extracted as they +/// stream in and checked against their digests. +mod oci { + use std::io::Read; + use std::path::Path; + use std::time::Duration; + + use sha2::{Digest, Sha256}; + use wxc_common::logger::Logger; + + const MANIFEST_TYPES: &str = "application/vnd.oci.image.manifest.v1+json, \ + application/vnd.oci.image.index.v1+json, \ + application/vnd.docker.distribution.manifest.v2+json, \ + application/vnd.docker.distribution.manifest.list.v2+json"; + + /// Write the file at `path_in_image` inside `image:tag` to `dst`. + /// `image` is `/`. + pub(super) fn fetch_file( + image: &str, + tag: &str, + path_in_image: &str, + dst: &Path, + logger: &mut Logger, + ) -> Result<(), String> { + let (registry, repo) = image + .split_once('/') + .ok_or_else(|| format!("image {image:?} names no registry host"))?; + // Bounded so a dead link fails instead of hanging setup; the body + // bound covers the largest layer on a slow link. + let agent = ureq::Agent::config_builder() + .timeout_resolve(Some(Duration::from_secs(30))) + .timeout_connect(Some(Duration::from_secs(30))) + .timeout_recv_response(Some(Duration::from_secs(60))) + .timeout_recv_body(Some(Duration::from_secs(30 * 60))) + .build() + .new_agent(); + let token = pull_token(&agent, registry, repo)?; + let get = |url: String, accept: &str| -> Result { + agent + .get(&url) + .header("Authorization", &format!("Bearer {token}")) + .header("Accept", accept) + .call() + .map(|response| response.into_body()) + .map_err(|e| format!("GET {url}: {e}")) + }; + + // A tag may name an index of per-platform manifests (a buildx push + // adds attestation manifests under an "unknown" platform too) + // rather than the manifest itself; take the linux/amd64 one. + let manifests_url = format!("https://{registry}/v2/{repo}/manifests"); + let mut manifest = read_json(get(format!("{manifests_url}/{tag}"), MANIFEST_TYPES)?)?; + if let Some(entries) = manifest.get("manifests").and_then(|m| m.as_array()) { + let digest = entries + .iter() + .find(|m| { + m["platform"]["os"] == "linux" && m["platform"]["architecture"] == "amd64" + }) + .and_then(|m| m["digest"].as_str()) + .ok_or_else(|| format!("{image}:{tag} has no linux/amd64 manifest"))? + .to_string(); + manifest = read_json(get(format!("{manifests_url}/{digest}"), MANIFEST_TYPES)?)?; + } + let layers = manifest["layers"] + .as_array() + .ok_or_else(|| format!("{image}:{tag}: manifest lists no layers"))?; + + let wanted = path_in_image.trim_start_matches('/'); + let mut found = false; + for layer in layers { + let digest = layer["digest"] + .as_str() + .ok_or_else(|| format!("{image}:{tag}: a layer has no digest"))?; + let media_type = layer["mediaType"].as_str().unwrap_or_default(); + if media_type.ends_with("zstd") { + return Err(format!( + "{image}:{tag}: zstd-compressed layers are not supported" + )); + } + let size_mib = layer["size"].as_u64().unwrap_or(0) / (1024 * 1024); + logger.log_line(&format!( + "hyperlight setup: downloading {image}:{tag} layer {digest} ({size_mib} MiB)" )); + let body = get( + format!("https://{registry}/v2/{repo}/blobs/{digest}"), + "application/octet-stream", + )?; + let mut blob = Digested::new(body.into_reader()); + // A later layer's copy of the file replaces an earlier one's, + // as it does in the image. + found |= extract(&mut blob, media_type.ends_with("gzip"), wanted, dst)?; + // Read to the end so the digest covers the whole blob. + std::io::copy(&mut blob, &mut std::io::sink()) + .map_err(|e| format!("read layer {digest}: {e}"))?; + let actual = blob.digest(); + if actual != digest { + return Err(format!("layer {digest} arrived with digest {actual}")); + } } - Ok(()) - }; - - logger.log_line(&format!("hyperlight setup: {tool} pull {image}")); - run(Command::new(tool).args(["pull", &image]), "pull")?; - - let cname = format!("mxc-hyperlight-rootfs-{}", std::process::id()); - let _ = Command::new(tool).args(["rm", "-f", &cname]).output(); - // A scratch image has no command, and `create` insists on one; any - // string does, the container is never started. - run( - Command::new(tool).args(["create", "--name", &cname, &image, "/"]), - "create", - )?; - let _cleanup = ContainerCleanup { - tool, - cname: &cname, - }; - - let staged = dst.with_file_name(format!(".{INITRD_FILE}.part")); - let staged_str = staged - .to_str() - .ok_or_else(|| format!("image home path {staged:?} is not valid UTF-8"))?; - run( - Command::new(tool).args(["cp", &format!("{cname}:{ROOTFS_PATH_IN_IMAGE}"), staged_str]), - "cp", - )?; - std::fs::rename(&staged, dst).map_err(|e| format!("move rootfs into place at {dst:?}: {e}")) -} + if found { + Ok(()) + } else { + Err(format!("{image}:{tag} has no {path_in_image}")) + } + } -/// Removes the extraction container on drop, success or failure. -struct ContainerCleanup<'a> { - tool: &'a str, - cname: &'a str, -} + /// An anonymous pull token for `repo` from the registry's token + /// endpoint, in the layout GHCR uses. + fn pull_token(agent: &ureq::Agent, registry: &str, repo: &str) -> Result { + let url = format!("https://{registry}/token?scope=repository:{repo}:pull"); + let body = agent + .get(&url) + .call() + .map(|response| response.into_body()) + .map_err(|e| format!("GET {url}: {e}"))?; + read_json(body)?["token"] + .as_str() + .map(str::to_string) + .ok_or_else(|| format!("{registry} token response carries no token")) + } -impl Drop for ContainerCleanup<'_> { - fn drop(&mut self) { - let _ = std::process::Command::new(self.tool) - .args(["rm", "-f", self.cname]) - .output(); + fn read_json(mut body: ureq::Body) -> Result { + let bytes = body + .read_to_vec() + .map_err(|e| format!("read response: {e}"))?; + serde_json::from_slice(&bytes).map_err(|e| format!("parse response: {e}")) } -} -/// Return the first name in `names` present as an executable on `$PATH`. -fn find_on_path(names: &[&'static str]) -> Option<&'static str> { - let path = std::env::var_os("PATH")?; - names.iter().copied().find(|name| { - std::env::split_paths(&path).any(|dir| { - let candidate = dir.join(name); - let Ok(md) = candidate.metadata() else { - return false; + /// Walk one layer tarball, writing the `wanted` entry to `dst` if it + /// is there. + fn extract( + blob: &mut impl Read, + gzipped: bool, + wanted: &str, + dst: &Path, + ) -> Result { + let reader: Box = if gzipped { + Box::new(flate2::read::GzDecoder::new(blob)) + } else { + Box::new(blob) + }; + let mut archive = tar::Archive::new(reader); + let mut found = false; + for entry in archive.entries().map_err(|e| format!("read layer: {e}"))? { + let mut entry = entry.map_err(|e| format!("read layer entry: {e}"))?; + let is_wanted = { + let path = entry.path().map_err(|e| format!("read layer entry: {e}"))?; + let name = path.to_string_lossy(); + name.trim_start_matches("./").trim_start_matches('/') == wanted }; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - md.is_file() && md.permissions().mode() & 0o111 != 0 + if !is_wanted { + continue; } - #[cfg(not(unix))] - { - md.is_file() + if !entry.header().entry_type().is_file() { + return Err(format!("{wanted} in the layer is not a regular file")); } - }) - }) + let mut file = + std::fs::File::create(dst).map_err(|e| format!("create {dst:?}: {e}"))?; + let written = + std::io::copy(&mut entry, &mut file).map_err(|e| format!("write {dst:?}: {e}"))?; + if written == 0 { + return Err(format!("{wanted} in the layer is empty")); + } + found = true; + } + Ok(found) + } + + /// A reader that keeps the SHA-256 of everything read through it. + struct Digested { + inner: R, + hasher: Sha256, + } + + impl Digested { + fn new(inner: R) -> Self { + Self { + inner, + hasher: Sha256::new(), + } + } + + /// The digest of everything read so far, in the registry's + /// `sha256:` form. + fn digest(&self) -> String { + format!("sha256:{:x}", self.hasher.clone().finalize()) + } + } + + impl Read for Digested { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.inner.read(buf)?; + self.hasher.update(&buf[..n]); + Ok(n) + } + } } /// One guest per process: the runner holds at most one sandbox, and the diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index d46541000..863fc5e8a 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -64,11 +64,12 @@ struct Cli { log_file: Option, /// Install the warmed Hyperlight snapshot and exit. Pulls the - /// published rootfs from GHCR (via docker or podman), boots it - /// once, and writes the snapshot into the default user - /// data dir (~/.local/share/mxc-hyperlight on Linux, %LOCALAPPDATA%\mxc-hyperlight on - /// Windows). $MXC_HYPERLIGHT_HOME overrides the destination if set. Intended - /// for tool install hooks so first-run has zero warmup cost. + /// published rootfs from GHCR unless the image home already holds + /// it, boots it once, and writes the snapshot into the default user + /// data dir (~/.local/share/mxc-hyperlight on Linux, + /// %LOCALAPPDATA%\mxc-hyperlight on Windows). $MXC_HYPERLIGHT_HOME + /// overrides the destination if set. Intended for tool install hooks + /// so first-run has zero warmup cost. #[arg(long = "setup-hyperlight")] setup_hyperlight: bool, diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 615e49be4..7299fc752 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -69,11 +69,12 @@ struct Cli { log_file: Option, /// Install the warmed Hyperlight snapshot and exit. Pulls the - /// published rootfs from GHCR (via docker or podman), boots it - /// once, and writes the snapshot into the default user - /// data dir (~/.local/share/mxc-hyperlight on Linux, %LOCALAPPDATA%\mxc-hyperlight on - /// Windows). $MXC_HYPERLIGHT_HOME overrides the destination if set. Intended - /// for tool install hooks so first-run has zero warmup cost. + /// published rootfs from GHCR unless the image home already holds + /// it, boots it once, and writes the snapshot into the default user + /// data dir (~/.local/share/mxc-hyperlight on Linux, + /// %LOCALAPPDATA%\mxc-hyperlight on Windows). $MXC_HYPERLIGHT_HOME + /// overrides the destination if set. Intended for tool install hooks + /// so first-run has zero warmup cost. #[arg(long = "setup-hyperlight")] setup_hyperlight: bool, From 08c9a508dd57d56877402b798c538efbd8bfcf26 Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 23 Sep 2026 23:29:34 +0000 Subject: [PATCH 5/7] docs: replace the Hyperlight design note with a backend guide The design note from the original integration was the only document on the backend, and it described the 0.12 shape: pyhl, a kernel file, network rejected, tests that no longer exist. The guide covers what ships now: setup and the image home, how a run works, the fields the backend honours, what the guest has, the design choices that still hold, and the messages to expect. The README and SDK backend table link it. Signed-off-by: danbugs --- README.md | 1 + docs/hyperlight-integration-plan.md | 192 -------------------------- docs/hyperlight/hyperlight-backend.md | 148 ++++++++++++++++++++ sdk/node/README.md | 2 +- 4 files changed, 150 insertions(+), 193 deletions(-) delete mode 100644 docs/hyperlight-integration-plan.md create mode 100644 docs/hyperlight/hyperlight-backend.md diff --git a/README.md b/README.md index eff50e39f..856bf529d 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,7 @@ Privacy information can be found at https://privacy.microsoft.com and in the Mic | [docs/bwrap-support/bubblewrap-backend.md](docs/bwrap-support/bubblewrap-backend.md) | Bubblewrap backend (Linux) | | [docs/seatbelt/seatbelt-backend.md](docs/seatbelt/seatbelt-backend.md) | Seatbelt backend (macOS) | | [docs/windows-sandbox/windows-sandbox.md](docs/windows-sandbox/windows-sandbox.md) | Windows Sandbox backend | +| [docs/hyperlight/hyperlight-backend.md](docs/hyperlight/hyperlight-backend.md) | Hyperlight backend (Linux, Windows) | | [docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md](docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md) | State-aware sandbox lifecycle API | | [docs/telemetry/telemetry.md](docs/telemetry/telemetry.md) | TraceLogging telemetry architecture | | [docs/telemetry/telemetry-consent-design.md](docs/telemetry/telemetry-consent-design.md) | Telemetry consent contract | diff --git a/docs/hyperlight-integration-plan.md b/docs/hyperlight-integration-plan.md deleted file mode 100644 index d272313a6..000000000 --- a/docs/hyperlight-integration-plan.md +++ /dev/null @@ -1,192 +0,0 @@ -# MXC Hyperlight Integration — Design Document - -## Problem - -MXC needs a **cross-platform micro-VM execution backend** with a good story -for agentic Python workloads that care about cold-start time. The backend -should work identically on Linux and Windows, boot in milliseconds, and -provide hardware-level isolation. - -## Proposed Solution - -Add a **Hyperlight backend** — embedded [Hyperlight](https://github.com/hyperlight-dev/hyperlight) -+ [Unikraft](https://unikraft.org/) driving a warmed-up CPython snapshot -via the [`hyperlight-unikraft-host`](https://github.com/hyperlight-dev/hyperlight-unikraft) library. - -When the JSON config specifies `"containment": "hyperlight"`, `wxc-exec` -routes to `HyperlightScriptRunner`, which instantiates a Hyperlight micro-VM -directly in-process. Every `run_code(&script)` rewinds to the snapshot and runs -hermetic. - -**Cross-platform:** KVM on Linux, WHP on Windows — same code path, same -library. - -## Performance - -> Benchmarks: bare-metal Windows (Hyper-V / WHP). -> pyhl 0.1.0 (the CLI from `hyperlight-unikraft-host` for running python-agent unikernels), CPython 3.12.0, x86_64. 15 runs. - -| Metric | Median | Avg | Min | Max | -|--------|--------|-----|-----|-----| -| Hello world (`print(42)`, end-to-end) | 139 ms | 141 ms | 133 ms | 157 ms | - -## Density - -| Metric | Value | -|--------|-------| -| Per-VM memory | 17 MB | -| Shared snapshot (one-time, mapped read-only CoW) | ~650 MiB on disk (2 GiB apparent) | - -The snapshot file is 2 GiB in apparent size but only ~650 MiB on disk -thanks to sparse-file hole-punching (`fallocate(PUNCH_HOLE)` on Linux, -`FSCTL_SET_SPARSE` on Windows). It is mmap'd read-only and shared across -all VMs — each new VM only pays for pages it actually writes. - -## Ecosystem - -**Hyperlight-Unikraft** builds on two open-source foundations: - -- **[Unikraft](https://unikraft.org/)** — Linux Foundation project with - an active community, regular releases, and commercial backing. Hyperlight - platform support has been upstreamed - ([unikraft/unikraft#1821](https://github.com/unikraft/unikraft/pull/1821), - [unikraft/app-elfloader#102](https://github.com/unikraft/app-elfloader/pull/102), - [unikraft/kraftkit#2797](https://github.com/unikraft/kraftkit/pull/2797)). -- **[Hyperlight](https://github.com/hyperlight-dev/hyperlight)** — CNCF - sandbox project. Already adopted across multiple Microsoft organizations - including Edge Actions, HorizonDB, and the Agentic Framework. - -Beyond Python, hyperlight-unikraft supports .NET, Node.js, Go, Rust, -C/C++, PowerShell, and Bash/Shell runtimes. - -## Why a separate `Hyperlight` variant - -- **Non-breaking.** Existing containment backends are unaffected. -- **Distinct semantics.** Hyperlight has: - - A pre-installed warm snapshot as a prerequisite (not just binaries). - - An in-process execution model. - - A rich stdlib (full CPython + ~20 pre-imported packages including C - extensions: numpy, pandas, Pillow, pydantic, cryptography, lxml). - - Live VFS forwarding for host filesystem access — guest POSIX calls - are forwarded to the host in real-time, limited only by host disk. -- **Different artifact provenance.** Hyperlight images come from - `hyperlight-dev/hyperlight-unikraft`'s `python-agent-driver` pipeline. - Adding new packages is a Dockerfile change + rebuild. - -## Design Decisions - -1. **In-process, not subprocess.** Hyperlight is a Rust library; wxc-exec - is a Rust binary. Linking directly avoids pipe plumbing, watchdog - threads, and process lifecycle management. - -2. **`script_code` is raw Python source.** No shell quoting, no cmdline - limit (`run_code` takes `&str` unbounded). - -3. **`--experimental` gate.** Keeps this backend off the happy path until - artifact distribution and docs catch up. - -4. **Unsupported policies are rejected.** A config specifying `network` - or `workingDirectory` with `containment: "hyperlight"` produces a preflight - error. - -5. **Image artifacts are user-provided, not bundled.** The `--setup-hyperlight` - flag populates the image home. The runner auto-discovers - `$MXC_HYPERLIGHT_HOME` → `~/.local/share/mxc-hyperlight/` (or - `%LOCALAPPDATA%\mxc-hyperlight\`) → `/mxc-hyperlight/` → - `/.mxc-hyperlight/`; the first location holding the rootfs and a - snapshot of this build wins. - -6. **Exit codes.** 0 on clean completion of `run_code`; -1 on any error - (preflight, runtime, guest crash). Distinct per-error variants go - through `error_message`. - -7. **stdout/stderr are inherited.** Guest `print(...)` reaches the user's - terminal directly via Hyperlight's `host_print`. - `ScriptResponse.standard_{out,err}` stay empty — consumers who need - capture redirect wxc-exec at the process level. - -## Workspace Changes - -``` -mxc/src/core/wxc_common/ -├── Cargo.toml # + hyperlight-unikraft-host dependency -└── src/ - ├── lib.rs # + pub mod hyperlight_runner; - ├── models.rs # + ContainmentBackend::Hyperlight (serde "hyperlight") - ├── config_parser.rs # + Some("hyperlight") => Hyperlight match arm - └── hyperlight_runner.rs # NEW - -mxc/src/core/wxc/ -└── src/main.rs # + ContainmentBackend::Hyperlight dispatch arm - -mxc/tests/configs/ -├── hyperlight_hello.json # NEW — hello from Python -└── hyperlight_pandas.json # NEW — exercises pre-imported numpy/pandas - -mxc/docs/ -└── hyperlight-integration-plan.md # NEW — this document -``` - -## Configuration - -### JSON - -```json -{ - "process": { - "commandLine": "import sys\nprint(f'Python {sys.version.split()[0]} on {sys.platform}')", - "timeout": 30000 - }, - "containment": "hyperlight" -} -``` - -### Field semantics - -| JSON Field | Hyperlight Behavior | -|------------|---------------| -| `process.commandLine` | ✅ Used — raw Python source | -| `process.timeout` | ✅ Used — script execution timeout (ms) | -| `containment` | ✅ Must be `"hyperlight"` | -| `filesystem.*` | ✅ `readwritePaths`/`readonlyPaths` mapped to host mounts | -| `network.*` | ❌ Rejected | -| `workingDirectory` | ❌ Rejected (guest has its own FS namespace) | - -## Security Model - -| Property | Hyperlight | -|----------|------| -| Isolation level | Micro VM (KVM/WHP) | -| Host FS access | Explicit mounts via `Preopen` | -| Network | None | -| Guest OS | Unikraft unikernel | -| Cold start | ~30ms KVM / ~140 ms WHP | -| Host platforms | Linux + Windows | - -## Supported Workloads - -### Supported out of the box (preloaded in snapshot) - -| Category | Examples | -|----------|----------| -| Stdlib | `os`, `sys`, `json`, `re`, `pathlib`, `datetime`, `hashlib`, `itertools`, `functools`, `math`, `decimal`, `fractions`, `collections`, `statistics` | -| Pre-imported 3rd-party | `numpy`, `pandas`, `pydantic`, `yaml`, `jinja2`, `bs4`, `tabulate`, `click`, `tenacity`, `tqdm`, `openpyxl`, `pypdf`, `markdown_it`, `PIL`, `lxml`, `cryptography`, `dateutil`, `dotenv` | - -### Not supported - -| Why not | Example failure | -|---------|-----------------| -| No network stack in guest | `urllib`, `socket`, `http` — `OSError: Function not implemented` | -| Read-only sysroot by default | File writes under `/` — `OSError: Read-only file system` | -| No subprocess / fork | `subprocess.run` — `OSError: Function not implemented` | - -## Testing Strategy - -### Unit tests (`cargo test -p wxc_common`) - -- `is_installed_false_on_empty_dir` — negative case for the install probe -- `resolve_home_errors_when_nothing_configured` — actionable error when no image -- `policy_rejects_filesystem_paths` — blocks readwritePaths/readonlyPaths/deniedPaths -- `policy_rejects_network_rules` — blocks allowed/blockedHosts -- `policy_rejects_block_default_network` — blocks `defaultNetworkPolicy: block` -- `policy_rejects_working_directory` — blocks non-empty `workingDirectory` diff --git a/docs/hyperlight/hyperlight-backend.md b/docs/hyperlight/hyperlight-backend.md new file mode 100644 index 000000000..1453edf4f --- /dev/null +++ b/docs/hyperlight/hyperlight-backend.md @@ -0,0 +1,148 @@ +# Hyperlight Backend + +Runs Python inside a [Hyperlight](https://github.com/hyperlight-dev/hyperlight) +micro-VM booting a [Unikraft](https://unikraft.org/) unikernel, driven +in-process by the [`hyperlight-unikraft`](https://github.com/hyperlight-dev/hyperlight-unikraft) +crate. One code path serves Linux (KVM) and Windows (WHP). + +## At a glance + +| | | +|---|---| +| **Binary** | `lxc-exec` (Linux), `wxc-exec.exe` (Windows) | +| **Config value** | `"containment": "hyperlight"` | +| **Schema** | `0.9.0-alpha`, with `--experimental` | +| **Requires** | x86_64; `/dev/kvm` readable and writable, or WHP enabled; a build with `--with-hyperlight` | +| **Isolation** | Hardware virtualization; the guest is a unikernel with its own filesystem | +| **Guest** | The `agent` rootfs: CPython 3.12 with numpy, pandas, scipy and scikit-learn already imported | +| **Cold start** | A snapshot restore | + +## Quick start + +```sh +./build.sh --with-hyperlight # Linux; build.bat --with-hyperlight on Windows +lxc-exec --setup-hyperlight # once per machine: pulls the rootfs, warms the snapshot +``` + +```json +{ + "version": "0.9.0-alpha", + "process": { + "commandLine": "import pandas as pd\nprint(pd.DataFrame({'x': [1, 2]}).sum().to_dict())", + "timeout": 30000 + }, + "containment": "hyperlight" +} +``` + +```sh +lxc-exec --experimental pandas.json +``` + +`commandLine` is Python source, passed to the interpreter as is. The +guest's `print` output goes straight to the process's stdout. + +## The image home + +Setup fills an image home with three things: + +| Entry | Purpose | +|---|---| +| `initrd.cpio` | The guest rootfs, pulled from `ghcr.io/hyperlight-dev/hyperlight-unikraft/agent` at the release the crate is pinned to | +| `snapshot/` | The guest, booted once and captured with the interpreter warm; every run restores it | +| `VERSION` | The rootfs release the home holds | + +The kernel is embedded in the crate, so nothing else is downloaded. The +pull talks to the registry directly; no container runtime is involved. + +The runner looks for a home in this order and takes the first that holds a +snapshot this build loads, or a rootfs of this release to warm one from: + +1. `$MXC_HYPERLIGHT_HOME` +2. `~/.local/share/mxc-hyperlight/` on Linux, `%LOCALAPPDATA%\mxc-hyperlight\` on Windows +3. `/mxc-hyperlight/` +4. `/.mxc-hyperlight/` + +Setup writes to the first or second of these. The rootfs is needed only +to warm, so a home trimmed to just the snapshot still runs. + +A snapshot loads only under the build that saved it (the crate keys it by +its kernel and host contract), and a rootfs only boots on its own +release's kernel. After a crate upgrade, `--setup-hyperlight` rebuilds a +home from another release, and a run warms a fresh snapshot by itself +when only the snapshot is stale. `--force` rewarms a home that is already +current, for example after dropping in a rootfs of your own. + +## How a run works + +Every request restores the warm snapshot with its own mounts and network +policy, so consecutive runs are hermetic. A runner reused within one +process rewinds to the snapshot between calls. + +| Aspect | Behaviour | +|---|---| +| Exit code | The script's: `sys.exit(N)` gives `N`, an uncaught exception gives `1`, a runner error gives `-1` with the reason in `error_message` | +| Timeout | `process.timeout` bounds the run; a guest that overruns is interrupted and the run reports `execution timed out` | +| stdout / stderr | Inherited by the guest; `ScriptResponse.standard_out` stays empty, so capture at the process level | + +## Configuration + +| Field | Behaviour | +|---|---| +| `process.commandLine` | Python source | +| `process.timeout` | Run timeout in milliseconds | +| `filesystem.readwritePaths` | Each directory appears in the guest at `/host/` | +| `filesystem.readonlyPaths` | The same, mounted read-only; the guest kernel and the host both enforce it | +| `filesystem.deniedPaths` | Checked at preflight against the two lists above | +| `network.defaultPolicy` | `allow` gives the guest host-proxied sockets; `block` refuses every `socket()` | +| `network.allowedHosts` / `blockedHosts` | An allow list or a block list of hosts; one or the other | +| `network.proxy` | Rejected at preflight | +| `workingDirectory` | Rejected at preflight; the guest has its own filesystem | + +Mount directories are created when their parent exists. A basename may not +contain whitespace, `:` or brackets, and two mounts may not share one. + +## What the guest has + +Present in the rootfs: numpy, pandas, scipy, scikit-learn, matplotlib, +Pillow, pydantic, PyYAML, Jinja2, BeautifulSoup, tabulate, tqdm, openpyxl, +pypdf, lxml, cryptography, dateutil, requests, httpx and pip. numpy, +pandas, scipy, scikit-learn, matplotlib, Pillow and dateutil are imported +before the snapshot is taken, so importing them costs nothing at run +time. `subprocess` works, and with network allowed, `pip install` fetches +packages from PyPI into the guest's writable scratch. + +## Other guest runtimes + +hyperlight-unikraft publishes rootfs images for Node.js, .NET, Go, Rust, +C, Bash and PowerShell beside Python, each with the same boot, snapshot +and mount machinery. The backend's runner is built around one rootfs and +one snapshot per home, so another runtime is a second home and a +selector in the request. + +## Design notes + +- **In process.** Hyperlight is a Rust library and the executors are Rust + binaries, so the backend links it and boots the VM in the executor's own + process: no helper process, pipes or lifecycle to manage. +- **A containment value of its own.** The backend needs a warmed snapshot + in place before the first run, executes source rather than a command + line, and forwards host directories to the guest live, so it is + selected explicitly rather than resolved from `vm` or `microvm`. +- **Images come from upstream.** The rootfs is built and published by + hyperlight-unikraft's pipeline, and the kernel ships inside the crate. + Adding a package to the guest is a change to the image there. +- **Experimental.** The backend sits behind `--experimental` while the + request surface settles; the runtime selector above is the next step. + +## Troubleshooting + +| Message | Meaning | +|---|---| +| `no hyperlight image found` | No home holds an image; run `--setup-hyperlight`, or set `MXC_HYPERLIGHT_HOME` | +| `holds a rootfs from another hyperlight-unikraft release` | The home predates the crate the binary was built with; `--setup-hyperlight` rebuilds it | +| `hyperlight backend unavailable` | No hypervisor: KVM is missing or not accessible, or WHP is not enabled | +| `execution timed out after N s` | The script overran `process.timeout` | + +Run with `--debug` to see the home chosen, restore and call timings, and +the reason behind any runner error. diff --git a/sdk/node/README.md b/sdk/node/README.md index 35df32921..7faed7249 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -303,7 +303,7 @@ console.log(result.stdout); | `seatbelt` | `process` | macOS | `0.7.0-alpha` | ✅ | [`docs/seatbelt/seatbelt-backend.md`](https://github.com/microsoft/mxc/blob/main/docs/seatbelt/seatbelt-backend.md) | | `windows_sandbox` | `vm` | Windows | `0.10.0-alpha` | Experimental | [`docs/windows-sandbox/windows-sandbox.md`](https://github.com/microsoft/mxc/blob/main/docs/windows-sandbox/windows-sandbox.md) | | `microvm` | `microvm` | Windows | `0.10.0-alpha` | Experimental | [`docs/nanvix-microvm/nanvix.md`](https://github.com/microsoft/mxc/blob/main/docs/nanvix-microvm/nanvix.md) — MicroVM via NanVix on Windows Hypervisor Platform | -| `hyperlight` | (concrete only) | Windows x64 / Linux x64 | `0.10.0-alpha` | Experimental | No dedicated guide | +| `hyperlight` | (concrete only) | Windows x64 / Linux x64 | `0.10.0-alpha` | Experimental | [`docs/hyperlight/hyperlight-backend.md`](https://github.com/microsoft/mxc/blob/main/docs/hyperlight/hyperlight-backend.md) — Hyperlight + Unikraft micro-VM on KVM / WHP | | `wslc` | (concrete only) | Windows | `0.9.0-alpha` | Stable | [`docs/wsl/wsl-container-getting-started.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wsl-container-getting-started.md) | | `isolation_session` | (concrete only) | Windows | `0.9.0-alpha` | Stable | [`docs/isolation-session/oneshot.md`](https://github.com/microsoft/mxc/blob/main/docs/isolation-session/oneshot.md) | From 3d77b79966f4af28187fc2bdabcaad3b67ad9643 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 24 Sep 2026 04:01:46 +0000 Subject: [PATCH 6/7] config: add a hyperlight section with the guest runtime The Hyperlight backend can boot any of the guest runtimes hyperlight-unikraft publishes, so a request needs a way to name one. The `hyperlight` section's `runtime` says what process.commandLine is source for: `agent` (the default), `python`, `python-shell`, `node`, `bash` or `dotnet-jit`, each named as upstream names the image. The section sits beside `wslc` and `windowsSandbox` in the 0.10.0-alpha contract: the request IR, the domain model, the parser's section validation, the adapters, the engine's exact builder and the policy identity all carry it, and the generated schema and TypeScript are regenerated. The runtime is part of the policy identity because it picks the image the sandbox boots. Signed-off-by: danbugs --- docs/schema.md | 4 + .../dev/mxc-config.schema.0.10.0-alpha.json | 61 +++++++++++++++ sdk/node/src/generated/v0_10_0_alpha/wire.ts | 16 ++++ sdk/node/src/types.ts | 10 +++ .../src/dev/experimental.rs | 30 ++++++++ src/core/mxc_config_contract/src/dev/mod.rs | 3 +- .../mxc_config_contract/src/dev/one_shot.rs | 5 +- .../tests/v0_10_0_alpha/experimental.rs | 2 + .../v0_10_0_alpha/experimental/hyperlight.rs | 60 +++++++++++++++ .../tests/v0_10_0_alpha/experimental/root.rs | 3 +- .../one_shot/valid/hyperlight_node.json | 10 +++ .../tests/version_boundaries/experimental.rs | 5 ++ src/core/mxc_engine/src/policy/exact/v0_10.rs | 1 + src/core/wxc_common/src/common_request_ir.rs | 1 + .../config_contract_adapters/dev/one_shot.rs | 18 +++++ .../dev/state_aware.rs | 1 + .../src/config_contract_adapters/v0_6.rs | 1 + .../src/config_contract_adapters/v0_7.rs | 1 + .../src/config_contract_adapters/v0_8.rs | 1 + .../config_contract_adapters/v0_9/one_shot.rs | 1 + .../v0_9/state_aware.rs | 1 + src/core/wxc_common/src/config_parser.rs | 12 ++- src/core/wxc_common/src/models.rs | 76 ++++++++++++++++++- src/core/wxc_common/src/policy_identity.rs | 31 ++++++++ src/core/wxc_common/src/wire.rs | 9 +++ 25 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 src/core/mxc_config_contract/tests/v0_10_0_alpha/experimental/hyperlight.rs create mode 100644 src/core/mxc_config_contract/tests/v0_10_0_alpha/fixtures/one_shot/valid/hyperlight_node.json diff --git a/docs/schema.md b/docs/schema.md index 81e2b434f..51992753a 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -249,6 +249,10 @@ that can be executed independently. "portMappings": [ // Host<->container port forwarding. TCP only -- the WSLC SDK runtime returns E_NOTIMPL for UDP, so the parser hard-rejects "udp" entries with a clear message. { "windowsPort": 8080, "containerPort": 80, "protocol": "tcp" } ] + }, + + "hyperlight": { // Hyperlight settings (v0.10+) + "runtime": "node" // Guest runtime: agent (default), python, python-shell, node, bash or dotnet-jit } } ``` diff --git a/schemas/dev/mxc-config.schema.0.10.0-alpha.json b/schemas/dev/mxc-config.schema.0.10.0-alpha.json index b5ef266d4..5d2a964b7 100644 --- a/schemas/dev/mxc-config.schema.0.10.0-alpha.json +++ b/schemas/dev/mxc-config.schema.0.10.0-alpha.json @@ -376,6 +376,52 @@ }, "type": "object" }, + "HyperlightRuntime": { + "oneOf": [ + { + "description": "Agent", + "enum": [ + "agent" + ], + "type": "string" + }, + { + "description": "Python", + "enum": [ + "python" + ], + "type": "string" + }, + { + "description": "PythonShell", + "enum": [ + "python-shell" + ], + "type": "string" + }, + { + "description": "Node", + "enum": [ + "node" + ], + "type": "string" + }, + { + "description": "Bash", + "enum": [ + "bash" + ], + "type": "string" + }, + { + "description": "DotnetJit", + "enum": [ + "dotnet-jit" + ], + "type": "string" + } + ] + }, "IsolationSessionContainment": { "enum": [ "isolation_session" @@ -800,6 +846,17 @@ } ] }, + "OneShotHyperlight": { + "additionalProperties": false, + "description": "One-shot Hyperlight backend settings.", + "properties": { + "runtime": { + "$ref": "#/definitions/HyperlightRuntime", + "description": "Guest runtime that `process.commandLine` is source for. Defaults to `agent`." + } + }, + "type": "object" + }, "OneShotRequest": { "additionalProperties": false, "allOf": [ @@ -849,6 +906,10 @@ "$ref": "#/definitions/Filesystem", "description": "Optional filesystem policy." }, + "hyperlight": { + "$ref": "#/definitions/OneShotHyperlight", + "description": "Optional one-shot Hyperlight backend settings." + }, "lifecycle": { "$ref": "#/definitions/Lifecycle", "description": "Optional lifecycle settings." diff --git a/sdk/node/src/generated/v0_10_0_alpha/wire.ts b/sdk/node/src/generated/v0_10_0_alpha/wire.ts index 866d68669..803506764 100644 --- a/sdk/node/src/generated/v0_10_0_alpha/wire.ts +++ b/sdk/node/src/generated/v0_10_0_alpha/wire.ts @@ -136,6 +136,8 @@ export interface Filesystem { readwritePaths?: string[]; } +export type HyperlightRuntime = "agent" | "python" | "python-shell" | "node" | "bash" | "dotnet-jit"; + export type IsolationSessionContainment = "isolation_session"; /** @@ -356,6 +358,16 @@ export type NonEmptyString = string; export type OneShotContainment = "process" | "processcontainer" | "appcontainer" | "lxc" | "bubblewrap" | "seatbelt" | "macos_sandbox" | "vm" | "windows_sandbox" | "microvm" | "hyperlight" | "wslc" | "isolation_session"; +/** + * One-shot Hyperlight backend settings. + */ +export interface OneShotHyperlight { + /** + * Guest runtime that `process.commandLine` is source for. Defaults to `agent`. + */ + runtime?: HyperlightRuntime; +} + /** * A complete one-shot `0.10.0-alpha` configuration request. */ @@ -388,6 +400,10 @@ export type OneShotRequest = { * Optional filesystem policy. */ filesystem?: Filesystem; + /** + * Optional one-shot Hyperlight backend settings. + */ + hyperlight?: OneShotHyperlight; /** * Optional lifecycle settings. */ diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts index b589de2d9..c1e49f071 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -370,6 +370,14 @@ export interface WslcConfig { portMappings?: PortMapping[]; } +/** + * Hyperlight backend configuration + */ +export interface HyperlightConfig { + /** Guest runtime that `process.commandLine` is source for (default: "agent") */ + runtime?: 'agent' | 'python' | 'python-shell' | 'node' | 'bash' | 'dotnet-jit'; +} + /** * Port mapping for host↔container port forwarding. */ @@ -439,6 +447,8 @@ export interface ContainerConfig { telemetry?: TelemetryConfig; /** WSLC SDK configuration for Linux containers from Windows */ wslc?: WslcConfig; + /** Hyperlight backend configuration */ + hyperlight?: HyperlightConfig; /** macOS Seatbelt sandbox configuration (macOS only) */ seatbelt?: SeatbeltConfig; /** Cross-platform UI configuration */ diff --git a/src/core/mxc_config_contract/src/dev/experimental.rs b/src/core/mxc_config_contract/src/dev/experimental.rs index 2f5a08af2..54786aed0 100644 --- a/src/core/mxc_config_contract/src/dev/experimental.rs +++ b/src/core/mxc_config_contract/src/dev/experimental.rs @@ -83,3 +83,33 @@ pub struct OneShotWslc { #[serde(default)] pub port_mappings: OptionalField>, } + +string_enum! { + /// Guest runtime for the Hyperlight backend. + #[derive(Debug)] + pub enum HyperlightRuntime { + /// CPython with the data-science stack preloaded (the default). + Agent => ["agent"], + /// CPython. + Python => ["python"], + /// CPython with a BusyBox shell. + PythonShell => ["python-shell"], + /// Node.js. + Node => ["node"], + /// Bash with BusyBox. + Bash => ["bash"], + /// .NET with the JIT. + DotnetJit => ["dotnet-jit"], + } +} + +/// One-shot Hyperlight backend settings. +#[derive(Debug, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OneShotHyperlight { + /// Guest runtime that `process.commandLine` is source for. Defaults to + /// `agent`. + #[serde(default)] + pub runtime: OptionalField, +} diff --git a/src/core/mxc_config_contract/src/dev/mod.rs b/src/core/mxc_config_contract/src/dev/mod.rs index 2bcbbf19b..bbf10fb6f 100644 --- a/src/core/mxc_config_contract/src/dev/mod.rs +++ b/src/core/mxc_config_contract/src/dev/mod.rs @@ -231,7 +231,8 @@ mod stable; mod state_aware; pub use experimental::{ - OneShotWindowsSandbox, OneShotWslc, PortMapping, TestFeature, TransportProtocol, + HyperlightRuntime, OneShotHyperlight, OneShotWindowsSandbox, OneShotWslc, PortMapping, + TestFeature, TransportProtocol, }; pub use network::{ Network, NetworkAction, NetworkEgress, NetworkIngress, NetworkPeer, NetworkPort, diff --git a/src/core/mxc_config_contract/src/dev/one_shot.rs b/src/core/mxc_config_contract/src/dev/one_shot.rs index e1d9d96ed..ac50637b8 100644 --- a/src/core/mxc_config_contract/src/dev/one_shot.rs +++ b/src/core/mxc_config_contract/src/dev/one_shot.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use super::experimental::{OneShotWindowsSandbox, OneShotWslc, TestFeature}; +use super::experimental::{OneShotHyperlight, OneShotWindowsSandbox, OneShotWslc, TestFeature}; use super::network::Network; use super::primitives::OptionalField; use super::stable::{ @@ -104,4 +104,7 @@ pub struct Request { /// Optional one-shot WSLC backend settings. #[serde(default)] pub wslc: OptionalField, + /// Optional one-shot Hyperlight backend settings. + #[serde(default)] + pub hyperlight: OptionalField, } diff --git a/src/core/mxc_config_contract/tests/v0_10_0_alpha/experimental.rs b/src/core/mxc_config_contract/tests/v0_10_0_alpha/experimental.rs index 1d8719bd3..6e5146964 100644 --- a/src/core/mxc_config_contract/tests/v0_10_0_alpha/experimental.rs +++ b/src/core/mxc_config_contract/tests/v0_10_0_alpha/experimental.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[path = "experimental/hyperlight.rs"] +mod hyperlight; #[path = "experimental/root.rs"] mod root; #[path = "experimental/test_and_telemetry.rs"] diff --git a/src/core/mxc_config_contract/tests/v0_10_0_alpha/experimental/hyperlight.rs b/src/core/mxc_config_contract/tests/v0_10_0_alpha/experimental/hyperlight.rs new file mode 100644 index 000000000..3e711bd88 --- /dev/null +++ b/src/core/mxc_config_contract/tests/v0_10_0_alpha/experimental/hyperlight.rs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::common::{assert_invalid, assert_valid}; + +fn hyperlight_request(fields: &str) -> String { + format!( + r#"{{ + "version": "0.10.0-alpha", + "containment": "hyperlight", + "hyperlight": {{{fields}}}, + "process": {{"commandLine": "print(1)"}} + }}"# + ) +} + +#[test] +fn accepts_empty_hyperlight_object() { + assert_valid(&hyperlight_request("")); +} + +#[test] +fn accepts_known_runtimes() { + for runtime in [ + "agent", + "python", + "python-shell", + "node", + "bash", + "dotnet-jit", + ] { + assert_valid(&hyperlight_request(&format!(r#""runtime": "{runtime}""#))); + } +} + +#[test] +fn rejects_unknown_runtime_values() { + for value in [ + r#""Python""#, + r#""ruby""#, + r#""powershell""#, + r#""c""#, + r#""""#, + "1", + "true", + "null", + "[]", + "{}", + ] { + assert_invalid(&hyperlight_request(&format!(r#""runtime": {value}"#))); + } +} + +#[test] +fn rejects_unknown_hyperlight_fields() { + assert_invalid(&hyperlight_request(r#""image": "agent""#)); + assert_invalid(&hyperlight_request( + r#""runtime": "node", "scratchMb": 512"#, + )); +} diff --git a/src/core/mxc_config_contract/tests/v0_10_0_alpha/experimental/root.rs b/src/core/mxc_config_contract/tests/v0_10_0_alpha/experimental/root.rs index 8f06dbfd2..48bf368ec 100644 --- a/src/core/mxc_config_contract/tests/v0_10_0_alpha/experimental/root.rs +++ b/src/core/mxc_config_contract/tests/v0_10_0_alpha/experimental/root.rs @@ -10,7 +10,8 @@ fn accepts_permanent_development_fields() { "process": {"commandLine": "echo"}, "test": {"message": "this is a message"}, "windowsSandbox": {}, - "wslc": {} + "wslc": {}, + "hyperlight": {} }"#; assert_valid(json); diff --git a/src/core/mxc_config_contract/tests/v0_10_0_alpha/fixtures/one_shot/valid/hyperlight_node.json b/src/core/mxc_config_contract/tests/v0_10_0_alpha/fixtures/one_shot/valid/hyperlight_node.json new file mode 100644 index 000000000..8f0f10d4a --- /dev/null +++ b/src/core/mxc_config_contract/tests/v0_10_0_alpha/fixtures/one_shot/valid/hyperlight_node.json @@ -0,0 +1,10 @@ +{ + "version": "0.10.0-alpha", + "containment": "hyperlight", + "process": { + "commandLine": "console.log(process.version)" + }, + "hyperlight": { + "runtime": "node" + } +} diff --git a/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs b/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs index d5f9e01ee..2d38a8b9c 100644 --- a/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs +++ b/src/core/mxc_config_contract/tests/version_boundaries/experimental.rs @@ -22,6 +22,11 @@ fn windows_sandbox_config_is_introduced_in_v010() { ); } +#[test] +fn hyperlight_config_is_introduced_in_v010() { + assert_v10_introduces(r#""hyperlight": {"runtime": "node"}"#); +} + #[test] fn wslc_config_is_introduced_in_v09() { assert_v09_introduces( diff --git a/src/core/mxc_engine/src/policy/exact/v0_10.rs b/src/core/mxc_engine/src/policy/exact/v0_10.rs index d8e3618f9..b59bf8c99 100644 --- a/src/core/mxc_engine/src/policy/exact/v0_10.rs +++ b/src/core/mxc_engine/src/policy/exact/v0_10.rs @@ -363,5 +363,6 @@ pub(super) fn build(input: &PreparedInput<'_>) -> Result, pub(crate) test_feature: Option, pub(crate) windows_sandbox: Option, + pub(crate) hyperlight: Option, } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs index e6ee1f457..5e8407c8a 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/one_shot.rs @@ -280,6 +280,7 @@ pub(super) fn into_common_request_ir( test, windows_sandbox, wslc, + hyperlight, } = request; crate::common_request_ir::CommonRequestIR { schema: schema.into_option(), @@ -307,6 +308,23 @@ pub(super) fn into_common_request_ir( seatbelt: seatbelt.into_option().map(convert_seatbelt), test_feature: test.into_option().map(convert_test), windows_sandbox: windows_sandbox.into_option().map(convert_windows_sandbox), + hyperlight: hyperlight.into_option().map(convert_hyperlight), + } +} + +fn convert_hyperlight(value: contract::OneShotHyperlight) -> wire::Hyperlight { + let contract::OneShotHyperlight { runtime } = value; + wire::Hyperlight { + runtime: runtime.into_option().map(|runtime| match runtime { + contract::HyperlightRuntime::Agent => crate::models::HyperlightRuntime::Agent, + contract::HyperlightRuntime::Python => crate::models::HyperlightRuntime::Python, + contract::HyperlightRuntime::PythonShell => { + crate::models::HyperlightRuntime::PythonShell + } + contract::HyperlightRuntime::Node => crate::models::HyperlightRuntime::Node, + contract::HyperlightRuntime::Bash => crate::models::HyperlightRuntime::Bash, + contract::HyperlightRuntime::DotnetJit => crate::models::HyperlightRuntime::DotnetJit, + }), } } diff --git a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs index 1f97d8747..7b6d956b2 100644 --- a/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs +++ b/src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs @@ -103,6 +103,7 @@ fn state_aware_common( process_container: None, seatbelt: None, ui: None, + hyperlight: None, } } diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_6.rs b/src/core/wxc_common/src/config_contract_adapters/v0_6.rs index 3d6589cfe..a7709b379 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_6.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_6.rs @@ -246,6 +246,7 @@ pub(crate) fn into_common_request_ir( seatbelt: None, test_feature: None, windows_sandbox: None, + hyperlight: None, } } diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_7.rs b/src/core/wxc_common/src/config_contract_adapters/v0_7.rs index 3247ed005..dadb84e32 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_7.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_7.rs @@ -276,6 +276,7 @@ pub(crate) fn into_common_request_ir( seatbelt: seatbelt.into_option().map(convert_seatbelt), test_feature: None, windows_sandbox: None, + hyperlight: None, } } diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_8.rs b/src/core/wxc_common/src/config_contract_adapters/v0_8.rs index feec49ca0..6e282acdd 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_8.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_8.rs @@ -407,6 +407,7 @@ pub(crate) fn into_common_request_ir( telemetry: None, test_feature: None, windows_sandbox: None, + hyperlight: None, } } diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot.rs b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot.rs index 02aebf305..f997787f9 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_9/one_shot.rs @@ -280,6 +280,7 @@ pub(super) fn into_common_request_ir( seatbelt: seatbelt.into_option().map(convert_seatbelt), test_feature: None, windows_sandbox: None, + hyperlight: None, } } diff --git a/src/core/wxc_common/src/config_contract_adapters/v0_9/state_aware.rs b/src/core/wxc_common/src/config_contract_adapters/v0_9/state_aware.rs index 730f05ee4..c115da052 100644 --- a/src/core/wxc_common/src/config_contract_adapters/v0_9/state_aware.rs +++ b/src/core/wxc_common/src/config_contract_adapters/v0_9/state_aware.rs @@ -103,6 +103,7 @@ fn state_aware_common( process_container: None, seatbelt: None, ui: None, + hyperlight: None, } } diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 363b5f3f6..8bebe5fff 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -8,8 +8,8 @@ use crate::error::WxcError; use crate::logger::Logger; use crate::models::{ CaptureDenialsConfig, CaptureDenialsMode, ContainerPolicy, ContainmentBackend, - ExecutionRequest, LifecycleConfig, LxcConfig, NetworkEnforcementMode, NetworkPolicy, - PortMapping, SeatbeltConfig, TelemetryConfig, TestFeatureConfig, UiPolicy, + ExecutionRequest, HyperlightConfig, LifecycleConfig, LxcConfig, NetworkEnforcementMode, + NetworkPolicy, PortMapping, SeatbeltConfig, TelemetryConfig, TestFeatureConfig, UiPolicy, WindowsSandboxConfig, WslcConfig, }; use crate::mxc_error::MxcError; @@ -914,6 +914,9 @@ fn present_backend_sections(cfg: &crate::common_request_ir::CommonRequestIR) -> if cfg.windows_sandbox.is_some() { push(ContainmentBackend::WindowsSandbox); } + if cfg.hyperlight.is_some() { + push(ContainmentBackend::Hyperlight); + } sections } @@ -1661,6 +1664,10 @@ fn normalize_common_request_ir( config }); + let hyperlight = cfg.hyperlight.map(|h| HyperlightConfig { + runtime: h.runtime.unwrap_or_default(), + }); + let seatbelt = cfg.seatbelt.map(make_seatbelt_config); let telemetry = cfg.telemetry.map(|raw| TelemetryConfig { enabled: raw.enabled, @@ -1701,6 +1708,7 @@ fn normalize_common_request_ir( telemetry, test_feature, windows_sandbox, + hyperlight, experimental_enabled: false, testing_features_enabled: false, dry_run: false, diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 11c253bd0..29ffc7bff 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -77,8 +77,8 @@ impl ContainmentBackend { ContainmentBackend::Wslc => Some("wslc"), ContainmentBackend::Seatbelt => Some("seatbelt"), ContainmentBackend::IsolationSession => Some("isolationSession"), + ContainmentBackend::Hyperlight => Some("hyperlight"), ContainmentBackend::Bubblewrap - | ContainmentBackend::Hyperlight | ContainmentBackend::MicroVm | ContainmentBackend::Vm => None, } @@ -962,6 +962,78 @@ pub struct TelemetryConfig { pub requested_sandbox_kind: Option<&'static str>, } +/// Guest runtime for the Hyperlight backend: what `process.commandLine` +/// is source for. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HyperlightRuntime { + /// CPython with the data-science stack preloaded. + #[default] + Agent, + /// CPython. + Python, + /// CPython with a BusyBox shell. + #[serde(rename = "python-shell")] + PythonShell, + /// Node.js. + Node, + /// Bash with BusyBox. + Bash, + /// .NET with the JIT. + #[serde(rename = "dotnet-jit")] + DotnetJit, +} + +impl HyperlightRuntime { + /// Every runtime the backend can install and run. + pub const ALL: [HyperlightRuntime; 6] = [ + HyperlightRuntime::Agent, + HyperlightRuntime::Python, + HyperlightRuntime::PythonShell, + HyperlightRuntime::Node, + HyperlightRuntime::Bash, + HyperlightRuntime::DotnetJit, + ]; + + /// The wire spelling: the upstream image's name, also the runtime's + /// directory in an image home. + pub fn name(self) -> &'static str { + match self { + HyperlightRuntime::Agent => "agent", + HyperlightRuntime::Python => "python", + HyperlightRuntime::PythonShell => "python-shell", + HyperlightRuntime::Node => "node", + HyperlightRuntime::Bash => "bash", + HyperlightRuntime::DotnetJit => "dotnet-jit", + } + } +} + +impl std::str::FromStr for HyperlightRuntime { + type Err = String; + + fn from_str(s: &str) -> Result { + HyperlightRuntime::ALL + .into_iter() + .find(|runtime| runtime.name() == s) + .ok_or_else(|| { + let known: Vec<&str> = HyperlightRuntime::ALL.iter().map(|r| r.name()).collect(); + format!( + "unknown hyperlight runtime {s:?}; expected one of {}", + known.join(", ") + ) + }) + } +} + +/// Hyperlight backend configuration (`hyperlight`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct HyperlightConfig { + /// Guest runtime for the run. + pub runtime: HyperlightRuntime, +} + #[derive(Debug, Clone, Default, Serialize)] pub struct ExecutionRequest { /// Exact external contract that produced this request. @@ -1027,6 +1099,8 @@ pub struct ExecutionRequest { pub test_feature: Option, /// Windows Sandbox backend configuration. pub windows_sandbox: Option, + /// Hyperlight backend configuration (used when containment == Hyperlight). + pub hyperlight: Option, /// Whether the --experimental flag was passed. pub experimental_enabled: bool, /// Whether the --allow-testing-features flag was passed. Gates testing-only, diff --git a/src/core/wxc_common/src/policy_identity.rs b/src/core/wxc_common/src/policy_identity.rs index 9b469570d..404178d1f 100644 --- a/src/core/wxc_common/src/policy_identity.rs +++ b/src/core/wxc_common/src/policy_identity.rs @@ -205,6 +205,7 @@ fn policy_projection(request: &ExecutionRequest) -> Value { wslc, seatbelt, windows_sandbox, + hyperlight, // Telemetry settings do not affect enforcement. telemetry: _excluded_telemetry, // A placeholder feature with no enforcement effect. @@ -286,6 +287,16 @@ fn policy_projection(request: &ExecutionRequest) -> Value { "windowsSandbox".into(), serde_json::to_value(windows_sandbox).unwrap_or(Value::Null), ); + // The guest runtime picks the image the sandbox boots. Hashed as the + // effective value whenever the backend is Hyperlight, so an omitted + // section and an explicit default are one policy; other backends carry + // no key and hash as they did before the section existed. + if *containment == crate::models::ContainmentBackend::Hyperlight { + root.insert( + "hyperlight".into(), + serde_json::to_value(hyperlight.clone().unwrap_or_default()).unwrap_or(Value::Null), + ); + } Value::Object(root) } @@ -537,6 +548,26 @@ mod tests { ); } + #[test] + fn hyperlight_runtime_hashes_as_its_effective_value() { + use crate::models::{ContainmentBackend, HyperlightConfig, HyperlightRuntime}; + let mut omitted = request(); + omitted.containment = ContainmentBackend::Hyperlight; + let mut explicit_default = omitted.clone(); + explicit_default.hyperlight = Some(HyperlightConfig::default()); + let mut node = omitted.clone(); + node.hyperlight = Some(HyperlightConfig { + runtime: HyperlightRuntime::Node, + }); + assert_eq!(policy_hash(&omitted), policy_hash(&explicit_default)); + assert_ne!(policy_hash(&omitted), policy_hash(&node)); + + let Value::Object(projection) = policy_projection(&request()) else { + panic!("policy projection must be an object"); + }; + assert!(!projection.contains_key("hyperlight")); + } + #[test] fn changing_a_readwrite_path_changes_the_hash() { let baseline = policy_hash(&request()); diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index 329484cd2..aa720b154 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -495,6 +495,15 @@ pub struct WindowsSandbox { pub daemon_pipe_name: Option, } +/// Hyperlight backend config. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Hyperlight { + /// Guest runtime that `process.commandLine` is source for; `agent` is + /// the default. + pub runtime: Option, +} + /// WSL container backend config. #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] From e1401ff7c3e4c079eff6d28da2499497d63cb166 Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 23 Sep 2026 23:56:51 +0000 Subject: [PATCH 7/7] hyperlight: install and run one image home per guest runtime The image home now holds one directory per runtime (agent/, python/, node/, ...), each with its rootfs, snapshot and stamp, so runtimes install, upgrade and run independently. The runner picks the directory from the request's hyperlight.runtime and keys its booted guest on it; the image table gives each runtime its upstream image and scratch size. The images that take source through a warm driver are selectable: agent, python, python-shell, node, bash and dotnet-jit. The compiled images run a program baked into the rootfs, so they are not. --setup-hyperlight takes runtime names and installs agent by default, and a run naming an uninstalled runtime fails at preflight with the setup command to run. The e2e workflow installs agent, python and node, and the suite gains a plain-Python hello, a Node.js hello and a Node.js exit-code case on both platforms. The guide gains the runtime table and the per-runtime layout. Signed-off-by: danbugs --- .github/workflows/hyperlight-e2e.yml | 8 +- docs/hyperlight/hyperlight-backend.md | 118 ++++--- sdk/node/README.md | 2 +- src/backends/hyperlight/common/src/lib.rs | 313 ++++++++++++------ src/core/lxc/src/main.rs | 54 ++- src/core/wxc/src/main.rs | 54 ++- src/testing/wxc_e2e_tests/src/lib.rs | 34 +- .../wxc_e2e_tests/tests/e2e_windows.rs | 43 ++- tests/configs/hyperlight_node_exit_code.json | 11 + tests/configs/hyperlight_node_hello.json | 11 + tests/configs/hyperlight_python_hello.json | 11 + 11 files changed, 466 insertions(+), 193 deletions(-) create mode 100644 tests/configs/hyperlight_node_exit_code.json create mode 100644 tests/configs/hyperlight_node_hello.json create mode 100644 tests/configs/hyperlight_python_hello.json diff --git a/.github/workflows/hyperlight-e2e.yml b/.github/workflows/hyperlight-e2e.yml index 781dfa8a4..6d6d47350 100644 --- a/.github/workflows/hyperlight-e2e.yml +++ b/.github/workflows/hyperlight-e2e.yml @@ -67,11 +67,11 @@ jobs: if: steps.whp-check.outputs.whp_available == 'true' shell: pwsh run: | - # Pulls the agent rootfs straight from GHCR (no container runtime - # needed), boots it once, and saves the snapshot under - # %LOCALAPPDATA%\mxc-hyperlight, where the e2e tests look for it. + # Pulls each runtime's rootfs straight from GHCR (no container + # runtime needed), boots it once, and saves the snapshot under + # %LOCALAPPDATA%\mxc-hyperlight\, where the e2e tests look. $binDir = Join-Path $env:GITHUB_WORKSPACE "src\target\x86_64-pc-windows-msvc\debug" - & "$binDir\wxc-exec.exe" --setup-hyperlight --debug 2>&1 + & "$binDir\wxc-exec.exe" --setup-hyperlight=agent,python,node --debug 2>&1 if ($LASTEXITCODE -ne 0) { Write-Host "::error::Hyperlight setup failed" exit 1 diff --git a/docs/hyperlight/hyperlight-backend.md b/docs/hyperlight/hyperlight-backend.md index 1453edf4f..6c839b6d3 100644 --- a/docs/hyperlight/hyperlight-backend.md +++ b/docs/hyperlight/hyperlight-backend.md @@ -1,6 +1,6 @@ # Hyperlight Backend -Runs Python inside a [Hyperlight](https://github.com/hyperlight-dev/hyperlight) +Runs source for a guest runtime inside a [Hyperlight](https://github.com/hyperlight-dev/hyperlight) micro-VM booting a [Unikraft](https://unikraft.org/) unikernel, driven in-process by the [`hyperlight-unikraft`](https://github.com/hyperlight-dev/hyperlight-unikraft) crate. One code path serves Linux (KVM) and Windows (WHP). @@ -11,22 +11,22 @@ crate. One code path serves Linux (KVM) and Windows (WHP). |---|---| | **Binary** | `lxc-exec` (Linux), `wxc-exec.exe` (Windows) | | **Config value** | `"containment": "hyperlight"` | -| **Schema** | `0.9.0-alpha`, with `--experimental` | +| **Schema** | `0.10.0-alpha`, with `--experimental` | | **Requires** | x86_64; `/dev/kvm` readable and writable, or WHP enabled; a build with `--with-hyperlight` | | **Isolation** | Hardware virtualization; the guest is a unikernel with its own filesystem | -| **Guest** | The `agent` rootfs: CPython 3.12 with numpy, pandas, scipy and scikit-learn already imported | -| **Cold start** | A snapshot restore | +| **Guest** | One of the runtimes below, restored from a warm snapshot on every run | ## Quick start ```sh ./build.sh --with-hyperlight # Linux; build.bat --with-hyperlight on Windows -lxc-exec --setup-hyperlight # once per machine: pulls the rootfs, warms the snapshot +lxc-exec --setup-hyperlight # once per machine: installs the default runtime +lxc-exec --setup-hyperlight=python,node # installs others; any of the names below ``` ```json { - "version": "0.9.0-alpha", + "version": "0.10.0-alpha", "process": { "commandLine": "import pandas as pd\nprint(pd.DataFrame({'x': [1, 2]}).sum().to_dict())", "timeout": 30000 @@ -39,24 +39,56 @@ lxc-exec --setup-hyperlight # once per machine: pulls the rootfs, warms lxc-exec --experimental pandas.json ``` -`commandLine` is Python source, passed to the interpreter as is. The -guest's `print` output goes straight to the process's stdout. +`commandLine` is source for the guest runtime, passed to it as is, and the +guest's output goes straight to the process's stdout. The runtime is +`agent` unless the request names another: + +```json +{ + "version": "0.10.0-alpha", + "process": { "commandLine": "console.log(`hello from node ${process.version}`)" }, + "containment": "hyperlight", + "hyperlight": { "runtime": "node" } +} +``` + +## Guest runtimes + +Each runtime is one of hyperlight-unikraft's published images, named as +upstream names it. All take source through a driver that is parked warm +in the snapshot, and all return the script's exit status. + +| `runtime` | Guest | Scratch | +|---|---|---| +| `agent` (default) | CPython 3.12 with numpy, pandas, scipy, scikit-learn, matplotlib and Pillow imported; pydantic, PyYAML, Jinja2, BeautifulSoup, lxml, cryptography, requests, httpx and pip present | 1536 MiB | +| `python` | CPython 3.12 | 256 MiB | +| `python-shell` | CPython 3.12 with a BusyBox shell | 256 MiB | +| `node` | Node.js 22 | 512 MiB | +| `bash` | A BusyBox POSIX shell, under the name upstream gives the image | 256 MiB | +| `dotnet-jit` | .NET with the JIT | 768 MiB | + +`subprocess` and `fs`-style access to mounts work in every guest. The +compiled images upstream also publishes (C, Go, Rust, .NET AOT) run a +program baked into the rootfs rather than source, so they are not +selectable here. ## The image home -Setup fills an image home with three things: +Setup fills one directory per runtime under the image home, each with +three things: | Entry | Purpose | |---|---| -| `initrd.cpio` | The guest rootfs, pulled from `ghcr.io/hyperlight-dev/hyperlight-unikraft/agent` at the release the crate is pinned to | -| `snapshot/` | The guest, booted once and captured with the interpreter warm; every run restores it | -| `VERSION` | The rootfs release the home holds | +| `initrd.cpio` | The guest rootfs, pulled from `ghcr.io/hyperlight-dev/hyperlight-unikraft/` at the release the crate is pinned to | +| `snapshot/` | The guest, booted once and captured with the runtime warm; every run restores it | +| `VERSION` | The rootfs release the directory holds | The kernel is embedded in the crate, so nothing else is downloaded. The pull talks to the registry directly; no container runtime is involved. -The runner looks for a home in this order and takes the first that holds a -snapshot this build loads, or a rootfs of this release to warm one from: +The runner looks for a home in this order and takes the first whose +runtime directory holds a snapshot this build loads, or a rootfs of this +release to warm one from: 1. `$MXC_HYPERLIGHT_HOME` 2. `~/.local/share/mxc-hyperlight/` on Linux, `%LOCALAPPDATA%\mxc-hyperlight\` on Windows @@ -64,24 +96,24 @@ snapshot this build loads, or a rootfs of this release to warm one from: 4. `/.mxc-hyperlight/` Setup writes to the first or second of these. The rootfs is needed only -to warm, so a home trimmed to just the snapshot still runs. +to warm, so a directory trimmed to just its snapshot still runs. A snapshot loads only under the build that saved it (the crate keys it by its kernel and host contract), and a rootfs only boots on its own release's kernel. After a crate upgrade, `--setup-hyperlight` rebuilds a -home from another release, and a run warms a fresh snapshot by itself -when only the snapshot is stale. `--force` rewarms a home that is already -current, for example after dropping in a rootfs of your own. +runtime directory from another release, and a run warms a fresh snapshot +by itself when only the snapshot is stale. `--force` rewarms one that is +already current, for example after dropping in a rootfs of your own. ## How a run works -Every request restores the warm snapshot with its own mounts and network -policy, so consecutive runs are hermetic. A runner reused within one -process rewinds to the snapshot between calls. +Every request restores the warm snapshot with its own mounts, so +consecutive runs are hermetic. A runner reused within one process rewinds +to the snapshot between calls. | Aspect | Behaviour | |---|---| -| Exit code | The script's: `sys.exit(N)` gives `N`, an uncaught exception gives `1`, a runner error gives `-1` with the reason in `error_message` | +| Exit code | The script's: `sys.exit(N)`, `process.exit(N)` or `exit N` gives `N`, an uncaught exception gives `1`, a runner error gives `-1` with the reason in `error_message` | | Timeout | `process.timeout` bounds the run; a guest that overruns is interrupted and the run reports `execution timed out` | | stdout / stderr | Inherited by the guest; `ScriptResponse.standard_out` stays empty, so capture at the process level | @@ -89,59 +121,43 @@ process rewinds to the snapshot between calls. | Field | Behaviour | |---|---| -| `process.commandLine` | Python source | +| `process.commandLine` | Source for the guest runtime | +| `hyperlight.runtime` | A name from the table above; `agent` when omitted | | `process.timeout` | Run timeout in milliseconds | | `filesystem.readwritePaths` | Each directory appears in the guest at `/host/` | | `filesystem.readonlyPaths` | The same, mounted read-only; the guest kernel and the host both enforce it | | `filesystem.deniedPaths` | Checked at preflight against the two lists above | -| `network.defaultPolicy` | `allow` gives the guest host-proxied sockets; `block` refuses every `socket()` | -| `network.allowedHosts` / `blockedHosts` | An allow list or a block list of hosts; one or the other | -| `network.proxy` | Rejected at preflight | +| `network` | Rejected at preflight in this release: the guest runs without networking. The backend's host-proxied sockets take a host allow or block list; wiring the `0.10.0-alpha` egress model onto them is the next step | | `workingDirectory` | Rejected at preflight; the guest has its own filesystem | Mount directories are created when their parent exists. A basename may not contain whitespace, `:` or brackets, and two mounts may not share one. -## What the guest has - -Present in the rootfs: numpy, pandas, scipy, scikit-learn, matplotlib, -Pillow, pydantic, PyYAML, Jinja2, BeautifulSoup, tabulate, tqdm, openpyxl, -pypdf, lxml, cryptography, dateutil, requests, httpx and pip. numpy, -pandas, scipy, scikit-learn, matplotlib, Pillow and dateutil are imported -before the snapshot is taken, so importing them costs nothing at run -time. `subprocess` works, and with network allowed, `pip install` fetches -packages from PyPI into the guest's writable scratch. - -## Other guest runtimes - -hyperlight-unikraft publishes rootfs images for Node.js, .NET, Go, Rust, -C, Bash and PowerShell beside Python, each with the same boot, snapshot -and mount machinery. The backend's runner is built around one rootfs and -one snapshot per home, so another runtime is a second home and a -selector in the request. - ## Design notes - **In process.** Hyperlight is a Rust library and the executors are Rust binaries, so the backend links it and boots the VM in the executor's own - process: no helper process, pipes or lifecycle to manage. + process. - **A containment value of its own.** The backend needs a warmed snapshot in place before the first run, executes source rather than a command line, and forwards host directories to the guest live, so it is selected explicitly rather than resolved from `vm` or `microvm`. +- **One directory per runtime.** A runtime is a rootfs, a scratch size + and a snapshot; the runner keys a booted guest on its directory, so + runtimes install, upgrade and run independently. - **Images come from upstream.** The rootfs is built and published by hyperlight-unikraft's pipeline, and the kernel ships inside the crate. - Adding a package to the guest is a change to the image there. -- **Experimental.** The backend sits behind `--experimental` while the - request surface settles; the runtime selector above is the next step. + Adding a package to a guest is a change to the image there. +- **Experimental.** The backend sits behind `--experimental`; its own + settings live in the `hyperlight` section of the development contract. ## Troubleshooting | Message | Meaning | |---|---| -| `no hyperlight image found` | No home holds an image; run `--setup-hyperlight`, or set `MXC_HYPERLIGHT_HOME` | -| `holds a rootfs from another hyperlight-unikraft release` | The home predates the crate the binary was built with; `--setup-hyperlight` rebuilds it | -| `hyperlight backend unavailable` | No hypervisor: KVM is missing or not accessible, or WHP is not enabled | +| `no hyperlight image found for the runtime` | No home holds that runtime; run `--setup-hyperlight=`, or set `MXC_HYPERLIGHT_HOME` | +| `holds a rootfs from another hyperlight-unikraft release` | The directory predates the crate the binary was built with; `--setup-hyperlight=` rebuilds it | +| `Hyperlight requires KVM` / `Hyperlight requires Windows Hypervisor Platform` | No hypervisor for this process (error code `backend_unavailable`): on Linux `/dev/kvm` is missing or not readable and writable, on Windows WHP is not enabled | | `execution timed out after N s` | The script overran `process.timeout` | Run with `--debug` to see the home chosen, restore and call timings, and diff --git a/sdk/node/README.md b/sdk/node/README.md index 7faed7249..d74540623 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -303,7 +303,7 @@ console.log(result.stdout); | `seatbelt` | `process` | macOS | `0.7.0-alpha` | ✅ | [`docs/seatbelt/seatbelt-backend.md`](https://github.com/microsoft/mxc/blob/main/docs/seatbelt/seatbelt-backend.md) | | `windows_sandbox` | `vm` | Windows | `0.10.0-alpha` | Experimental | [`docs/windows-sandbox/windows-sandbox.md`](https://github.com/microsoft/mxc/blob/main/docs/windows-sandbox/windows-sandbox.md) | | `microvm` | `microvm` | Windows | `0.10.0-alpha` | Experimental | [`docs/nanvix-microvm/nanvix.md`](https://github.com/microsoft/mxc/blob/main/docs/nanvix-microvm/nanvix.md) — MicroVM via NanVix on Windows Hypervisor Platform | -| `hyperlight` | (concrete only) | Windows x64 / Linux x64 | `0.10.0-alpha` | Experimental | [`docs/hyperlight/hyperlight-backend.md`](https://github.com/microsoft/mxc/blob/main/docs/hyperlight/hyperlight-backend.md) — Hyperlight + Unikraft micro-VM on KVM / WHP | +| `hyperlight` | (concrete only) | Windows x64 / Linux x64 | `0.10.0-alpha` | Experimental | [`docs/hyperlight/hyperlight-backend.md`](https://github.com/microsoft/mxc/blob/main/docs/hyperlight/hyperlight-backend.md) — Hyperlight + Unikraft micro-VM on KVM / WHP; `hyperlight.runtime` selects the guest image: `agent` (default), `python`, `python-shell`, `node`, `bash` or `dotnet-jit` | | `wslc` | (concrete only) | Windows | `0.9.0-alpha` | Stable | [`docs/wsl/wsl-container-getting-started.md`](https://github.com/microsoft/mxc/blob/main/docs/wsl/wsl-container-getting-started.md) | | `isolation_session` | (concrete only) | Windows | `0.9.0-alpha` | Stable | [`docs/isolation-session/oneshot.md`](https://github.com/microsoft/mxc/blob/main/docs/isolation-session/oneshot.md) | diff --git a/src/backends/hyperlight/common/src/lib.rs b/src/backends/hyperlight/common/src/lib.rs index 383cbac7d..714d5025b 100644 --- a/src/backends/hyperlight/common/src/lib.rs +++ b/src/backends/hyperlight/common/src/lib.rs @@ -40,7 +40,8 @@ //! else is already populated — so one eager install persists across //! shell sessions, across reboots, and across `cargo install` upgrades. //! -//! An image home holds the guest rootfs (`initrd.cpio`), the warmed +//! An image home holds one directory per guest runtime (`agent/`, `node/`, +//! ...), each with that runtime's rootfs (`initrd.cpio`), its warmed //! `snapshot/` directory, and a `VERSION` stamp naming the rootfs release. //! The Unikraft kernel is embedded in the `hyperlight-unikraft` crate, so //! nothing else is downloaded. A snapshot loads only under a build with @@ -52,10 +53,11 @@ //! //! ## Setup //! -//! `lxc-exec --setup-hyperlight` (or `wxc-exec --setup-hyperlight`) pulls -//! the published `agent` rootfs straight from GHCR (no container runtime +//! `lxc-exec --setup-hyperlight[=RUNTIME,...]` (or `wxc-exec`) pulls each +//! runtime's published rootfs straight from GHCR (no container runtime //! needed), boots it once, and persists the warmed guest as a snapshot in -//! the default home. +//! the default home. `agent` is the default runtime; a request's +//! `hyperlight.runtime` selects another. //! //! On first `run` (if setup was skipped) the runner also does a lazy //! auto-install if `initrd.cpio` is already in the resolved home but no @@ -97,7 +99,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use wxc_common::logger::Logger; -use wxc_common::models::{ExecutionRequest, NetworkPolicy, ScriptResponse}; +use wxc_common::models::{ExecutionRequest, HyperlightRuntime, NetworkPolicy, ScriptResponse}; use wxc_common::script_runner::ScriptRunner; use wxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; @@ -183,26 +185,59 @@ const SNAPSHOT_DIR: &str = "snapshot"; /// Stamp `setup` writes beside the rootfs, naming the release it holds. const VERSION_FILE: &str = "VERSION"; -/// The published rootfs: the `agent` runtime (CPython plus numpy, pandas, -/// scipy, scikit-learn, ...). Its `:initrd-` tag wraps the runnable -/// CPIO in a scratch image at [`ROOTFS_PATH_IN_IMAGE`]. -const ROOTFS_IMAGE: &str = "ghcr.io/hyperlight-dev/hyperlight-unikraft/agent"; /// Pinned to the `hyperlight-unikraft` release in Cargo.toml: a rootfs /// only boots on the kernel and driver protocol of its own release. const ROOTFS_TAG: &str = "initrd-v0.14.1"; const ROOTFS_PATH_IN_IMAGE: &str = "/initrd.cpio"; -/// Scratch memory for the agent image — upstream's own figure for it; the -/// ML stack needs far more than the library's 256 MiB default. -const SCRATCH_MB: usize = 1536; /// Where host directories appear in the guest: `/host/`. const GUEST_MOUNT_ROOT: &str = "/host"; +/// The executor whose `--setup-hyperlight` installs an image, for hints. +const SETUP_BIN: &str = if cfg!(windows) { + "wxc-exec" +} else { + "lxc-exec" +}; + +/// What a guest runtime is built from. +struct RuntimeImage { + /// The published rootfs, `ghcr.io///`, named as + /// the runtime is. Its `:initrd-` tag wraps the runnable CPIO + /// in a scratch image at [`ROOTFS_PATH_IN_IMAGE`]. + image: String, + /// Scratch memory for it: upstream's own figure, covering rootfs + /// extraction plus runtime start-up. + scratch_mb: usize, +} + +fn runtime_image(runtime: HyperlightRuntime) -> RuntimeImage { + // Upstream's own scratch figures per image: rootfs extraction plus + // runtime start-up. + let scratch_mb = match runtime { + HyperlightRuntime::Agent => 1536, + HyperlightRuntime::Python | HyperlightRuntime::PythonShell | HyperlightRuntime::Bash => 256, + HyperlightRuntime::Node => 512, + HyperlightRuntime::DotnetJit => 768, + }; + RuntimeImage { + image: format!( + "ghcr.io/hyperlight-dev/hyperlight-unikraft/{}", + runtime.name() + ), + scratch_mb, + } +} const ERR_PROXY_POLICY: &str = "network proxy is not supported by the hyperlight backend"; const ERR_WORKDIR: &str = "workingDirectory is not supported by the hyperlight backend -- guest has its own filesystem namespace"; -const ERR_NO_INSTALL_SOURCE: &str = - "no warmed snapshot and no rootfs to install from. drop `initrd.cpio` into the image \ - home (or run `--setup-hyperlight`)."; + +fn no_install_source(runtime: HyperlightRuntime) -> String { + let name = runtime.name(); + format!( + "no warmed {name} snapshot and no rootfs to install from. drop `{INITRD_FILE}` into \ + the runtime's directory of the image home (or run `--setup-hyperlight={name}`)." + ) +} // -- Runner ------------------------------------------------------------------ @@ -279,6 +314,15 @@ impl Default for HyperlightScriptRunner { } } +/// The runtimes `--setup-hyperlight` was given: the default runtime for +/// no names, otherwise each name parsed, the first unknown one reported. +pub fn parse_runtimes(names: &[String]) -> Result, String> { + if names.is_empty() { + return Ok(vec![HyperlightRuntime::default()]); + } + names.iter().map(|name| name.parse()).collect() +} + /// Eagerly install the warmed snapshot so the *first* run later /// pays no warmup cost. Intended to be called from a tool install /// step (npm postinstall, a `--setup-hyperlight` CLI flag, CI, etc.). @@ -290,7 +334,7 @@ impl Default for HyperlightScriptRunner { /// /// `$MXC_HYPERLIGHT_HOME` if set, otherwise the OS-local default /// (`~/.local/share/mxc-hyperlight` on Linux, `%LOCALAPPDATA%\mxc-hyperlight` on -/// Windows). We intentionally do NOT walk the runtime search chain +/// Windows), with one directory per runtime under it. We intentionally do NOT walk the runtime search chain /// here — that would let a stale `/.mxc-hyperlight/` from an old dev /// session short-circuit the install and leave the default home /// empty, which would make later runs from a different cwd fail. @@ -300,47 +344,56 @@ impl Default for HyperlightScriptRunner { /// When `force` is false, an existing install for this release is a /// no-op; one left by another release is rebuilt. When `force` is true, /// the snapshot is rebuilt regardless. -pub fn setup(force: bool, logger: &mut Logger) -> Result { - let home = match std::env::var_os(HOME_ENV) { +pub fn setup( + force: bool, + runtimes: &[HyperlightRuntime], + logger: &mut Logger, +) -> Result { + let root = match std::env::var_os(HOME_ENV) { Some(v) => PathBuf::from(v), None => HyperlightScriptRunner::default_home(), }; - if !force && is_installed(&home) { - logger.log_line(&format!( - "hyperlight: snapshot already present at {:?}; nothing to do \ - (pass --force to rebuild)", - home.join(SNAPSHOT_DIR) - )); - return Ok(home.join(SNAPSHOT_DIR)); - } + for &runtime in runtimes { + let home = root.join(runtime.name()); + if !force && is_installed(&home, runtime) { + logger.log_line(&format!( + "hyperlight: {} snapshot already present at {:?}; nothing to do \ + (pass --force to rebuild)", + runtime.name(), + home.join(SNAPSHOT_DIR) + )); + continue; + } - std::fs::create_dir_all(&home).map_err(|e| format!("create image home {home:?}: {e}"))?; + std::fs::create_dir_all(&home).map_err(|e| format!("create image home {home:?}: {e}"))?; - // A rootfs of this release already in the home is kept, so a rebuild - // (a stale snapshot, or --force after replacing it) only warms. - if has_install_source(&home) { - logger.log_line(&format!( - "hyperlight setup: rootfs present at {:?}", - home.join(INITRD_FILE) - )); - } else { - logger.log_line(&format!( - "hyperlight setup: pulling {ROOTFS_IMAGE}:{ROOTFS_TAG}" - )); - pull_rootfs(&home.join(INITRD_FILE), logger)?; - std::fs::write(home.join(VERSION_FILE), version_stamp()) - .map_err(|e| format!("write {VERSION_FILE}: {e}"))?; - } + // A rootfs of this release already in the home is kept, so a rebuild + // (a stale snapshot, or --force after replacing it) only warms. + if has_install_source(&home, runtime) { + logger.log_line(&format!( + "hyperlight setup: rootfs present at {:?}", + home.join(INITRD_FILE) + )); + } else { + logger.log_line(&format!( + "hyperlight setup: pulling {}:{ROOTFS_TAG}", + runtime_image(runtime).image + )); + pull_rootfs(&home.join(INITRD_FILE), runtime, logger)?; + std::fs::write(home.join(VERSION_FILE), version_stamp(runtime)) + .map_err(|e| format!("write {VERSION_FILE}: {e}"))?; + } - let (_, persisted) = warm_snapshot(&home, logger).map_err(|e| e.to_string())?; - if !persisted { - return Err(format!( - "the snapshot could not be put in place at {:?}; the log above says why", - home.join(SNAPSHOT_DIR) - )); + let (_, persisted) = warm_snapshot(&home, runtime, logger).map_err(|e| e.to_string())?; + if !persisted { + return Err(format!( + "the snapshot could not be put in place at {:?}; the log above says why", + home.join(SNAPSHOT_DIR) + )); + } } - Ok(home.join(SNAPSHOT_DIR)) + Ok(root) } impl HyperlightScriptRunner { @@ -359,10 +412,12 @@ impl HyperlightScriptRunner { /// discovery chain (see module doc) and returns the first /// location that has at least the rootfs — snapshot may be /// missing, the runner will install it. - fn resolve_home() -> Result { + fn resolve_home(runtime: HyperlightRuntime) -> Result { + let name = runtime.name(); let mut stale = None; - for cand in Self::search_paths() { - if is_installed(&cand) || has_install_source(&cand) { + for root in Self::search_paths() { + let cand = root.join(name); + if is_installed(&cand, runtime) || has_install_source(&cand, runtime) { return Ok(cand); } if stale.is_none() && cand.join(INITRD_FILE).is_file() { @@ -373,16 +428,18 @@ impl HyperlightScriptRunner { let hint = match stale { Some(home) => format!( "{home:?} holds a rootfs from another hyperlight-unikraft release; \ - run `lxc-exec --setup-hyperlight` to rebuild it." + run `{SETUP_BIN} --setup-hyperlight={name}` to rebuild it." ), None => format!( - "run `lxc-exec --setup-hyperlight` \ - (or drop `{INITRD_FILE}` into {default:?})." + "run `{SETUP_BIN} --setup-hyperlight={name}` \ + (or drop `{INITRD_FILE}` into {:?}).", + default.join(name) ), }; Err(RunnerError::Preflight(format!( - "no hyperlight image found. searched ${HOME_ENV}, {default:?}, \ - /{EXE_RELATIVE_HOME}/, /{CWD_RELATIVE_HOME}/. {hint}" + "no hyperlight image found for the {name} runtime. searched ${HOME_ENV}, \ + {default:?}, /{EXE_RELATIVE_HOME}/, /{CWD_RELATIVE_HOME}/, each under \ + `{name}/`. {hint}" ))) } @@ -572,6 +629,7 @@ impl HyperlightScriptRunner { fn ensure_runtime( &mut self, home: &Path, + runtime: HyperlightRuntime, mounts: Vec, network: NetworkKey, logger: &mut Logger, @@ -599,7 +657,7 @@ impl HyperlightScriptRunner { Some(rewind) => rewind, None => { configure_surrogates(); - let rewind = load_persisted_snapshot(home, logger)?; + let rewind = load_persisted_snapshot(home, runtime, logger)?; self.rewind = Some(rewind.clone()); rewind } @@ -686,7 +744,12 @@ impl ScriptRunner for HyperlightScriptRunner { } fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { - let home = match Self::resolve_home() { + let runtime = request + .hyperlight + .as_ref() + .map(|h| h.runtime) + .unwrap_or_default(); + let home = match Self::resolve_home(runtime) { Ok(h) => h, Err(e) => { logger.log_line(&e.to_string()); @@ -700,14 +763,19 @@ impl ScriptRunner for HyperlightScriptRunner { return e.to_response(); } }; - let (guest, rewind) = - match self.ensure_runtime(&home, mounts, NetworkKey::from_request(request), logger) { - Ok(pair) => pair, - Err(e) => { - logger.log_line(&e.to_string()); - return e.to_response(); - } - }; + let (guest, rewind) = match self.ensure_runtime( + &home, + runtime, + mounts, + NetworkKey::from_request(request), + logger, + ) { + Ok(pair) => pair, + Err(e) => { + logger.log_line(&e.to_string()); + return e.to_response(); + } + }; let timeout = (request.script_timeout > 0).then(|| { logger.log_line(&format!( @@ -833,13 +901,15 @@ fn drive_until( // -- Install ----------------------------------------------------------------- -/// Boot the rootfs in `home` without mounts, snapshot the warmed guest to -/// `home/snapshot` (replacing any snapshot there), and hand it back. /// Boot the rootfs in `home`, save the warm image beside its snapshot /// directory and publish it there. The flag says whether the new layout /// is what now sits in the snapshot directory; when it is not, the image /// in memory is still this warm's and the log says what happened on disk. -fn warm_snapshot(home: &Path, logger: &mut Logger) -> Result<(Arc, bool), RunnerError> { +fn warm_snapshot( + home: &Path, + runtime: HyperlightRuntime, + logger: &mut Logger, +) -> Result<(Arc, bool), RunnerError> { let snapshot_dir = home.join(SNAPSHOT_DIR); logger.log_line(&format!( "hyperlight: booting {:?} to warm a snapshot", @@ -847,7 +917,7 @@ fn warm_snapshot(home: &Path, logger: &mut Logger) -> Result<(Arc, boo )); let t = Instant::now(); configure_surrogates(); - let mut sandbox = rootfs_builder(home) + let mut sandbox = rootfs_builder(home, runtime) .boot() .map_err(|e| RunnerError::Runtime(format!("boot hyperlight rootfs: {e}")))?; // Saved beside the snapshot directory, so a failed save leaves whatever @@ -937,7 +1007,11 @@ fn publish_snapshot(home: &Path, staged: &Path, snapshot_dir: &Path, logger: &mu /// The persisted snapshot in `home`, warming and persisting one first /// when there is none this build loads. -fn load_persisted_snapshot(home: &Path, logger: &mut Logger) -> Result, RunnerError> { +fn load_persisted_snapshot( + home: &Path, + runtime: HyperlightRuntime, + logger: &mut Logger, +) -> Result, RunnerError> { let snapshot_dir = home.join(SNAPSHOT_DIR); let unloadable = match hyperlight_unikraft::load_snapshot(&snapshot_dir) { Ok(snapshot) => { @@ -946,8 +1020,8 @@ fn load_persisted_snapshot(home: &Path, logger: &mut Logger) -> Result e, }; - if !has_install_source(home) { - return Err(RunnerError::Preflight(ERR_NO_INSTALL_SOURCE.to_string())); + if !has_install_source(home, runtime) { + return Err(RunnerError::Preflight(no_install_source(runtime))); } logger.log_line(&match unloadable { hyperlight_unikraft::Error::SnapshotRelease { saved_by, .. } => format!( @@ -959,7 +1033,7 @@ fn load_persisted_snapshot(home: &Path, logger: &mut Logger) -> Result Result SandboxBuilder { - SandboxBuilder::from_initrd(home.join(INITRD_FILE)).scratch_mb(SCRATCH_MB) +fn rootfs_builder(home: &Path, runtime: HyperlightRuntime) -> SandboxBuilder { + SandboxBuilder::from_initrd(home.join(INITRD_FILE)) + .scratch_mb(runtime_image(runtime).scratch_mb) } fn with_network( @@ -987,11 +1062,11 @@ fn with_network( /// from the registry's distribution API: no container runtime needed. /// Staged beside `dst` and renamed into place so a failed pull leaves no /// half-written rootfs. -fn pull_rootfs(dst: &Path, logger: &mut Logger) -> Result<(), String> { +fn pull_rootfs(dst: &Path, runtime: HyperlightRuntime, logger: &mut Logger) -> Result<(), String> { // Staged per process, so two setups at once each pull their own copy. let staged = dst.with_file_name(format!(".{INITRD_FILE}.{}.part", std::process::id())); let pulled = oci::fetch_file( - ROOTFS_IMAGE, + &runtime_image(runtime).image, ROOTFS_TAG, ROOTFS_PATH_IN_IMAGE, &staged, @@ -1177,6 +1252,7 @@ mod oci { return Err(format!("{wanted} in the layer is empty")); } found = true; + break; } Ok(found) } @@ -1225,25 +1301,26 @@ fn configure_surrogates() { /// A home has a snapshot this build loads, beside no rootfs of another /// release. The rootfs is only needed to warm, not to run. -fn is_installed(home: &Path) -> bool { - stamp_matches(home) && hyperlight_unikraft::load_snapshot(home.join(SNAPSHOT_DIR)).is_ok() +fn is_installed(home: &Path, runtime: HyperlightRuntime) -> bool { + stamp_matches(home, runtime) + && hyperlight_unikraft::load_snapshot(home.join(SNAPSHOT_DIR)).is_ok() } /// A home has a rootfs of this release — enough to warm a snapshot from. /// A rootfs with no stamp (dropped in by hand) is taken on trust; one /// stamped for another release is not, since it will not boot on this /// release's kernel. -fn has_install_source(home: &Path) -> bool { - home.join(INITRD_FILE).is_file() && stamp_matches(home) +fn has_install_source(home: &Path, runtime: HyperlightRuntime) -> bool { + home.join(INITRD_FILE).is_file() && stamp_matches(home, runtime) } -fn version_stamp() -> String { - format!("rootfs: {ROOTFS_IMAGE}:{ROOTFS_TAG}\n") +fn version_stamp(runtime: HyperlightRuntime) -> String { + format!("rootfs: {}:{ROOTFS_TAG}\n", runtime_image(runtime).image) } -fn stamp_matches(home: &Path) -> bool { +fn stamp_matches(home: &Path, runtime: HyperlightRuntime) -> bool { match std::fs::read_to_string(home.join(VERSION_FILE)) { - Ok(stamp) => stamp.trim() == version_stamp().trim(), + Ok(stamp) => stamp.trim() == version_stamp(runtime).trim(), Err(e) => e.kind() == std::io::ErrorKind::NotFound, } } @@ -1316,8 +1393,8 @@ mod tests { #[test] fn is_installed_false_on_empty_dir() { let tmp = fresh_tmp("empty"); - assert!(!is_installed(&tmp)); - assert!(!has_install_source(&tmp)); + assert!(!is_installed(&tmp, HyperlightRuntime::Agent)); + assert!(!has_install_source(&tmp, HyperlightRuntime::Agent)); let _ = std::fs::remove_dir_all(&tmp); } @@ -1325,8 +1402,8 @@ mod tests { fn has_install_source_true_when_rootfs_present() { let tmp = fresh_tmp("install-src"); std::fs::write(tmp.join(INITRD_FILE), b"").unwrap(); - assert!(has_install_source(&tmp)); - assert!(!is_installed(&tmp)); // snapshot still absent + assert!(has_install_source(&tmp, HyperlightRuntime::Agent)); + assert!(!is_installed(&tmp, HyperlightRuntime::Agent)); // snapshot still absent let _ = std::fs::remove_dir_all(&tmp); } @@ -1335,16 +1412,58 @@ mod tests { let tmp = fresh_tmp("stale"); std::fs::write(tmp.join(INITRD_FILE), b"").unwrap(); // A stamp from an earlier release than ROOTFS_TAG names. + let agent = HyperlightRuntime::Agent; std::fs::write( tmp.join(VERSION_FILE), - format!("rootfs: {ROOTFS_IMAGE}:initrd-v0.13.0\n"), + format!("rootfs: {}:initrd-v0.13.0\n", runtime_image(agent).image), ) .unwrap(); - assert!(!has_install_source(&tmp)); - assert!(!is_installed(&tmp)); + assert!(!has_install_source(&tmp, agent)); + assert!(!is_installed(&tmp, agent)); - std::fs::write(tmp.join(VERSION_FILE), version_stamp()).unwrap(); - assert!(has_install_source(&tmp)); + std::fs::write(tmp.join(VERSION_FILE), version_stamp(agent)).unwrap(); + assert!(has_install_source(&tmp, agent)); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn rootfs_tag_follows_the_pinned_crate_release() { + let manifest = + std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")).unwrap(); + let line = manifest + .lines() + .find(|l| l.starts_with("hyperlight-unikraft = ")) + .expect("the crate dependency line"); + let version = line + .split("version = \"") + .nth(1) + .and_then(|rest| rest.split('"').next()) + .expect("a pinned version"); + assert_eq!(ROOTFS_TAG, format!("initrd-v{version}")); + } + + #[test] + fn a_runtime_only_takes_its_own_rootfs() { + let tmp = fresh_tmp("runtimes"); + let agent = tmp.join(HyperlightRuntime::Agent.name()); + let node = tmp.join(HyperlightRuntime::Node.name()); + for dir in [&agent, &node] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join(INITRD_FILE), b"").unwrap(); + std::fs::write( + dir.join(VERSION_FILE), + version_stamp(HyperlightRuntime::Agent), + ) + .unwrap(); + } + assert!(has_install_source(&agent, HyperlightRuntime::Agent)); + assert!(!has_install_source(&node, HyperlightRuntime::Node)); + std::fs::write( + node.join(VERSION_FILE), + version_stamp(HyperlightRuntime::Node), + ) + .unwrap(); + assert!(has_install_source(&node, HyperlightRuntime::Node)); let _ = std::fs::remove_dir_all(&tmp); } @@ -1377,7 +1496,7 @@ mod tests { std::env::set_var("LOCALAPPDATA", &empty); } - let result = HyperlightScriptRunner::resolve_home(); + let result = HyperlightScriptRunner::resolve_home(HyperlightRuntime::Agent); // Restore env before asserting so a failing assert can't leak. unsafe { diff --git a/src/core/lxc/src/main.rs b/src/core/lxc/src/main.rs index 863fc5e8a..587feccaa 100644 --- a/src/core/lxc/src/main.rs +++ b/src/core/lxc/src/main.rs @@ -63,15 +63,24 @@ struct Cli { #[arg(long = "log-file")] log_file: Option, - /// Install the warmed Hyperlight snapshot and exit. Pulls the - /// published rootfs from GHCR unless the image home already holds - /// it, boots it once, and writes the snapshot into the default user - /// data dir (~/.local/share/mxc-hyperlight on Linux, + /// Install the warmed Hyperlight snapshot and exit, for the default + /// runtime (`agent`) or the comma-separated runtimes given with `=`: + /// `--setup-hyperlight=python,node`. Names are `agent`, `python`, + /// `python-shell`, `node`, `bash` and `dotnet-jit`. For each, pulls the + /// published rootfs from GHCR unless the image home already holds it, + /// boots it once, and writes the snapshot into the default user data + /// dir (~/.local/share/mxc-hyperlight on Linux, /// %LOCALAPPDATA%\mxc-hyperlight on Windows). $MXC_HYPERLIGHT_HOME /// overrides the destination if set. Intended for tool install hooks /// so first-run has zero warmup cost. - #[arg(long = "setup-hyperlight")] - setup_hyperlight: bool, + #[arg( + long = "setup-hyperlight", + value_name = "RUNTIMES", + num_args = 0..=1, + require_equals = true, + value_delimiter = ',' + )] + setup_hyperlight: Option>, /// Rebuild the snapshot even if one already exists. Use after /// replacing `initrd.cpio` so the warm state matches the new @@ -160,7 +169,16 @@ fn main() { // --setup-hyperlight: eagerly warm up the snapshot and exit. Runs // before config parsing so the user doesn't need a JSON file on // disk just to install. - if cli.setup_hyperlight { + if let Some(runtime_names) = &cli.setup_hyperlight { + // Setup exits before any config is read, so a positional here is + // most likely a runtime name given with a space instead of `=`. + if let Some(stray) = &cli.config_path { + eprintln!( + "Error: --setup-hyperlight takes no config path; name runtimes with \ + --setup-hyperlight={stray}" + ); + process::exit(1); + } #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] { // WHP is delay-loaded; check before setup boots a VM. @@ -182,14 +200,19 @@ fn main() { process::exit(1); } - let mut logger = Logger::new(if cli.debug { - Mode::Console - } else { - Mode::Buffer - }); - match hyperlight_common::setup(cli.force, &mut logger) { - Ok(snap) => { - eprintln!("hyperlight setup: snapshot ready at {:?}", snap); + // Setup is an interactive install: the pull and the warm-up + // report progress as they go. + let mut logger = Logger::new(Mode::Console); + let runtimes = match hyperlight_common::parse_runtimes(runtime_names) { + Ok(runtimes) => runtimes, + Err(msg) => { + eprintln!("Error: {msg}"); + process::exit(1); + } + }; + match hyperlight_common::setup(cli.force, &runtimes, &mut logger) { + Ok(home) => { + eprintln!("hyperlight setup: image home ready at {:?}", home); process::exit(0); } Err(msg) => { @@ -200,6 +223,7 @@ fn main() { } #[cfg(not(all(feature = "hyperlight", target_arch = "x86_64")))] { + let _ = runtime_names; eprintln!("Error: --setup-hyperlight requires x86_64 (Hyperlight needs KVM or WHP)"); process::exit(1); } diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index 7299fc752..4b4314e27 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -68,15 +68,24 @@ struct Cli { #[arg(long = "log-file")] log_file: Option, - /// Install the warmed Hyperlight snapshot and exit. Pulls the - /// published rootfs from GHCR unless the image home already holds - /// it, boots it once, and writes the snapshot into the default user - /// data dir (~/.local/share/mxc-hyperlight on Linux, + /// Install the warmed Hyperlight snapshot and exit, for the default + /// runtime (`agent`) or the comma-separated runtimes given with `=`: + /// `--setup-hyperlight=python,node`. Names are `agent`, `python`, + /// `python-shell`, `node`, `bash` and `dotnet-jit`. For each, pulls the + /// published rootfs from GHCR unless the image home already holds it, + /// boots it once, and writes the snapshot into the default user data + /// dir (~/.local/share/mxc-hyperlight on Linux, /// %LOCALAPPDATA%\mxc-hyperlight on Windows). $MXC_HYPERLIGHT_HOME /// overrides the destination if set. Intended for tool install hooks /// so first-run has zero warmup cost. - #[arg(long = "setup-hyperlight")] - setup_hyperlight: bool, + #[arg( + long = "setup-hyperlight", + value_name = "RUNTIMES", + num_args = 0..=1, + require_equals = true, + value_delimiter = ',' + )] + setup_hyperlight: Option>, /// Rebuild the snapshot even if one already exists. Use after /// replacing `initrd.cpio` so the warm state matches the new @@ -1062,7 +1071,16 @@ fn main() { // --setup-hyperlight: warm up the snapshot and exit. Runs before // config parsing so the user doesn't need a JSON file on disk // just to install. - if cli.setup_hyperlight { + if let Some(runtime_names) = &cli.setup_hyperlight { + // Setup exits before any config is read, so a positional here is + // most likely a runtime name given with a space instead of `=`. + if let Some(stray) = &cli.config_path { + eprintln!( + "Error: --setup-hyperlight takes no config path; name runtimes with \ + --setup-hyperlight={stray}" + ); + process::exit(1); + } #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] { // WHP is delay-loaded; check before setup boots a VM. @@ -1075,14 +1093,19 @@ fn main() { process::exit(1); } - let mut logger = Logger::new(if cli.debug { - Mode::Console - } else { - Mode::Buffer - }); - match hyperlight_common::setup(cli.force, &mut logger) { - Ok(snap) => { - eprintln!("hyperlight setup: snapshot ready at {:?}", snap); + // Setup is an interactive install: the pull and the warm-up + // report progress as they go. + let mut logger = Logger::new(Mode::Console); + let runtimes = match hyperlight_common::parse_runtimes(runtime_names) { + Ok(runtimes) => runtimes, + Err(msg) => { + eprintln!("Error: {msg}"); + process::exit(1); + } + }; + match hyperlight_common::setup(cli.force, &runtimes, &mut logger) { + Ok(home) => { + eprintln!("hyperlight setup: image home ready at {:?}", home); process::exit(0); } Err(msg) => { @@ -1093,6 +1116,7 @@ fn main() { } #[cfg(not(all(feature = "hyperlight", target_arch = "x86_64")))] { + let _ = runtime_names; eprintln!("Error: --setup-hyperlight requires x86_64 (Hyperlight needs KVM or WHP)"); process::exit(1); } diff --git a/src/testing/wxc_e2e_tests/src/lib.rs b/src/testing/wxc_e2e_tests/src/lib.rs index 618740319..b7d647fb1 100644 --- a/src/testing/wxc_e2e_tests/src/lib.rs +++ b/src/testing/wxc_e2e_tests/src/lib.rs @@ -255,17 +255,39 @@ pub fn run_lxc_config(config_file: &str, extra_args: &[&str]) -> CommandResult { run_executable(config_file, &exe, args) } -/// Return whether the Hyperlight snapshot is installed at the default -/// location (`%LOCALAPPDATA%\mxc-hyperlight\snapshot\index.json`). -pub fn has_hyperlight_snapshot() -> bool { - let home = std::env::var_os("LOCALAPPDATA") +/// The Hyperlight image home the runner resolves first: `MXC_HYPERLIGHT_HOME` +/// when set, else `%LOCALAPPDATA%\mxc-hyperlight`. +fn hyperlight_home() -> PathBuf { + if let Some(home) = std::env::var_os("MXC_HYPERLIGHT_HOME") { + return PathBuf::from(home); + } + std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) .unwrap_or_else(|| { std::env::var_os("USERPROFILE") .map(|v| PathBuf::from(v).join("AppData").join("Local")) .unwrap_or_default() - }); - let snapshot = home.join("mxc-hyperlight").join("snapshot").join("index.json"); + }) + .join("mxc-hyperlight") +} + +/// Return whether `runtime`'s snapshot is installed in the default home +/// (`%LOCALAPPDATA%\mxc-hyperlight\\snapshot\index.json`). +pub fn has_hyperlight_runtime(runtime: &str) -> bool { + hyperlight_home() + .join(runtime) + .join("snapshot") + .join("index.json") + .is_file() +} + +/// Return whether the default Hyperlight runtime's snapshot is installed +/// (`%LOCALAPPDATA%\mxc-hyperlight\agent\snapshot\index.json`). +pub fn has_hyperlight_snapshot() -> bool { + let snapshot = hyperlight_home() + .join("agent") + .join("snapshot") + .join("index.json"); if snapshot.is_file() { println!("Using Hyperlight snapshot at {}", snapshot.display()); true diff --git a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs index eb0c18d4c..11e8cb774 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_windows.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_windows.rs @@ -13,10 +13,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::Serialize; use wxc_e2e_tests::{ assert_exit, assert_pwsh, assert_python, assert_success, - assert_success_or_skip_missing_prerequisite, examples_dir, has_hyperlight_snapshot, - has_nanvix_binaries, has_test_driver, has_windows_sandbox_feature, has_wxc_exe, repo_root, - run_test_driver, run_wxc_config, run_wxc_config_value, run_wxc_example, run_wxc_state_aware, - test_configs_dir, TempDirs, + assert_success_or_skip_missing_prerequisite, examples_dir, has_hyperlight_runtime, + has_hyperlight_snapshot, has_nanvix_binaries, has_test_driver, has_windows_sandbox_feature, + has_wxc_exe, repo_root, run_test_driver, run_wxc_config, run_wxc_config_value, run_wxc_example, + run_wxc_state_aware, test_configs_dir, TempDirs, }; static HAS_WXC_EXE: OnceLock = OnceLock::new(); @@ -981,6 +981,9 @@ fn expected_exit_description(case: &MicrovmCase) -> String { #[derive(Debug)] struct HyperlightCase { config: &'static str, + /// The guest runtime the config names; the case is skipped when its + /// snapshot is not installed. + runtime: &'static str, description: &'static str, expected_exit: i32, output_contains: Option<&'static str>, @@ -990,26 +993,51 @@ fn hyperlight_suite() { let cases = [ HyperlightCase { config: "hyperlight_hello.json", + runtime: "agent", description: "Hello world", expected_exit: 0, output_contains: Some("Hello from Hyperlight!"), }, HyperlightCase { config: "hyperlight_pandas.json", + runtime: "agent", description: "numpy + pandas", expected_exit: 0, output_contains: Some("'x':"), }, HyperlightCase { config: "hyperlight_exit_code.json", + runtime: "agent", description: "sys.exit(42) propagates exit code", expected_exit: 42, output_contains: None, }, // The legacy hostname-policy fixtures are intentional v0.9 parser // rejections covered by run_hyperlight_network_migration_test.ps1. + HyperlightCase { + config: "hyperlight_python_hello.json", + runtime: "python", + description: "plain CPython image (hyperlight.runtime = python)", + expected_exit: 0, + output_contains: Some("hello from plain python"), + }, + HyperlightCase { + config: "hyperlight_node_hello.json", + runtime: "node", + description: "Node.js hello (hyperlight.runtime = node)", + expected_exit: 0, + output_contains: Some("hello from node"), + }, + HyperlightCase { + config: "hyperlight_node_exit_code.json", + runtime: "node", + description: "Node.js process.exit(42) propagates exit code", + expected_exit: 42, + output_contains: None, + }, HyperlightCase { config: "hyperlight_timeout.json", + runtime: "agent", description: "time.sleep(120) killed by 1s timeout", expected_exit: -1, output_contains: Some("timed out"), @@ -1019,6 +1047,13 @@ fn hyperlight_suite() { let mut failures = Vec::new(); for case in cases { println!("--- {} ({}) ---", case.description, case.config); + if !has_hyperlight_runtime(case.runtime) { + println!( + " SKIPPED: {} runtime not installed (run --setup-hyperlight={})", + case.runtime, case.runtime + ); + continue; + } let result = run_wxc_config(case.config, &["--debug", "--experimental"]); if result.code != Some(case.expected_exit) { diff --git a/tests/configs/hyperlight_node_exit_code.json b/tests/configs/hyperlight_node_exit_code.json new file mode 100644 index 000000000..ea7d60a7d --- /dev/null +++ b/tests/configs/hyperlight_node_exit_code.json @@ -0,0 +1,11 @@ +{ + "version": "0.10.0-alpha", + "process": { + "commandLine": "console.log('exiting with 42');\nprocess.exit(42);", + "timeout": 30000 + }, + "containment": "hyperlight", + "hyperlight": { + "runtime": "node" + } +} diff --git a/tests/configs/hyperlight_node_hello.json b/tests/configs/hyperlight_node_hello.json new file mode 100644 index 000000000..77d4e281c --- /dev/null +++ b/tests/configs/hyperlight_node_hello.json @@ -0,0 +1,11 @@ +{ + "version": "0.10.0-alpha", + "process": { + "commandLine": "const os = require('os');\nconsole.log(`hello from node ${process.version} on ${os.platform()}`);", + "timeout": 30000 + }, + "containment": "hyperlight", + "hyperlight": { + "runtime": "node" + } +} diff --git a/tests/configs/hyperlight_python_hello.json b/tests/configs/hyperlight_python_hello.json new file mode 100644 index 000000000..68bf08c14 --- /dev/null +++ b/tests/configs/hyperlight_python_hello.json @@ -0,0 +1,11 @@ +{ + "version": "0.10.0-alpha", + "process": { + "commandLine": "import sys\nprint(f'hello from plain python {sys.version.split()[0]}')", + "timeout": 30000 + }, + "containment": "hyperlight", + "hyperlight": { + "runtime": "python" + } +}