diff --git a/.github/workflows/driver-vm-linux.yml b/.github/workflows/driver-vm-linux.yml index 7b41dc70ca..0963fecde2 100644 --- a/.github/workflows/driver-vm-linux.yml +++ b/.github/workflows/driver-vm-linux.yml @@ -147,10 +147,16 @@ jobs: OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed" \ tasks/scripts/vm/build-supervisor-bundle.sh --arch "${{ matrix.guest_arch }}" + - name: Build containerd shim + run: | + set -euo pipefail + OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed" \ + mise x -- tasks/scripts/vm/build-containerd-shim.sh + - name: Verify embedded driver inputs run: | set -euo pipefail - for file in libkrun.so.zst libkrunfw.so.5.zst gvproxy.zst umoci.zst openshell-sandbox.zst; do + for file in libkrun.so.zst libkrunfw.so.5.zst gvproxy.zst umoci.zst openshell-sandbox.zst libopenshell_containerd_shim.so.zst; do test -s "target/vm-runtime-compressed/${file}" done diff --git a/Cargo.lock b/Cargo.lock index b412467610..c145aca798 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -965,12 +965,6 @@ 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" @@ -1111,16 +1105,6 @@ 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" @@ -1168,27 +1152,6 @@ 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" @@ -1576,37 +1539,6 @@ 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" @@ -2089,18 +2021,6 @@ 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" @@ -2338,15 +2258,6 @@ 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 = "0.4.6" @@ -2870,50 +2781,6 @@ 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" @@ -2977,20 +2844,6 @@ dependencies = [ "simple_asn1", ] -[[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" @@ -3004,21 +2857,6 @@ 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 = "kqueue" version = "1.2.0" @@ -3712,7 +3550,7 @@ dependencies = [ "getrandom 0.2.17", "http 1.4.0", "rand 0.8.6", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", "serde_path_to_error", @@ -3730,49 +3568,6 @@ 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 1.4.0", - "http-auth", - "jsonwebtoken 10.3.0", - "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 = "oid-registry" version = "0.7.1" @@ -3782,17 +3577,6 @@ dependencies = [ "asn1-rs", ] -[[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" @@ -3859,7 +3643,7 @@ dependencies = [ "owo-colors", "prost-types", "rcgen", - "reqwest 0.12.28", + "reqwest", "rustls 0.23.38", "rustls-pemfile", "serde", @@ -3892,7 +3676,7 @@ dependencies = [ "prost", "prost-types", "protobuf-src", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", "tempfile", @@ -3982,13 +3766,11 @@ version = "0.0.0" dependencies = [ "bollard", "clap", - "flate2", "futures", "libc", "libloading", "miette", "nix", - "oci-client", "openshell-core", "openshell-policy", "openshell-vfio", @@ -3998,7 +3780,6 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_json", - "sha2 0.10.9", "tar", "temp-env", "tokio", @@ -4087,7 +3868,7 @@ version = "0.0.0" dependencies = [ "bytes", "openshell-core", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", "serde_yml", @@ -4140,7 +3921,7 @@ dependencies = [ "miette", "oauth2", "openshell-core", - "reqwest 0.12.28", + "reqwest", "rustls 0.23.38", "serde", "thiserror 2.0.18", @@ -4177,7 +3958,7 @@ dependencies = [ "hyper-rustls 0.27.9", "hyper-util", "ipnet", - "jsonwebtoken 9.3.1", + "jsonwebtoken", "k8s-openapi", "kube", "metrics", @@ -4204,7 +3985,7 @@ dependencies = [ "prost-types", "rand 0.9.4", "rcgen", - "reqwest 0.12.28", + "reqwest", "russh", "rustix 1.1.4", "rustls 0.23.38", @@ -4295,7 +4076,7 @@ dependencies = [ "prost-types", "rcgen", "regorus", - "reqwest 0.12.28", + "reqwest", "rustls 0.23.38", "rustls-pemfile", "serde", @@ -5002,7 +4783,6 @@ 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", @@ -5141,7 +4921,7 @@ dependencies = [ "lru", "paste", "stability", - "strum 0.26.3", + "strum", "unicode-segmentation", "unicode-truncate", "unicode-width 0.1.14", @@ -5282,47 +5062,6 @@ 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 1.4.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls 0.23.38", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls 0.26.4", - "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" @@ -5606,33 +5345,6 @@ 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 0.23.38", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki 0.103.13", - "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.101.7" @@ -6455,15 +6167,9 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros 0.26.4", + "strum_macros", ] -[[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" @@ -6477,18 +6183,6 @@ 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" @@ -7534,19 +7228,6 @@ 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" @@ -7579,15 +7260,6 @@ 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" @@ -7748,15 +7420,6 @@ 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" @@ -7802,21 +7465,6 @@ 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" @@ -7874,12 +7522,6 @@ 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" @@ -7898,12 +7540,6 @@ 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" @@ -7922,12 +7558,6 @@ 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" @@ -7958,12 +7588,6 @@ 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" @@ -7982,12 +7606,6 @@ 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" @@ -8006,12 +7624,6 @@ 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" @@ -8030,12 +7642,6 @@ 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" @@ -8277,7 +7883,7 @@ dependencies = [ "bindgen", "cmake", "pkg-config", - "reqwest 0.12.28", + "reqwest", "serde_json", "zip", ] diff --git a/crates/openshell-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml index cef3e67f88..98a5d5b25e 100644 --- a/crates/openshell-driver-vm/Cargo.toml +++ b/crates/openshell-driver-vm/Cargo.toml @@ -38,13 +38,10 @@ miette = { workspace = true } url = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -oci-client = "0.16" libc = "0.2" rustix = { workspace = true } libloading = "0.8" tar = "0.4" -flate2 = "1" -sha2 = "0.10" zstd = "0.13" [features] diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index 724bde06cb..e437074925 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -35,7 +35,7 @@ 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/umoci and `mise run vm:supervisor` builds the bundled guest supervisor. Subsequent runs are cached. +First run takes a few minutes while `mise run vm:setup` stages libkrun/libkrunfw/gvproxy/umoci, builds `libopenshell_containerd_shim` from `goshim/`, and `mise run vm:supervisor` builds the bundled guest supervisor. Subsequent runs are cached. By default `mise run gateway:vm`: @@ -170,16 +170,32 @@ background. The driver publishes platform events for image resolution, cache hits/misses, layer pulls, rootfs preparation, overlay creation, and VM launcher startup so the CLI can show progress through the existing sandbox watch stream. +Registry image resolution and pulling (manifest/config/layer fetch, digest +verification, OCI Image Layout construction) are implemented in Go on top of +containerd's client libraries (`core/remotes/docker`, `pkg/archive`) rather +than a hand-rolled registry client, in +[`goshim/`](goshim/). That Go code is built as a small cgo shared library +(`libopenshell_containerd_shim`) and loaded at runtime the same way the +driver loads libkrun; there is no live `containerd` daemon involved. See +[`src/containerd_shim.rs`](src/containerd_shim.rs) for the Rust side. + The VM driver keeps two image caches. The bootstrap cache is a controlled -`rootfs.ext4` used to boot the guest init and OpenShell supervisor. The prepared -image cache is used when the requested sandbox image differs from the bootstrap -image: the host downloads registry layers into a valid OCI layout, attaches that -payload to a temporary bootstrap VM, and guest init runs `umoci raw unpack` onto -Linux-owned ext4 storage. The resulting disk is cached under -`/images//rootfs.ext4` and attached read-only to later -sandboxes. Local Docker images are still exported as rootfs tar archives and -prepared inside the bootstrap VM. Set `OPENSHELL_VM_IMAGE_PULL_CONCURRENCY` to -tune registry layer download parallelism (default `4`, maximum `16`). +`rootfs.ext4` used to boot the guest init and OpenShell supervisor: the shim +pulls the image and applies its layers directly onto the host-side rootfs +directory before it's formatted into an ext4 disk. The prepared image cache +is used when the requested sandbox image differs from the bootstrap image: +the shim pulls registry layers into a valid OCI layout, the host attaches +that payload to a temporary bootstrap VM, and guest init runs +`umoci raw unpack` onto Linux-owned ext4 storage. The resulting disk is +cached under `/images//rootfs.ext4` and attached +read-only to later sandboxes. Local Docker images are still exported as +rootfs tar archives and prepared inside the bootstrap VM. + +The host only unpacks layers directly for the bootstrap image, because that +rootfs boots the guest and no VM exists yet to unpack it inside of. Every +other (user-requested, less trusted) registry image is unpacked in the +prepared-image path above, guest-side via `umoci`, so archive extraction of +that content never runs against host-owned storage. Both caches are scoped by source image identity and OpenShell version, so an OpenShell upgrade builds a fresh guest rootfs instead of reusing one with an old embedded supervisor. @@ -233,6 +249,7 @@ Each table is created atomically via `nft -f` on VM start and torn down atomical - macOS on Apple Silicon, or Linux on aarch64/x86_64 with KVM - Rust toolchain +- Go toolchain (`mise` installs it; see `[tools]` in the repo's `mise.toml`) to build `goshim/` into `libopenshell_containerd_shim` via cgo. `CGO_ENABLED=1` and a working C compiler are required (already present alongside the Rust toolchain). - e2fsprogs (`mke2fs` or `mkfs.ext4`, plus `debugfs`) for root and overlay disk image creation and QEMU environment injection - 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) diff --git a/crates/openshell-driver-vm/build.rs b/crates/openshell-driver-vm/build.rs index 1763590545..2d2a43c735 100644 --- a/crates/openshell-driver-vm/build.rs +++ b/crates/openshell-driver-vm/build.rs @@ -22,6 +22,8 @@ fn main() { "gvproxy.zst", "openshell-sandbox.zst", "umoci.zst", + "libopenshell_containerd_shim.so.zst", + "libopenshell_containerd_shim.dylib.zst", ] { println!("cargo:rerun-if-changed={dir}/{name}"); } @@ -31,14 +33,28 @@ fn main() { let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); - let (libkrun_name, libkrunfw_name) = match target_os.as_str() { - "macos" => ("libkrun.dylib", "libkrunfw.5.dylib"), - "linux" => ("libkrun.so", "libkrunfw.so.5"), + let (libkrun_name, libkrunfw_name, containerd_shim_name) = match target_os.as_str() { + "macos" => ( + "libkrun.dylib", + "libkrunfw.5.dylib", + "libopenshell_containerd_shim.dylib", + ), + "linux" => ( + "libkrun.so", + "libkrunfw.so.5", + "libopenshell_containerd_shim.so", + ), _ => { println!("cargo:warning=VM runtime not available for {target_os}-{target_arch}"); generate_stub_resources( &out_dir, - &["libkrun", "libkrunfw", "openshell-sandbox.zst", "umoci.zst"], + &[ + "libkrun", + "libkrunfw", + "openshell-sandbox.zst", + "umoci.zst", + "libopenshell_containerd_shim", + ], ); return; } @@ -57,6 +73,7 @@ fn main() { "gvproxy.zst", "openshell-sandbox.zst", "umoci.zst", + &format!("{containerd_shim_name}.zst"), ], ); return; @@ -76,6 +93,7 @@ fn main() { "gvproxy.zst", "openshell-sandbox.zst", "umoci.zst", + &format!("{containerd_shim_name}.zst"), ], ); return; @@ -93,6 +111,10 @@ fn main() { "openshell-sandbox.zst".to_string(), ), ("umoci.zst".to_string(), "umoci.zst".to_string()), + ( + format!("{containerd_shim_name}.zst"), + format!("{containerd_shim_name}.zst"), + ), ]; let mut all_found = true; @@ -136,6 +158,7 @@ fn main() { "gvproxy.zst", "openshell-sandbox.zst", "umoci.zst", + &format!("{containerd_shim_name}.zst"), ], ); } diff --git a/crates/openshell-driver-vm/goshim/auth.go b/crates/openshell-driver-vm/goshim/auth.go new file mode 100644 index 0000000000..99f29b8831 --- /dev/null +++ b/crates/openshell-driver-vm/goshim/auth.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/containerd/containerd/v2/core/remotes/docker" +) + +// Environment variables consulted for registry auth. These mirror the +// variables historically read by the Rust oci-client based pull path so +// operator configuration does not need to change. +const ( + envRegistryUsername = "OPENSHELL_REGISTRY_USERNAME" + envRegistryToken = "OPENSHELL_REGISTRY_TOKEN" +) + +// newResolver builds a docker.Resolver configured with credentials from the +// environment, matching the historical (anonymous by default, optional +// username/token, GHCR "__token__" convenience default) auth behavior. +func newResolver() docker.ResolverOptions { + authorizer := docker.NewDockerAuthorizer(docker.WithAuthCreds(registryCreds)) + return docker.ResolverOptions{ + Hosts: docker.ConfigureDefaultRegistries(docker.WithAuthorizer(authorizer)), + } +} + +func registryCreds(host string) (string, string, error) { + token := strings.TrimSpace(os.Getenv(envRegistryToken)) + if token == "" { + return "", "", nil + } + + username := strings.TrimSpace(os.Getenv(envRegistryUsername)) + if username != "" { + return username, token, nil + } + if isGHCRHost(host) { + return "__token__", token, nil + } + return "", "", fmt.Errorf( + "%s is required when %s is set for non-GHCR registries", + envRegistryUsername, envRegistryToken, + ) +} + +func isGHCRHost(host string) bool { + return strings.EqualFold(host, "ghcr.io") +} diff --git a/crates/openshell-driver-vm/goshim/auth_test.go b/crates/openshell-driver-vm/goshim/auth_test.go new file mode 100644 index 0000000000..0db37a1b13 --- /dev/null +++ b/crates/openshell-driver-vm/goshim/auth_test.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import "testing" + +func TestRegistryCredsAnonymousWithoutToken(t *testing.T) { + t.Setenv(envRegistryToken, "") + t.Setenv(envRegistryUsername, "") + + username, secret, err := registryCreds("docker.io") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if username != "" || secret != "" { + t.Fatalf("expected anonymous creds, got username=%q secret=%q", username, secret) + } +} + +func TestRegistryCredsUsesExplicitUsername(t *testing.T) { + t.Setenv(envRegistryToken, "tok") + t.Setenv(envRegistryUsername, "alice") + + username, secret, err := registryCreds("example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if username != "alice" || secret != "tok" { + t.Fatalf("got username=%q secret=%q", username, secret) + } +} + +func TestRegistryCredsDefaultsGHCRUsername(t *testing.T) { + t.Setenv(envRegistryToken, "tok") + t.Setenv(envRegistryUsername, "") + + username, secret, err := registryCreds("ghcr.io") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if username != "__token__" || secret != "tok" { + t.Fatalf("got username=%q secret=%q", username, secret) + } +} + +func TestRegistryCredsRequiresUsernameForNonGHCR(t *testing.T) { + t.Setenv(envRegistryToken, "tok") + t.Setenv(envRegistryUsername, "") + + _, _, err := registryCreds("example.com") + if err == nil { + t.Fatal("expected error when username is missing for a non-GHCR registry") + } +} diff --git a/crates/openshell-driver-vm/goshim/go.mod b/crates/openshell-driver-vm/goshim/go.mod new file mode 100644 index 0000000000..20901120bb --- /dev/null +++ b/crates/openshell-driver-vm/goshim/go.mod @@ -0,0 +1,45 @@ +module github.com/docker/OpenShell/crates/openshell-driver-vm/goshim + +go 1.26.3 + +require ( + github.com/containerd/containerd/v2 v2.3.3 + github.com/containerd/platforms v1.0.0-rc.4 + github.com/opencontainers/go-digest v1.0.0 + github.com/opencontainers/image-spec v1.1.1 +) + +require ( + github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect + github.com/Microsoft/hcsshim v0.15.0-rc.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/cgroups/v3 v3.1.3 // indirect + github.com/containerd/continuity v0.5.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/ttrpc v1.2.8 // indirect + github.com/containerd/typeurl/v2 v2.2.3 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/moby/locker v1.0.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect +) diff --git a/crates/openshell-driver-vm/goshim/go.sum b/crates/openshell-driver-vm/goshim/go.sum new file mode 100644 index 0000000000..46edb5b97f --- /dev/null +++ b/crates/openshell-driver-vm/goshim/go.sum @@ -0,0 +1,210 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= +github.com/Microsoft/hcsshim v0.15.0-rc.1 h1:FbbwtQmiD+BVHynGkx5S65JkLyhkEiiTP8nrpmg2SZw= +github.com/Microsoft/hcsshim v0.15.0-rc.1/go.mod h1:HWvvUPIy9HF6LotILj1G4VyS065rcLQ6tqj6tMUdOfI= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6dyLYXQ= +github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= +github.com/containerd/containerd/api v1.11.1 h1:h8nfoDW9+fNsC/9TwiAHj8B1GzXKtR4eFtkhi/X5RLU= +github.com/containerd/containerd/api v1.11.1/go.mod h1:CaQFRu+N1MtbgL6JDOJLUB1hCKESU1lD6MuTJhgtdlw= +github.com/containerd/containerd/v2 v2.3.3 h1:MUNBVVBTBpPll7KPh5GTvkC3cfG03PQLAHVdsUoue9k= +github.com/containerd/containerd/v2 v2.3.3/go.mod h1:rHKGm3VW6wNrINb3x8mNT+w7qYXFVElTt/8HTuxVhD4= +github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg= +github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v1.0.0-rc.4 h1:M42JrUT4zfZTqtkUwkr0GzmUWbfyO5VO0Q5b3op97T4= +github.com/containerd/platforms v1.0.0-rc.4/go.mod h1:lKlMXyLybmBedS/JJm11uDofzI8L2v0J2ZbYvNsbq1A= +github.com/containerd/ttrpc v1.2.8 h1:xbVu6D4qF2jihdh9rDVOKqUMiFBQk6YctTdo1zk087Y= +github.com/containerd/ttrpc v1.2.8/go.mod h1:wyZW2K79t4Hfcxl+GUvkZqRBzJlqFFvgEeeWXa42tyE= +github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40= +github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= +github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/crates/openshell-driver-vm/goshim/pull.go b/crates/openshell-driver-vm/goshim/pull.go new file mode 100644 index 0000000000..6a1c9ee3f8 --- /dev/null +++ b/crates/openshell-driver-vm/goshim/pull.go @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/remotes" + "github.com/containerd/containerd/v2/core/remotes/docker" + "github.com/containerd/containerd/v2/plugins/content/local" + "github.com/containerd/platforms" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// ociLayoutRefName is the "org.opencontainers.image.ref.name" annotation +// value written into index.json for every pulled image. The guest-side +// `openshell-vm-sandbox-init.sh` unpacks images via +// `umoci raw unpack --image "$dir:openshell"`, so this value must stay in +// sync with that script. +const ociLayoutRefName = "openshell" + +func resolveDigest(ctx context.Context, imageRef, platformOS, platformArch string) (string, error) { + resolver := docker.NewResolver(newResolver()) + _, desc, err := resolver.Resolve(ctx, imageRef) + if err != nil { + return "", wrapf(err, "resolve image %q", imageRef) + } + + if !images.IsIndexType(desc.MediaType) { + return desc.Digest.String(), nil + } + + // The top-level descriptor is a multi-platform index; resolving the + // per-platform manifest digest (rather than the index digest) keeps + // cache identity aligned with what actually gets unpacked, matching + // the previous oci-client based behavior. + fetcher, err := resolver.Fetcher(ctx, imageRef) + if err != nil { + return "", wrapf(err, "create fetcher for %q", imageRef) + } + rc, err := fetcher.Fetch(ctx, desc) + if err != nil { + return "", wrapf(err, "fetch index for %q", imageRef) + } + indexBytes, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil { + return "", wrapf(err, "read index for %q", imageRef) + } + platform := platforms.OnlyStrict(ocispec.Platform{OS: platformOS, Architecture: platformArch}) + manifestDesc, err := selectManifestForPlatform(indexBytes, platform) + if err != nil { + return "", wrapf(err, "select platform manifest for %q", imageRef) + } + return manifestDesc.Digest.String(), nil +} + +func pullImage(ctx context.Context, imageRef, destLayoutDir, platformOS, platformArch string) error { + resolver := docker.NewResolver(newResolver()) + + name, desc, err := resolver.Resolve(ctx, imageRef) + if err != nil { + return wrapf(err, "resolve image %q", imageRef) + } + fetcher, err := resolver.Fetcher(ctx, name) + if err != nil { + return wrapf(err, "create fetcher for %q", imageRef) + } + + if err := os.MkdirAll(destLayoutDir, 0o755); err != nil { + return wrapf(err, "create OCI layout dir %q", destLayoutDir) + } + store, err := local.NewStore(destLayoutDir) + if err != nil { + return wrapf(err, "open content store %q", destLayoutDir) + } + + platform := platforms.OnlyStrict(ocispec.Platform{OS: platformOS, Architecture: platformArch}) + + // Recursively fetch the descriptor tree (index -> manifest -> config + + // layers), restricted to the requested platform so multi-arch images + // only download the bytes this sandbox actually needs. Digest + // verification happens automatically: the content store's writer + // commits each blob against its expected descriptor digest. + handler := remotes.FilterManifestByPlatformHandler( + images.Handlers( + remotes.FetchHandler(store, fetcher), + images.ChildrenHandler(store), + ), + platform, + ) + if err := images.Dispatch(ctx, handler, nil, desc); err != nil { + return wrapf(err, "pull image content for %q", imageRef) + } + + manifestDesc, err := resolveManifestDescriptor(ctx, store, desc, platform) + if err != nil { + return wrapf(err, "resolve manifest for %q", imageRef) + } + + if err := writeOCILayout(destLayoutDir, manifestDesc); err != nil { + return wrapf(err, "write OCI layout at %q", destLayoutDir) + } + + // local.NewStore scratch state; harmless to leave but unnecessary in a + // layout that gets copied verbatim into a guest disk image. + _ = os.RemoveAll(filepath.Join(destLayoutDir, "ingest")) + return nil +} + +// resolveManifestDescriptor returns the descriptor of the single-platform +// manifest to record as the sole entry of index.json. If desc already +// refers to a manifest (not a multi-platform index), it is returned as-is. +func resolveManifestDescriptor( + ctx context.Context, + provider content.Provider, + desc ocispec.Descriptor, + platform platforms.MatchComparer, +) (ocispec.Descriptor, error) { + if !images.IsIndexType(desc.MediaType) { + return desc, nil + } + + indexBytes, err := content.ReadBlob(ctx, provider, desc) + if err != nil { + return ocispec.Descriptor{}, wrapf(err, "read index blob") + } + return selectManifestForPlatform(indexBytes, platform) +} + +func selectManifestForPlatform(indexBytes []byte, matcher platforms.MatchComparer) (ocispec.Descriptor, error) { + var index ocispec.Index + if err := json.Unmarshal(indexBytes, &index); err != nil { + return ocispec.Descriptor{}, wrapf(err, "decode image index") + } + + for _, candidate := range index.Manifests { + if candidate.Platform == nil || matcher.Match(*candidate.Platform) { + return candidate, nil + } + } + return ocispec.Descriptor{}, fmt.Errorf("no manifest in image index matches the requested platform") +} + +func writeOCILayout(layoutDir string, manifestDesc ocispec.Descriptor) error { + layout := ocispec.ImageLayout{Version: "1.0.0"} + layoutBytes, err := json.Marshal(layout) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(layoutDir, "oci-layout"), layoutBytes, 0o644); err != nil { + return err + } + + if manifestDesc.Annotations == nil { + manifestDesc.Annotations = map[string]string{} + } else { + // Copy before mutating; the caller's descriptor may be reused. + annotations := make(map[string]string, len(manifestDesc.Annotations)+1) + for k, v := range manifestDesc.Annotations { + annotations[k] = v + } + manifestDesc.Annotations = annotations + } + manifestDesc.Annotations[ocispec.AnnotationRefName] = ociLayoutRefName + + index := ocispec.Index{ + Versioned: struct { + SchemaVersion int `json:"schemaVersion"` + }{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{manifestDesc}, + } + indexBytes, err := json.MarshalIndent(index, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(layoutDir, "index.json"), indexBytes, 0o644) +} diff --git a/crates/openshell-driver-vm/goshim/pull_test.go b/crates/openshell-driver-vm/goshim/pull_test.go new file mode 100644 index 0000000000..2d31ee2716 --- /dev/null +++ b/crates/openshell-driver-vm/goshim/pull_test.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/containerd/platforms" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +func TestSelectManifestForPlatformPicksExactMatch(t *testing.T) { + index := ocispec.Index{ + Manifests: []ocispec.Descriptor{ + {Digest: "sha256:amd64", Platform: &ocispec.Platform{OS: "linux", Architecture: "amd64"}}, + {Digest: "sha256:arm64", Platform: &ocispec.Platform{OS: "linux", Architecture: "arm64", Variant: "v8"}}, + }, + } + indexBytes, err := json.Marshal(index) + if err != nil { + t.Fatalf("marshal index: %v", err) + } + + matcher := platforms.OnlyStrict(ocispec.Platform{OS: "linux", Architecture: "arm64"}) + desc, err := selectManifestForPlatform(indexBytes, matcher) + if err != nil { + t.Fatalf("select manifest: %v", err) + } + if desc.Digest != "sha256:arm64" { + t.Fatalf("expected arm64 manifest, got %s", desc.Digest) + } +} + +func TestSelectManifestForPlatformRejectsNoMatch(t *testing.T) { + index := ocispec.Index{ + Manifests: []ocispec.Descriptor{ + {Digest: "sha256:amd64", Platform: &ocispec.Platform{OS: "linux", Architecture: "amd64"}}, + }, + } + indexBytes, err := json.Marshal(index) + if err != nil { + t.Fatalf("marshal index: %v", err) + } + + matcher := platforms.OnlyStrict(ocispec.Platform{OS: "linux", Architecture: "arm64"}) + if _, err := selectManifestForPlatform(indexBytes, matcher); err == nil { + t.Fatal("expected an error when no manifest matches the platform") + } +} + +func TestWriteOCILayoutSetsRefNameAnnotation(t *testing.T) { + dir := t.TempDir() + desc := ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: "sha256:deadbeef", + Size: 42, + } + + if err := writeOCILayout(dir, desc); err != nil { + t.Fatalf("write OCI layout: %v", err) + } + + layoutBytes, err := os.ReadFile(filepath.Join(dir, "oci-layout")) + if err != nil { + t.Fatalf("read oci-layout: %v", err) + } + var layout ocispec.ImageLayout + if err := json.Unmarshal(layoutBytes, &layout); err != nil { + t.Fatalf("decode oci-layout: %v", err) + } + if layout.Version != "1.0.0" { + t.Fatalf("unexpected layout version %q", layout.Version) + } + + indexBytes, err := os.ReadFile(filepath.Join(dir, "index.json")) + if err != nil { + t.Fatalf("read index.json: %v", err) + } + var index ocispec.Index + if err := json.Unmarshal(indexBytes, &index); err != nil { + t.Fatalf("decode index.json: %v", err) + } + if len(index.Manifests) != 1 { + t.Fatalf("expected exactly one manifest, got %d", len(index.Manifests)) + } + if got := index.Manifests[0].Annotations[ocispec.AnnotationRefName]; got != ociLayoutRefName { + t.Fatalf("expected ref.name annotation %q, got %q", ociLayoutRefName, got) + } +} diff --git a/crates/openshell-driver-vm/goshim/shim.go b/crates/openshell-driver-vm/goshim/shim.go new file mode 100644 index 0000000000..f68880dac9 --- /dev/null +++ b/crates/openshell-driver-vm/goshim/shim.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package main implements a cgo shared library that lets the openshell-vm +// compute driver (Rust) delegate OCI registry image resolution/pull and +// layer unpacking to containerd's Go client libraries instead of a +// hand-rolled registry client and tar merge routine. +// +// This binary is never run directly. It is built with +// `go build -buildmode=c-shared` and dynamically loaded by +// crates/openshell-driver-vm/src/containerd_shim.rs via libloading, the +// same way the driver loads libkrun. Every exported function returns NULL +// on success or a heap-allocated C string describing the error; callers +// must free non-NULL results with ContainerdFreeString. +package main + +/* +#include +*/ +import "C" + +import ( + "context" + "fmt" + "time" + "unsafe" +) + +// requestTimeout bounds a single pull/resolve call. Sandbox image pulls can +// legitimately take a while on a slow link, so this is generous rather than +// tight; the Rust caller runs it in a blocking task and has its own +// higher-level timeouts via the gRPC request lifecycle. +const requestTimeout = 30 * time.Minute + +func main() {} // required by -buildmode=c-shared, never executed. + +//export ContainerdFreeString +func ContainerdFreeString(s *C.char) { + if s != nil { + C.free(unsafe.Pointer(s)) + } +} + +// ContainerdResolveDigest resolves imageRef against its registry and writes +// the resolved manifest/index digest (e.g. "sha256:...") to *outDigest +// without downloading any layer content. Used to cheaply check the driver's +// on-disk image cache before committing to a full pull. +// +//export ContainerdResolveDigest +func ContainerdResolveDigest(cImageRef, cPlatformOS, cPlatformArch *C.char, outDigest **C.char) *C.char { + if outDigest == nil { + return errString("outDigest must not be null") + } + *outDigest = nil + + imageRef := C.GoString(cImageRef) + platformOS := C.GoString(cPlatformOS) + platformArch := C.GoString(cPlatformArch) + + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + + digest, err := resolveDigest(ctx, imageRef, platformOS, platformArch) + if err != nil { + return errString(err.Error()) + } + *outDigest = C.CString(digest) + return nil +} + +// ContainerdPullImage resolves imageRef, downloads its manifest/config/layer +// blobs for the requested platform, and writes them out as a standard OCI +// Image Layout (oci-layout, index.json, blobs/sha256/*) rooted at +// destLayoutDir. The index.json manifest entry carries the +// "org.opencontainers.image.ref.name" annotation used by the guest-side +// `umoci raw unpack --image :` step, so callers must keep this in +// sync with ociLayoutRefName below and the guest init script. +// +//export ContainerdPullImage +func ContainerdPullImage(cImageRef, cDestLayoutDir, cPlatformOS, cPlatformArch *C.char) *C.char { + imageRef := C.GoString(cImageRef) + destLayoutDir := C.GoString(cDestLayoutDir) + platformOS := C.GoString(cPlatformOS) + platformArch := C.GoString(cPlatformArch) + + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + + if err := pullImage(ctx, imageRef, destLayoutDir, platformOS, platformArch); err != nil { + return errString(err.Error()) + } + return nil +} + +// ContainerdUnpackLayout applies every layer of the single-manifest OCI +// Image Layout at layoutDir onto destRootfsDir, in order, using containerd's +// archive.Apply (correct OCI whiteout/opaque-directory semantics, xattrs, +// and device/fifo entries). destRootfsDir is created if missing; existing +// contents are merged into (not replaced by) the unpack, matching the +// semantics of applying layers onto an already-prepared base directory. +// +//export ContainerdUnpackLayout +func ContainerdUnpackLayout(cLayoutDir, cDestRootfsDir *C.char) *C.char { + layoutDir := C.GoString(cLayoutDir) + destRootfsDir := C.GoString(cDestRootfsDir) + + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + + if err := unpackLayout(ctx, layoutDir, destRootfsDir); err != nil { + return errString(err.Error()) + } + return nil +} + +func errString(msg string) *C.char { + return C.CString(msg) +} + +// wrapf is a small helper to keep error construction terse in the pull/unpack +// implementation files. +func wrapf(err error, format string, args ...any) error { + return fmt.Errorf(format+": %w", append(args, err)...) +} diff --git a/crates/openshell-driver-vm/goshim/unpack.go b/crates/openshell-driver-vm/goshim/unpack.go new file mode 100644 index 0000000000..26feac2728 --- /dev/null +++ b/crates/openshell-driver-vm/goshim/unpack.go @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/pkg/archive" + "github.com/containerd/containerd/v2/pkg/archive/compression" + "github.com/containerd/containerd/v2/plugins/content/local" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// unpackLayout applies every layer described by the OCI Image Layout at +// layoutDir onto destRootfsDir, in manifest order, using containerd's +// archive.Apply. This replaces a hand-rolled directory merge that only +// understood plain files/dirs/symlinks and OCI whiteouts; archive.Apply +// additionally handles opaque directories, hardlinks, device nodes, FIFOs, +// and xattrs the way any other OCI-compliant unpacker would. +func unpackLayout(ctx context.Context, layoutDir, destRootfsDir string) error { + store, err := local.NewStore(layoutDir) + if err != nil { + return wrapf(err, "open content store %q", layoutDir) + } + + indexBytes, err := os.ReadFile(filepath.Join(layoutDir, "index.json")) + if err != nil { + return wrapf(err, "read index.json in %q", layoutDir) + } + var index ocispec.Index + if err := json.Unmarshal(indexBytes, &index); err != nil { + return wrapf(err, "decode index.json in %q", layoutDir) + } + if len(index.Manifests) == 0 { + return fmt.Errorf("OCI layout %q has no manifests", layoutDir) + } + + manifestBytes, err := content.ReadBlob(ctx, store, index.Manifests[0]) + if err != nil { + return wrapf(err, "read manifest blob") + } + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return wrapf(err, "decode manifest") + } + + if err := os.MkdirAll(destRootfsDir, 0o755); err != nil { + return wrapf(err, "create rootfs dir %q", destRootfsDir) + } + + for i, layer := range manifest.Layers { + if err := applyLayer(ctx, store, layer, destRootfsDir); err != nil { + return wrapf(err, "apply layer %d/%d (%s)", i+1, len(manifest.Layers), layer.Digest) + } + } + return nil +} + +func applyLayer(ctx context.Context, provider content.Provider, layer ocispec.Descriptor, dest string) error { + ra, err := provider.ReaderAt(ctx, layer) + if err != nil { + return wrapf(err, "open layer reader") + } + defer ra.Close() + + decompressed, err := compression.DecompressStream(content.NewReader(ra)) + if err != nil { + return wrapf(err, "decompress layer") + } + defer decompressed.Close() + + // Sandbox rootfs ownership is normalized separately by the guest-side + // init script and host-side debugfs pass; preserve the layer's own + // uid/gid here rather than remapping to the calling (host) user. + if _, err := archive.Apply(ctx, dest, decompressed, archive.WithNoSameOwner()); err != nil { + return wrapf(err, "apply layer tar") + } + return nil +} diff --git a/crates/openshell-driver-vm/goshim/unpack_test.go b/crates/openshell-driver-vm/goshim/unpack_test.go new file mode 100644 index 0000000000..12b28a36b7 --- /dev/null +++ b/crates/openshell-driver-vm/goshim/unpack_test.go @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/plugins/content/local" + digest "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +func writeBlob(t *testing.T, ctx context.Context, store content.Store, mediaType string, data []byte) ocispec.Descriptor { + t.Helper() + dgst := digest.FromBytes(data) + desc := ocispec.Descriptor{MediaType: mediaType, Digest: dgst, Size: int64(len(data))} + ref := "test-" + dgst.String() + if err := content.WriteBlob(ctx, store, ref, bytes.NewReader(data), desc); err != nil { + t.Fatalf("write blob %s: %v", mediaType, err) + } + return desc +} + +// buildLayoutWithSingleFileLayer writes a minimal single-manifest OCI Image +// Layout at dir whose one layer contains a single regular file, exercising +// the same layout shape pullImage produces without touching the network. +func buildLayoutWithSingleFileLayer(t *testing.T, dir string) { + t.Helper() + ctx := context.Background() + + store, err := local.NewStore(dir) + if err != nil { + t.Fatalf("open content store: %v", err) + } + + var layerBuf bytes.Buffer + gz := gzip.NewWriter(&layerBuf) + tw := tar.NewWriter(gz) + contents := []byte("hello from a layer\n") + if err := tw.WriteHeader(&tar.Header{ + Name: "hello.txt", + Mode: 0o644, + Size: int64(len(contents)), + }); err != nil { + t.Fatalf("write tar header: %v", err) + } + if _, err := tw.Write(contents); err != nil { + t.Fatalf("write tar contents: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar writer: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip writer: %v", err) + } + layerDesc := writeBlob(t, ctx, store, ocispec.MediaTypeImageLayerGzip, layerBuf.Bytes()) + + configDesc := writeBlob(t, ctx, store, ocispec.MediaTypeImageConfig, []byte(`{}`)) + + manifest := ocispec.Manifest{ + MediaType: ocispec.MediaTypeImageManifest, + Config: configDesc, + Layers: []ocispec.Descriptor{layerDesc}, + } + manifestBytes, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + manifestDesc := writeBlob(t, ctx, store, ocispec.MediaTypeImageManifest, manifestBytes) + + if err := writeOCILayout(dir, manifestDesc); err != nil { + t.Fatalf("write OCI layout: %v", err) + } +} + +func TestUnpackLayoutAppliesLayerOntoRootfs(t *testing.T) { + layoutDir := t.TempDir() + rootfsDir := filepath.Join(t.TempDir(), "rootfs") + buildLayoutWithSingleFileLayer(t, layoutDir) + + if err := unpackLayout(context.Background(), layoutDir, rootfsDir); err != nil { + t.Fatalf("unpack layout: %v", err) + } + + got, err := os.ReadFile(filepath.Join(rootfsDir, "hello.txt")) + if err != nil { + t.Fatalf("read unpacked file: %v", err) + } + if string(got) != "hello from a layer\n" { + t.Fatalf("unexpected unpacked contents: %q", got) + } +} + +func TestUnpackLayoutRejectsEmptyIndex(t *testing.T) { + layoutDir := t.TempDir() + if err := os.WriteFile(filepath.Join(layoutDir, "index.json"), []byte(`{"schemaVersion":2,"manifests":[]}`), 0o644); err != nil { + t.Fatalf("write index.json: %v", err) + } + + err := unpackLayout(context.Background(), layoutDir, filepath.Join(t.TempDir(), "rootfs")) + if err == nil { + t.Fatal("expected an error for a layout with no manifests") + } +} diff --git a/crates/openshell-driver-vm/src/containerd_shim.rs b/crates/openshell-driver-vm/src/containerd_shim.rs new file mode 100644 index 0000000000..bafa651737 --- /dev/null +++ b/crates/openshell-driver-vm/src/containerd_shim.rs @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Runtime-loaded bindings for the `openshell-containerd-shim` cgo library. +//! +//! Registry image resolution/pull and OCI layer unpacking are implemented +//! in Go (`crates/openshell-driver-vm/goshim/`) on top of containerd's +//! client libraries (`core/remotes/docker`, `pkg/archive`) rather than +//! hand-rolled in Rust. That Go code is compiled with +//! `go build -buildmode=c-shared` into a small shared library and loaded +//! dynamically here via `libloading`, the same way this crate loads +//! libkrun in `ffi.rs`. There is no live `containerd` daemon involved: +//! the shim links containerd's content/remotes/archive packages directly +//! and talks to registries itself. +//! +//! Every exported Go function returns `NULL` on success or a heap-allocated +//! C string describing the error on failure; failure strings are freed via +//! `ContainerdFreeString` immediately after being copied into a Rust +//! `String`. + +#![allow(unsafe_code)] + +use std::ffi::{CStr, CString}; +use std::path::Path; +use std::ptr; +use std::sync::OnceLock; + +use libc::c_char; +use libloading::Library; + +pub fn required_shim_lib_name() -> &'static str { + #[cfg(target_os = "macos")] + { + "libopenshell_containerd_shim.dylib" + } + #[cfg(not(target_os = "macos"))] + { + "libopenshell_containerd_shim.so" + } +} + +type ContainerdFreeString = unsafe extern "C" fn(*mut c_char); +type ContainerdResolveDigest = unsafe extern "C" fn( + image_ref: *const c_char, + platform_os: *const c_char, + platform_arch: *const c_char, + out_digest: *mut *mut c_char, +) -> *mut c_char; +type ContainerdPullImage = unsafe extern "C" fn( + image_ref: *const c_char, + dest_layout_dir: *const c_char, + platform_os: *const c_char, + platform_arch: *const c_char, +) -> *mut c_char; +type ContainerdUnpackLayout = + unsafe extern "C" fn(layout_dir: *const c_char, dest_rootfs_dir: *const c_char) -> *mut c_char; + +struct ContainerdShim { + free_string: ContainerdFreeString, + resolve_digest: ContainerdResolveDigest, + pull_image: ContainerdPullImage, + unpack_layout: ContainerdUnpackLayout, +} + +static SHIM: OnceLock = OnceLock::new(); + +fn shim(runtime_dir: &Path) -> Result<&'static ContainerdShim, String> { + if let Some(shim) = SHIM.get() { + return Ok(shim); + } + + let loaded = ContainerdShim::load(runtime_dir)?; + let _ = SHIM.set(loaded); + Ok(SHIM.get().expect("containerd shim should be initialized")) +} + +impl ContainerdShim { + fn load(runtime_dir: &Path) -> Result { + let lib_path = runtime_dir.join(required_shim_lib_name()); + let library = Box::leak(Box::new(unsafe { + Library::new(&lib_path) + .map_err(|e| format!("load containerd shim from {}: {e}", lib_path.display()))? + })); + + Ok(Self { + free_string: load_symbol(library, b"ContainerdFreeString\0", &lib_path)?, + resolve_digest: load_symbol(library, b"ContainerdResolveDigest\0", &lib_path)?, + pull_image: load_symbol(library, b"ContainerdPullImage\0", &lib_path)?, + unpack_layout: load_symbol(library, b"ContainerdUnpackLayout\0", &lib_path)?, + }) + } +} + +fn load_symbol(library: &'static Library, name: &[u8], path: &Path) -> Result { + unsafe { + library.get::(name).map(|symbol| *symbol).map_err(|e| { + format!( + "load symbol {} from {}: {e}", + String::from_utf8_lossy(name).trim_end_matches('\0'), + path.display() + ) + }) + } +} + +fn to_cstring(value: &str, what: &str) -> Result { + CString::new(value).map_err(|e| format!("invalid {what} '{value}': {e}")) +} + +/// Take ownership of an error string returned by the shim, converting it to +/// an owned Rust `String` and freeing the underlying C allocation. +unsafe fn take_error(shim: &ContainerdShim, err: *mut c_char) -> Option { + if err.is_null() { + return None; + } + unsafe { + let message = CStr::from_ptr(err).to_string_lossy().into_owned(); + (shim.free_string)(err); + Some(message) + } +} + +/// Take ownership of a non-error output string returned via an out-param, +/// converting it to an owned Rust `String` and freeing the underlying C +/// allocation. Returns an error if the pointer is unexpectedly null. +unsafe fn take_output( + shim: &ContainerdShim, + out: *mut c_char, + what: &str, +) -> Result { + if out.is_null() { + return Err(format!("containerd shim returned no {what}")); + } + unsafe { + let value = CStr::from_ptr(out).to_string_lossy().into_owned(); + (shim.free_string)(out); + Ok(value) + } +} + +/// Resolve `image_ref` against its registry and return the digest of the +/// manifest that matches `platform_arch` (linux/), without +/// downloading any layer content. Used to check the driver's on-disk image +/// cache before committing to a full pull. +pub fn resolve_image_digest( + runtime_dir: &Path, + image_ref: &str, + platform_arch: &str, +) -> Result { + let shim = shim(runtime_dir)?; + let c_image_ref = to_cstring(image_ref, "image reference")?; + let c_os = to_cstring("linux", "platform os")?; + let c_arch = to_cstring(platform_arch, "platform arch")?; + + unsafe { + let mut out_digest: *mut c_char = ptr::null_mut(); + let err = (shim.resolve_digest)( + c_image_ref.as_ptr(), + c_os.as_ptr(), + c_arch.as_ptr(), + &raw mut out_digest, + ); + if let Some(message) = take_error(shim, err) { + return Err(format!( + "failed to resolve vm sandbox image '{image_ref}': {message}" + )); + } + take_output(shim, out_digest, "digest") + } +} + +/// Pull `image_ref`'s manifest/config/layer blobs for `platform_arch` +/// (linux/) into a standard OCI Image Layout at `dest_layout_dir`. +pub fn pull_image_to_oci_layout( + runtime_dir: &Path, + image_ref: &str, + dest_layout_dir: &Path, + platform_arch: &str, +) -> Result<(), String> { + let shim = shim(runtime_dir)?; + let c_image_ref = to_cstring(image_ref, "image reference")?; + let c_dest = to_cstring(&dest_layout_dir.to_string_lossy(), "OCI layout dir path")?; + let c_os = to_cstring("linux", "platform os")?; + let c_arch = to_cstring(platform_arch, "platform arch")?; + + unsafe { + let err = (shim.pull_image)( + c_image_ref.as_ptr(), + c_dest.as_ptr(), + c_os.as_ptr(), + c_arch.as_ptr(), + ); + if let Some(message) = take_error(shim, err) { + return Err(format!( + "failed to pull vm sandbox image '{image_ref}': {message}" + )); + } + } + Ok(()) +} + +/// Apply every layer of the single-manifest OCI Image Layout at +/// `layout_dir` onto `dest_rootfs_dir`, in order. +pub fn unpack_oci_layout_to_dir( + runtime_dir: &Path, + layout_dir: &Path, + dest_rootfs_dir: &Path, +) -> Result<(), String> { + let shim = shim(runtime_dir)?; + let c_layout = to_cstring(&layout_dir.to_string_lossy(), "OCI layout dir path")?; + let c_dest = to_cstring(&dest_rootfs_dir.to_string_lossy(), "rootfs dir path")?; + + unsafe { + let err = (shim.unpack_layout)(c_layout.as_ptr(), c_dest.as_ptr()); + if let Some(message) = take_error(shim, err) { + return Err(format!( + "failed to unpack vm sandbox image layers into {}: {message}", + dest_rootfs_dir.display() + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(label: &str) -> std::path::PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_nanos(); + let suffix = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "openshell-driver-vm-containerd-shim-test-{label}-{}-{nanos}-{suffix}", + std::process::id() + )) + } + + /// End-to-end check against the real containerd shim library and a real + /// public registry: resolve a digest, pull the image into an OCI Image + /// Layout, and unpack its layer onto disk. Requires the shim to be + /// built (`mise run vm:setup`, which now also runs + /// `build-containerd-shim.sh`) and network access to docker.io, so it + /// is `#[ignore]`d by default; run with `cargo test -- --ignored`. + #[test] + #[ignore = "requires network access and a built containerd shim library"] + fn resolve_pull_and_unpack_a_real_image() { + let runtime_dir = crate::runtime::configured_runtime_dir() + .expect("runtime dir should be extracted (run `mise run vm:setup` first)"); + + let digest = resolve_image_digest( + &runtime_dir, + "docker.io/library/busybox:latest", + linux_test_arch(), + ) + .expect("resolve digest"); + assert!(digest.starts_with("sha256:"), "digest was {digest}"); + + let layout_dir = unique_temp_dir("layout"); + let rootfs_dir = unique_temp_dir("rootfs"); + let _cleanup = CleanupDirs(vec![layout_dir.clone(), rootfs_dir.clone()]); + + pull_image_to_oci_layout( + &runtime_dir, + "docker.io/library/busybox:latest", + &layout_dir, + linux_test_arch(), + ) + .expect("pull image"); + assert!(layout_dir.join("index.json").is_file()); + assert!(layout_dir.join("oci-layout").is_file()); + + unpack_oci_layout_to_dir(&runtime_dir, &layout_dir, &rootfs_dir).expect("unpack layout"); + assert!(rootfs_dir.join("bin").is_dir()); + } + + fn linux_test_arch() -> &'static str { + match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + other => other, + } + } + + struct CleanupDirs(Vec); + + impl Drop for CleanupDirs { + fn drop(&mut self) { + for dir in &self.0 { + let _ = std::fs::remove_dir_all(dir); + } + } + } +} diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 90f5467927..9dfbe4f5ee 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use crate::containerd_shim; use crate::gpu::{ GpuInventory, SubnetAllocator, allocate_vsock_cid, mac_from_sandbox_id, tap_device_name, }; @@ -13,22 +14,15 @@ use crate::rootfs::{ extract_rootfs_archive_to, prepare_sandbox_rootfs_from_image_root, sandbox_guest_init_path, set_rootfs_image_file_mode, write_rootfs_image_file, }; -use crate::runtime::VmBackend; +use crate::runtime::{VmBackend, configured_runtime_dir}; use bollard::Docker; use bollard::errors::Error as BollardError; use bollard::models::ContainerCreateBody; use bollard::query_parameters::{CreateContainerOptionsBuilder, RemoveContainerOptionsBuilder}; -use flate2::read::GzDecoder; -use futures::{Stream, StreamExt, TryStreamExt}; +use futures::{Stream, StreamExt}; 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, OCI_IMAGE_MEDIA_TYPE, OciDescriptor, OciImageManifest, -}; -use oci_client::secrets::RegistryAuth; -use oci_client::{Reference, RegistryOperation}; use openshell_core::gpu::{ driver_gpu_requirements, effective_driver_gpu_count, validate_specific_gpu_device_request, }; @@ -52,10 +46,8 @@ use openshell_core::proto_struct::{ }; use openshell_vfio::SysfsRoot; use prost::Message; -use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::fs; -use std::io::Read; use std::net::Ipv4Addr; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; @@ -79,8 +71,6 @@ const WATCH_BUFFER: usize = 256; const DEFAULT_VCPUS: u8 = 2; const DEFAULT_MEM_MIB: u32 = 2048; const DEFAULT_OVERLAY_DISK_MIB: u64 = 4096; -const DEFAULT_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY: usize = 4; -const MAX_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY: usize = 16; #[derive(Debug, Clone, Default, serde::Deserialize)] #[serde(default, deny_unknown_fields)] @@ -164,7 +154,11 @@ const SANDBOX_OVERLAY_IMAGE: &str = "overlay.ext4"; const SANDBOX_REQUEST_FILE: &str = "sandbox.pb"; const GUEST_IMAGE_CONFIG_DIR: &str = "openshell-image"; const GUEST_IMAGE_OCI_LAYOUT_DIR: &str = "oci"; -const GUEST_IMAGE_OCI_REF: &str = "openshell"; +// The "org.opencontainers.image.ref.name" annotation the goshim pull writes +// into index.json (see goshim/pull.go's `ociLayoutRefName`) and the guest +// init script reads back via `umoci raw unpack --image "$dir:openshell"` +// (scripts/openshell-vm-sandbox-init.sh). Kept here only as documentation; +// update all three together if it ever changes. const IMAGE_EXPORT_ROOTFS_ARCHIVE: &str = "source-rootfs.tar"; const BOOTSTRAP_IMAGE_CACHE_LAYOUT_VERSION: &str = "sandbox-bootstrap-rootfs-ext4-v3"; const PREPARED_IMAGE_CACHE_LAYOUT_VERSION: &str = "sandbox-prepared-rootfs-ext4-umoci-v3"; @@ -1805,28 +1799,7 @@ impl VmDriver { } info!(image_ref = %image_ref, "vm driver: ensuring cached root disk image (registry)"); - let reference = parse_registry_reference(image_ref)?; - let client = registry_client(); - let auth = registry_auth(image_ref)?; - info!(image_ref = %image_ref, "vm driver: authenticating with registry"); - self.publish_vm_progress( - sandbox_id, - "AuthenticatingRegistry", - format!("Authenticating registry access for image \"{image_ref}\""), - HashMap::from([ - ("image_ref".to_string(), image_ref.to_string()), - ("image_source".to_string(), "registry".to_string()), - ]), - ); - 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}" - )) - })?; - info!(image_ref = %image_ref, "vm driver: fetching manifest digest"); + info!(image_ref = %image_ref, "vm driver: resolving manifest digest via containerd shim"); self.publish_vm_progress( sandbox_id, "FetchingManifest", @@ -1836,14 +1809,7 @@ impl VmDriver { ("image_source".to_string(), "registry".to_string()), ]), ); - let source_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 source_image_identity = self.resolve_registry_image_digest(image_ref).await?; info!( image_ref = %image_ref, image_identity = %source_image_identity, @@ -1936,20 +1902,86 @@ impl VmDriver { return Ok(image_identity); } - self.build_cached_registry_image_rootfs_image( - sandbox_id, - &client, - &reference, - &auth, - image_ref, - &image_identity, - ) - .await?; + self.build_cached_registry_image_rootfs_image(sandbox_id, image_ref, &image_identity) + .await?; self.publish_pulled_event(sandbox_id, image_ref, &image_path) .await; Ok(image_identity) } + /// Resolve `image_ref`'s manifest digest for the guest platform via the + /// containerd shim, without downloading any layer content. + async fn resolve_registry_image_digest(&self, image_ref: &str) -> Result { + let runtime_dir = configured_runtime_dir() + .map_err(|err| Status::internal(format!("resolve VM runtime dir failed: {err}")))?; + let image_ref_owned = image_ref.to_string(); + tokio::task::spawn_blocking(move || { + containerd_shim::resolve_image_digest(&runtime_dir, &image_ref_owned, linux_oci_arch()) + }) + .await + .map_err(|err| Status::internal(format!("image digest resolution panicked: {err}")))? + .map_err(Status::failed_precondition) + } + + /// Pull `image_ref`'s manifest/config/layer blobs for the guest platform + /// into a standard OCI Image Layout at `layout_dir` via the containerd + /// shim. Digest verification happens inside the shim. + async fn pull_registry_image_to_layout( + &self, + image_ref: &str, + layout_dir: &Path, + ) -> Result<(), Status> { + let runtime_dir = configured_runtime_dir() + .map_err(|err| Status::internal(format!("resolve VM runtime dir failed: {err}")))?; + let image_ref_owned = image_ref.to_string(); + let layout_dir_owned = layout_dir.to_path_buf(); + tokio::task::spawn_blocking(move || { + containerd_shim::pull_image_to_oci_layout( + &runtime_dir, + &image_ref_owned, + &layout_dir_owned, + linux_oci_arch(), + ) + }) + .await + .map_err(|err| Status::internal(format!("image pull panicked: {err}")))? + .map_err(Status::failed_precondition) + } + + /// Pull `image_ref` into `layout_dir` and apply every layer onto + /// `rootfs_dir`, both via the containerd shim. Used for the bootstrap + /// root disk, which is unpacked directly on the host rather than inside + /// a guest VM (unlike `ensure_prepared_registry_image_disk`'s + /// umoci-in-guest path): the bootstrap rootfs *is* the guest's own root + /// filesystem, so there is no VM yet for it to be unpacked inside of. + /// The prepared-image path unpacks in the guest instead so that + /// untrusted, user-requested registry layer content never runs an + /// archive extractor directly against host-owned storage. + async fn pull_and_unpack_registry_image( + &self, + image_ref: &str, + layout_dir: &Path, + rootfs_dir: &Path, + ) -> Result<(), Status> { + self.pull_registry_image_to_layout(image_ref, layout_dir) + .await?; + + let runtime_dir = configured_runtime_dir() + .map_err(|err| Status::internal(format!("resolve VM runtime dir failed: {err}")))?; + let layout_dir_owned = layout_dir.to_path_buf(); + let rootfs_dir_owned = rootfs_dir.to_path_buf(); + tokio::task::spawn_blocking(move || { + containerd_shim::unpack_oci_layout_to_dir( + &runtime_dir, + &layout_dir_owned, + &rootfs_dir_owned, + ) + }) + .await + .map_err(|err| Status::internal(format!("image unpack panicked: {err}")))? + .map_err(Status::failed_precondition) + } + async fn resolve_local_container_image( &self, image_ref: &str, @@ -2207,28 +2239,6 @@ impl VmDriver { image_ref: &str, bootstrap_root_disk: &Path, ) -> Result { - let reference = parse_registry_reference(image_ref)?; - let client = registry_client(); - let auth = registry_auth(image_ref)?; - - self.publish_vm_progress( - sandbox_id, - "AuthenticatingRegistry", - format!("Authenticating registry access for image \"{image_ref}\""), - HashMap::from([ - ("image_ref".to_string(), image_ref.to_string()), - ("image_source".to_string(), "registry".to_string()), - ]), - ); - 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}" - )) - })?; - self.publish_vm_progress( sandbox_id, "FetchingManifest", @@ -2238,14 +2248,7 @@ impl VmDriver { ("image_source".to_string(), "registry".to_string()), ]), ); - let source_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 source_image_identity = self.resolve_registry_image_digest(image_ref).await?; let cache_identity = prepared_image_cache_identity(&source_image_identity); let image_path = image_cache_rootfs_image(&self.config.state_dir, &cache_identity); @@ -2271,62 +2274,18 @@ impl VmDriver { self.reset_image_staging_dir(&staging_dir).await?; let layout_dir = staging_dir.join(GUEST_IMAGE_OCI_LAYOUT_DIR); - 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(oci_layout_blobs_dir(&layout_dir)) - .await - .map_err(|err| Status::internal(format!("create guest OCI layout failed: {err}")))?; - - download_registry_descriptor_blob_file( - &client, - &reference, - image_ref, - &layout_dir, - &manifest.config, - "config", - ) - .await?; - - let total_layers = manifest.layers.len(); - let total_bytes: i64 = manifest.layers.iter().map(|layer| layer.size.max(0)).sum(); - futures::stream::iter(manifest.layers.iter().cloned().enumerate()) - .map(|(index, layer)| { - let client = client.clone(); - let reference = reference.clone(); - let layout_dir = layout_dir.clone(); - async move { - self.publish_registry_layer_progress( - sandbox_id, - image_ref, - &layer, - index, - total_layers, - total_bytes, - ); - download_registry_descriptor_blob_file( - &client, - &reference, - image_ref, - &layout_dir, - &layer, - &format!("layer {}", index + 1), - ) - .await - } - }) - .buffer_unordered(registry_layer_download_concurrency()) - .try_collect::>() + self.publish_vm_progress( + sandbox_id, + "PullingImage", + format!("Pulling image \"{image_ref}\""), + HashMap::from([ + ("image_ref".to_string(), image_ref.to_string()), + ("image_source".to_string(), "registry".to_string()), + ]), + ); + self.pull_registry_image_to_layout(image_ref, &layout_dir) .await?; - write_oci_layout_for_manifest(&layout_dir, GUEST_IMAGE_OCI_REF, &manifest) - .map_err(|err| Status::internal(format!("write OCI layout failed: {err}")))?; - let payload = GuestImagePayload { image_ref: image_ref.to_string(), image_identity: cache_identity.clone(), @@ -2667,15 +2626,13 @@ impl VmDriver { async fn build_cached_registry_image_rootfs_image( &self, sandbox_id: &str, - 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 image_path = image_cache_rootfs_image(&self.config.state_dir, image_identity); let staging_dir = image_cache_staging_dir(&self.config.state_dir, image_identity); + let layout_dir = staging_dir.join(GUEST_IMAGE_OCI_LAYOUT_DIR); let prepared_rootfs = staging_dir.join("rootfs"); let prepared_image = staging_dir.join(IMAGE_CACHE_ROOTFS_IMAGE); @@ -2704,24 +2661,25 @@ impl VmDriver { info!( image_ref = %image_ref, staging_dir = %staging_dir.display(), - "vm driver: pulling registry image layers" + "vm driver: pulling registry image via containerd shim" + ); + self.publish_vm_progress( + sandbox_id, + "PullingImage", + format!("Pulling image \"{image_ref}\""), + HashMap::from([ + ("image_ref".to_string(), image_ref.to_string()), + ("image_source".to_string(), "registry".to_string()), + ]), ); if let Err(err) = self - .pull_registry_image_rootfs( - sandbox_id, - client, - reference, - auth, - image_ref, - &staging_dir, - &prepared_rootfs, - ) + .pull_and_unpack_registry_image(image_ref, &layout_dir, &prepared_rootfs) .await { warn!( image_ref = %image_ref, error = %err.message(), - "vm driver: pull_registry_image_rootfs failed" + "vm driver: containerd shim pull/unpack failed" ); let _ = tokio::fs::remove_dir_all(&staging_dir).await; return Err(err); @@ -3249,15 +3207,6 @@ fn validate_sandbox_id(sandbox_id: &str) -> Result<(), Status> { Ok(()) } -#[allow(clippy::result_large_err)] -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}" - )) - }) -} - /// Try to connect to a local container engine (Docker or Podman). /// /// Tries Docker first (`connect_with_local_defaults`, which respects @@ -3412,31 +3361,10 @@ async fn export_local_image_rootfs_to_path( } } -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()) - }) - }) -} - +/// Registry auth for the containerd shim is configured entirely via the +/// `OPENSHELL_REGISTRY_USERNAME`/`OPENSHELL_REGISTRY_TOKEN` environment +/// variables read directly by goshim/auth.go; the driver process does not +/// need to construct or pass credentials itself. fn linux_oci_arch() -> &'static str { match std::env::consts::ARCH { "x86_64" => "amd64", @@ -3446,155 +3374,7 @@ fn linux_oci_arch() -> &'static str { } } -#[allow(clippy::result_large_err)] -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 mut parts = image_ref.splitn(2, '/'); - let first = parts.next().unwrap_or(image_ref); - let has_path = parts.next().is_some(); - if has_path - && (first.contains('.') || first.contains(':') || first.eq_ignore_ascii_case("localhost")) - { - first - } else { - "docker.io" - } -} - impl VmDriver { - #[allow(clippy::too_many_arguments)] - async fn pull_registry_image_rootfs( - &self, - sandbox_id: &str, - 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}")))?; - - let total_layers = manifest.layers.len(); - let total_bytes: i64 = manifest.layers.iter().map(|layer| layer.size.max(0)).sum(); - let mut layers = futures::stream::iter(manifest.layers.iter().cloned().enumerate()) - .map(|(index, layer)| async move { - self.publish_registry_layer_progress( - sandbox_id, - image_ref, - &layer, - index, - total_layers, - total_bytes, - ); - download_registry_layer_blob( - client, - reference, - image_ref, - staging_dir, - layer, - index, - ) - .await - }) - .buffer_unordered(registry_layer_download_concurrency()) - .try_collect::>() - .await?; - layers.sort_by_key(|layer| layer.index); - - for layer in &layers { - apply_registry_layer_blob(image_ref, rootfs, layer).await?; - } - - Ok(()) - } - - fn publish_registry_layer_progress( - &self, - sandbox_id: &str, - image_ref: &str, - layer: &OciDescriptor, - index: usize, - total_layers: usize, - total_bytes: i64, - ) { - let mut metadata = HashMap::new(); - metadata.insert("layer_index".to_string(), (index + 1).to_string()); - metadata.insert("layer_total".to_string(), total_layers.to_string()); - metadata.insert("layer_digest".to_string(), layer.digest.clone()); - metadata.insert("layer_size_bytes".to_string(), layer.size.to_string()); - metadata.insert("image_ref".to_string(), image_ref.to_string()); - if total_bytes > 0 { - metadata.insert("image_size_bytes".to_string(), total_bytes.to_string()); - } - let mut event = platform_event( - "vm", - "Normal", - "PullingLayer", - format!( - "Pulling layer {}/{} ({} bytes) for image \"{image_ref}\"", - index + 1, - total_layers, - layer.size - ), - ); - event.metadata = metadata; - attach_vm_progress_metadata(&mut event); - self.publish_platform_event(sandbox_id.to_string(), event); - } - /// Emit a `Pulled` platform event with progress metadata for the CLI. async fn publish_pulled_event(&self, sandbox_id: &str, image_ref: &str, image_path: &Path) { let mut metadata = HashMap::from([("image_ref".to_string(), image_ref.to_string())]); @@ -3614,380 +3394,6 @@ impl VmDriver { } } -struct DownloadedRegistryLayer { - index: usize, - digest: String, - layer_root: PathBuf, -} - -async fn download_registry_layer_blob( - client: &OciClient, - reference: &Reference, - image_ref: &str, - staging_dir: &Path, - layer: OciDescriptor, - index: usize, -) -> Result { - 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 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) - }) - .await - .map_err(|err| Status::internal(format!("layer extraction panicked: {err}")))? - .map_err(|err| { - Status::failed_precondition(format!( - "failed to extract layer '{}' for vm sandbox image '{image_ref}': {err}", - layer.digest - )) - })?; - - Ok(DownloadedRegistryLayer { - index, - digest: layer.digest, - layer_root, - }) -} - -async fn apply_registry_layer_blob( - image_ref: &str, - rootfs: &Path, - layer: &DownloadedRegistryLayer, -) -> Result<(), Status> { - let layer_root_for_unpack = layer.layer_root.clone(); - let rootfs_for_unpack = rootfs.to_path_buf(); - tokio::task::spawn_blocking(move || { - apply_layer_dir_to_rootfs(&layer_root_for_unpack, &rootfs_for_unpack) - }) - .await - .map_err(|err| Status::internal(format!("layer application panicked: {err}")))? - .map_err(|err| { - Status::failed_precondition(format!( - "failed to apply layer '{}' for vm sandbox image '{image_ref}': {err}", - layer.digest - )) - }) -} - -async fn download_registry_descriptor_blob_file( - client: &OciClient, - reference: &Reference, - image_ref: &str, - layout_dir: &Path, - descriptor: &OciDescriptor, - kind: &str, -) -> Result<(), Status> { - let blob_path = oci_layout_blob_path(layout_dir, &descriptor.digest) - .map_err(|err| Status::failed_precondition(format!("invalid {kind} digest: {err}")))?; - if let Some(parent) = blob_path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|err| Status::internal(format!("create OCI blob dir failed: {err}")))?; - } - - let mut file = tokio::fs::File::create(&blob_path) - .await - .map_err(|err| Status::internal(format!("create OCI {kind} blob failed: {err}")))?; - client - .pull_blob(reference, descriptor, &mut file) - .await - .map_err(|err| { - Status::failed_precondition(format!( - "failed to download {kind} '{}' for vm sandbox image '{image_ref}': {err}", - descriptor.digest - )) - })?; - file.flush() - .await - .map_err(|err| Status::internal(format!("flush OCI {kind} blob failed: {err}")))?; - - let blob_path_for_digest = blob_path.clone(); - let expected_digest = descriptor.digest.clone(); - tokio::task::spawn_blocking(move || { - verify_descriptor_digest(&blob_path_for_digest, &expected_digest) - }) - .await - .map_err(|err| Status::internal(format!("OCI {kind} digest verification panicked: {err}")))? - .map_err(|err| { - Status::failed_precondition(format!( - "vm sandbox image {kind} verification failed for '{}': {err}", - descriptor.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_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 = vec![0_u8; 64 * 1024].into_boxed_slice(); - 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 compute_bytes_sha256_hex(bytes: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(bytes); - 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())) -} - -// `media_type` is an OCI media type string (e.g. `application/vnd.oci.image.layer.v1.tar+gzip`), -// not a filesystem path, so case-sensitive comparison is correct. -#[allow(clippy::case_sensitive_file_extension_comparisons)] -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(fs::DirEntry::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 let Ok(dest_metadata) = fs::symlink_metadata(&dest_path) - && !dest_metadata.file_type().is_dir() - && !path_is_dir_or_symlink_to_dir(&dest_path)? - { - 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)?; - if fs::symlink_metadata(&dest_path) - .map_err(|err| format!("stat {}: {err}", dest_path.display()))? - .file_type() - .is_dir() - { - 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 path_is_dir_or_symlink_to_dir(path: &Path) -> Result { - match fs::metadata(path) { - Ok(metadata) => Ok(metadata.file_type().is_dir()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(format!("stat {}: {err}", path.display())), - } -} - -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 @@ -4290,75 +3696,6 @@ fn image_cache_staging_dir(root: &Path, image_identity: &str) -> PathBuf { )) } -fn oci_layout_blobs_dir(layout_dir: &Path) -> PathBuf { - layout_dir.join("blobs").join("sha256") -} - -fn oci_layout_blob_path(layout_dir: &Path, digest: &str) -> Result { - let hex = sha256_digest_hex(digest)?; - Ok(oci_layout_blobs_dir(layout_dir).join(hex)) -} - -fn sha256_digest_hex(digest: &str) -> Result<&str, String> { - let Some((algorithm, hex)) = digest.split_once(':') else { - return Err(format!("digest '{digest}' is missing an algorithm")); - }; - if algorithm != "sha256" { - return Err(format!("unsupported digest algorithm '{algorithm}'")); - } - if hex.is_empty() || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) { - return Err(format!("digest '{digest}' is not a valid sha256 digest")); - } - Ok(hex) -} - -fn write_oci_layout_for_manifest( - layout_dir: &Path, - ref_name: &str, - manifest: &OciImageManifest, -) -> Result<(), String> { - fs::create_dir_all(oci_layout_blobs_dir(layout_dir)) - .map_err(|err| format!("create OCI layout blobs dir failed: {err}"))?; - - fs::write( - layout_dir.join("oci-layout"), - br#"{"imageLayoutVersion":"1.0.0"}"#, - ) - .map_err(|err| format!("write OCI layout marker failed: {err}"))?; - - let manifest_bytes = serde_json::to_vec(manifest) - .map_err(|err| format!("serialize OCI manifest failed: {err}"))?; - let manifest_digest = format!("sha256:{}", compute_bytes_sha256_hex(&manifest_bytes)); - let manifest_blob = oci_layout_blob_path(layout_dir, &manifest_digest) - .map_err(|err| format!("compute OCI manifest blob path failed: {err}"))?; - fs::write(&manifest_blob, &manifest_bytes) - .map_err(|err| format!("write OCI manifest blob failed: {err}"))?; - - let media_type = manifest - .media_type - .clone() - .unwrap_or_else(|| OCI_IMAGE_MEDIA_TYPE.to_string()); - let index = serde_json::json!({ - "schemaVersion": 2, - "manifests": [ - { - "mediaType": media_type, - "digest": manifest_digest, - "size": manifest_bytes.len(), - "annotations": { - "org.opencontainers.image.ref.name": ref_name - } - } - ] - }); - let index_bytes = serde_json::to_vec_pretty(&index) - .map_err(|err| format!("serialize OCI index failed: {err}"))?; - fs::write(layout_dir.join("index.json"), index_bytes) - .map_err(|err| format!("write OCI index failed: {err}"))?; - - Ok(()) -} - fn bootstrap_image_cache_identity(image_identity: &str) -> String { format!( "{BOOTSTRAP_IMAGE_CACHE_LAYOUT_VERSION}:openshell-{}:{image_identity}", @@ -4373,20 +3710,6 @@ fn prepared_image_cache_identity(image_identity: &str) -> String { ) } -fn registry_layer_download_concurrency() -> usize { - let value = std::env::var("OPENSHELL_VM_IMAGE_PULL_CONCURRENCY").ok(); - registry_layer_download_concurrency_value(value.as_deref()) -} - -fn registry_layer_download_concurrency_value(value: Option<&str>) -> usize { - value - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0) - .map_or(DEFAULT_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY, |value| { - value.min(MAX_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY) - }) -} - fn sanitize_image_identity(image_identity: &str) -> String { image_identity .chars() @@ -5084,6 +4407,7 @@ fn attach_vm_progress_metadata(event: &mut PlatformEvent) { mark_progress_detail(&mut event.metadata, detail); } } + "PullingImage" => mark_progress_detail(&mut event.metadata, "Pulling image"), "ResolvingImage" => mark_progress_detail(&mut event.metadata, "Resolving image"), "AuthenticatingRegistry" => { mark_progress_detail(&mut event.metadata, "Authenticating registry"); @@ -6131,27 +5455,6 @@ 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("library/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/example:dev"), - "localhost" - ); - assert_eq!( - image_reference_registry_host("localhost:5000/example/sandbox:dev"), - "localhost:5000" - ); - } - #[test] fn openshell_local_build_image_ref_matches_cli_tags() { assert!(is_openshell_local_build_image_ref( @@ -6188,85 +5491,6 @@ mod tests { assert!(err.contains("unknown/unknown")); } - #[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 apply_layer_dir_to_rootfs_preserves_lower_symlink_dirs() { - let base = unique_temp_dir(); - let rootfs = base.join("rootfs"); - let layer = base.join("layer"); - - fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); - fs::write(rootfs.join("usr/bin/bash"), "bash").unwrap(); - std::os::unix::fs::symlink("usr/bin", rootfs.join("bin")).unwrap(); - - fs::create_dir_all(layer.join("bin")).unwrap(); - fs::write(layer.join("bin/foo"), "foo").unwrap(); - - apply_layer_dir_to_rootfs(&layer, &rootfs).unwrap(); - - assert!( - fs::symlink_metadata(rootfs.join("bin")) - .unwrap() - .file_type() - .is_symlink(), - "lower /bin symlink should be preserved" - ); - assert_eq!( - fs::read_to_string(rootfs.join("usr/bin/bash")).unwrap(), - "bash" - ); - assert_eq!( - fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(), - "foo" - ); - - 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 { @@ -6617,23 +5841,6 @@ mod tests { let _ = std::fs::remove_dir_all(base); } - #[test] - fn registry_layer_download_concurrency_is_bounded() { - assert_eq!( - registry_layer_download_concurrency_value(None), - DEFAULT_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY - ); - assert_eq!( - registry_layer_download_concurrency_value(Some("0")), - DEFAULT_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY - ); - assert_eq!(registry_layer_download_concurrency_value(Some("8")), 8); - assert_eq!( - registry_layer_download_concurrency_value(Some("999")), - MAX_REGISTRY_LAYER_DOWNLOAD_CONCURRENCY - ); - } - #[test] fn sanitize_image_identity_rewrites_path_separators() { assert_eq!( diff --git a/crates/openshell-driver-vm/src/embedded_runtime.rs b/crates/openshell-driver-vm/src/embedded_runtime.rs index 70626edd98..5dc32d9066 100644 --- a/crates/openshell-driver-vm/src/embedded_runtime.rs +++ b/crates/openshell-driver-vm/src/embedded_runtime.rs @@ -11,8 +11,13 @@ mod resources { pub const LIBKRUN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libkrun.dylib.zst")); pub const LIBKRUNFW: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libkrunfw.5.dylib.zst")); pub const GVPROXY: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/gvproxy.zst")); + pub const CONTAINERD_SHIM: &[u8] = include_bytes!(concat!( + env!("OUT_DIR"), + "/libopenshell_containerd_shim.dylib.zst" + )); pub const LIBKRUN_NAME: &str = "libkrun.dylib"; pub const LIBKRUNFW_NAME: &str = "libkrunfw.5.dylib"; + pub const CONTAINERD_SHIM_NAME: &str = "libopenshell_containerd_shim.dylib"; } #[cfg(all(target_os = "linux", target_arch = "aarch64"))] @@ -20,8 +25,13 @@ mod resources { pub const LIBKRUN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libkrun.so.zst")); pub const LIBKRUNFW: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libkrunfw.so.5.zst")); pub const GVPROXY: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/gvproxy.zst")); + pub const CONTAINERD_SHIM: &[u8] = include_bytes!(concat!( + env!("OUT_DIR"), + "/libopenshell_containerd_shim.so.zst" + )); pub const LIBKRUN_NAME: &str = "libkrun.so"; pub const LIBKRUNFW_NAME: &str = "libkrunfw.so.5"; + pub const CONTAINERD_SHIM_NAME: &str = "libopenshell_containerd_shim.so"; } #[cfg(all(target_os = "linux", target_arch = "x86_64"))] @@ -29,8 +39,13 @@ mod resources { pub const LIBKRUN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libkrun.so.zst")); pub const LIBKRUNFW: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libkrunfw.so.5.zst")); pub const GVPROXY: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/gvproxy.zst")); + pub const CONTAINERD_SHIM: &[u8] = include_bytes!(concat!( + env!("OUT_DIR"), + "/libopenshell_containerd_shim.so.zst" + )); pub const LIBKRUN_NAME: &str = "libkrun.so"; pub const LIBKRUNFW_NAME: &str = "libkrunfw.so.5"; + pub const CONTAINERD_SHIM_NAME: &str = "libopenshell_containerd_shim.so"; } #[cfg(not(any( @@ -42,8 +57,10 @@ mod resources { pub const LIBKRUN: &[u8] = &[]; pub const LIBKRUNFW: &[u8] = &[]; pub const GVPROXY: &[u8] = &[]; + pub const CONTAINERD_SHIM: &[u8] = &[]; pub const LIBKRUN_NAME: &str = "libkrun"; pub const LIBKRUNFW_NAME: &str = "libkrunfw"; + pub const CONTAINERD_SHIM_NAME: &str = "libopenshell_containerd_shim"; } const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -83,6 +100,10 @@ pub fn ensure_runtime_extracted() -> Result { &cache_dir.join(resources::LIBKRUNFW_NAME), )?; extract_resource(resources::GVPROXY, &cache_dir.join("gvproxy"))?; + extract_resource( + resources::CONTAINERD_SHIM, + &cache_dir.join(resources::CONTAINERD_SHIM_NAME), + )?; #[cfg(target_os = "macos")] { @@ -110,8 +131,9 @@ pub fn validate_runtime_dir(dir: &Path) -> Result<(), String> { let libkrun = dir.join(resources::LIBKRUN_NAME); let libkrunfw = dir.join(resources::LIBKRUNFW_NAME); let gvproxy = dir.join("gvproxy"); + let containerd_shim = dir.join(resources::CONTAINERD_SHIM_NAME); - for path in [&libkrun, &libkrunfw, &gvproxy] { + for path in [&libkrun, &libkrunfw, &gvproxy, &containerd_shim] { if !path.is_file() { return Err(format!("missing runtime file: {}", path.display())); } @@ -129,6 +151,7 @@ fn runtime_cache_key() -> String { for (index, chunk) in [resources::LIBKRUN, resources::LIBKRUNFW] .into_iter() .chain(std::iter::once(resources::GVPROXY)) + .chain(std::iter::once(resources::CONTAINERD_SHIM)) .enumerate() { let sample = &chunk[..chunk.len().min(64)]; diff --git a/crates/openshell-driver-vm/src/lib.rs b/crates/openshell-driver-vm/src/lib.rs index 88e2c3b201..e888ac17a2 100644 --- a/crates/openshell-driver-vm/src/lib.rs +++ b/crates/openshell-driver-vm/src/lib.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +mod containerd_shim; pub mod driver; mod embedded_runtime; mod ffi; diff --git a/deploy/docker/Dockerfile.driver-vm-macos b/deploy/docker/Dockerfile.driver-vm-macos index 58317a52d8..d1f827c1b8 100644 --- a/deploy/docker/Dockerfile.driver-vm-macos +++ b/deploy/docker/Dockerfile.driver-vm-macos @@ -20,14 +20,16 @@ ARG OSXCROSS_IMAGE=ghcr.io/crazy-max/osxcross:latest ARG PYTHON_IMAGE=public.ecr.aws/docker/library/python:3.12-slim +ARG GO_VERSION=1.26.4 FROM ${OSXCROSS_IMAGE} AS osxcross FROM ${PYTHON_IMAGE} AS builder ARG CARGO_TARGET_CACHE_SCOPE=default +ARG GO_VERSION -ENV PATH="/root/.cargo/bin:/usr/local/bin:/osxcross/bin:${PATH}" +ENV PATH="/root/.cargo/bin:/usr/local/bin:/usr/local/go/bin:/osxcross/bin:${PATH}" ENV LD_LIBRARY_PATH="/osxcross/lib" COPY --from=osxcross /osxcross /osxcross @@ -39,12 +41,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ cmake \ curl \ pkg-config \ + zstd \ && rm -rf /var/lib/apt/lists/* RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.95.0 RUN rustup target add aarch64-apple-darwin +# Go toolchain used to cross-build openshell-containerd-shim's cgo shared +# library for macOS (GOOS=darwin GOARCH=arm64 via osxcross's oa64-clang). +# See tasks/scripts/vm/build-containerd-shim.sh. +RUN curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \ + | tar -C /usr/local -xz + WORKDIR /build ENV CC_aarch64_apple_darwin=oa64-clang @@ -87,6 +96,15 @@ RUN --mount=type=cache,id=cargo-registry-driver-vm-macos,sharing=locked,target=/ # Stage 2: real build with compressed runtime artifacts # --------------------------------------------------------------------------- COPY crates/ crates/ +COPY tasks/scripts/vm/build-containerd-shim.sh tasks/scripts/vm/_lib.sh tasks/scripts/vm/ + +# Cross-build the containerd shim's cgo shared library for macOS using +# osxcross's oa64-clang as CC, staged directly into the compressed runtime +# dir so build.rs picks it up the same way it does the host-provided +# libkrun/libkrunfw/gvproxy/umoci artifacts below. +RUN GOOS=darwin GOARCH=arm64 CC=oa64-clang \ + OPENSHELL_VM_RUNTIME_COMPRESSED_DIR=/build/vm-runtime-compressed \ + tasks/scripts/vm/build-containerd-shim.sh # Copy compressed VM runtime artifacts for embedding. # These are passed in via --build-context vm-runtime-compressed=... diff --git a/mise.lock b/mise.lock index a3864976c7..f99f48cdfc 100644 --- a/mise.lock +++ b/mise.lock @@ -94,6 +94,22 @@ checksum = "sha256:29caf036bdbb4e6f07afea31706b6f386cb5a4db9a46a3a8b462b9b78157e url = "https://github.com/rust-cross/cargo-zigbuild/releases/download/v0.22.3/cargo-zigbuild-aarch64-apple-darwin.tar.xz" url_api = "https://api.github.com/repos/rust-cross/cargo-zigbuild/releases/assets/405676922" +[[tools.go]] +version = "1.26.4" +backend = "core:go" + +[tools.go."platforms.linux-arm64"] +checksum = "sha256:ef758ae7c6cf9267c9c0ef080b8965f453d89ab2d25d9eb22de4405925238768" +url = "https://dl.google.com/go/go1.26.4.linux-arm64.tar.gz" + +[tools.go."platforms.linux-x64"] +checksum = "sha256:1153d3d50e0ac764b447adfe05c2bcf08e889d42a02e0fe0259bd47f6733ad7f" +url = "https://dl.google.com/go/go1.26.4.linux-amd64.tar.gz" + +[tools.go."platforms.macos-arm64"] +checksum = "sha256:b62ad2b6d7d2464f12a5bcad7ff47f19d08325773b5efd21610e445a05a9bf53" +url = "https://dl.google.com/go/go1.26.4.darwin-arm64.tar.gz" + [[tools.helm]] version = "4.2.0" backend = "aqua:helm/helm" diff --git a/mise.toml b/mise.toml index fc5ba340aa..572c0ea16d 100644 --- a/mise.toml +++ b/mise.toml @@ -22,6 +22,9 @@ lockfile_platforms = ["linux-x64", "linux-arm64", "macos-arm64"] python = "3.14.5" rust = "1.95.0" node = "24.15.0" +# Builds the openshell-containerd-shim cgo library for openshell-driver-vm +# (crates/openshell-driver-vm/goshim/); see tasks/scripts/vm/build-containerd-shim.sh. +go = "1.26.4" kubectl = "1.36.1" uv = "0.10.12" protoc = "29.6" diff --git a/scripts/update_license_headers.py b/scripts/update_license_headers.py index f56dbc2935..e3f4f7f37e 100755 --- a/scripts/update_license_headers.py +++ b/scripts/update_license_headers.py @@ -37,6 +37,7 @@ COMMENT_STYLES: dict[str, str] = { ".rs": "//", ".proto": "//", + ".go": "//", ".py": "#", ".sh": "#", ".toml": "#", @@ -62,6 +63,10 @@ "Cargo.lock", "uv.lock", ".gitlab-ci.yml", + # go.mod/go.sum are rewritten by `go mod tidy`, which strips leading + # comments; treat them like other lockfile-adjacent manifests. + "go.mod", + "go.sum", } # Glob-style directory prefixes to also skip (CI / editor config dirs). diff --git a/tasks/scripts/vm/build-containerd-shim.sh b/tasks/scripts/vm/build-containerd-shim.sh new file mode 100755 index 0000000000..039f9ec219 --- /dev/null +++ b/tasks/scripts/vm/build-containerd-shim.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Build the openshell-containerd-shim cgo shared library and stage it as a +# compressed artifact next to libkrun/libkrunfw/gvproxy/umoci. +# +# Unlike those, this shim is small, pure Go source vendored in this repo +# (crates/openshell-driver-vm/goshim/), so it is always built locally from +# source rather than downloaded from a release. It wraps containerd's Go +# client libraries (core/remotes/docker, pkg/archive) to resolve/pull OCI +# registry images and unpack layers, and is loaded at runtime by +# crates/openshell-driver-vm/src/containerd_shim.rs via libloading — the +# same way the driver loads libkrun. +# +# Usage: +# ./build-containerd-shim.sh +# +# Cross-compiles when GOOS/GOARCH/CC are set (used by +# deploy/docker/Dockerfile.driver-vm-macos to cross-build the macOS dylib +# from a Linux container via osxcross); otherwise builds natively for the +# current host. +# +# Environment: +# OPENSHELL_VM_RUNTIME_COMPRESSED_DIR - Output directory (default: target/vm-runtime-compressed) +# GOOS, GOARCH, CC - Cross-compilation target (all three, or none) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/_lib.sh" +ROOT="$(vm_lib_root)" + +GOSHIM_DIR="${ROOT}/crates/openshell-driver-vm/goshim" +WORK_DIR="${ROOT}/target/containerd-shim-build" +OUTPUT_DIR="${OPENSHELL_VM_RUNTIME_COMPRESSED_DIR:-${ROOT}/target/vm-runtime-compressed}" + +if ! command -v go >/dev/null 2>&1; then + echo "Error: go toolchain not found. Install Go (https://go.dev/dl/) and retry." >&2 + exit 1 +fi + +TARGET_GOOS="${GOOS:-$( [ "$(uname -s)" = Darwin ] && echo darwin || echo linux )}" +case "$TARGET_GOOS" in + darwin) LIB_NAME="libopenshell_containerd_shim.dylib" ;; + linux) LIB_NAME="libopenshell_containerd_shim.so" ;; + *) + echo "Error: Unsupported GOOS: ${TARGET_GOOS}" >&2 + exit 1 + ;; +esac + +echo "==> Building openshell-containerd-shim (${LIB_NAME}, GOOS=${TARGET_GOOS})..." +mkdir -p "$WORK_DIR" "$OUTPUT_DIR" + +( + cd "$GOSHIM_DIR" + export CGO_ENABLED=1 + export GOOS="$TARGET_GOOS" + export GOARCH="${GOARCH:-}" + export CC="${CC:-}" + go build -trimpath -buildmode=c-shared -o "${WORK_DIR}/${LIB_NAME}" . +) +# The generated C header only serves cgo/C consumers; the Rust FFI bindings +# in containerd_shim.rs are maintained by hand against the exported symbols. +rm -f "${WORK_DIR}/${LIB_NAME%.dylib}.h" "${WORK_DIR}/${LIB_NAME%.so}.h" + +compress_file "${WORK_DIR}/${LIB_NAME}" "${OUTPUT_DIR}/${LIB_NAME}.zst" +echo "==> Staged ${OUTPUT_DIR}/${LIB_NAME}.zst" diff --git a/tasks/scripts/vm/vm-setup.sh b/tasks/scripts/vm/vm-setup.sh index de568d8783..9e37017291 100755 --- a/tasks/scripts/vm/vm-setup.sh +++ b/tasks/scripts/vm/vm-setup.sh @@ -93,6 +93,10 @@ else "${ROOT}/tasks/scripts/vm/download-kernel-runtime.sh" fi +echo "" +echo "==> Building openshell-containerd-shim from source..." +"${ROOT}/tasks/scripts/vm/build-containerd-shim.sh" + # ── Validate ──────────────────────────────────────────────────────────── OUTPUT_DIR="${OPENSHELL_VM_RUNTIME_COMPRESSED_DIR:-${ROOT}/target/vm-runtime-compressed}" @@ -101,7 +105,7 @@ OUTPUT_DIR="${OPENSHELL_VM_RUNTIME_COMPRESSED_DIR:-${ROOT}/target/vm-runtime-com missing=0 case "$PLATFORM" in darwin-aarch64) - for f in libkrun.dylib.zst libkrunfw.5.dylib.zst gvproxy.zst umoci.zst; do + for f in libkrun.dylib.zst libkrunfw.5.dylib.zst gvproxy.zst umoci.zst libopenshell_containerd_shim.dylib.zst; do if [ ! -f "${OUTPUT_DIR}/${f}" ]; then echo "ERROR: Missing ${OUTPUT_DIR}/${f}" >&2 missing=1 @@ -109,7 +113,7 @@ case "$PLATFORM" in done ;; linux-aarch64|linux-x86_64) - for f in libkrun.so.zst libkrunfw.so.5.zst gvproxy.zst umoci.zst; do + for f in libkrun.so.zst libkrunfw.so.5.zst gvproxy.zst umoci.zst libopenshell_containerd_shim.so.zst; do if [ ! -f "${OUTPUT_DIR}/${f}" ]; then echo "ERROR: Missing ${OUTPUT_DIR}/${f}" >&2 missing=1