diff --git a/.github/workflows/release-vm-dev.yml b/.github/workflows/release-vm-dev.yml
index b32bfd8de..3f37a17bb 100644
--- a/.github/workflows/release-vm-dev.yml
+++ b/.github/workflows/release-vm-dev.yml
@@ -421,7 +421,7 @@ jobs:
# ---------------------------------------------------------------------------
build-driver-vm-linux:
name: Build Driver VM (Linux ${{ matrix.arch }})
- needs: [compute-versions, download-kernel-runtime, build-rootfs]
+ needs: [compute-versions, download-kernel-runtime]
strategy:
matrix:
include:
@@ -477,12 +477,6 @@ jobs:
name: kernel-runtime-tarballs
path: runtime-download/
- - name: Download rootfs tarball
- uses: actions/download-artifact@v4
- with:
- name: rootfs-${{ matrix.arch }}
- path: rootfs-download/
-
- name: Stage compressed runtime for embedding
run: |
set -euo pipefail
@@ -504,12 +498,15 @@ jobs:
zstd -19 -f -q -T0 -o "${COMPRESSED_DIR}/${name}.zst" "$file"
done
- # Copy rootfs tarball (already zstd-compressed)
- cp rootfs-download/rootfs.tar.zst "${COMPRESSED_DIR}/rootfs.tar.zst"
-
echo "Staged compressed artifacts:"
ls -lah "$COMPRESSED_DIR"
+ - name: Build bundled supervisor
+ run: |
+ set -euo pipefail
+ OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed" \
+ tasks/scripts/vm/build-supervisor-bundle.sh --arch "${{ matrix.guest_arch }}"
+
- name: Scope workspace to driver-vm crates
run: |
set -euo pipefail
@@ -551,7 +548,7 @@ jobs:
# ---------------------------------------------------------------------------
build-driver-vm-macos:
name: Build Driver VM (macOS)
- needs: [compute-versions, download-kernel-runtime, build-rootfs]
+ needs: [compute-versions, download-kernel-runtime]
runs-on: build-amd64
timeout-minutes: 60
container:
@@ -591,12 +588,6 @@ jobs:
name: kernel-runtime-tarballs
path: runtime-download/
- - name: Download rootfs tarball (arm64)
- uses: actions/download-artifact@v4
- with:
- name: rootfs-arm64
- path: rootfs-download/
-
- name: Prepare compressed runtime directory
run: |
set -euo pipefail
@@ -619,12 +610,24 @@ jobs:
zstd -19 -f -q -T0 -o "${COMPRESSED_DIR}/${name}.zst" "$file"
done
- # The macOS VM guest is always Linux ARM64, so use the arm64 rootfs
- cp rootfs-download/rootfs.tar.zst "${COMPRESSED_DIR}/rootfs.tar.zst"
-
echo "Staged macOS compressed artifacts:"
ls -lah "$COMPRESSED_DIR"
+ - name: Build bundled supervisor
+ run: |
+ set -euo pipefail
+ docker buildx build \
+ --file deploy/docker/Dockerfile.images \
+ --platform linux/arm64 \
+ --build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \
+ --build-arg OPENSHELL_IMAGE_TAG=dev \
+ --target supervisor-output \
+ --output type=local,dest=supervisor-out/ \
+ .
+
+ zstd -19 -T0 -f supervisor-out/openshell-sandbox \
+ -o "${PWD}/target/vm-runtime-compressed-macos/openshell-sandbox.zst"
+
- name: Build macOS binary via Docker (osxcross)
run: |
set -euo pipefail
@@ -776,7 +779,7 @@ jobs:
### VM Compute Driver Binaries
- `openshell-driver-vm` binaries with embedded kernel runtime and sandbox rootfs.
+ `openshell-driver-vm` binaries with embedded kernel runtime and bundled sandbox supervisor.
Launched by the gateway when `--drivers=vm` is configured. Rebuilt on every
push to main alongside the openshell-vm binaries.
diff --git a/Cargo.lock b/Cargo.lock
index 967cce7f9..ccedad1fc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -621,6 +621,12 @@ dependencies = [
"shlex",
]
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
[[package]]
name = "cexpr"
version = "0.6.0"
@@ -761,6 +767,16 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
[[package]]
name = "compact_str"
version = "0.7.1"
@@ -808,6 +824,27 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+[[package]]
+name = "const_format"
+version = "0.2.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
+dependencies = [
+ "const_format_proc_macros",
+ "konst",
+]
+
+[[package]]
+name = "const_format_proc_macros"
+version = "0.2.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
[[package]]
name = "constant_time_eq"
version = "0.4.2"
@@ -1175,6 +1212,37 @@ dependencies = [
"syn 1.0.109",
]
+[[package]]
+name = "derive_builder"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
+dependencies = [
+ "derive_builder_macro",
+]
+
+[[package]]
+name = "derive_builder_core"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "derive_builder_macro"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
+dependencies = [
+ "derive_builder_core",
+ "syn 2.0.117",
+]
+
[[package]]
name = "dialoguer"
version = "0.11.0"
@@ -1648,6 +1716,18 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "getset"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912"
+dependencies = [
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "ghash"
version = "0.5.1"
@@ -1861,6 +1941,15 @@ dependencies = [
"itoa",
]
+[[package]]
+name = "http-auth"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "150fa4a9462ef926824cf4519c84ed652ca8f4fbae34cb8af045b5cbcaf98822"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "http-body"
version = "1.0.1"
@@ -2324,6 +2413,50 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "jobserver"
version = "0.1.34"
@@ -2372,6 +2505,20 @@ dependencies = [
"thiserror 1.0.69",
]
+[[package]]
+name = "jsonwebtoken"
+version = "10.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
+dependencies = [
+ "base64 0.22.1",
+ "getrandom 0.2.17",
+ "js-sys",
+ "serde",
+ "serde_json",
+ "signature 2.2.0",
+]
+
[[package]]
name = "k8s-openapi"
version = "0.21.1"
@@ -2385,6 +2532,21 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "konst"
+version = "0.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb"
+dependencies = [
+ "konst_macro_rules",
+]
+
+[[package]]
+name = "konst_macro_rules"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
+
[[package]]
name = "kube"
version = "0.90.0"
@@ -3035,6 +3197,60 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "oci-client"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b7f8deaffcd3b0e3baf93dddcab3d18b91d46dc37d38a8b170089b234de5bb3"
+dependencies = [
+ "bytes",
+ "chrono",
+ "futures-util",
+ "http",
+ "http-auth",
+ "jsonwebtoken",
+ "lazy_static",
+ "oci-spec",
+ "olpc-cjson",
+ "regex",
+ "reqwest 0.13.2",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "unicase",
+]
+
+[[package]]
+name = "oci-spec"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8445a2631507cec628a15fdd6154b54a3ab3f20ed4fe9d73a3b8b7a4e1ba03a"
+dependencies = [
+ "const_format",
+ "derive_builder",
+ "getset",
+ "regex",
+ "serde",
+ "serde_json",
+ "strum 0.27.2",
+ "strum_macros 0.27.2",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "olpc-cjson"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083"
+dependencies = [
+ "serde",
+ "serde_json",
+ "unicode-normalization",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -3071,6 +3287,7 @@ dependencies = [
"tempfile",
"tokio",
"tracing",
+ "url",
]
[[package]]
@@ -3100,7 +3317,7 @@ dependencies = [
"owo-colors",
"prost-types",
"rcgen",
- "reqwest",
+ "reqwest 0.12.28",
"rustls",
"rustls-pemfile",
"serde",
@@ -3187,14 +3404,18 @@ name = "openshell-driver-vm"
version = "0.0.0"
dependencies = [
"clap",
+ "flate2",
"futures",
"libc",
"libloading",
"miette",
"nix",
+ "oci-client",
+ "openshell-bootstrap",
"openshell-core",
"polling",
"prost-types",
+ "sha2 0.10.9",
"tar",
"tokio",
"tokio-stream",
@@ -3254,7 +3475,7 @@ version = "0.0.0"
dependencies = [
"bytes",
"openshell-core",
- "reqwest",
+ "reqwest 0.12.28",
"serde",
"serde_json",
"serde_yml",
@@ -3345,7 +3566,7 @@ dependencies = [
"prost-types",
"rand 0.9.4",
"rcgen",
- "reqwest",
+ "reqwest 0.12.28",
"russh",
"rustls",
"rustls-pemfile",
@@ -3993,6 +4214,7 @@ version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
+ "aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
@@ -4131,7 +4353,7 @@ dependencies = [
"lru",
"paste",
"stability",
- "strum",
+ "strum 0.26.3",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.1.14",
@@ -4266,6 +4488,47 @@ dependencies = [
"webpki-roots 1.0.7",
]
+[[package]]
+name = "reqwest"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "tower 0.5.3",
+ "tower-http 0.6.8",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+]
+
[[package]]
name = "rfc6979"
version = "0.4.0"
@@ -4469,6 +4732,7 @@ version = "0.23.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
dependencies = [
+ "aws-lc-rs",
"log",
"once_cell",
"ring",
@@ -4509,12 +4773,40 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
+dependencies = [
+ "core-foundation",
+ "core-foundation-sys",
+ "jni",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
+
[[package]]
name = "rustls-webpki"
version = "0.103.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
dependencies = [
+ "aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted 0.9.0",
@@ -4541,6 +4833,15 @@ dependencies = [
"cipher",
]
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
[[package]]
name = "schannel"
version = "0.1.29"
@@ -5252,9 +5553,15 @@ version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
- "strum_macros",
+ "strum_macros 0.26.4",
]
+[[package]]
+name = "strum"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
+
[[package]]
name = "strum_macros"
version = "0.26.4"
@@ -5268,6 +5575,18 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "strum_macros"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -5920,6 +6239,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -6075,6 +6400,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
[[package]]
name = "want"
version = "0.3.1"
@@ -6191,6 +6526,19 @@ dependencies = [
"wasmparser",
]
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -6223,6 +6571,15 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "webpki-root-certs"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -6267,6 +6624,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
@@ -6374,6 +6740,15 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -6419,6 +6794,21 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -6476,6 +6866,12 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -6494,6 +6890,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -6512,6 +6914,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -6542,6 +6950,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -6560,6 +6974,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -6578,6 +6998,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -6596,6 +7022,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@@ -6805,7 +7237,7 @@ dependencies = [
"bindgen",
"cmake",
"pkg-config",
- "reqwest",
+ "reqwest 0.12.28",
"serde_json",
"zip",
]
diff --git a/architecture/custom-vm-runtime.md b/architecture/custom-vm-runtime.md
index 045ee2e9a..18594d4af 100644
--- a/architecture/custom-vm-runtime.md
+++ b/architecture/custom-vm-runtime.md
@@ -20,8 +20,9 @@ kernel.
The driver is spawned by `openshell-gateway` as a subprocess, talks to it over a
Unix domain socket (`compute-driver.sock`) with the
`openshell.compute.v1.ComputeDriver` gRPC surface, and manages per-sandbox
-microVMs. The runtime (libkrun + libkrunfw + gvproxy) and the sandbox rootfs are
-embedded directly in the driver binary — no sibling files required at runtime.
+microVMs. The runtime (libkrun + libkrunfw + gvproxy) and the sandbox
+supervisor are embedded directly in the driver binary; each sandbox guest
+rootfs is derived from a container image at create time.
## Architecture
@@ -30,7 +31,7 @@ graph TD
subgraph Host["Host (macOS / Linux)"]
GATEWAY["openshell-gateway
(compute::vm::spawn)"]
DRIVER["openshell-driver-vm
(compute-driver.sock)"]
- EMB["Embedded runtime (zstd)
libkrun · libkrunfw · gvproxy
+ sandbox rootfs.tar.zst"]
+ EMB["Embedded runtime (zstd)
libkrun · libkrunfw · gvproxy
+ openshell-sandbox.zst"]
GVP["gvproxy (per sandbox)
virtio-net · DHCP · DNS"]
GATEWAY <-->|gRPC over UDS| DRIVER
@@ -58,8 +59,8 @@ never binds a host-side TCP listener.
## Embedded Runtime
-`openshell-driver-vm` embeds the VM runtime libraries and the sandbox rootfs as
-zstd-compressed byte arrays, extracting on demand:
+`openshell-driver-vm` embeds the VM runtime libraries and the sandbox
+supervisor as zstd-compressed byte arrays, extracting on demand:
```text
~/.local/share/openshell/vm-runtime// # libkrun / libkrunfw / gvproxy
@@ -74,14 +75,17 @@ Old runtime cache versions are cleaned up when a new version is extracted.
### Sandbox rootfs preparation
-The rootfs tarball the driver embeds starts from the same minimal Ubuntu base
-used across the project, and is **rewritten into a supervisor-only sandbox
-guest** during extraction:
+Each VM sandbox starts from either a registry image fetched directly over OCI or
+a local rootfs tar artifact exported by the CLI for Dockerfile-based `--from`
+sources, then the driver **rewrites that filesystem into a supervisor-only
+sandbox guest** before caching it:
-- k3s state and Kubernetes manifests are stripped out
- `/srv/openshell-vm-sandbox-init.sh` is installed as the guest entrypoint
-- the guest boots directly into `openshell-sandbox` — no k3s, no kube-proxy,
- no CNI plugins
+- the bundled `openshell-sandbox` binary is copied into
+ `/opt/openshell/bin/openshell-sandbox`
+- k3s state and Kubernetes manifests are stripped out if the image contains them
+- the guest boots directly into `openshell-sandbox` — no k3s, no kube-proxy, no
+ CNI plugins
See `crates/openshell-driver-vm/src/rootfs.rs` for the rewrite logic and
`crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh` for the init
@@ -95,6 +99,44 @@ spawns one launcher per sandbox as a subprocess, which in turn starts `gvproxy`
and calls `krun_start_enter` to boot the guest. Keeping the launcher in the
same binary means the driver ships a single artifact for both roles.
+When a sandbox sets `template.image` through `openshell sandbox create --from ...`,
+the VM driver treats that image as the base guest rootfs source for that
+sandbox. When `template.image` is omitted, the gateway fills it from the VM
+driver's advertised `default_image`, which matches the gateway's configured
+sandbox image. The driver:
+
+- resolves the image on the gateway host without Docker for registry and
+ community image refs
+- for local Dockerfile sources, the CLI builds through the host Docker socket
+ and hands the VM driver a local rootfs tar artifact instead of a Docker tag
+- unpacks the image filesystem, injects the VM sandbox init/supervisor files,
+ and validates required guest tools such as `bash`, `mount`, `ip`, and `sed`
+- caches the prepared guest rootfs under
+ `/images//rootfs.tar`
+- extracts a private runtime copy under
+ `/sandboxes//rootfs`
+
+The cache key uses an immutable image identity: repo digest when available,
+otherwise a SHA-256 fingerprint of the local rootfs tar artifact.
+Different VM sandboxes can use different base images concurrently because the
+shared cache is per image, not global for the driver. Cached prepared rootfs
+entries remain on disk until the operator removes them from the VM driver state
+directory.
+
+Docker is therefore no longer required for VM sandboxes created from registry or
+community image refs. It is only required on the CLI host when the source is a
+local Dockerfile or build context.
+
+There is no embedded guest rootfs fallback anymore. VM sandboxes therefore
+require either `template.image` or a configured default sandbox image. This is
+still replace-the-rootfs semantics, so VM images must remain base-compatible
+with the sandbox guest init path. Distroless or `scratch` images are not
+expected to work.
+
+The separate `openshell-vm` binary still uses `vm:rootfs` to build a standalone
+embedded guest filesystem, but `openshell-driver-vm` no longer consumes that
+artifact.
+
## Network Plane
The driver launches a **dedicated `gvproxy` instance per sandbox** to provide the
@@ -262,8 +304,8 @@ host platform.
### Driver Binary (`release-vm-dev.yml`)
Builds the self-contained `openshell-driver-vm` binary for every platform,
-with the kernel runtime + sandbox rootfs embedded. Runs on every push to
-`main` that touches VM-related crates.
+with the kernel runtime + bundled sandbox supervisor embedded. Runs on every
+push to `main` that touches VM-related crates.
The `download-kernel-runtime` job pulls the current `vm-runtime-.tar.zst`
from the `vm-dev` release; the `build-openshell-driver-vm` jobs set
diff --git a/architecture/gateway.md b/architecture/gateway.md
index 5fb82717a..ad3260369 100644
--- a/architecture/gateway.md
+++ b/architecture/gateway.md
@@ -133,7 +133,7 @@ All configuration is via CLI flags with environment variable fallbacks. The `--d
| `--sandbox-image` | `OPENSHELL_SANDBOX_IMAGE` | None | Default container image for sandbox pods |
| `--grpc-endpoint` | `OPENSHELL_GRPC_ENDPOINT` | None | gRPC endpoint reachable from within the cluster (for supervisor callbacks) |
| `--drivers` | `OPENSHELL_DRIVERS` | `kubernetes` | Compute backend to use. Current options are `kubernetes` and `vm`. |
-| `--vm-driver-state-dir` | `OPENSHELL_VM_DRIVER_STATE_DIR` | `target/openshell-vm-driver` | Host directory for VM sandbox rootfs, console logs, and runtime state |
+| `--vm-driver-state-dir` | `OPENSHELL_VM_DRIVER_STATE_DIR` | `target/openshell-vm-driver` | Host directory for VM sandbox rootfs, console logs, runtime state, and shared image-rootfs cache |
| `--driver-dir` | `OPENSHELL_DRIVER_DIR` | unset | Override directory for `openshell-driver-vm`. When unset, the gateway searches `~/.local/libexec/openshell`, `/usr/local/libexec/openshell`, `/usr/local/libexec`, then a sibling binary. |
| `--vm-krun-log-level` | `OPENSHELL_VM_KRUN_LOG_LEVEL` | `1` | libkrun log level for VM helper processes |
| `--vm-driver-vcpus` | `OPENSHELL_VM_DRIVER_VCPUS` | `2` | Default vCPU count for VM sandboxes |
@@ -604,7 +604,7 @@ The gateway reaches the sandbox exclusively through the supervisor-initiated `Co
`VmDriver` (`crates/openshell-driver-vm/src/driver.rs`) is served by the standalone `openshell-driver-vm` process. The gateway spawns that binary on demand and talks to it over the internal `openshell.compute.v1.ComputeDriver` gRPC contract via a Unix domain socket.
-- **Create**: The VM driver process allocates a sandbox-specific rootfs from its own embedded `rootfs.tar.zst`, injects an explicitly configured guest mTLS bundle when the gateway callback endpoint is `https://`, then re-execs itself in a hidden helper mode that loads libkrun directly and boots the supervisor.
+- **Create**: The VM driver process exports the selected sandbox image from the local Docker daemon, rewrites it into a sandbox-specific guest rootfs, injects an explicitly configured guest mTLS bundle when the gateway callback endpoint is `https://`, then re-execs itself in a hidden helper mode that loads libkrun directly and boots the supervisor.
- **Networking**: The helper starts an embedded `gvproxy`, wires it into libkrun as virtio-net, and gives the guest outbound connectivity. No inbound TCP listener is needed — the supervisor reaches the gateway over its outbound `ConnectSupervisor` stream.
- **Gateway callback**: The guest init script configures `eth0` for gvproxy networking, seeds `/etc/hosts` so `host.openshell.internal` resolves to the gvproxy gateway IP (`192.168.127.1`), preserves gvproxy's legacy `host.containers.internal` / `host.docker.internal` DNS answers, prefers the configured `OPENSHELL_GRPC_ENDPOINT`, and falls back to those aliases or the raw gateway IP when local hostname resolution is unavailable on macOS.
- **Guest boot**: The sandbox guest runs a minimal init script that starts `openshell-sandbox` directly as PID 1 inside the VM.
diff --git a/crates/openshell-bootstrap/Cargo.toml b/crates/openshell-bootstrap/Cargo.toml
index 942ffc48b..30fd4fbfc 100644
--- a/crates/openshell-bootstrap/Cargo.toml
+++ b/crates/openshell-bootstrap/Cargo.toml
@@ -20,6 +20,7 @@ miette = { workspace = true }
rcgen = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+url = { workspace = true }
tar = "0.4"
tempfile = "3"
tokio = { workspace = true }
diff --git a/crates/openshell-bootstrap/src/build.rs b/crates/openshell-bootstrap/src/build.rs
index fb9b4a63d..ecc4bffc9 100644
--- a/crates/openshell-bootstrap/src/build.rs
+++ b/crates/openshell-bootstrap/src/build.rs
@@ -1,54 +1,117 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
-//! Build and push container images into a k3s gateway.
+//! Build and export container images for gateway runtimes.
//!
//! This module wraps bollard's `build_image()` API to build a container image
-//! from a Dockerfile and build context, then reuses the existing push pipeline
-//! to import the image into the gateway's containerd runtime.
+//! from a Dockerfile and build context. Kubernetes deployments reuse the
+//! existing push pipeline to import the image into the gateway's containerd
+//! runtime, while the VM backend can export the built image as a rootfs tar.
use std::collections::HashMap;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use bollard::Docker;
-use bollard::query_parameters::BuildImageOptionsBuilder;
+use bollard::models::ContainerCreateBody;
+use bollard::query_parameters::{
+ BuildImageOptionsBuilder, CreateContainerOptionsBuilder, RemoveContainerOptionsBuilder,
+};
use futures::StreamExt;
use miette::{IntoDiagnostic, Result, WrapErr};
+use tokio::io::AsyncWriteExt;
+use url::{Position, Url};
use crate::constants::container_name;
use crate::push::push_local_images;
-/// Build a container image from a Dockerfile and push it into the gateway.
+/// Pseudo-image URI scheme used to hand a local rootfs tar artifact to the VM driver.
+pub const ROOTFS_TAR_IMAGE_REF_SCHEME: &str = "openshell-rootfs-tar";
+
+/// Build a container image from a Dockerfile using the local Docker daemon.
///
-/// This is used by `openshell sandbox create --from `. It:
-/// 1. Creates a tar archive of the build context directory.
-/// 2. Sends it to the local Docker daemon via `build_image()`.
-/// 3. Pushes the resulting image into the gateway's containerd via the
-/// existing `push_local_images()` pipeline.
+/// This is used by `openshell sandbox create --from ` for both the
+/// Kubernetes and VM backends. The image remains available in the local Docker
+/// daemon so the caller can either hand the resulting tag directly to the VM
+/// backend or import it into a local gateway containerd runtime.
#[allow(clippy::implicit_hasher)]
-pub async fn build_and_push_image(
+pub async fn build_local_image(
dockerfile_path: &Path,
tag: &str,
context_dir: &Path,
- gateway_name: &str,
build_args: &HashMap,
on_log: &mut impl FnMut(String),
) -> Result<()> {
- // 1. Build the image locally.
on_log(format!(
"Building image {tag} from {}",
dockerfile_path.display()
));
build_image(dockerfile_path, tag, context_dir, build_args, on_log).await?;
on_log(format!("Built image {tag}"));
+ Ok(())
+}
+
+/// Encode a local rootfs tar path as an internal image reference understood by the VM driver.
+pub fn encode_rootfs_tar_image_ref(path: &Path) -> Result {
+ let canonical = path
+ .canonicalize()
+ .into_diagnostic()
+ .wrap_err_with(|| format!("failed to resolve rootfs tar path {}", path.display()))?;
+ let file_url = Url::from_file_path(&canonical)
+ .map_err(|_| miette::miette!("failed to encode rootfs tar path {}", canonical.display()))?;
+ Ok(format!(
+ "{ROOTFS_TAR_IMAGE_REF_SCHEME}:{}",
+ &file_url[Position::BeforePath..]
+ ))
+}
+
+/// Decode a VM-driver rootfs tar image reference back to a local filesystem path.
+pub fn decode_rootfs_tar_image_ref(image_ref: &str) -> Option {
+ let remainder = image_ref.strip_prefix(&format!("{ROOTFS_TAR_IMAGE_REF_SCHEME}:"))?;
+ let file_url = format!("file:{remainder}");
+ Url::parse(&file_url).ok()?.to_file_path().ok()
+}
+
+/// Export a locally-built Docker image as a persistent rootfs tar artifact for the VM driver.
+pub async fn export_local_image_rootfs(
+ image_ref: &str,
+ on_log: &mut impl FnMut(String),
+) -> Result {
+ let temp = tempfile::Builder::new()
+ .prefix("openshell-vm-rootfs-")
+ .suffix(".tar")
+ .tempfile()
+ .into_diagnostic()
+ .wrap_err("failed to allocate temporary VM rootfs artifact")?;
+ let temp_path = temp.path().to_path_buf();
+ let (_file, output_path) = temp.keep().into_diagnostic().wrap_err_with(|| {
+ format!(
+ "failed to persist temporary VM rootfs artifact {}",
+ temp_path.display()
+ )
+ })?;
- // 2. Push into the gateway.
+ on_log(format!(
+ "Exporting built image {image_ref} as VM rootfs artifact {}",
+ output_path.display()
+ ));
+ export_local_image_rootfs_to_path(image_ref, &output_path).await?;
+ on_log(format!(
+ "Exported VM rootfs artifact {}",
+ output_path.display()
+ ));
+ Ok(output_path)
+}
+
+/// Push a locally-built image into the gateway's containerd runtime.
+#[allow(clippy::implicit_hasher)]
+pub async fn push_image_into_gateway(
+ tag: &str,
+ gateway_name: &str,
+ on_log: &mut impl FnMut(String),
+) -> Result<()> {
on_log(format!(
"Pushing image {tag} into gateway \"{gateway_name}\""
));
- // Use the long-timeout Docker client so `docker save` of multi-GB images
- // doesn't trip the 120s bollard default mid-stream. Override with
- // OPENSHELL_DOCKER_TIMEOUT_SECS=.
let local_docker = crate::docker::connect_local_for_large_transfers()
.into_diagnostic()
.wrap_err("failed to connect to local Docker daemon")?;
@@ -60,6 +123,28 @@ pub async fn build_and_push_image(
Ok(())
}
+/// Build a container image from a Dockerfile and push it into the gateway.
+///
+/// This is used by `openshell sandbox create --from ` when the
+/// active gateway is the local Kubernetes deployment. It:
+/// 1. Creates a tar archive of the build context directory.
+/// 2. Sends it to the local Docker daemon via `build_image()`.
+/// 3. Pushes the resulting image into the gateway's containerd via the
+/// existing `push_local_images()` pipeline.
+#[allow(clippy::implicit_hasher)]
+pub async fn build_and_push_image(
+ dockerfile_path: &Path,
+ tag: &str,
+ context_dir: &Path,
+ gateway_name: &str,
+ build_args: &HashMap,
+ on_log: &mut impl FnMut(String),
+) -> Result<()> {
+ build_local_image(dockerfile_path, tag, context_dir, build_args, on_log).await?;
+ push_image_into_gateway(tag, gateway_name, on_log).await?;
+ Ok(())
+}
+
/// Build a container image using the local Docker daemon.
///
/// Creates a tar archive of `context_dir`, sends it to Docker with the
@@ -127,6 +212,79 @@ async fn build_image(
Ok(())
}
+async fn export_local_image_rootfs_to_path(image_ref: &str, tar_path: &Path) -> Result<()> {
+ let docker = Docker::connect_with_local_defaults()
+ .into_diagnostic()
+ .wrap_err("failed to connect to local Docker daemon")?;
+ let container_name = format!(
+ "openshell-rootfs-export-{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos()
+ );
+ let create_options = CreateContainerOptionsBuilder::default()
+ .name(container_name.as_str())
+ .build();
+ let container = docker
+ .create_container(
+ Some(create_options),
+ ContainerCreateBody {
+ image: Some(image_ref.to_string()),
+ ..Default::default()
+ },
+ )
+ .await
+ .into_diagnostic()
+ .wrap_err_with(|| {
+ format!("failed to create temporary export container for image {image_ref}")
+ })?;
+ let container_id = container.id;
+
+ let export_result = async {
+ if let Some(parent) = tar_path.parent() {
+ tokio::fs::create_dir_all(parent)
+ .await
+ .into_diagnostic()
+ .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
+ }
+ let mut file = tokio::fs::File::create(tar_path)
+ .await
+ .into_diagnostic()
+ .wrap_err_with(|| format!("failed to create {}", tar_path.display()))?;
+ let mut stream = docker.export_container(&container_id);
+ while let Some(chunk) = stream.next().await {
+ let chunk = chunk
+ .into_diagnostic()
+ .wrap_err_with(|| format!("failed to export image {image_ref}"))?;
+ file.write_all(&chunk)
+ .await
+ .into_diagnostic()
+ .wrap_err_with(|| format!("failed to write {}", tar_path.display()))?;
+ }
+ file.flush()
+ .await
+ .into_diagnostic()
+ .wrap_err_with(|| format!("failed to flush {}", tar_path.display()))
+ }
+ .await;
+
+ let cleanup_result = docker
+ .remove_container(
+ &container_id,
+ Some(RemoveContainerOptionsBuilder::default().force(true).build()),
+ )
+ .await;
+
+ match (export_result, cleanup_result) {
+ (Ok(()), Ok(())) => Ok(()),
+ (Err(err), _) => Err(err),
+ (Ok(()), Err(err)) => Err(err).into_diagnostic().wrap_err_with(|| {
+ format!("failed to remove temporary export container for {image_ref}")
+ }),
+ }
+}
+
/// Create a tar archive of a directory for use as a Docker build context.
///
/// Walks `context_dir` recursively, respects a `.dockerignore` file if present,
@@ -468,4 +626,16 @@ mod tests {
assert!(is_ignored("node_modules", true, &patterns));
assert!(is_ignored("node_modules/foo.js", false, &patterns));
}
+
+ #[test]
+ fn encode_and_decode_rootfs_tar_image_ref_round_trip() {
+ let dir = tempfile::tempdir().unwrap();
+ let tar_path = dir.path().join("rootfs tar.tar");
+ fs::write(&tar_path, "rootfs").unwrap();
+
+ let encoded = encode_rootfs_tar_image_ref(&tar_path).unwrap();
+ let decoded = decode_rootfs_tar_image_ref(&encoded).unwrap();
+
+ assert_eq!(decoded, tar_path.canonicalize().unwrap());
+ }
}
diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs
index ba25488b8..f4ba81ae7 100644
--- a/crates/openshell-cli/src/run.rs
+++ b/crates/openshell-cli/src/run.rs
@@ -2630,15 +2630,29 @@ fn image_requests_gpu(image: &str) -> bool {
image_name.contains("gpu")
}
-/// Build a Dockerfile and push the resulting image into the gateway.
+fn dockerfile_sources_supported_for_gateway(metadata: Option<&GatewayMetadata>) -> bool {
+ !metadata.is_some_and(|metadata| metadata.is_remote)
+}
+
+/// Build a Dockerfile and make the resulting image available to the gateway.
///
-/// Returns the image tag that was built so the caller can use it for sandbox
-/// creation.
+/// For local Kubernetes gateways running in Docker, this imports the built image
+/// into the gateway runtime and returns the Docker tag. For local VM gateways,
+/// this exports the built image as a rootfs tar artifact and returns an internal
+/// pseudo-image URI understood by the VM driver.
async fn build_from_dockerfile(
dockerfile: &Path,
context: &Path,
gateway_name: &str,
) -> Result {
+ let metadata = get_gateway_metadata(gateway_name);
+ if !dockerfile_sources_supported_for_gateway(metadata.as_ref()) {
+ return Err(miette!(
+ "local Dockerfile sources are only supported for local gateways; gateway '{}' is remote",
+ gateway_name
+ ));
+ }
+
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
@@ -2658,25 +2672,48 @@ async fn build_from_dockerfile(
eprintln!(" {msg}");
};
- openshell_bootstrap::build::build_and_push_image(
+ openshell_bootstrap::build::build_local_image(
dockerfile,
&tag,
context,
- gateway_name,
&HashMap::new(),
&mut on_log,
)
.await?;
+ let existing_gateway = openshell_bootstrap::check_existing_deployment(gateway_name, None)
+ .await
+ .wrap_err("failed to inspect local gateway deployment state")?;
+ let pushed_into_gateway = existing_gateway
+ .is_some_and(|gateway| gateway.container_exists && gateway.container_running);
+ if pushed_into_gateway {
+ openshell_bootstrap::build::push_image_into_gateway(&tag, gateway_name, &mut on_log)
+ .await?;
+ eprintln!();
+ eprintln!(
+ "{} Image {} is available in the gateway.",
+ "✓".green().bold(),
+ tag.cyan(),
+ );
+ eprintln!();
+ return Ok(tag);
+ }
+
+ let rootfs_tar = openshell_bootstrap::build::export_local_image_rootfs(&tag, &mut on_log)
+ .await
+ .wrap_err("failed to export built image as a VM rootfs artifact")?;
+ let artifact_ref = openshell_bootstrap::build::encode_rootfs_tar_image_ref(&rootfs_tar)?;
+
eprintln!();
eprintln!(
- "{} Image {} is available in the gateway.",
+ "{} VM rootfs artifact {} is ready for gateway '{}'.",
"✓".green().bold(),
- tag.cyan(),
+ rootfs_tar.display().to_string().cyan(),
+ gateway_name,
);
eprintln!();
- Ok(tag)
+ Ok(artifact_ref)
}
/// Load sandbox policy YAML.
@@ -5435,13 +5472,13 @@ fn format_timestamp_ms(ms: i64) -> String {
#[cfg(test)]
mod tests {
use super::{
- GatewayControlTarget, TlsOptions, format_gateway_select_header,
- format_gateway_select_items, gateway_add, gateway_auth_label, gateway_select_with,
- gateway_type_label, git_sync_files, http_health_check, image_requests_gpu,
- inferred_provider_type, parse_cli_setting_value, parse_credential_pairs,
- plaintext_gateway_is_remote, provisioning_timeout_message, ready_false_condition_message,
- resolve_gateway_control_target_from, sandbox_should_persist, shell_escape,
- source_requests_gpu, validate_gateway_name, validate_ssh_host,
+ GatewayControlTarget, TlsOptions, dockerfile_sources_supported_for_gateway,
+ format_gateway_select_header, format_gateway_select_items, gateway_add, gateway_auth_label,
+ gateway_select_with, gateway_type_label, git_sync_files, http_health_check,
+ image_requests_gpu, inferred_provider_type, parse_cli_setting_value,
+ parse_credential_pairs, plaintext_gateway_is_remote, provisioning_timeout_message,
+ ready_false_condition_message, resolve_gateway_control_target_from, sandbox_should_persist,
+ shell_escape, source_requests_gpu, validate_gateway_name, validate_ssh_host,
};
use crate::TEST_ENV_LOCK;
use hyper::StatusCode;
@@ -5691,6 +5728,41 @@ mod tests {
assert!(!source_requests_gpu("base"));
}
+ #[test]
+ fn dockerfile_sources_are_rejected_for_remote_gateways() {
+ let metadata = GatewayMetadata {
+ name: "remote".to_string(),
+ gateway_endpoint: "https://gateway.example.com".to_string(),
+ is_remote: true,
+ gateway_port: 443,
+ remote_host: Some("user@gateway.example.com".to_string()),
+ resolved_host: Some("gateway.example.com".to_string()),
+ auth_mode: None,
+ edge_team_domain: None,
+ edge_auth_url: None,
+ };
+
+ assert!(!dockerfile_sources_supported_for_gateway(Some(&metadata)));
+ }
+
+ #[test]
+ fn dockerfile_sources_are_allowed_for_local_gateways() {
+ let metadata = GatewayMetadata {
+ name: "local".to_string(),
+ gateway_endpoint: "http://127.0.0.1:8080".to_string(),
+ is_remote: false,
+ gateway_port: 8080,
+ remote_host: None,
+ resolved_host: None,
+ auth_mode: None,
+ edge_team_domain: None,
+ edge_auth_url: None,
+ };
+
+ assert!(dockerfile_sources_supported_for_gateway(Some(&metadata)));
+ assert!(dockerfile_sources_supported_for_gateway(None));
+ }
+
#[test]
fn ready_false_condition_message_prefers_reason_and_message() {
let status = SandboxStatus {
diff --git a/crates/openshell-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml
index b4d92b0fc..c2ddf984a 100644
--- a/crates/openshell-driver-vm/Cargo.toml
+++ b/crates/openshell-driver-vm/Cargo.toml
@@ -20,6 +20,7 @@ path = "src/main.rs"
[dependencies]
openshell-core = { path = "../openshell-core" }
+openshell-bootstrap = { path = "../openshell-bootstrap" }
tokio = { workspace = true }
tonic = { workspace = true, features = ["transport"] }
@@ -32,9 +33,12 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
miette = { workspace = true }
url = { workspace = true }
+oci-client = "0.16"
libc = "0.2"
libloading = "0.8"
tar = "0.4"
+flate2 = "1"
+sha2 = "0.10"
zstd = "0.13"
# smol-rs/polling drives the BSD/macOS parent-death detection in
diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md
index 44f326c3b..d01761e37 100644
--- a/crates/openshell-driver-vm/README.md
+++ b/crates/openshell-driver-vm/README.md
@@ -2,7 +2,7 @@
> Status: Experimental. The VM compute driver is under active development and the interface still has VM-specific plumbing that will be generalized.
-Standalone libkrun-backed [`ComputeDriver`](../../proto/compute_driver.proto) for OpenShell. The gateway spawns this binary as a subprocess, talks to it over a Unix domain socket with the `openshell.compute.v1.ComputeDriver` gRPC surface, and lets it manage per-sandbox microVMs. The runtime (libkrun + libkrunfw + gvproxy) and sandbox rootfs are embedded directly in the binary — no sibling files required at runtime.
+Standalone libkrun-backed [`ComputeDriver`](../../proto/compute_driver.proto) for OpenShell. The gateway spawns this binary as a subprocess, talks to it over a Unix domain socket with the `openshell.compute.v1.ComputeDriver` gRPC surface, and lets it manage per-sandbox microVMs. The runtime (libkrun + libkrunfw + gvproxy) and the sandbox supervisor are embedded directly in the binary; each sandbox guest rootfs is derived from a configured container image at create time.
## How it fits together
@@ -10,7 +10,7 @@ Standalone libkrun-backed [`ComputeDriver`](../../proto/compute_driver.proto) fo
flowchart LR
subgraph host["Host process"]
gateway["openshell-server
(compute::vm::spawn)"]
- driver["openshell-driver-vm
├── libkrun (VM)
├── gvproxy (net)
└── rootfs.tar.zst"]
+ driver["openshell-driver-vm
├── libkrun (VM)
├── gvproxy (net)
└── openshell-sandbox.zst"]
gateway <-->|"gRPC over UDS
compute-driver.sock"| driver
end
@@ -35,8 +35,9 @@ Sandbox guests execute `/opt/openshell/bin/openshell-sandbox` as PID 1 inside th
mise run gateway:vm
```
-First run takes a few minutes while `mise run vm:setup` stages libkrun/libkrunfw/gvproxy and `mise run vm:rootfs -- --base` builds the embedded rootfs. Subsequent runs are cached. To keep the Unix socket path under macOS `SUN_LEN`, `mise run gateway:vm` and `start.sh` default the state dir to `/tmp/openshell-vm-driver-dev-$USER-port-$PORT/` (SQLite DB + per-sandbox rootfs + `compute-driver.sock`) unless `OPENSHELL_VM_DRIVER_STATE_DIR` is set.
-The wrapper also prints the recommended gateway name (`vm-driver-port-$PORT` by default) plus the exact repo-local `scripts/bin/openshell gateway add` and `scripts/bin/openshell gateway select` commands to use from another terminal. This avoids accidentally hitting an older `openshell` binary elsewhere on your `PATH`.
+First run takes a few minutes while `mise run vm:setup` stages libkrun/libkrunfw/gvproxy and `mise run vm:supervisor` builds the bundled guest supervisor. Subsequent runs are cached. To keep the Unix socket path under macOS `SUN_LEN`, `mise run gateway:vm` and `start.sh` default the state dir to `/tmp/openshell-vm-driver-dev-$USER-/` (SQLite DB + per-sandbox rootfs + `compute-driver.sock`) unless `OPENSHELL_VM_DRIVER_STATE_DIR` is set.
+By default the wrapper names the gateway after the repo directory, writes `OPENSHELL_GATEWAY=` into `.env`, and writes plaintext local gateway metadata under `~/.config/openshell/gateways//metadata.json` so repo-local `scripts/bin/openshell status` and `sandbox create` resolve to the VM gateway without an extra `gateway select`.
+If neither `OPENSHELL_SERVER_PORT` nor `GATEWAY_PORT` is set, the wrapper picks a random free local port once and appends `GATEWAY_PORT=` to `.env`. Later runs reuse that port through `mise`'s env loading. If you set `OPENSHELL_SERVER_PORT` explicitly, the wrapper uses it for that run and still fails fast on conflicts.
It also exports `OPENSHELL_DRIVER_DIR=$PWD/target/debug` before starting the gateway so local dev runs use the freshly built `openshell-driver-vm` instead of an older installed copy from `~/.local/libexec/openshell` or `/usr/local/libexec`.
Override via environment:
@@ -47,25 +48,24 @@ OPENSHELL_SSH_HANDSHAKE_SECRET=$(openssl rand -hex 32) \
crates/openshell-driver-vm/start.sh
```
-Run multiple dev gateways side by side by giving each one a unique port. The wrapper derives a distinct default state dir from that port automatically:
+If you want to pin the project port instead of using the `.env` default:
```shell
-OPENSHELL_SERVER_PORT=8080 mise run gateway:vm
-OPENSHELL_SERVER_PORT=8081 mise run gateway:vm
+GATEWAY_PORT=28080 mise run gateway:vm
```
-If you want a custom suffix instead of `port-$PORT`, set `OPENSHELL_VM_INSTANCE`:
+If you want a custom state-dir suffix instead of the repo-name default, set `OPENSHELL_VM_INSTANCE`:
```shell
-OPENSHELL_SERVER_PORT=8082 \
+GATEWAY_PORT=28081 \
OPENSHELL_VM_INSTANCE=feature-a \
mise run gateway:vm
```
-If you want a custom CLI gateway name, set `OPENSHELL_VM_GATEWAY_NAME`:
+If you want a custom CLI gateway name instead of the repo directory, set `OPENSHELL_VM_GATEWAY_NAME`:
```shell
-OPENSHELL_SERVER_PORT=8082 \
+GATEWAY_PORT=28082 \
OPENSHELL_VM_GATEWAY_NAME=vm-feature-a \
mise run gateway:vm
```
@@ -73,7 +73,7 @@ mise run gateway:vm
Teardown:
```shell
-rm -rf /tmp/openshell-vm-driver-dev-$USER-port-8080
+rm -rf /tmp/openshell-vm-driver-dev-$USER-$(basename "$PWD" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
```
## Manual equivalent
@@ -81,9 +81,9 @@ rm -rf /tmp/openshell-vm-driver-dev-$USER-port-8080
If you want to drive the launch yourself instead of using `start.sh`:
```shell
-# 1. Stage runtime artifacts + base rootfs into target/vm-runtime-compressed/
+# 1. Stage runtime artifacts + supervisor bundle into target/vm-runtime-compressed/
mise run vm:setup
-mise run vm:rootfs -- --base # if rootfs.tar.zst is not already present
+mise run vm:supervisor # if openshell-sandbox.zst is not already present
# 2. Build both binaries with the staged artifacts embedded
OPENSHELL_VM_RUNTIME_COMPRESSED_DIR=$PWD/target/vm-runtime-compressed \
@@ -101,6 +101,7 @@ target/debug/openshell-gateway \
--disable-tls \
--database-url sqlite:/tmp/openshell-vm-driver-dev-$USER-port-8080/openshell.db \
--driver-dir $PWD/target/debug \
+ --sandbox-image \
--grpc-endpoint http://host.containers.internal:8080 \
--ssh-handshake-secret dev-vm-driver-secret \
--ssh-gateway-host 127.0.0.1 \
@@ -132,13 +133,12 @@ See [`openshell-gateway --help`](../openshell-server/src/cli.rs) for the full fl
In another terminal:
```shell
-export OPENSHELL_GATEWAY_URL=http://127.0.0.1:8080
-cargo run -p openshell-cli -- gateway register local --url $OPENSHELL_GATEWAY_URL --no-tls
-cargo run -p openshell-cli -- sandbox create --name demo
-cargo run -p openshell-cli -- sandbox connect demo
+./scripts/bin/openshell status
+./scripts/bin/openshell sandbox create --name demo --from
+./scripts/bin/openshell sandbox connect demo
```
-First sandbox takes 10–30 seconds to boot (rootfs extraction + libkrun + guest init). Subsequent creates reuse the prepared sandbox rootfs.
+First sandbox takes 10–30 seconds to boot (image fetch/prepare/cache + libkrun + guest init). If `--from` is omitted, the VM driver uses the gateway's configured default sandbox image. Without either `--from` or `--sandbox-image`, VM sandbox creation fails. Subsequent creates reuse the prepared sandbox rootfs.
## Logs and debugging
@@ -157,9 +157,9 @@ The VM guest's serial console is appended to `//console.l
- Rust toolchain
- Guest-supervisor cross-compile toolchain (needed on macOS, and on Linux when host arch ≠ guest arch):
- Matching rustup target: `rustup target add aarch64-unknown-linux-gnu` (or `x86_64-unknown-linux-gnu` for an amd64 guest)
- - `cargo install --locked cargo-zigbuild` and `brew install zig` (or distro equivalent). `build-rootfs.sh` uses `cargo zigbuild` to cross-compile the in-VM `openshell-sandbox` supervisor binary.
+ - `cargo install --locked cargo-zigbuild` and `brew install zig` (or distro equivalent). `vm:supervisor` uses `cargo zigbuild` to cross-compile the in-VM `openshell-sandbox` supervisor binary.
- [mise](https://mise.jdx.dev/) task runner
-- Docker (needed by `mise run vm:rootfs` to build the base rootfs)
+- Docker-compatible socket on the CLI host when using `openshell sandbox create --from ./Dockerfile` or `--from ./dir`
- `gh` CLI (used by `mise run vm:setup` to download pre-built runtime artifacts)
## Relationship to `openshell-vm`
diff --git a/crates/openshell-driver-vm/build.rs b/crates/openshell-driver-vm/build.rs
index 174a90fc8..36b3eb183 100644
--- a/crates/openshell-driver-vm/build.rs
+++ b/crates/openshell-driver-vm/build.rs
@@ -3,9 +3,9 @@
//! Build script for openshell-driver-vm.
//!
-//! This crate embeds the sandbox rootfs plus the minimal libkrun runtime
-//! artifacts it needs to boot base VMs without depending on the openshell-vm
-//! binary or crate.
+//! This crate embeds the sandbox supervisor plus the minimal libkrun runtime
+//! artifacts it needs to boot VMs without depending on the openshell-vm binary
+//! or crate.
use std::path::PathBuf;
use std::{env, fs};
@@ -21,7 +21,7 @@ fn main() {
"libkrun.dylib.zst",
"libkrunfw.5.dylib.zst",
"gvproxy.zst",
- "rootfs.tar.zst",
+ "openshell-sandbox.zst",
] {
println!("cargo:rerun-if-changed={dir}/{name}");
}
@@ -36,7 +36,7 @@ fn main() {
"linux" => ("libkrun.so", "libkrunfw.so.5"),
_ => {
println!("cargo:warning=VM runtime not available for {target_os}-{target_arch}");
- generate_stub_resources(&out_dir, &["libkrun", "libkrunfw", "rootfs.tar.zst"]);
+ generate_stub_resources(&out_dir, &["libkrun", "libkrunfw", "openshell-sandbox.zst"]);
return;
}
};
@@ -45,14 +45,14 @@ fn main() {
PathBuf::from(dir)
} else {
println!("cargo:warning=OPENSHELL_VM_RUNTIME_COMPRESSED_DIR not set");
- println!("cargo:warning=Run: mise run vm:setup");
+ println!("cargo:warning=Run: mise run vm:setup && mise run vm:supervisor");
generate_stub_resources(
&out_dir,
&[
&format!("{libkrun_name}.zst"),
&format!("{libkrunfw_name}.zst"),
"gvproxy.zst",
- "rootfs.tar.zst",
+ "openshell-sandbox.zst",
],
);
return;
@@ -63,14 +63,14 @@ fn main() {
"cargo:warning=Compressed runtime dir not found: {}",
compressed_dir.display()
);
- println!("cargo:warning=Run: mise run vm:setup");
+ println!("cargo:warning=Run: mise run vm:setup && mise run vm:supervisor");
generate_stub_resources(
&out_dir,
&[
&format!("{libkrun_name}.zst"),
&format!("{libkrunfw_name}.zst"),
"gvproxy.zst",
- "rootfs.tar.zst",
+ "openshell-sandbox.zst",
],
);
return;
@@ -83,7 +83,10 @@ fn main() {
format!("{libkrunfw_name}.zst"),
),
("gvproxy.zst".to_string(), "gvproxy.zst".to_string()),
- ("rootfs.tar.zst".to_string(), "rootfs.tar.zst".to_string()),
+ (
+ "openshell-sandbox.zst".to_string(),
+ "openshell-sandbox.zst".to_string(),
+ ),
];
let mut all_found = true;
@@ -116,14 +119,16 @@ fn main() {
}
if !all_found {
- println!("cargo:warning=Some artifacts missing. Run: mise run vm:setup");
+ println!(
+ "cargo:warning=Some artifacts missing. Run: mise run vm:setup && mise run vm:supervisor"
+ );
generate_stub_resources(
&out_dir,
&[
&format!("{libkrun_name}.zst"),
&format!("{libkrunfw_name}.zst"),
"gvproxy.zst",
- "rootfs.tar.zst",
+ "openshell-sandbox.zst",
],
);
}
diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs
index d649a585a..26286631e 100644
--- a/crates/openshell-driver-vm/src/driver.rs
+++ b/crates/openshell-driver-vm/src/driver.rs
@@ -1,11 +1,20 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
-use crate::rootfs::{extract_sandbox_rootfs_to, sandbox_guest_init_path};
+use crate::rootfs::{
+ create_rootfs_archive_from_dir, extract_rootfs_archive_to,
+ prepare_sandbox_rootfs_from_image_root, sandbox_guest_init_path,
+};
+use flate2::read::GzDecoder;
use futures::Stream;
use nix::errno::Errno;
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
+use oci_client::client::{Client as OciClient, ClientConfig};
+use oci_client::manifest::{ImageIndexEntry, OciDescriptor};
+use oci_client::secrets::RegistryAuth;
+use oci_client::{Reference, RegistryOperation};
+use openshell_bootstrap::build::decode_rootfs_tar_image_ref;
use openshell_core::proto::compute::v1::{
CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse,
DriverCondition as SandboxCondition, DriverPlatformEvent as PlatformEvent,
@@ -16,13 +25,18 @@ use openshell_core::proto::compute::v1::{
WatchSandboxesPlatformEvent, WatchSandboxesRequest, WatchSandboxesSandboxEvent,
compute_driver_server::ComputeDriver, watch_sandboxes_event,
};
+use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
+use std::fs;
+use std::io::Read;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::Stdio;
use std::sync::Arc;
+use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
+use tokio::io::AsyncWriteExt;
use tokio::process::{Child, Command};
use tokio::sync::{Mutex, broadcast, mpsc};
use tokio_stream::wrappers::ReceiverStream;
@@ -40,6 +54,11 @@ const GUEST_TLS_DIR: &str = "/opt/openshell/tls";
const GUEST_TLS_CA_PATH: &str = "/opt/openshell/tls/ca.crt";
const GUEST_TLS_CERT_PATH: &str = "/opt/openshell/tls/tls.crt";
const GUEST_TLS_KEY_PATH: &str = "/opt/openshell/tls/tls.key";
+const IMAGE_CACHE_ROOT_DIR: &str = "images";
+const IMAGE_CACHE_ROOTFS_ARCHIVE: &str = "rootfs.tar";
+const IMAGE_IDENTITY_FILE: &str = "image-identity";
+const IMAGE_REFERENCE_FILE: &str = "image-reference";
+static IMAGE_CACHE_BUILD_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone)]
struct VmDriverTlsPaths {
@@ -53,6 +72,7 @@ pub struct VmDriverConfig {
pub openshell_endpoint: String,
pub state_dir: PathBuf,
pub launcher_bin: Option,
+ pub default_image: String,
pub ssh_handshake_secret: String,
pub ssh_handshake_skew_secs: u64,
pub log_level: String,
@@ -70,6 +90,7 @@ impl Default for VmDriverConfig {
openshell_endpoint: String::new(),
state_dir: PathBuf::from("target/openshell-vm-driver"),
launcher_bin: None,
+ default_image: String::new(),
ssh_handshake_secret: String::new(),
ssh_handshake_skew_secs: 300,
log_level: "info".to_string(),
@@ -174,6 +195,7 @@ pub struct VmDriver {
config: VmDriverConfig,
launcher_bin: PathBuf,
registry: Arc>>,
+ image_cache_lock: Arc>,
events: broadcast::Sender,
}
@@ -185,7 +207,7 @@ impl VmDriver {
validate_openshell_endpoint(&config.openshell_endpoint)?;
let _ = config.tls_paths()?;
- let state_root = config.state_dir.join("sandboxes");
+ let state_root = sandboxes_root_dir(&config.state_dir);
tokio::fs::create_dir_all(&state_root)
.await
.map_err(|err| {
@@ -194,6 +216,15 @@ impl VmDriver {
state_root.display()
)
})?;
+ let image_cache_root = image_cache_root_dir(&config.state_dir);
+ tokio::fs::create_dir_all(&image_cache_root)
+ .await
+ .map_err(|err| {
+ format!(
+ "failed to create state dir '{}': {err}",
+ image_cache_root.display()
+ )
+ })?;
let launcher_bin = if let Some(path) = config.launcher_bin.clone() {
path
@@ -207,6 +238,7 @@ impl VmDriver {
config,
launcher_bin,
registry: Arc::new(Mutex::new(HashMap::new())),
+ image_cache_lock: Arc::new(Mutex::new(())),
events,
})
}
@@ -216,13 +248,19 @@ impl VmDriver {
GetCapabilitiesResponse {
driver_name: DRIVER_NAME.to_string(),
driver_version: openshell_core::VERSION.to_string(),
- default_image: String::new(),
+ default_image: self.config.default_image.clone(),
supports_gpu: false,
}
}
pub async fn validate_sandbox(&self, sandbox: &Sandbox) -> Result<(), Status> {
- validate_vm_sandbox(sandbox)
+ validate_vm_sandbox(sandbox)?;
+ if self.resolved_sandbox_image(sandbox).is_none() {
+ return Err(Status::failed_precondition(
+ "vm sandboxes require template.image or a configured default sandbox image",
+ ));
+ }
+ Ok(())
}
pub async fn create_sandbox(&self, sandbox: &Sandbox) -> Result {
@@ -234,6 +272,11 @@ impl VmDriver {
let state_dir = sandbox_state_dir(&self.config.state_dir, &sandbox.id);
let rootfs = state_dir.join("rootfs");
+ let image_ref = self.resolved_sandbox_image(sandbox).ok_or_else(|| {
+ Status::failed_precondition(
+ "vm sandboxes require template.image or a configured default sandbox image",
+ )
+ })?;
tokio::fs::create_dir_all(&state_dir)
.await
@@ -243,17 +286,29 @@ impl VmDriver {
.config
.tls_paths()
.map_err(Status::failed_precondition)?;
- let rootfs_for_extract = rootfs.clone();
- tokio::task::spawn_blocking(move || extract_sandbox_rootfs_to(&rootfs_for_extract))
- .await
- .map_err(|err| Status::internal(format!("sandbox rootfs extraction panicked: {err}")))?
- .map_err(|err| Status::internal(format!("extract sandbox rootfs failed: {err}")))?;
+ let image_identity = match self.prepare_runtime_rootfs(&image_ref, &rootfs).await {
+ Ok(image_identity) => image_identity,
+ Err(err) => {
+ let _ = tokio::fs::remove_dir_all(&state_dir).await;
+ return Err(err);
+ }
+ };
if let Some(tls_paths) = tls_paths.as_ref() {
- prepare_guest_tls_materials(&rootfs, tls_paths)
- .await
- .map_err(|err| {
- Status::internal(format!("prepare guest TLS materials failed: {err}"))
- })?;
+ if let Err(err) = prepare_guest_tls_materials(&rootfs, tls_paths).await {
+ let _ = tokio::fs::remove_dir_all(&state_dir).await;
+ return Err(Status::internal(format!(
+ "prepare guest TLS materials failed: {err}"
+ )));
+ }
+ }
+
+ if let Err(err) =
+ write_sandbox_image_metadata(&state_dir, &image_ref, &image_identity).await
+ {
+ let _ = tokio::fs::remove_dir_all(&state_dir).await;
+ return Err(Status::internal(format!(
+ "write sandbox image metadata failed: {err}"
+ )));
}
let console_output = state_dir.join("rootfs-console.log");
@@ -417,6 +472,276 @@ impl VmDriver {
snapshots
}
+ async fn prepare_runtime_rootfs(
+ &self,
+ image_ref: &str,
+ rootfs: &Path,
+ ) -> Result {
+ let image_identity = self.ensure_cached_image_rootfs_archive(image_ref).await?;
+ let archive_path = image_cache_rootfs_archive(&self.config.state_dir, &image_identity);
+ let rootfs_dest = rootfs.to_path_buf();
+ tokio::task::spawn_blocking(move || extract_rootfs_archive_to(&archive_path, &rootfs_dest))
+ .await
+ .map_err(|err| Status::internal(format!("sandbox rootfs extraction panicked: {err}")))?
+ .map_err(|err| Status::internal(format!("extract sandbox rootfs failed: {err}")))?;
+
+ Ok(image_identity)
+ }
+
+ fn resolved_sandbox_image(&self, sandbox: &Sandbox) -> Option {
+ requested_sandbox_image(sandbox)
+ .map(ToOwned::to_owned)
+ .or_else(|| {
+ let image = self.config.default_image.trim();
+ (!image.is_empty()).then(|| image.to_string())
+ })
+ }
+
+ async fn ensure_cached_image_rootfs_archive(&self, image_ref: &str) -> Result {
+ if let Some(rootfs_tar_path) = decode_rootfs_tar_image_ref(image_ref) {
+ return self
+ .ensure_cached_rootfs_tar_image_rootfs_archive(image_ref, &rootfs_tar_path)
+ .await;
+ }
+
+ let reference = parse_registry_reference(image_ref)?;
+ let client = registry_client();
+ let auth = registry_auth(image_ref)?;
+ client
+ .auth(&reference, &auth, RegistryOperation::Pull)
+ .await
+ .map_err(|err| {
+ Status::failed_precondition(format!(
+ "failed to authenticate registry access for vm sandbox image '{image_ref}': {err}"
+ ))
+ })?;
+ let image_identity = client
+ .fetch_manifest_digest(&reference, &auth)
+ .await
+ .map_err(|err| {
+ Status::failed_precondition(format!(
+ "failed to resolve vm sandbox image '{image_ref}': {err}"
+ ))
+ })?;
+ let archive_path = image_cache_rootfs_archive(&self.config.state_dir, &image_identity);
+
+ if tokio::fs::metadata(&archive_path).await.is_ok() {
+ return Ok(image_identity);
+ }
+
+ let _cache_guard = self.image_cache_lock.lock().await;
+ if tokio::fs::metadata(&archive_path).await.is_ok() {
+ return Ok(image_identity);
+ }
+
+ self.build_cached_registry_image_rootfs_archive(
+ &client,
+ &reference,
+ &auth,
+ image_ref,
+ &image_identity,
+ )
+ .await?;
+ Ok(image_identity)
+ }
+
+ async fn ensure_cached_rootfs_tar_image_rootfs_archive(
+ &self,
+ image_ref: &str,
+ rootfs_tar_path: &Path,
+ ) -> Result {
+ let rootfs_tar = rootfs_tar_path.to_path_buf();
+ let image_identity = tokio::task::spawn_blocking(move || compute_file_sha256(&rootfs_tar))
+ .await
+ .map_err(|err| {
+ Status::internal(format!("rootfs tar digest computation panicked: {err}"))
+ })?
+ .map_err(|err| {
+ Status::failed_precondition(format!(
+ "failed to fingerprint vm sandbox rootfs artifact '{}': {err}",
+ rootfs_tar_path.display()
+ ))
+ })?;
+ let archive_path = image_cache_rootfs_archive(&self.config.state_dir, &image_identity);
+
+ if tokio::fs::metadata(&archive_path).await.is_ok() {
+ return Ok(image_identity);
+ }
+
+ let _cache_guard = self.image_cache_lock.lock().await;
+ if tokio::fs::metadata(&archive_path).await.is_ok() {
+ return Ok(image_identity);
+ }
+
+ self.build_cached_rootfs_tar_image_rootfs_archive(
+ image_ref,
+ rootfs_tar_path,
+ &image_identity,
+ )
+ .await?;
+ Ok(image_identity)
+ }
+
+ async fn build_cached_rootfs_tar_image_rootfs_archive(
+ &self,
+ image_ref: &str,
+ rootfs_tar_path: &Path,
+ image_identity: &str,
+ ) -> Result<(), Status> {
+ let cache_dir = image_cache_dir(&self.config.state_dir, image_identity);
+ let archive_path = image_cache_rootfs_archive(&self.config.state_dir, image_identity);
+ let staging_dir = image_cache_staging_dir(&self.config.state_dir, image_identity);
+ let prepared_rootfs = staging_dir.join("rootfs");
+ let prepared_archive = staging_dir.join(IMAGE_CACHE_ROOTFS_ARCHIVE);
+
+ tokio::fs::create_dir_all(image_cache_root_dir(&self.config.state_dir))
+ .await
+ .map_err(|err| Status::internal(format!("create image cache dir failed: {err}")))?;
+ tokio::fs::create_dir_all(&cache_dir)
+ .await
+ .map_err(|err| Status::internal(format!("create image cache dir failed: {err}")))?;
+
+ if tokio::fs::metadata(&staging_dir).await.is_ok() {
+ tokio::fs::remove_dir_all(&staging_dir)
+ .await
+ .map_err(|err| {
+ Status::internal(format!(
+ "remove stale image cache staging dir failed: {err}"
+ ))
+ })?;
+ }
+ tokio::fs::create_dir_all(&staging_dir)
+ .await
+ .map_err(|err| {
+ Status::internal(format!("create image cache staging dir failed: {err}"))
+ })?;
+
+ let image_ref_owned = image_ref.to_string();
+ let image_identity_owned = image_identity.to_string();
+ let rootfs_tar_path_owned = rootfs_tar_path.to_path_buf();
+ let prepared_rootfs_for_build = prepared_rootfs.clone();
+ let prepared_archive_for_build = prepared_archive.clone();
+ let build_result = tokio::task::spawn_blocking(move || {
+ extract_rootfs_archive_to(&rootfs_tar_path_owned, &prepared_rootfs_for_build)?;
+ prepare_sandbox_rootfs_from_image_root(
+ &prepared_rootfs_for_build,
+ &image_identity_owned,
+ )
+ .map_err(|err| {
+ format!(
+ "vm sandbox image '{}' is not base-compatible: {err}",
+ image_ref_owned
+ )
+ })?;
+ create_rootfs_archive_from_dir(&prepared_rootfs_for_build, &prepared_archive_for_build)
+ })
+ .await
+ .map_err(|err| Status::internal(format!("rootfs artifact preparation panicked: {err}")))?;
+
+ if let Err(err) = build_result {
+ let _ = tokio::fs::remove_dir_all(&staging_dir).await;
+ return Err(Status::failed_precondition(err));
+ }
+
+ if tokio::fs::metadata(&archive_path).await.is_ok() {
+ let _ = tokio::fs::remove_dir_all(&staging_dir).await;
+ return Ok(());
+ }
+
+ tokio::fs::rename(&prepared_archive, &archive_path)
+ .await
+ .map_err(|err| Status::internal(format!("store cached image rootfs failed: {err}")))?;
+ let _ = tokio::fs::remove_dir_all(&staging_dir).await;
+ Ok(())
+ }
+
+ async fn build_cached_registry_image_rootfs_archive(
+ &self,
+ client: &OciClient,
+ reference: &Reference,
+ auth: &RegistryAuth,
+ image_ref: &str,
+ image_identity: &str,
+ ) -> Result<(), Status> {
+ let cache_dir = image_cache_dir(&self.config.state_dir, image_identity);
+ let archive_path = image_cache_rootfs_archive(&self.config.state_dir, image_identity);
+ let staging_dir = image_cache_staging_dir(&self.config.state_dir, image_identity);
+ let prepared_rootfs = staging_dir.join("rootfs");
+ let prepared_archive = staging_dir.join(IMAGE_CACHE_ROOTFS_ARCHIVE);
+
+ tokio::fs::create_dir_all(image_cache_root_dir(&self.config.state_dir))
+ .await
+ .map_err(|err| Status::internal(format!("create image cache dir failed: {err}")))?;
+ tokio::fs::create_dir_all(&cache_dir)
+ .await
+ .map_err(|err| Status::internal(format!("create image cache dir failed: {err}")))?;
+
+ if tokio::fs::metadata(&staging_dir).await.is_ok() {
+ tokio::fs::remove_dir_all(&staging_dir)
+ .await
+ .map_err(|err| {
+ Status::internal(format!(
+ "remove stale image cache staging dir failed: {err}"
+ ))
+ })?;
+ }
+ tokio::fs::create_dir_all(&staging_dir)
+ .await
+ .map_err(|err| {
+ Status::internal(format!("create image cache staging dir failed: {err}"))
+ })?;
+
+ if let Err(err) = pull_registry_image_rootfs(
+ client,
+ reference,
+ auth,
+ image_ref,
+ &staging_dir,
+ &prepared_rootfs,
+ )
+ .await
+ {
+ let _ = tokio::fs::remove_dir_all(&staging_dir).await;
+ return Err(err);
+ }
+
+ let image_ref_owned = image_ref.to_string();
+ let image_identity_owned = image_identity.to_string();
+ let prepared_rootfs_for_build = prepared_rootfs.clone();
+ let prepared_archive_for_build = prepared_archive.clone();
+ let build_result = tokio::task::spawn_blocking(move || {
+ prepare_sandbox_rootfs_from_image_root(
+ &prepared_rootfs_for_build,
+ &image_identity_owned,
+ )
+ .map_err(|err| {
+ format!(
+ "vm sandbox image '{}' is not base-compatible: {err}",
+ image_ref_owned
+ )
+ })?;
+ create_rootfs_archive_from_dir(&prepared_rootfs_for_build, &prepared_archive_for_build)
+ })
+ .await
+ .map_err(|err| Status::internal(format!("image rootfs preparation panicked: {err}")))?;
+
+ if let Err(err) = build_result {
+ let _ = tokio::fs::remove_dir_all(&staging_dir).await;
+ return Err(Status::failed_precondition(err));
+ }
+
+ if tokio::fs::metadata(&archive_path).await.is_ok() {
+ let _ = tokio::fs::remove_dir_all(&staging_dir).await;
+ return Ok(());
+ }
+
+ tokio::fs::rename(&prepared_archive, &archive_path)
+ .await
+ .map_err(|err| Status::internal(format!("store cached image rootfs failed: {err}")))?;
+ let _ = tokio::fs::remove_dir_all(&staging_dir).await;
+ Ok(())
+ }
+
/// Watch the launcher child process and surface errors as driver
/// conditions.
///
@@ -689,11 +1014,6 @@ fn validate_vm_sandbox(sandbox: &Sandbox) -> Result<(), Status> {
));
}
if let Some(template) = spec.template.as_ref() {
- if !template.image.is_empty() {
- return Err(Status::failed_precondition(
- "vm sandboxes do not support template.image",
- ));
- }
if !template.agent_socket_path.is_empty() {
return Err(Status::failed_precondition(
"vm sandboxes do not support template.agent_socket_path",
@@ -713,6 +1033,426 @@ fn validate_vm_sandbox(sandbox: &Sandbox) -> Result<(), Status> {
Ok(())
}
+fn parse_registry_reference(image_ref: &str) -> Result {
+ Reference::try_from(image_ref).map_err(|err| {
+ Status::failed_precondition(format!(
+ "invalid vm sandbox image reference '{image_ref}': {err}"
+ ))
+ })
+}
+
+fn registry_client() -> OciClient {
+ OciClient::new(ClientConfig {
+ platform_resolver: Some(Box::new(linux_platform_resolver)),
+ ..Default::default()
+ })
+}
+
+fn linux_platform_resolver(manifests: &[ImageIndexEntry]) -> Option {
+ let expected_arch = linux_oci_arch();
+ manifests
+ .iter()
+ .find_map(|entry| {
+ let platform = entry.platform.as_ref()?;
+ (platform.os.to_string() == "linux"
+ && platform.architecture.to_string() == expected_arch)
+ .then(|| entry.digest.clone())
+ })
+ .or_else(|| {
+ manifests.iter().find_map(|entry| {
+ let platform = entry.platform.as_ref()?;
+ (platform.os.to_string() == "linux").then(|| entry.digest.clone())
+ })
+ })
+}
+
+fn linux_oci_arch() -> &'static str {
+ match std::env::consts::ARCH {
+ "x86_64" => "amd64",
+ "aarch64" => "arm64",
+ "arm" => "arm",
+ other => other,
+ }
+}
+
+fn registry_auth(image_ref: &str) -> Result {
+ let username = env_non_empty("OPENSHELL_REGISTRY_USERNAME");
+ let token = env_non_empty("OPENSHELL_REGISTRY_TOKEN");
+
+ match token {
+ Some(token) => {
+ let username = match username {
+ Some(username) => username,
+ None if image_reference_registry_host(image_ref)
+ .eq_ignore_ascii_case("ghcr.io") =>
+ {
+ "__token__".to_string()
+ }
+ None => {
+ return Err(Status::failed_precondition(
+ "OPENSHELL_REGISTRY_USERNAME is required when OPENSHELL_REGISTRY_TOKEN is set for non-GHCR registries",
+ ));
+ }
+ };
+ Ok(RegistryAuth::Basic(username, token))
+ }
+ None => Ok(RegistryAuth::Anonymous),
+ }
+}
+
+fn env_non_empty(key: &str) -> Option {
+ std::env::var(key)
+ .ok()
+ .filter(|value| !value.trim().is_empty())
+}
+
+fn image_reference_registry_host(image_ref: &str) -> &str {
+ let first = image_ref.split('/').next().unwrap_or(image_ref);
+ if first.contains('.') || first.contains(':') || first.eq_ignore_ascii_case("localhost") {
+ first
+ } else {
+ "docker.io"
+ }
+}
+
+async fn pull_registry_image_rootfs(
+ client: &OciClient,
+ reference: &Reference,
+ auth: &RegistryAuth,
+ image_ref: &str,
+ staging_dir: &Path,
+ rootfs: &Path,
+) -> Result<(), Status> {
+ client
+ .auth(reference, auth, RegistryOperation::Pull)
+ .await
+ .map_err(|err| {
+ Status::failed_precondition(format!(
+ "failed to authenticate registry access for vm sandbox image '{image_ref}': {err}"
+ ))
+ })?;
+ let (manifest, _) = client
+ .pull_image_manifest(reference, auth)
+ .await
+ .map_err(|err| {
+ Status::failed_precondition(format!(
+ "failed to pull vm sandbox image manifest '{image_ref}': {err}"
+ ))
+ })?;
+
+ tokio::fs::create_dir_all(rootfs)
+ .await
+ .map_err(|err| Status::internal(format!("create rootfs dir failed: {err}")))?;
+ tokio::fs::create_dir_all(staging_dir.join("layers"))
+ .await
+ .map_err(|err| Status::internal(format!("create layer staging dir failed: {err}")))?;
+
+ for (index, layer) in manifest.layers.iter().enumerate() {
+ pull_registry_layer(
+ client,
+ reference,
+ image_ref,
+ staging_dir,
+ rootfs,
+ layer,
+ index,
+ )
+ .await?;
+ }
+
+ Ok(())
+}
+
+async fn pull_registry_layer(
+ client: &OciClient,
+ reference: &Reference,
+ image_ref: &str,
+ staging_dir: &Path,
+ rootfs: &Path,
+ layer: &OciDescriptor,
+ index: usize,
+) -> Result<(), Status> {
+ let digest_component = sanitize_image_identity(&layer.digest);
+ let blob_path = staging_dir
+ .join("layers")
+ .join(format!("{index:02}-{digest_component}.blob"));
+ let layer_root = staging_dir
+ .join("layers")
+ .join(format!("{index:02}-{digest_component}.root"));
+
+ let mut file = tokio::fs::File::create(&blob_path)
+ .await
+ .map_err(|err| Status::internal(format!("create layer blob failed: {err}")))?;
+ client
+ .pull_blob(reference, layer, &mut file)
+ .await
+ .map_err(|err| {
+ Status::failed_precondition(format!(
+ "failed to download layer '{}' for vm sandbox image '{image_ref}': {err}",
+ layer.digest
+ ))
+ })?;
+ file.flush()
+ .await
+ .map_err(|err| Status::internal(format!("flush layer blob failed: {err}")))?;
+
+ let blob_path_for_digest = blob_path.clone();
+ let expected_digest = layer.digest.clone();
+ tokio::task::spawn_blocking(move || {
+ verify_descriptor_digest(&blob_path_for_digest, &expected_digest)
+ })
+ .await
+ .map_err(|err| Status::internal(format!("layer digest verification panicked: {err}")))?
+ .map_err(|err| {
+ Status::failed_precondition(format!(
+ "vm sandbox image layer verification failed for '{}': {err}",
+ layer.digest
+ ))
+ })?;
+
+ let blob_path_for_unpack = blob_path.clone();
+ let layer_root_for_unpack = layer_root.clone();
+ let rootfs_for_unpack = rootfs.to_path_buf();
+ let media_type = layer.media_type.clone();
+ tokio::task::spawn_blocking(move || {
+ extract_layer_blob_to_dir(&blob_path_for_unpack, &media_type, &layer_root_for_unpack)?;
+ apply_layer_dir_to_rootfs(&layer_root_for_unpack, &rootfs_for_unpack)
+ })
+ .await
+ .map_err(|err| Status::internal(format!("layer extraction panicked: {err}")))?
+ .map_err(|err| {
+ Status::failed_precondition(format!(
+ "failed to apply layer '{}' for vm sandbox image '{image_ref}': {err}",
+ layer.digest
+ ))
+ })
+}
+
+fn verify_descriptor_digest(path: &Path, expected_digest: &str) -> Result<(), String> {
+ let expected = expected_digest
+ .strip_prefix("sha256:")
+ .ok_or_else(|| format!("unsupported layer digest '{expected_digest}'"))?;
+ let actual = compute_file_sha256_hex(path)?;
+ if actual == expected {
+ Ok(())
+ } else {
+ Err(format!(
+ "digest mismatch for {}: expected sha256:{expected}, got sha256:{actual}",
+ path.display()
+ ))
+ }
+}
+
+fn compute_file_sha256(path: &Path) -> Result {
+ compute_file_sha256_hex(path).map(|digest| format!("sha256:{digest}"))
+}
+
+fn compute_file_sha256_hex(path: &Path) -> Result {
+ let mut file = fs::File::open(path).map_err(|err| format!("open {}: {err}", path.display()))?;
+ let mut hasher = Sha256::new();
+ let mut buffer = [0_u8; 64 * 1024];
+ loop {
+ let read = file
+ .read(&mut buffer)
+ .map_err(|err| format!("read {}: {err}", path.display()))?;
+ if read == 0 {
+ break;
+ }
+ hasher.update(&buffer[..read]);
+ }
+ Ok(format!("{:x}", hasher.finalize()))
+}
+
+fn extract_layer_blob_to_dir(
+ blob_path: &Path,
+ media_type: &str,
+ dest: &Path,
+) -> Result<(), String> {
+ if dest.exists() {
+ fs::remove_dir_all(dest).map_err(|err| format!("remove {}: {err}", dest.display()))?;
+ }
+ fs::create_dir_all(dest).map_err(|err| format!("create {}: {err}", dest.display()))?;
+
+ let file =
+ fs::File::open(blob_path).map_err(|err| format!("open {}: {err}", blob_path.display()))?;
+ match layer_compression_from_media_type(media_type)? {
+ LayerCompression::None => extract_tar_reader_to_dir(file, dest),
+ LayerCompression::Gzip => extract_tar_reader_to_dir(GzDecoder::new(file), dest),
+ LayerCompression::Zstd => {
+ let decoder = zstd::stream::read::Decoder::new(file)
+ .map_err(|err| format!("decompress {}: {err}", blob_path.display()))?;
+ extract_tar_reader_to_dir(decoder, dest)
+ }
+ }
+}
+
+fn extract_tar_reader_to_dir(reader: impl Read, dest: &Path) -> Result<(), String> {
+ let mut archive = tar::Archive::new(reader);
+ archive
+ .unpack(dest)
+ .map_err(|err| format!("extract layer into {}: {err}", dest.display()))
+}
+
+fn layer_compression_from_media_type(media_type: &str) -> Result {
+ if media_type.is_empty() {
+ return Err("layer media type is missing".to_string());
+ }
+ if media_type.ends_with("+zstd") {
+ return Ok(LayerCompression::Zstd);
+ }
+ if media_type.ends_with("+gzip") || media_type.ends_with(".gzip") {
+ return Ok(LayerCompression::Gzip);
+ }
+ if media_type.ends_with(".tar")
+ || media_type.ends_with("tar")
+ || media_type == "application/vnd.oci.image.layer.v1.tar"
+ || media_type == "application/vnd.oci.image.layer.nondistributable.v1.tar"
+ {
+ return Ok(LayerCompression::None);
+ }
+ Err(format!("unsupported layer media type '{media_type}'"))
+}
+
+fn apply_layer_dir_to_rootfs(layer_root: &Path, rootfs: &Path) -> Result<(), String> {
+ merge_layer_directory(layer_root, rootfs)
+}
+
+fn merge_layer_directory(source_dir: &Path, target_dir: &Path) -> Result<(), String> {
+ fs::create_dir_all(target_dir)
+ .map_err(|err| format!("create {}: {err}", target_dir.display()))?;
+
+ let mut entries = fs::read_dir(source_dir)
+ .map_err(|err| format!("read {}: {err}", source_dir.display()))?
+ .collect::, _>>()
+ .map_err(|err| format!("read {}: {err}", source_dir.display()))?;
+ entries.sort_by_key(|entry| entry.file_name());
+
+ if entries
+ .iter()
+ .any(|entry| entry.file_name().to_string_lossy() == ".wh..wh..opq")
+ {
+ clear_directory_contents(target_dir)?;
+ }
+
+ for entry in entries {
+ let file_name = entry.file_name();
+ let name = file_name.to_string_lossy();
+ if name == ".wh..wh..opq" {
+ continue;
+ }
+ if let Some(hidden_name) = name.strip_prefix(".wh.") {
+ remove_path_if_exists(&target_dir.join(hidden_name))?;
+ continue;
+ }
+
+ let source_path = entry.path();
+ let dest_path = target_dir.join(&file_name);
+ let metadata = fs::symlink_metadata(&source_path)
+ .map_err(|err| format!("stat {}: {err}", source_path.display()))?;
+ let file_type = metadata.file_type();
+
+ if file_type.is_dir() {
+ if dest_path.exists()
+ && !fs::symlink_metadata(&dest_path)
+ .map_err(|err| format!("stat {}: {err}", dest_path.display()))?
+ .file_type()
+ .is_dir()
+ {
+ remove_path_if_exists(&dest_path)?;
+ }
+ fs::create_dir_all(&dest_path)
+ .map_err(|err| format!("create {}: {err}", dest_path.display()))?;
+ merge_layer_directory(&source_path, &dest_path)?;
+ fs::set_permissions(&dest_path, metadata.permissions())
+ .map_err(|err| format!("chmod {}: {err}", dest_path.display()))?;
+ } else if file_type.is_file() {
+ remove_path_if_exists(&dest_path)?;
+ if let Some(parent) = dest_path.parent() {
+ fs::create_dir_all(parent)
+ .map_err(|err| format!("create {}: {err}", parent.display()))?;
+ }
+ fs::copy(&source_path, &dest_path).map_err(|err| {
+ format!(
+ "copy {} to {}: {err}",
+ source_path.display(),
+ dest_path.display()
+ )
+ })?;
+ fs::set_permissions(&dest_path, metadata.permissions())
+ .map_err(|err| format!("chmod {}: {err}", dest_path.display()))?;
+ } else if file_type.is_symlink() {
+ copy_symlink(&source_path, &dest_path)?;
+ } else {
+ return Err(format!(
+ "unsupported layer entry type at {}",
+ source_path.display()
+ ));
+ }
+ }
+
+ Ok(())
+}
+
+fn clear_directory_contents(dir: &Path) -> Result<(), String> {
+ if !dir.exists() {
+ return Ok(());
+ }
+ for entry in fs::read_dir(dir).map_err(|err| format!("read {}: {err}", dir.display()))? {
+ let entry = entry.map_err(|err| format!("read {}: {err}", dir.display()))?;
+ remove_path_if_exists(&entry.path())?;
+ }
+ Ok(())
+}
+
+fn remove_path_if_exists(path: &Path) -> Result<(), String> {
+ let Ok(metadata) = fs::symlink_metadata(path) else {
+ return Ok(());
+ };
+ if metadata.file_type().is_dir() {
+ fs::remove_dir_all(path).map_err(|err| format!("remove {}: {err}", path.display()))
+ } else {
+ fs::remove_file(path).map_err(|err| format!("remove {}: {err}", path.display()))
+ }
+}
+
+#[cfg(unix)]
+fn copy_symlink(source_path: &Path, dest_path: &Path) -> Result<(), String> {
+ let target = fs::read_link(source_path)
+ .map_err(|err| format!("readlink {}: {err}", source_path.display()))?;
+ remove_path_if_exists(dest_path)?;
+ if let Some(parent) = dest_path.parent() {
+ fs::create_dir_all(parent).map_err(|err| format!("create {}: {err}", parent.display()))?;
+ }
+ std::os::unix::fs::symlink(&target, dest_path).map_err(|err| {
+ format!(
+ "symlink {} to {}: {err}",
+ target.display(),
+ dest_path.display()
+ )
+ })
+}
+
+#[cfg(not(unix))]
+fn copy_symlink(_source_path: &Path, _dest_path: &Path) -> Result<(), String> {
+ Err("symlink layers are only supported on Unix hosts".to_string())
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum LayerCompression {
+ None,
+ Gzip,
+ Zstd,
+}
+
+fn requested_sandbox_image(sandbox: &Sandbox) -> Option<&str> {
+ sandbox
+ .spec
+ .as_ref()
+ .and_then(|spec| spec.template.as_ref())
+ .map(|template| template.image.trim())
+ .filter(|image| !image.is_empty())
+}
+
fn merged_environment(sandbox: &Sandbox) -> HashMap {
let mut environment = sandbox
.spec
@@ -807,8 +1547,69 @@ fn sandbox_log_level(sandbox: &Sandbox, default_level: &str) -> String {
.to_string()
}
+fn sandboxes_root_dir(root: &Path) -> PathBuf {
+ root.join("sandboxes")
+}
+
fn sandbox_state_dir(root: &Path, sandbox_id: &str) -> PathBuf {
- root.join("sandboxes").join(sandbox_id)
+ sandboxes_root_dir(root).join(sandbox_id)
+}
+
+fn image_cache_root_dir(root: &Path) -> PathBuf {
+ root.join(IMAGE_CACHE_ROOT_DIR)
+}
+
+fn image_cache_dir(root: &Path, image_identity: &str) -> PathBuf {
+ image_cache_root_dir(root).join(sanitize_image_identity(image_identity))
+}
+
+fn image_cache_rootfs_archive(root: &Path, image_identity: &str) -> PathBuf {
+ image_cache_dir(root, image_identity).join(IMAGE_CACHE_ROOTFS_ARCHIVE)
+}
+
+fn image_cache_staging_dir(root: &Path, image_identity: &str) -> PathBuf {
+ image_cache_root_dir(root).join(format!(
+ "{}.staging-{}",
+ sanitize_image_identity(image_identity),
+ unique_image_cache_suffix()
+ ))
+}
+
+fn sanitize_image_identity(image_identity: &str) -> String {
+ image_identity
+ .chars()
+ .map(|ch| {
+ if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
+ ch
+ } else {
+ '-'
+ }
+ })
+ .collect()
+}
+
+fn unique_image_cache_suffix() -> String {
+ let counter = IMAGE_CACHE_BUILD_COUNTER.fetch_add(1, Ordering::Relaxed);
+ format!("{}-{counter}", current_time_ms())
+}
+
+async fn write_sandbox_image_metadata(
+ state_dir: &Path,
+ image_ref: &str,
+ image_identity: &str,
+) -> Result<(), std::io::Error> {
+ tokio::fs::write(
+ state_dir.join(IMAGE_IDENTITY_FILE),
+ format!("{image_identity}\n"),
+ )
+ .await?;
+ tokio::fs::write(
+ state_dir.join(IMAGE_REFERENCE_FILE),
+ format!("{image_ref}\n"),
+ )
+ .await?;
+
+ Ok(())
}
async fn prepare_guest_tls_materials(
@@ -830,7 +1631,7 @@ async fn copy_guest_tls_material(
mode: u32,
) -> Result<(), std::io::Error> {
tokio::fs::copy(source, dest).await?;
- tokio::fs::set_permissions(dest, std::fs::Permissions::from_mode(mode)).await?;
+ tokio::fs::set_permissions(dest, fs::Permissions::from_mode(mode)).await?;
Ok(())
}
@@ -940,6 +1741,7 @@ mod tests {
DriverSandboxSpec as SandboxSpec, DriverSandboxTemplate as SandboxTemplate,
};
use prost_types::{Struct, Value, value::Kind};
+ use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use tonic::Code;
@@ -984,6 +1786,112 @@ mod tests {
assert!(err.message().contains("platform_config"));
}
+ #[test]
+ fn validate_vm_sandbox_accepts_template_image() {
+ let sandbox = Sandbox {
+ spec: Some(SandboxSpec {
+ template: Some(SandboxTemplate {
+ image: "ghcr.io/example/sandbox:latest".to_string(),
+ ..Default::default()
+ }),
+ ..Default::default()
+ }),
+ ..Default::default()
+ };
+ validate_vm_sandbox(&sandbox).expect("template.image should be accepted");
+ }
+
+ #[test]
+ fn capabilities_report_configured_default_image() {
+ let driver = VmDriver {
+ config: VmDriverConfig {
+ default_image: "openshell/sandbox:dev".to_string(),
+ ..Default::default()
+ },
+ launcher_bin: PathBuf::from("/tmp/openshell-driver-vm"),
+ registry: Arc::new(Mutex::new(HashMap::new())),
+ image_cache_lock: Arc::new(Mutex::new(())),
+ events: broadcast::channel(WATCH_BUFFER).0,
+ };
+
+ assert_eq!(driver.capabilities().default_image, "openshell/sandbox:dev");
+ }
+
+ #[test]
+ fn resolved_sandbox_image_prefers_template_image() {
+ let driver = VmDriver {
+ config: VmDriverConfig {
+ default_image: "openshell/sandbox:default".to_string(),
+ ..Default::default()
+ },
+ launcher_bin: PathBuf::from("/tmp/openshell-driver-vm"),
+ registry: Arc::new(Mutex::new(HashMap::new())),
+ image_cache_lock: Arc::new(Mutex::new(())),
+ events: broadcast::channel(WATCH_BUFFER).0,
+ };
+ let sandbox = Sandbox {
+ spec: Some(SandboxSpec {
+ template: Some(SandboxTemplate {
+ image: "ghcr.io/example/custom:latest".to_string(),
+ ..Default::default()
+ }),
+ ..Default::default()
+ }),
+ ..Default::default()
+ };
+
+ assert_eq!(
+ driver.resolved_sandbox_image(&sandbox).as_deref(),
+ Some("ghcr.io/example/custom:latest")
+ );
+ }
+
+ #[test]
+ fn resolved_sandbox_image_falls_back_to_driver_default() {
+ let driver = VmDriver {
+ config: VmDriverConfig {
+ default_image: "openshell/sandbox:default".to_string(),
+ ..Default::default()
+ },
+ launcher_bin: PathBuf::from("/tmp/openshell-driver-vm"),
+ registry: Arc::new(Mutex::new(HashMap::new())),
+ image_cache_lock: Arc::new(Mutex::new(())),
+ events: broadcast::channel(WATCH_BUFFER).0,
+ };
+ let sandbox = Sandbox {
+ spec: Some(SandboxSpec {
+ template: Some(SandboxTemplate::default()),
+ ..Default::default()
+ }),
+ ..Default::default()
+ };
+
+ assert_eq!(
+ driver.resolved_sandbox_image(&sandbox).as_deref(),
+ Some("openshell/sandbox:default")
+ );
+ }
+
+ #[test]
+ fn resolved_sandbox_image_returns_none_without_template_or_default() {
+ let driver = VmDriver {
+ config: VmDriverConfig::default(),
+ launcher_bin: PathBuf::from("/tmp/openshell-driver-vm"),
+ registry: Arc::new(Mutex::new(HashMap::new())),
+ image_cache_lock: Arc::new(Mutex::new(())),
+ events: broadcast::channel(WATCH_BUFFER).0,
+ };
+ let sandbox = Sandbox {
+ spec: Some(SandboxSpec {
+ template: Some(SandboxTemplate::default()),
+ ..Default::default()
+ }),
+ ..Default::default()
+ };
+
+ assert!(driver.resolved_sandbox_image(&sandbox).is_none());
+ }
+
#[test]
fn merged_environment_prefers_spec_values() {
let sandbox = Sandbox {
@@ -1068,6 +1976,64 @@ mod tests {
);
}
+ #[test]
+ fn image_reference_registry_host_defaults_to_docker_hub() {
+ assert_eq!(image_reference_registry_host("ubuntu:24.04"), "docker.io");
+ assert_eq!(
+ image_reference_registry_host("ghcr.io/nvidia/openshell/base:latest"),
+ "ghcr.io"
+ );
+ assert_eq!(
+ image_reference_registry_host("localhost:5000/example/sandbox:dev"),
+ "localhost:5000"
+ );
+ }
+
+ #[test]
+ fn apply_layer_dir_to_rootfs_honors_whiteouts() {
+ let base = unique_temp_dir();
+ let rootfs = base.join("rootfs");
+ let layer = base.join("layer");
+
+ fs::create_dir_all(rootfs.join("dir")).unwrap();
+ fs::write(rootfs.join("removed.txt"), "old").unwrap();
+ fs::write(rootfs.join("dir/old.txt"), "old").unwrap();
+
+ fs::create_dir_all(layer.join("dir")).unwrap();
+ fs::write(layer.join(".wh.removed.txt"), "").unwrap();
+ fs::write(layer.join("dir/.wh..wh..opq"), "").unwrap();
+ fs::write(layer.join("dir/new.txt"), "new").unwrap();
+
+ apply_layer_dir_to_rootfs(&layer, &rootfs).unwrap();
+
+ assert!(!rootfs.join("removed.txt").exists());
+ assert!(!rootfs.join("dir/old.txt").exists());
+ assert_eq!(
+ fs::read_to_string(rootfs.join("dir/new.txt")).unwrap(),
+ "new"
+ );
+
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn layer_compression_from_media_type_supports_common_formats() {
+ assert_eq!(
+ layer_compression_from_media_type("application/vnd.oci.image.layer.v1.tar").unwrap(),
+ LayerCompression::None
+ );
+ assert_eq!(
+ layer_compression_from_media_type("application/vnd.oci.image.layer.v1.tar+gzip")
+ .unwrap(),
+ LayerCompression::Gzip
+ );
+ assert_eq!(
+ layer_compression_from_media_type("application/vnd.oci.image.layer.v1.tar+zstd")
+ .unwrap(),
+ LayerCompression::Zstd
+ );
+ }
+
#[test]
fn build_guest_environment_includes_tls_paths_for_https_endpoint() {
let config = VmDriverConfig {
@@ -1110,6 +2076,7 @@ mod tests {
config: VmDriverConfig::default(),
launcher_bin: PathBuf::from("openshell-driver-vm"),
registry: Arc::new(Mutex::new(HashMap::new())),
+ image_cache_lock: Arc::new(Mutex::new(())),
events,
};
@@ -1184,6 +2151,29 @@ mod tests {
.expect("dns endpoint should be accepted");
}
+ #[test]
+ fn compute_file_sha256_returns_prefixed_digest() {
+ let base = unique_temp_dir();
+ fs::create_dir_all(&base).unwrap();
+ let file = base.join("rootfs.tar");
+ fs::write(&file, b"openshell").unwrap();
+
+ assert_eq!(
+ compute_file_sha256(&file).unwrap(),
+ "sha256:dc5cbc21a452a783ec453e8a8603101dfec5c7d6a19b6c645889bec8b97c2390"
+ );
+
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn sanitize_image_identity_rewrites_path_separators() {
+ assert_eq!(
+ sanitize_image_identity("sha256:abc/def@ghi"),
+ "sha256-abc-def-ghi"
+ );
+ }
+
#[tokio::test]
async fn prepare_guest_tls_materials_copies_bundle_into_rootfs() {
let base = unique_temp_dir();
diff --git a/crates/openshell-driver-vm/src/main.rs b/crates/openshell-driver-vm/src/main.rs
index 5a675e78a..313ce799c 100644
--- a/crates/openshell-driver-vm/src/main.rs
+++ b/crates/openshell-driver-vm/src/main.rs
@@ -63,6 +63,9 @@ struct Args {
#[arg(long, env = "OPENSHELL_GRPC_ENDPOINT")]
openshell_endpoint: Option,
+ #[arg(long, env = "OPENSHELL_SANDBOX_IMAGE", default_value = "")]
+ default_image: String,
+
#[arg(
long,
env = "OPENSHELL_VM_DRIVER_STATE_DIR",
@@ -137,6 +140,7 @@ async fn main() -> Result<()> {
.ok_or_else(|| miette::miette!("OPENSHELL_GRPC_ENDPOINT is required"))?,
state_dir: args.state_dir,
launcher_bin: None,
+ default_image: args.default_image,
ssh_handshake_secret: args.ssh_handshake_secret.unwrap_or_default(),
ssh_handshake_skew_secs: args.ssh_handshake_skew_secs,
log_level: args.log_level,
diff --git a/crates/openshell-driver-vm/src/rootfs.rs b/crates/openshell-driver-vm/src/rootfs.rs
index b9b29b5fc..929641945 100644
--- a/crates/openshell-driver-vm/src/rootfs.rs
+++ b/crates/openshell-driver-vm/src/rootfs.rs
@@ -2,59 +2,140 @@
// SPDX-License-Identifier: Apache-2.0
use std::fs;
-use std::io::Cursor;
+use std::fs::File;
+use std::io::{BufWriter, Cursor};
use std::path::Path;
-const ROOTFS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rootfs.tar.zst"));
+const SUPERVISOR: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/openshell-sandbox.zst"));
const ROOTFS_VARIANT_MARKER: &str = ".openshell-rootfs-variant";
const SANDBOX_GUEST_INIT_PATH: &str = "/srv/openshell-vm-sandbox-init.sh";
+const SANDBOX_SUPERVISOR_PATH: &str = "/opt/openshell/bin/openshell-sandbox";
pub const fn sandbox_guest_init_path() -> &'static str {
SANDBOX_GUEST_INIT_PATH
}
-pub fn extract_sandbox_rootfs_to(dest: &Path) -> Result<(), String> {
- if ROOTFS.is_empty() {
- return Err(
- "sandbox rootfs not embedded. Build openshell-driver-vm with OPENSHELL_VM_RUNTIME_COMPRESSED_DIR set or run `mise run vm:setup` first"
- .to_string(),
- );
- }
-
- let expected_marker = format!("{}:sandbox", env!("CARGO_PKG_VERSION"));
- let marker_path = dest.join(ROOTFS_VARIANT_MARKER);
-
- if dest.is_dir()
- && fs::read_to_string(&marker_path)
- .map(|value| value.trim() == expected_marker)
- .unwrap_or(false)
- {
- return Ok(());
- }
+pub fn prepare_sandbox_rootfs_from_image_root(
+ rootfs: &Path,
+ image_identity: &str,
+) -> Result<(), String> {
+ prepare_sandbox_rootfs(rootfs)?;
+ validate_sandbox_rootfs(rootfs)?;
+ fs::write(
+ rootfs.join(ROOTFS_VARIANT_MARKER),
+ format!("{}:image:{image_identity}\n", env!("CARGO_PKG_VERSION")),
+ )
+ .map_err(|e| format!("write rootfs variant marker: {e}"))?;
+ Ok(())
+}
+pub fn extract_rootfs_archive_to(archive_path: &Path, dest: &Path) -> Result<(), String> {
if dest.exists() {
fs::remove_dir_all(dest)
.map_err(|e| format!("remove old rootfs {}: {e}", dest.display()))?;
}
- extract_rootfs_to(dest)?;
- prepare_sandbox_rootfs(dest)?;
- fs::write(marker_path, format!("{expected_marker}\n"))
- .map_err(|e| format!("write rootfs variant marker: {e}"))?;
- Ok(())
-}
-
-fn extract_rootfs_to(dest: &Path) -> Result<(), String> {
fs::create_dir_all(dest).map_err(|e| format!("create rootfs dir {}: {e}", dest.display()))?;
-
- let decoder =
- zstd::Decoder::new(Cursor::new(ROOTFS)).map_err(|e| format!("decompress rootfs: {e}"))?;
- let mut archive = tar::Archive::new(decoder);
+ let file =
+ File::open(archive_path).map_err(|e| format!("open {}: {e}", archive_path.display()))?;
+ let mut archive = tar::Archive::new(file);
archive
.unpack(dest)
.map_err(|e| format!("extract rootfs tarball into {}: {e}", dest.display()))
}
+pub fn create_rootfs_archive_from_dir(source: &Path, archive_path: &Path) -> Result<(), String> {
+ if let Some(parent) = archive_path.parent() {
+ fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
+ }
+
+ let file = File::create(archive_path)
+ .map_err(|e| format!("create {}: {e}", archive_path.display()))?;
+ let writer = BufWriter::new(file);
+ let mut builder = tar::Builder::new(writer);
+ append_rootfs_tree_to_archive(&mut builder, source, Path::new("")).map_err(|e| {
+ format!(
+ "archive {} into {}: {e}",
+ source.display(),
+ archive_path.display()
+ )
+ })?;
+ builder
+ .finish()
+ .map_err(|e| format!("finalize {}: {e}", archive_path.display()))
+}
+
+fn append_rootfs_tree_to_archive(
+ builder: &mut tar::Builder>,
+ source: &Path,
+ archive_prefix: &Path,
+) -> Result<(), String> {
+ let mut entries = fs::read_dir(source)
+ .map_err(|e| format!("read {}: {e}", source.display()))?
+ .collect::, _>>()
+ .map_err(|e| format!("read {}: {e}", source.display()))?;
+ entries.sort_by_key(|entry| entry.file_name());
+
+ for entry in entries {
+ let entry_name = entry.file_name();
+ let source_path = entry.path();
+ let archive_path = if archive_prefix.as_os_str().is_empty() {
+ entry_name.into()
+ } else {
+ archive_prefix.join(entry_name)
+ };
+ let metadata = fs::symlink_metadata(&source_path)
+ .map_err(|e| format!("stat {}: {e}", source_path.display()))?;
+ let file_type = metadata.file_type();
+
+ if file_type.is_dir() {
+ builder
+ .append_dir(&archive_path, &source_path)
+ .map_err(|e| format!("append dir {}: {e}", source_path.display()))?;
+ append_rootfs_tree_to_archive(builder, &source_path, &archive_path)?;
+ continue;
+ }
+
+ if file_type.is_file() {
+ let mut file = File::open(&source_path)
+ .map_err(|e| format!("open {}: {e}", source_path.display()))?;
+ builder
+ .append_file(&archive_path, &mut file)
+ .map_err(|e| format!("append file {}: {e}", source_path.display()))?;
+ continue;
+ }
+
+ if file_type.is_symlink() {
+ append_symlink_to_archive(builder, &source_path, &archive_path, &metadata)?;
+ continue;
+ }
+
+ return Err(format!(
+ "unsupported rootfs entry type at {}",
+ source_path.display()
+ ));
+ }
+
+ Ok(())
+}
+
+fn append_symlink_to_archive(
+ builder: &mut tar::Builder>,
+ source_path: &Path,
+ archive_path: &Path,
+ metadata: &fs::Metadata,
+) -> Result<(), String> {
+ let target = fs::read_link(source_path)
+ .map_err(|e| format!("readlink {}: {e}", source_path.display()))?;
+ let mut header = tar::Header::new_gnu();
+ header.set_metadata(metadata);
+ header.set_size(0);
+ header.set_cksum();
+ builder
+ .append_link(&mut header, archive_path, target)
+ .map_err(|e| format!("append symlink {}: {e}", source_path.display()))
+}
+
fn prepare_sandbox_rootfs(rootfs: &Path) -> Result<(), String> {
for relative in [
"usr/local/bin/k3s",
@@ -86,6 +167,8 @@ fn prepare_sandbox_rootfs(rootfs: &Path) -> Result<(), String> {
.map_err(|e| format!("chmod {}: {e}", init_path.display()))?;
}
+ ensure_supervisor_binary(rootfs)?;
+
let opt_dir = rootfs.join("opt/openshell");
fs::create_dir_all(&opt_dir).map_err(|e| format!("create {}: {e}", opt_dir.display()))?;
fs::write(opt_dir.join(".rootfs-type"), "sandbox\n")
@@ -97,6 +180,19 @@ fn prepare_sandbox_rootfs(rootfs: &Path) -> Result<(), String> {
Ok(())
}
+pub fn validate_sandbox_rootfs(rootfs: &Path) -> Result<(), String> {
+ require_rootfs_path(rootfs, SANDBOX_GUEST_INIT_PATH)?;
+ require_rootfs_path(rootfs, "/opt/openshell/bin/openshell-sandbox")?;
+ require_any_rootfs_path(rootfs, &["/bin/bash"])?;
+ require_any_rootfs_path(rootfs, &["/bin/mount", "/usr/bin/mount"])?;
+ require_any_rootfs_path(
+ rootfs,
+ &["/sbin/ip", "/usr/sbin/ip", "/bin/ip", "/usr/bin/ip"],
+ )?;
+ require_any_rootfs_path(rootfs, &["/bin/sed", "/usr/bin/sed"])?;
+ Ok(())
+}
+
fn ensure_sandbox_guest_user(rootfs: &Path) -> Result<(), String> {
const SANDBOX_UID: u32 = 10001;
const SANDBOX_GID: u32 = 10001;
@@ -150,6 +246,62 @@ fn ensure_line_in_file(
fs::write(path, contents).map_err(|e| format!("write {}: {e}", path.display()))
}
+fn ensure_supervisor_binary(rootfs: &Path) -> Result<(), String> {
+ let path = rootfs.join(SANDBOX_SUPERVISOR_PATH.trim_start_matches('/'));
+ if SUPERVISOR.is_empty() {
+ if !path.exists() {
+ return Err(
+ "sandbox supervisor not embedded. Build openshell-driver-vm with OPENSHELL_VM_RUNTIME_COMPRESSED_DIR set and run `mise run vm:setup && mise run vm:supervisor` first"
+ .to_string(),
+ );
+ }
+ } else {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
+ }
+
+ let supervisor = zstd::decode_all(Cursor::new(SUPERVISOR))
+ .map_err(|e| format!("decompress supervisor: {e}"))?;
+ fs::write(&path, supervisor).map_err(|e| format!("write {}: {e}", path.display()))?;
+ }
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
+ .map_err(|e| format!("chmod {}: {e}", path.display()))?;
+ }
+
+ Ok(())
+}
+
+fn require_rootfs_path(rootfs: &Path, relative: &str) -> Result<(), String> {
+ let candidate = rootfs.join(relative.trim_start_matches('/'));
+ if candidate.exists() {
+ Ok(())
+ } else {
+ Err(format!(
+ "prepared rootfs is missing {}",
+ candidate.display()
+ ))
+ }
+}
+
+fn require_any_rootfs_path(rootfs: &Path, candidates: &[&str]) -> Result<(), String> {
+ if candidates
+ .iter()
+ .any(|candidate| rootfs.join(candidate.trim_start_matches('/')).exists())
+ {
+ Ok(())
+ } else {
+ Err(format!(
+ "prepared rootfs is missing one of: {}",
+ candidates.join(", ")
+ ))
+ }
+}
+
fn remove_rootfs_path(rootfs: &Path, relative: &str) -> Result<(), String> {
let path = rootfs.join(relative);
if !path.exists() {
@@ -181,9 +333,15 @@ mod tests {
fs::create_dir_all(rootfs.join("var/lib/rancher")).expect("create var/lib/rancher");
fs::create_dir_all(rootfs.join("opt/openshell/charts")).expect("create charts");
fs::create_dir_all(rootfs.join("opt/openshell/manifests")).expect("create manifests");
+ fs::create_dir_all(rootfs.join("opt/openshell/bin")).expect("create openshell bin");
fs::write(rootfs.join("usr/local/bin/k3s"), b"k3s").expect("write k3s");
fs::write(rootfs.join("usr/local/bin/kubectl"), b"kubectl").expect("write kubectl");
fs::write(rootfs.join("opt/openshell/.initialized"), b"yes").expect("write initialized");
+ fs::write(
+ rootfs.join("opt/openshell/bin/openshell-sandbox"),
+ b"sandbox",
+ )
+ .expect("write openshell-sandbox");
fs::write(
rootfs.join("etc/passwd"),
"root:x:0:0:root:/root:/bin/bash\n",
@@ -191,8 +349,15 @@ mod tests {
.expect("write passwd");
fs::write(rootfs.join("etc/group"), "root:x:0:\n").expect("write group");
fs::write(rootfs.join("etc/hosts"), "127.0.0.1 localhost\n").expect("write hosts");
+ fs::create_dir_all(rootfs.join("bin")).expect("create bin");
+ fs::create_dir_all(rootfs.join("sbin")).expect("create sbin");
+ fs::write(rootfs.join("bin/bash"), b"bash").expect("write bash");
+ fs::write(rootfs.join("bin/mount"), b"mount").expect("write mount");
+ fs::write(rootfs.join("bin/sed"), b"sed").expect("write sed");
+ fs::write(rootfs.join("sbin/ip"), b"ip").expect("write ip");
prepare_sandbox_rootfs(&rootfs).expect("prepare sandbox rootfs");
+ validate_sandbox_rootfs(&rootfs).expect("validate sandbox rootfs");
assert!(!rootfs.join("usr/local/bin/k3s").exists());
assert!(!rootfs.join("usr/local/bin/kubectl").exists());
@@ -219,6 +384,37 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
+ #[cfg(unix)]
+ #[test]
+ fn create_rootfs_archive_preserves_broken_symlinks() {
+ let dir = unique_temp_dir();
+ let rootfs = dir.join("rootfs");
+ let extracted = dir.join("extracted");
+ let archive = dir.join("rootfs.tar");
+
+ fs::create_dir_all(rootfs.join("etc")).expect("create etc");
+ fs::write(rootfs.join("etc/hosts"), "127.0.0.1 localhost\n").expect("write hosts");
+ std::os::unix::fs::symlink("/proc/self/mounts", rootfs.join("etc/mtab"))
+ .expect("create symlink");
+
+ create_rootfs_archive_from_dir(&rootfs, &archive).expect("archive rootfs");
+ extract_rootfs_archive_to(&archive, &extracted).expect("extract rootfs");
+
+ let extracted_link = extracted.join("etc/mtab");
+ assert!(
+ fs::symlink_metadata(&extracted_link)
+ .unwrap()
+ .file_type()
+ .is_symlink()
+ );
+ assert_eq!(
+ fs::read_link(&extracted_link).expect("read extracted symlink"),
+ PathBuf::from("/proc/self/mounts")
+ );
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
fn unique_temp_dir() -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
diff --git a/crates/openshell-driver-vm/start.sh b/crates/openshell-driver-vm/start.sh
index 0579e8aa0..4f2ae6c79 100755
--- a/crates/openshell-driver-vm/start.sh
+++ b/crates/openshell-driver-vm/start.sh
@@ -5,30 +5,70 @@
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+source "${ROOT}/crates/openshell-vm/pins.env" 2>/dev/null || true
CLI_BIN="${ROOT}/scripts/bin/openshell"
-COMPRESSED_DIR="${ROOT}/target/vm-runtime-compressed"
-SERVER_PORT="${OPENSHELL_SERVER_PORT:-8080}"
-# Keep the driver socket path under AF_UNIX SUN_LEN on macOS.
+ENV_FILE="${ROOT}/.env"
+COMPRESSED_DIR_DEFAULT="${ROOT}/target/vm-runtime-compressed"
+COMPRESSED_DIR="${OPENSHELL_VM_RUNTIME_COMPRESSED_DIR:-${COMPRESSED_DIR_DEFAULT}}"
+SERVER_PORT_REQUESTED="${OPENSHELL_SERVER_PORT:-${GATEWAY_PORT:-}}"
+SERVER_PORT="${SERVER_PORT_REQUESTED:-}"
STATE_DIR_ROOT="${OPENSHELL_VM_DRIVER_STATE_ROOT:-/tmp}"
-STATE_LABEL_RAW="${OPENSHELL_VM_INSTANCE:-port-${SERVER_PORT}}"
-STATE_LABEL="$(printf '%s' "${STATE_LABEL_RAW}" | tr -cs '[:alnum:]._-' '-')"
-if [ -z "${STATE_LABEL}" ]; then
- STATE_LABEL="port-${SERVER_PORT}"
-fi
-STATE_DIR_DEFAULT="${STATE_DIR_ROOT}/openshell-vm-driver-dev-${USER:-user}-${STATE_LABEL}"
-STATE_DIR="${OPENSHELL_VM_DRIVER_STATE_DIR:-${STATE_DIR_DEFAULT}}"
-DB_PATH_DEFAULT="${STATE_DIR}/openshell.db"
VM_HOST_GATEWAY_DEFAULT="${OPENSHELL_VM_HOST_GATEWAY:-host.containers.internal}"
-LOCAL_GATEWAY_ENDPOINT_DEFAULT="http://127.0.0.1:${SERVER_PORT}"
-LOCAL_GATEWAY_ENDPOINT="${OPENSHELL_VM_LOCAL_GATEWAY_ENDPOINT:-${LOCAL_GATEWAY_ENDPOINT_DEFAULT}}"
-GATEWAY_NAME_DEFAULT="vm-driver-${STATE_LABEL}"
-GATEWAY_NAME="${OPENSHELL_VM_GATEWAY_NAME:-${GATEWAY_NAME_DEFAULT}}"
DRIVER_DIR_DEFAULT="${ROOT}/target/debug"
DRIVER_DIR="${OPENSHELL_DRIVER_DIR:-${DRIVER_DIR_DEFAULT}}"
-export OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${OPENSHELL_VM_RUNTIME_COMPRESSED_DIR:-${COMPRESSED_DIR}}"
+normalize_name() {
+ printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//'
+}
-mkdir -p "${STATE_DIR}"
+has_env_key() {
+ local key=$1
+ [ -f "${ENV_FILE}" ] || return 1
+ grep -Eq "^[[:space:]]*(export[[:space:]]+)?${key}=" "${ENV_FILE}"
+}
+
+append_env_if_missing() {
+ local key=$1
+ local value=$2
+ if has_env_key "${key}"; then
+ return
+ fi
+ if [ -f "${ENV_FILE}" ] && [ -s "${ENV_FILE}" ]; then
+ if [ "$(tail -c1 "${ENV_FILE}" | wc -l)" -eq 0 ]; then
+ printf "\n" >>"${ENV_FILE}"
+ fi
+ fi
+ printf "%s=%s\n" "${key}" "${value}" >>"${ENV_FILE}"
+}
+
+upsert_env_key() {
+ local key=$1
+ local value=$2
+ local tmp_file
+
+ tmp_file="$(mktemp "${ENV_FILE}.tmp.XXXXXX")"
+ if [ -f "${ENV_FILE}" ]; then
+ awk -v key="${key}" -v value="${value}" '
+ BEGIN { updated = 0 }
+ $0 ~ "^[[:space:]]*(export[[:space:]]+)?" key "=" {
+ if (!updated) {
+ print key "=" value
+ updated = 1
+ }
+ next
+ }
+ { print }
+ END {
+ if (!updated) {
+ print key "=" value
+ }
+ }
+ ' "${ENV_FILE}" >"${tmp_file}"
+ else
+ printf "%s=%s\n" "${key}" "${value}" >"${tmp_file}"
+ fi
+ mv "${tmp_file}" "${ENV_FILE}"
+}
normalize_bool() {
case "${1,,}" in
@@ -41,6 +81,39 @@ normalize_bool() {
esac
}
+port_is_in_use() {
+ local port=$1
+ if command -v lsof >/dev/null 2>&1; then
+ lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1
+ return $?
+ fi
+
+ if command -v nc >/dev/null 2>&1; then
+ nc -z 127.0.0.1 "${port}" >/dev/null 2>&1
+ return $?
+ fi
+
+ (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1
+}
+
+pick_random_port() {
+ local lower=20000
+ local upper=60999
+ local attempts=256
+ local port
+
+ for _ in $(seq 1 "${attempts}"); do
+ port=$((RANDOM % (upper - lower + 1) + lower))
+ if ! port_is_in_use "${port}"; then
+ echo "${port}"
+ return 0
+ fi
+ done
+
+ echo "ERROR: could not find a free port after ${attempts} attempts." >&2
+ return 1
+}
+
check_supervisor_cross_toolchain() {
# The sandbox supervisor inside the guest is always Linux. On non-Linux
# hosts (macOS) and on Linux hosts with a different arch than the guest,
@@ -73,17 +146,54 @@ check_supervisor_cross_toolchain() {
fi
}
-if [ ! -f "${COMPRESSED_DIR}/rootfs.tar.zst" ]; then
- check_supervisor_cross_toolchain
- echo "==> Building base VM rootfs tarball"
- mise run vm:rootfs -- --base
+if [ -n "${SERVER_PORT_REQUESTED}" ]; then
+ if port_is_in_use "${SERVER_PORT}"; then
+ echo "ERROR: requested gateway port ${SERVER_PORT} is already in use." >&2
+ echo " Update .env GATEWAY_PORT or override it for one run:" >&2
+ echo " OPENSHELL_SERVER_PORT= mise run gateway:vm" >&2
+ exit 1
+ fi
+else
+ SERVER_PORT="$(pick_random_port)"
+ append_env_if_missing "GATEWAY_PORT" "${SERVER_PORT}"
+fi
+
+GATEWAY_NAME_DEFAULT="$(basename "${ROOT}")"
+GATEWAY_NAME="${OPENSHELL_VM_GATEWAY_NAME:-${GATEWAY_NAME_DEFAULT}}"
+if [ -z "${GATEWAY_NAME}" ]; then
+ GATEWAY_NAME="openshell"
+fi
+
+# Keep the driver socket path under AF_UNIX SUN_LEN on macOS.
+STATE_LABEL_RAW="${OPENSHELL_VM_INSTANCE:-$(normalize_name "${GATEWAY_NAME}")}"
+STATE_LABEL="$(printf '%s' "${STATE_LABEL_RAW}" | tr -cs '[:alnum:]._-' '-')"
+if [ -z "${STATE_LABEL}" ]; then
+ STATE_LABEL="gateway"
fi
+STATE_DIR_DEFAULT="${STATE_DIR_ROOT}/openshell-vm-driver-dev-${USER:-user}-${STATE_LABEL}"
+STATE_DIR="${OPENSHELL_VM_DRIVER_STATE_DIR:-${STATE_DIR_DEFAULT}}"
+DB_PATH_DEFAULT="${STATE_DIR}/openshell.db"
+LOCAL_GATEWAY_ENDPOINT_DEFAULT="http://127.0.0.1:${SERVER_PORT}"
+LOCAL_GATEWAY_ENDPOINT="${OPENSHELL_VM_LOCAL_GATEWAY_ENDPOINT:-${LOCAL_GATEWAY_ENDPOINT_DEFAULT}}"
+
+export OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${COMPRESSED_DIR}"
+export OPENSHELL_GATEWAY="${GATEWAY_NAME}"
+
+upsert_env_key "OPENSHELL_GATEWAY" "${GATEWAY_NAME}"
-if [ ! -f "${COMPRESSED_DIR}/rootfs.tar.zst" ] || ! find "${COMPRESSED_DIR}" -maxdepth 1 -name 'libkrun*.zst' | grep -q .; then
+mkdir -p "${STATE_DIR}"
+
+if [ ! -d "${COMPRESSED_DIR}" ] || ! find "${COMPRESSED_DIR}" -maxdepth 1 -name 'libkrun*.zst' | grep -q . || [ ! -f "${COMPRESSED_DIR}/gvproxy.zst" ]; then
echo "==> Preparing embedded VM runtime"
mise run vm:setup
fi
+if [ ! -f "${COMPRESSED_DIR}/openshell-sandbox.zst" ]; then
+ check_supervisor_cross_toolchain
+ echo "==> Building bundled VM supervisor"
+ mise run vm:supervisor
+fi
+
echo "==> Building gateway and VM compute driver"
cargo build -p openshell-server -p openshell-driver-vm
@@ -100,18 +210,35 @@ export OPENSHELL_DISABLE_TLS="$(normalize_bool "${OPENSHELL_DISABLE_TLS:-true}")
export OPENSHELL_DB_URL="${OPENSHELL_DB_URL:-sqlite:${DB_PATH_DEFAULT}}"
export OPENSHELL_DRIVERS="${OPENSHELL_DRIVERS:-vm}"
export OPENSHELL_DRIVER_DIR="${DRIVER_DIR}"
+export OPENSHELL_SERVER_PORT="${SERVER_PORT}"
export OPENSHELL_GRPC_ENDPOINT="${OPENSHELL_GRPC_ENDPOINT:-http://${VM_HOST_GATEWAY_DEFAULT}:${SERVER_PORT}}"
+export OPENSHELL_SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-${COMMUNITY_SANDBOX_IMAGE:-}}"
export OPENSHELL_SSH_GATEWAY_HOST="${OPENSHELL_SSH_GATEWAY_HOST:-127.0.0.1}"
export OPENSHELL_SSH_GATEWAY_PORT="${OPENSHELL_SSH_GATEWAY_PORT:-${SERVER_PORT}}"
export OPENSHELL_SSH_HANDSHAKE_SECRET="${OPENSHELL_SSH_HANDSHAKE_SECRET:-dev-vm-driver-secret}"
export OPENSHELL_VM_DRIVER_STATE_DIR="${STATE_DIR}"
-echo "==> Gateway registration"
+GATEWAY_METADATA_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/openshell/gateways/${GATEWAY_NAME}"
+mkdir -p "${GATEWAY_METADATA_DIR}"
+cat >"${GATEWAY_METADATA_DIR}/metadata.json" < Gateway config"
echo " Name: ${GATEWAY_NAME}"
echo " Endpoint: ${LOCAL_GATEWAY_ENDPOINT}"
-echo " Register: ${CLI_BIN} gateway add --name ${GATEWAY_NAME} ${LOCAL_GATEWAY_ENDPOINT}"
-echo " Select: ${CLI_BIN} gateway select ${GATEWAY_NAME}"
+echo " .env: OPENSHELL_GATEWAY=${GATEWAY_NAME}"
+echo " .env: GATEWAY_PORT=${SERVER_PORT}"
echo " Driver: ${OPENSHELL_DRIVER_DIR}/openshell-driver-vm"
+echo " Image: ${OPENSHELL_SANDBOX_IMAGE}"
+echo " Status: ${CLI_BIN} status"
+echo " Create: ${CLI_BIN} sandbox create --name vm-test --from ubuntu:24.04"
echo "==> Starting OpenShell server with VM compute driver"
exec "${ROOT}/target/debug/openshell-gateway"
diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs
index 2e6e2823b..6fcd5aa48 100644
--- a/crates/openshell-server/src/cli.rs
+++ b/crates/openshell-server/src/cli.rs
@@ -307,6 +307,7 @@ async fn run_from_args(args: Args) -> Result<()> {
let vm_config = VmComputeConfig {
state_dir: args.vm_driver_state_dir,
driver_dir: args.driver_dir,
+ default_image: config.sandbox_image.clone(),
krun_log_level: args.vm_krun_log_level,
vcpus: args.vm_vcpus,
mem_mib: args.vm_mem_mib,
diff --git a/crates/openshell-server/src/compute/vm.rs b/crates/openshell-server/src/compute/vm.rs
index 622bc393e..d549e7b6c 100644
--- a/crates/openshell-server/src/compute/vm.rs
+++ b/crates/openshell-server/src/compute/vm.rs
@@ -63,6 +63,9 @@ pub struct VmComputeConfig {
/// falls back to its conventional install paths and sibling binary.
pub driver_dir: Option,
+ /// Default sandbox image the driver should use when a request omits one.
+ pub default_image: String,
+
/// libkrun log level used by the VM driver helper.
pub krun_log_level: u32,
@@ -124,6 +127,7 @@ impl Default for VmComputeConfig {
Self {
state_dir: Self::default_state_dir(),
driver_dir: None,
+ default_image: String::new(),
krun_log_level: Self::default_krun_log_level(),
vcpus: Self::default_vcpus(),
mem_mib: Self::default_mem_mib(),
@@ -304,6 +308,9 @@ pub(crate) async fn spawn(
.arg("--openshell-endpoint")
.arg(&config.grpc_endpoint);
command.arg("--state-dir").arg(&vm_config.state_dir);
+ if !vm_config.default_image.trim().is_empty() {
+ command.arg("--default-image").arg(&vm_config.default_image);
+ }
command
.arg("--ssh-handshake-secret")
.arg(&config.ssh_handshake_secret);
diff --git a/deploy/docker/Dockerfile.driver-vm-macos b/deploy/docker/Dockerfile.driver-vm-macos
index ac0aec952..1932905e3 100644
--- a/deploy/docker/Dockerfile.driver-vm-macos
+++ b/deploy/docker/Dockerfile.driver-vm-macos
@@ -8,7 +8,7 @@
#
# openshell-driver-vm loads libkrun/libkrunfw at runtime via dlopen, so it
# does NOT need Hypervisor.framework headers at build time. Pre-compressed
-# runtime artifacts (libkrun, libkrunfw, gvproxy, rootfs) are injected via
+# runtime artifacts (libkrun, libkrunfw, gvproxy, bundled supervisor) are injected via
# the vm-runtime-compressed build context and embedded into the binary via
# include_bytes!().
#
diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx
index 2f26c7bfb..28d093ecb 100644
--- a/docs/get-started/quickstart.mdx
+++ b/docs/get-started/quickstart.mdx
@@ -107,6 +107,13 @@ For example, to pull the `base` image, run the following command:
openshell sandbox create --from base
```
+You can also point `--from` at a local Dockerfile or directory on disk when
+using a local gateway:
+
+```shell
+openshell sandbox create --from ./my-sandbox-dir
+```
+
diff --git a/docs/sandboxes/community-sandboxes.mdx b/docs/sandboxes/community-sandboxes.mdx
index 32668d3e5..80ce32360 100644
--- a/docs/sandboxes/community-sandboxes.mdx
+++ b/docs/sandboxes/community-sandboxes.mdx
@@ -15,7 +15,7 @@ own.
Community sandboxes are ready-to-use environments published in the
[OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) repository.
Each sandbox bundles a Dockerfile, policy, optional skills, and startup scripts
-into a single package that you can launch with one command.
+into a single package that can be published as a pre-built sandbox image.
## Current Catalog
@@ -40,12 +40,11 @@ When you pass `--from` with a community sandbox name, the CLI:
1. Resolves the name against the
[OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) repository.
-2. Pulls the Dockerfile, policy, skills, and any startup scripts.
-3. Builds the container image locally.
-4. Creates the sandbox with the bundled configuration applied.
+2. Converts the catalog name into the published sandbox image reference.
+3. Creates the sandbox with that image and the bundled community defaults.
-You end up with a running sandbox whose image, policy, and tooling are all
-preconfigured by the community package.
+You end up with a running sandbox whose image and tooling are preconfigured by
+the community package.
### Other Sources
@@ -58,12 +57,26 @@ The `--from` flag also accepts:
openshell sandbox create --from ./my-sandbox-dir
```
+ This local Dockerfile flow is supported only when the selected gateway runs on
+ the same machine as the CLI. The CLI builds the image in the local Docker
+ daemon. For local Kubernetes gateways it also imports that image into the
+ gateway container runtime. For local VM gateways, the CLI exports the built
+ image as a local rootfs tar artifact and the VM driver consumes that artifact.
+
- Container image references: Use an existing container image directly:
```shell
openshell sandbox create --from my-registry.example.com/my-image:latest
```
+ On the VM backend, the image becomes the base guest rootfs for that sandbox.
+ The VM driver prepares and caches a rewritten rootfs per immutable image
+ identity, so different VM sandboxes can run with different `--from` images at
+ the same time. VM images must remain base-compatible with the guest init path.
+ Prepared VM rootfs caches stay on disk until they are removed from the VM
+ driver state directory. Docker is not required for registry or community image
+ refs on the VM backend.
+
## Contribute a Community Sandbox
Each community sandbox is a directory under `sandboxes/` in the
diff --git a/tasks/scripts/vm/build-supervisor-bundle.sh b/tasks/scripts/vm/build-supervisor-bundle.sh
new file mode 100755
index 000000000..9e3995a33
--- /dev/null
+++ b/tasks/scripts/vm/build-supervisor-bundle.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
+OUTPUT_DIR="${OPENSHELL_VM_RUNTIME_COMPRESSED_DIR:-${ROOT}/target/vm-runtime-compressed}"
+
+GUEST_ARCH=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --arch)
+ GUEST_ARCH="$2"
+ shift 2
+ ;;
+ --arch=*)
+ GUEST_ARCH="${1#--arch=}"
+ shift
+ ;;
+ --help|-h)
+ echo "Usage: $0 [--arch aarch64|x86_64]"
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [ -z "${GUEST_ARCH}" ]; then
+ case "$(uname -m)" in
+ aarch64|arm64) GUEST_ARCH="aarch64" ;;
+ x86_64|amd64) GUEST_ARCH="x86_64" ;;
+ *)
+ echo "ERROR: Unsupported host architecture: $(uname -m)" >&2
+ echo " Use --arch aarch64 or --arch x86_64 to override." >&2
+ exit 1
+ ;;
+ esac
+fi
+
+case "${GUEST_ARCH}" in
+ aarch64|arm64)
+ RUST_TARGET="aarch64-unknown-linux-gnu"
+ ;;
+ x86_64|amd64)
+ RUST_TARGET="x86_64-unknown-linux-gnu"
+ ;;
+ *)
+ echo "ERROR: Unsupported guest architecture: ${GUEST_ARCH}" >&2
+ echo " Supported: aarch64, x86_64" >&2
+ exit 1
+ ;;
+esac
+
+SUPERVISOR_BIN="${ROOT}/target/${RUST_TARGET}/release/openshell-sandbox"
+SUPERVISOR_OUTPUT="${OUTPUT_DIR}/openshell-sandbox.zst"
+
+echo "==> Building openshell-sandbox supervisor bundle"
+echo " Guest arch: ${GUEST_ARCH}"
+echo " Rust target: ${RUST_TARGET}"
+echo " Output: ${SUPERVISOR_OUTPUT}"
+
+mkdir -p "${OUTPUT_DIR}"
+
+SUPERVISOR_BUILD_LOG="$(mktemp -t openshell-supervisor-build.XXXXXX.log)"
+run_supervisor_build() {
+ if command -v cargo-zigbuild >/dev/null 2>&1; then
+ cargo zigbuild --release -p openshell-sandbox --target "${RUST_TARGET}" \
+ --manifest-path "${ROOT}/Cargo.toml"
+ else
+ echo " cargo-zigbuild not found, falling back to cargo build..."
+ cargo build --release -p openshell-sandbox --target "${RUST_TARGET}" \
+ --manifest-path "${ROOT}/Cargo.toml"
+ fi
+}
+
+if run_supervisor_build >"${SUPERVISOR_BUILD_LOG}" 2>&1; then
+ tail -5 "${SUPERVISOR_BUILD_LOG}"
+ rm -f "${SUPERVISOR_BUILD_LOG}"
+else
+ status=$?
+ echo "ERROR: supervisor build failed. Full output:" >&2
+ cat "${SUPERVISOR_BUILD_LOG}" >&2
+ echo " (log saved at ${SUPERVISOR_BUILD_LOG})" >&2
+ exit "${status}"
+fi
+
+if [ ! -f "${SUPERVISOR_BIN}" ]; then
+ echo "ERROR: supervisor binary not found at ${SUPERVISOR_BIN}" >&2
+ exit 1
+fi
+
+zstd -19 -T0 -f "${SUPERVISOR_BIN}" -o "${SUPERVISOR_OUTPUT}"
+
+echo "==> Bundled supervisor ready"
+echo " Binary: $(du -sh "${SUPERVISOR_BIN}" | cut -f1)"
+echo " Compressed: $(du -sh "${SUPERVISOR_OUTPUT}" | cut -f1)"
diff --git a/tasks/scripts/vm/vm-setup.sh b/tasks/scripts/vm/vm-setup.sh
index bccb7f754..7563819b9 100755
--- a/tasks/scripts/vm/vm-setup.sh
+++ b/tasks/scripts/vm/vm-setup.sh
@@ -21,6 +21,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/_lib.sh"
ROOT="$(vm_lib_root)"
+CLI_BIN="${ROOT}/scripts/bin/openshell"
FROM_SOURCE="${FROM_SOURCE:-0}"
@@ -126,6 +127,6 @@ echo ""
echo "==> Setup complete!"
echo " Compressed artifacts in: ${OUTPUT_DIR}"
echo ""
-echo "Next steps:"
-echo " mise run vm:rootfs --base # build rootfs (requires Docker)"
-echo " mise run gateway:vm # start openshell-gateway with the VM driver"
+echo "After starting the gateway:"
+echo " ${CLI_BIN} status"
+echo " ${CLI_BIN} sandbox create --name vm-test --from ubuntu:24.04"
diff --git a/tasks/vm.toml b/tasks/vm.toml
index 0a44b4ff7..31c0728e6 100644
--- a/tasks/vm.toml
+++ b/tasks/vm.toml
@@ -5,6 +5,7 @@
#
# Workflow:
# mise run vm:setup # one-time: download pre-built runtime (~30s)
+# mise run vm:supervisor # build the bundled sandbox supervisor
# mise run gateway:vm # start openshell-gateway with the VM driver
# mise run vm # build + run the standalone openshell-vm microVM
# mise run vm:clean # wipe everything and start over
@@ -38,6 +39,10 @@ run = [
description = "One-time setup: download (or build) the VM runtime"
run = "tasks/scripts/vm/vm-setup.sh"
+["vm:supervisor"]
+description = "Build the bundled openshell-sandbox supervisor for openshell-driver-vm"
+run = "tasks/scripts/vm/build-supervisor-bundle.sh"
+
["vm:rootfs"]
description = "Build the VM rootfs tarball (use -- --base for lightweight)"
run = "tasks/scripts/vm/build-rootfs-tarball.sh"