diff --git a/Cargo.lock b/Cargo.lock index a1c5af25..5d2393bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,30 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" version = "0.4.48" @@ -136,6 +160,124 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if 1.0.4", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if 1.0.4", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if 1.0.4", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -144,7 +286,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -247,9 +389,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -277,9 +425,18 @@ dependencies = [ "arrayref", "arrayvec", "cc", - "cfg-if", + "cfg-if 1.0.4", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", ] [[package]] @@ -291,6 +448,19 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bstr" version = "1.12.1" @@ -359,6 +529,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.4" @@ -371,14 +547,28 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cgroups-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae79ba89081d30804e3312bb1163ab82cc8dca0a1b16275e55fb19fce4e89b" +dependencies = [ + "bit-vec 0.6.3", + "libc", + "log", + "nix 0.25.1", + "thiserror 1.0.69", + "zbus", +] + [[package]] name = "chacha20" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ - "cfg-if", - "cpufeatures", + "cfg-if 1.0.4", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -424,10 +614,10 @@ version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -459,24 +649,103 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "containerd-shim" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a886cc76f89aab452d78a9034a62426468175d795d0fb2ee57338fe075cbdc0a" +dependencies = [ + "async-trait", + "cgroups-rs", + "containerd-shim-protos", + "futures", + "go-flag", + "libc", + "log", + "mio", + "nix 0.31.3", + "oci-spec 0.9.0", + "serde", + "serde_json", + "sha2 0.10.9", + "signal-hook", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "which 8.0.6", + "windows-sys 0.52.0", +] + +[[package]] +name = "containerd-shim-protos" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f714d7ff4f5e9d5f9b57d27152d70c8cab6639284e5c4f7dfaca774a7b94fa52" +dependencies = [ + "async-trait", + "protobuf", + "ttrpc", + "ttrpc-codegen", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -492,7 +761,20 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", +] + +[[package]] +name = "crossbeam" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e71406cd8807725f7ac2f999a4cdd32e98f829fdf65f528343cebf945e41df1e" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", ] [[package]] @@ -504,12 +786,50 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.1" @@ -519,13 +839,48 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "dashmap" version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "crossbeam-utils", "hashbrown 0.14.5", "lock_api", @@ -561,7 +916,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -573,15 +928,73 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive-new" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3418329ca0ad70234b9735dc4ceed10af4df60eff9c8e7b06cb5e520d92c3535" +dependencies = [ + "proc-macro2", + "quote", + "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 = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.0", "const-oid", - "crypto-common", + "crypto-common 0.2.1", ] [[package]] @@ -613,7 +1026,40 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -655,6 +1101,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -667,7 +1133,7 @@ version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", ] @@ -677,6 +1143,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ab347416e802de484e4d03c7316c48f1ecb56574dfd4a46a80f173ce1de04d" + [[package]] name = "flate2" version = "1.1.9" @@ -781,6 +1253,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -789,7 +1274,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -821,13 +1306,23 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", "wasi", @@ -840,7 +1335,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", "r-efi 5.3.0", @@ -854,7 +1349,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "r-efi 6.0.0", "rand_core 0.10.1", @@ -862,6 +1357,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "glob" version = "0.3.3" @@ -881,6 +1387,15 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "go-flag" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b4a40c9ca507513f573aabaf6a8558173a1ac9aa1363d8de30c7f89b34f8d2b" +dependencies = [ + "cfg-if 0.1.10", +] + [[package]] name = "h2" version = "0.4.13" @@ -893,7 +1408,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -909,6 +1424,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -942,19 +1463,49 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "hostname" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "windows-link", ] @@ -1186,6 +1737,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1215,7 +1772,7 @@ checksum = "404f567d70cd6d288e43f95ea8f2d6e8fe6156dd07df7da955cb9b147c4ab2a5" dependencies = [ "async-trait", "bincode 2.0.1", - "cfg-if", + "cfg-if 1.0.4", "futures", "libc", "maybe-async", @@ -1228,6 +1785,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1262,6 +1829,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1301,7 +1877,7 @@ dependencies = [ "jiff-core", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1320,12 +1896,27 @@ version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "futures-util", "once_cell", "wasm-bindgen", ] +[[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 = "krun-arch" version = "0.1.0-1.19.3" @@ -1538,7 +2129,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows-link", ] @@ -1560,6 +2151,12 @@ dependencies = [ "vm-memory", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1586,6 +2183,9 @@ name = "log" version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +dependencies = [ + "value-bag", +] [[package]] name = "lru" @@ -1631,7 +2231,7 @@ checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1649,6 +2249,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -1685,6 +2294,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if 1.0.4", + "libc", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if 1.0.4", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + [[package]] name = "nix" version = "0.29.0" @@ -1692,7 +2332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.1", - "cfg-if", + "cfg-if 1.0.4", "cfg_aliases", "libc", ] @@ -1704,10 +2344,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.11.1", - "cfg-if", + "cfg-if 1.0.4", + "cfg_aliases", + "libc", + "memoffset 0.9.1", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.1", + "cfg-if 1.0.4", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -1719,6 +2372,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1728,6 +2387,40 @@ dependencies = [ "autocfg", ] +[[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", + "strum_macros", + "thiserror 2.0.18", +] + +[[package]] +name = "oci-spec" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df6f876ad774d6a676f7e968f5c3edacc32f90e65fe680a8b686235396556fb" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum", + "strum_macros", + "thiserror 2.0.18", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1781,6 +2474,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "page_size" version = "0.6.0" @@ -1791,13 +2494,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot_core" version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "redox_syscall", "smallvec", @@ -1864,7 +2573,7 @@ dependencies = [ "log", "persisting-control", "serde_json", - "sha2", + "sha2 0.11.0", "tempfile", ] @@ -1933,14 +2642,14 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "tar", "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", "toml", - "toml_edit", + "toml_edit 0.22.27", "tracing", "unicode-width", "uuid", @@ -1960,25 +2669,85 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "tempfile", "tokio", "toml", "uuid", ] +[[package]] +name = "persisting-shim" +version = "0.3.0" +dependencies = [ + "anyhow", + "async-trait", + "containerd-shim", + "containerd-shim-protos", + "libc", + "libkrun", + "log", + "oci-spec 0.10.0", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "petgraph" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "467d164a6de56270bd7c4d070df81d07beace25012d5103ced4e9ff08d6afdb7" +dependencies = [ + "fixedbitset", + "indexmap 1.9.3", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if 1.0.4", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2003,6 +2772,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2019,7 +2794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2033,6 +2808,15 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.15+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2049,7 +2833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", - "bit-vec", + "bit-vec 0.8.0", "bitflags 2.11.1", "num-traits", "rand 0.9.4", @@ -2061,6 +2845,108 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de5e2533f59d08fcf364fd374ebda0692a70bd6d7e66ef97f306f45c6c5d8020" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355f634b43cdd80724ee7848f95770e7e70eefa6dcf14fea676216573b8fd603" +dependencies = [ + "bytes", + "heck 0.3.3", + "itertools", + "log", + "multimap", + "petgraph", + "prost", + "prost-types", + "tempfile", + "which 4.4.2", +] + +[[package]] +name = "prost-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "600d2f334aa05acb02a755e217ef1ab6dea4d51b58b7846588b747edec04efba" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "603bbd6394701d13f3f25aada59c7de9d35a6a5887cfc156181234a44002771b" +dependencies = [ + "bytes", + "prost", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-codegen" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-parse" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror 1.0.69", + "which 4.4.2", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "pulsing-actor" version = "0.1.2" @@ -2358,7 +3244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.2.17", "libc", "untrusted", @@ -2380,6 +3266,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2389,7 +3288,7 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2491,7 +3390,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2518,6 +3417,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2545,22 +3455,33 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2578,6 +3499,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2614,7 +3545,7 @@ checksum = "5f73d40463bba65efc9adc6370b56df76d563cc46e2482bba58351b4afb7535e" dependencies = [ "bitflags 1.3.2", "byteorder", - "cfg-if", + "cfg-if 1.0.4", "defmt 0.3.100", "heapless", "log", @@ -2643,12 +3574,41 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[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.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -2660,6 +3620,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2677,7 +3648,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2700,7 +3671,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -2730,7 +3701,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2741,7 +3712,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2750,7 +3721,37 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", ] [[package]] @@ -2802,7 +3803,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2840,6 +3841,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-vsock" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b319ef9394889dab2e1b4f0085b45ba11d0c79dc9d1a9d1afc057d009d0f1c7" +dependencies = [ + "bytes", + "futures", + "libc", + "tokio", + "vsock", +] + [[package]] name = "toml" version = "0.8.23" @@ -2848,8 +3862,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -2861,18 +3875,48 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -2950,7 +3994,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3029,12 +4073,72 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttrpc" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f12efe237328bbe3b3b50503627f7cc9e74cfae34ffa8438561916623cd8f50" +dependencies = [ + "async-stream", + "async-trait", + "byteorder", + "crossbeam", + "futures", + "libc", + "log", + "nix 0.26.4", + "protobuf", + "protobuf-codegen", + "thiserror 1.0.69", + "tokio", + "tokio-vsock", + "windows-sys 0.48.0", +] + +[[package]] +name = "ttrpc-codegen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e5c657ef5cea6f6c6073c1be0787ba4482f42a569d4821e467daec795271f86" +dependencies = [ + "protobuf", + "protobuf-codegen", + "protobuf-support", + "ttrpc-compiler", +] + +[[package]] +name = "ttrpc-compiler" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa71f4a44711b3b9cc10ed0c7e239ff0fe4b8e6c900a142fb3bb26401385718" +dependencies = [ + "derive-new", + "prost", + "prost-build", + "prost-types", + "protobuf", + "protobuf-codegen", + "tempfile", +] + [[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" @@ -3047,6 +4151,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-width" version = "0.2.2" @@ -3110,6 +4220,7 @@ dependencies = [ "getrandom 0.4.2", "js-sys", "rand 0.10.1", + "serde_core", "wasm-bindgen", ] @@ -3119,6 +4230,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "virtio-bindings" version = "0.2.7" @@ -3168,6 +4291,16 @@ dependencies = [ "libc", ] +[[package]] +name = "vsock" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba782755fc073877e567c2253c0be48e4aa9a254c232d36d3985dfae0bd5205" +dependencies = [ + "libc", + "nix 0.31.3", +] + [[package]] name = "vt100" version = "0.16.2" @@ -3237,7 +4370,7 @@ version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -3273,7 +4406,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -3303,7 +4436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -3329,7 +4462,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -3362,6 +4495,27 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3405,7 +4559,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3416,7 +4570,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3443,13 +4597,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3461,34 +4624,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3501,24 +4697,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3534,6 +4754,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3556,7 +4785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -3567,10 +4796,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", - "indexmap", + "heck 0.5.0", + "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3586,7 +4815,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3599,7 +4828,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.1", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -3618,7 +4847,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -3641,7 +4870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -3669,10 +4898,80 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -3690,7 +4989,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3710,7 +5009,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3750,7 +5049,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3786,3 +5085,44 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index dcd8353b..ec16d60c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/persisting-gateway", "crates/persisting-pvisor", "crates/persisting-replay", + "crates/persisting-shim", "crates/persisting-overlayfs", "crates/persisting-overlay-core", "crates/persisting-overlaynet", @@ -25,6 +26,9 @@ default-members = [ [workspace.dependencies] anyhow = "1" async-trait = "0.1" +containerd-shim = "0.11" +containerd-shim-protos = "0.11" +oci-spec = "0.10" axum = { version = "0.8", default-features = false } base64 = "0.22" blake3 = "1" diff --git a/crates/persisting-shim/Cargo.toml b/crates/persisting-shim/Cargo.toml new file mode 100644 index 00000000..9a525736 --- /dev/null +++ b/crates/persisting-shim/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "persisting-shim" +description = "containerd Runtime v2 shim for pVisor (binary: containerd-shim-pvisor-v2)" + +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[lib] +name = "persisting_shim" +path = "src/lib.rs" + +[[bin]] +name = "containerd-shim-pvisor-v2" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +libc = { workspace = true } +log = { workspace = true } +oci-spec = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[target.'cfg(target_os = "linux")'.dependencies] +async-trait = { workspace = true } +containerd-shim = { workspace = true, features = ["async"] } +containerd-shim-protos = { workspace = true, features = ["sandbox"] } +libkrun = { workspace = true, optional = true } +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } + +[features] +# libkrun microVM executor. Off by default: linking libkrun requires the +# vendored VMM toolchain and a libkrunfw kernel on the host. +vm = ["libkrun"] + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/persisting-shim/README.md b/crates/persisting-shim/README.md new file mode 100644 index 00000000..df316dac --- /dev/null +++ b/crates/persisting-shim/README.md @@ -0,0 +1,181 @@ +# persisting-shim + +`containerd-shim-pvisor-v2`: a [containerd Runtime v2] shim for pVisor, +registered under the runtime type `io.containerd.pvisor.v2` (the binary name +follows containerd's discovery rule: dots become dashes, last two +components, `containerd-shim` prefix). + +This is **M2 scope**: the host process path with full task IO. The shim +implements the lifecycle every caller needs (`create`/`start`/`kill`/ +`wait`/`delete`/`state`/`pids`/`connect`/`shutdown`), exec into running +tasks (`Exec` -> `Start(exec_id)` with `TaskExecAdded`/`TaskExecStarted` +events), shim-owned FIFO/PTY IO with `CloseIO` (stdin keepalive, so +`docker run -i` sees EOF) and `ResizePty`, task events, and a cgroup v2 +subset (pids/memory/cpu). Stats, pause/resume, checkpointing, and the +pod-level Sandbox API are not implemented yet — the generated ttrpc trait +defaults report them as unsupported, which containerd tolerates. + +## Registering the runtime + +### containerd (Kubernetes path) + +`/etc/containerd/config.toml`: + +```toml +version = 2 + +[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.pvisor] + runtime_type = "io.containerd.pvisor.v2" + sandboxer = "shim" +``` + +with `containerd-shim-pvisor-v2` on the `PATH` of the containerd process. +`sandboxer = "shim"` activates the pod-level Sandbox API: one shim instance +per pod, the shim replaces the pause container with a namespace holder, and +the pod's containers share its uts/ipc/network namespaces (the CNI pod +netns is joined directly, so pod IP semantics hold). Kubernetes Pods select +the runtime through a RuntimeClass: + +```yaml +apiVersion: node.k8s.io/v1 +kind: RuntimeClass +metadata: + name: pvisor +handler: pvisor +``` + +### Docker (23.0+) + +Either register a short name in `/etc/docker/daemon.json`: + +```json +{ + "runtimes": { + "pvisor": { "runtimeType": "io.containerd.pvisor.v2" } + } +} +``` + +or, with the shim on `PATH`, use the fully qualified name directly without +any registration: `docker run --runtime=io.containerd.pvisor.v2 ...`. + +## How it works + +``` +containerd ──ttrpc── PvisorTask (Task service) + │ Create: plan from config.json + request mounts + │ re-exec self as init parent (A) + ├─ A: setns/unshare → mount rootfs+binds → fork G + │ → cgroup limits → relay readiness + └─ G: mount proc → open FIFOs/console PTY → + pivot_root → wait for Start → set ids/caps → + execve(args) +``` + +- `plan.rs` turns the OCI spec plus the `Create` request into one + serializable `ContainerPlan` (cross-platform, unit-tested). Non-enforced + spec features (seccomp, hooks, maskedPaths, time namespaces) surface as + warnings in the shim log. +- `child.rs` is the self-exec pipeline (the house `INTERNAL_SANDBOX_ARG` + pattern): `Create` produces a created-but-not-running init process that + blocks on a start pipe; `Start` releases it. Exits are reaped by the + framework's SIGCHLD monitor and mapped back onto tasks. +- `service.rs` implements the ttrpc Task service and publishes + `TaskCreate/TaskStart/TaskExit/TaskDelete` events through the containerd + event publisher. + +## VM executor (M3, feature `vm`) + +Bundles annotated with `"io.pvisor.executor": "vm"` run in a libkrun +microVM instead of host namespaces: one VM per task, rootfs shared +read-write over virtio-fs (`/dev/root`), stdio over the virtio-console, VM +shape via `io.pvisor.vm.cpus` / `io.pvisor.vm.memory-mib` (default 2 vCPU / +512 MiB). Kill signals the VM runner (destroying the VM); the guest exit +code propagates through the task exit status. The implicit vsock is +disabled, mirroring pVisor's VM executor posture. + +Build with the feature (Linux host, or cross via `just shim-vm-build`): + +```bash +cargo build -p persisting-shim --features vm +``` + +Host requirements: `/dev/kvm` and `libkrunfw` on the library path. +Not mapped into VMs yet (logged as warnings): spec bind mounts and cgroup +limits (the VM shape is the resource boundary). + +## Guest agent and exec-in-VM (M5, feature `vm`) + +VM tasks boot the shim binary itself as a guest agent: at boot the +(statically linked) binary is copied into the rootfs and the guest init +helper starts it (`io.pvisor.vm.agent=off` disables it). The agent listens +on vsock port 0x7076; libkrun proxies host connections from +`/pvisor-agent.sock` into the guest (so `docker exec` / +`kubectl exec` work on VM tasks): + +- one agent connection per exec; frames are `[channel][length][payload]` + with JSON control messages (`exec_start`/`started`/`exited`) +- the guest process starts at `Exec` time (containerd's `Start` reports + the pid; there is no two-phase gate across the VM boundary) +- killing an exec drops the connection; the agent SIGKILLs the process +- tty exec in VMs is not supported yet + +Pod-level VM sandboxes (per-container rootfs and namespaces inside one VM +per pod, TC/TAP pod networking) remain the open item for the VM path. + +## M4 status + +Pod-level sandboxes are in (host path): Create/Start/Wait/Stop/Shutdown/ +Platform/Ping/Status on the Sandbox service, a holder process that owns the +pod namespaces (pause replacement, including shareProcessNamespace pods), +and containers that join the shared namespaces unless their spec overrides +them. Pod-level **VM** sandboxes return a clear error until the guest agent +lands (M5); per-container VMs via `io.pvisor.executor=vm` keep working. + +## M2 limitations (deliberate) + +- Exec joins the init process's namespaces via `setns`; it needs `CAP_SYS_ADMIN` + (rootful containerd). Rootless exec is not supported yet. +- Pod-level Sandbox API and per-pod VMs are M3/M4 (Kata-style); today each + task runs in its own namespace set on the host. +- Seccomp profiles, OCI hooks, maskedPaths/readonlyPaths, device cgroups, + and systemd cgroup delegation are ignored (logged as warnings). +- Stats, pause/resume, and checkpointing are unimplemented. +- VM exec: no tty, and the process starts at `Exec` time (see above). + +## Developing + +Pure logic (`plan`, `state`, `caps`, `cgroup`, mount option parsing) is +cross-platform and unit-tested on any host: + +```bash +just test shim # or: cargo nextest run -p persisting-shim +``` + +The syscall paths compile-check cross-platform; full builds need Linux: + +```bash +just shim-check # cargo check + clippy for x86_64-unknown-linux-gnu +``` + +## Linux acceptance smoke test + +On a Linux host with containerd ≥ 1.7: + +```bash +cargo build --release -p persisting-shim +sudo install -m755 target/release/containerd-shim-pvisor-v2 /usr/local/bin/ +sudo systemctl restart containerd +sudo ctr run --runtime io.containerd.pvisor.v2 -t --rm \ + docker.io/library/busybox:latest pvisor-smoke echo hello from pvisor +``` + +Expected: the container prints `hello from pvisor`, the task exits with +status 0, and `journalctl -u containerd` shows the four task events. +With Docker ≥ 23: `docker run --rm --runtime=io.containerd.pvisor.v2 +busybox echo hello` (registration optional, see above); interactive and +exec flows are the M2 additions to try: `docker run -it --rm +--runtime=io.containerd.pvisor.v2 busybox sh`, `docker exec ls /`, +and `echo hi | docker run -i --rm --runtime=io.containerd.pvisor.v2 busybox cat`. + +[containerd Runtime v2]: https://github.com/containerd/containerd/blob/main/docs/runtime-v2.md diff --git a/crates/persisting-shim/src/agent.rs b/crates/persisting-shim/src/agent.rs new file mode 100644 index 00000000..9b3d8c4f --- /dev/null +++ b/crates/persisting-shim/src/agent.rs @@ -0,0 +1,417 @@ +//! The guest agent protocol: exec processes inside libkrun VMs. +//! +//! Transport: one vsock connection per exec. The shim (host) connects to a +//! unix socket that libkrun proxies into the VM (`krun_add_vsock_port2` +//! with `listen = true`); the agent (guest) listens on the vsock port and +//! serves each connection with one exec'd process. +//! +//! Framing on every connection (little-endian): +//! +//! ```text +//! [channel: u8][length: u32][payload: length bytes] +//! ``` +//! +//! channel 0 carries JSON control messages, 1/2/3 carry stdin/stdout/stderr +//! bytes. A zero-length stdin frame signals EOF. Closing the connection +//! kills the exec'd process on the guest side. + +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; + +/// Well-known vsock port for the pVisor agent. +pub const AGENT_VSOCK_PORT: u32 = 0x7076; // "pv" + +/// CLI argument that turns the shim binary into the guest agent. +pub const AGENT_ARG: &str = "--pvisor-shim-guest-agent"; + +/// Guest-side path the agent binary is copied to at VM boot. +pub const AGENT_GUEST_PATH: &str = "/.pvisor-agent"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Channel { + Control = 0, + Stdin = 1, + Stdout = 2, + Stderr = 3, +} + +impl Channel { + fn from_u8(value: u8) -> Option { + match value { + 0 => Some(Channel::Control), + 1 => Some(Channel::Stdin), + 2 => Some(Channel::Stdout), + 3 => Some(Channel::Stderr), + _ => None, + } + } +} + +/// Control messages (channel 0, JSON payload). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Control { + /// Host -> guest: run this process; reply `started`, then stream. + ExecStart { + argv: Vec, + env: Vec, + cwd: String, + }, + /// Guest -> host: the process is running with this guest pid. + Started { pid: u32 }, + /// Guest -> host: the process exited with this status. + Exited { status: u32 }, + /// Either direction: something went wrong for this connection. + Error { message: String }, +} + +/// Encode one frame into `out`. +pub fn encode_frame(out: &mut Vec, channel: Channel, payload: &[u8]) { + out.push(channel as u8); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(payload); +} + +/// Encode one control message as a frame. +pub fn encode_control(out: &mut Vec, message: &Control) -> serde_json::Result<()> { + let payload = serde_json::to_vec(message)?; + encode_frame(out, Channel::Control, &payload); + Ok(()) +} + +/// One decoded frame. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Frame { + pub channel: Channel, + pub payload: Vec, +} + +/// Blocking frame reader over any `Read`. +pub struct FrameReader { + inner: R, +} + +impl FrameReader { + pub fn new(inner: R) -> Self { + FrameReader { inner } + } + + pub fn read_frame(&mut self) -> std::io::Result> { + let mut header = [0u8; 5]; + match self.inner.read_exact(&mut header) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(error) => return Err(error), + } + let Some(channel) = Channel::from_u8(header[0]) else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid channel {}", header[0]), + )); + }; + let length = u32::from_le_bytes([header[1], header[2], header[3], header[4]]) as usize; + let mut payload = vec![0u8; length]; + self.inner.read_exact(&mut payload)?; + Ok(Some(Frame { channel, payload })) + } + + pub fn read_control(&mut self) -> std::io::Result> { + loop { + let Some(frame) = self.read_frame()? else { + return Ok(None); + }; + match frame.channel { + Channel::Control => { + let message = serde_json::from_slice(&frame.payload).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid control message: {error}"), + ) + })?; + return Ok(Some(message)); + } + // Bytes before the first control message cannot be routed + // anywhere yet; drop them. + _ => continue, + } + } + } +} + +/// Blocking frame writer over any `Write`. +pub struct FrameWriter { + inner: W, +} + +impl FrameWriter { + pub fn new(inner: W) -> Self { + FrameWriter { inner } + } + + pub fn write_frame(&mut self, channel: Channel, payload: &[u8]) -> std::io::Result<()> { + let mut buffer = Vec::with_capacity(5 + payload.len()); + encode_frame(&mut buffer, channel, payload); + self.inner.write_all(&buffer)?; + self.inner.flush() + } + + pub fn write_control(&mut self, message: &Control) -> std::io::Result<()> { + let payload = serde_json::to_vec(message).map_err(|error| { + std::io::Error::other(format!("serialize control message: {error}")) + })?; + self.write_frame(Channel::Control, &payload) + } +} + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + + /// Guest agent entry; Ok(false) when not invoked in agent mode, + /// otherwise never returns. + pub fn run_guest_agent_if_requested() -> anyhow::Result { + let args: Vec = std::env::args().collect(); + if !args.iter().any(|arg| arg == AGENT_ARG) { + return Ok(false); + } + let code = match guest_agent_main() { + Ok(()) => 0, + Err(error) => { + eprintln!("pvisor shim guest agent: {error:#}"); + 1 + } + }; + std::process::exit(code) + } + + /// Listen on the vsock port and serve one exec per connection. + fn guest_agent_main() -> anyhow::Result<()> { + let listener = vsock_listen(AGENT_VSOCK_PORT)?; + eprintln!("pvisor shim guest agent listening on vsock:{AGENT_VSOCK_PORT}"); + loop { + let (stream, _) = listener.accept()?; + std::thread::spawn(move || { + if let Err(error) = serve_connection(stream) { + eprintln!("pvisor shim guest agent session: {error:#}"); + } + }); + } + } + + fn vsock_listen(port: u32) -> anyhow::Result { + use std::os::fd::FromRawFd; + use std::os::unix::net::UnixListener as VsockListener; + // AF_VSOCK has the same socket API shape as AF_UNIX on Linux; use a + // sockaddr_vm built by hand. + let fd = unsafe { libc::socket(libc::AF_VSOCK, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) }; + if fd < 0 { + anyhow::bail!("socket(AF_VSOCK): {}", std::io::Error::last_os_error()); + } + let address = libc::sockaddr_vm { + svm_family: libc::AF_VSOCK as u16, + svm_reserved1: 0, + svm_port: port, + svm_cid: libc::VMADDR_CID_ANY, + svm_zero: [0; 4], + }; + let bind = unsafe { + libc::bind( + fd, + std::ptr::from_ref(&address).cast(), + std::mem::size_of::() as libc::socklen_t, + ) + }; + if bind != 0 || unsafe { libc::listen(fd, 16) } != 0 { + let error = std::io::Error::last_os_error(); + unsafe { libc::close(fd) }; + anyhow::bail!("vsock listen on {port}: {error}"); + } + // SAFETY-ish: the fd is a listening AF_VSOCK socket; treating it as + // a UnixListener keeps the I/O methods without touching path logic. + // We never call path-based methods on it. + let listener = unsafe { VsockListener::from_raw_fd(fd) }; + Ok(listener) + } + + /// Serve one exec: read the start request, spawn, stream, report exit. + fn serve_connection(stream: std::os::unix::net::UnixStream) -> anyhow::Result<()> { + let read_half = stream.try_clone()?; + let mut writer = FrameWriter::new(stream); + + let Some(Control::ExecStart { argv, env, cwd }) = + FrameReader::new(&read_half).read_control()? + else { + anyhow::bail!("expected exec start request"); + }; + if argv.is_empty() { + writer.write_control(&Control::Error { + message: "empty argv".to_string(), + })?; + return Ok(()); + } + + let mut child = std::process::Command::new(&argv[0]) + .args(&argv[1..]) + .env_clear() + .envs(env.iter().map(|entry| split_env(entry))) + .current_dir(&cwd) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + + let pid = child.id(); + writer.write_control(&Control::Started { pid })?; + + // Guest process output -> frames. + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let writer_stdout = writer.inner.try_clone()?; + let writer_stderr = writer.inner.try_clone()?; + let out_handle = + std::thread::spawn(move || pump_to_frames(stdout, writer_stdout, Channel::Stdout)); + let err_handle = + std::thread::spawn(move || pump_to_frames(stderr, writer_stderr, Channel::Stderr)); + + // Socket -> child stdin in a thread of its own: the main thread + // blocks in `wait` instead, so a process that exits while the host + // keeps the connection open still gets its Exited message. + let mut reader = FrameReader::new(read_half); + let mut child_stdin = child.stdin.take(); + let kill_target = pid as i32; + let stdin_handle = std::thread::spawn(move || { + while let Ok(Some(frame)) = reader.read_frame() { + if frame.channel != Channel::Stdin || frame.payload.is_empty() { + continue; + } + let Some(stdin) = child_stdin.as_mut() else { + break; + }; + if stdin.write_all(&frame.payload).is_err() { + break; + } + } + // Socket EOF is the host-side kill for this exec. + unsafe { libc::kill(kill_target, libc::SIGKILL) }; + }); + + let status = child + .wait() + .ok() + .and_then(|status| status.code()) + .unwrap_or(255) as u32; + let _ = writer.write_control(&Control::Exited { status }); + // Dropping our writer closes the guest side; the host then closes + // its end, which unblocks and ends the stdin thread. + drop(writer); + let _ = stdin_handle.join(); + let _ = out_handle.join(); + let _ = err_handle.join(); + Ok(()) + } + + fn pump_to_frames(input: Option, mut output: W, channel: Channel) { + let Some(mut input) = input else { return }; + let mut buffer = [0u8; 8192]; + loop { + match input.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(n) => { + if FrameWriter::new(&mut output) + .write_frame(channel, &buffer[..n]) + .is_err() + { + break; + } + } + } + } + } + + fn split_env(entry: &str) -> (String, String) { + match entry.split_once('=') { + Some((key, value)) => (key.to_string(), value.to_string()), + None => (entry.to_string(), String::new()), + } + } +} + +#[cfg(target_os = "linux")] +pub use linux::run_guest_agent_if_requested; + +#[cfg(test)] +mod tests { + use super::*; + + fn control_bytes(message: &Control) -> Vec { + serde_json::to_vec(message).expect("serialize control") + } + + #[test] + fn frames_round_trip_through_the_codec() { + let mut buffer = Vec::new(); + encode_frame( + &mut buffer, + Channel::Control, + &control_bytes(&Control::Started { pid: 4242 }), + ); + encode_frame(&mut buffer, Channel::Stdin, b"hello"); + encode_frame(&mut buffer, Channel::Stdout, &[]); + encode_frame(&mut buffer, Channel::Stderr, &[0xff, 0xfe]); + + let mut reader = FrameReader::new(&buffer[..]); + let first = reader.read_frame().expect("first frame").expect("some"); + assert_eq!(first.channel, Channel::Control); + let message: Control = serde_json::from_slice(&first.payload).expect("control"); + assert_eq!(message, Control::Started { pid: 4242 }); + + let second = reader.read_frame().expect("second frame").expect("some"); + assert_eq!( + (second.channel, second.payload.as_slice()), + (Channel::Stdin, b"hello".as_slice()) + ); + + let third = reader.read_frame().expect("third frame").expect("some"); + assert_eq!(third.channel, Channel::Stdout); + assert!(third.payload.is_empty()); + + let fourth = reader.read_frame().expect("fourth frame").expect("some"); + assert_eq!(fourth.channel, Channel::Stderr); + assert_eq!(fourth.payload, vec![0xff, 0xfe]); + + assert!(reader.read_frame().expect("eof").is_none()); + } + + #[test] + fn control_messages_survive_serde() { + let messages = vec![ + Control::ExecStart { + argv: vec!["/bin/ls".to_string(), "-l /tmp with space".to_string()], + env: vec!["A=b c".to_string()], + cwd: "/work dir".to_string(), + }, + Control::Started { pid: 1 }, + Control::Exited { status: 137 }, + Control::Error { + message: "nope".to_string(), + }, + ]; + for message in &messages { + let mut buffer = Vec::new(); + encode_control(&mut buffer, message).expect("encode"); + let mut reader = FrameReader::new(&buffer[..]); + let decoded = reader.read_control().expect("read").expect("some"); + assert_eq!(&decoded, message); + } + } + + #[test] + fn invalid_channel_is_rejected() { + let mut buffer = Vec::new(); + buffer.push(9); + buffer.extend_from_slice(&0u32.to_le_bytes()); + let mut reader = FrameReader::new(&buffer[..]); + let error = reader.read_frame().expect_err("invalid channel"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } +} diff --git a/crates/persisting-shim/src/caps.rs b/crates/persisting-shim/src/caps.rs new file mode 100644 index 00000000..b48e0275 --- /dev/null +++ b/crates/persisting-shim/src/caps.rs @@ -0,0 +1,121 @@ +//! Linux capability name -> bit mapping. +//! +//! The init child applies capability sets through `capset(2)`, which takes +//! u32 bitmask pairs; names arrive from the OCI spec (with or without the +//! `CAP_` prefix depending on the serializer). + +use std::collections::HashMap; +use std::sync::LazyLock; + +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +#[error("unknown capability: {0}")] +pub struct UnknownCapability(pub String); + +/// bit index of every capability known to Linux 6.x (0..=40). +const CAPABILITY_BITS: &[(&str, u32)] = &[ + ("CHOWN", 0), + ("DAC_OVERRIDE", 1), + ("DAC_READ_SEARCH", 2), + ("FOWNER", 3), + ("FSETID", 4), + ("KILL", 5), + ("SETGID", 6), + ("SETUID", 7), + ("SETPCAP", 8), + ("LINUX_IMMUTABLE", 9), + ("NET_BIND_SERVICE", 10), + ("NET_BROADCAST", 11), + ("NET_ADMIN", 12), + ("NET_RAW", 13), + ("IPC_LOCK", 14), + ("IPC_OWNER", 15), + ("SYS_MODULE", 16), + ("SYS_RAWIO", 17), + ("SYS_CHROOT", 18), + ("SYS_PTRACE", 19), + ("SYS_PACCT", 20), + ("SYS_ADMIN", 21), + ("SYS_BOOT", 22), + ("SYS_NICE", 23), + ("SYS_RESOURCE", 24), + ("SYS_TIME", 25), + ("SYS_TTY_CONFIG", 26), + ("MKNOD", 27), + ("LEASE", 28), + ("AUDIT_WRITE", 29), + ("AUDIT_CONTROL", 30), + ("SETFCAP", 31), + ("MAC_OVERRIDE", 32), + ("MAC_ADMIN", 33), + ("SYSLOG", 34), + ("WAKE_ALARM", 35), + ("BLOCK_SUSPEND", 36), + ("AUDIT_READ", 37), + ("PERFMON", 38), + ("BPF", 39), + ("CHECKPOINT_RESTORE", 40), +]; + +static CAPABILITY_INDEX: LazyLock> = + LazyLock::new(|| CAPABILITY_BITS.iter().copied().collect::>()); + +/// Normalize one capability name to the bare, prefix-free form. +fn normalize(name: &str) -> &str { + name.trim() + .strip_prefix("CAP_") + .unwrap_or_else(|| name.trim()) +} + +/// Convert capability names into a u64 bitmask (bit n = capability n). +pub fn mask_from_names(names: I) -> Result +where + I: IntoIterator, + I::Item: AsRef, +{ + let mut mask: u64 = 0; + for name in names { + let name = name.as_ref(); + let key = normalize(name).to_ascii_uppercase(); + let bit = *CAPABILITY_INDEX + .get(key.as_str()) + .ok_or_else(|| UnknownCapability(name.to_string()))?; + mask |= 1 << bit; + } + Ok(mask) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_with_and_without_prefix_map_to_bits() { + assert_eq!(mask_from_names(["CAP_CHOWN"]).unwrap(), 1 << 0); + assert_eq!(mask_from_names(["chown"]).unwrap(), 1 << 0); + assert_eq!(mask_from_names(["NET_ADMIN"]).unwrap(), 1 << 12); + assert_eq!(mask_from_names(["CHECKPOINT_RESTORE"]).unwrap(), 1 << 40); + } + + #[test] + fn duplicate_names_collapse() { + let mask = mask_from_names(["CAP_KILL", "KILL"]).unwrap(); + assert_eq!(mask, 1 << 5); + } + + #[test] + fn unknown_names_are_rejected_with_the_original_name() { + let error = mask_from_names(["CAP_NOT_A_CAP"]).unwrap_err(); + assert_eq!(error.0, "CAP_NOT_A_CAP"); + } + + #[test] + fn every_known_capability_round_trips() { + for (name, bit) in CAPABILITY_BITS { + let mask = mask_from_names([name]).unwrap(); + assert_eq!(mask, 1u64 << bit, "{name}"); + } + assert_eq!(CAPABILITY_BITS.len(), 41); + } +} diff --git a/crates/persisting-shim/src/cgroup.rs b/crates/persisting-shim/src/cgroup.rs new file mode 100644 index 00000000..9418cd29 --- /dev/null +++ b/crates/persisting-shim/src/cgroup.rs @@ -0,0 +1,80 @@ +//! Cgroup v2 paths and values. +//! +//! Limit values are pre-rendered by [`crate::plan`]; this module owns the +//! filesystem layout: where the unified controller is mounted, which files a +//! `CgroupPlan` produces, and how the systemd `slice:prefix:id` notation maps +//! onto directories. + +use crate::plan::CgroupPlan; + +/// Default mount point of the cgroup v2 unified hierarchy. +pub const UNIFIED_MOUNT: &str = "/sys/fs/cgroup"; + +/// Files (relative to the cgroup directory) written for a plan, in order. +pub fn control_files(plan: &CgroupPlan) -> Vec<(&'static str, String)> { + let mut files = Vec::new(); + if let Some(value) = plan.pids_max.as_deref() { + files.push(("pids.max", value.to_string())); + } + if let Some(value) = plan.memory_max.as_deref() { + files.push(("memory.max", value.to_string())); + } + if let Some(value) = plan.cpu_max.as_deref() { + files.push(("cpu.max", value.to_string())); + } + files +} + +/// Absolute directory of the cgroup for a plan, when one is configured. +pub fn cgroup_dir(plan: &CgroupPlan) -> Option { + let path = plan.path.as_deref()?; + if path.is_empty() { + return None; + } + Some(std::path::Path::new(UNIFIED_MOUNT).join(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plan::CgroupPlan; + + #[test] + fn limits_render_as_ordered_files() { + let plan = CgroupPlan { + path: Some("burstable.slice/pod1/c1".to_string()), + pids_max: Some("128".to_string()), + memory_max: Some("max".to_string()), + cpu_max: Some("500000 100000".to_string()), + }; + let files = control_files(&plan); + assert_eq!( + files, + vec![ + ("pids.max", "128".to_string()), + ("memory.max", "max".to_string()), + ("cpu.max", "500000 100000".to_string()), + ] + ); + assert_eq!( + cgroup_dir(&plan), + Some(std::path::PathBuf::from( + "/sys/fs/cgroup/burstable.slice/pod1/c1" + )) + ); + } + + #[test] + fn no_path_means_no_directory() { + let plan = CgroupPlan { + path: None, + ..CgroupPlan::default() + }; + assert!(cgroup_dir(&plan).is_none()); + let empty = CgroupPlan { + path: Some(String::new()), + ..CgroupPlan::default() + }; + assert!(cgroup_dir(&empty).is_none()); + } +} diff --git a/crates/persisting-shim/src/child.rs b/crates/persisting-shim/src/child.rs new file mode 100644 index 00000000..d35501ec --- /dev/null +++ b/crates/persisting-shim/src/child.rs @@ -0,0 +1,1088 @@ +//! The container process pipeline. +//! +//! Both the init process and exec processes follow the same shape (house +//! "self-exec" pattern, cf. pvisor's INTERNAL_SANDBOX_ARG): +//! +//! - **init**: `Create` re-executes the shim binary as the internal parent +//! (A). A enters the configured namespaces, mounts the rootfs, forks the +//! init process (G), and relays G's readiness over a pipe. G mounts +//! procfs, pivots into the container root, wires the inherited IO fds, +//! signals readiness, and blocks on the start pipe until `Start`. +//! - **exec**: `Exec` re-executes as the exec parent (E). E joins the init +//! process's namespaces (`/proc//ns/*`), forks the exec process +//! (F), which wires the same inherited IO fds and blocks until +//! `Start(exec_id)`. +//! +//! After the start byte both paths run the shared tail: apply uid/gid/ +//! capabilities/rlimits, `chdir`, `execve`. +//! +//! File-descriptor inheritance (pipes are non-CLOEXEC so they survive the +//! exec into A/E): +//! +//! ```text +//! pipe shim A/E (internal parent) G/F (process) +//! ready read end write end -> relay closed +//! start write end closed after fork read end, blocks +//! fork report closed read end write end +//! ``` +//! The workload-facing stdio descriptors are opened by the shim +//! ([`crate::fifo::ContainerIo`]) and travel the same way; each child closes +//! every other inherited fd before exec so the workload only sees stdio. + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::os::fd::{FromRawFd, RawFd}; +use std::path::Path; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; +use log::{debug, warn}; +use serde::{Deserialize, Serialize}; + +use crate::caps; +use crate::cgroup; +use crate::mount; +use crate::plan::{CgroupPlan, ContainerPlan, ExecPlan, IdMappingPlan, NamespaceKind, ProcessPlan}; + +/// CLI argument that turns the shim binary into the internal init parent. +pub const INTERNAL_INIT_ARG: &str = "--pvisor-shim-internal-init"; +/// CLI argument that turns the shim binary into the internal exec parent. +pub const INTERNAL_EXEC_ARG: &str = "--pvisor-shim-internal-exec"; +/// CLI argument that turns the shim binary into the internal VM runner. +#[cfg(feature = "vm")] +pub const INTERNAL_VM_ARG: &str = "--pvisor-shim-internal-vm"; +/// Path of the serialized [`ContainerPlan`]. +pub const ENV_PLAN: &str = "PVISOR_SHIM_INIT_PLAN"; +/// Path of the serialized [`ExecPlan`]. +pub const ENV_EXEC_PLAN: &str = "PVISOR_SHIM_EXEC_PLAN"; +/// Write end of the ready pipe (A/E reports to the shim). +pub const ENV_READY_FD: &str = "PVISOR_SHIM_INIT_READY_FD"; +/// Read end of the start pipe (G/F waits for one byte). +pub const ENV_START_FD: &str = "PVISOR_SHIM_INIT_START_FD"; +/// Write end of the fork report pipe (G/F reports setup to A/E). +pub const ENV_FORK_REPORT_FD: &str = "PVISOR_SHIM_INIT_FORK_REPORT_FD"; +/// Read end of the fork report pipe (A/E relays it to the shim). +pub const ENV_FORK_REPORT_READ_FD: &str = "PVISOR_SHIM_FORK_REPORT_READ_FD"; +/// CLI argument that turns the shim binary into the pod sandbox holder. +pub const INTERNAL_SANDBOX_ARG: &str = "--pvisor-shim-internal-sandbox"; +/// Path of the serialized [`crate::plan::SandboxPlan`]. +pub const ENV_SANDBOX_PLAN: &str = "PVISOR_SHIM_SANDBOX_PLAN"; +/// Pid of the pod sandbox holder; container runners join its namespaces. +pub const ENV_SANDBOX_PID: &str = "PVISOR_SHIM_SANDBOX_PID"; +/// Workload stdio descriptors handed over by the shim. +pub const ENV_STDIO_IN: &str = "PVISOR_SHIM_STDIO_IN"; +pub const ENV_STDIO_OUT: &str = "PVISOR_SHIM_STDIO_OUT"; +pub const ENV_STDIO_ERR: &str = "PVISOR_SHIM_STDIO_ERR"; +pub const ENV_STDIO_TERMINAL: &str = "PVISOR_SHIM_STDIO_TERMINAL"; + +/// Message exchanged over the ready/fork-report pipes, one JSON line. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum InitReport { + Ok { pid: u32 }, + Error { context: String, errno: i32 }, +} + +impl InitReport { + fn line(&self) -> Result> { + let mut line = serde_json::to_vec(self).context("serialize init report")?; + line.push(b'\n'); + Ok(line) + } + + fn parse(line: &str) -> Result { + serde_json::from_str(line.trim_end()).context("parse init report") + } +} + +/// Host-side state kept by the task service between Create/Exec and Start. +pub struct InternalChild { + /// PID of the workload process, set once the parent reports readiness. + pub pid: Option, + /// Write end of the start pipe; held until Start (or cleanup). + start_fd: RawFd, +} + +impl InternalChild { + /// An internal child with nothing left to release; useful as a + /// placeholder when moving the real one out of a slot. + pub fn exited() -> Self { + InternalChild { + pid: None, + start_fd: -1, + } + } + + /// Signal the child to exec; consumes the start pipe write end. + pub fn start(&mut self) -> Result<()> { + let byte = b"s"; + let written = unsafe { libc::write(self.start_fd, byte.as_ptr().cast(), byte.len()) }; + self.close_start(); + if written < 0 { + return Err(std::io::Error::last_os_error()) + .context("write start pipe (process may have exited)"); + } + Ok(()) + } + + /// Release the start pipe write end; the child sees EOF and exits. + pub fn close_start(&mut self) { + if self.start_fd >= 0 { + unsafe { libc::close(self.start_fd) }; + self.start_fd = -1; + } + } +} + +impl Drop for InternalChild { + fn drop(&mut self) { + self.close_start(); + } +} + +fn make_pipe() -> Result<(RawFd, RawFd)> { + let mut fds = [0 as libc::c_int; 2]; + if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 { + return Err(std::io::Error::last_os_error()).context("pipe"); + } + Ok((fds[0], fds[1])) +} + +/// The stdio descriptors the shim handed over, parsed back in the child. +#[derive(Clone, Copy, Debug)] +pub struct StdioFds { + pub stdin: RawFd, + pub stdout: RawFd, + pub stderr: RawFd, + pub terminal: bool, +} + +impl StdioFds { + fn from_env() -> Result { + let stdin = fd_from_env(ENV_STDIO_IN)?; + let stdout = fd_from_env(ENV_STDIO_OUT)?; + let stderr = fd_from_env(ENV_STDIO_ERR)?; + let terminal = std::env::var(ENV_STDIO_TERMINAL).as_deref() == Ok("1"); + Ok(StdioFds { + stdin, + stdout, + stderr, + terminal, + }) + } +} + +/// Re-exec the shim binary as an internal parent and wait for its relay. +/// +/// `stdio` are the shim-opened workload descriptors (passed through env); +/// `plan_bytes` is the serialized plan written to `plan_path`. Blocks until +/// the workload process signals readiness, so call from a blocking context. +pub fn spawn_internal( + arg: &str, + plan_path: &Path, + plan_bytes: &[u8], + stdio: Option, + sandbox_pid: Option, +) -> Result { + spawn_internal_opts(arg, plan_path, plan_bytes, stdio, sandbox_pid, true) +} + +/// [`spawn_internal`] with control over waiting for the internal parent to +/// exit: the sandbox holder IS the parent and stays alive, so it must be +/// spawned detached. +pub fn spawn_internal_opts( + arg: &str, + plan_path: &Path, + plan_bytes: &[u8], + stdio: Option, + sandbox_pid: Option, + wait_parent_exit: bool, +) -> Result { + fs::write(plan_path, plan_bytes).with_context(|| format!("write {}", plan_path.display()))?; + + let (ready_r, ready_w) = make_pipe()?; + let (start_r, start_w) = make_pipe()?; + let (fork_r, fork_w) = make_pipe()?; + + let exe = std::env::current_exe().context("current exe")?; + let mut command = Command::new(exe); + command + .arg(arg) + .env(ENV_READY_FD, ready_w.to_string()) + .env(ENV_START_FD, start_r.to_string()) + .env(ENV_FORK_REPORT_FD, fork_w.to_string()) + .env(ENV_FORK_REPORT_READ_FD, fork_r.to_string()); + let plan_env = if arg == INTERNAL_EXEC_ARG { + ENV_EXEC_PLAN + } else if arg == INTERNAL_SANDBOX_ARG { + ENV_SANDBOX_PLAN + } else { + ENV_PLAN + }; + command.env(plan_env, plan_path); + if let Some(pid) = sandbox_pid { + command.env(ENV_SANDBOX_PID, pid.to_string()); + } + if let Some(stdio) = stdio { + command + .env(ENV_STDIO_IN, stdio.stdin.to_string()) + .env(ENV_STDIO_OUT, stdio.stdout.to_string()) + .env(ENV_STDIO_ERR, stdio.stderr.to_string()) + .env(ENV_STDIO_TERMINAL, if stdio.terminal { "1" } else { "0" }); + } + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .spawn() + .context("spawn internal process")?; + + // The internal parent inherited every end; drop the copies this + // process must not hold. + for fd in [ready_w, start_r, fork_r, fork_w] { + unsafe { libc::close(fd) }; + } + + let mut line = String::new(); + { + let mut reader = BufReader::new(unsafe { fs::File::from_raw_fd(ready_r) }); + reader.read_line(&mut line).context("read init report")?; + } + if wait_parent_exit { + let _ = child.wait(); + } else { + // Detached: the parent stays alive; dropping the handle leaks it + // into init (the shim is a subreaper, so reaping still works). + std::mem::forget(child); + } + + match InitReport::parse(&line)? { + InitReport::Ok { pid } => Ok(InternalChild { + pid: Some(pid), + start_fd: start_w, + }), + InitReport::Error { context, errno } => { + unsafe { libc::close(start_w) }; + anyhow::bail!("internal process setup failed: {context} (errno {errno})"); + } + } +} + +/// Entry point of the internal parents. Returns Ok(false) when the binary +/// was not invoked in internal mode; otherwise never returns. +pub fn run_internal_if_requested() -> Result { + let args: Vec = std::env::args().collect(); + #[cfg(feature = "vm")] + if args.iter().any(|arg| arg == INTERNAL_VM_ARG) { + return run_vm_runner_requested(); + } + if args.iter().any(|arg| arg == INTERNAL_SANDBOX_ARG) { + return run_sandbox_holder_requested(); + } + let (mode_arg, main) = if args.iter().any(|arg| arg == INTERNAL_INIT_ARG) { + (INTERNAL_INIT_ARG, init_parent_main as fn() -> Result<()>) + } else if args.iter().any(|arg| arg == INTERNAL_EXEC_ARG) { + (INTERNAL_EXEC_ARG, exec_parent_main as fn() -> Result<()>) + } else { + return Ok(false); + }; + let exit_code = match main() { + Ok(()) => 0, + Err(error) => { + let report = InitReport::Error { + context: format!("{error:#}"), + errno: 1, + }; + let mut delivered = false; + if let Some(mut file) = ready_pipe_from_env() { + delivered = file + .write_all(&report.line().unwrap_or_default()) + .and_then(|()| file.flush()) + .is_ok(); + } + if !delivered { + eprintln!("pvisor shim internal ({mode_arg}) failed: {error:#}"); + } + 1 + } + }; + std::process::exit(exit_code); +} + +fn fd_from_env(name: &str) -> Result { + std::env::var(name) + .with_context(|| format!("missing {name}"))? + .parse() + .with_context(|| format!("parse {name}")) +} + +fn ready_pipe_from_env() -> Option { + fd_from_env(ENV_READY_FD) + .ok() + .map(|fd| unsafe { fs::File::from_raw_fd(fd) }) +} + +fn read_plan Deserialize<'de>>(env: &str) -> Result { + let plan_path = std::env::var(env).with_context(|| format!("missing {env}"))?; + serde_json::from_slice(&fs::read(&plan_path).with_context(|| format!("read {plan_path}"))?) + .with_context(|| format!("parse {plan_path}")) +} + +fn init_parent_main() -> Result<()> { + let plan: ContainerPlan = read_plan(ENV_PLAN)?; + let start_fd = fd_from_env(ENV_START_FD)?; + let fork_report_fd = fd_from_env(ENV_FORK_REPORT_FD)?; + + // Namespace entry order matters: join existing namespaces (CRI sandbox + // network et al.) before unsharing the private ones. + join_namespace_paths(&plan)?; + // Containers of a pod join the sandbox holder's shared namespaces for + // anything their own spec does not configure explicitly. + if let Some(sandbox_pid) = sandbox_pid_from_env() { + join_sandbox_namespaces(&plan, sandbox_pid); + } + let unshare_flags = collect_unshare_flags(&plan); + if unshare_flags != 0 && unsafe { libc::unshare(unshare_flags) } != 0 { + return Err(std::io::Error::last_os_error()) + .context(format!("unshare(0x{unshare_flags:x})")); + } + if plan.has_new_user_namespace() { + setup_user_namespace(&plan.uid_mappings, &plan.gid_mappings)?; + } + mount::make_mounts_private()?; + mount::apply_mounts(&plan.rootfs_mounts, &plan.rootfs)?; + mount::apply_mounts(&plan.mounts, &plan.rootfs)?; + if plan.has_new_uts_namespace() + && let Some(hostname) = plan.hostname.as_deref() + && unsafe { libc::sethostname(hostname.as_ptr().cast(), hostname.len()) } != 0 + { + return Err(std::io::Error::last_os_error()).context(format!("sethostname {hostname}")); + } + + let g_pid = unsafe { libc::fork() }; + if g_pid < 0 { + return Err(std::io::Error::last_os_error()).context("fork init process"); + } + if g_pid == 0 { + init_process_main(&plan, start_fd, fork_report_fd); + } + let g_pid = g_pid as u32; + + // A no longer needs the start pipe or the report write end (G holds + // the only copy, so its exit closes the pipe). + unsafe { libc::close(start_fd) }; + unsafe { libc::close(fork_report_fd) }; + + // Cgroup bookkeeping with host paths still visible. + if let Some(cgroup_plan) = plan.cgroup.as_ref() + && let Err(error) = attach_cgroup(cgroup_plan, g_pid) + { + warn!("cgroup setup skipped: {error:#}"); + } + + relay_fork_report(fork_report_fd, g_pid) +} + +/// VM runner: wait for Start, then boot the VM and exit with its code. +/// Returns Ok(false) only if the process was not invoked in VM mode. +#[cfg(feature = "vm")] +fn run_vm_runner_requested() -> Result { + let exit_code = match vm_runner_main() { + Ok(code) => code, + Err(error) => { + // Best effort: surface the failure through the ready pipe so + // Create fails cleanly instead of hanging. + let report = InitReport::Error { + context: format!("{error:#}"), + errno: 1, + }; + let mut delivered = false; + if let Some(mut file) = ready_pipe_from_env() { + delivered = file + .write_all(&report.line().unwrap_or_default()) + .and_then(|()| file.flush()) + .is_ok(); + } + if !delivered { + eprintln!("pvisor shim VM runner failed: {error:#}"); + } + 1 + } + }; + std::process::exit(exit_code); +} + +/// The VM is the isolation boundary: no container namespaces here. A +/// private mount namespace keeps the snapshotter materialization off the +/// host while the in-process virtio-fs still serves it to the guest. +#[cfg(feature = "vm")] +fn vm_runner_main() -> Result { + let plan: ContainerPlan = read_plan(ENV_PLAN)?; + let start_fd = fd_from_env(ENV_START_FD)?; + + if unsafe { libc::unshare(libc::CLONE_NEWNS) } != 0 { + return Err(std::io::Error::last_os_error()).context("unshare(CLONE_NEWNS)"); + } + mount::make_mounts_private()?; + // Spec mounts (proc/sysfs/...) are guest-kernel business in a VM; only + // the snapshotter rootfs entries need materializing. + mount::apply_mounts(&plan.rootfs_mounts, &plan.rootfs)?; + + // Readiness: this process is the task pid from containerd's point of + // view, and it stays alive for the whole VM lifetime. + { + let mut ready = ready_pipe_from_env().context("ready pipe")?; + let message = InitReport::Ok { + pid: std::process::id(), + }; + ready + .write_all(&message.line()?) + .and_then(|()| ready.flush()) + .context("report VM readiness")?; + } + + // Wait for Start (one byte). EOF means the shim gave up on this task. + let mut byte = [0u8; 1]; + let read = unsafe { libc::read(start_fd, byte.as_mut_ptr().cast(), 1) }; + unsafe { libc::close(start_fd) }; + if read <= 0 { + return Ok(255); + } + + // The virtio-console host side is wired to this process's stdio. + let stdio = StdioFds::from_env()?; + unsafe { + libc::dup2(stdio.stdin, libc::STDIN_FILENO); + libc::dup2(stdio.stdout, libc::STDOUT_FILENO); + libc::dup2(stdio.stderr, libc::STDERR_FILENO); + } + + crate::vm::boot_vm(&plan) +} + +/// Sandbox holder entry; never returns when invoked in holder mode. +fn run_sandbox_holder_requested() -> Result { + let exit_code = match sandbox_holder_main() { + Ok(()) => 0, + Err(error) => { + let report = InitReport::Error { + context: format!("{error:#}"), + errno: 1, + }; + let mut delivered = false; + if let Some(mut file) = ready_pipe_from_env() { + delivered = file + .write_all(&report.line().unwrap_or_default()) + .and_then(|()| file.flush()) + .is_ok(); + } + if !delivered { + eprintln!("pvisor shim sandbox holder failed: {error:#}"); + } + 1 + } + }; + std::process::exit(exit_code); +} + +/// The sandbox holder replaces CRI's pause container: it owns the pod +/// namespaces (uts/ipc always new, network joined or new, pid new only for +/// shareProcessNamespace pods) and lives until ShutdownSandbox. +fn sandbox_holder_main() -> Result<()> { + let plan: crate::plan::SandboxPlan = read_plan(ENV_SANDBOX_PLAN)?; + let start_fd = fd_from_env(ENV_START_FD)?; + + if let Some(netns) = plan.netns_path.as_deref() { + setns_by_path(netns, NamespaceKind::Network).context("join sandbox network namespace")?; + } + let mut flags = libc::CLONE_NEWUTS | libc::CLONE_NEWIPC; + if plan.share_pid_namespace { + flags |= libc::CLONE_NEWPID; + } + if unsafe { libc::unshare(flags) } != 0 { + return Err(std::io::Error::last_os_error()).context("unshare sandbox namespaces"); + } + if let Some(hostname) = plan.hostname.as_deref() + && unsafe { libc::sethostname(hostname.as_ptr().cast(), hostname.len()) } != 0 + { + return Err(std::io::Error::last_os_error()).context(format!("sethostname {hostname}")); + } + + // A pid namespace only exists while a member runs; with pid sharing the + // joinable owner is a sleeper child inside the namespace. + let mut reported_pid = std::process::id(); + if plan.share_pid_namespace { + let sleeper = unsafe { libc::fork() }; + if sleeper < 0 { + return Err(std::io::Error::last_os_error()).context("fork sandbox sleeper"); + } + if sleeper == 0 { + pause_forever(); + } + reported_pid = sleeper as u32; + } + + { + let mut ready = ready_pipe_from_env().context("ready pipe")?; + let message = InitReport::Ok { pid: reported_pid }; + ready + .write_all(&message.line()?) + .and_then(|()| ready.flush()) + .context("report sandbox readiness")?; + } + + let mut byte = [0u8; 1]; + let read = unsafe { libc::read(start_fd, byte.as_mut_ptr().cast(), 1) }; + unsafe { libc::close(start_fd) }; + if read <= 0 { + return Ok(()); + } + pause_forever() +} + +/// Sleep until a signal arrives; used by the sandbox holder and sleeper. +fn pause_forever() -> ! { + loop { + // Signal-driven exit: any delivered signal terminates the default + // disposition, so the sleep only ever returns on EINTR. + std::thread::sleep(std::time::Duration::from_secs(3600)); + } +} + +/// E: join the init process's namespaces, then fork the exec process. +fn exec_parent_main() -> Result<()> { + let plan: ExecPlan = read_plan(ENV_EXEC_PLAN)?; + let start_fd = fd_from_env(ENV_START_FD)?; + let fork_report_fd = fd_from_env(ENV_FORK_REPORT_FD)?; + + join_init_namespaces(plan.init_pid)?; + + let f_pid = unsafe { libc::fork() }; + if f_pid < 0 { + return Err(std::io::Error::last_os_error()).context("fork exec process"); + } + if f_pid == 0 { + exec_process_main(&plan, start_fd, fork_report_fd); + } + let f_pid = f_pid as u32; + + // E no longer needs the start pipe or the report write end (F holds + // the only copy). + unsafe { libc::close(start_fd) }; + unsafe { libc::close(fork_report_fd) }; + relay_fork_report(fork_report_fd, f_pid) +} + +/// Read the child's fork report and relay it to the shim's ready pipe. +fn relay_fork_report(_write_end: RawFd, child_pid: u32) -> Result<()> { + // Read from the pipe's read end; the env var carries it explicitly + // because fd numbers are all the parent has after the self-exec. + let read_end = fd_from_env(ENV_FORK_REPORT_READ_FD).context("fork report read end")?; + let mut line = String::new(); + { + let mut reader = BufReader::new(unsafe { fs::File::from_raw_fd(read_end) }); + reader.read_line(&mut line).context("read fork report")?; + } + let relayed = match InitReport::parse(&line)? { + InitReport::Ok { .. } => InitReport::Ok { pid: child_pid }, + error @ InitReport::Error { .. } => error, + }; + let mut ready = ready_pipe_from_env().context("ready pipe")?; + ready + .write_all(&relayed.line()?) + .and_then(|()| ready.flush()) + .context("relay fork report")?; + Ok(()) +} + +/// The kinds a pod shares through the sandbox holder. +const SANDBOX_SHARED_KINDS: [NamespaceKind; 3] = [ + NamespaceKind::Uts, + NamespaceKind::Ipc, + NamespaceKind::Network, +]; + +fn sandbox_pid_from_env() -> Option { + std::env::var(ENV_SANDBOX_PID).ok()?.parse().ok() +} + +/// Join the holder's shared namespaces for kinds the container spec leaves +/// unconfigured; failures fall back to a fresh namespace (logged). +fn join_sandbox_namespaces(plan: &ContainerPlan, sandbox_pid: u32) { + for kind in SANDBOX_SHARED_KINDS { + if plan.has_namespace(kind) { + continue; + } + let path = format!("/proc/{sandbox_pid}/ns/{}", kind.proc_ns_name()); + match setns_by_path(&path, kind) { + Ok(()) => {} + Err(error) => warn!( + "cannot join sandbox {} namespace of pid {sandbox_pid}: {error:#}", + kind.proc_ns_name() + ), + } + } +} + +fn collect_unshare_flags(plan: &ContainerPlan) -> libc::c_int { + let mut flags = libc::CLONE_NEWNS; + for namespace in &plan.namespaces { + if namespace.path.is_some() { + continue; + } + match namespace.kind { + // CLONE_NEWNS is always part of the unshare call above. + NamespaceKind::Mount => {} + other => flags |= other.clone_flag(), + } + } + flags +} + +fn join_namespace_paths(plan: &ContainerPlan) -> Result<()> { + for namespace in &plan.namespaces { + let Some(path) = namespace.path.as_deref() else { + continue; + }; + setns_by_path(&path.display().to_string(), namespace.kind)?; + } + Ok(()) +} + +/// Join every namespace of the init process we can address, in the order +/// the kernel expects (user namespace first, pid last — pid membership only +/// applies to children, which is why the exec process forks afterwards). +fn join_init_namespaces(init_pid: u32) -> Result<()> { + let order = [ + NamespaceKind::User, + NamespaceKind::Ipc, + NamespaceKind::Uts, + NamespaceKind::Network, + NamespaceKind::Mount, + NamespaceKind::Pid, + ]; + let mut joined = 0; + for kind in order { + let path = format!("/proc/{init_pid}/ns/{}", kind.proc_ns_name()); + if setns_by_path(&path, kind).is_ok() { + joined += 1; + } else { + warn!( + "exec cannot join {} namespace of pid {init_pid}", + kind.proc_ns_name() + ); + } + } + if joined == 0 { + anyhow::bail!("could not join any namespace of pid {init_pid}"); + } + Ok(()) +} + +fn setns_by_path(path: &str, kind: NamespaceKind) -> Result<()> { + let name = kind.proc_ns_name(); + let cpath = + std::ffi::CString::new(path).with_context(|| format!("namespace path {path} has NUL"))?; + let fd = unsafe { libc::open(cpath.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) }; + if fd < 0 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("open {name} namespace at {path}")); + } + let ret = unsafe { libc::setns(fd, kind.clone_flag()) }; + unsafe { libc::close(fd) }; + if ret != 0 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("setns {name} namespace at {path}")); + } + Ok(()) +} + +fn setup_user_namespace( + uid_mappings: &[IdMappingPlan], + gid_mappings: &[IdMappingPlan], +) -> Result<()> { + write_id_map("setgroups", "deny")?; + if uid_mappings.is_empty() { + write_id_map("uid_map", &format!("0 {} 1", unsafe { libc::getuid() }))?; + } else { + write_id_map( + "uid_map", + &uid_mappings + .iter() + .map(IdMappingPlan::render) + .collect::>() + .join("\n"), + )?; + } + if gid_mappings.is_empty() { + write_id_map("gid_map", &format!("0 {} 1", unsafe { libc::getgid() }))?; + } else { + write_id_map( + "gid_map", + &gid_mappings + .iter() + .map(IdMappingPlan::render) + .collect::>() + .join("\n"), + )?; + } + Ok(()) +} + +fn write_id_map(file: &str, content: &str) -> Result<()> { + let path = format!("/proc/self/{file}"); + fs::write(&path, content).with_context(|| format!("write {path}")) +} + +fn attach_cgroup(plan: &CgroupPlan, pid: u32) -> Result<()> { + let Some(dir) = cgroup::cgroup_dir(plan) else { + return Ok(()); + }; + fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + for (file, value) in cgroup::control_files(plan) { + fs::write(dir.join(file), value) + .with_context(|| format!("write {file} in {}", dir.display()))?; + } + fs::write(dir.join("cgroup.procs"), pid.to_string()) + .with_context(|| format!("attach {pid} to {}", dir.display()))?; + Ok(()) +} + +/// G: finish the container setup, report readiness, wait for start, exec. +/// +/// Runs in the forked child of A; never returns. +fn init_process_main(plan: &ContainerPlan, start_fd: RawFd, fork_report_fd: RawFd) -> ! { + let fail = |context: String| -> ! { + let report = InitReport::Error { context, errno: 1 }; + let mut file = unsafe { fs::File::from_raw_fd(fork_report_fd) }; + let _ = file.write_all(&report.line().unwrap_or_default()); + let _ = file.flush(); + std::process::exit(1); + }; + + let stdio = StdioFds::from_env().unwrap_or_else(|error| fail(format!("{error:#}"))); + close_inherited_fds(&[ + 0, + 1, + 2, + start_fd, + fork_report_fd, + stdio.stdin, + stdio.stdout, + stdio.stderr, + ]); + + // procfs must be mounted by a member of the new pid namespace, i.e. G. + if let Err(error) = mount::mount_proc(&plan.rootfs) { + fail(format!("{error:#}")); + } + if let Err(error) = mount::pivot_root(&plan.rootfs) { + fail(format!("{error:#}")); + } + if plan.root_readonly + && let Err(error) = mount::remount_root_readonly() + { + warn!("root read-only remount failed: {error:#}"); + } + + finish_process(&plan.process, stdio, start_fd, fork_report_fd) +} + +/// F: wire the inherited IO, report readiness, wait for start, exec. +/// +/// Runs in the forked child of E, already inside the container namespaces; +/// never returns. +fn exec_process_main(plan: &ExecPlan, start_fd: RawFd, fork_report_fd: RawFd) -> ! { + let fail = |context: String| -> ! { + let report = InitReport::Error { context, errno: 1 }; + let mut file = unsafe { fs::File::from_raw_fd(fork_report_fd) }; + let _ = file.write_all(&report.line().unwrap_or_default()); + let _ = file.flush(); + std::process::exit(1); + }; + + let stdio = StdioFds::from_env().unwrap_or_else(|error| fail(format!("{error:#}"))); + close_inherited_fds(&[ + 0, + 1, + 2, + start_fd, + fork_report_fd, + stdio.stdin, + stdio.stdout, + stdio.stderr, + ]); + + finish_process(&plan.process, stdio, start_fd, fork_report_fd) +} + +/// Shared tail of init and exec processes: stdio, session setup, the +/// start-gate, process identity, and exec. +fn finish_process( + process: &ProcessPlan, + stdio: StdioFds, + start_fd: RawFd, + fork_report_fd: RawFd, +) -> ! { + // Detach into a session/process group so signals addressed to the task + // reach the workload; terminals also claim a controlling tty. + unsafe { + if stdio.terminal { + libc::setsid(); + let ret = libc::ioctl(stdio.stdin, libc::TIOCSCTTY, 0); + if ret != 0 { + warn!("TIOCSCTTY failed: {}", std::io::Error::last_os_error()); + } + } else { + libc::setpgid(0, 0); + } + libc::dup2(stdio.stdin, libc::STDIN_FILENO); + libc::dup2(stdio.stdout, libc::STDOUT_FILENO); + libc::dup2(stdio.stderr, libc::STDERR_FILENO); + } + + // Signal readiness, then stop holding the report pipe open. + { + let message = InitReport::Ok { + pid: std::process::id(), + }; + let mut file = unsafe { fs::File::from_raw_fd(fork_report_fd) }; + let _ = file.write_all(&message.line().unwrap_or_default()); + let _ = file.flush(); + } + + // Wait for Start (one byte). EOF means the shim gave up on this task. + let mut byte = [0u8; 1]; + let read = unsafe { libc::read(start_fd, byte.as_mut_ptr().cast(), 1) }; + unsafe { libc::close(start_fd) }; + if read <= 0 { + std::process::exit(255); + } + + if let Err(error) = apply_process_identity(process) { + eprintln!("pvisor shim: {error:#}"); + std::process::exit(crate::EXEC_FAILURE_EXIT_CODE); + } + if let Err(error) = exec_process(process) { + eprintln!("pvisor shim: exec failed: {error:#}"); + std::process::exit(crate::EXEC_FAILURE_EXIT_CODE); + } + unreachable!("exec never returns on success") +} + +/// Close every open fd except the listed ones. +fn close_inherited_fds(keep: &[RawFd]) { + let Ok(entries) = fs::read_dir("/proc/self/fd") else { + warn!("cannot enumerate /proc/self/fd to close inherited fds"); + return; + }; + // Collect first: closing the directory fd mid-iteration would break it. + let closable: Vec = entries + .flatten() + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter_map(|name| name.parse::().ok()) + .filter(|fd| *fd > 2 && !keep.contains(fd)) + .collect(); + for fd in closable { + unsafe { libc::close(fd) }; + } +} + +fn apply_process_identity(process: &ProcessPlan) -> Result<()> { + let user = &process.user; + + for rlimit in &process.rlimits { + apply_rlimit(rlimit.typ.as_str(), rlimit.soft, rlimit.hard)?; + } + + // SAFETY: identity syscalls in the forked, single-threaded child. + unsafe { + if user.additional_gids.is_empty() { + libc::setgroups(0, std::ptr::null()); + } else { + libc::setgroups(user.additional_gids.len(), user.additional_gids.as_ptr()); + } + libc::setresgid(user.gid, user.gid, user.gid); + libc::setresuid(user.uid, user.uid, user.uid); + } + + apply_capabilities(&process.capabilities)?; + + if let Some(umask) = user.umask { + unsafe { libc::umask(umask) }; + } + if process.no_new_privileges + && unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 + { + return Err(std::io::Error::last_os_error()).context("PR_SET_NO_NEW_PRIVS"); + } + Ok(()) +} + +fn apply_rlimit(typ: &str, soft: u64, hard: u64) -> Result<()> { + let resource = match typ { + "RLIMIT_AS" => libc::RLIMIT_AS, + "RLIMIT_CORE" => libc::RLIMIT_CORE, + "RLIMIT_CPU" => libc::RLIMIT_CPU, + "RLIMIT_DATA" => libc::RLIMIT_DATA, + "RLIMIT_FSIZE" => libc::RLIMIT_FSIZE, + "RLIMIT_LOCKS" => libc::RLIMIT_LOCKS, + "RLIMIT_MEMLOCK" => libc::RLIMIT_MEMLOCK, + "RLIMIT_MSGQUEUE" => libc::RLIMIT_MSGQUEUE, + "RLIMIT_NICE" => libc::RLIMIT_NICE, + "RLIMIT_NOFILE" => libc::RLIMIT_NOFILE, + "RLIMIT_NPROC" => libc::RLIMIT_NPROC, + "RLIMIT_RSS" => libc::RLIMIT_RSS, + "RLIMIT_RTPRIO" => libc::RLIMIT_RTPRIO, + "RLIMIT_RTTIME" => libc::RLIMIT_RTTIME, + "RLIMIT_SIGPENDING" => libc::RLIMIT_SIGPENDING, + "RLIMIT_STACK" => libc::RLIMIT_STACK, + other => anyhow::bail!("unsupported rlimit {other}"), + }; + let limit = libc::rlimit { + rlim_cur: soft, + rlim_max: hard, + }; + if unsafe { libc::setrlimit(resource, &limit) } != 0 { + return Err(std::io::Error::last_os_error()).with_context(|| format!("setrlimit {typ}")); + } + Ok(()) +} + +fn apply_capabilities(plan: &crate::plan::CapabilityPlan) -> Result<()> { + let bounding = caps::mask_from_names(&plan.bounding).context("bounding capabilities")?; + let effective = caps::mask_from_names(&plan.effective).context("effective capabilities")?; + let permitted = caps::mask_from_names(&plan.permitted).context("permitted capabilities")?; + let inheritable = + caps::mask_from_names(&plan.inheritable).context("inheritable capabilities")?; + let ambient = caps::mask_from_names(&plan.ambient).context("ambient capabilities")?; + + // Drop everything not kept in the bounding set first. + for bit in 0..41u64 { + if bounding & (1 << bit) == 0 { + let ret = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, bit as libc::c_ulong, 0, 0, 0) }; + if ret != 0 { + let error = std::io::Error::last_os_error(); + // EINVAL means the capability does not exist on this kernel. + if error.raw_os_error() != Some(libc::EINVAL) { + warn!("PR_CAPBSET_DROP {bit} failed: {error}"); + } + } + } + } + + // Ambient capabilities must be cleared before capset drops them. + if unsafe { libc::prctl(libc::PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0, 0) } != 0 { + warn!( + "PR_CAP_AMBIENT_CLEAR_ALL failed: {}", + std::io::Error::last_os_error() + ); + } + + // `capset(2)` argument layout (kernel uapi); libc does not expose it. + #[repr(C)] + struct CapHeader { + version: u32, + pid: i32, + } + #[repr(C)] + #[derive(Default)] + struct CapData { + effective: u32, + permitted: u32, + inheritable: u32, + } + const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522; + + let header = CapHeader { + version: LINUX_CAPABILITY_VERSION_3, + pid: 0, + }; + let mut data = [CapData::default(), CapData::default()]; + data[0].effective = (effective & 0xffff_ffff) as u32; + data[0].permitted = (permitted & 0xffff_ffff) as u32; + data[0].inheritable = (inheritable & 0xffff_ffff) as u32; + data[1].effective = (effective >> 32) as u32; + data[1].permitted = (permitted >> 32) as u32; + data[1].inheritable = (inheritable >> 32) as u32; + if unsafe { libc::syscall(libc::SYS_capset, &header as *const CapHeader, data.as_ptr()) } < 0 { + return Err(std::io::Error::last_os_error()).context("capset"); + } + + // Raise ambient set last; each raise needs the capability in both the + // permitted and inheritable sets. + for bit in 0..41u64 { + if ambient & (1 << bit) != 0 { + let ret = + unsafe { libc::prctl(libc::PR_CAP_AMBIENT_RAISE, bit as libc::c_ulong, 0, 0, 0) }; + if ret != 0 { + warn!("PR_CAP_AMBIENT_RAISE {bit} failed"); + } + } + } + Ok(()) +} + +/// Resolve `argv[0]` the way runc does: keep paths as-is, look bare names +/// up in PATH (from the process env), fall back to the name itself. +fn resolve_program(argv0: &std::ffi::CString, env: &[String]) -> std::ffi::CString { + let raw = argv0.to_string_lossy(); + if raw.contains('/') || raw.is_empty() { + return argv0.clone(); + } + let default_path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + let path = env + .iter() + .find_map(|entry| entry.strip_prefix("PATH=")) + .unwrap_or(default_path); + for dir in path.split(':') { + if dir.is_empty() { + continue; + } + let Ok(candidate) = std::ffi::CString::new(format!("{dir}/{raw}")) else { + continue; + }; + if unsafe { libc::access(candidate.as_ptr(), libc::X_OK) } == 0 { + return candidate; + } + } + argv0.clone() +} + +fn exec_process(process: &ProcessPlan) -> Result<()> { + let argv: Vec = process + .argv + .iter() + .map(|arg| std::ffi::CString::new(arg.as_str())) + .collect::>() + .context("argv contains NUL")?; + let envp: Vec = process + .env + .iter() + .map(|entry| std::ffi::CString::new(entry.as_str())) + .collect::>() + .context("env contains NUL")?; + + let cwd = Path::new(&process.cwd); + if let Err(error) = std::env::set_current_dir(cwd) { + fs::create_dir_all(cwd) + .and_then(|()| std::env::set_current_dir(cwd)) + .with_context(|| format!("enter cwd {} ({error})", cwd.display()))?; + } + + // OCI runtimes resolve a bare argv[0] against PATH from the process + // environment (ctr and CRI both send names like "echo"). + let program = resolve_program(&argv[0], &process.env); + let mut argv_ptrs: Vec<*const libc::c_char> = argv.iter().map(|arg| arg.as_ptr()).collect(); + argv_ptrs.push(std::ptr::null()); + let mut envp_ptrs: Vec<*const libc::c_char> = envp.iter().map(|entry| entry.as_ptr()).collect(); + envp_ptrs.push(std::ptr::null()); + debug!( + "executing process {} in {}", + program.to_string_lossy(), + cwd.display() + ); + if unsafe { libc::execve(program.as_ptr(), argv_ptrs.as_ptr(), envp_ptrs.as_ptr()) } < 0 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("execve {}", program.to_string_lossy())); + } + Ok(()) +} diff --git a/crates/persisting-shim/src/fifo.rs b/crates/persisting-shim/src/fifo.rs new file mode 100644 index 00000000..c4f38ba4 --- /dev/null +++ b/crates/persisting-shim/src/fifo.rs @@ -0,0 +1,413 @@ +//! Container IO plumbing. +//! +//! The shim owns the task IO (the role runtime-v2 assigns to it): it opens +//! the bundle FIFOs or allocates the PTY at Create/Exec time, keeps the +//! keepalive/master ends for `CloseIO` and `ResizePty`, and passes the +//! workload-facing descriptors on to the init/exec child through fd +//! inheritance. The low-level helpers also serve the children directly when +//! a path-based open is needed. + +use std::fs::File; +use std::io::Write; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::net::UnixStream; +use std::path::Path; + +use anyhow::{Context, Result}; +use log::warn; + +use crate::plan::IoPlan; + +/// Open a FIFO read-write (never blocks, never EOFs); /dev/null fallback. +unsafe fn open_fifo_fifo_or_null(path: Option<&str>) -> Result { + match path { + None => File::open("/dev/null").context("open /dev/null"), + Some(path) => unsafe { open_fifo(path, libc::O_RDWR) }, + } +} + +fn cstr(path: &str) -> std::ffi::CString { + std::ffi::CString::new(path).expect("path has no interior NUL") +} + +unsafe fn file_from_raw(fd: libc::c_int) -> File { + unsafe { File::from_raw_fd(fd) } +} + +/// Clear FD_CLOEXEC: `File::try_clone` uses F_DUPFD_CLOEXEC on Linux, but +/// the workload stdio descriptors must survive the self-exec into the +/// internal parents. +fn pass_to_child(file: &File) -> Result { + let clone = file.try_clone().context("dup descriptor")?; + unsafe { + libc::fcntl(clone.as_raw_fd(), libc::F_SETFD, 0); + } + Ok(clone) +} + +/// Open a FIFO without `O_CLOEXEC` so the descriptor survives into the +/// re-exec'd children. +unsafe fn open_fifo(path: &str, flags: libc::c_int) -> Result { + let fd = loop { + let fd = unsafe { libc::open(cstr(path).as_ptr(), flags) }; + if fd >= 0 { + break fd; + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error).with_context(|| format!("open fifo {path}")); + }; + Ok(unsafe { file_from_raw(fd) }) +} + +/// Open the workload stdin FIFO read-only. Must be called after a keepalive +/// writer exists (see [`ContainerIo`]) so the open cannot block. +pub fn open_child_stdin(path: Option<&str>) -> Result { + match path { + None => File::open("/dev/null").context("open /dev/null for stdin"), + Some(path) => unsafe { open_fifo(path, libc::O_RDONLY) }, + } +} + +/// Open a container output FIFO for writing, retrying while the reader on +/// the other side is not up yet; falls back to /dev/null for detached IO. +pub fn open_output(kind: &str, path: Option<&str>) -> Result { + let Some(path) = path else { + return File::open("/dev/null").with_context(|| format!("open /dev/null for {kind}")); + }; + for _ in 0..50 { + let fd = unsafe { libc::open(cstr(path).as_ptr(), libc::O_WRONLY | libc::O_NONBLOCK) }; + if fd >= 0 { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if unsafe { libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK) } < 0 { + let error = std::io::Error::last_os_error(); + unsafe { libc::close(fd) }; + return Err(error).with_context(|| format!("clear O_NONBLOCK on {kind}")); + } + return Ok(unsafe { file_from_raw(fd) }); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENXIO) { + std::thread::sleep(std::time::Duration::from_millis(20)); + continue; + } + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error).with_context(|| format!("open {kind} fifo {path}")); + } + warn!("{kind} fifo {path} had no reader; using /dev/null"); + File::open("/dev/null").with_context(|| format!("open /dev/null for {kind}")) +} + +/// Allocate a PTY; returns (master, slave). +pub fn open_pty(rows: u16, cols: u16) -> Result<(File, File)> { + let mut master: libc::c_int = -1; + let mut slave: libc::c_int = -1; + let size = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let ret = unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + &size, + ) + }; + if ret != 0 { + return Err(std::io::Error::last_os_error()).context("openpty"); + } + Ok(unsafe { (file_from_raw(master), file_from_raw(slave)) }) +} + +/// Hand a PTY master to containerd through the console socket via +/// `SCM_RIGHTS` (the runc/console protocol), retrying while the client's +/// listener is coming up. +pub fn send_console_master(socket_path: &str, master: &File) -> Result<()> { + let mut last_error: Option = None; + for _ in 0..5 { + match send_fd_once(socket_path, master) { + Ok(()) => return Ok(()), + Err(error) => { + last_error = Some(error); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + } + } + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("console send failed"))) +} + +fn send_fd_once(socket_path: &str, master: &File) -> Result<()> { + let mut stream = UnixStream::connect(Path::new(socket_path)) + .with_context(|| format!("connect console socket {socket_path}"))?; + let fd = master.as_raw_fd(); + // One dummy byte carries the ancillary data. + let control_len = + unsafe { libc::CMSG_SPACE(std::mem::size_of::() as u32) } as usize; + let mut control = vec![0u8; control_len]; + let sent = unsafe { + let mut header: libc::msghdr = std::mem::zeroed(); + let mut payload: libc::iovec = libc::iovec { + iov_base: b"1".as_ptr() as *mut libc::c_void, + iov_len: 1, + }; + header.msg_iov = &mut payload; + header.msg_iovlen = 1; + header.msg_control = control.as_mut_ptr().cast(); + header.msg_controllen = control.len() as _; + let cmsg = libc::CMSG_FIRSTHDR(&header); + (*cmsg).cmsg_level = libc::SOL_SOCKET; + (*cmsg).cmsg_type = libc::SCM_RIGHTS; + (*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::() as u32) as _; + (*(libc::CMSG_DATA(cmsg) as *mut libc::c_int)) = fd; + libc::sendmsg(stream.as_raw_fd(), &header, 0) + }; + if sent < 0 { + return Err(std::io::Error::last_os_error()).context("send console master fd"); + } + stream.flush().ok(); + Ok(()) +} + +/// Shim-owned IO for one process (init or exec). +pub struct ContainerIo { + /// Workload stdin read end handed to the child. + stdin: Option, + /// Shim-held `O_RDWR` copy of the stdin FIFO; dropping it (CloseIO) is + /// what lets the workload observe EOF once containerd's writer leaves. + stdin_keepalive: Option, + stdout: Option, + stderr: Option, + /// PTY master retained for ResizePty (terminal tasks only). + master: Option, + terminal: bool, +} + +impl ContainerIo { + /// Open the IO described by `io`. + /// + /// Terminal tasks come in two flavors: ctr passes the console socket in + /// `io.stdout` (handed the PTY master via SCM_RIGHTS); docker passes + /// ordinary FIFO paths and expects the runtime to provide PTY semantics + /// through them. The second flavor gets a shim-side PTY whose master is + /// relayed against the FIFOs. + pub fn open(io: &IoPlan) -> Result { + let console_socket = io + .stdout + .as_deref() + .map(|path| { + std::fs::metadata(path) + .map(|meta| { + use std::os::unix::fs::FileTypeExt; + meta.file_type().is_socket() + }) + .unwrap_or(false) + }) + .unwrap_or(false); + if io.terminal && !console_socket { + return ContainerIo::open_pty_over_fifos(io); + } + if io.terminal { + let console_socket = io + .stdout + .as_deref() + .context("terminal task without console socket")?; + let (master, slave) = open_pty(0, 0)?; + send_console_master(console_socket, &master)?; + let keepalive_master = master.try_clone().context("dup pty master")?; + let stdin = pass_to_child(&slave)?; + let stdout = pass_to_child(&slave)?; + let stderr = pass_to_child(&slave)?; + return Ok(ContainerIo { + stdin: Some(stdin), + stdin_keepalive: None, + stdout: Some(stdout), + stderr: Some(stderr), + master: Some(keepalive_master), + terminal: true, + }); + } + // The keepalive writer must exist before the child's O_RDONLY open, + // otherwise the open would block waiting for a writer. + let stdin_keepalive = match io.stdin.as_deref() { + Some(path) => Some(unsafe { open_fifo(path, libc::O_RDWR | libc::O_NONBLOCK) }?), + None => None, + }; + let stdin = open_child_stdin(io.stdin.as_deref())?; + let stdout = open_output("stdout", io.stdout.as_deref())?; + let stderr = if io.stderr.as_deref() == io.stdout.as_deref() { + stdout.try_clone().context("dup stdout fifo")? + } else { + open_output("stderr", io.stderr.as_deref())? + }; + Ok(ContainerIo { + stdin: Some(stdin), + stdin_keepalive, + stdout: Some(stdout), + stderr: Some(stderr), + master: None, + terminal: false, + }) + } + + /// docker-style terminal IO: a shim-owned PTY relayed against the task + /// FIFOs (stdin -> master, master -> stdout; a PTY merges stderr). + fn open_pty_over_fifos(io: &IoPlan) -> Result { + let (master, slave) = open_pty(0, 0)?; + let stdin_reader = unsafe { open_fifo_fifo_or_null(io.stdin.as_deref())? }; + let stdout_fifo = open_output("stdout", io.stdout.as_deref())?; + + let mut master_in = master.try_clone().context("dup pty master")?; + let mut master_out = master.try_clone().context("dup pty master")?; + let mut fifo_out = stdout_fifo.try_clone().context("dup stdout fifo")?; + let mut fifo_in = stdin_reader.try_clone().context("dup stdin fifo")?; + + std::thread::spawn(move || { + use std::io::{Read, Write}; + let mut buffer = [0u8; 4096]; + loop { + match fifo_in.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(n) => { + if master_in.write_all(&buffer[..n]).is_err() { + break; + } + } + } + } + }); + std::thread::spawn(move || { + use std::io::{Read, Write}; + let mut buffer = [0u8; 4096]; + loop { + match master_out.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(n) => { + if fifo_out.write_all(&buffer[..n]).is_err() { + break; + } + } + } + } + }); + + let stdin = pass_to_child(&slave)?; + let stdout = pass_to_child(&slave)?; + let stderr = pass_to_child(&slave)?; + Ok(ContainerIo { + stdin: Some(stdin), + stdin_keepalive: None, + stdout: Some(stdout), + stderr: Some(stderr), + master: Some(master), + terminal: true, + }) + } + + /// The three descriptors the child should wire onto 0/1/2. For + /// terminals all three are the PTY slave. + pub fn child_fds(&self) -> (libc::c_int, libc::c_int, libc::c_int) { + let stdin = self + .stdin + .as_ref() + .map_or(libc::STDIN_FILENO, File::as_raw_fd); + let stdout = self + .stdout + .as_ref() + .map_or(libc::STDOUT_FILENO, File::as_raw_fd); + let stderr = self + .stderr + .as_ref() + .map_or(libc::STDERR_FILENO, File::as_raw_fd); + (stdin, stdout, stderr) + } + + /// Drop the workload-facing descriptors after the child has been + /// spawned; it holds its own copies through fd inheritance. + pub fn release_child_fds(&mut self) { + self.stdin = None; + self.stdout = None; + self.stderr = None; + } + + /// Take the workload-facing descriptors out (VM exec relays: no child + /// process inherits them, the shim pumps the streams itself). + pub fn take_child_fds(&mut self) -> Option<(File, File, File)> { + Some((self.stdin.take()?, self.stdout.take()?, self.stderr.take()?)) + } + + /// CloseIO: release the shim-held stdin keepalive so the workload can + /// see EOF. Idempotent. + pub fn close_stdin(&mut self) { + if self.stdin_keepalive.take().is_some() { + log::debug!("stdin keepalive released (CloseIO)"); + } + } + + /// ResizePty: resize the retained PTY master. + pub fn resize(&self, width: u32, height: u32) -> Result<()> { + let Some(master) = self.master.as_ref() else { + anyhow::bail!("task is not a terminal task"); + }; + let size = libc::winsize { + ws_row: height as u16, + ws_col: width as u16, + ws_xpixel: 0, + ws_ypixel: 0, + }; + if unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &size) } != 0 { + return Err(std::io::Error::last_os_error()).context("TIOCSWINSZ"); + } + Ok(()) + } + + pub fn is_terminal(&self) -> bool { + self.terminal + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_paths_fall_back_to_dev_null() { + // /dev/null is one of the few paths guaranteed to exist on dev hosts. + let stdin = open_child_stdin(None).expect("stdin"); + assert!(stdin.as_raw_fd() >= 0); + let out = open_output("stdout", None).expect("stdout"); + assert!(out.as_raw_fd() >= 0); + } + + #[cfg(target_os = "linux")] + #[test] + fn detached_io_end_to_end() { + use crate::plan::IoPlan; + + let dir = tempfile::tempdir().expect("tempdir"); + let fifo = |name: &str| { + let path = dir.path().join(name); + let cpath = cstr(path.to_str().expect("utf8 path")); + assert_eq!(unsafe { libc::mkfifo(cpath.as_ptr(), 0o600) }, 0); + path.to_str().expect("utf8 path").to_string() + }; + let io = IoPlan { + terminal: false, + stdin: Some(fifo("stdin")), + stdout: Some(fifo("stdout")), + stderr: Some(fifo("stderr")), + }; + let mut owned = ContainerIo::open(&io).expect("open io"); + let (in_fd, out_fd, err_fd) = owned.child_fds(); + assert!(in_fd >= 0 && out_fd >= 0 && err_fd >= 0); + assert!(owned.resize(80, 24).is_err(), "no master on pipes"); + owned.close_stdin(); + } +} diff --git a/crates/persisting-shim/src/lib.rs b/crates/persisting-shim/src/lib.rs new file mode 100644 index 00000000..bf86fd4c --- /dev/null +++ b/crates/persisting-shim/src/lib.rs @@ -0,0 +1,32 @@ +//! containerd Runtime v2 shim for pVisor. +//! +//! The binary name (`containerd-shim-pvisor-v2`) follows the containerd +//! runtime-v2 discovery rule for the runtime type `io.containerd.pvisor.v2`. +//! Pure planning and state logic is cross-platform so it stays unit-testable +//! on development hosts; everything that touches Linux container primitives +//! is gated to `target_os = "linux"`. + +pub mod agent; +pub mod caps; +pub mod cgroup; +pub mod plan; +pub mod spec; +pub mod state; + +#[cfg(target_os = "linux")] +pub mod child; +#[cfg(target_os = "linux")] +pub mod fifo; +#[cfg(target_os = "linux")] +pub mod mount; +#[cfg(target_os = "linux")] +pub mod service; +#[cfg(all(target_os = "linux", feature = "vm"))] +pub mod vm; + +/// Runtime type under which containerd discovers this shim. +pub const RUNTIME_TYPE: &str = "io.containerd.pvisor.v2"; + +/// Exit code the container init child uses when `execve` itself fails, +/// matching the OCI runtime convention. +pub const EXEC_FAILURE_EXIT_CODE: i32 = 127; diff --git a/crates/persisting-shim/src/main.rs b/crates/persisting-shim/src/main.rs new file mode 100644 index 00000000..31ef8485 --- /dev/null +++ b/crates/persisting-shim/src/main.rs @@ -0,0 +1,22 @@ +fn main() { + #[cfg(target_os = "linux")] + { + // The shim binary is also the container init parent: when containerd + // asks it to create a task it re-executes itself in internal mode + // (house "self-exec" pattern, cf. pvisor's INTERNAL_SANDBOX_ARG). + if let Err(error) = persisting_shim::agent::run_guest_agent_if_requested() { + eprintln!("pvisor shim guest agent failed: {error:#}"); + std::process::exit(1); + } + if let Err(error) = persisting_shim::child::run_internal_if_requested() { + eprintln!("pvisor shim internal init failed: {error:#}"); + std::process::exit(1); + } + persisting_shim::service::shim_main(); + } + #[cfg(not(target_os = "linux"))] + { + eprintln!("containerd-shim-pvisor-v2 only supports Linux hosts"); + std::process::exit(1); + } +} diff --git a/crates/persisting-shim/src/mount.rs b/crates/persisting-shim/src/mount.rs new file mode 100644 index 00000000..10cf92f6 --- /dev/null +++ b/crates/persisting-shim/src/mount.rs @@ -0,0 +1,350 @@ +//! Mount table handling: option parsing is pure and cross-platform, the +//! syscall layer is Linux-only (used by the init child). + +/// Mount flags parsed out of an option list; anything unrecognized becomes +/// filesystem-specific data (e.g. `size=64m` for tmpfs, `upperdir=` for +/// overlay). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ParsedMountOptions { + pub flags: libc::c_ulong, + /// `rbind` requests a recursive bind mount. + pub recursive: bool, + pub data: Vec, +} + +/// Split an OCI/containerd mount option list into flags and data options. +pub fn parse_mount_options(options: &[String]) -> ParsedMountOptions { + let mut parsed = ParsedMountOptions::default(); + for option in options { + match option.as_str() { + "ro" => parsed.flags |= libc::MS_RDONLY, + "rw" => {} + "suid" => parsed.flags &= !libc::MS_NOSUID, + "nosuid" => parsed.flags |= libc::MS_NOSUID, + "dev" => parsed.flags &= !libc::MS_NODEV, + "nodev" => parsed.flags |= libc::MS_NODEV, + "exec" => parsed.flags &= !libc::MS_NOEXEC, + "noexec" => parsed.flags |= libc::MS_NOEXEC, + "sync" => parsed.flags |= libc::MS_SYNCHRONOUS, + "async" | "atime" | "diratime" => {} + "noatime" => parsed.flags |= libc::MS_NOATIME, + "relatime" => parsed.flags |= libc::MS_RELATIME, + "strictatime" => parsed.flags |= libc::MS_STRICTATIME, + "nodiratime" => parsed.flags |= libc::MS_NODIRATIME, + "bind" => parsed.flags |= libc::MS_BIND, + "rbind" => { + parsed.flags |= libc::MS_BIND | libc::MS_REC; + parsed.recursive = true; + } + "remount" => parsed.flags |= libc::MS_REMOUNT, + "private" => parsed.flags |= libc::MS_PRIVATE, + "shared" => parsed.flags |= libc::MS_SHARED, + "slave" => parsed.flags |= libc::MS_SLAVE, + "unbindable" => parsed.flags |= libc::MS_UNBINDABLE, + "rprivate" => parsed.flags |= libc::MS_REC | libc::MS_PRIVATE, + "rshared" => parsed.flags |= libc::MS_REC | libc::MS_SHARED, + "rslave" => parsed.flags |= libc::MS_REC | libc::MS_SLAVE, + "runbindable" => parsed.flags |= libc::MS_REC | libc::MS_UNBINDABLE, + _ => parsed.data.push(option.clone()), + } + } + parsed +} + +#[cfg(target_os = "linux")] +mod linux { + use crate::plan::MountPlan; + use anyhow::{Context, Result}; + use log::warn; + use std::fs; + use std::os::fd::AsRawFd; + use std::os::unix::ffi::OsStrExt; + use std::path::Path; + + fn cstr(path: &Path) -> std::ffi::CString { + std::ffi::CString::new(path.as_os_str().as_bytes().to_vec()) + .expect("path contains no interior NUL") + } + + pub fn mount( + source: Option<&Path>, + target: &Path, + fs_type: Option<&str>, + flags: libc::c_ulong, + data: Option<&str>, + ) -> Result<()> { + let source = source.map(cstr); + let fs_type = fs_type.map(|ty| std::ffi::CString::new(ty).expect("fs type has no NUL")); + let data = data.map(|data| std::ffi::CString::new(data).expect("mount data has no NUL")); + let ret = unsafe { + libc::mount( + source.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), + cstr(target).as_ptr(), + fs_type.as_ref().map_or(std::ptr::null(), |t| t.as_ptr()), + flags, + data.as_ref() + .map_or(std::ptr::null(), |d| d.as_ptr().cast()), + ) + }; + if ret != 0 { + return Err(std::io::Error::last_os_error()).context(format!( + "mount {} -> {}", + source_display(source.as_ref(), fs_type.as_ref()), + target.display() + )); + } + Ok(()) + } + + fn source_display( + source: Option<&std::ffi::CString>, + fs_type: Option<&std::ffi::CString>, + ) -> String { + if let Some(fs_type) = fs_type { + return fs_type.to_string_lossy().to_string(); + } + source + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default() + } + + /// Make the whole mount tree private so container mounts do not propagate + /// back to the host. + pub fn make_mounts_private() -> Result<()> { + mount( + None, + Path::new("/"), + None, + libc::MS_REC | libc::MS_PRIVATE, + None, + ) + .context("mark mount tree private") + } + + fn apply_one(mount_plan: &MountPlan, rootfs: &Path) -> Result<()> { + let target = rootfs.join(mount_plan.destination.strip_prefix("/")?); + fs::create_dir_all(&target) + .with_context(|| format!("create mountpoint {}", target.display()))?; + let parsed = super::parse_mount_options(&mount_plan.options); + let data = parsed.data.join(","); + let source = mount_plan.source.as_deref().map(Path::new); + match mount_plan.fs_type.as_str() { + "bind" | "rbind" => { + let source = source.with_context(|| "bind mount without source")?; + mount( + Some(source), + &target, + None, + libc::MS_BIND | libc::MS_REC, + None, + ) + .with_context(|| format!("bind {} -> {}", source.display(), target.display()))?; + // Re-apply non-bind options (ro, nosuid, ...) in a remount. + let extra = parsed.flags & !libc::MS_BIND & !libc::MS_REC; + if extra != 0 || !parsed.data.is_empty() { + mount( + None, + &target, + None, + libc::MS_BIND | libc::MS_REMOUNT | extra, + if parsed.data.is_empty() { + None + } else { + Some(data.as_str()) + }, + ) + .context(format!("remount {}", target.display()))?; + } + } + "overlay" => { + let source = mount_plan + .source + .clone() + .unwrap_or_else(|| "overlay".to_string()); + mount( + Some(Path::new(&source)), + &target, + Some("overlay"), + 0, + Some(data.as_str()), + ) + .with_context(|| format!("overlay mount at {}", target.display()))?; + } + "proc" | "sysfs" | "cgroup" | "cgroup2" | "mqueue" | "tmpfs" | "devpts" => { + mount( + source.or(Some(Path::new(&mount_plan.fs_type))), + &target, + Some(&mount_plan.fs_type), + parsed.flags, + if parsed.data.is_empty() { + None + } else { + Some(data.as_str()) + }, + ) + .with_context(|| format!("{} mount at {}", mount_plan.fs_type, target.display()))?; + } + other => warn!( + "skipping unsupported mount type {other} at {}", + target.display() + ), + } + Ok(()) + } + + /// Apply request mounts (snapshotter output), then spec mounts, into the + /// container mount namespace. `proc` spec mounts are skipped here: they + /// must be mounted by a process inside the new pid namespace. + pub fn apply_mounts(mounts: &[MountPlan], rootfs: &Path) -> Result<()> { + for mount_plan in mounts { + if !mount_plan.from_request && mount_plan.fs_type == "proc" { + continue; + } + if let Err(error) = apply_one(mount_plan, rootfs) { + // Request mounts are fatal: without the snapshotter rootfs + // there is nothing to run on. Spec mounts degrade loudly. + if mount_plan.from_request { + return Err(error); + } + warn!("spec mount failed: {error:#}"); + } + } + Ok(()) + } + + /// Mount a fresh procfs at `/proc`; called from inside the new + /// pid namespace. + pub fn mount_proc(rootfs: &Path) -> Result<()> { + let target = rootfs.join("proc"); + std::fs::create_dir_all(&target).with_context(|| format!("create {}", target.display()))?; + mount( + Some(Path::new("proc")), + &target, + Some("proc"), + libc::MS_NOSUID | libc::MS_NOEXEC | libc::MS_NODEV, + None, + ) + .context("mount proc") + } + + /// `pivot_root(".", ".")` dance: move the new root over `/`, detach the + /// old root, and land in the new root. + pub fn pivot_root(new_root: &Path) -> Result<()> { + use std::os::unix::fs::OpenOptionsExt; + + std::env::set_current_dir(new_root).context("chdir new root")?; + // Bind the new root onto itself so pivot_root has a parent to move. + mount( + Some(new_root), + new_root, + None, + libc::MS_BIND | libc::MS_REC, + None, + ) + .context("self bind before pivot")?; + let ret = unsafe { + libc::syscall( + libc::SYS_pivot_root, + cstr(Path::new(".")).as_ptr(), + cstr(Path::new(".")).as_ptr(), + ) + }; + if ret != 0 { + let error = std::io::Error::last_os_error(); + // Fall back to chroot when pivot_root is unavailable (very old + // kernels or restricted sandboxes). + warn!("pivot_root failed ({error}), falling back to chroot"); + let root = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY) + .open(new_root) + .context("open new root before chroot")?; + if unsafe { libc::fchdir(root.as_raw_fd()) } != 0 { + return Err(std::io::Error::last_os_error()).context("fchdir before chroot"); + } + if unsafe { libc::chroot(cstr(Path::new(".")).as_ptr()) } != 0 { + return Err(std::io::Error::last_os_error()).context("chroot fallback"); + } + } else { + // The old root is now mounted on top of ".". + let ret = unsafe { libc::umount2(cstr(Path::new(".")).as_ptr(), libc::MNT_DETACH) }; + if ret != 0 { + return Err(std::io::Error::last_os_error()).context("detach old root"); + } + } + std::env::set_current_dir("/").context("chdir /")?; + Ok(()) + } + + /// Remount the (new) root read-only when the spec asks for it. + pub fn remount_root_readonly() -> Result<()> { + mount( + None, + Path::new("/"), + None, + libc::MS_BIND | libc::MS_REMOUNT | libc::MS_RDONLY, + None, + ) + .or_else(|_| { + mount( + None, + Path::new("/"), + None, + libc::MS_REMOUNT | libc::MS_RDONLY, + None, + ) + }) + .context("remount root read-only") + } +} + +#[cfg(target_os = "linux")] +pub use linux::{ + apply_mounts, make_mounts_private, mount, mount_proc, pivot_root, remount_root_readonly, +}; + +#[cfg(test)] +mod tests { + use super::*; + + fn opts(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn flags_and_data_are_separated() { + let parsed = parse_mount_options(&opts(&["ro", "nosuid", "size=64m", "mode=700"])); + assert_eq!(parsed.flags, libc::MS_RDONLY | libc::MS_NOSUID); + assert_eq!( + parsed.data, + vec!["size=64m".to_string(), "mode=700".to_string()] + ); + assert!(!parsed.recursive); + } + + #[test] + fn rbind_sets_recursive() { + let parsed = parse_mount_options(&opts(&["rbind", "nodev"])); + assert!(parsed.recursive); + assert!(parsed.flags & libc::MS_BIND != 0); + assert!(parsed.flags & libc::MS_REC != 0); + assert!(parsed.flags & libc::MS_NODEV != 0); + } + + #[test] + fn propagation_options_map_to_flags() { + let parsed = parse_mount_options(&opts(&["rprivate", "relatime"])); + assert_eq!( + parsed.flags, + libc::MS_REC | libc::MS_PRIVATE | libc::MS_RELATIME + ); + assert!(parsed.data.is_empty()); + } + + #[test] + fn overlay_options_stay_data() { + let parsed = parse_mount_options(&opts(&["lowerdir=/a:/b", "upperdir=/u", "workdir=/w"])); + assert_eq!(parsed.flags, 0); + assert_eq!(parsed.data.len(), 3); + } +} diff --git a/crates/persisting-shim/src/plan.rs b/crates/persisting-shim/src/plan.rs new file mode 100644 index 00000000..9b357ea5 --- /dev/null +++ b/crates/persisting-shim/src/plan.rs @@ -0,0 +1,1054 @@ +//! The container plan: everything the init child needs, derived once from the +//! OCI spec plus the containerd `Create` request. +//! +//! The plan is a plain serializable value so the Linux child process can +//! consume it after re-exec (house "self-exec" pattern) and so the derivation +//! logic stays unit-testable on any host. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::spec::ANNOTATION_PREFIX; +use oci_spec::runtime::{LinuxNamespaceType, Spec}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PlanError { + #[error("bundle config.json has no process section")] + MissingProcess, + #[error("bundle process section has no args")] + MissingArgs, + #[error("namespace type {0} is not supported (M1 limitation)")] + UnsupportedNamespaceType(String), +} + +/// Namespace kinds the init child knows how to create or join. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum NamespaceKind { + Mount, + Pid, + Network, + Ipc, + Uts, + User, + Cgroup, +} + +// `clone(2)` namespace flags (Linux ABI values, kept here so the planner +// stays compilable and testable on non-Linux hosts). +const CLONE_NEWNS: libc::c_int = 0x0002_0000; +const CLONE_NEWCGROUP: libc::c_int = 0x0200_0000; +const CLONE_NEWUTS: libc::c_int = 0x0400_0000; +const CLONE_NEWIPC: libc::c_int = 0x0800_0000; +const CLONE_NEWUSER: libc::c_int = 0x1000_0000; +const CLONE_NEWPID: libc::c_int = 0x2000_0000; +const CLONE_NEWNET: libc::c_int = 0x4000_0000; + +impl NamespaceKind { + /// `clone(2)` flag used when the namespace is created with `unshare`. + pub fn clone_flag(self) -> libc::c_int { + match self { + NamespaceKind::Mount => CLONE_NEWNS, + NamespaceKind::Pid => CLONE_NEWPID, + NamespaceKind::Network => CLONE_NEWNET, + NamespaceKind::Ipc => CLONE_NEWIPC, + NamespaceKind::Uts => CLONE_NEWUTS, + NamespaceKind::User => CLONE_NEWUSER, + NamespaceKind::Cgroup => CLONE_NEWCGROUP, + } + } + + /// Name of the namespace symlink under `/proc//ns/`. + pub fn proc_ns_name(self) -> &'static str { + match self { + NamespaceKind::Mount => "mnt", + NamespaceKind::Pid => "pid", + NamespaceKind::Network => "net", + NamespaceKind::Ipc => "ipc", + NamespaceKind::Uts => "uts", + NamespaceKind::User => "user", + NamespaceKind::Cgroup => "cgroup", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NamespacePlan { + pub kind: NamespaceKind, + /// When set the child joins an existing namespace instead of unsharing. + pub path: Option, +} + +/// One line of a uid_map/gid_map for user namespaces. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct IdMappingPlan { + pub container_id: u32, + pub host_id: u32, + pub size: u32, +} + +impl IdMappingPlan { + pub fn render(&self) -> String { + format!("{} {} {}", self.container_id, self.host_id, self.size) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserPlan { + pub uid: u32, + pub gid: u32, + pub additional_gids: Vec, + pub umask: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityPlan { + pub bounding: Vec, + pub effective: Vec, + pub permitted: Vec, + pub inheritable: Vec, + pub ambient: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RlimitPlan { + pub typ: String, + pub soft: u64, + pub hard: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcessPlan { + pub argv: Vec, + pub env: Vec, + pub cwd: PathBuf, + pub user: UserPlan, + pub capabilities: CapabilityPlan, + pub rlimits: Vec, + pub no_new_privileges: bool, +} + +/// One mount to apply inside the container's mount namespace. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MountPlan { + /// Absolute path inside the container namespace. + pub destination: PathBuf, + pub fs_type: String, + pub source: Option, + pub options: Vec, + /// Mounts coming from the CreateTaskRequest must be applied before the + /// spec mounts so layered filesystems exist before binds target them. + pub from_request: bool, +} + +/// Container IO as passed by containerd. +/// +/// With `terminal` set, `stdout` carries the console socket path (the +/// containerd task v2 convention) and `stderr` is unused. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct IoPlan { + pub terminal: bool, + pub stdin: Option, + pub stdout: Option, + pub stderr: Option, +} + +/// Pre-rendered cgroup v2 file contents for the limits the shim enforces. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CgroupPlan { + /// Path relative to the unified cgroup mount, `:` separators already + /// normalized to `/` (systemd-style `slice:prefix:id` becomes a path). + pub path: Option, + pub pids_max: Option, + pub memory_max: Option, + pub cpu_max: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ContainerPlan { + pub id: String, + pub bundle: PathBuf, + pub rootfs: PathBuf, + pub process: ProcessPlan, + pub namespaces: Vec, + /// uid/gid mappings applied when a user namespace is unshared. + pub uid_mappings: Vec, + pub gid_mappings: Vec, + pub hostname: Option, + /// Mounts from the OCI spec, ordered as they appear in config.json. + pub mounts: Vec, + /// Rootfs mounts from the CreateTaskRequest (snapshotter output). + pub rootfs_mounts: Vec, + pub root_readonly: bool, + pub cgroup: Option, + pub io: IoPlan, + pub annotations: HashMap, + /// Non-fatal gaps recorded while planning (surfaced in shim logs). + pub warnings: Vec, +} + +/// Filesystem types the init child can mount without a warning. +const SUPPORTED_MOUNT_TYPES: &[&str] = &[ + "bind", "rbind", "proc", "tmpfs", "devpts", "sysfs", "cgroup", "cgroup2", "mqueue", "overlay", +]; + +/// Turn the OCI cwd (relative to the container root) into an absolute path +/// inside the container namespace. +pub fn normalize_cwd(cwd: &Path) -> PathBuf { + if cwd.is_absolute() { + normalize_absolute(cwd) + } else { + normalize_absolute(&Path::new("/").join(cwd)) + } +} + +impl ContainerPlan { + /// True when the init child must `unshare(CLONE_NEWUSER)` and write id + /// mappings (as opposed to joining a user namespace by path). + pub fn has_new_user_namespace(&self) -> bool { + self.namespaces + .iter() + .any(|ns| ns.kind == NamespaceKind::User && ns.path.is_none()) + } + + /// True when a private UTS namespace (and thus hostname) is created. + pub fn has_new_uts_namespace(&self) -> bool { + self.namespaces + .iter() + .any(|ns| ns.kind == NamespaceKind::Uts && ns.path.is_none()) + } +} + +/// Lexically clean an absolute path without touching the filesystem. +fn normalize_absolute(path: &Path) -> PathBuf { + let mut out = PathBuf::from("/"); + for component in path.components() { + use std::path::Component; + match component { + Component::RootDir | Component::Prefix(_) => {} + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + Component::Normal(part) => out.push(part), + } + } + out +} + +/// Convert `slice:prefix:id` systemd notation into a relative cgroup path. +pub fn normalize_cgroup_path(path: &str) -> String { + let trimmed = path.trim_start_matches('/'); + trimmed.replace(':', "/") +} + +fn capability_names(caps: Option<&oci_spec::runtime::Capabilities>) -> Vec { + let Some(caps) = caps else { + return Vec::new(); + }; + let mut names: Vec = caps.iter().map(|cap| cap.to_string()).collect(); + names.sort(); + names +} + +/// Render a cgroup v2 limit value; non-positive or absent limits mean "max". +fn render_limit(limit: Option) -> Option { + match limit { + None => None, + Some(value) if value <= 0 => Some("max".to_string()), + Some(value) => Some(value.to_string()), + } +} + +fn plan_cgroup(spec: &Spec) -> Option { + let linux = spec.linux().as_ref()?; + let resources = linux.resources().as_ref(); + let path = linux + .cgroups_path() + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .map(|p| normalize_cgroup_path(&p)); + if resources.is_none() && path.is_none() { + return None; + } + let mut plan = CgroupPlan { + path, + pids_max: None, + memory_max: None, + cpu_max: None, + }; + if let Some(resources) = resources { + if let Some(pids) = resources.pids().as_ref() { + plan.pids_max = render_limit(Some(pids.limit())); + } + if let Some(memory) = resources.memory().as_ref() { + plan.memory_max = render_limit(memory.limit()); + } + if let Some(cpu) = resources.cpu().as_ref() { + let period = cpu.period().unwrap_or(100_000); + let quota = cpu.quota(); + let value = match quota { + Some(q) if q > 0 => format!("{q} {period}"), + _ => format!("max {period}"), + }; + plan.cpu_max = Some(value); + } + } + Some(plan) +} + +fn plan_namespaces( + spec: &Spec, + warnings: &mut Vec, +) -> Result, PlanError> { + let mut out = Vec::new(); + let namespaces = spec + .linux() + .as_ref() + .and_then(|linux| linux.namespaces().clone()) + .unwrap_or_default(); + for namespace in namespaces { + let kind = match namespace.typ() { + LinuxNamespaceType::Mount => NamespaceKind::Mount, + LinuxNamespaceType::Pid => NamespaceKind::Pid, + LinuxNamespaceType::Network => NamespaceKind::Network, + LinuxNamespaceType::Ipc => NamespaceKind::Ipc, + LinuxNamespaceType::Uts => NamespaceKind::Uts, + LinuxNamespaceType::User => NamespaceKind::User, + LinuxNamespaceType::Cgroup => NamespaceKind::Cgroup, + LinuxNamespaceType::Time => { + warnings.push("time namespace ignored (M1 limitation)".to_string()); + continue; + } + }; + // Namespace paths (CRI pod containers point at the sandbox's + // namespaces) are joined via setns in the internal parent; a pid + // namespace join takes effect for the forked init child. + out.push(NamespacePlan { + kind, + path: namespace.path().clone(), + }); + } + // The container always gets a private mount namespace for its rootfs. + if !out.iter().any(|ns| ns.kind == NamespaceKind::Mount) { + out.insert( + 0, + NamespacePlan { + kind: NamespaceKind::Mount, + path: None, + }, + ); + } + Ok(out) +} + +/// Build the full container plan. +/// +/// `rootfs_mounts` are the converted `CreateTaskRequest.rootfs` entries; +/// conversion from protobuf stays on the Linux side. +/// Extract the process execution details from an OCI process section. +/// +/// Shared by the init container (`config.json`) and exec processes (the +/// `ExecProcessRequest` spec). +pub fn process_plan_from(process: &oci_spec::runtime::Process) -> Result { + let argv = process.args().clone().ok_or(PlanError::MissingArgs)?; + if argv.is_empty() { + return Err(PlanError::MissingArgs); + } + + let user_spec = process.user(); + let user = UserPlan { + uid: user_spec.uid(), + gid: user_spec.gid(), + additional_gids: user_spec.additional_gids().clone().unwrap_or_default(), + umask: user_spec.umask(), + }; + + let capabilities = process + .capabilities() + .as_ref() + .map(|caps| CapabilityPlan { + bounding: capability_names(caps.bounding().as_ref()), + effective: capability_names(caps.effective().as_ref()), + permitted: capability_names(caps.permitted().as_ref()), + inheritable: capability_names(caps.inheritable().as_ref()), + ambient: capability_names(caps.ambient().as_ref()), + }) + .unwrap_or_default(); + + let rlimits = process + .rlimits() + .clone() + .unwrap_or_default() + .into_iter() + .map(|rlimit| RlimitPlan { + typ: rlimit.typ().to_string(), + soft: rlimit.soft(), + hard: rlimit.hard(), + }) + .collect(); + + Ok(ProcessPlan { + argv, + env: process.env().clone().unwrap_or_default(), + cwd: normalize_cwd(process.cwd()), + user, + capabilities, + rlimits, + no_new_privileges: process.no_new_privileges().unwrap_or(false), + }) +} + +/// Plan for the pod sandbox holder (the pause-container replacement). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SandboxPlan { + pub sandbox_id: String, + pub hostname: Option, + /// Network namespace to join (the CRI pod netns); when unset the holder + /// creates a fresh one. + pub netns_path: Option, + /// True for shareProcessNamespace pods: the holder owns a pid namespace + /// that member containers join. + pub share_pid_namespace: bool, +} + +/// Derive the sandbox holder plan from the CreateSandboxRequest fields. +/// +/// The sandboxer contract carries no OCI spec — the sandbox shape is the +/// shim's own decision. The hostname falls back to the sandbox id prefix +/// (k8s pods surface the pod name via annotations). +pub fn build_sandbox_plan( + sandbox_id: &str, + netns_path: Option<&str>, + annotations: &HashMap, +) -> SandboxPlan { + let hostname = annotations + .get("io.kubernetes.pod.name") + .cloned() + .or_else(|| sandbox_id.get(..12).map(str::to_string)); + SandboxPlan { + sandbox_id: sandbox_id.to_string(), + hostname, + netns_path: netns_path.map(str::to_string), + share_pid_namespace: false, + } +} + +/// Resource shape for the libkrun microVM executor, derived from +/// `io.pvisor.vm.*` annotations. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VmConfig { + pub cpus: u8, + pub ram_mib: u32, +} + +impl Default for VmConfig { + fn default() -> Self { + VmConfig { + cpus: 2, + ram_mib: 512, + } + } +} + +impl ContainerPlan { + /// True when the spec configures this namespace kind at all (created + /// or joined); sandbox sharing only fills in the kinds it leaves out. + pub fn has_namespace(&self, kind: NamespaceKind) -> bool { + self.namespaces + .iter() + .any(|namespace| namespace.kind == kind) + } + + /// True when the bundle asks for the libkrun VM executor via the + /// `io.pvisor.executor` annotation (`host` keeps the default). + pub fn wants_vm(&self) -> bool { + self.annotations + .get(format!("{ANNOTATION_PREFIX}executor").as_str()) + .is_some_and(|value| value == "vm") + } + + /// VM shape from `io.pvisor.vm.cpus` / `io.pvisor.vm.memory-mib`. + pub fn vm_config(&self) -> VmConfig { + let mut config = VmConfig::default(); + if let Some(cpus) = self + .annotations + .get(format!("{ANNOTATION_PREFIX}vm.cpus").as_str()) + .and_then(|value| value.parse::().ok()) + .filter(|cpus| *cpus > 0) + { + config.cpus = cpus; + } + if let Some(ram) = self + .annotations + .get(format!("{ANNOTATION_PREFIX}vm.memory-mib").as_str()) + .and_then(|value| value.parse::().ok()) + .filter(|ram| *ram > 0) + { + config.ram_mib = ram; + } + config + } +} + +/// Shell-quote one word for the guest init helper (`/bin/sh` semantics). +fn sh_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +/// True unless `io.pvisor.vm.agent=off`: VMs boot the guest agent so exec +/// works (the shim binary is copied into the rootfs at boot). +pub fn vm_agent_enabled(annotations: &HashMap) -> bool { + annotations + .get(format!("{ANNOTATION_PREFIX}vm.agent").as_str()) + .map(|value| value != "off") + .unwrap_or(true) +} + +/// Render the helper script the guest init executes. +/// +/// `krun_set_exec` collapses argv/envp into strings the guest init re-splits, +/// so arguments or values containing spaces would be mangled. A generated +/// script sidesteps that (the same pattern pVisor's VM executor uses), and +/// carries the working directory and a clean environment in one place. +pub fn render_guest_init_script(process: &ProcessPlan) -> String { + render_guest_init_script_with(process, false) +} + +/// [`render_guest_init_script`] with control over the guest agent: when +/// enabled the script starts the agent (copied into the rootfs at boot) +/// before exec'ing the workload. +pub fn render_guest_init_script_with(process: &ProcessPlan, agent: bool) -> String { + let mut script = String::from("#!/bin/sh\n"); + if agent { + script.push_str(&format!( + "{path} {arg} >/dev/null 2>&1 &\n", + path = crate::agent::AGENT_GUEST_PATH, + arg = crate::agent::AGENT_ARG + )); + } + if process.cwd != Path::new("/") { + script.push_str(&format!( + "cd {} || exit 127\n", + sh_quote(&process.cwd.to_string_lossy()) + )); + } + script.push_str("exec env -i"); + for entry in &process.env { + script.push(' '); + script.push_str(&sh_quote(entry)); + } + for arg in &process.argv { + script.push(' '); + script.push_str(&sh_quote(arg)); + } + script.push('\n'); + script +} + +/// Path of the generated guest init helper inside the container rootfs. +pub fn guest_init_script_path(id: &str) -> String { + format!("/.pvisor-shim-init-{id}.sh") +} + +/// Plan for one exec process inside a running container. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExecPlan { + pub container_id: String, + pub exec_id: String, + /// Init process pid; the exec child joins its namespaces. + pub init_pid: u32, + pub process: ProcessPlan, + pub io: IoPlan, +} + +/// Build an exec plan from the OCI process spec of the exec request. +pub fn build_exec_plan( + process: &oci_spec::runtime::Process, + container_id: &str, + exec_id: &str, + init_pid: u32, + io: IoPlan, +) -> Result { + Ok(ExecPlan { + container_id: container_id.to_string(), + exec_id: exec_id.to_string(), + init_pid, + process: process_plan_from(process)?, + io, + }) +} + +pub fn build_plan( + spec: &Spec, + id: &str, + bundle: &Path, + rootfs_mounts: Vec, + io: IoPlan, +) -> Result { + let process = spec.process().as_ref().ok_or(PlanError::MissingProcess)?; + let process_plan = process_plan_from(process)?; + + let mut warnings = Vec::new(); + if spec + .linux() + .as_ref() + .and_then(|linux| linux.seccomp().as_ref()) + .is_some() + { + warnings.push("seccomp profile ignored (M1 limitation)".to_string()); + } + if spec.hooks().is_some() { + warnings.push("OCI hooks ignored (M1 limitation)".to_string()); + } + if spec + .linux() + .as_ref() + .and_then(|linux| linux.masked_paths().clone()) + .is_some_and(|masked| !masked.is_empty()) + { + warnings.push("maskedPaths ignored (M1 limitation)".to_string()); + } + + let namespaces = plan_namespaces(spec, &mut warnings)?; + + let map_mappings = |mappings: Option<&Vec>| { + mappings + .map(|entries| { + entries + .iter() + .map(|m| IdMappingPlan { + container_id: m.container_id(), + host_id: m.host_id(), + size: m.size(), + }) + .collect() + }) + .unwrap_or_default() + }; + let linux = spec.linux().as_ref(); + let uid_mappings = map_mappings(linux.and_then(|l| l.uid_mappings().as_ref())); + let gid_mappings = map_mappings(linux.and_then(|l| l.gid_mappings().as_ref())); + + let mut mounts = Vec::new(); + for mount in spec.mounts().clone().unwrap_or_default() { + let fs_type = mount.typ().clone().unwrap_or_else(|| "bind".to_string()); + if !SUPPORTED_MOUNT_TYPES.contains(&fs_type.as_str()) { + warnings.push(format!( + "mount of type {fs_type} at {} skipped (M1 limitation)", + mount.destination().display() + )); + continue; + } + mounts.push(MountPlan { + destination: normalize_absolute(&Path::new("/").join(mount.destination())), + fs_type, + source: mount + .source() + .as_ref() + .map(|s| s.to_string_lossy().to_string()), + options: mount.options().clone().unwrap_or_default(), + from_request: false, + }); + } + + let rootfs = spec + .root() + .as_ref() + .map(|root| bundle.join(root.path())) + .unwrap_or_else(|| bundle.join("rootfs")); + + let annotations = spec.annotations().clone().unwrap_or_default(); + + Ok(ContainerPlan { + id: id.to_string(), + bundle: bundle.to_path_buf(), + rootfs, + process: process_plan, + namespaces, + uid_mappings, + gid_mappings, + hostname: spec.hostname().clone(), + mounts, + rootfs_mounts, + root_readonly: spec + .root() + .as_ref() + .and_then(|root| root.readonly()) + .unwrap_or(false), + cgroup: plan_cgroup(spec), + io, + annotations, + warnings, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec_from_json(json: &str) -> Spec { + serde_json::from_str(json).expect("parse spec") + } + + fn minimal_spec() -> Spec { + spec_from_json( + r#"{"ociVersion":"1.0.2","process":{"args":["/bin/sh"],"cwd":"/","user":{"uid":0,"gid":0}},"root":{"path":"rootfs"}}"#, + ) + } + + #[test] + fn cwd_relative_to_root_is_normalized_absolute() { + assert_eq!(normalize_cwd(Path::new("/tmp/x")), PathBuf::from("/tmp/x")); + assert_eq!(normalize_cwd(Path::new("tmp/x")), PathBuf::from("/tmp/x")); + assert_eq!( + normalize_cwd(Path::new("/tmp/../var/./log")), + PathBuf::from("/var/log") + ); + assert_eq!(normalize_cwd(Path::new("/")), PathBuf::from("/")); + } + + #[test] + fn systemd_cgroup_paths_become_slash_separated() { + assert_eq!( + normalize_cgroup_path("kubepods.slice:cri-containerd:test-id"), + "kubepods.slice/cri-containerd/test-id" + ); + assert_eq!( + normalize_cgroup_path("/system.slice/foo.service"), + "system.slice/foo.service" + ); + } + + #[test] + fn minimal_plan_has_private_mount_namespace() { + let plan = build_plan( + &minimal_spec(), + "c1", + Path::new("/bundle"), + vec![], + IoPlan::default(), + ) + .expect("plan"); + assert_eq!(plan.process.argv, vec!["/bin/sh".to_string()]); + assert_eq!(plan.rootfs, PathBuf::from("/bundle/rootfs")); + assert!( + plan.namespaces + .iter() + .any(|ns| ns.kind == NamespaceKind::Mount && ns.path.is_none()) + ); + assert_eq!(plan.cgroup, None); + } + + #[test] + fn missing_process_or_args_is_rejected() { + let no_process = spec_from_json(r#"{"ociVersion":"1.0.2"}"#); + assert!(matches!( + build_plan( + &no_process, + "c1", + Path::new("/b"), + vec![], + IoPlan::default() + ), + Err(PlanError::MissingProcess) + )); + let no_args = spec_from_json( + r#"{"ociVersion":"1.0.2","process":{"cwd":"/","user":{"uid":0,"gid":0}}}"#, + ); + assert!(matches!( + build_plan(&no_args, "c1", Path::new("/b"), vec![], IoPlan::default()), + Err(PlanError::MissingArgs) + )); + } + + #[test] + fn namespace_paths_join_the_sandbox() { + let spec = spec_from_json( + r#"{"ociVersion":"1.0.2","process":{"args":["/bin/sh"],"cwd":"/","user":{"uid":0,"gid":0}},"linux":{"namespaces":[{"type":"pid","path":"/proc/123/ns/pid"},{"type":"mount","path":"/proc/123/ns/mnt"}]}}"#, + ); + let plan = + build_plan(&spec, "c1", Path::new("/b"), vec![], IoPlan::default()).expect("plan"); + let pid = plan + .namespaces + .iter() + .find(|ns| ns.kind == NamespaceKind::Pid) + .expect("pid ns"); + assert_eq!(pid.path.as_deref(), Some(Path::new("/proc/123/ns/pid"))); + let mount = plan + .namespaces + .iter() + .find(|ns| ns.kind == NamespaceKind::Mount) + .expect("mount ns"); + assert_eq!(mount.path.as_deref(), Some(Path::new("/proc/123/ns/mnt"))); + } + + #[test] + fn namespaces_map_with_stable_join_semantics() { + let spec = spec_from_json( + r#"{"ociVersion":"1.0.2","process":{"args":["/bin/sh"],"cwd":"/","user":{"uid":0,"gid":0}},"linux":{"namespaces":[{"type":"pid"},{"type":"network","path":"/var/run/netns/n1"},{"type":"time"}]}}"#, + ); + let plan = + build_plan(&spec, "c1", Path::new("/b"), vec![], IoPlan::default()).expect("plan"); + let kinds: Vec<_> = plan + .namespaces + .iter() + .map(|ns| (ns.kind, ns.path.is_some())) + .collect(); + // mount namespace inserted first, then pid, then joined network. + assert_eq!(kinds[0], (NamespaceKind::Mount, false)); + assert_eq!(kinds[1], (NamespaceKind::Pid, false)); + assert_eq!(kinds[2], (NamespaceKind::Network, true)); + assert!(plan.warnings.iter().any(|w| w.contains("time namespace"))); + } + + #[test] + fn cgroup_limits_render_cgroupv2_values() { + let spec = spec_from_json( + r#"{"ociVersion":"1.0.2","process":{"args":["/bin/sh"],"cwd":"/","user":{"uid":0,"gid":0}},"linux":{"cgroupsPath":"burstable.slice:pod123:abc","resources":{"pids":{"limit":128},"memory":{"limit":536870912},"cpu":{"quota":500000,"period":100000}}}}"#, + ); + let plan = + build_plan(&spec, "c1", Path::new("/b"), vec![], IoPlan::default()).expect("plan"); + let cgroup = plan.cgroup.expect("cgroup planned"); + assert_eq!(cgroup.path.as_deref(), Some("burstable.slice/pod123/abc")); + assert_eq!(cgroup.pids_max.as_deref(), Some("128")); + assert_eq!(cgroup.memory_max.as_deref(), Some("536870912")); + assert_eq!(cgroup.cpu_max.as_deref(), Some("500000 100000")); + } + + #[test] + fn unlimited_cpu_renders_max() { + let spec = spec_from_json( + r#"{"ociVersion":"1.0.2","process":{"args":["/bin/sh"],"cwd":"/","user":{"uid":0,"gid":0}},"linux":{"resources":{"cpu":{"quota":-1}}}}"#, + ); + let plan = + build_plan(&spec, "c1", Path::new("/b"), vec![], IoPlan::default()).expect("plan"); + assert_eq!( + plan.cgroup.and_then(|c| c.cpu_max), + Some("max 100000".to_string()) + ); + } + + #[test] + fn unsupported_mounts_and_seccomp_land_in_warnings() { + let spec = spec_from_json( + r#"{"ociVersion":"1.0.2","process":{"args":["/bin/sh"],"cwd":"/","user":{"uid":0,"gid":0}},"mounts":[{"destination":"/data","type":"ceph","source":"mon1:/"},{"destination":"/proc","type":"proc"}],"linux":{"seccomp":{"defaultAction":"SCMP_ACT_ERRNO"}}}"#, + ); + let plan = + build_plan(&spec, "c1", Path::new("/b"), vec![], IoPlan::default()).expect("plan"); + assert_eq!(plan.mounts.len(), 1); + assert_eq!(plan.mounts[0].fs_type, "proc"); + assert!(plan.warnings.iter().any(|w| w.contains("ceph"))); + assert!(plan.warnings.iter().any(|w| w.contains("seccomp"))); + } + + #[test] + fn sandbox_plan_derives_holder_semantics() { + let mut annotations = HashMap::new(); + annotations.insert("io.kubernetes.pod.name".to_string(), "my-pod".to_string()); + let plan = build_sandbox_plan("sbx1", Some("/var/run/netns/n1"), &annotations); + assert_eq!(plan.hostname.as_deref(), Some("my-pod")); + assert_eq!(plan.netns_path.as_deref(), Some("/var/run/netns/n1")); + assert!(!plan.share_pid_namespace); + + // Without a pod annotation the hostname falls back to the id prefix. + let fallback = build_sandbox_plan("abcdef1234567890", None, &HashMap::new()); + assert_eq!(fallback.hostname.as_deref(), Some("abcdef123456")); + assert!(fallback.netns_path.is_none()); + } + + #[test] + fn has_namespace_reflects_spec_configuration() { + let spec = spec_from_json( + r#"{"ociVersion":"1.0.2","process":{"args":["/bin/sh"],"cwd":"/","user":{"uid":0,"gid":0}},"linux":{"namespaces":[{"type":"pid"},{"type":"network","path":"/var/run/netns/n1"}]}}"#, + ); + let plan = + build_plan(&spec, "c1", Path::new("/b"), vec![], IoPlan::default()).expect("plan"); + assert!(plan.has_namespace(NamespaceKind::Pid)); + assert!(plan.has_namespace(NamespaceKind::Network)); + assert!(!plan.has_namespace(NamespaceKind::Uts)); + assert!(!plan.has_namespace(NamespaceKind::Ipc)); + // The planner always inserts a private mount namespace. + assert!(plan.has_namespace(NamespaceKind::Mount)); + } + + #[test] + fn vm_executor_is_annotation_driven() { + let base = minimal_spec(); + let plan = + build_plan(&base, "c1", Path::new("/b"), vec![], IoPlan::default()).expect("plan"); + assert!(!plan.wants_vm()); + assert_eq!(plan.vm_config(), VmConfig::default()); + + let mut vm_spec = minimal_spec(); + vm_spec + .annotations_mut() + .get_or_insert_with(Default::default) + .insert("io.pvisor.executor".to_string(), "vm".to_string()); + vm_spec + .annotations_mut() + .get_or_insert_with(Default::default) + .insert("io.pvisor.vm.cpus".to_string(), "4".to_string()); + vm_spec + .annotations_mut() + .get_or_insert_with(Default::default) + .insert("io.pvisor.vm.memory-mib".to_string(), "1024".to_string()); + let plan = + build_plan(&vm_spec, "c1", Path::new("/b"), vec![], IoPlan::default()).expect("plan"); + assert!(plan.wants_vm()); + assert_eq!( + plan.vm_config(), + VmConfig { + cpus: 4, + ram_mib: 1024 + } + ); + } + + #[test] + fn guest_init_script_quotes_everything() { + let process = ProcessPlan { + argv: vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo 'hi there'".to_string(), + ], + env: vec!["GREETING=hello world".to_string()], + cwd: PathBuf::from("/work dir"), + user: UserPlan::default(), + capabilities: CapabilityPlan::default(), + rlimits: vec![], + no_new_privileges: false, + }; + let script = render_guest_init_script(&process); + assert!(script.starts_with("#!/bin/sh\n")); + assert!(script.contains("cd '/work dir' || exit 127")); + assert!(script.contains("exec env -i 'GREETING=hello world'")); + // The inner single quotes of the argument survive shell quoting. + let expected_arg = format!("'{}'", "echo 'hi there'".replace('\'', "'\\''")); + assert!( + script.contains(&format!(" {expected_arg}\n")), + "script did not contain {expected_arg}: {script}" + ); + } + + #[test] + fn guest_init_script_starts_the_agent_when_enabled() { + let process = ProcessPlan { + argv: vec!["/bin/true".to_string()], + env: vec![], + cwd: PathBuf::from("/"), + user: UserPlan::default(), + capabilities: CapabilityPlan::default(), + rlimits: vec![], + no_new_privileges: false, + }; + let plain = render_guest_init_script_with(&process, false); + assert!(!plain.contains(crate::agent::AGENT_GUEST_PATH)); + let with_agent = render_guest_init_script_with(&process, true); + assert!(with_agent.contains(&format!( + "{} {} >/dev/null 2>&1 &", + crate::agent::AGENT_GUEST_PATH, + crate::agent::AGENT_ARG + ))); + // The agent line comes before the exec tail. + let agent_at = with_agent + .find(crate::agent::AGENT_GUEST_PATH) + .expect("agent line"); + let exec_at = with_agent.find("exec env -i").expect("exec line"); + assert!(agent_at < exec_at); + } + + #[test] + fn vm_agent_annotation_controls_the_agent() { + let mut annotations = HashMap::new(); + assert!(vm_agent_enabled(&annotations)); + annotations.insert("io.pvisor.vm.agent".to_string(), "off".to_string()); + assert!(!vm_agent_enabled(&annotations)); + annotations.insert("io.pvisor.vm.agent".to_string(), "on".to_string()); + assert!(vm_agent_enabled(&annotations)); + } + + #[test] + fn guest_script_path_is_unique_per_task() { + assert_eq!(guest_init_script_path("abc"), "/.pvisor-shim-init-abc.sh"); + } + + #[test] + fn exec_plan_reuses_the_process_extraction() { + let process: oci_spec::runtime::Process = serde_json::from_str( + r#"{"args":["/bin/ls","-l"],"cwd":"/tmp","user":{"uid":0,"gid":0},"env":["FOO=bar"]}"#, + ) + .expect("parse process"); + let plan = build_exec_plan( + &process, + "c1", + "e1", + 4242, + IoPlan { + terminal: false, + stdin: Some("/bundle/stdin".to_string()), + stdout: Some("/bundle/stdout".to_string()), + stderr: Some("/bundle/stderr".to_string()), + }, + ) + .expect("exec plan"); + assert_eq!(plan.init_pid, 4242); + assert_eq!( + plan.process.argv, + vec!["/bin/ls".to_string(), "-l".to_string()] + ); + assert_eq!(plan.process.cwd, PathBuf::from("/tmp")); + assert_eq!(plan.process.env, vec!["FOO=bar".to_string()]); + + let no_args = serde_json::from_str::( + r#"{"cwd":"/","user":{"uid":0,"gid":0}}"#, + ) + .expect("parse process"); + assert!(matches!( + build_exec_plan(&no_args, "c1", "e1", 1, IoPlan::default()), + Err(PlanError::MissingArgs) + )); + } + + #[test] + fn request_mounts_are_kept_separate_from_spec_mounts() { + let request_mounts = vec![MountPlan { + destination: PathBuf::from("/"), + fs_type: "overlay".to_string(), + source: Some("overlay".to_string()), + options: vec!["lowerdir=/snaps/l1".to_string()], + from_request: true, + }]; + let plan = build_plan( + &minimal_spec(), + "c1", + Path::new("/bundle"), + request_mounts, + IoPlan::default(), + ) + .expect("plan"); + assert!(plan.rootfs_mounts[0].from_request); + assert!(!plan.mounts.iter().any(|m| m.from_request)); + } + + #[test] + fn user_and_capabilities_carry_through() { + let spec = spec_from_json( + r#"{"ociVersion":"1.0.2","process":{"args":["/bin/sh"],"cwd":"/w","user":{"uid":1000,"gid":2000,"additionalGids":[2000,999]},"capabilities":{"bounding":["CAP_CHOWN","CAP_NET_BIND_SERVICE"],"effective":["CAP_CHOWN"]},"rlimits":[{"type":"RLIMIT_NOFILE","soft":1024,"hard":2048}],"noNewPrivileges":true},"root":{"path":"rootfs","readonly":true}}"#, + ); + let plan = + build_plan(&spec, "c1", Path::new("/b"), vec![], IoPlan::default()).expect("plan"); + assert_eq!(plan.process.user.uid, 1000); + assert_eq!(plan.process.user.additional_gids, vec![2000, 999]); + assert_eq!(plan.process.cwd, PathBuf::from("/w")); + assert!(plan.process.no_new_privileges); + assert!(plan.root_readonly); + // Capability names arrive via Display ("CAP_" prefix is stripped by + // oci-spec); caps::from_names re-normalizes them. + assert_eq!(plan.process.capabilities.bounding.len(), 2); + assert_eq!(plan.process.rlimits.len(), 1); + assert_eq!(plan.process.rlimits[0].typ, "RLIMIT_NOFILE"); + } +} diff --git a/crates/persisting-shim/src/service.rs b/crates/persisting-shim/src/service.rs new file mode 100644 index 00000000..249350ec --- /dev/null +++ b/crates/persisting-shim/src/service.rs @@ -0,0 +1,1685 @@ +//! The task service (ttrpc `containerd.task.v2.Task`) and the shim +//! bootstrap (`containerd_shim::Shim`) for `io.containerd.pvisor.v2`. +//! +//! M4 scope: pod-level sandboxes (Sandbox API, sandboxer = "shim") plus the +//! host process path with full task IO ownership — create/ +//! start/kill/wait/delete/state/pids/connect/shutdown for init processes, +//! exec (`Exec` -> `Start(exec_id)` -> `Wait`/`Kill`/`Delete` with +//! `TaskExecAdded`/`TaskExecStarted` events), `CloseIO` (stdin keepalive) +//! and `ResizePty` (retained PTY master). Stats, pause/resume and +//! checkpointing are not implemented yet (the generated trait defaults +//! report them as unsupported). + +use std::collections::HashMap; +use std::path::PathBuf; +use std::process; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use anyhow::{Context as AnyhowContext, Result}; +use async_trait::async_trait; +use containerd_shim::asynchronous::ExitSignal; +use containerd_shim::asynchronous::monitor::{Subscription, monitor_subscribe}; +use containerd_shim::monitor::{ExitEvent, Subject, Topic}; +use containerd_shim::{Config, Flags, Shim, StartOpts, TtrpcContext}; +use containerd_shim_protos::api::{ + CloseIORequest, ConnectRequest, ConnectResponse, CreateTaskRequest, CreateTaskResponse, + DeleteRequest, DeleteResponse, Empty, ExecProcessRequest, KillRequest, Mount, PidsRequest, + PidsResponse, ProcessInfo, ResizePtyRequest, ShutdownRequest, StartRequest, StartResponse, + StateRequest, StateResponse, Status, WaitRequest, WaitResponse, +}; +use containerd_shim_protos::events::task::{ + TaskCreate, TaskDelete, TaskExecAdded, TaskExecStarted, TaskExit, TaskStart, +}; +use containerd_shim_protos::protobuf::well_known_types::timestamp::Timestamp; +use containerd_shim_protos::protobuf::{Message, MessageDyn}; +use containerd_shim_protos::sandbox_api::{ + CreateSandboxRequest, CreateSandboxResponse, PingRequest, PingResponse, PlatformRequest, + PlatformResponse, SandboxStatusRequest, SandboxStatusResponse, ShutdownSandboxRequest, + ShutdownSandboxResponse, StartSandboxRequest, StartSandboxResponse, StopSandboxRequest, + StopSandboxResponse, WaitSandboxRequest, WaitSandboxResponse, +}; +use containerd_shim_protos::sandbox_async::Sandbox; +use containerd_shim_protos::shim_async::Task; +use containerd_shim_protos::topics::{ + TASK_CREATE_EVENT_TOPIC, TASK_DELETE_EVENT_TOPIC, TASK_EXEC_ADDED_EVENT_TOPIC, + TASK_EXEC_STARTED_EVENT_TOPIC, TASK_EXIT_EVENT_TOPIC, TASK_START_EVENT_TOPIC, +}; +use containerd_shim_protos::ttrpc::{self, Code, context::Context, get_status}; +use log::{info, warn}; +#[cfg(feature = "vm")] +use std::os::fd::AsRawFd; +#[cfg(feature = "vm")] +use tokio::sync::mpsc; +use tokio::sync::watch; + +#[cfg(feature = "vm")] +use crate::agent::{Channel, Control, FrameReader, FrameWriter}; +use crate::child::{self, InternalChild, StdioFds}; +use crate::fifo::ContainerIo; +#[cfg(feature = "vm")] +use crate::plan::ExecPlan; +use crate::plan::{IoPlan, MountPlan, build_exec_plan, build_plan, build_sandbox_plan}; +use crate::spec::load_bundle_spec; +use crate::state::{ExecEntry, ExitInfo, ExitTarget, TaskEntry, TaskStatus}; + +/// One live exec process: bookkeeping lives on the task entry; here we keep +/// the start pipe and the shim-owned IO. +struct LiveExec { + exit_tx: watch::Sender>, + internal: Option, + /// VM execs: the agent socket fd; shutting it down is the kill. + vm_sock: Option, + io: ContainerIo, +} + +/// One live task: init bookkeeping plus its start pipe, IO, and execs. +struct LiveTask { + entry: TaskEntry, + /// Exit channel for the init process; sends `Some(ExitInfo)` once. + exit_tx: watch::Sender>, + internal: Option, + io: ContainerIo, + execs: HashMap, + /// Task runs in a libkrun VM: exec goes through the guest agent. + vm: bool, +} + +/// The pod sandbox served by this shim instance: the holder process (the +/// pause-container replacement) and its lifecycle. +struct SandboxState { + id: String, + internal: InternalChild, + exit_tx: watch::Sender>, +} + +struct TaskInner { + namespace: String, + publisher: containerd_shim::asynchronous::publisher::RemotePublisher, + tasks: Mutex>, + sandbox: Mutex>, + exit: Arc, +} + +pub struct PvisorTask { + inner: Arc, +} + +impl PvisorTask { + async fn publish(&self, topic: &str, event: Box) { + if let Err(error) = self + .inner + .publisher + .publish(Context::default(), topic, &self.inner.namespace, event) + .await + { + warn!("publish {topic} failed: {error}"); + } + } + + fn resolve_bundle(bundle: &str) -> Result { + let path = PathBuf::from(bundle); + if path.is_absolute() { + return Ok(path); + } + let cwd = std::env::current_dir().context("current dir")?; + Ok(cwd.join(path)) + } + + fn convert_mounts(request_mounts: &[Mount]) -> Vec { + request_mounts + .iter() + .map(|mount| { + let source = mount.source(); + // containerd sends the rootfs mount with an empty target; + // it means "the root of the bundle rootfs". + let target = mount.target(); + let destination = if target.is_empty() { + PathBuf::from("/") + } else { + PathBuf::from(target) + }; + MountPlan { + destination, + fs_type: mount.type_().to_string(), + source: if source.is_empty() { + None + } else { + Some(source.to_string()) + }, + options: mount.options().to_vec(), + from_request: true, + } + }) + .collect() + } + + fn stdio_fds(io: &ContainerIo) -> StdioFds { + let (stdin, stdout, stderr) = io.child_fds(); + StdioFds { + stdin, + stdout, + stderr, + terminal: io.is_terminal(), + } + } + + async fn delete_exec(&self, id: String, exec_id: String) -> ttrpc::Result { + let (entry, mut live_exec, bundle) = { + let mut tasks = self.inner.tasks.lock().expect("tasks mutex"); + let Some(live) = tasks.get_mut(&id) else { + return Err(not_found(&id)); + }; + let Some(entry) = live.entry.execs.get(&exec_id) else { + return Err(not_found(&exec_id)); + }; + if entry.status != TaskStatus::Stopped { + return Err(rpc_error( + Code::FAILED_PRECONDITION, + format!("exec {exec_id} is not stopped"), + )); + } + let entry = live.entry.execs.remove(&exec_id).expect("checked above"); + let live_exec = live.execs.remove(&exec_id).expect("entries stay in sync"); + (entry, live_exec, live.entry.bundle.clone()) + }; + + if let Some(mut internal) = live_exec.internal.take() { + internal.close_start(); + } + if let Some(sock_fd) = live_exec.vm_sock.take() { + unsafe { libc::close(sock_fd) }; + } + let _ = std::fs::remove_file(bundle.join(format!("pvisor-exec-{exec_id}.json"))); + + let mut response = DeleteResponse::new(); + response.set_pid(entry.pid.unwrap_or(0)); + if let Some(exit) = entry.exit.as_ref() { + response.set_exit_status(exit.status); + response.set_exited_at(timestamp_from(exit.exited_at)); + } + + let mut event = TaskDelete::new(); + event.set_container_id(id); + event.set_pid(response.pid); + event.set_exited_at(response.exited_at.clone().into_option().unwrap_or_default()); + self.publish(TASK_DELETE_EVENT_TOPIC, Box::new(event)).await; + Ok(response) + } +} + +#[cfg(feature = "vm")] +impl PvisorTask { + /// Exec inside a libkrun VM task: connect to the guest agent over the + /// bundle's agent socket, run the process there, and relay its IO. + async fn exec_in_vm( + &self, + req: ExecProcessRequest, + bundle: PathBuf, + _init_pid: u32, + ) -> ttrpc::Result { + if !cfg!(feature = "vm") { + return Err(rpc_error( + Code::UNIMPLEMENTED, + "exec in VMs requires building the shim with --features vm", + )); + } + if req.terminal { + return Err(rpc_error( + Code::UNIMPLEMENTED, + "tty exec in VMs is not supported yet", + )); + } + let process: oci_spec::runtime::Process = serde_json::from_slice(&req.spec().value) + .map_err(|error| { + rpc_error( + Code::INVALID_ARGUMENT, + format!("invalid exec process spec: {error}"), + ) + })?; + let io = io_plan_from(false, &req.stdin, &req.stdout, &req.stderr); + let plan = build_exec_plan(&process, &req.id, &req.exec_id, 0, io) + .map_err(|error| rpc_error(Code::INVALID_ARGUMENT, error.to_string()))?; + + let mut owned_io = ContainerIo::open(&plan.io) + .map_err(|error| rpc_error(Code::INTERNAL, format!("open exec io: {error:#}")))?; + let Some((stdin, stdout, stderr)) = owned_io.take_child_fds() else { + return Err(rpc_error(Code::INTERNAL, "exec io without child fds")); + }; + + let socket_path = bundle.join("pvisor-agent.sock"); + let (pid, stream) = tokio::task::spawn_blocking({ + let plan = plan.clone(); + let socket_path = socket_path.clone(); + move || vm_exec_connect_and_start(&socket_path, &plan) + }) + .await + .map_err(|error| rpc_error(Code::INTERNAL, format!("vm exec join: {error}")))? + .map_err(|error| rpc_error(Code::INTERNAL, format!("{error:#}")))?; + let sock_fd = stream.as_raw_fd(); + + // The guest process starts immediately (containerd's Start call for + // VM execs just returns the pid we already know). + let exec_entry = ExecEntry::new( + &req.exec_id, + plan.io.stdin.clone(), + plan.io.stdout.clone(), + plan.io.stderr.clone(), + false, + Some(pid), + ); + let (exit_tx, _) = watch::channel(None); + let (events_tx, mut events_rx) = mpsc::unbounded_channel::(); + { + let mut tasks = self.inner.tasks.lock().expect("tasks mutex"); + let live = tasks.get_mut(&req.id).ok_or_else(|| not_found(&req.id))?; + if !live.entry.add_exec(exec_entry) { + return Err(rpc_error( + Code::ALREADY_EXISTS, + format!("exec {} already exists", req.exec_id), + )); + } + live.execs.insert( + req.exec_id.clone(), + LiveExec { + exit_tx, + internal: None, + vm_sock: Some(sock_fd), + io: owned_io, + }, + ); + } + + // Relay the exec IO and bridge the guest exit into the task state. + { + let inner = self.inner.clone(); + let container_id = req.id.clone(); + let exec_id = req.exec_id.clone(); + tokio::spawn(async move { + while let Some(status) = events_rx.recv().await { + vm_exec_exited(&inner, &container_id, &exec_id, pid, status).await; + } + }); + } + spawn_vm_exec_relay(stream, stdin, stdout, stderr, events_tx); + + let mut event = TaskExecAdded::new(); + event.set_container_id(req.id.clone()); + event.set_exec_id(req.exec_id.clone()); + self.publish(TASK_EXEC_ADDED_EVENT_TOPIC, Box::new(event)) + .await; + Ok(Empty::new()) + } +} + +/// Connect to the agent socket (retrying while the VM boots), send the exec +/// request, and return the guest pid plus the live connection. +#[cfg(feature = "vm")] +fn vm_exec_connect_and_start( + socket_path: &std::path::Path, + plan: &ExecPlan, +) -> Result<(u32, std::os::unix::net::UnixStream)> { + let mut stream = None; + for _ in 0..300 { + match std::os::unix::net::UnixStream::connect(socket_path) { + Ok(connection) => { + stream = Some(connection); + break; + } + Err(_) => std::thread::sleep(std::time::Duration::from_millis(100)), + } + } + let mut stream = stream.context("agent socket never came up")?; + + let mut writer = FrameWriter::new(stream.try_clone()?); + writer.write_control(&Control::ExecStart { + argv: plan.process.argv.clone(), + env: plan.process.env.clone(), + cwd: plan.process.cwd.to_string_lossy().to_string(), + })?; + let message = FrameReader::new(&mut stream) + .read_control()? + .context("agent closed before starting the process")?; + match message { + Control::Started { pid } => Ok((pid, stream)), + Control::Error { message } => anyhow::bail!("guest agent: {message}"), + other => anyhow::bail!("unexpected agent reply: {other:?}"), + } +} + +/// Pump the exec IO between the task FIFOs and the agent connection; the +/// exit status (or 137 for a dropped connection, i.e. a kill) flows back +/// through `events`. +#[cfg(feature = "vm")] +fn spawn_vm_exec_relay( + stream: std::os::unix::net::UnixStream, + stdin: std::fs::File, + stdout: std::fs::File, + stderr: std::fs::File, + events: mpsc::UnboundedSender, +) { + use std::io::{Read, Write}; + + let writer = stream.try_clone().expect("clone agent stream"); + std::thread::spawn(move || { + let mut stdin = stdin; + let mut writer = FrameWriter::new(writer); + let mut buffer = [0u8; 8192]; + loop { + match stdin.read(&mut buffer) { + Ok(0) | Err(_) => { + let _ = writer.write_frame(Channel::Stdin, b""); + break; + } + Ok(n) => { + if writer.write_frame(Channel::Stdin, &buffer[..n]).is_err() { + break; + } + } + } + } + }); + + std::thread::spawn(move || { + let mut stdout = stdout; + let mut stderr = stderr; + let mut reader = FrameReader::new(stream); + let status = loop { + match reader.read_frame() { + Ok(Some(frame)) => match frame.channel { + Channel::Stdout => { + if stdout.write_all(&frame.payload).is_err() { + break 255; + } + } + Channel::Stderr => { + if stderr.write_all(&frame.payload).is_err() { + break 255; + } + } + Channel::Stdin => {} + Channel::Control => { + if let Control::Exited { status } = serde_json::from_slice(&frame.payload) + .unwrap_or(Control::Error { + message: "bad exit frame".to_string(), + }) + { + break status; + } + } + }, + // Socket dropped without an exit frame: killed or crashed. + Ok(None) => break 137, + Err(_) => break 137, + } + }; + let _ = events.send(status); + }); +} + +/// Record a VM exec exit (guest pid, never matched against host pids) and +/// publish the TaskExit event. +#[cfg(feature = "vm")] +async fn vm_exec_exited( + inner: &Arc, + container_id: &str, + exec_id: &str, + pid: u32, + status: u32, +) { + let notified = { + let mut tasks = inner.tasks.lock().expect("tasks mutex"); + let Some(live) = tasks.get_mut(container_id) else { + return; + }; + if !live + .entry + .record_exec_exit(exec_id, status, SystemTime::now()) + { + return; + } + live.execs.get(exec_id).map(|exec| { + exec.exit_tx.send(Some(ExitInfo { + status, + exited_at: SystemTime::now(), + })) + }) + }; + if notified.is_some() { + let mut event = TaskExit::new(); + event.set_container_id(container_id.to_string()); + event.set_id(exec_id.to_string()); + event.set_pid(pid); + event.set_exit_status(status); + event.set_exited_at(timestamp_from(SystemTime::now())); + let task = PvisorTask { + inner: inner.clone(), + }; + task.publish(TASK_EXIT_EVENT_TOPIC, Box::new(event)).await; + } +} + +fn timestamp_from(system_time: SystemTime) -> Timestamp { + let since = system_time + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + let mut timestamp = Timestamp::new(); + timestamp.seconds = since.as_secs() as i64; + timestamp.nanos = since.subsec_nanos() as i32; + timestamp +} + +fn rpc_error(code: Code, message: impl Into) -> ttrpc::Error { + ttrpc::Error::RpcStatus(get_status(code, message.into())) +} + +fn not_found(id: &str) -> ttrpc::Error { + rpc_error(Code::NOT_FOUND, format!("task {id} not found")) +} + +fn status_to_api(status: TaskStatus) -> Status { + match status { + TaskStatus::Created => Status::CREATED, + TaskStatus::Running => Status::RUNNING, + TaskStatus::Stopped => Status::STOPPED, + } +} + +fn start_response(pid: u32) -> StartResponse { + let mut response = StartResponse::new(); + response.set_pid(pid); + response +} + +fn non_empty(value: &str) -> Option { + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +fn io_plan_from(terminal: bool, stdin: &str, stdout: &str, stderr: &str) -> IoPlan { + IoPlan { + terminal, + stdin: non_empty(stdin), + stdout: non_empty(stdout), + stderr: non_empty(stderr), + } +} + +/// Background loop mapping framework exit events onto tracked processes. +async fn run_exit_watcher(inner: Arc, mut subscription: Subscription) { + loop { + let Some(event) = subscription.rx.recv().await else { + return; + }; + let ExitEvent { + subject: Subject::Pid(pid), + exit_code, + } = event + else { + continue; + }; + let exit = ExitInfo { + status: exit_code as u32, + exited_at: SystemTime::now(), + }; + let notified = { + let mut tasks = inner.tasks.lock().expect("tasks mutex"); + let mut found = None; + for (id, live) in tasks.iter_mut() { + match live + .entry + .record_exit_by_pid(pid as u32, exit.status, exit.exited_at) + { + Some(ExitTarget::Init) => { + let _ = live.exit_tx.send(Some(exit)); + found = Some((id.clone(), String::new())); + break; + } + Some(ExitTarget::Exec(exec_id)) => { + if let Some(exec) = live.execs.get_mut(&exec_id) { + let _ = exec.exit_tx.send(Some(exit)); + } + found = Some((id.clone(), exec_id)); + break; + } + None => {} + } + } + found + }; + // The sandbox holder exiting outside any task still needs to wake + // WaitSandbox; it produces no task event. + if notified.is_none() { + let sandbox = inner.sandbox.lock().expect("sandbox mutex"); + if let Some(sandbox) = sandbox.as_ref() + && sandbox.internal.pid == Some(pid as u32) + { + let _ = sandbox.exit_tx.send(Some(exit)); + } + continue; + } + let Some((container_id, exec_id)) = notified else { + continue; + }; + let mut event = TaskExit::new(); + event.set_container_id(container_id.clone()); + // The runtime contract (and moby's EventExit handling) expects the + // init process's exit to carry the container id, not an empty exec + // id — empty is treated as an unknown exec (exit 127). + event.set_id(if exec_id.is_empty() { + container_id.clone() + } else { + exec_id + }); + event.set_pid(pid as u32); + event.set_exit_status(exit.status); + event.set_exited_at(timestamp_from(exit.exited_at)); + let task = PvisorTask { + inner: inner.clone(), + }; + task.publish(TASK_EXIT_EVENT_TOPIC, Box::new(event)).await; + } +} + +#[async_trait] +impl Task for PvisorTask { + async fn create( + &self, + _ctx: &TtrpcContext, + req: CreateTaskRequest, + ) -> ttrpc::Result { + if req.id.is_empty() { + return Err(rpc_error(Code::INVALID_ARGUMENT, "task id required")); + } + if req.bundle.is_empty() { + return Err(rpc_error(Code::INVALID_ARGUMENT, "bundle required")); + } + if self + .inner + .tasks + .lock() + .expect("tasks mutex") + .contains_key(&req.id) + { + return Err(rpc_error( + Code::ALREADY_EXISTS, + format!("task {} already exists", req.id), + )); + } + + let bundle = Self::resolve_bundle(&req.bundle) + .map_err(|error| rpc_error(Code::INVALID_ARGUMENT, format!("{error:#}")))?; + let spec = load_bundle_spec(&bundle) + .map_err(|error| rpc_error(Code::INVALID_ARGUMENT, format!("{error:#}")))?; + let io = io_plan_from(req.terminal, &req.stdin, &req.stdout, &req.stderr); + let plan = build_plan( + &spec, + &req.id, + &bundle, + Self::convert_mounts(&req.rootfs), + io, + ) + .map_err(|error| rpc_error(Code::INVALID_ARGUMENT, error.to_string()))?; + for warning in &plan.warnings { + warn!("task {}: {warning}", req.id); + } + + // The shim owns the task IO; descriptors travel to the init child + // through fd inheritance. + let mut owned_io = ContainerIo::open(&plan.io) + .map_err(|error| rpc_error(Code::INTERNAL, format!("open task io: {error:#}")))?; + let stdio = Self::stdio_fds(&owned_io); + let plan_path = bundle.join("pvisor-plan.json"); + let plan_bytes = serde_json::to_vec(&plan) + .map_err(|error| rpc_error(Code::INTERNAL, format!("{error}")))?; + + // `io.pvisor.executor=vm` routes the task to the libkrun runner; + // everything else (lifecycle, events, kill, wait) is identical. + let wants_vm = plan.wants_vm(); + let runner_arg = if wants_vm { + if !cfg!(feature = "vm") { + return Err(rpc_error( + Code::UNIMPLEMENTED, + "io.pvisor.executor=vm requires building the shim with --features vm", + )); + } + #[cfg(feature = "vm")] + { + child::INTERNAL_VM_ARG + } + #[cfg(not(feature = "vm"))] + { + unreachable!("checked the vm feature above") + } + } else { + child::INTERNAL_INIT_ARG + }; + if wants_vm && !plan.mounts.is_empty() { + warn!( + "task {}: {} spec mounts are not mapped into VMs yet", + req.id, + plan.mounts.len() + ); + } + + // Containers created while this shim's sandbox runs join the pod's + // shared namespaces unless their spec overrides them. + let sandbox_pid = self + .inner + .sandbox + .lock() + .expect("sandbox mutex") + .as_ref() + .and_then(|sandbox| sandbox.internal.pid); + + let internal = tokio::task::spawn_blocking({ + let plan_path = plan_path.clone(); + move || { + child::spawn_internal( + runner_arg, + &plan_path, + &plan_bytes, + Some(stdio), + sandbox_pid, + ) + } + }) + .await + .map_err(|error| rpc_error(Code::INTERNAL, format!("init join: {error}")))? + .map_err(|error| rpc_error(Code::INTERNAL, format!("{error:#}")))?; + let pid = internal.pid.unwrap_or(0); + // The child owns its descriptors now; keep only keepalive/master. + owned_io.release_child_fds(); + + let entry = TaskEntry::new( + &req.id, + bundle, + plan.io.stdin.clone(), + plan.io.stdout.clone(), + plan.io.stderr.clone(), + plan.io.terminal, + Some(pid), + ); + let (exit_tx, _) = watch::channel(None); + self.inner.tasks.lock().expect("tasks mutex").insert( + req.id.clone(), + LiveTask { + entry, + exit_tx, + internal: Some(internal), + io: owned_io, + execs: HashMap::new(), + vm: wants_vm, + }, + ); + + let mut event = TaskCreate::new(); + event.set_container_id(req.id.clone()); + event.set_bundle(plan.bundle.to_string_lossy().to_string()); + event.set_pid(pid); + self.publish(TASK_CREATE_EVENT_TOPIC, Box::new(event)).await; + + let mut response = CreateTaskResponse::new(); + response.set_pid(pid); + Ok(response) + } + + async fn start(&self, _ctx: &TtrpcContext, req: StartRequest) -> ttrpc::Result { + let (pid, exec_id) = { + let mut tasks = self.inner.tasks.lock().expect("tasks mutex"); + let live = tasks.get_mut(&req.id).ok_or_else(|| not_found(&req.id))?; + if !req.exec_id.is_empty() { + // VM execs run from the moment Exec returns (the guest agent + // has no two-phase gate); Start just reports the pid. + let entry = live + .entry + .execs + .get(&req.exec_id) + .ok_or_else(|| not_found(&req.exec_id))?; + if entry.status == TaskStatus::Running && entry.pid.is_some() { + return Ok(start_response(entry.pid.unwrap_or(0))); + } + let mut internal = live + .execs + .get_mut(&req.exec_id) + .and_then(|exec| exec.internal.take()) + .ok_or_else(|| { + rpc_error( + Code::FAILED_PRECONDITION, + format!("exec {} already started", req.exec_id), + ) + })?; + internal + .start() + .map_err(|error| rpc_error(Code::INTERNAL, format!("{error:#}")))?; + let entry = live + .entry + .execs + .get_mut(&req.exec_id) + .ok_or_else(|| not_found(&req.exec_id))?; + let pid = entry.pid.unwrap_or(0); + if !entry.mark_started(pid) { + return Err(rpc_error( + Code::FAILED_PRECONDITION, + format!("exec {} cannot start", req.exec_id), + )); + } + (pid, req.exec_id.clone()) + } else { + let mut internal = live + .internal + .take() + .ok_or_else(|| rpc_error(Code::FAILED_PRECONDITION, "task already started"))?; + internal + .start() + .map_err(|error| rpc_error(Code::INTERNAL, format!("{error:#}")))?; + let pid = live.entry.pid.unwrap_or(0); + if !live.entry.mark_started(pid) { + return Err(rpc_error( + Code::FAILED_PRECONDITION, + format!("task {} cannot start", req.id), + )); + } + (pid, String::new()) + } + }; + + if exec_id.is_empty() { + let mut event = TaskStart::new(); + event.set_container_id(req.id.clone()); + event.set_pid(pid); + self.publish(TASK_START_EVENT_TOPIC, Box::new(event)).await; + } else { + let mut event = TaskExecStarted::new(); + event.set_container_id(req.id.clone()); + event.set_exec_id(exec_id); + event.set_pid(pid); + self.publish(TASK_EXEC_STARTED_EVENT_TOPIC, Box::new(event)) + .await; + } + + let mut response = StartResponse::new(); + response.set_pid(pid); + Ok(response) + } + + async fn state(&self, _ctx: &TtrpcContext, req: StateRequest) -> ttrpc::Result { + let tasks = self.inner.tasks.lock().expect("tasks mutex"); + let live = tasks.get(&req.id).ok_or_else(|| not_found(&req.id))?; + if !req.exec_id.is_empty() { + let exec = live + .entry + .execs + .get(&req.exec_id) + .ok_or_else(|| not_found(&req.exec_id))?; + let mut response = StateResponse::new(); + response.set_id(req.id.clone()); + response.set_exec_id(req.exec_id.clone()); + response.set_bundle(live.entry.bundle.to_string_lossy().to_string()); + response.set_pid(exec.pid.unwrap_or(0)); + response.set_status(status_to_api(exec.status)); + response.set_stdin(exec.stdin.clone().unwrap_or_default()); + response.set_stdout(exec.stdout.clone().unwrap_or_default()); + response.set_stderr(exec.stderr.clone().unwrap_or_default()); + response.set_terminal(exec.terminal); + if let Some(exit) = exec.exit.as_ref() { + response.set_exit_status(exit.status); + response.set_exited_at(timestamp_from(exit.exited_at)); + } + return Ok(response); + } + let entry = &live.entry; + let mut response = StateResponse::new(); + response.set_id(entry.id.clone()); + response.set_bundle(entry.bundle.to_string_lossy().to_string()); + response.set_pid(entry.pid.unwrap_or(0)); + response.set_status(status_to_api(entry.status)); + response.set_stdin(entry.stdin.clone().unwrap_or_default()); + response.set_stdout(entry.stdout.clone().unwrap_or_default()); + response.set_stderr(entry.stderr.clone().unwrap_or_default()); + response.set_terminal(entry.terminal); + if let Some(exit) = entry.exit.as_ref() { + response.set_exit_status(exit.status); + response.set_exited_at(timestamp_from(exit.exited_at)); + } + Ok(response) + } + + async fn wait(&self, _ctx: &TtrpcContext, req: WaitRequest) -> ttrpc::Result { + let mut receiver = { + let tasks = self.inner.tasks.lock().expect("tasks mutex"); + let live = tasks.get(&req.id).ok_or_else(|| not_found(&req.id))?; + if req.exec_id.is_empty() { + live.exit_tx.subscribe() + } else { + live.execs + .get(&req.exec_id) + .ok_or_else(|| not_found(&req.exec_id))? + .exit_tx + .subscribe() + } + }; + loop { + if let Some(exit) = receiver.borrow().as_ref() { + let mut response = WaitResponse::new(); + response.set_exit_status(exit.status); + response.set_exited_at(timestamp_from(exit.exited_at)); + return Ok(response); + } + if receiver.changed().await.is_err() { + // Sender dropped: the process was deleted while we waited. + return Err(not_found(&req.id)); + } + } + } + + async fn exec(&self, _ctx: &TtrpcContext, req: ExecProcessRequest) -> ttrpc::Result { + if req.exec_id.is_empty() { + return Err(rpc_error(Code::INVALID_ARGUMENT, "exec id required")); + } + if req.spec.is_none() { + return Err(rpc_error( + Code::INVALID_ARGUMENT, + "exec process spec required", + )); + } + let (bundle, init_pid, is_vm) = { + let tasks = self.inner.tasks.lock().expect("tasks mutex"); + let live = tasks.get(&req.id).ok_or_else(|| not_found(&req.id))?; + if live.entry.status != TaskStatus::Running { + return Err(rpc_error( + Code::FAILED_PRECONDITION, + format!("task {} is not running", req.id), + )); + } + if live.entry.execs.contains_key(&req.exec_id) { + return Err(rpc_error( + Code::ALREADY_EXISTS, + format!("exec {} already exists", req.exec_id), + )); + } + ( + live.entry.bundle.clone(), + live.entry.pid.unwrap_or(0), + live.vm, + ) + }; + + if is_vm { + #[cfg(feature = "vm")] + { + return self.exec_in_vm(req, bundle, init_pid).await; + } + #[cfg(not(feature = "vm"))] + { + return Err(rpc_error( + Code::UNIMPLEMENTED, + "exec in VMs requires building the shim with --features vm", + )); + } + } + + // The Any payload carries the JSON-encoded OCI process spec. + let process: oci_spec::runtime::Process = serde_json::from_slice(&req.spec().value) + .map_err(|error| { + rpc_error( + Code::INVALID_ARGUMENT, + format!("invalid exec process spec: {error}"), + ) + })?; + let io = io_plan_from(req.terminal, &req.stdin, &req.stdout, &req.stderr); + let plan = build_exec_plan(&process, &req.id, &req.exec_id, init_pid, io) + .map_err(|error| rpc_error(Code::INVALID_ARGUMENT, error.to_string()))?; + + let mut owned_io = ContainerIo::open(&plan.io) + .map_err(|error| rpc_error(Code::INTERNAL, format!("open exec io: {error:#}")))?; + let stdio = Self::stdio_fds(&owned_io); + let plan_path = bundle.join(format!("pvisor-exec-{}.json", req.exec_id)); + let plan_bytes = serde_json::to_vec(&plan) + .map_err(|error| rpc_error(Code::INTERNAL, format!("serialize exec plan: {error}")))?; + + let internal = tokio::task::spawn_blocking({ + let plan_path = plan_path.clone(); + move || { + child::spawn_internal( + child::INTERNAL_EXEC_ARG, + &plan_path, + &plan_bytes, + Some(stdio), + None, + ) + } + }) + .await + .map_err(|error| rpc_error(Code::INTERNAL, format!("exec join: {error}")))? + .map_err(|error| rpc_error(Code::INTERNAL, format!("{error:#}")))?; + let pid = internal.pid.unwrap_or(0); + owned_io.release_child_fds(); + + let exec_entry = ExecEntry::new( + &req.exec_id, + plan.io.stdin.clone(), + plan.io.stdout.clone(), + plan.io.stderr.clone(), + plan.io.terminal, + Some(pid), + ); + let (exit_tx, _) = watch::channel(None); + { + let mut tasks = self.inner.tasks.lock().expect("tasks mutex"); + let live = tasks.get_mut(&req.id).ok_or_else(|| not_found(&req.id))?; + if !live.entry.add_exec(exec_entry) { + return Err(rpc_error( + Code::ALREADY_EXISTS, + format!("exec {} already exists", req.exec_id), + )); + } + live.execs.insert( + req.exec_id.clone(), + LiveExec { + exit_tx, + internal: Some(internal), + vm_sock: None, + io: owned_io, + }, + ); + } + + let mut event = TaskExecAdded::new(); + event.set_container_id(req.id.clone()); + event.set_exec_id(req.exec_id.clone()); + self.publish(TASK_EXEC_ADDED_EVENT_TOPIC, Box::new(event)) + .await; + Ok(Empty::new()) + } + + async fn kill(&self, _ctx: &TtrpcContext, req: KillRequest) -> ttrpc::Result { + let signal = req.signal as i32; + if !(1..=64).contains(&signal) { + return Err(rpc_error( + Code::INVALID_ARGUMENT, + format!("invalid signal {}", req.signal), + )); + } + let (pid, vm_sock) = { + let tasks = self.inner.tasks.lock().expect("tasks mutex"); + let live = tasks.get(&req.id).ok_or_else(|| not_found(&req.id))?; + if req.exec_id.is_empty() { + (live.entry.pid, None) + } else { + let exec = live + .entry + .execs + .get(&req.exec_id) + .ok_or_else(|| not_found(&req.exec_id))?; + ( + exec.pid, + live.execs.get(&req.exec_id).and_then(|exec| exec.vm_sock), + ) + } + }; + if let Some(sock_fd) = vm_sock { + // VM exec kill: dropping the agent connection makes the guest + // agent SIGKILL the process (guest pids must never be signaled + // on the host). + unsafe { libc::shutdown(sock_fd, libc::SHUT_RDWR) }; + return Ok(Empty::new()); + } + let Some(pid) = pid else { + return Ok(Empty::new()); + }; + // `all` targets the process group; each process detached into one. + let target = if req.all { -(pid as i32) } else { pid as i32 }; + if unsafe { libc::kill(target, signal) } != 0 { + let error = std::io::Error::last_os_error(); + // ESRCH after exit is benign: the process already went away. + if error.raw_os_error() != Some(libc::ESRCH) { + return Err(rpc_error( + Code::INTERNAL, + format!("kill {target} with {signal}: {error}"), + )); + } + } + Ok(Empty::new()) + } + + async fn delete( + &self, + _ctx: &TtrpcContext, + req: DeleteRequest, + ) -> ttrpc::Result { + if !req.exec_id.is_empty() { + return self.delete_exec(req.id, req.exec_id).await; + } + let live = { + let mut tasks = self.inner.tasks.lock().expect("tasks mutex"); + let Some(live) = tasks.get_mut(&req.id) else { + return Err(not_found(&req.id)); + }; + if !live.entry.can_delete() { + return Err(rpc_error( + Code::FAILED_PRECONDITION, + format!("task {} is not stopped", req.id), + )); + } + tasks.remove(&req.id).expect("entry checked above") + }; + + let mut response = DeleteResponse::new(); + response.set_pid(live.entry.pid.unwrap_or(0)); + if let Some(exit) = live.entry.exit.as_ref() { + response.set_exit_status(exit.status); + response.set_exited_at(timestamp_from(exit.exited_at)); + } + + // Release a created-never-started init (EOF makes it exit) and drop + // the serialized plan from the bundle. + if let Some(mut internal) = live.internal { + internal.close_start(); + } + let _ = std::fs::remove_file(live.entry.bundle.join("pvisor-plan.json")); + + let mut event = TaskDelete::new(); + event.set_container_id(req.id.clone()); + event.set_pid(response.pid); + event.set_exited_at(response.exited_at.clone().into_option().unwrap_or_default()); + self.publish(TASK_DELETE_EVENT_TOPIC, Box::new(event)).await; + Ok(response) + } + + async fn pids(&self, _ctx: &TtrpcContext, req: PidsRequest) -> ttrpc::Result { + let tasks = self.inner.tasks.lock().expect("tasks mutex"); + let live = tasks.get(&req.id).ok_or_else(|| not_found(&req.id))?; + let mut response = PidsResponse::new(); + if let Some(pid) = live.entry.pid { + let mut process = ProcessInfo::new(); + process.set_pid(pid); + response.processes.push(process); + } + for exec in live.entry.execs.values() { + if let Some(pid) = exec.pid { + let mut process = ProcessInfo::new(); + process.set_pid(pid); + response.processes.push(process); + } + } + Ok(response) + } + + async fn connect( + &self, + _ctx: &TtrpcContext, + req: ConnectRequest, + ) -> ttrpc::Result { + let tasks = self.inner.tasks.lock().expect("tasks mutex"); + let live = tasks.get(&req.id).ok_or_else(|| not_found(&req.id))?; + let mut response = ConnectResponse::new(); + response.set_shim_pid(process::id()); + response.set_task_pid(live.entry.pid.unwrap_or(0)); + Ok(response) + } + + async fn close_io(&self, _ctx: &TtrpcContext, req: CloseIORequest) -> ttrpc::Result { + if !req.stdin { + return Ok(Empty::new()); + } + let mut tasks = self.inner.tasks.lock().expect("tasks mutex"); + let live = tasks.get_mut(&req.id).ok_or_else(|| not_found(&req.id))?; + if req.exec_id.is_empty() { + live.io.close_stdin(); + } else if let Some(exec) = live.execs.get_mut(&req.exec_id) { + exec.io.close_stdin(); + } else { + return Err(not_found(&req.exec_id)); + } + Ok(Empty::new()) + } + + async fn resize_pty(&self, _ctx: &TtrpcContext, req: ResizePtyRequest) -> ttrpc::Result { + let tasks = self.inner.tasks.lock().expect("tasks mutex"); + let live = tasks.get(&req.id).ok_or_else(|| not_found(&req.id))?; + let io = if req.exec_id.is_empty() { + &live.io + } else { + &live + .execs + .get(&req.exec_id) + .ok_or_else(|| not_found(&req.exec_id))? + .io + }; + io.resize(req.width, req.height) + .map_err(|error| rpc_error(Code::FAILED_PRECONDITION, format!("{error:#}")))?; + Ok(Empty::new()) + } + + async fn shutdown(&self, _ctx: &TtrpcContext, _req: ShutdownRequest) -> ttrpc::Result { + let task_count = self.inner.tasks.lock().expect("tasks mutex").len(); + if task_count == 0 { + info!("shim shutdown requested with no live tasks"); + // Signal asynchronously so this (and any concurrent Delete) + // response flushes before the server tears connections down. + let exit = self.inner.exit.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + exit.signal(); + }); + } else { + info!("shim shutdown deferred: {task_count} task(s) still tracked"); + } + Ok(Empty::new()) + } +} + +/// The pod sandbox service (`containerd.runtime.sandbox.v1.Sandbox`), +/// served on the same ttrpc socket as the task service. +pub struct PvisorSandbox { + inner: Arc, +} + +#[async_trait] +impl Sandbox for PvisorSandbox { + async fn create_sandbox( + &self, + _ctx: &TtrpcContext, + req: CreateSandboxRequest, + ) -> ttrpc::Result { + if req.sandbox_id.is_empty() { + return Err(rpc_error(Code::INVALID_ARGUMENT, "sandbox id required")); + } + if req.bundle_path.is_empty() { + return Err(rpc_error( + Code::INVALID_ARGUMENT, + "sandbox bundle path required", + )); + } + if self.inner.sandbox.lock().expect("sandbox mutex").is_some() { + return Err(rpc_error( + Code::ALREADY_EXISTS, + format!("sandbox {} already exists", req.sandbox_id), + )); + } + + // The sandboxer contract carries no OCI spec; the bundle is only a + // scratch directory for the holder plan. + let bundle = PvisorTask::resolve_bundle(&req.bundle_path) + .map_err(|error| rpc_error(Code::INVALID_ARGUMENT, format!("{error:#}")))?; + + let plan = build_sandbox_plan( + &req.sandbox_id, + non_empty(&req.netns_path).as_deref(), + &req.annotations, + ); + let plan_path = bundle.join("pvisor-sandbox.json"); + let plan_bytes = serde_json::to_vec(&plan).map_err(|error| { + rpc_error(Code::INTERNAL, format!("serialize sandbox plan: {error}")) + })?; + + let internal = tokio::task::spawn_blocking({ + let plan_path = plan_path.clone(); + move || { + child::spawn_internal_opts( + child::INTERNAL_SANDBOX_ARG, + &plan_path, + &plan_bytes, + None, + None, + false, + ) + } + }) + .await + .map_err(|error| rpc_error(Code::INTERNAL, format!("sandbox join: {error}")))? + .map_err(|error| rpc_error(Code::INTERNAL, format!("{error:#}")))?; + + info!( + "sandbox {} holder ready (pid {:?})", + req.sandbox_id, internal.pid + ); + let (exit_tx, _) = watch::channel(None); + *self.inner.sandbox.lock().expect("sandbox mutex") = Some(SandboxState { + id: req.sandbox_id.clone(), + internal, + exit_tx, + }); + Ok(CreateSandboxResponse::new()) + } + + async fn start_sandbox( + &self, + _ctx: &TtrpcContext, + req: StartSandboxRequest, + ) -> ttrpc::Result { + let mut sandbox = self.inner.sandbox.lock().expect("sandbox mutex"); + let Some(state) = sandbox.as_mut() else { + return Err(rpc_error(Code::NOT_FOUND, "no sandbox on this shim")); + }; + if state.id != req.sandbox_id { + return Err(rpc_error( + Code::INVALID_ARGUMENT, + format!("sandbox id mismatch: {} != {}", state.id, req.sandbox_id), + )); + } + let mut internal = std::mem::replace(&mut state.internal, child::InternalChild::exited()); + internal + .start() + .map_err(|error| rpc_error(Code::INTERNAL, format!("{error:#}")))?; + state.internal = internal; + let mut response = StartSandboxResponse::new(); + response.set_pid(state.internal.pid.unwrap_or(0)); + Ok(response) + } + + async fn wait_sandbox( + &self, + _ctx: &TtrpcContext, + req: WaitSandboxRequest, + ) -> ttrpc::Result { + let mut receiver = { + let sandbox = self.inner.sandbox.lock().expect("sandbox mutex"); + let Some(state) = sandbox.as_ref() else { + return Err(rpc_error(Code::NOT_FOUND, "no sandbox on this shim")); + }; + if state.id != req.sandbox_id { + return Err(rpc_error(Code::INVALID_ARGUMENT, "sandbox id mismatch")); + } + state.exit_tx.subscribe() + }; + loop { + if let Some(exit) = receiver.borrow().as_ref() { + let mut response = WaitSandboxResponse::new(); + response.set_exit_status(exit.status); + response.set_exited_at(timestamp_from(exit.exited_at)); + return Ok(response); + } + if receiver.changed().await.is_err() { + return Err(rpc_error(Code::NOT_FOUND, "sandbox gone while waiting")); + } + } + } + + async fn stop_sandbox( + &self, + _ctx: &TtrpcContext, + req: StopSandboxRequest, + ) -> ttrpc::Result { + let pid = { + let sandbox = self.inner.sandbox.lock().expect("sandbox mutex"); + sandbox + .as_ref() + .filter(|state| state.id == req.sandbox_id) + .and_then(|state| state.internal.pid) + }; + if let Some(pid) = pid + && unsafe { libc::kill(pid as i32, libc::SIGTERM) } != 0 + { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::ESRCH) { + return Err(rpc_error( + Code::INTERNAL, + format!("stop sandbox holder {pid}: {error}"), + )); + } + } + Ok(StopSandboxResponse::new()) + } + + async fn shutdown_sandbox( + &self, + _ctx: &TtrpcContext, + req: ShutdownSandboxRequest, + ) -> ttrpc::Result { + let state = { + let mut sandbox = self.inner.sandbox.lock().expect("sandbox mutex"); + sandbox.take().filter(|state| state.id == req.sandbox_id) + }; + let Some(mut state) = state else { + return Err(rpc_error(Code::NOT_FOUND, "no sandbox on this shim")); + }; + if let Some(pid) = state.internal.pid + && unsafe { libc::kill(pid as i32, libc::SIGKILL) } != 0 + { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::ESRCH) { + warn!("kill sandbox holder {pid}: {error}"); + } + } + state.internal.close_start(); + let task_count = self.inner.tasks.lock().expect("tasks mutex").len(); + if task_count == 0 { + info!("sandbox {} shut down with no live tasks", req.sandbox_id); + self.inner.exit.signal(); + } else { + info!( + "sandbox {} holder down; {task_count} task(s) remain until TaskService.Shutdown", + req.sandbox_id + ); + } + Ok(ShutdownSandboxResponse::new()) + } + + async fn platform( + &self, + _ctx: &TtrpcContext, + _req: PlatformRequest, + ) -> ttrpc::Result { + let mut platform = containerd_shim_protos::types::platform::Platform::new(); + platform.set_os(std::env::consts::OS.to_string()); + platform.set_architecture(arch_to_containerd(std::env::consts::ARCH)); + let mut response = PlatformResponse::new(); + response.set_platform(platform); + Ok(response) + } + + async fn ping_sandbox( + &self, + _ctx: &TtrpcContext, + _req: PingRequest, + ) -> ttrpc::Result { + Ok(PingResponse::new()) + } + + async fn sandbox_status( + &self, + _ctx: &TtrpcContext, + req: SandboxStatusRequest, + ) -> ttrpc::Result { + let sandbox = self.inner.sandbox.lock().expect("sandbox mutex"); + let Some(state) = sandbox.as_ref() else { + return Err(rpc_error(Code::NOT_FOUND, "no sandbox on this shim")); + }; + let _ = req; + let mut response = SandboxStatusResponse::new(); + response.set_sandbox_id(state.id.clone()); + response.set_pid(state.internal.pid.unwrap_or(0)); + Ok(response) + } +} + +fn io_error(err: std::io::Error) -> containerd_shim::Error { + containerd_shim::Error::IoError { + context: "shim bootstrap io".to_string(), + err, + } +} + +/// Normalize a socket address to the filesystem path to bind. Prefixed +/// forms (`unix://`, abstract ``) degrade to their path component. +fn sock_path(address: &str) -> String { + let trimmed = address + .strip_prefix("unix://") + .or_else(|| address.strip_prefix("unix:")) + .unwrap_or(address); + trimmed.trim_start_matches('\0').to_string() +} + +fn arch_to_containerd(arch: &str) -> String { + match arch { + "x86_64" => "amd64".to_string(), + "aarch64" => "arm64".to_string(), + other => other.to_string(), + } +} + +/// Shim bootstrap: containerd's `start`/`delete` CLI actions plus task +/// service construction. +pub struct PvisorShim { + id: String, + namespace: String, + exit: Arc, +} + +#[async_trait] +impl Shim for PvisorShim { + type T = PvisorTask; + + async fn new(runtime_id: &str, args: &Flags, _config: &mut Config) -> Self { + info!( + "creating {} shim instance (container {})", + runtime_id, args.id + ); + PvisorShim { + id: args.id.clone(), + namespace: args.namespace.clone(), + exit: Arc::new(ExitSignal::default()), + } + } + + async fn start_shim(&mut self, opts: StartOpts) -> containerd_shim::Result { + let ttrpc_address = opts.ttrpc_address.clone(); + let vars = vec![("TTRPC_ADDRESS", ttrpc_address.as_str())]; + containerd_shim::asynchronous::spawn(opts, &self.id, vars).await + } + + async fn delete_shim(&mut self) -> containerd_shim::Result { + info!("shim cleanup for {}", self.id); + Ok(DeleteResponse::new()) + } + + async fn wait(&mut self) { + self.exit.wait().await; + } + + async fn create_task_service( + &self, + publisher: containerd_shim::asynchronous::publisher::RemotePublisher, + ) -> Self::T { + // The bootstrap owns watcher setup (it registers two services on + // one inner); the trait method stays for API compatibility. + let inner = self.build_inner(publisher); + PvisorTask { inner } + } +} + +impl PvisorShim { + fn build_inner( + &self, + publisher: containerd_shim::asynchronous::publisher::RemotePublisher, + ) -> Arc { + Arc::new(TaskInner { + namespace: self.namespace.clone(), + publisher, + tasks: Mutex::new(HashMap::new()), + sandbox: Mutex::new(None), + exit: self.exit.clone(), + }) + } +} + +/// Binary entry: run the shim bootstrap inside a tokio runtime. +pub fn shim_main() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime"); + runtime.block_on(async move { + if let Err(error) = bootstrap().await { + eprintln!("{}: {error:?}", crate::RUNTIME_TYPE); + std::process::exit(1); + } + }); +} + +/// The shim bootstrap, modeled on `containerd_shim::run` (Apache-2.0, +/// containerd authors) but registering both the task service and the +/// sandbox service on the same ttrpc socket — required for +/// `sandboxer = "shim"`. +async fn bootstrap() -> containerd_shim::Result<()> { + use containerd_shim::StartOpts; + use containerd_shim_protos::ttrpc::r#async::Server; + use containerd_shim_protos::ttrpc::r#async::transport::Listener; + use tokio::io::AsyncWriteExt; + + let os_args: Vec<_> = std::env::args_os().collect(); + let flags = containerd_shim::parse(&os_args[1..])?; + + // `-info`: containerd introspects the runtime; answer with a minimal + // RuntimeInfo and exit. + if flags.info { + use containerd_shim_protos::protobuf::Message; + let mut info = containerd_shim_protos::types::introspection::RuntimeInfo::new(); + info.set_name(crate::RUNTIME_TYPE.to_string()); + info.mut_version().version = env!("CARGO_PKG_VERSION").to_string(); + let bytes = info + .write_to_bytes() + .map_err(containerd_shim::Error::Protobuf)?; + std::io::Write::write_all(&mut std::io::stdout(), &bytes).map_err(io_error)?; + return Ok(()); + } + let ttrpc_address = std::env::var("TTRPC_ADDRESS")?; + + // The framework's reaper relies on the shim being a child subreaper and + // on SIGCHLD draining into the exit monitor. + if unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) } != 0 { + return Err(containerd_shim::Error::IoError { + context: "set child subreaper".to_string(), + err: std::io::Error::last_os_error(), + }); + } + + let mut shim = PvisorShim::new(crate::RUNTIME_TYPE, &flags, &mut Config::default()).await; + + match flags.action.as_str() { + "start" => { + let opts = StartOpts { + id: flags.id, + publish_binary: flags.publish_binary, + address: flags.address, + ttrpc_address, + namespace: flags.namespace, + debug: flags.debug, + }; + // containerd >= 2.3 sends BootstrapParams on stdin; the + // framework's spawn() would try to parse it as legacy runc + // options and fail. Drain it — pVisor takes no runc options. + tokio::task::spawn_blocking(|| { + let mut stdin = std::io::stdin(); + let mut sink = Vec::new(); + let _ = std::io::Read::read_to_end(&mut stdin, &mut sink); + }) + .await + .map_err(|error| { + containerd_shim::Error::InvalidArgument(format!("drain stdin: {error}")) + })?; + let address = shim.start_shim(opts).await?; + let mut stdout = tokio::io::stdout(); + stdout + .write_all(address.as_bytes()) + .await + .map_err(io_error)?; + stdout.flush().await.map_err(io_error)?; + Ok(()) + } + "delete" => { + let response = shim.delete_shim().await?; + let bytes = response + .write_to_bytes() + .map_err(containerd_shim::Error::Protobuf)?; + tokio::io::stdout() + .write_all(&bytes) + .await + .map_err(io_error)?; + Ok(()) + } + _ => { + if flags.socket.is_empty() { + return Err(containerd_shim::Error::InvalidArgument( + "shim socket cannot be empty".to_string(), + )); + } + containerd_shim::logger::init(flags.debug, "info", &flags.namespace, &flags.id)?; + + let publisher = + containerd_shim::asynchronous::publisher::RemotePublisher::new(&ttrpc_address) + .await?; + let inner = shim.build_inner(publisher); + match monitor_subscribe(Topic::Pid).await { + Ok(subscription) => { + let watcher_inner = inner.clone(); + tokio::spawn(async move { + run_exit_watcher(watcher_inner, subscription).await; + }); + } + Err(error) => warn!("exit monitor unavailable: {error}"), + } + + let task_service = PvisorTask { + inner: inner.clone(), + }; + let sandbox_service = PvisorSandbox { inner }; + let task_methods = + containerd_shim_protos::shim_async::create_task(std::sync::Arc::new(task_service)); + let sandbox_methods = containerd_shim_protos::sandbox_async::create_sandbox( + std::sync::Arc::new(sandbox_service), + ); + + let path = sock_path(&flags.socket); + if let Some(parent) = std::path::Path::new(&path).parent() { + std::fs::create_dir_all(parent).map_err(io_error)?; + } + let listener = std::os::unix::net::UnixListener::bind(&path).map_err(io_error)?; + let listener = + Listener::try_from(listener).map_err(|e| containerd_shim::Error::IoError { + context: format!("creating ttrpc listener {path}"), + err: e, + })?; + let mut server = Server::new().add_listener(listener); + server = server.register_service(task_methods); + server = server.register_service(sandbox_methods); + server + .start() + .await + .map_err(containerd_shim::Error::Ttrpc)?; + // containerd occasionally reads an empty stdout without flush. + unsafe { + libc::dup2(libc::STDERR_FILENO, libc::STDOUT_FILENO); + } + std::fs::write("address", &flags.socket).map_err(io_error)?; + + info!("shim serving task + sandbox services on {}", flags.socket); + tokio::spawn(async move { + reap_children_loop().await; + }); + shim.wait().await; + info!("shutting down shim instance"); + server.shutdown().await.unwrap_or_default(); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file("address"); + Ok(()) + } + } +} + +/// Drain exited children into the framework exit monitor (the SIGCHLD +/// counterpart of `containerd_shim`'s signal handler). +async fn reap_children_loop() { + use tokio::signal::unix::{SignalKind, signal}; + let mut sigchld = signal(SignalKind::from_raw(libc::SIGCHLD)).expect("install SIGCHLD handler"); + loop { + if sigchld.recv().await.is_none() { + return; + } + loop { + let mut status: libc::c_int = 0; + let pid = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) }; + if pid > 0 { + let code = if libc::WIFEXITED(status) { + libc::WEXITSTATUS(status) + } else if libc::WIFSIGNALED(status) { + 128 + libc::WTERMSIG(status) + } else { + continue; + }; + if let Err(error) = + containerd_shim::asynchronous::monitor::monitor_notify_by_pid(pid, code).await + { + warn!("failed to forward exit of {pid}: {error}"); + } + } else { + break; + } + } + } +} diff --git a/crates/persisting-shim/src/spec.rs b/crates/persisting-shim/src/spec.rs new file mode 100644 index 00000000..c369a185 --- /dev/null +++ b/crates/persisting-shim/src/spec.rs @@ -0,0 +1,60 @@ +//! OCI bundle loading helpers. +//! +//! The shim consumes `config.json` from the bundle directory containerd +//! prepares. `Spec::load` keeps the JSON canonical, so parsing errors carry +//! the file path context here once instead of at every call site. + +use std::path::Path; + +use anyhow::{Context, Result}; +use oci_spec::runtime::Spec; + +/// Annotation prefix reserved for pVisor policy steering inside bundles. +pub const ANNOTATION_PREFIX: &str = "io.pvisor."; + +/// Load and lightly validate the OCI runtime spec of a bundle directory. +pub fn load_bundle_spec(bundle: &Path) -> Result { + let config = bundle.join("config.json"); + let spec = Spec::load(&config) + .with_context(|| format!("failed to load OCI config {}", config.display()))?; + Ok(spec) +} + +/// Read one `io.pvisor.*` annotation (key given without the prefix). +pub fn pvisor_annotation<'a>(spec: &'a Spec, key: &str) -> Option<&'a str> { + let annotations = spec.annotations().as_ref()?; + annotations + .get(&format!("{ANNOTATION_PREFIX}{key}")) + .map(String::as_str) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec_from_json(json: &str) -> Spec { + serde_json::from_str(json).expect("parse spec") + } + + #[test] + fn annotation_lookup_requires_the_pvisor_prefix() { + let spec = spec_from_json( + r#"{"ociVersion":"1.0.2","annotations":{"io.pvisor.executor":"vm","other":"x"}}"#, + ); + assert_eq!(pvisor_annotation(&spec, "executor"), Some("vm")); + assert_eq!(pvisor_annotation(&spec, "missing"), None); + } + + #[test] + fn missing_annotations_table_yields_none() { + let spec = spec_from_json(r#"{"ociVersion":"1.0.2"}"#); + assert!(pvisor_annotation(&spec, "executor").is_none()); + } + + #[test] + fn load_reports_the_config_path_on_error() { + let error = load_bundle_spec(Path::new("/nonexistent-bundle")) + .expect_err("missing bundle must fail"); + assert!(error.to_string().contains("config.json")); + } +} diff --git a/crates/persisting-shim/src/state.rs b/crates/persisting-shim/src/state.rs new file mode 100644 index 00000000..f932af7c --- /dev/null +++ b/crates/persisting-shim/src/state.rs @@ -0,0 +1,301 @@ +//! Task lifecycle bookkeeping shared by the task service and the exit +//! watcher. Pure data manipulation so transitions stay unit-testable. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::SystemTime; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TaskStatus { + Created, + Running, + Stopped, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ExitInfo { + pub status: u32, + pub exited_at: SystemTime, +} + +/// Which process inside a task an exit belongs to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExitTarget { + Init, + Exec(String), +} + +/// One `exec`-added process of a task. +#[derive(Clone, Debug)] +pub struct ExecEntry { + pub exec_id: String, + pub stdin: Option, + pub stdout: Option, + pub stderr: Option, + pub terminal: bool, + pub pid: Option, + pub status: TaskStatus, + pub exit: Option, +} + +impl ExecEntry { + pub fn new( + exec_id: &str, + stdin: Option, + stdout: Option, + stderr: Option, + terminal: bool, + pid: Option, + ) -> Self { + ExecEntry { + exec_id: exec_id.to_string(), + stdin, + stdout, + stderr, + terminal, + pid, + status: TaskStatus::Created, + exit: None, + } + } + + /// Transition Created -> Running. + pub fn mark_started(&mut self, pid: u32) -> bool { + if self.status != TaskStatus::Created { + return false; + } + self.status = TaskStatus::Running; + self.pid = Some(pid); + true + } + + /// Record the process exit; repeated notifications are no-ops. + pub fn mark_exited(&mut self, status: u32, exited_at: SystemTime) -> bool { + if self.status == TaskStatus::Stopped { + return false; + } + self.status = TaskStatus::Stopped; + self.exit = Some(ExitInfo { status, exited_at }); + true + } +} + +/// One tracked task (container init process plus its exec processes). +#[derive(Clone, Debug)] +pub struct TaskEntry { + pub id: String, + pub bundle: PathBuf, + pub stdin: Option, + pub stdout: Option, + pub stderr: Option, + pub terminal: bool, + pub pid: Option, + pub status: TaskStatus, + pub exit: Option, + pub execs: HashMap, +} + +impl TaskEntry { + #[allow(clippy::too_many_arguments)] + pub fn new( + id: &str, + bundle: PathBuf, + stdin: Option, + stdout: Option, + stderr: Option, + terminal: bool, + pid: Option, + ) -> Self { + TaskEntry { + id: id.to_string(), + bundle, + stdin, + stdout, + stderr, + terminal, + pid, + status: TaskStatus::Created, + exit: None, + execs: HashMap::new(), + } + } + + /// Transition Created -> Running. Returns whether the transition applied. + pub fn mark_started(&mut self, pid: u32) -> bool { + if self.status != TaskStatus::Created { + return false; + } + self.status = TaskStatus::Running; + self.pid = Some(pid); + true + } + + /// Record the init process exit. Transitions from Created (killed + /// before start) and Running are both legal; repeats are no-ops. + pub fn mark_exited(&mut self, status: u32, exited_at: SystemTime) -> bool { + if self.status == TaskStatus::Stopped { + return false; + } + self.status = TaskStatus::Stopped; + self.exit = Some(ExitInfo { status, exited_at }); + true + } + + /// True once the task may be deleted per the task v2 protocol. + pub fn can_delete(&self) -> bool { + self.status == TaskStatus::Stopped + } + + /// Register an exec process; false when the exec id already exists. + pub fn add_exec(&mut self, exec: ExecEntry) -> bool { + if self.execs.contains_key(&exec.exec_id) { + return false; + } + self.execs.insert(exec.exec_id.clone(), exec); + true + } + + /// Record an exec exit by id (VM execs report guest pids that must not + /// be matched against host pids). + pub fn record_exec_exit(&mut self, exec_id: &str, status: u32, exited_at: SystemTime) -> bool { + self.execs + .get_mut(exec_id) + .is_some_and(|exec| exec.mark_exited(status, exited_at)) + } + + /// Record an exit for whichever process (init or exec) owns `pid`. + /// Returns what got transitioned, if anything. + pub fn record_exit_by_pid( + &mut self, + pid: u32, + status: u32, + exited_at: SystemTime, + ) -> Option { + if self.pid == Some(pid) && self.mark_exited(status, exited_at) { + return Some(ExitTarget::Init); + } + for (exec_id, exec) in self.execs.iter_mut() { + if exec.pid == Some(pid) && exec.mark_exited(status, exited_at) { + return Some(ExitTarget::Exec(exec_id.clone())); + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry() -> TaskEntry { + TaskEntry::new( + "c1", + PathBuf::from("/bundle"), + None, + None, + None, + false, + None, + ) + } + + #[test] + fn created_entry_waits_for_start() { + let mut task = entry(); + assert_eq!(task.status, TaskStatus::Created); + assert!(!task.can_delete()); + assert!(task.mark_started(42)); + assert_eq!(task.status, TaskStatus::Running); + assert_eq!(task.pid, Some(42)); + } + + #[test] + fn start_is_idempotent_and_one_way() { + let mut task = entry(); + assert!(task.mark_started(42)); + assert!(!task.mark_started(43)); + assert_eq!(task.pid, Some(42)); + } + + #[test] + fn exit_from_running_and_created_are_legal() { + let mut running = entry(); + running.mark_started(7); + let at = SystemTime::now(); + assert!(running.mark_exited(3, at)); + assert_eq!(running.exit.map(|e| e.status), Some(3)); + assert!(!running.mark_exited(9, at), "second exit is a no-op"); + + let mut created = entry(); + assert!(created.mark_exited(137, at)); + assert!(created.can_delete()); + } + + #[test] + fn delete_requires_stopped() { + let mut task = entry(); + assert!(!task.can_delete()); + task.mark_started(7); + assert!(!task.can_delete()); + task.mark_exited(0, SystemTime::now()); + assert!(task.can_delete()); + } + + fn task_with_execs() -> TaskEntry { + let mut task = entry(); + task.mark_started(10); + assert!(task.add_exec(ExecEntry::new("e1", None, None, None, false, Some(11),))); + task + } + + #[test] + fn exec_ids_cannot_collide() { + let mut task = task_with_execs(); + assert!(!task.add_exec(ExecEntry::new("e1", None, None, None, false, Some(12),))); + assert_eq!(task.execs.len(), 1); + assert!(task.add_exec(ExecEntry::new("e2", None, None, None, false, Some(13),))); + } + + #[test] + fn exec_lifecycle_transitions() { + let mut task = task_with_execs(); + let exec = task.execs.get_mut("e1").expect("exec exists"); + assert!(exec.mark_started(11)); + assert!(!exec.mark_started(99)); + let at = SystemTime::now(); + assert!(exec.mark_exited(4, at)); + assert!(!exec.mark_exited(4, at)); + assert_eq!(exec.exit.map(|e| e.status), Some(4)); + } + + #[test] + fn exits_are_attributed_by_pid() { + let mut task = task_with_execs(); + task.execs.get_mut("e1").expect("exec").mark_started(11); + let at = SystemTime::now(); + + assert_eq!( + task.record_exit_by_pid(11, 5, at), + Some(ExitTarget::Exec("e1".to_string())) + ); + // Repeated notification for the same pid is a no-op. + assert_eq!(task.record_exit_by_pid(11, 5, at), None); + assert_eq!(task.record_exit_by_pid(10, 0, at), Some(ExitTarget::Init)); + // Unknown pids are ignored. + assert_eq!(task.record_exit_by_pid(404, 0, at), None); + } + + #[test] + fn init_pid_wins_when_pids_collide() { + // Pids are unique per process, so this cannot happen in practice; + // the mapping still stays deterministic. + let mut task = task_with_execs(); + let init_pid = task.pid.unwrap_or(0); + task.execs.get_mut("e1").expect("exec").pid = Some(init_pid); + let at = SystemTime::now(); + assert_eq!( + task.record_exit_by_pid(init_pid, 2, at), + Some(ExitTarget::Init) + ); + } +} diff --git a/crates/persisting-shim/src/vm.rs b/crates/persisting-shim/src/vm.rs new file mode 100644 index 00000000..deecca9c --- /dev/null +++ b/crates/persisting-shim/src/vm.rs @@ -0,0 +1,129 @@ +//! The libkrun microVM executor (feature `vm`, Linux only). +//! +//! Boots one VM per task: the container rootfs (materialized from the +//! snapshotter mounts by the internal VM runner) is shared read-write over +//! virtio-fs as `/dev/root`, the workload stdio rides the virtio-console +//! (wired to the task FIFOs/PTY by the runner), and the workload itself is +//! started through a generated helper script — `krun_set_exec` serializes +//! argv through the kernel command line without escaping, so only the +//! quote-free helper path crosses that boundary (the same pattern pVisor's +//! VM executor uses). +//! +//! The guest kernel comes from libkrunfw on the host (same deployment +//! requirement as `/dev/kvm`). `krun_start_enter` blocks until the guest +//! init exits and returns its exit code. + +use crate::agent::{AGENT_GUEST_PATH, AGENT_VSOCK_PORT}; +use crate::plan::{ + ContainerPlan, guest_init_script_path, render_guest_init_script_with, vm_agent_enabled, +}; +use anyhow::{Context, Result}; +use std::ffi::CString; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +/// Write the guest init helper into the container rootfs and boot the VM. +/// Returns the guest exit code. +pub fn boot_vm(plan: &ContainerPlan) -> Result { + let config = plan.vm_config(); + + let script_guest = guest_init_script_path(&plan.id); + let host_script = plan.rootfs.join(script_guest.trim_start_matches('/')); + let agent = vm_agent_enabled(&plan.annotations); + write_executable( + &host_script, + &render_guest_init_script_with(&plan.process, agent), + ) + .with_context(|| format!("write guest init helper {}", host_script.display()))?; + if agent { + // The static musl shim binary doubles as the guest agent: copy it + // into the rootfs so exec works without image requirements. + let agent_host = plan.rootfs.join(AGENT_GUEST_PATH.trim_start_matches('/')); + std::fs::copy(std::env::current_exe().context("current exe")?, &agent_host) + .with_context(|| format!("copy agent to {}", agent_host.display()))?; + make_executable(&agent_host)?; + } + + let ctx = krun::krun_create_ctx(); + if ctx < 0 { + anyhow::bail!("krun_create_ctx failed: {ctx}"); + } + let ctx = ctx as u32; + + krun_check( + krun::krun_set_vm_config(ctx, config.cpus, config.ram_mib), + "krun_set_vm_config", + )?; + + let tag = cstring("/dev/root")?; + let root = path_cstring(&plan.rootfs)?; + krun_check( + unsafe { krun::krun_add_virtiofs(ctx, tag.as_ptr(), root.as_ptr()) }, + "krun_add_virtiofs", + )?; + + // Contexts start with an implicit vsock whose heuristics could let guest + // sockets escape through the host stack; replace it with an explicit + // zero-feature device (mirrors pVisor's VM executor). + krun_check( + krun::krun_disable_implicit_vsock(ctx), + "krun_disable_implicit_vsock", + )?; + krun_check(krun::krun_add_vsock(ctx, 0), "krun_add_vsock")?; + // The agent vsock port: libkrun listens on a unix socket in the bundle + // and proxies host connections into the guest listener. + let agent_socket = path_cstring(&plan.bundle.join("pvisor-agent.sock"))?; + krun_check( + unsafe { krun::krun_add_vsock_port2(ctx, AGENT_VSOCK_PORT, agent_socket.as_ptr(), true) }, + "krun_add_vsock_port2", + )?; + + let workdir = cstring("/")?; + krun_check( + unsafe { krun::krun_set_workdir(ctx, workdir.as_ptr()) }, + "krun_set_workdir", + )?; + + // Empty argv/envp: everything argument-shaped lives in the helper. + let program = cstring(&script_guest)?; + let argv = [std::ptr::null::()]; + let envp = [std::ptr::null::()]; + krun_check( + unsafe { krun::krun_set_exec(ctx, program.as_ptr(), argv.as_ptr(), envp.as_ptr()) }, + "krun_set_exec", + )?; + + let code = krun::krun_start_enter(ctx); + if code < 0 { + anyhow::bail!("krun_start_enter failed with errno {}", -code); + } + Ok(code) +} + +fn write_executable(path: &Path, content: &str) -> Result<()> { + std::fs::write(path, content)?; + make_executable(path) +} + +fn make_executable(path: &Path) -> Result<()> { + let mut permissions = std::fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions)?; + Ok(()) +} + +fn cstring(value: &str) -> Result { + CString::new(value).with_context(|| format!("{value:?} contains NUL")) +} + +fn path_cstring(path: &Path) -> Result { + CString::new(path.as_os_str().as_encoded_bytes()) + .with_context(|| format!("path {:?} contains NUL", path.display())) +} + +fn krun_check(result: i32, what: &str) -> Result<()> { + if result < 0 { + anyhow::bail!("{what} failed with errno {}", -result); + } + Ok(()) +} diff --git a/justfile b/justfile index 3882d268..37cf6de5 100644 --- a/justfile +++ b/justfile @@ -83,7 +83,7 @@ test *packages: just test-rust "$@" if [[ $# -eq 0 ]]; then just test-py; fi -# Debug nextest; accepts Cargo names and pvisor/control/agentctl/capture aliases. +# Debug nextest; accepts Cargo names and pvisor/control/agentctl/capture/shim aliases. test-rust *packages: #!/usr/bin/env bash set -euo pipefail @@ -93,12 +93,23 @@ test-rust *packages: pvisor) package=persisting-pvisor ;; control|agentctl) package=persisting-control ;; capture) package=persisting-gateway ;; + shim) package=persisting-shim ;; esac args+=(-p "$package") done if [[ $# -eq 0 ]]; then args+=(--workspace); fi cargo nextest run --locked "${args[@]}" +# Cross-check the containerd shim for Linux; full builds need a Linux host. +shim-check: + cargo check --locked -p persisting-shim --target x86_64-unknown-linux-gnu + cargo clippy --locked -p persisting-shim --all-targets --target x86_64-unknown-linux-gnu -- -D warnings + +# Build the static musl shim with the libkrun VM executor (needs zigbuild). +shim-vm-build: + cargo zigbuild --locked --target x86_64-unknown-linux-musl --target-dir target \ + -p persisting-shim --features vm --bin containerd-shim-pvisor-v2 + # Python tests; append pytest options such as -v or -k packaging. test-py *args: uv run --extra dev pytest tests/ -q "$@"