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"