commit 9dc492f90fb62875e7702c9c1cfa714b2007fc07 Author: Till Wegmueller Date: Sat Feb 14 18:25:17 2026 +0100 Add vm-manager library and vmctl CLI Unified VM management consolidating QEMU-KVM (Linux) and Propolis/bhyve (illumos) backends behind an async Hypervisor trait, with a vmctl CLI for direct use and a library API for orchestrators. - Core library: types, async Hypervisor trait, miette diagnostic errors - QEMU backend: direct process management, raw QMP client, QCOW2 overlays - Propolis backend: zone-based VMM with REST API control - Shared infra: cloud-init NoCloud ISO generation, image download/cache, SSH helpers with retry - vmctl CLI: create, start, stop, destroy, list, status, console, ssh, suspend, resume, image pull/list/inspect - nebula-vm zone brand: lifecycle scripts and platform/config XML for illumos zone integration Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6a10c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +other-codes/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..514e251 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2712 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[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 = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fatfs" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05669f8e7e2d7badc545c513710f0eba09c2fbef683eb859fd79c46c355048e0" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "chrono", + "log", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "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", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "isobemak" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f7c30532f5875767898676678c293cc0251697a58aa31208b13dd501e37510" +dependencies = [ + "crc32fast", + "fatfs", + "regex", + "tempfile", + "uuid", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "ssh2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" +dependencies = [ + "bitflags 2.11.0", + "libc", + "libssh2-sys", + "parking_lot", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vm-manager" +version = "0.1.0" +dependencies = [ + "dirs", + "futures-util", + "isobemak", + "libc", + "miette", + "reqwest", + "serde", + "serde_json", + "ssh2", + "tempfile", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", + "zstd", +] + +[[package]] +name = "vmctl" +version = "0.1.0" +dependencies = [ + "clap", + "dirs", + "miette", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "vm-manager", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "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-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ed57d3c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[workspace] +resolver = "3" +members = ["crates/vm-manager", "crates/vmctl"] + +[workspace.package] +edition = "2024" +license = "MPL-2.0" +rust-version = "1.85" + +[workspace.dependencies] +tokio = { version = "1", features = [ + "rt-multi-thread", + "macros", + "signal", + "fs", + "io-util", + "io-std", + "process", + "net", + "time", +] } +miette = { version = "7", features = ["fancy"] } +thiserror = "2" +clap = { version = "4", features = ["derive", "env"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = [ + "rustls-tls-native-roots", + "stream", +] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4", "serde"] } +tempfile = "3" +futures-util = "0.3" +zstd = "0.13" +dirs = "6" diff --git a/brand/nebula-vm/boot.ksh b/brand/nebula-vm/boot.ksh new file mode 100644 index 0000000..0cf4d4f --- /dev/null +++ b/brand/nebula-vm/boot.ksh @@ -0,0 +1,52 @@ +#!/bin/ksh +# +# nebula-vm brand: boot script +# +# Called by zoneadm(8) when the zone is booted. +# Arguments: %z = zone name, %R = zone root +# + +ZONENAME="$1" +ZONEROOT="$2" + +if [[ -z "$ZONENAME" || -z "$ZONEROOT" ]]; then + echo "Usage: boot.ksh " >&2 + exit 1 +fi + +echo "nebula-vm: booting zone '${ZONENAME}'" + +# Read zone network configuration and create VNIC if needed +VNIC_NAME="vnic_${ZONENAME}" +PHYSICAL_LINK=$(dladm show-phys -p -o LINK 2>/dev/null | head -1) + +if [[ -n "$PHYSICAL_LINK" ]]; then + # Check if VNIC already exists + if ! dladm show-vnic "$VNIC_NAME" >/dev/null 2>&1; then + echo "nebula-vm: creating VNIC ${VNIC_NAME} over ${PHYSICAL_LINK}" + dladm create-vnic -l "$PHYSICAL_LINK" "$VNIC_NAME" || { + echo "nebula-vm: WARNING - failed to create VNIC" >&2 + } + fi +fi + +# Start propolis-server inside the zone +PROPOLIS="${ZONEROOT}/root/opt/propolis/propolis-server" +PROPOLIS_CONFIG="${ZONEROOT}/root/opt/propolis/config.toml" +PIDFILE="${ZONEROOT}/root/var/run/propolis.pid" +LOGFILE="${ZONEROOT}/root/var/log/propolis.log" + +if [[ -x "$PROPOLIS" ]]; then + echo "nebula-vm: starting propolis-server" + nohup zlogin "$ZONENAME" /opt/propolis/propolis-server \ + run /opt/propolis/config.toml \ + > "$LOGFILE" 2>&1 & + echo $! > "$PIDFILE" + echo "nebula-vm: propolis-server started (pid=$(cat $PIDFILE))" +else + echo "nebula-vm: ERROR - propolis-server not found at ${PROPOLIS}" >&2 + exit 1 +fi + +echo "nebula-vm: zone '${ZONENAME}' booted" +exit 0 diff --git a/brand/nebula-vm/config.xml b/brand/nebula-vm/config.xml new file mode 100644 index 0000000..6b7bffd --- /dev/null +++ b/brand/nebula-vm/config.xml @@ -0,0 +1,36 @@ + + + + + + + + + nebula-vm + + /sbin/init + /usr/bin/login -z %Z %u + /usr/bin/login -z %Z -f %u + /usr/bin/getent passwd %u + + + /usr/lib/brand/nebula-vm/install.ksh %z %R + /usr/lib/brand/nebula-vm/boot.ksh %z %R + /usr/lib/brand/nebula-vm/halt.ksh %z %R + /usr/lib/brand/nebula-vm/uninstall.ksh %z %R + /usr/lib/brand/nebula-vm/support.ksh prestate %z %R + /usr/lib/brand/nebula-vm/support.ksh poststate %z %R + + + + + + + + + diff --git a/brand/nebula-vm/halt.ksh b/brand/nebula-vm/halt.ksh new file mode 100644 index 0000000..241e7a1 --- /dev/null +++ b/brand/nebula-vm/halt.ksh @@ -0,0 +1,49 @@ +#!/bin/ksh +# +# nebula-vm brand: halt script +# +# Called by zoneadm(8) when the zone is being halted. +# Arguments: %z = zone name, %R = zone root +# + +ZONENAME="$1" +ZONEROOT="$2" + +if [[ -z "$ZONENAME" || -z "$ZONEROOT" ]]; then + echo "Usage: halt.ksh " >&2 + exit 1 +fi + +echo "nebula-vm: halting zone '${ZONENAME}'" + +# Stop propolis-server +PIDFILE="${ZONEROOT}/root/var/run/propolis.pid" +if [[ -f "$PIDFILE" ]]; then + PID=$(cat "$PIDFILE") + if [[ -n "$PID" ]] && kill -0 "$PID" 2>/dev/null; then + echo "nebula-vm: stopping propolis-server (pid=${PID})" + kill -TERM "$PID" + # Wait up to 10 seconds for graceful shutdown + WAIT=0 + while kill -0 "$PID" 2>/dev/null && [[ $WAIT -lt 10 ]]; do + sleep 1 + WAIT=$((WAIT + 1)) + done + # Force kill if still running + if kill -0 "$PID" 2>/dev/null; then + echo "nebula-vm: force-killing propolis-server" + kill -KILL "$PID" + fi + fi + rm -f "$PIDFILE" +fi + +# Clean up VNIC +VNIC_NAME="vnic_${ZONENAME}" +if dladm show-vnic "$VNIC_NAME" >/dev/null 2>&1; then + echo "nebula-vm: removing VNIC ${VNIC_NAME}" + dladm delete-vnic "$VNIC_NAME" || true +fi + +echo "nebula-vm: zone '${ZONENAME}' halted" +exit 0 diff --git a/brand/nebula-vm/install.ksh b/brand/nebula-vm/install.ksh new file mode 100644 index 0000000..807db35 --- /dev/null +++ b/brand/nebula-vm/install.ksh @@ -0,0 +1,49 @@ +#!/bin/ksh +# +# nebula-vm brand: install script +# +# Called by zoneadm(8) during zone installation. +# Arguments: %z = zone name, %R = zone root +# + +ZONENAME="$1" +ZONEROOT="$2" + +if [[ -z "$ZONENAME" || -z "$ZONEROOT" ]]; then + echo "Usage: install.ksh " >&2 + exit 1 +fi + +echo "nebula-vm: installing zone '${ZONENAME}' at ${ZONEROOT}" + +# Create the minimal zone root structure +mkdir -p "${ZONEROOT}/root" +mkdir -p "${ZONEROOT}/root/dev" +mkdir -p "${ZONEROOT}/root/etc" +mkdir -p "${ZONEROOT}/root/var/run" +mkdir -p "${ZONEROOT}/root/var/log" +mkdir -p "${ZONEROOT}/root/opt/propolis" + +# Copy propolis-server binary into the zone if available on the host +PROPOLIS_BIN="/opt/oxide/propolis-server/bin/propolis-server" +if [[ -f "$PROPOLIS_BIN" ]]; then + cp "$PROPOLIS_BIN" "${ZONEROOT}/root/opt/propolis/propolis-server" + chmod 0755 "${ZONEROOT}/root/opt/propolis/propolis-server" + echo "nebula-vm: propolis-server copied to zone" +else + echo "nebula-vm: WARNING - propolis-server not found at ${PROPOLIS_BIN}" + echo "nebula-vm: you must manually place propolis-server in the zone" +fi + +# Write a default propolis configuration +cat > "${ZONEROOT}/root/opt/propolis/config.toml" <<'EOF' +[main] +listen_addr = "0.0.0.0" +listen_port = 12400 + +[log] +level = "info" +EOF + +echo "nebula-vm: zone '${ZONENAME}' installed successfully" +exit 0 diff --git a/brand/nebula-vm/platform.xml b/brand/nebula-vm/platform.xml new file mode 100644 index 0000000..4372ecb --- /dev/null +++ b/brand/nebula-vm/platform.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/brand/nebula-vm/support.ksh b/brand/nebula-vm/support.ksh new file mode 100644 index 0000000..16aa19e --- /dev/null +++ b/brand/nebula-vm/support.ksh @@ -0,0 +1,44 @@ +#!/bin/ksh +# +# nebula-vm brand: support script +# +# Called for pre/post state change hooks. +# Arguments: prestate|poststate +# + +ACTION="$1" +ZONENAME="$2" +ZONEROOT="$3" + +case "$ACTION" in + prestate) + # Pre-state-change hook: ensure network resources are available + VNIC_NAME="vnic_${ZONENAME}" + PHYSICAL_LINK=$(dladm show-phys -p -o LINK 2>/dev/null | head -1) + + if [[ -n "$PHYSICAL_LINK" ]]; then + if ! dladm show-vnic "$VNIC_NAME" >/dev/null 2>&1; then + dladm create-vnic -l "$PHYSICAL_LINK" "$VNIC_NAME" 2>/dev/null || true + fi + fi + ;; + + poststate) + # Post-state-change hook: cleanup if zone is no longer running + VNIC_NAME="vnic_${ZONENAME}" + ZONE_STATE=$(zoneadm -z "$ZONENAME" list -p 2>/dev/null | cut -d: -f3) + + if [[ "$ZONE_STATE" != "running" ]]; then + if dladm show-vnic "$VNIC_NAME" >/dev/null 2>&1; then + dladm delete-vnic "$VNIC_NAME" 2>/dev/null || true + fi + fi + ;; + + *) + echo "nebula-vm support: unknown action '${ACTION}'" >&2 + exit 1 + ;; +esac + +exit 0 diff --git a/brand/nebula-vm/uninstall.ksh b/brand/nebula-vm/uninstall.ksh new file mode 100644 index 0000000..b504da7 --- /dev/null +++ b/brand/nebula-vm/uninstall.ksh @@ -0,0 +1,29 @@ +#!/bin/ksh +# +# nebula-vm brand: uninstall script +# +# Called by zoneadm(8) during zone uninstallation. +# Arguments: %z = zone name, %R = zone root +# + +ZONENAME="$1" +ZONEROOT="$2" + +if [[ -z "$ZONENAME" || -z "$ZONEROOT" ]]; then + echo "Usage: uninstall.ksh " >&2 + exit 1 +fi + +echo "nebula-vm: uninstalling zone '${ZONENAME}'" + +# Remove the zone root contents +if [[ -d "${ZONEROOT}/root" ]]; then + rm -rf "${ZONEROOT}/root" + echo "nebula-vm: zone root removed" +fi + +# Remove the zone path itself if empty +rmdir "${ZONEROOT}" 2>/dev/null || true + +echo "nebula-vm: zone '${ZONENAME}' uninstalled" +exit 0 diff --git a/crates/vm-manager/Cargo.toml b/crates/vm-manager/Cargo.toml new file mode 100644 index 0000000..d1b47f3 --- /dev/null +++ b/crates/vm-manager/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "vm-manager" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[features] +default = [] +pure-iso = ["dep:isobemak"] + +[dependencies] +tokio.workspace = true +miette.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +reqwest.workspace = true +tracing.workspace = true +uuid.workspace = true +tempfile.workspace = true +futures-util.workspace = true +zstd.workspace = true +dirs.workspace = true + +# Optional pure-Rust ISO generation +isobemak = { version = "0.2", optional = true } + +# SSH +ssh2 = "0.9" + +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" + +[target.'cfg(target_os = "illumos")'.dependencies] +tokio-tungstenite = "0.26" diff --git a/crates/vm-manager/src/backends/mod.rs b/crates/vm-manager/src/backends/mod.rs new file mode 100644 index 0000000..ce8b5d2 --- /dev/null +++ b/crates/vm-manager/src/backends/mod.rs @@ -0,0 +1,281 @@ +pub mod noop; + +#[cfg(target_os = "linux")] +pub mod qemu; +#[cfg(target_os = "linux")] +pub mod qmp; + +#[cfg(target_os = "illumos")] +pub mod propolis; + +use std::time::Duration; + +use crate::error::{Result, VmError}; +use crate::traits::{ConsoleEndpoint, Hypervisor}; +use crate::types::{BackendTag, VmHandle, VmSpec, VmState}; + +/// Platform-aware router that delegates to the appropriate backend. +pub struct RouterHypervisor { + pub noop: noop::NoopBackend, + #[cfg(target_os = "linux")] + pub qemu: Option, + #[cfg(target_os = "illumos")] + pub propolis: Option, +} + +impl RouterHypervisor { + /// Build a router with platform defaults. + /// + /// On Linux, creates a QemuBackend with the given bridge. + /// On illumos, creates a PropolisBackend with the given ZFS pool. + #[allow(unused_variables)] + pub fn new(bridge: Option, zfs_pool: Option) -> Self { + #[cfg(target_os = "linux")] + { + RouterHypervisor { + noop: noop::NoopBackend, + qemu: Some(qemu::QemuBackend::new(None, None, bridge)), + } + } + #[cfg(target_os = "illumos")] + { + RouterHypervisor { + noop: noop::NoopBackend, + propolis: Some(propolis::PropolisBackend::new( + None, + zfs_pool.unwrap_or_else(|| "rpool".into()), + )), + } + } + #[cfg(not(any(target_os = "linux", target_os = "illumos")))] + { + RouterHypervisor { + noop: noop::NoopBackend, + } + } + } + + /// Build a router that only has the noop backend (for dev/testing). + pub fn noop_only() -> Self { + #[cfg(target_os = "linux")] + { + RouterHypervisor { + noop: noop::NoopBackend, + qemu: None, + } + } + #[cfg(target_os = "illumos")] + { + RouterHypervisor { + noop: noop::NoopBackend, + propolis: None, + } + } + #[cfg(not(any(target_os = "linux", target_os = "illumos")))] + { + RouterHypervisor { + noop: noop::NoopBackend, + } + } + } +} + +impl Hypervisor for RouterHypervisor { + async fn prepare(&self, spec: &VmSpec) -> Result { + #[cfg(target_os = "linux")] + if let Some(ref qemu) = self.qemu { + return qemu.prepare(spec).await; + } + #[cfg(target_os = "illumos")] + if let Some(ref propolis) = self.propolis { + return propolis.prepare(spec).await; + } + self.noop.prepare(spec).await + } + + async fn start(&self, vm: &VmHandle) -> Result<()> { + match vm.backend { + #[cfg(target_os = "linux")] + BackendTag::Qemu => match self.qemu { + Some(ref q) => q.start(vm).await, + None => Err(VmError::BackendNotAvailable { + backend: "qemu".into(), + }), + }, + #[cfg(target_os = "illumos")] + BackendTag::Propolis => match self.propolis { + Some(ref p) => p.start(vm).await, + None => Err(VmError::BackendNotAvailable { + backend: "propolis".into(), + }), + }, + BackendTag::Noop => self.noop.start(vm).await, + #[allow(unreachable_patterns)] + _ => Err(VmError::BackendNotAvailable { + backend: vm.backend.to_string(), + }), + } + } + + async fn stop(&self, vm: &VmHandle, timeout: Duration) -> Result<()> { + match vm.backend { + #[cfg(target_os = "linux")] + BackendTag::Qemu => match self.qemu { + Some(ref q) => q.stop(vm, timeout).await, + None => Err(VmError::BackendNotAvailable { + backend: "qemu".into(), + }), + }, + #[cfg(target_os = "illumos")] + BackendTag::Propolis => match self.propolis { + Some(ref p) => p.stop(vm, timeout).await, + None => Err(VmError::BackendNotAvailable { + backend: "propolis".into(), + }), + }, + BackendTag::Noop => self.noop.stop(vm, timeout).await, + #[allow(unreachable_patterns)] + _ => Err(VmError::BackendNotAvailable { + backend: vm.backend.to_string(), + }), + } + } + + async fn suspend(&self, vm: &VmHandle) -> Result<()> { + match vm.backend { + #[cfg(target_os = "linux")] + BackendTag::Qemu => match self.qemu { + Some(ref q) => q.suspend(vm).await, + None => Err(VmError::BackendNotAvailable { + backend: "qemu".into(), + }), + }, + #[cfg(target_os = "illumos")] + BackendTag::Propolis => match self.propolis { + Some(ref p) => p.suspend(vm).await, + None => Err(VmError::BackendNotAvailable { + backend: "propolis".into(), + }), + }, + BackendTag::Noop => self.noop.suspend(vm).await, + #[allow(unreachable_patterns)] + _ => Err(VmError::BackendNotAvailable { + backend: vm.backend.to_string(), + }), + } + } + + async fn resume(&self, vm: &VmHandle) -> Result<()> { + match vm.backend { + #[cfg(target_os = "linux")] + BackendTag::Qemu => match self.qemu { + Some(ref q) => q.resume(vm).await, + None => Err(VmError::BackendNotAvailable { + backend: "qemu".into(), + }), + }, + #[cfg(target_os = "illumos")] + BackendTag::Propolis => match self.propolis { + Some(ref p) => p.resume(vm).await, + None => Err(VmError::BackendNotAvailable { + backend: "propolis".into(), + }), + }, + BackendTag::Noop => self.noop.resume(vm).await, + #[allow(unreachable_patterns)] + _ => Err(VmError::BackendNotAvailable { + backend: vm.backend.to_string(), + }), + } + } + + async fn destroy(&self, vm: VmHandle) -> Result<()> { + match vm.backend { + #[cfg(target_os = "linux")] + BackendTag::Qemu => match self.qemu { + Some(ref q) => q.destroy(vm).await, + None => Err(VmError::BackendNotAvailable { + backend: "qemu".into(), + }), + }, + #[cfg(target_os = "illumos")] + BackendTag::Propolis => match self.propolis { + Some(ref p) => p.destroy(vm).await, + None => Err(VmError::BackendNotAvailable { + backend: "propolis".into(), + }), + }, + BackendTag::Noop => self.noop.destroy(vm).await, + #[allow(unreachable_patterns)] + _ => Err(VmError::BackendNotAvailable { + backend: vm.backend.to_string(), + }), + } + } + + async fn state(&self, vm: &VmHandle) -> Result { + match vm.backend { + #[cfg(target_os = "linux")] + BackendTag::Qemu => match self.qemu { + Some(ref q) => q.state(vm).await, + None => Ok(VmState::Destroyed), + }, + #[cfg(target_os = "illumos")] + BackendTag::Propolis => match self.propolis { + Some(ref p) => p.state(vm).await, + None => Ok(VmState::Destroyed), + }, + BackendTag::Noop => self.noop.state(vm).await, + #[allow(unreachable_patterns)] + _ => Ok(VmState::Destroyed), + } + } + + async fn guest_ip(&self, vm: &VmHandle) -> Result { + match vm.backend { + #[cfg(target_os = "linux")] + BackendTag::Qemu => match self.qemu { + Some(ref q) => q.guest_ip(vm).await, + None => Err(VmError::BackendNotAvailable { + backend: "qemu".into(), + }), + }, + #[cfg(target_os = "illumos")] + BackendTag::Propolis => match self.propolis { + Some(ref p) => p.guest_ip(vm).await, + None => Err(VmError::BackendNotAvailable { + backend: "propolis".into(), + }), + }, + BackendTag::Noop => self.noop.guest_ip(vm).await, + #[allow(unreachable_patterns)] + _ => Err(VmError::BackendNotAvailable { + backend: vm.backend.to_string(), + }), + } + } + + fn console_endpoint(&self, vm: &VmHandle) -> Result { + match vm.backend { + #[cfg(target_os = "linux")] + BackendTag::Qemu => match self.qemu { + Some(ref q) => q.console_endpoint(vm), + None => Err(VmError::BackendNotAvailable { + backend: "qemu".into(), + }), + }, + #[cfg(target_os = "illumos")] + BackendTag::Propolis => match self.propolis { + Some(ref p) => p.console_endpoint(vm), + None => Err(VmError::BackendNotAvailable { + backend: "propolis".into(), + }), + }, + BackendTag::Noop => self.noop.console_endpoint(vm), + #[allow(unreachable_patterns)] + _ => Err(VmError::BackendNotAvailable { + backend: vm.backend.to_string(), + }), + } + } +} diff --git a/crates/vm-manager/src/backends/noop.rs b/crates/vm-manager/src/backends/noop.rs new file mode 100644 index 0000000..8129cd3 --- /dev/null +++ b/crates/vm-manager/src/backends/noop.rs @@ -0,0 +1,116 @@ +use std::time::Duration; + +use tracing::info; + +use crate::error::Result; +use crate::traits::{ConsoleEndpoint, Hypervisor}; +use crate::types::{BackendTag, VmHandle, VmSpec, VmState}; + +/// No-op hypervisor for development and testing on hosts without VM capabilities. +#[derive(Debug, Clone, Default)] +pub struct NoopBackend; + +impl Hypervisor for NoopBackend { + async fn prepare(&self, spec: &VmSpec) -> Result { + let id = format!("noop-{}", uuid::Uuid::new_v4()); + let work_dir = std::env::temp_dir().join("vmctl-noop").join(&id); + tokio::fs::create_dir_all(&work_dir).await?; + info!(id = %id, name = %spec.name, image = ?spec.image_path, "noop: prepare"); + Ok(VmHandle { + id, + name: spec.name.clone(), + backend: BackendTag::Noop, + work_dir, + overlay_path: None, + seed_iso_path: None, + pid: None, + qmp_socket: None, + console_socket: None, + vnc_addr: None, + }) + } + + async fn start(&self, vm: &VmHandle) -> Result<()> { + info!(id = %vm.id, name = %vm.name, "noop: start"); + Ok(()) + } + + async fn stop(&self, vm: &VmHandle, _timeout: Duration) -> Result<()> { + info!(id = %vm.id, name = %vm.name, "noop: stop"); + Ok(()) + } + + async fn suspend(&self, vm: &VmHandle) -> Result<()> { + info!(id = %vm.id, name = %vm.name, "noop: suspend"); + Ok(()) + } + + async fn resume(&self, vm: &VmHandle) -> Result<()> { + info!(id = %vm.id, name = %vm.name, "noop: resume"); + Ok(()) + } + + async fn destroy(&self, vm: VmHandle) -> Result<()> { + info!(id = %vm.id, name = %vm.name, "noop: destroy"); + let _ = tokio::fs::remove_dir_all(&vm.work_dir).await; + Ok(()) + } + + async fn state(&self, _vm: &VmHandle) -> Result { + Ok(VmState::Prepared) + } + + async fn guest_ip(&self, _vm: &VmHandle) -> Result { + Ok("127.0.0.1".to_string()) + } + + fn console_endpoint(&self, _vm: &VmHandle) -> Result { + Ok(ConsoleEndpoint::None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + use crate::types::NetworkConfig; + + fn test_spec() -> VmSpec { + VmSpec { + name: "test-vm".into(), + image_path: PathBuf::from("/tmp/test.qcow2"), + vcpus: 1, + memory_mb: 512, + disk_gb: None, + network: NetworkConfig::None, + cloud_init: None, + ssh: None, + } + } + + #[tokio::test] + async fn noop_lifecycle() { + let backend = NoopBackend; + let spec = test_spec(); + + let handle = backend.prepare(&spec).await.unwrap(); + assert_eq!(handle.backend, BackendTag::Noop); + assert!(handle.id.starts_with("noop-")); + + backend.start(&handle).await.unwrap(); + assert_eq!(backend.state(&handle).await.unwrap(), VmState::Prepared); + + backend.suspend(&handle).await.unwrap(); + backend.resume(&handle).await.unwrap(); + + let ip = backend.guest_ip(&handle).await.unwrap(); + assert_eq!(ip, "127.0.0.1"); + + let endpoint = backend.console_endpoint(&handle).unwrap(); + assert!(matches!(endpoint, ConsoleEndpoint::None)); + + backend.stop(&handle, Duration::from_secs(5)).await.unwrap(); + backend.destroy(handle).await.unwrap(); + } +} diff --git a/crates/vm-manager/src/backends/propolis.rs b/crates/vm-manager/src/backends/propolis.rs new file mode 100644 index 0000000..24f4792 --- /dev/null +++ b/crates/vm-manager/src/backends/propolis.rs @@ -0,0 +1,292 @@ +//! Propolis (Oxide's bhyve VMM) backend for illumos. +//! +//! Manages VMs inside `nebula-vm` branded zones with propolis-server. + +use std::path::PathBuf; +use std::time::Duration; + +use tracing::{info, warn}; + +use crate::error::{Result, VmError}; +use crate::traits::{ConsoleEndpoint, Hypervisor}; +use crate::types::{BackendTag, NetworkConfig, VmHandle, VmSpec, VmState}; + +/// Propolis backend for illumos zones. +pub struct PropolisBackend { + data_dir: PathBuf, + zfs_pool: String, +} + +impl PropolisBackend { + pub fn new(data_dir: Option, zfs_pool: String) -> Self { + let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("/var/lib/vmctl/vms")); + Self { data_dir, zfs_pool } + } + + fn work_dir(&self, name: &str) -> PathBuf { + self.data_dir.join(name) + } + + /// Run a shell command and return (success, stdout, stderr). + async fn run_cmd(cmd: &str, args: &[&str]) -> Result<(bool, String, String)> { + let output = tokio::process::Command::new(cmd) + .args(args) + .output() + .await?; + Ok(( + output.status.success(), + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + )) + } + + /// Poll propolis-server until it responds, up to timeout. + async fn wait_for_propolis(addr: &str, timeout: Duration) -> Result<()> { + let client = reqwest::Client::new(); + let deadline = tokio::time::Instant::now() + timeout; + let url = format!("http://{addr}/instance"); + + loop { + if let Ok(resp) = client.get(&url).send().await { + if resp.status().is_success() || resp.status().as_u16() == 404 { + return Ok(()); + } + } + if tokio::time::Instant::now() >= deadline { + return Err(VmError::PropolisUnreachable { + addr: addr.into(), + source: Box::new(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "propolis-server did not become available", + )), + }); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } +} + +impl Hypervisor for PropolisBackend { + async fn prepare(&self, spec: &VmSpec) -> Result { + let work_dir = self.work_dir(&spec.name); + tokio::fs::create_dir_all(&work_dir).await?; + + // Clone ZFS dataset for the VM disk + let base_dataset = format!("{}/images/{}", self.zfs_pool, spec.name); + let vm_dataset = format!("{}/vms/{}", self.zfs_pool, spec.name); + + let (ok, _, stderr) = Self::run_cmd( + "zfs", + &["clone", &format!("{base_dataset}@latest"), &vm_dataset], + ) + .await?; + if !ok { + warn!(name = %spec.name, stderr = %stderr, "ZFS clone failed (may already exist)"); + } + + // Create cloud-init seed ISO if configured + let mut seed_iso_path = None; + if let Some(ref ci) = spec.cloud_init { + let iso_path = work_dir.join("seed.iso"); + let instance_id = ci.instance_id.as_deref().unwrap_or(&spec.name); + let hostname = ci.hostname.as_deref().unwrap_or(&spec.name); + let meta_data = format!("instance-id: {instance_id}\nlocal-hostname: {hostname}\n"); + crate::cloudinit::create_nocloud_iso_raw( + &ci.user_data, + meta_data.as_bytes(), + &iso_path, + )?; + seed_iso_path = Some(iso_path); + } + + // Determine VNIC name + let vnic_name = match &spec.network { + NetworkConfig::Vnic { name } => name.clone(), + _ => format!("vnic_{}", spec.name), + }; + + // Configure and install zone + let zone_name = &spec.name; + let zonecfg_cmds = format!( + "create -b; set brand=nebula-vm; set zonepath={work_dir}; set ip-type=exclusive; \ + add net; set physical={vnic_name}; end; commit", + work_dir = work_dir.display() + ); + let (ok, _, stderr) = Self::run_cmd("zonecfg", &["-z", zone_name, &zonecfg_cmds]).await?; + if !ok { + warn!(name = %zone_name, stderr = %stderr, "zonecfg failed (zone may already exist)"); + } + + let (ok, _, stderr) = Self::run_cmd("zoneadm", &["-z", zone_name, "install"]).await?; + if !ok { + warn!(name = %zone_name, stderr = %stderr, "zone install failed"); + } + + let handle = VmHandle { + id: format!("propolis-{}", uuid::Uuid::new_v4()), + name: spec.name.clone(), + backend: BackendTag::Propolis, + work_dir, + overlay_path: None, + seed_iso_path, + pid: None, + qmp_socket: None, + console_socket: None, + vnc_addr: None, + }; + + info!(name = %spec.name, id = %handle.id, "Propolis: prepared"); + Ok(handle) + } + + async fn start(&self, vm: &VmHandle) -> Result<()> { + // Boot zone + let (ok, _, stderr) = Self::run_cmd("zoneadm", &["-z", &vm.name, "boot"]).await?; + if !ok { + return Err(VmError::QemuSpawnFailed { + source: std::io::Error::other(format!("zone boot failed: {stderr}")), + }); + } + + // The brand boot script starts propolis-server inside the zone. + // Wait for it to become available. + let propolis_addr = format!("127.0.0.1:12400"); // default propolis port + Self::wait_for_propolis(&propolis_addr, Duration::from_secs(30)).await?; + + // PUT /instance with instance spec + let client = reqwest::Client::new(); + let instance_spec = serde_json::json!({ + "properties": { + "id": vm.id, + "name": vm.name, + "description": "managed by vmctl" + }, + "nics": [], + "disks": [], + "boot_settings": { + "order": [{"name": "disk0"}] + } + }); + + client + .put(format!("http://{propolis_addr}/instance")) + .json(&instance_spec) + .send() + .await + .map_err(|e| VmError::PropolisUnreachable { + addr: propolis_addr.clone(), + source: Box::new(e), + })?; + + // PUT /instance/state → Run + client + .put(format!("http://{propolis_addr}/instance/state")) + .json(&serde_json::json!("Run")) + .send() + .await + .map_err(|e| VmError::PropolisUnreachable { + addr: propolis_addr.clone(), + source: Box::new(e), + })?; + + info!(name = %vm.name, "Propolis: started"); + Ok(()) + } + + async fn stop(&self, vm: &VmHandle, _timeout: Duration) -> Result<()> { + let propolis_addr = "127.0.0.1:12400"; + let client = reqwest::Client::new(); + + // PUT /instance/state → Stop + let _ = client + .put(format!("http://{propolis_addr}/instance/state")) + .json(&serde_json::json!("Stop")) + .send() + .await; + + // Halt the zone + let _ = Self::run_cmd("zoneadm", &["-z", &vm.name, "halt"]).await; + + info!(name = %vm.name, "Propolis: stopped"); + Ok(()) + } + + async fn suspend(&self, vm: &VmHandle) -> Result<()> { + info!(name = %vm.name, "Propolis: suspend (not yet implemented)"); + Ok(()) + } + + async fn resume(&self, vm: &VmHandle) -> Result<()> { + info!(name = %vm.name, "Propolis: resume (not yet implemented)"); + Ok(()) + } + + async fn destroy(&self, vm: VmHandle) -> Result<()> { + // Stop first + self.stop(&vm, Duration::from_secs(10)).await?; + + // Uninstall and delete zone + let _ = Self::run_cmd("zoneadm", &["-z", &vm.name, "uninstall", "-F"]).await; + let _ = Self::run_cmd("zonecfg", &["-z", &vm.name, "delete", "-F"]).await; + + // Destroy ZFS dataset + let vm_dataset = format!("{}/vms/{}", self.zfs_pool, vm.name); + let _ = Self::run_cmd("zfs", &["destroy", "-r", &vm_dataset]).await; + + // Remove work directory + let _ = tokio::fs::remove_dir_all(&vm.work_dir).await; + + info!(name = %vm.name, "Propolis: destroyed"); + Ok(()) + } + + async fn state(&self, vm: &VmHandle) -> Result { + let (ok, stdout, _) = Self::run_cmd("zoneadm", &["-z", &vm.name, "list", "-p"]).await?; + if !ok { + return Ok(VmState::Destroyed); + } + + // Output format: zoneid:zonename:state:zonepath:uuid:brand:ip-type + let state_field = stdout.split(':').nth(2).unwrap_or("").trim(); + + Ok(match state_field { + "running" => VmState::Running, + "installed" => VmState::Prepared, + "configured" => VmState::Prepared, + _ => VmState::Stopped, + }) + } + + async fn guest_ip(&self, vm: &VmHandle) -> Result { + // For exclusive-IP zones, the IP is configured inside the zone. + // Try to query it via zlogin. + let (ok, stdout, _) = Self::run_cmd( + "zlogin", + &[&vm.name, "ipadm", "show-addr", "-p", "-o", "ADDR"], + ) + .await?; + + if ok { + for line in stdout.lines() { + let addr = line + .trim() + .trim_end_matches(|c: char| c == '/' || c.is_ascii_digit()); + let addr = line.split('/').next().unwrap_or("").trim(); + if !addr.is_empty() && addr != "127.0.0.1" && addr.contains('.') { + return Ok(addr.to_string()); + } + } + } + + Err(VmError::IpDiscoveryTimeout { + name: vm.name.clone(), + }) + } + + fn console_endpoint(&self, vm: &VmHandle) -> Result { + // Propolis serial console is available via WebSocket + Ok(ConsoleEndpoint::WebSocket(format!( + "ws://127.0.0.1:12400/instance/serial" + ))) + } +} diff --git a/crates/vm-manager/src/backends/qemu.rs b/crates/vm-manager/src/backends/qemu.rs new file mode 100644 index 0000000..3f7767e --- /dev/null +++ b/crates/vm-manager/src/backends/qemu.rs @@ -0,0 +1,401 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use tracing::{debug, info, warn}; + +use crate::cloudinit; +use crate::error::{Result, VmError}; +use crate::image; +use crate::traits::{ConsoleEndpoint, Hypervisor}; +use crate::types::{BackendTag, VmHandle, VmSpec, VmState}; + +use super::qmp::QmpClient; + +/// QEMU-KVM backend for Linux. +/// +/// Manages VMs as QEMU processes with QMP control sockets. +pub struct QemuBackend { + qemu_binary: PathBuf, + data_dir: PathBuf, + default_bridge: Option, +} + +impl QemuBackend { + pub fn new( + qemu_binary: Option, + data_dir: Option, + default_bridge: Option, + ) -> Self { + let data_dir = data_dir.unwrap_or_else(|| { + dirs::data_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("vmctl") + .join("vms") + }); + Self { + qemu_binary: qemu_binary.unwrap_or_else(|| "qemu-system-x86_64".into()), + data_dir, + default_bridge, + } + } + + fn work_dir(&self, name: &str) -> PathBuf { + self.data_dir.join(name) + } + + /// Generate a random locally-administered MAC address. + pub fn generate_mac() -> String { + let bytes: [u8; 6] = rand_mac(); + format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5] + ) + } + + /// Read PID from the pidfile in the work directory. + async fn read_pid(work_dir: &Path) -> Option { + let pid_path = work_dir.join("qemu.pid"); + tokio::fs::read_to_string(&pid_path) + .await + .ok() + .and_then(|s| s.trim().parse().ok()) + } + + /// Check if a process with the given PID is alive. + fn pid_alive(pid: u32) -> bool { + // Signal 0 checks if process exists without sending a signal + unsafe { libc::kill(pid as i32, 0) == 0 } + } +} + +/// Generate a locally-administered unicast MAC address using random bytes. +fn rand_mac() -> [u8; 6] { + use std::collections::hash_map::RandomState; + use std::hash::{BuildHasher, Hasher}; + + let s = RandomState::new(); + let mut h = s.build_hasher(); + h.write_u64( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64, + ); + let v = h.finish(); + + let mut mac = [0u8; 6]; + mac[0] = 0x52; // locally administered, unicast + mac[1] = 0x54; + mac[2] = (v >> 24) as u8; + mac[3] = (v >> 16) as u8; + mac[4] = (v >> 8) as u8; + mac[5] = v as u8; + mac +} + +impl Hypervisor for QemuBackend { + async fn prepare(&self, spec: &VmSpec) -> Result { + let work_dir = self.work_dir(&spec.name); + tokio::fs::create_dir_all(&work_dir).await?; + + // Create QCOW2 overlay + let overlay = work_dir.join("overlay.qcow2"); + image::create_overlay(&spec.image_path, &overlay, spec.disk_gb).await?; + + // Generate cloud-init seed ISO if configured + let mut seed_iso_path = None; + if let Some(ref ci) = spec.cloud_init { + let iso_path = work_dir.join("seed.iso"); + let instance_id = ci.instance_id.as_deref().unwrap_or(&spec.name); + let hostname = ci.hostname.as_deref().unwrap_or(&spec.name); + let meta_data = format!("instance-id: {instance_id}\nlocal-hostname: {hostname}\n"); + + cloudinit::create_nocloud_iso_raw(&ci.user_data, meta_data.as_bytes(), &iso_path)?; + seed_iso_path = Some(iso_path); + } + + let qmp_socket = work_dir.join("qmp.sock"); + let console_socket = work_dir.join("console.sock"); + + let handle = VmHandle { + id: format!("qemu-{}", uuid::Uuid::new_v4()), + name: spec.name.clone(), + backend: BackendTag::Qemu, + work_dir, + overlay_path: Some(overlay), + seed_iso_path, + pid: None, + qmp_socket: Some(qmp_socket), + console_socket: Some(console_socket), + vnc_addr: None, + }; + + info!( + name = %spec.name, + id = %handle.id, + overlay = ?handle.overlay_path, + seed = ?handle.seed_iso_path, + "QEMU: prepared" + ); + + Ok(handle) + } + + async fn start(&self, vm: &VmHandle) -> Result<()> { + let overlay = vm + .overlay_path + .as_ref() + .ok_or_else(|| VmError::InvalidState { + name: vm.name.clone(), + state: "no overlay path".into(), + })?; + + // Read the VmSpec vcpus/memory from the overlay's qemu-img info? No — we need + // to reconstruct from VmHandle. For now, use defaults if not stored. + // The CLI will re-read spec and pass to prepare+start in sequence. + + let qmp_sock = vm.qmp_socket.as_ref().unwrap(); + let console_sock = vm.console_socket.as_ref().unwrap(); + + let mut args: Vec = vec![ + "-enable-kvm".into(), + "-machine".into(), + "q35,accel=kvm".into(), + "-cpu".into(), + "host".into(), + "-nodefaults".into(), + // QMP socket + "-qmp".into(), + format!("unix:{},server,nowait", qmp_sock.display()), + // Serial console socket + "-serial".into(), + format!("unix:{},server,nowait", console_sock.display()), + // VNC on localhost with auto-port + "-vnc".into(), + "127.0.0.1:0".into(), + // Virtio RNG + "-device".into(), + "virtio-rng-pci".into(), + // Main disk + "-drive".into(), + format!( + "file={},format=qcow2,if=none,id=drive0,discard=unmap", + overlay.display() + ), + "-device".into(), + "virtio-blk-pci,drive=drive0".into(), + ]; + + // Seed ISO (cloud-init) + if let Some(ref iso) = vm.seed_iso_path { + args.extend([ + "-drive".into(), + format!( + "file={},format=raw,if=none,id=seed,readonly=on", + iso.display() + ), + "-device".into(), + "virtio-blk-pci,drive=seed".into(), + ]); + } + + // Daemonize and pidfile + args.extend([ + "-daemonize".into(), + "-pidfile".into(), + vm.work_dir.join("qemu.pid").display().to_string(), + ]); + + info!( + name = %vm.name, + binary = %self.qemu_binary.display(), + "QEMU: starting" + ); + debug!(args = ?args, "QEMU command line"); + + let status = tokio::process::Command::new(&self.qemu_binary) + .args(&args) + .status() + .await + .map_err(|e| VmError::QemuSpawnFailed { source: e })?; + + if !status.success() { + return Err(VmError::QemuSpawnFailed { + source: std::io::Error::other(format!("QEMU exited with status {}", status)), + }); + } + + // Wait for QMP socket and verify connection + let mut qmp = QmpClient::connect(qmp_sock, Duration::from_secs(10)).await?; + let status = qmp.query_status().await?; + info!(name = %vm.name, status = %status, "QEMU: started"); + + Ok(()) + } + + async fn stop(&self, vm: &VmHandle, timeout: Duration) -> Result<()> { + // Try ACPI shutdown via QMP first + if let Some(ref qmp_sock) = vm.qmp_socket { + if qmp_sock.exists() { + if let Ok(mut qmp) = QmpClient::connect(qmp_sock, Duration::from_secs(2)).await { + let _ = qmp.system_powerdown().await; + } + } + } + + // Wait for process to exit + let start = tokio::time::Instant::now(); + loop { + if let Some(pid) = Self::read_pid(&vm.work_dir).await { + if !Self::pid_alive(pid) { + info!(name = %vm.name, "QEMU: process exited after ACPI shutdown"); + return Ok(()); + } + } else { + // No PID file, process likely already gone + return Ok(()); + } + + if start.elapsed() >= timeout { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // SIGTERM fallback + if let Some(pid) = Self::read_pid(&vm.work_dir).await { + if Self::pid_alive(pid) { + warn!(name = %vm.name, pid, "QEMU: ACPI shutdown timed out, sending SIGTERM"); + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + + // SIGKILL if still alive + if Self::pid_alive(pid) { + warn!(name = %vm.name, pid, "QEMU: SIGTERM failed, sending SIGKILL"); + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + } + } + + Ok(()) + } + + async fn suspend(&self, vm: &VmHandle) -> Result<()> { + if let Some(ref qmp_sock) = vm.qmp_socket { + let mut qmp = QmpClient::connect(qmp_sock, Duration::from_secs(5)).await?; + qmp.stop().await?; + } + Ok(()) + } + + async fn resume(&self, vm: &VmHandle) -> Result<()> { + if let Some(ref qmp_sock) = vm.qmp_socket { + let mut qmp = QmpClient::connect(qmp_sock, Duration::from_secs(5)).await?; + qmp.cont().await?; + } + Ok(()) + } + + async fn destroy(&self, vm: VmHandle) -> Result<()> { + // Stop if running + self.stop(&vm, Duration::from_secs(5)).await?; + + // QMP quit to ensure cleanup + if let Some(ref qmp_sock) = vm.qmp_socket { + if qmp_sock.exists() { + if let Ok(mut qmp) = QmpClient::connect(qmp_sock, Duration::from_secs(2)).await { + let _ = qmp.quit().await; + } + } + } + + // Remove work directory + let _ = tokio::fs::remove_dir_all(&vm.work_dir).await; + info!(name = %vm.name, "QEMU: destroyed"); + Ok(()) + } + + async fn state(&self, vm: &VmHandle) -> Result { + // Check if process is alive + if let Some(pid) = Self::read_pid(&vm.work_dir).await { + if Self::pid_alive(pid) { + // Try QMP for detailed state + if let Some(ref qmp_sock) = vm.qmp_socket { + if let Ok(mut qmp) = QmpClient::connect(qmp_sock, Duration::from_secs(2)).await + { + if let Ok(status) = qmp.query_status().await { + return Ok(match status.as_str() { + "running" => VmState::Running, + "paused" | "suspended" => VmState::Stopped, + _ => VmState::Running, + }); + } + } + } + return Ok(VmState::Running); + } + } + + // Check if work dir exists (prepared but not running) + if vm.work_dir.exists() { + Ok(VmState::Stopped) + } else { + Ok(VmState::Destroyed) + } + } + + async fn guest_ip(&self, vm: &VmHandle) -> Result { + // Parse ARP table (`ip neigh`) looking for IPs on the bridge + let output = tokio::process::Command::new("ip") + .args(["neigh", "show"]) + .output() + .await + .map_err(|_| VmError::IpDiscoveryTimeout { + name: vm.name.clone(), + })?; + + let text = String::from_utf8_lossy(&output.stdout); + + // Try to find an IP from the ARP table. This is a best-effort heuristic: + // look for REACHABLE or STALE entries on common bridge interfaces. + for line in text.lines() { + if line.contains("REACHABLE") || line.contains("STALE") { + if let Some(ip) = line.split_whitespace().next() { + // Basic IPv4 check + if ip.contains('.') && !ip.starts_with("127.") { + return Ok(ip.to_string()); + } + } + } + } + + // Fallback: check dnsmasq leases if available + if self.default_bridge.is_some() { + let leases_path = "/var/lib/misc/dnsmasq.leases"; + if let Ok(content) = tokio::fs::read_to_string(leases_path).await { + // Lease format: epoch MAC IP hostname clientid + if let Some(line) = content.lines().last() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 3 { + return Ok(parts[2].to_string()); + } + } + } + } + + Err(VmError::IpDiscoveryTimeout { + name: vm.name.clone(), + }) + } + + fn console_endpoint(&self, vm: &VmHandle) -> Result { + match vm.console_socket { + Some(ref path) => Ok(ConsoleEndpoint::UnixSocket(path.clone())), + None => Ok(ConsoleEndpoint::None), + } + } +} diff --git a/crates/vm-manager/src/backends/qmp.rs b/crates/vm-manager/src/backends/qmp.rs new file mode 100644 index 0000000..990253f --- /dev/null +++ b/crates/vm-manager/src/backends/qmp.rs @@ -0,0 +1,200 @@ +//! QMP (QEMU Machine Protocol) client over Unix domain socket. +//! +//! Implements the QMP wire protocol directly using JSON over a tokio `UnixStream`. +//! QMP is a simple line-delimited JSON protocol: +//! 1. Server sends a greeting `{"QMP": {...}}` +//! 2. Client sends `{"execute": "qmp_capabilities"}` +//! 3. Server responds `{"return": {}}` +//! 4. Client sends commands, server sends responses and events. + +use std::path::Path; +use std::time::Duration; + +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tracing::{debug, info, trace}; + +use crate::error::{Result, VmError}; + +/// A connected QMP client for a single QEMU instance. +pub struct QmpClient { + reader: BufReader>, + writer: tokio::io::WriteHalf, +} + +impl QmpClient { + /// Connect to a QMP Unix socket and negotiate capabilities. + /// + /// Retries the connection for up to `timeout` if the socket is not yet available. + pub async fn connect(socket_path: &Path, timeout: Duration) -> Result { + let deadline = tokio::time::Instant::now() + timeout; + let mut backoff = Duration::from_millis(100); + + let stream = loop { + match UnixStream::connect(socket_path).await { + Ok(s) => break s, + Err(e) => { + if tokio::time::Instant::now() >= deadline { + return Err(VmError::QmpConnectionFailed { + path: socket_path.into(), + source: e, + }); + } + let remaining = deadline.duration_since(tokio::time::Instant::now()); + tokio::time::sleep(backoff.min(remaining)).await; + backoff = backoff.saturating_mul(2).min(Duration::from_secs(1)); + } + } + }; + + let (read_half, write_half) = tokio::io::split(stream); + let mut client = Self { + reader: BufReader::new(read_half), + writer: write_half, + }; + + // Read the QMP greeting + let greeting = client.read_response().await?; + debug!(greeting = %greeting, "QMP greeting received"); + + // Negotiate capabilities + client.send_command("qmp_capabilities", None).await?; + let resp = client.read_response().await?; + if resp.get("error").is_some() { + return Err(VmError::QmpCommandFailed { + message: format!("qmp_capabilities failed: {resp}"), + }); + } + + debug!(path = %socket_path.display(), "QMP connected and negotiated"); + Ok(client) + } + + /// Send a QMP command and return the response. + async fn send_command(&mut self, execute: &str, arguments: Option) -> Result<()> { + let mut cmd = serde_json::json!({ "execute": execute }); + if let Some(args) = arguments { + cmd.as_object_mut() + .unwrap() + .insert("arguments".into(), args); + } + let mut line = serde_json::to_string(&cmd).unwrap(); + line.push('\n'); + trace!(cmd = %line.trim(), "QMP send"); + self.writer + .write_all(line.as_bytes()) + .await + .map_err(|e| VmError::QmpCommandFailed { + message: format!("write failed: {e}"), + })?; + self.writer + .flush() + .await + .map_err(|e| VmError::QmpCommandFailed { + message: format!("flush failed: {e}"), + })?; + Ok(()) + } + + /// Read the next JSON response (skipping asynchronous events). + async fn read_response(&mut self) -> Result { + loop { + let mut line = String::new(); + let n = + self.reader + .read_line(&mut line) + .await + .map_err(|e| VmError::QmpCommandFailed { + message: format!("read failed: {e}"), + })?; + if n == 0 { + return Err(VmError::QmpCommandFailed { + message: "QMP connection closed".into(), + }); + } + let line = line.trim(); + if line.is_empty() { + continue; + } + trace!(resp = %line, "QMP recv"); + let val: Value = serde_json::from_str(line).map_err(|e| VmError::QmpCommandFailed { + message: format!("JSON parse failed: {e}: {line}"), + })?; + + // Skip async events (they have an "event" key) + if val.get("event").is_some() { + debug!(event = %val, "QMP async event (skipped)"); + continue; + } + + return Ok(val); + } + } + + /// Execute a QMP command and return the response. + async fn execute(&mut self, command: &str, arguments: Option) -> Result { + self.send_command(command, arguments).await?; + self.read_response().await + } + + /// Send an ACPI system_powerdown event (graceful shutdown). + pub async fn system_powerdown(&mut self) -> Result<()> { + let resp = self.execute("system_powerdown", None).await?; + if resp.get("error").is_some() { + return Err(VmError::QmpCommandFailed { + message: format!("system_powerdown: {resp}"), + }); + } + info!("QMP: system_powerdown sent"); + Ok(()) + } + + /// Immediately terminate the QEMU process. + pub async fn quit(&mut self) -> Result<()> { + // quit disconnects before we can read a response, which is expected + let _ = self.send_command("quit", None).await; + info!("QMP: quit sent"); + Ok(()) + } + + /// Pause VM execution (freeze vCPUs). + pub async fn stop(&mut self) -> Result<()> { + let resp = self.execute("stop", None).await?; + if resp.get("error").is_some() { + return Err(VmError::QmpCommandFailed { + message: format!("stop: {resp}"), + }); + } + info!("QMP: stop (pause) sent"); + Ok(()) + } + + /// Resume VM execution. + pub async fn cont(&mut self) -> Result<()> { + let resp = self.execute("cont", None).await?; + if resp.get("error").is_some() { + return Err(VmError::QmpCommandFailed { + message: format!("cont: {resp}"), + }); + } + info!("QMP: cont (resume) sent"); + Ok(()) + } + + /// Query the current VM status. Returns the "status" string (e.g. "running", "paused"). + pub async fn query_status(&mut self) -> Result { + let resp = self.execute("query-status", None).await?; + if let Some(err) = resp.get("error") { + return Err(VmError::QmpCommandFailed { + message: format!("query-status: {err}"), + }); + } + let status = resp + .pointer("/return/status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + Ok(status) + } +} diff --git a/crates/vm-manager/src/cloudinit.rs b/crates/vm-manager/src/cloudinit.rs new file mode 100644 index 0000000..2b93373 --- /dev/null +++ b/crates/vm-manager/src/cloudinit.rs @@ -0,0 +1,177 @@ +use std::path::Path; + +use crate::error::{Result, VmError}; + +/// Create a NoCloud seed ISO from raw user-data and meta-data byte slices. +/// +/// If the `pure-iso` feature is enabled, uses the `isobemak` crate to build the ISO entirely in +/// Rust. Otherwise falls back to external `genisoimage` or `mkisofs`. +pub fn create_nocloud_iso_raw(user_data: &[u8], meta_data: &[u8], out_iso: &Path) -> Result<()> { + use std::fs; + use std::io::Write; + + // Ensure output directory exists + if let Some(parent) = out_iso.parent() { + fs::create_dir_all(parent)?; + } + + #[cfg(feature = "pure-iso")] + { + use isobemak::{BootInfo, IsoImage, IsoImageFile, build_iso}; + use std::fs::OpenOptions; + use std::io::{Seek, SeekFrom}; + use tempfile::NamedTempFile; + use tracing::info; + + info!(path = %out_iso.display(), "creating cloud-init ISO via isobemak (pure Rust)"); + + let mut tmp_user = NamedTempFile::new()?; + tmp_user.write_all(user_data)?; + let user_path = tmp_user.path().to_path_buf(); + + let mut tmp_meta = NamedTempFile::new()?; + tmp_meta.write_all(meta_data)?; + let meta_path = tmp_meta.path().to_path_buf(); + + let image = IsoImage { + files: vec![ + IsoImageFile { + source: user_path, + destination: "user-data".to_string(), + }, + IsoImageFile { + source: meta_path, + destination: "meta-data".to_string(), + }, + ], + boot_info: BootInfo { + bios_boot: None, + uefi_boot: None, + }, + }; + + build_iso(out_iso, &image, false).map_err(|e| VmError::CloudInitIsoFailed { + detail: format!("isobemak: {e}"), + })?; + + // Patch the PVD volume identifier to "CIDATA" (ISO 9660 Section 8.4.3). + const SECTOR_SIZE: u64 = 2048; + const PVD_LBA: u64 = 16; + const VOLID_OFFSET: u64 = 40; + const VOLID_LEN: usize = 32; + + let mut f = OpenOptions::new().read(true).write(true).open(out_iso)?; + let offset = PVD_LBA * SECTOR_SIZE + VOLID_OFFSET; + f.seek(SeekFrom::Start(offset))?; + let mut buf = [b' '; VOLID_LEN]; + let label = b"CIDATA"; + buf[..label.len()].copy_from_slice(label); + f.write_all(&buf)?; + + return Ok(()); + } + + #[cfg(not(feature = "pure-iso"))] + { + use std::fs::File; + use std::process::{Command, Stdio}; + use tempfile::tempdir; + + let dir = tempdir()?; + let seed_path = dir.path(); + + let user_data_path = seed_path.join("user-data"); + let meta_data_path = seed_path.join("meta-data"); + + { + let mut f = File::create(&user_data_path)?; + f.write_all(user_data)?; + } + { + let mut f = File::create(&meta_data_path)?; + f.write_all(meta_data)?; + } + + // Try genisoimage first, then mkisofs. + let status = Command::new("genisoimage") + .arg("-quiet") + .arg("-output") + .arg(out_iso) + .arg("-volid") + .arg("cidata") + .arg("-joliet") + .arg("-rock") + .arg(&user_data_path) + .arg(&meta_data_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + let status = match status { + Ok(s) => s, + Err(_) => Command::new("mkisofs") + .arg("-quiet") + .arg("-output") + .arg(out_iso) + .arg("-volid") + .arg("cidata") + .arg("-joliet") + .arg("-rock") + .arg(&user_data_path) + .arg(&meta_data_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?, + }; + + if !status.success() { + return Err(VmError::CloudInitIsoFailed { + detail: "genisoimage/mkisofs exited with non-zero status".into(), + }); + } + + Ok(()) + } +} + +/// Convenience: build cloud-config YAML from user/SSH key params, then create the ISO. +pub fn create_nocloud_iso( + user: &str, + ssh_pubkey: &str, + instance_id: &str, + hostname: &str, + out_iso: &Path, +) -> Result<()> { + let (user_data, meta_data) = build_cloud_config(user, ssh_pubkey, instance_id, hostname); + create_nocloud_iso_raw(&user_data, &meta_data, out_iso) +} + +/// Build a minimal cloud-config user-data and meta-data from parameters. +/// +/// Returns `(user_data_bytes, meta_data_bytes)`. +pub fn build_cloud_config( + user: &str, + ssh_pubkey: &str, + instance_id: &str, + hostname: &str, +) -> (Vec, Vec) { + let user_data = format!( + r#"#cloud-config +users: + - name: {user} + groups: [sudo] + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + ssh_authorized_keys: + - {ssh_pubkey} +ssh_pwauth: false +disable_root: true +chpasswd: + expire: false +"# + ); + + let meta_data = format!("instance-id: {instance_id}\nlocal-hostname: {hostname}\n"); + + (user_data.into_bytes(), meta_data.into_bytes()) +} diff --git a/crates/vm-manager/src/error.rs b/crates/vm-manager/src/error.rs new file mode 100644 index 0000000..0875ec5 --- /dev/null +++ b/crates/vm-manager/src/error.rs @@ -0,0 +1,125 @@ +// The `unused_assignments` warnings are false positives from thiserror 2's derive macro +// on Rust edition 2024 — it generates destructuring assignments that the compiler considers +// "never read" even though they are used in the Display implementation. +#![allow(unused_assignments)] + +use miette::Diagnostic; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error, Diagnostic)] +pub enum VmError { + #[error("failed to spawn QEMU process: {source}")] + #[diagnostic( + code(vm_manager::qemu::spawn_failed), + help( + "ensure qemu-system-x86_64 is installed and in PATH, and that KVM is available (/dev/kvm)" + ) + )] + QemuSpawnFailed { source: std::io::Error }, + + #[error("failed to connect to QMP socket at {}: {source}", path.display())] + #[diagnostic( + code(vm_manager::qemu::qmp_connect_failed), + help( + "the QEMU process may have crashed before the QMP socket was ready — check the work directory for logs" + ) + )] + QmpConnectionFailed { + path: PathBuf, + source: std::io::Error, + }, + + #[error("QMP command failed: {message}")] + #[diagnostic(code(vm_manager::qemu::qmp_command_failed))] + QmpCommandFailed { message: String }, + + #[error("failed to create QCOW2 overlay from base image {}: {detail}", base.display())] + #[diagnostic( + code(vm_manager::image::overlay_creation_failed), + help("ensure qemu-img is installed and the base image exists and is readable") + )] + OverlayCreationFailed { base: PathBuf, detail: String }, + + #[error("timed out waiting for guest IP address for VM {name}")] + #[diagnostic( + code(vm_manager::network::ip_discovery_timeout), + help( + "the guest may not have obtained a DHCP lease — check bridge/network configuration and that the guest cloud-init is configured correctly" + ) + )] + IpDiscoveryTimeout { name: String }, + + #[error("propolis server at {addr} is unreachable: {source}")] + #[diagnostic( + code(vm_manager::propolis::unreachable), + help( + "ensure the propolis-server process is running inside the zone and listening on the expected address" + ) + )] + PropolisUnreachable { + addr: String, + source: Box, + }, + + #[error("failed to create cloud-init seed ISO: {detail}")] + #[diagnostic( + code(vm_manager::cloudinit::iso_failed), + help( + "ensure genisoimage or mkisofs is installed, or enable the `pure-iso` feature for a Rust-only fallback" + ) + )] + CloudInitIsoFailed { detail: String }, + + #[error("SSH operation failed: {detail}")] + #[diagnostic( + code(vm_manager::ssh::failed), + help("check that the SSH key is correct, the guest is reachable, and sshd is running") + )] + SshFailed { detail: String }, + + #[error("failed to download image from {url}: {detail}")] + #[diagnostic( + code(vm_manager::image::download_failed), + help("check network connectivity and that the URL is correct") + )] + ImageDownloadFailed { url: String, detail: String }, + + #[error("image format detection failed for {}: {detail}", path.display())] + #[diagnostic( + code(vm_manager::image::format_detection_failed), + help("ensure qemu-img is installed and the file is a valid disk image") + )] + ImageFormatDetectionFailed { path: PathBuf, detail: String }, + + #[error("image conversion failed: {detail}")] + #[diagnostic( + code(vm_manager::image::conversion_failed), + help("ensure qemu-img is installed and there is enough disk space") + )] + ImageConversionFailed { detail: String }, + + #[error("VM {name} not found")] + #[diagnostic( + code(vm_manager::vm::not_found), + help("run `vmctl list` to see available VMs") + )] + VmNotFound { name: String }, + + #[error("VM {name} is in state {state} which does not allow this operation")] + #[diagnostic(code(vm_manager::vm::invalid_state))] + InvalidState { name: String, state: String }, + + #[error("backend not available: {backend}")] + #[diagnostic( + code(vm_manager::backend::not_available), + help("this backend is not supported on the current platform") + )] + BackendNotAvailable { backend: String }, + + #[error(transparent)] + #[diagnostic(code(vm_manager::io))] + Io(#[from] std::io::Error), +} + +pub type Result = std::result::Result; diff --git a/crates/vm-manager/src/image.rs b/crates/vm-manager/src/image.rs new file mode 100644 index 0000000..05679d1 --- /dev/null +++ b/crates/vm-manager/src/image.rs @@ -0,0 +1,322 @@ +use std::cmp::min; +use std::path::{Path, PathBuf}; + +use futures_util::StreamExt; +use tracing::info; + +use crate::error::{Result, VmError}; + +/// Returns the default image cache directory: `{XDG_DATA_HOME}/vmctl/images/`. +pub fn cache_dir() -> PathBuf { + dirs::data_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("vmctl") + .join("images") +} + +/// Streaming image downloader with progress logging and zstd decompression support. +pub struct ImageManager { + client: reqwest::Client, + cache: PathBuf, +} + +impl Default for ImageManager { + fn default() -> Self { + Self { + client: reqwest::Client::new(), + cache: cache_dir(), + } + } +} + +impl ImageManager { + pub fn new() -> Self { + Self::default() + } + + pub fn with_cache_dir(cache: PathBuf) -> Self { + Self { + client: reqwest::Client::new(), + cache, + } + } + + /// Download an image from `url` to `destination`. + /// + /// If the file already exists at `destination`, the download is skipped. + /// URLs ending in `.zst` or `.zstd` are automatically decompressed. + pub async fn download(&self, url: &str, destination: &Path) -> Result<()> { + if destination.exists() { + info!(url = %url, dest = %destination.display(), "image already present; skipping download"); + return Ok(()); + } + + if let Some(parent) = destination.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let is_zstd = url.ends_with(".zst") || url.ends_with(".zstd"); + + if is_zstd { + self.download_zstd(url, destination).await + } else { + self.download_raw(url, destination).await + } + } + + /// Pull an image from a URL into the cache directory, returning the cached path. + pub async fn pull(&self, url: &str, name: Option<&str>) -> Result { + let file_name = name.map(|n| n.to_string()).unwrap_or_else(|| { + url.rsplit('/') + .next() + .unwrap_or("image") + .trim_end_matches(".zst") + .trim_end_matches(".zstd") + .to_string() + }); + let dest = self.cache.join(&file_name); + self.download(url, &dest).await?; + Ok(dest) + } + + /// List all cached images. + pub async fn list(&self) -> Result> { + let mut entries = Vec::new(); + let cache = &self.cache; + if !cache.exists() { + return Ok(entries); + } + let mut dir = tokio::fs::read_dir(cache).await?; + while let Some(entry) = dir.next_entry().await? { + let path = entry.path(); + if path.is_file() { + let metadata = entry.metadata().await?; + entries.push(CachedImage { + name: entry.file_name().to_string_lossy().to_string(), + path, + size_bytes: metadata.len(), + }); + } + } + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(entries) + } + + async fn download_zstd(&self, url: &str, destination: &Path) -> Result<()> { + let res = self + .client + .get(url) + .send() + .await + .map_err(|e| VmError::ImageDownloadFailed { + url: url.into(), + detail: e.to_string(), + })?; + + let total_size = res.content_length().unwrap_or(0); + + let tmp_name = format!( + "{}.zst.tmp", + destination + .file_name() + .map(|s| s.to_string_lossy()) + .unwrap_or_default() + ); + let tmp_path = destination + .parent() + .map(|p| p.join(&tmp_name)) + .unwrap_or_else(|| PathBuf::from(&tmp_name)); + + info!(url = %url, dest = %destination.display(), size_bytes = total_size, "downloading image (zstd)"); + + // Stream to temp compressed file + { + let mut tmp_file = std::fs::File::create(&tmp_path)?; + let mut downloaded: u64 = 0; + let mut stream = res.bytes_stream(); + let mut last_logged_pct: u64 = 0; + while let Some(item) = stream.next().await { + let chunk = item.map_err(|e| VmError::ImageDownloadFailed { + url: url.into(), + detail: e.to_string(), + })?; + std::io::Write::write_all(&mut tmp_file, &chunk)?; + if total_size > 0 { + downloaded = min(downloaded + (chunk.len() as u64), total_size); + let pct = downloaded.saturating_mul(100) / total_size.max(1); + if pct >= last_logged_pct + 5 || pct == 100 { + info!( + percent = pct, + downloaded_mb = (downloaded as f64) / 1_000_000.0, + "downloading (zstd)..." + ); + last_logged_pct = pct; + } + } + } + } + + info!(tmp = %tmp_path.display(), "download complete; decompressing zstd"); + + // Decompress + let infile = std::fs::File::open(&tmp_path)?; + let mut decoder = + zstd::stream::Decoder::new(infile).map_err(|e| VmError::ImageDownloadFailed { + url: url.into(), + detail: format!("zstd decoder init: {e}"), + })?; + let mut outfile = std::fs::File::create(destination)?; + std::io::copy(&mut decoder, &mut outfile)?; + let _ = decoder.finish(); + let _ = std::fs::remove_file(&tmp_path); + + info!(dest = %destination.display(), "decompression completed"); + Ok(()) + } + + async fn download_raw(&self, url: &str, destination: &Path) -> Result<()> { + let res = self + .client + .get(url) + .send() + .await + .map_err(|e| VmError::ImageDownloadFailed { + url: url.into(), + detail: e.to_string(), + })?; + + let total_size = res.content_length().unwrap_or(0); + + info!(url = %url, dest = %destination.display(), size_bytes = total_size, "downloading image"); + + let mut file = std::fs::File::create(destination)?; + let mut downloaded: u64 = 0; + let mut stream = res.bytes_stream(); + let mut last_logged_pct: u64 = 0; + + while let Some(item) = stream.next().await { + let chunk = item.map_err(|e| VmError::ImageDownloadFailed { + url: url.into(), + detail: e.to_string(), + })?; + std::io::Write::write_all(&mut file, &chunk)?; + if total_size > 0 { + downloaded = min(downloaded + (chunk.len() as u64), total_size); + let pct = downloaded.saturating_mul(100) / total_size.max(1); + if pct >= last_logged_pct + 5 || pct == 100 { + info!( + percent = pct, + downloaded_mb = (downloaded as f64) / 1_000_000.0, + "downloading..." + ); + last_logged_pct = pct; + } + } + } + + info!(dest = %destination.display(), "download completed"); + Ok(()) + } +} + +/// Information about a cached image. +#[derive(Debug, Clone)] +pub struct CachedImage { + pub name: String, + pub path: PathBuf, + pub size_bytes: u64, +} + +/// Detect the format of a disk image using `qemu-img info`. +pub async fn detect_format(path: &Path) -> Result { + let output = tokio::process::Command::new("qemu-img") + .args(["info", "--output=json"]) + .arg(path) + .output() + .await + .map_err(|e| VmError::ImageFormatDetectionFailed { + path: path.into(), + detail: format!("qemu-img not found: {e}"), + })?; + + if !output.status.success() { + return Err(VmError::ImageFormatDetectionFailed { + path: path.into(), + detail: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + let info: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| { + VmError::ImageFormatDetectionFailed { + path: path.into(), + detail: format!("failed to parse qemu-img JSON: {e}"), + } + })?; + + Ok(info + .get("format") + .and_then(|f| f.as_str()) + .unwrap_or("raw") + .to_string()) +} + +/// Convert an image from one format to another using `qemu-img convert`. +pub async fn convert(src: &Path, dst: &Path, output_format: &str) -> Result<()> { + let output = tokio::process::Command::new("qemu-img") + .args(["convert", "-O", output_format]) + .arg(src) + .arg(dst) + .output() + .await + .map_err(|e| VmError::ImageConversionFailed { + detail: format!("qemu-img convert failed to start: {e}"), + })?; + + if !output.status.success() { + return Err(VmError::ImageConversionFailed { + detail: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + Ok(()) +} + +/// Create a QCOW2 overlay backed by a base image. +/// +/// Automatically detects the base image format. If `size_gb` is provided, the overlay is resized. +pub async fn create_overlay(base: &Path, overlay: &Path, size_gb: Option) -> Result<()> { + let base_fmt = detect_format(base).await?; + + let mut args = vec![ + "create".to_string(), + "-f".into(), + "qcow2".into(), + "-F".into(), + base_fmt, + "-b".into(), + base.to_string_lossy().into_owned(), + overlay.to_string_lossy().into_owned(), + ]; + + if let Some(gb) = size_gb { + args.push(format!("{gb}G")); + } + + let output = tokio::process::Command::new("qemu-img") + .args(&args) + .output() + .await + .map_err(|e| VmError::OverlayCreationFailed { + base: base.into(), + detail: format!("qemu-img not found: {e}"), + })?; + + if !output.status.success() { + return Err(VmError::OverlayCreationFailed { + base: base.into(), + detail: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + Ok(()) +} diff --git a/crates/vm-manager/src/lib.rs b/crates/vm-manager/src/lib.rs new file mode 100644 index 0000000..b6f0b03 --- /dev/null +++ b/crates/vm-manager/src/lib.rs @@ -0,0 +1,13 @@ +pub mod backends; +pub mod cloudinit; +pub mod error; +pub mod image; +pub mod ssh; +pub mod traits; +pub mod types; + +// Re-export key types at crate root for convenience. +pub use backends::RouterHypervisor; +pub use error::{Result, VmError}; +pub use traits::{ConsoleEndpoint, Hypervisor}; +pub use types::*; diff --git a/crates/vm-manager/src/ssh.rs b/crates/vm-manager/src/ssh.rs new file mode 100644 index 0000000..3d0f801 --- /dev/null +++ b/crates/vm-manager/src/ssh.rs @@ -0,0 +1,169 @@ +use std::io::Read; +use std::net::TcpStream; +use std::path::Path; +use std::time::Duration; + +use ssh2::Session; +use tracing::warn; + +use crate::error::{Result, VmError}; +use crate::types::SshConfig; + +/// Establish an SSH session to the given IP using the provided config. +/// +/// Tries in-memory key first, then key file path. +pub fn connect(ip: &str, config: &SshConfig) -> Result { + let addr = format!("{ip}:22"); + let tcp = TcpStream::connect(&addr).map_err(|e| VmError::SshFailed { + detail: format!("TCP connect to {addr}: {e}"), + })?; + + let mut sess = Session::new().map_err(|e| VmError::SshFailed { + detail: format!("session init: {e}"), + })?; + sess.set_tcp_stream(tcp); + sess.handshake().map_err(|e| VmError::SshFailed { + detail: format!("handshake with {addr}: {e}"), + })?; + + // Authenticate: in-memory PEM → file path + if let Some(ref pem) = config.private_key_pem { + sess.userauth_pubkey_memory(&config.user, None, pem, None) + .map_err(|e| VmError::SshFailed { + detail: format!("pubkey auth (memory) as {}: {e}", config.user), + })?; + } else if let Some(ref key_path) = config.private_key_path { + sess.userauth_pubkey_file(&config.user, None, key_path, None) + .map_err(|e| VmError::SshFailed { + detail: format!( + "pubkey auth (file {}) as {}: {e}", + key_path.display(), + config.user + ), + })?; + } else { + return Err(VmError::SshFailed { + detail: "no SSH private key configured (neither in-memory PEM nor file path)".into(), + }); + } + + if !sess.authenticated() { + return Err(VmError::SshFailed { + detail: "session not authenticated after auth attempt".into(), + }); + } + + Ok(sess) +} + +/// Execute a command over an existing SSH session. +/// +/// Returns `(stdout, stderr, exit_code)`. +pub fn exec(sess: &Session, cmd: &str) -> Result<(String, String, i32)> { + let mut channel = sess.channel_session().map_err(|e| VmError::SshFailed { + detail: format!("channel session: {e}"), + })?; + + channel.exec(cmd).map_err(|e| VmError::SshFailed { + detail: format!("exec '{cmd}': {e}"), + })?; + + let mut stdout = String::new(); + channel + .read_to_string(&mut stdout) + .map_err(|e| VmError::SshFailed { + detail: format!("read stdout: {e}"), + })?; + + let mut stderr = String::new(); + channel + .stderr() + .read_to_string(&mut stderr) + .map_err(|e| VmError::SshFailed { + detail: format!("read stderr: {e}"), + })?; + + channel.wait_close().map_err(|e| VmError::SshFailed { + detail: format!("wait close: {e}"), + })?; + let exit_code = channel.exit_status().unwrap_or(1); + + Ok((stdout, stderr, exit_code)) +} + +/// Upload a local file to a remote path via SFTP. +pub fn upload(sess: &Session, local: &Path, remote: &Path) -> Result<()> { + let sftp = sess.sftp().map_err(|e| VmError::SshFailed { + detail: format!("SFTP init: {e}"), + })?; + + let mut local_file = std::fs::File::open(local).map_err(|e| VmError::SshFailed { + detail: format!("open local file {}: {e}", local.display()), + })?; + + let mut buf = Vec::new(); + local_file + .read_to_end(&mut buf) + .map_err(|e| VmError::SshFailed { + detail: format!("read local file: {e}"), + })?; + + let mut remote_file = sftp.create(remote).map_err(|e| VmError::SshFailed { + detail: format!("SFTP create {}: {e}", remote.display()), + })?; + + std::io::Write::write_all(&mut remote_file, &buf).map_err(|e| VmError::SshFailed { + detail: format!("SFTP write: {e}"), + })?; + + Ok(()) +} + +/// Connect with exponential backoff retry. +/// +/// Retries the connection until `timeout` elapses, with exponential backoff capped at 5 seconds. +pub async fn connect_with_retry( + ip: &str, + config: &SshConfig, + timeout: Duration, +) -> Result { + let deadline = tokio::time::Instant::now() + timeout; + let mut backoff = Duration::from_secs(1); + let mut attempt: u32 = 0; + + loop { + attempt += 1; + let ip_owned = ip.to_string(); + let config_clone = config.clone(); + + // Run the blocking SSH connect on a blocking thread + let result = tokio::task::spawn_blocking(move || connect(&ip_owned, &config_clone)).await; + + match result { + Ok(Ok(sess)) => return Ok(sess), + Ok(Err(e)) => { + if tokio::time::Instant::now() >= deadline { + return Err(e); + } + warn!( + attempt, + ip = %ip, + error = %e, + "SSH connect failed; retrying" + ); + } + Err(join_err) => { + if tokio::time::Instant::now() >= deadline { + return Err(VmError::SshFailed { + detail: format!("spawn_blocking join error: {join_err}"), + }); + } + } + } + + let remaining = deadline.duration_since(tokio::time::Instant::now()); + let sleep_dur = backoff.min(remaining); + tokio::time::sleep(sleep_dur).await; + backoff = backoff.saturating_mul(2).min(Duration::from_secs(5)); + } +} diff --git a/crates/vm-manager/src/traits.rs b/crates/vm-manager/src/traits.rs new file mode 100644 index 0000000..03ad7aa --- /dev/null +++ b/crates/vm-manager/src/traits.rs @@ -0,0 +1,47 @@ +use std::time::Duration; + +use crate::error::Result; +use crate::types::{VmHandle, VmSpec, VmState}; + +/// Async hypervisor trait implemented by each backend (QEMU, Propolis, Noop). +/// +/// The lifecycle is: `prepare` -> `start` -> (optionally `suspend`/`resume`) -> `stop` -> `destroy`. +pub trait Hypervisor: Send + Sync { + /// Allocate resources (overlay disk, cloud-init ISO, zone config, etc.) and return a handle. + fn prepare(&self, spec: &VmSpec) -> impl Future> + Send; + + /// Boot the VM. + fn start(&self, vm: &VmHandle) -> impl Future> + Send; + + /// Gracefully stop the VM. Falls back to forceful termination after `timeout`. + fn stop(&self, vm: &VmHandle, timeout: Duration) -> impl Future> + Send; + + /// Pause VM execution (freeze vCPUs). + fn suspend(&self, vm: &VmHandle) -> impl Future> + Send; + + /// Resume a suspended VM. + fn resume(&self, vm: &VmHandle) -> impl Future> + Send; + + /// Stop the VM (if running) and clean up all resources. + fn destroy(&self, vm: VmHandle) -> impl Future> + Send; + + /// Query the current state of the VM. + fn state(&self, vm: &VmHandle) -> impl Future> + Send; + + /// Attempt to discover the guest's IP address. + fn guest_ip(&self, vm: &VmHandle) -> impl Future> + Send; + + /// Return a path or address for attaching to the VM's serial console. + fn console_endpoint(&self, vm: &VmHandle) -> Result; +} + +/// Describes how to connect to a VM's serial console. +#[derive(Debug, Clone)] +pub enum ConsoleEndpoint { + /// Unix domain socket path (QEMU). + UnixSocket(std::path::PathBuf), + /// WebSocket URL (Propolis). + WebSocket(String), + /// Not available (Noop). + None, +} diff --git a/crates/vm-manager/src/types.rs b/crates/vm-manager/src/types.rs new file mode 100644 index 0000000..6ccf482 --- /dev/null +++ b/crates/vm-manager/src/types.rs @@ -0,0 +1,128 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Identifies which backend manages a VM. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BackendTag { + Noop, + Qemu, + Propolis, +} + +impl std::fmt::Display for BackendTag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Noop => write!(f, "noop"), + Self::Qemu => write!(f, "qemu"), + Self::Propolis => write!(f, "propolis"), + } + } +} + +/// Full specification for creating a VM. +#[derive(Debug, Clone)] +pub struct VmSpec { + pub name: String, + pub image_path: PathBuf, + pub vcpus: u16, + pub memory_mb: u64, + pub disk_gb: Option, + pub network: NetworkConfig, + pub cloud_init: Option, + pub ssh: Option, +} + +/// Network configuration for a VM. +#[derive(Debug, Clone, Default)] +pub enum NetworkConfig { + /// TAP device bridged to a host bridge (default on Linux). + Tap { bridge: String }, + /// SLIRP user-mode networking (no root required). + #[default] + User, + /// illumos VNIC for exclusive-IP zones. + Vnic { name: String }, + /// No networking. + None, +} + +/// Cloud-init NoCloud configuration. +#[derive(Debug, Clone)] +pub struct CloudInitConfig { + /// Raw user-data content (typically a cloud-config YAML). + pub user_data: Vec, + /// Instance ID for cloud-init metadata. + pub instance_id: Option, + /// Hostname for the guest. + pub hostname: Option, +} + +/// SSH connection configuration. +#[derive(Debug, Clone)] +pub struct SshConfig { + /// Username to connect as. + pub user: String, + /// OpenSSH public key (for cloud-init authorized_keys injection). + pub public_key: Option, + /// Path to a private key file on the host. + pub private_key_path: Option, + /// In-memory PEM-encoded private key. + pub private_key_pem: Option, +} + +/// Runtime handle for a managed VM. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VmHandle { + /// Unique identifier for this VM instance. + pub id: String, + /// Human-readable name. + pub name: String, + /// Which backend manages this VM. + pub backend: BackendTag, + /// Working directory for this VM's files. + pub work_dir: PathBuf, + /// Path to the QCOW2 overlay (QEMU) or raw disk. + pub overlay_path: Option, + /// Path to the cloud-init seed ISO. + pub seed_iso_path: Option, + /// QEMU process PID (Linux). + pub pid: Option, + /// Path to the QMP Unix socket (QEMU). + pub qmp_socket: Option, + /// Path to the serial console Unix socket (QEMU). + pub console_socket: Option, + /// VNC listen address (e.g. "127.0.0.1:5900"). + pub vnc_addr: Option, +} + +/// Observed VM lifecycle state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum VmState { + /// Backend is setting up resources. + Preparing, + /// Resources allocated, ready to start. + Prepared, + /// VM is running. + Running, + /// VM has been stopped (gracefully or forcibly). + Stopped, + /// VM encountered an error. + Failed, + /// VM and resources have been cleaned up. + Destroyed, +} + +impl std::fmt::Display for VmState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Preparing => write!(f, "preparing"), + Self::Prepared => write!(f, "prepared"), + Self::Running => write!(f, "running"), + Self::Stopped => write!(f, "stopped"), + Self::Failed => write!(f, "failed"), + Self::Destroyed => write!(f, "destroyed"), + } + } +} diff --git a/crates/vmctl/Cargo.toml b/crates/vmctl/Cargo.toml new file mode 100644 index 0000000..858d184 --- /dev/null +++ b/crates/vmctl/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "vmctl" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[[bin]] +name = "vmctl" +path = "src/main.rs" + +[dependencies] +vm-manager = { path = "../vm-manager" } +tokio.workspace = true +miette.workspace = true +clap.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true +dirs.workspace = true diff --git a/crates/vmctl/src/commands/console.rs b/crates/vmctl/src/commands/console.rs new file mode 100644 index 0000000..643704a --- /dev/null +++ b/crates/vmctl/src/commands/console.rs @@ -0,0 +1,85 @@ +use clap::Args; +use miette::{IntoDiagnostic, Result}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use vm_manager::{ConsoleEndpoint, Hypervisor, RouterHypervisor}; + +use super::state; + +#[derive(Args)] +pub struct ConsoleArgs { + /// VM name + name: String, +} + +pub async fn run(args: ConsoleArgs) -> Result<()> { + let store = state::load_store().await?; + let handle = store + .get(&args.name) + .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; + + let hv = RouterHypervisor::new(None, None); + let endpoint = hv.console_endpoint(handle).into_diagnostic()?; + + match endpoint { + ConsoleEndpoint::UnixSocket(path) => { + println!( + "Connecting to console at {} (Ctrl+] to detach)...", + path.display() + ); + let mut sock = tokio::net::UnixStream::connect(&path) + .await + .into_diagnostic()?; + + let mut stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + + let (mut read_half, mut write_half) = sock.split(); + + // Bridge stdin/stdout to socket + let to_sock = async { + let mut buf = [0u8; 1024]; + loop { + let n = stdin.read(&mut buf).await?; + if n == 0 { + break; + } + // Check for Ctrl+] (0x1d) to detach + if buf[..n].contains(&0x1d) { + break; + } + write_half.write_all(&buf[..n]).await?; + } + Ok::<_, std::io::Error>(()) + }; + + let from_sock = async { + let mut buf = [0u8; 1024]; + loop { + let n = read_half.read(&mut buf).await?; + if n == 0 { + break; + } + stdout.write_all(&buf[..n]).await?; + stdout.flush().await?; + } + Ok::<_, std::io::Error>(()) + }; + + tokio::select! { + r = to_sock => { let _ = r; } + r = from_sock => { let _ = r; } + } + + println!("\nDetached from console."); + } + ConsoleEndpoint::WebSocket(url) => { + println!("Console available at WebSocket: {url}"); + println!("Use a WebSocket client to connect."); + } + ConsoleEndpoint::None => { + println!("No console available for this backend."); + } + } + + Ok(()) +} diff --git a/crates/vmctl/src/commands/create.rs b/crates/vmctl/src/commands/create.rs new file mode 100644 index 0000000..8f9139c --- /dev/null +++ b/crates/vmctl/src/commands/create.rs @@ -0,0 +1,136 @@ +use std::path::PathBuf; + +use clap::Args; +use miette::{IntoDiagnostic, Result}; +use tracing::info; +use vm_manager::{CloudInitConfig, Hypervisor, NetworkConfig, RouterHypervisor, SshConfig, VmSpec}; + +use super::state; + +#[derive(Args)] +pub struct CreateArgs { + /// VM name + #[arg(long)] + name: String, + + /// Path to a local disk image + #[arg(long)] + image: Option, + + /// URL to download an image from + #[arg(long)] + image_url: Option, + + /// Number of vCPUs + #[arg(long, default_value = "1")] + vcpus: u16, + + /// Memory in MB + #[arg(long, default_value = "1024")] + memory: u64, + + /// Disk size in GB (overlay resize) + #[arg(long)] + disk: Option, + + /// Bridge name for TAP networking + #[arg(long)] + bridge: Option, + + /// Path to cloud-init user-data file + #[arg(long)] + cloud_init: Option, + + /// Path to SSH public key file (injected via cloud-init) + #[arg(long)] + ssh_key: Option, + + /// Also start the VM after creation + #[arg(long)] + start: bool, +} + +pub async fn run(args: CreateArgs) -> Result<()> { + // Resolve image + let image_path = if let Some(ref path) = args.image { + path.clone() + } else if let Some(ref url) = args.image_url { + let mgr = vm_manager::image::ImageManager::new(); + mgr.pull(url, Some(&args.name)).await.into_diagnostic()? + } else { + miette::bail!("either --image or --image-url must be specified"); + }; + + // Build cloud-init config if user-data or ssh key provided + let cloud_init = if args.cloud_init.is_some() || args.ssh_key.is_some() { + let user_data = if let Some(ref path) = args.cloud_init { + tokio::fs::read(path).await.into_diagnostic()? + } else if let Some(ref key_path) = args.ssh_key { + let pubkey = tokio::fs::read_to_string(key_path) + .await + .into_diagnostic()?; + let (ud, _) = vm_manager::cloudinit::build_cloud_config( + "vm", + pubkey.trim(), + &args.name, + &args.name, + ); + ud + } else { + Vec::new() + }; + + Some(CloudInitConfig { + user_data, + instance_id: Some(args.name.clone()), + hostname: Some(args.name.clone()), + }) + } else { + None + }; + + // Build SSH config if key provided + let ssh = args.ssh_key.as_ref().map(|key_path| SshConfig { + user: "vm".into(), + public_key: None, + private_key_path: Some(key_path.clone()), + private_key_pem: None, + }); + + // Network config + let network = if let Some(bridge) = args.bridge { + NetworkConfig::Tap { bridge } + } else { + NetworkConfig::User + }; + + let spec = VmSpec { + name: args.name.clone(), + image_path, + vcpus: args.vcpus, + memory_mb: args.memory, + disk_gb: args.disk, + network, + cloud_init, + ssh, + }; + + let hv = RouterHypervisor::new(None, None); + let handle = hv.prepare(&spec).await.into_diagnostic()?; + + info!(name = %args.name, id = %handle.id, "VM created"); + + // Persist handle + let mut store = state::load_store().await?; + store.insert(args.name.clone(), handle.clone()); + state::save_store(&store).await?; + + println!("VM '{}' created (id: {})", args.name, handle.id); + + if args.start { + hv.start(&handle).await.into_diagnostic()?; + println!("VM '{}' started", args.name); + } + + Ok(()) +} diff --git a/crates/vmctl/src/commands/destroy.rs b/crates/vmctl/src/commands/destroy.rs new file mode 100644 index 0000000..382c2d8 --- /dev/null +++ b/crates/vmctl/src/commands/destroy.rs @@ -0,0 +1,25 @@ +use clap::Args; +use miette::{IntoDiagnostic, Result}; +use vm_manager::{Hypervisor, RouterHypervisor}; + +use super::state; + +#[derive(Args)] +pub struct DestroyArgs { + /// VM name + name: String, +} + +pub async fn run(args: DestroyArgs) -> Result<()> { + let mut store = state::load_store().await?; + let handle = store + .remove(&args.name) + .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; + + let hv = RouterHypervisor::new(None, None); + hv.destroy(handle).await.into_diagnostic()?; + + state::save_store(&store).await?; + println!("VM '{}' destroyed", args.name); + Ok(()) +} diff --git a/crates/vmctl/src/commands/image.rs b/crates/vmctl/src/commands/image.rs new file mode 100644 index 0000000..fb6d20f --- /dev/null +++ b/crates/vmctl/src/commands/image.rs @@ -0,0 +1,88 @@ +use std::path::PathBuf; + +use clap::{Args, Subcommand}; +use miette::{IntoDiagnostic, Result}; + +#[derive(Args)] +pub struct ImageCommand { + #[command(subcommand)] + action: ImageAction, +} + +#[derive(Subcommand)] +enum ImageAction { + /// Download an image to the local cache + Pull(PullArgs), + /// List cached images + List, + /// Show image format and details + Inspect(InspectArgs), +} + +#[derive(Args)] +struct PullArgs { + /// URL to download + url: String, + + /// Name to save as in the cache + #[arg(long)] + name: Option, +} + +#[derive(Args)] +struct InspectArgs { + /// Path to the image file + path: PathBuf, +} + +pub async fn run(args: ImageCommand) -> Result<()> { + match args.action { + ImageAction::Pull(pull) => { + let mgr = vm_manager::image::ImageManager::new(); + let path = mgr + .pull(&pull.url, pull.name.as_deref()) + .await + .into_diagnostic()?; + println!("Image cached at: {}", path.display()); + } + ImageAction::List => { + let mgr = vm_manager::image::ImageManager::new(); + let images = mgr.list().await.into_diagnostic()?; + + if images.is_empty() { + println!("No cached images."); + return Ok(()); + } + + println!("{:<40} {:<12} PATH", "NAME", "SIZE"); + println!("{}", "-".repeat(80)); + + for img in images { + let size = if img.size_bytes >= 1_073_741_824 { + format!("{:.1} GB", img.size_bytes as f64 / 1_073_741_824.0) + } else { + format!("{:.1} MB", img.size_bytes as f64 / 1_048_576.0) + }; + println!("{:<40} {:<12} {}", img.name, size, img.path.display()); + } + } + ImageAction::Inspect(inspect) => { + let fmt = vm_manager::image::detect_format(&inspect.path) + .await + .into_diagnostic()?; + println!("Format: {}", fmt); + println!("Path: {}", inspect.path.display()); + + if let Ok(meta) = tokio::fs::metadata(&inspect.path).await { + let size = meta.len(); + if size >= 1_073_741_824 { + println!("Size: {:.1} GB", size as f64 / 1_073_741_824.0); + } else { + println!("Size: {:.1} MB", size as f64 / 1_048_576.0); + } + } + } + } + + Ok(()) +} diff --git a/crates/vmctl/src/commands/list.rs b/crates/vmctl/src/commands/list.rs new file mode 100644 index 0000000..ddd1713 --- /dev/null +++ b/crates/vmctl/src/commands/list.rs @@ -0,0 +1,34 @@ +use clap::Args; +use miette::Result; + +use super::state; + +#[derive(Args)] +pub struct ListArgs; + +pub async fn run(_args: ListArgs) -> Result<()> { + let store = state::load_store().await?; + + if store.is_empty() { + println!("No VMs found."); + return Ok(()); + } + + println!("{:<20} {:<12} {:<40} WORK DIR", "NAME", "BACKEND", "ID"); + println!("{}", "-".repeat(90)); + + let mut entries: Vec<_> = store.iter().collect(); + entries.sort_by_key(|(name, _)| (*name).clone()); + + for (name, handle) in entries { + println!( + "{:<20} {:<12} {:<40} {}", + name, + handle.backend, + handle.id, + handle.work_dir.display() + ); + } + + Ok(()) +} diff --git a/crates/vmctl/src/commands/mod.rs b/crates/vmctl/src/commands/mod.rs new file mode 100644 index 0000000..ccaa6cd --- /dev/null +++ b/crates/vmctl/src/commands/mod.rs @@ -0,0 +1,64 @@ +pub mod console; +pub mod create; +pub mod destroy; +pub mod image; +pub mod list; +pub mod ssh; +pub mod start; +pub mod state; +pub mod status; +pub mod stop; + +use clap::{Parser, Subcommand}; +use miette::Result; + +#[derive(Parser)] +#[command(name = "vmctl", about = "Manage virtual machines", version)] +pub struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Create a new VM (and optionally start it) + Create(create::CreateArgs), + /// Start an existing VM + Start(start::StartArgs), + /// Stop a running VM + Stop(stop::StopArgs), + /// Destroy a VM and clean up all resources + Destroy(destroy::DestroyArgs), + /// List all VMs + List(list::ListArgs), + /// Show VM status + Status(status::StatusArgs), + /// Attach to a VM's serial console + Console(console::ConsoleArgs), + /// SSH into a VM + Ssh(ssh::SshArgs), + /// Suspend a running VM (pause vCPUs) + Suspend(start::SuspendArgs), + /// Resume a suspended VM + Resume(start::ResumeArgs), + /// Manage VM images + Image(image::ImageCommand), +} + +impl Cli { + pub async fn run(self) -> Result<()> { + match self.command { + Command::Create(args) => create::run(args).await, + Command::Start(args) => start::run_start(args).await, + Command::Stop(args) => stop::run(args).await, + Command::Destroy(args) => destroy::run(args).await, + Command::List(args) => list::run(args).await, + Command::Status(args) => status::run(args).await, + Command::Console(args) => console::run(args).await, + Command::Ssh(args) => ssh::run(args).await, + Command::Suspend(args) => start::run_suspend(args).await, + Command::Resume(args) => start::run_resume(args).await, + Command::Image(args) => image::run(args).await, + } + } +} diff --git a/crates/vmctl/src/commands/ssh.rs b/crates/vmctl/src/commands/ssh.rs new file mode 100644 index 0000000..ae420db --- /dev/null +++ b/crates/vmctl/src/commands/ssh.rs @@ -0,0 +1,78 @@ +use std::path::PathBuf; +use std::time::Duration; + +use clap::Args; +use miette::{IntoDiagnostic, Result}; +use vm_manager::{Hypervisor, RouterHypervisor, SshConfig}; + +use super::state; + +#[derive(Args)] +pub struct SshArgs { + /// VM name + name: String, + + /// SSH user + #[arg(long, default_value = "vm")] + user: String, + + /// Path to SSH private key + #[arg(long)] + key: Option, +} + +pub async fn run(args: SshArgs) -> Result<()> { + let store = state::load_store().await?; + let handle = store + .get(&args.name) + .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; + + let hv = RouterHypervisor::new(None, None); + let ip = hv.guest_ip(handle).await.into_diagnostic()?; + + let key_path = args.key.unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/root")) + .join(".ssh") + .join("id_ed25519") + }); + + let config = SshConfig { + user: args.user.clone(), + public_key: None, + private_key_path: Some(key_path), + private_key_pem: None, + }; + + println!("Connecting to {}@{}...", args.user, ip); + + let sess = vm_manager::ssh::connect_with_retry(&ip, &config, Duration::from_secs(30)) + .await + .into_diagnostic()?; + + // Drop the libssh2 session (just used to verify connectivity) and exec system ssh. + // We use the system ssh binary for interactive terminal support. + drop(sess); + + let status = tokio::process::Command::new("ssh") + .arg("-o") + .arg("StrictHostKeyChecking=no") + .arg("-o") + .arg("UserKnownHostsFile=/dev/null") + .args( + config + .private_key_path + .iter() + .flat_map(|p| ["-i".to_string(), p.display().to_string()]), + ) + .arg(format!("{}@{}", args.user, ip)) + .status() + .await + .into_diagnostic()?; + + if !status.success() { + miette::bail!("SSH exited with status {}", status); + } + + Ok(()) +} diff --git a/crates/vmctl/src/commands/start.rs b/crates/vmctl/src/commands/start.rs new file mode 100644 index 0000000..6176257 --- /dev/null +++ b/crates/vmctl/src/commands/start.rs @@ -0,0 +1,62 @@ +use clap::Args; +use miette::{IntoDiagnostic, Result}; +use vm_manager::{Hypervisor, RouterHypervisor}; + +use super::state; + +#[derive(Args)] +pub struct StartArgs { + /// VM name + name: String, +} + +pub async fn run_start(args: StartArgs) -> Result<()> { + let store = state::load_store().await?; + let handle = store.get(&args.name).ok_or_else(|| { + miette::miette!( + "VM '{}' not found — run `vmctl list` to see available VMs", + args.name + ) + })?; + + let hv = RouterHypervisor::new(None, None); + hv.start(handle).await.into_diagnostic()?; + println!("VM '{}' started", args.name); + Ok(()) +} + +#[derive(Args)] +pub struct SuspendArgs { + /// VM name + name: String, +} + +pub async fn run_suspend(args: SuspendArgs) -> Result<()> { + let store = state::load_store().await?; + let handle = store + .get(&args.name) + .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; + + let hv = RouterHypervisor::new(None, None); + hv.suspend(handle).await.into_diagnostic()?; + println!("VM '{}' suspended", args.name); + Ok(()) +} + +#[derive(Args)] +pub struct ResumeArgs { + /// VM name + name: String, +} + +pub async fn run_resume(args: ResumeArgs) -> Result<()> { + let store = state::load_store().await?; + let handle = store + .get(&args.name) + .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; + + let hv = RouterHypervisor::new(None, None); + hv.resume(handle).await.into_diagnostic()?; + println!("VM '{}' resumed", args.name); + Ok(()) +} diff --git a/crates/vmctl/src/commands/state.rs b/crates/vmctl/src/commands/state.rs new file mode 100644 index 0000000..439af09 --- /dev/null +++ b/crates/vmctl/src/commands/state.rs @@ -0,0 +1,39 @@ +//! Persistent state for vmctl: maps VM name → VmHandle in a JSON file. + +use std::collections::HashMap; +use std::path::PathBuf; + +use miette::{IntoDiagnostic, Result}; +use vm_manager::VmHandle; + +/// State file location: `{XDG_DATA_HOME}/vmctl/vms.json` +fn state_path() -> PathBuf { + dirs::data_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("vmctl") + .join("vms.json") +} + +pub type Store = HashMap; + +/// Load the VM store from disk. Returns an empty map if the file doesn't exist. +pub async fn load_store() -> Result { + let path = state_path(); + if !path.exists() { + return Ok(HashMap::new()); + } + let data = tokio::fs::read_to_string(&path).await.into_diagnostic()?; + let store: Store = serde_json::from_str(&data).into_diagnostic()?; + Ok(store) +} + +/// Save the VM store to disk. +pub async fn save_store(store: &Store) -> Result<()> { + let path = state_path(); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await.into_diagnostic()?; + } + let data = serde_json::to_string_pretty(store).into_diagnostic()?; + tokio::fs::write(&path, data).await.into_diagnostic()?; + Ok(()) +} diff --git a/crates/vmctl/src/commands/status.rs b/crates/vmctl/src/commands/status.rs new file mode 100644 index 0000000..9f3ee12 --- /dev/null +++ b/crates/vmctl/src/commands/status.rs @@ -0,0 +1,42 @@ +use clap::Args; +use miette::{IntoDiagnostic, Result}; +use vm_manager::{Hypervisor, RouterHypervisor}; + +use super::state; + +#[derive(Args)] +pub struct StatusArgs { + /// VM name + name: String, +} + +pub async fn run(args: StatusArgs) -> Result<()> { + let store = state::load_store().await?; + let handle = store + .get(&args.name) + .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; + + let hv = RouterHypervisor::new(None, None); + let state = hv.state(handle).await.into_diagnostic()?; + + println!("Name: {}", handle.name); + println!("ID: {}", handle.id); + println!("Backend: {}", handle.backend); + println!("State: {}", state); + println!("WorkDir: {}", handle.work_dir.display()); + + if let Some(ref overlay) = handle.overlay_path { + println!("Overlay: {}", overlay.display()); + } + if let Some(ref seed) = handle.seed_iso_path { + println!("Seed: {}", seed.display()); + } + if let Some(pid) = handle.pid { + println!("PID: {}", pid); + } + if let Some(ref vnc) = handle.vnc_addr { + println!("VNC: {}", vnc); + } + + Ok(()) +} diff --git a/crates/vmctl/src/commands/stop.rs b/crates/vmctl/src/commands/stop.rs new file mode 100644 index 0000000..94576ed --- /dev/null +++ b/crates/vmctl/src/commands/stop.rs @@ -0,0 +1,31 @@ +use std::time::Duration; + +use clap::Args; +use miette::{IntoDiagnostic, Result}; +use vm_manager::{Hypervisor, RouterHypervisor}; + +use super::state; + +#[derive(Args)] +pub struct StopArgs { + /// VM name + name: String, + + /// Graceful shutdown timeout in seconds + #[arg(long, default_value = "30")] + timeout: u64, +} + +pub async fn run(args: StopArgs) -> Result<()> { + let store = state::load_store().await?; + let handle = store + .get(&args.name) + .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; + + let hv = RouterHypervisor::new(None, None); + hv.stop(handle, Duration::from_secs(args.timeout)) + .await + .into_diagnostic()?; + println!("VM '{}' stopped", args.name); + Ok(()) +} diff --git a/crates/vmctl/src/main.rs b/crates/vmctl/src/main.rs new file mode 100644 index 0000000..d13e165 --- /dev/null +++ b/crates/vmctl/src/main.rs @@ -0,0 +1,19 @@ +use clap::Parser; +use miette::Result; +use tracing_subscriber::EnvFilter; + +mod commands; +use commands::Cli; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize tracing with RUST_LOG env filter (default: info) + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + cli.run().await +}