diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef0a7f9..090ede1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,10 +159,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust 1.85 + - name: Install Rust 1.88 uses: dtolnay/rust-toolchain@master with: - toolchain: "1.85" + toolchain: "1.88" - name: Check build with MSRV run: | diff --git a/AGENTS.md b/AGENTS.md index 19f2ff1..db32837 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,11 +154,14 @@ pipeline and won't carry trace context or attributes. | `.warn()` | Partial failures, degraded conditions | | `.error()` | Step/workflow failures | -**CLI subscriber:** `src/bin/voidbox.rs` initializes `tracing_subscriber` with -`EnvFilter` defaulting to `"info"`. Override at runtime with: +**CLI subscriber:** `src/bin/voidbox/main.rs` initializes `tracing_subscriber` with +`EnvFilter` from the resolved log level (see `src/bin/voidbox/cli_config.rs` for merge order). +Override at runtime with `VOIDBOX_LOG_LEVEL` or `--log-level`, or set `RUST_LOG` when no explicit +CLI/config level is set: ```bash -RUST_LOG=debug cargo run --bin voidbox -- run --file spec.yaml +VOIDBOX_LOG_LEVEL=debug cargo run --bin voidbox -- run --file spec.yaml +# RUST_LOG also applies when VOIDBOX_LOG_LEVEL / config omit log_level ``` **Convention:** Workflow progress messages use the `[workflow:]` prefix @@ -171,7 +174,8 @@ pattern, e.g. `[workflow:my-flow] step 1/3: "build" running...`. | `src/observe/logs.rs` | `StructuredLogger`, `LogConfig`, `LogEntry`, `LogLevel` | | `src/observe/mod.rs` | `Observer` (owns logger), `SpanGuard` (RAII span + logging) | | `src/workflow/scheduler.rs` | Step progress logging via `observer.logger()` | -| `src/bin/voidbox.rs` | CLI `tracing_subscriber` + `EnvFilter` setup | +| `src/bin/voidbox/main.rs` | CLI `tracing_subscriber` + `EnvFilter` setup | +| `src/bin/voidbox/cli_config.rs` | CLI config merge (`VOIDBOX_*`, YAML, `--log-level`) | ### Key source files diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c120a1..71ce9b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Rust MSRV bumped to 1.88 + ## [0.1.2] - 2026-03-16 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fa70de..43b97a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -230,7 +230,7 @@ void-box/ │ ├── lib.rs # Main library entry point │ ├── artifacts.rs # Artifact management │ ├── bin/ -│ │ └── voidbox.rs # CLI tool +│ │ └── voidbox/ # CLI tool │ ├── sandbox/ # Sandbox abstraction │ ├── workflow/ # Workflow engine │ ├── observe/ # Observability layer diff --git a/Cargo.lock b/Cargo.lock index 5a61fa8..90f4ad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,12 +17,93 @@ dependencies = [ "memchr", ] +[[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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[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 = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-object-pool" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1ac0219111eb7bb7cb76d4cf2cb50c598e7ae549091d3616f9e95442c18486f" +dependencies = [ + "async-lock", + "event-listener", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -46,6 +127,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.22.1" @@ -69,9 +172,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -108,6 +211,9 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "cc" @@ -121,6 +227,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -133,6 +245,46 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "claudio" version = "0.1.2" @@ -140,6 +292,56 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[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" @@ -158,6 +360,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -206,7 +423,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror", + "thiserror 2.0.18", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", ] [[package]] @@ -225,7 +451,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -242,6 +468,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.15.0" @@ -264,6 +496,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "event-manager" version = "0.4.2" @@ -328,6 +581,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.31" @@ -377,6 +636,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -496,6 +761,30 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heapless" version = "0.8.0" @@ -551,6 +840,46 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "httpmock" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4888a4d02d8e1f92ffb6b4965cf5ff56dda36ef41975f41c6fa0f6bde78c4e" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-trait", + "base64", + "bytes", + "crossbeam-utils", + "form_urlencoded", + "futures-timer", + "futures-util", + "headers", + "http", + "http-body-util", + "hyper", + "hyper-util", + "path-tree", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "stringmetrics", + "tabwriter", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + [[package]] name = "humantime" version = "2.3.0" @@ -571,6 +900,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -593,7 +923,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -768,6 +1097,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -783,6 +1118,50 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -818,7 +1197,7 @@ version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e013ae7fcd2c6a8f384104d16afe7ea02969301ea2bb2a56e44b011ebc907cab" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "kvm-bindings", "libc", "vmm-sys-util 0.12.1", @@ -848,7 +1227,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", "redox_syscall 0.7.2", ] @@ -925,6 +1304,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -952,7 +1337,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -968,6 +1353,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "objc2" version = "0.6.3" @@ -983,7 +1374,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-foundation", ] @@ -994,7 +1385,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", "objc2", ] @@ -1011,7 +1402,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -1024,7 +1415,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8edb98625ad1f7dea2e82ab54d5bdf3f477e6645b550f4a7cced1818be9ff59d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "dispatch2", "objc2", @@ -1039,6 +1430,18 @@ 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 = "opentelemetry" version = "0.31.0" @@ -1049,7 +1452,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -1077,7 +1480,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "thiserror", + "thiserror 2.0.18", "tokio", "tonic", "tracing", @@ -1114,11 +1517,17 @@ dependencies = [ "opentelemetry", "percent-encoding", "rand", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1142,6 +1551,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "path-tree" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a97453bc21a968f722df730bfe11bd08745cb50d1300b0df2bda131dece136" +dependencies = [ + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1195,6 +1613,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1282,7 +1706,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1294,6 +1718,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -1303,7 +1728,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1373,7 +1798,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -1382,7 +1807,19 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d94dd2f7cd932d4dc02cc8b2b50dfd38bd079a4e5d79198b99743d7fcf9a4b4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", +] + +[[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]] @@ -1410,9 +1847,9 @@ checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", @@ -1430,9 +1867,9 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -1443,7 +1880,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] @@ -1472,7 +1908,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -1485,14 +1921,26 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", "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" @@ -1503,12 +1951,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1526,6 +2002,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1541,6 +2035,29 @@ dependencies = [ "libc", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -1591,14 +2108,12 @@ dependencies = [ ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "serde_regex" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", + "regex", "serde", ] @@ -1615,6 +2130,17 @@ dependencies = [ "unsafe-libyaml", ] +[[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 = "sha2" version = "0.10.9" @@ -1657,6 +2183,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "slab" version = "0.4.12" @@ -1699,6 +2231,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stringmetrics" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3c8667cd96245cbb600b8dec5680a7319edd719c5aa2b5d23c6bff94f39765" + +[[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" @@ -1736,6 +2280,15 @@ dependencies = [ "syn", ] +[[package]] +name = "tabwriter" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" +dependencies = [ + "unicode-width", +] + [[package]] name = "tar" version = "0.4.44" @@ -1760,13 +2313,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1789,6 +2362,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1949,7 +2553,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -1979,11 +2583,24 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -2052,6 +2669,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +[[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" @@ -2088,6 +2711,12 @@ 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" @@ -2130,7 +2759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f39348a049689cabd3377cdd9182bf526ec76a6f823b79903896452e9d7a7380" dependencies = [ "libc", - "thiserror", + "thiserror 2.0.18", "winapi", ] @@ -2168,9 +2797,11 @@ dependencies = [ "bincode", "block2", "byteorder", + "clap", "dispatch2", "event-manager", "getrandom 0.3.4", + "httpmock", "humantime", "ipnet", "kvm-bindings", @@ -2194,10 +2825,11 @@ dependencies = [ "sha2", "smoltcp", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-test", "tracing", + "tracing-appender", "tracing-subscriber", "uuid", "virtio-bindings", @@ -2228,12 +2860,22 @@ dependencies = [ "sha2", "tar", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "zstd", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2354,7 +2996,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -2381,10 +3023,10 @@ dependencies = [ ] [[package]] -name = "webpki-roots" +name = "webpki-root-certs" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] @@ -2405,6 +3047,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2417,6 +3068,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2444,6 +3104,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2477,6 +3152,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2489,6 +3170,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2501,6 +3188,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2525,6 +3218,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2537,6 +3236,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2549,6 +3254,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2561,6 +3272,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2631,7 +3348,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8b6a5a6..b7dd014 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "void-box" version = "0.1.2" edition = "2021" -rust-version = "1.85" +rust-version = "1.88" authors = ["Diego Parra "] description = "Composable workflow sandbox with KVM micro-VMs and native observability" repository = "https://github.com/the-void-ia/void-box" @@ -33,7 +33,7 @@ opentelemetry-otlp = { version = "0.31", default-features = false, features = [" opentelemetry-semantic-conventions = { version = "0.31", features = ["semconv_experimental"] } # HTTP client (for fetching remote skills from skills.sh) -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } # Security (cross-platform) getrandom = "0.3" @@ -42,6 +42,10 @@ getrandom = "0.3" uuid = { version = "1", features = ["v7"] } humantime = "2" +# CLI +clap = { version = "4", features = ["derive", "cargo", "env"] } +tracing-appender = "0.2" + # Utilities thiserror = "2" tracing = "0.1" @@ -102,6 +106,7 @@ voidbox-oci = { path = "voidbox-oci" } # OTel testing support (in-memory exporters for assertions) opentelemetry = { version = "0.31" } opentelemetry_sdk = { version = "0.31", features = ["testing"] } +httpmock = "0.8.2" [features] default = [] @@ -110,7 +115,7 @@ opentelemetry = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetr [[bin]] name = "voidbox" -path = "src/bin/voidbox.rs" +path = "src/bin/voidbox/main.rs" [[example]] diff --git a/README.md b/README.md index bc0a0f6..1677121 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,15 @@ License - Rust 1.83+ + Rust 1.88+

Architecture · - Quick Start · + Install · + Quick Start (CLI · Rust) · OCI Support · Host Mounts · Snapshots · @@ -64,8 +65,88 @@ Containers share a host kernel — sufficient for general isolation, but AI agen --- +## Install the `voidbox` CLI + +Each [release](https://github.com/the-void-ia/void-box/releases) ships **`voidbox`** together with a **kernel** and **initramfs** so you can run workloads out of the box. + +### Shell installer (Linux & macOS) + +```bash +curl -fsSL https://raw.githubusercontent.com/the-void-ia/void-box/main/scripts/install.sh | sh +``` + +Installs to `/usr/local/bin` and `/usr/local/lib/voidbox/`. For a specific version: `VERSION=v0.1.2 curl -fsSL ... | sh`. + +### Homebrew (macOS) + +```bash +brew tap the-void-ia/tap +brew install voidbox +``` + +### Debian / Ubuntu + +Download the `.deb` for your CPU (`amd64` or `arm64`) from [Releases](https://github.com/the-void-ia/void-box/releases). Example for v0.1.2 on amd64: + +```bash +curl -fsSLO https://github.com/the-void-ia/void-box/releases/download/v0.1.2/voidbox_0.1.2_amd64.deb +sudo dpkg -i voidbox_0.1.2_amd64.deb +``` + +### Fedora / RHEL + +```bash +sudo rpm -i https://github.com/the-void-ia/void-box/releases/download/v0.1.2/voidbox-0.1.2-1.x86_64.rpm +``` + +Use the matching `.rpm` name from [Releases](https://github.com/the-void-ia/void-box/releases) for your version and architecture. + +### Next steps + +| | | +|---|---| +| **[Getting Started](https://the-void-ia.github.io/void-box/guides/getting-started/)** | First run, environment variables, API keys | +| **[Install (site)](https://the-void-ia.github.io/void-box/)** | Copy-paste install block and direct tarball links | + +If you use Rust already, you can also `cargo install void-box` for the CLI only — pair it with kernel and initramfs from a [release tarball](https://github.com/the-void-ia/void-box/releases) or another install method above. + +--- + ## Quick Start +### Using the CLI + +With [`voidbox`](#install-the-voidbox-cli) on your `PATH`, run an agent from a YAML spec. From a clone of this repository: + +```bash +voidbox run --file examples/hackernews/hackernews_agent.yaml +``` + +**CLI overview:** `voidbox run`, `validate`, `inspect`, `skills`, `snapshot`, and `config` run locally and do not require a background server. For HTTP remote control, start `voidbox serve` (default `127.0.0.1:43100`), then use `status`, `logs`, or `tui` against that daemon. Full command reference: [CLI + TUI](https://the-void-ia.github.io/void-box/docs/cli-tui/). + +The full spec lives in [`examples/hackernews/hackernews_agent.yaml`](examples/hackernews/hackernews_agent.yaml). A minimal shape looks like: + +```yaml +api_version: v1 +kind: agent +name: hn_researcher +sandbox: + mode: auto + memory_mb: 1024 + network: true +llm: + provider: claude +agent: + prompt: "Your task…" + skills: + - "file:examples/hackernews/skills/hackernews-api.md" + timeout_secs: 600 +``` + +### Using the Rust library + +Add the crate and build a `VoidBox` in code: + ```bash cargo add void-box ``` @@ -93,33 +174,6 @@ let researcher = VoidBox::new("hn_researcher") .build()?; ``` -```yaml -# hackernews_agent.yaml -api_version: v1 -kind: agent -name: hn_researcher - -sandbox: - mode: auto - memory_mb: 1024 - network: true - -llm: - provider: ollama - model: qwen3-coder - -agent: - prompt: "Analyze top HN stories for AI engineering trends" - skills: - - "file:skills/hackernews-api.md" - - "agent:claude-code" - timeout_secs: 600 -``` - -```bash -voidbox run --file hackernews_agent.yaml -``` - --- ## Documentation diff --git a/nfpm.yaml b/nfpm.yaml index 87f2b66..9fceb53 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -20,11 +20,13 @@ vendor: "the-void-ia" section: "devel" contents: + # Binary - src: dist/voidbox dst: /usr/bin/voidbox file_info: mode: 0755 + # Kernel + initramfs - src: dist/vmlinuz dst: /usr/lib/voidbox/vmlinuz file_info: @@ -35,6 +37,34 @@ contents: file_info: mode: 0644 + # FHS directories (created empty by the package) + - dst: /etc/voidbox + type: dir + file_info: + mode: 0755 + + - dst: /var/lib/voidbox + type: dir + file_info: + mode: 0755 + + - dst: /var/log/voidbox + type: dir + file_info: + mode: 0755 + + - dst: /usr/share/voidbox + type: dir + file_info: + mode: 0755 + + # Optional ASCII logo for TUI (packaged from assets/ if present) + # Uncomment when assets/logo/void-box.txt exists: + # - src: assets/logo/void-box.txt + # dst: /usr/share/voidbox/logo.txt + # file_info: + # mode: 0644 + overrides: deb: recommends: diff --git a/scripts/build_claude_rootfs.sh b/scripts/build_claude_rootfs.sh index aa08beb..42c52ac 100755 --- a/scripts/build_claude_rootfs.sh +++ b/scripts/build_claude_rootfs.sh @@ -226,6 +226,9 @@ echo "[claude-rootfs] Creating initramfs at: $OUT_CPIO" FINAL_SIZE="$(du -sh "$OUT_CPIO" | awk '{print $1}')" echo "[claude-rootfs] Done. Initramfs: $OUT_CPIO ($FINAL_SIZE)" +UNCOMPRESSED_BYTES="$(gzip -dc "$OUT_CPIO" | wc -c | tr -d ' ')" +UNCOMPRESSED_MB="$(( (UNCOMPRESSED_BYTES + 1048575) / 1048576 ))" +echo "[claude-rootfs] Uncompressed size: ~${UNCOMPRESSED_MB} MB — guest RAM must be larger (e.g. voidbox snapshot create --memory 512)." echo "" echo "Usage:" echo " ANTHROPIC_API_KEY=sk-ant-... \\" diff --git a/scripts/lib/guest_macos.sh b/scripts/lib/guest_macos.sh index 965b774..cdfbe14 100755 --- a/scripts/lib/guest_macos.sh +++ b/scripts/lib/guest_macos.sh @@ -142,6 +142,36 @@ install_kernel_modules_macos() { echo "[void-box] Downloading Ubuntu ARM64 kernel modules (${kmod_version}-generic)..." if curl -fsSL "$kmod_url" -o "$tmp/modules.deb"; then (cd "$tmp" && ar x modules.deb) + + # Ubuntu ships data.tar, data.tar.zst, or data.tar.xz — not always plain data.tar. + # (Same pattern as scripts/download_kernel.sh and guest_linux.sh.) + local data_tarball + data_tarball=$(find "$tmp" -maxdepth 1 -name 'data.tar*' -print | head -1) + local data_tar="" + if [[ -z "$data_tarball" ]]; then + echo "[void-box] WARNING: no data.tar* in linux-modules .deb — vsock will not work" + else + case "$data_tarball" in + *.zst) + data_tar="$tmp/data-extracted.tar" + if ! zstd -d "$data_tarball" -o "$data_tar" --force -q 2>/dev/null; then + echo "[void-box] WARNING: zstd decompress of data tarball failed (install zstd?)" + data_tar="" + fi + ;; + *.xz) + data_tar="$tmp/data-extracted.tar" + if ! xz -dc "$data_tarball" >"$data_tar" 2>/dev/null; then + echo "[void-box] WARNING: xz decompress of data tarball failed" + data_tar="" + fi + ;; + *) + data_tar="$data_tarball" + ;; + esac + fi + local vsock_modules=( "lib/modules/${kmod_version}-generic/kernel/net/vmw_vsock/vsock.ko.zst" "lib/modules/${kmod_version}-generic/kernel/net/vmw_vsock/vmw_vsock_virtio_transport_common.ko.zst" @@ -153,17 +183,25 @@ install_kernel_modules_macos() { local overlay_modules=( "lib/modules/${kmod_version}-generic/kernel/fs/overlayfs/overlay.ko.zst" ) - for mod_path in "${vsock_modules[@]}" "${virtiofs_modules[@]}" "${overlay_modules[@]}"; do - local mod_name - mod_name=$(basename "$mod_path" .zst) - tar xf "$tmp/data.tar" -C "$tmp" "./$mod_path" 2>/dev/null || true - if [[ -f "$tmp/$mod_path" ]]; then - zstd -d "$tmp/$mod_path" -o "$dest/$mod_name" --force -q - echo "[void-box] Installed kernel module: $mod_name" - else - echo "[void-box] WARNING: $mod_name not found in modules package" - fi - done + + if [[ -n "$data_tar" && -f "$data_tar" ]]; then + for mod_path in "${vsock_modules[@]}" "${virtiofs_modules[@]}" "${overlay_modules[@]}"; do + local mod_name + mod_name=$(basename "$mod_path" .zst) + tar xf "$data_tar" -C "$tmp" "./$mod_path" 2>/dev/null || true + if [[ -f "$tmp/$mod_path" ]]; then + zstd -d "$tmp/$mod_path" -o "$dest/$mod_name" --force -q + echo "[void-box] Installed kernel module: $mod_name" + else + echo "[void-box] WARNING: $mod_name not found in modules package" + fi + done + fi + + if [[ ! -f "$dest/vsock.ko" ]]; then + echo "[void-box] ERROR: vsock.ko missing under $dest — host↔guest vsock will not work." + echo "[void-box] Fix: ensure VOID_BOX_KMOD_VERSION/VOID_BOX_KMOD_UPLOAD match scripts/download_kernel.sh KERNEL_VER/KERNEL_UPLOAD, then rebuild initramfs." + fi else echo "[void-box] WARNING: failed to download kernel modules -- vsock may not work" fi diff --git a/site/docs/cli-tui/index.html b/site/docs/cli-tui/index.html index 2d0583e..e56b196 100644 --- a/site/docs/cli-tui/index.html +++ b/site/docs/cli-tui/index.html @@ -42,45 +42,73 @@

CLI / TUI

Current Interface Surface

-

The CLI provides run/validate/status/log flows. The TUI layers an interactive command mode on top of the daemon HTTP endpoints. Both communicate with the same daemon process.

+

Local (no HTTP daemon): voidbox run --file … executes a spec in-process on the host. Validation, inspection, snapshot file management, and config also run locally.

+

Daemon: voidbox serve starts the HTTP API (default bind 127.0.0.1:43100). Remote-only commands status, logs, and tui talk to that server. Set VOIDBOX_DAEMON_URL or use --daemon to override the default http://127.0.0.1:43100.

+

The TUI is an interactive client over the same HTTP API as those remote commands.

-

CLI Commands

+

CLI — local commands

+

These do not require voidbox serve.

- - - - - - - - - + + + + + + + + + + +
CommandDescription
voidbox serveStart the daemon HTTP server. All other commands (except validate) require the daemon to be running.
voidbox run --file <spec>Execute a spec file (agent, pipeline, or workflow). Submits the run to the daemon and streams events to stdout.
voidbox validate --file <spec>Validate a spec file without running it. Checks schema, skill references, and sandbox configuration for errors.
voidbox statusShow daemon status and active runs. Displays run IDs, current stage, and elapsed time.
voidbox logs <run-id>Stream logs for a specific run. Follows the event stream until the run completes or is cancelled.
voidbox tuiLaunch the interactive TUI interface. Connects to the daemon and provides a command prompt for managing runs.
voidbox snapshot create --config-hash <hash>Create a snapshot from a running or stopped VM, keyed by its configuration hash.
voidbox snapshot listList all stored snapshots with their hash prefixes, sizes, and creation timestamps.
voidbox snapshot delete <hash-prefix>Delete a snapshot by its hash prefix. Removes the state file and memory dumps.
voidbox run --file <spec>Run a spec (agent, pipeline, or workflow) in-process. Optional --input. Output respects global --output (human or JSON).
voidbox validate --file <spec>Validate a spec without executing it.
voidbox inspect --file <spec>Validate and print resolved sandbox-related configuration.
voidbox skills --file <spec>List skills declared in the spec.
voidbox snapshot create --kernel <path>Create a base snapshot after a cold boot (optional --initramfs, --memory, --vcpus; --diff for a delta on top of an existing base). Configuration hash is derived from kernel/initramfs/memory/vcpus — there is no --config-hash flag.
voidbox snapshot listList stored snapshots (hash prefixes and metadata).
voidbox snapshot delete <hash-prefix>Delete a snapshot by hash prefix.
voidbox config initWrite a template config to ~/.config/voidbox/config.yaml (XDG paths apply).
voidbox config validateValidate and print the merged CLI configuration.
voidbox versionPrint version information.
voidbox exec …Legacy sandbox exec (deprecated); prefer voidbox run --file ….
+
+

CLI — daemon and remote-only commands

+

Start the server with voidbox serve (optional --listen, default 127.0.0.1:43100). Then:

+ + + + + + + + + +
CommandDescription
voidbox status --run-id <id>Fetch JSON status for one run. Optional --daemon <url>.
voidbox logs --run-id <id>Fetch the run’s events payload as JSON (single HTTP GET). Optional --daemon. For live updates, poll the API or use a client that reads /v1/runs/:id/events repeatedly.
voidbox tuiInteractive TUI; optional --file to start a run via the daemon, --session, --daemon, --logo-ascii.
+
+ +
+

Global options and configuration

+

On any subcommand: --output human|json, --no-banner, --log-level (or env VOIDBOX_LOG_LEVEL). Log level and paths also merge from ~/.config/voidbox/config.yaml, /etc/voidbox/config.yaml, and VOIDBOX_CONFIG; see voidbox config validate.

+
+

Daemon API Endpoints

-

The daemon exposes an HTTP API used by both the CLI and TUI. These endpoints are also available for direct integration:

+

The daemon exposes JSON over HTTP. Default base URL http://127.0.0.1:43100. Selected routes:

- - - - - - + + + + + + + +
MethodPathDescription
POST/v1/runsCreate a new run. Accepts a spec payload and returns a run ID.
GET/v1/runs/:idGet run status. Returns current state, stage progress, and timing information.
GET/v1/runs/:id/eventsStream run events via Server-Sent Events (SSE). Provides real-time progress updates.
POST/v1/runs/:id/cancelCancel a running run. Sends SIGKILL to the guest process and tears down the VM.
POST/v1/sessions/:id/messagesSend a message to an active session. Used for interactive/conversational workflows.
GET/v1/sessions/:id/messagesGet session messages. Returns the full message history for a session.
GET/v1/healthHealth check and persistence backend name.
POST/v1/runsCreate a run. JSON body includes file, optional input, snapshot, etc. Returns run_id.
GET/v1/runsList runs. Optional query ?state=active or terminal.
GET/v1/runs/:idGet run status and metadata.
GET/v1/runs/:id/eventsGet run events as a JSON array (optional query from_event_id).
POST/v1/runs/:id/cancelCancel a run.
POST/v1/sessions/:id/messagesAppend a session message.
GET/v1/sessions/:id/messagesGet session message history.
+

Additional routes include run stages, telemetry, and stage output files under /v1/runs/… — see the daemon implementation in the repository.

@@ -105,7 +133,7 @@

TUI Commands

Known UX Gap

Minimal but Functional

-

The current TUI is functional but minimal: polling-oriented and plain text. A richer panel-based, live-streaming UX is planned and can be layered on top of the existing SSE event streaming APIs without changes to the daemon.

+

The current TUI is functional but minimal: polling-oriented and plain text. A richer panel-based, live-streaming UX is planned and can be layered on top of the existing /v1/runs/:id/events JSON APIs without changes to the daemon.

diff --git a/site/docs/snapshots/index.html b/site/docs/snapshots/index.html index 6891ce3..31301e2 100644 --- a/site/docs/snapshots/index.html +++ b/site/docs/snapshots/index.html @@ -97,8 +97,11 @@

Rust API

CLI Commands

Shell
-
# Create a snapshot from a running VM
-voidbox snapshot create --config-hash <hash>
+
# Create a base snapshot after cold boot (kernel required; initramfs optional)
+voidbox snapshot create --kernel /path/to/vmlinuz --initramfs /path/to/initramfs.cpio.gz
+
+# Optional: layered diff snapshot once a base exists for the same config hash
+voidbox snapshot create --kernel /path/to/vmlinuz --initramfs /path/to/initramfs.cpio.gz --diff
 
 # List stored snapshots
 voidbox snapshot list
@@ -115,8 +118,8 @@ 

CLI Commands

Daemon API

code-run
-
# POST /runs with snapshot override
-curl -X POST http://localhost:8080/runs \
+
# POST /v1/runs with snapshot override (daemon default: 127.0.0.1:43100)
+curl -X POST http://127.0.0.1:43100/v1/runs \
   -H 'Content-Type: application/json' \
   -d '{"file": "workflow.yaml", "snapshot": "abc123def456"}'
diff --git a/src/backend/vz/backend.rs b/src/backend/vz/backend.rs index 9a38b6d..3d735a7 100644 --- a/src/backend/vz/backend.rs +++ b/src/backend/vz/backend.rs @@ -116,6 +116,27 @@ impl Default for VzBackend { } } +/// Format a Virtualization.framework [`NSError`] with domain, code, and any +/// extra keys Apple populates (`localizedFailureReason`, etc.). The generic +/// `localizedDescription` alone is often useless (e.g. "Internal Virtualization error"). +fn format_vz_ns_error(err: *mut objc2_foundation::NSError) -> String { + if err.is_null() { + return "(null NSError)".to_string(); + } + let e = unsafe { &*err }; + let domain = e.domain().to_string(); + let code = e.code(); + let desc = e.localizedDescription().to_string(); + let mut out = format!("{desc} (domain={domain}, code={code})"); + if let Some(reason) = e.localizedFailureReason() { + out.push_str(&format!("; failure_reason={reason}")); + } + if let Some(sugg) = e.localizedRecoverySuggestion() { + out.push_str(&format!("; recovery_suggestion={sugg}")); + } + out +} + /// Dispatch a VZ completion-handler operation and wait for the result. /// /// Shared helper for pause/resume/save/restore — all follow the same @@ -142,8 +163,7 @@ where let result = if err.is_null() { Ok(()) } else { - let desc = unsafe { &*err }.localizedDescription().to_string(); - Err(desc) + Err(format_vz_ns_error(err)) }; if let Some(tx) = tx.lock().unwrap().take() { let _ = tx.send(result); @@ -386,7 +406,7 @@ impl VzBackend { move |connection: *mut VZVirtioSocketConnection, err: *mut objc2_foundation::NSError| { if !err.is_null() { - let desc = unsafe { &*err }.localizedDescription().to_string(); + let desc = format_vz_ns_error(err); debug!("VZ vsock connectToPort: error = {}", desc); let _ = tx.send(Err(desc)); return; @@ -501,8 +521,7 @@ impl VzBackend { let result = if err.is_null() { Ok(()) } else { - let desc = unsafe { &*err }.localizedDescription().to_string(); - Err(desc) + Err(format_vz_ns_error(err)) }; if let Some(tx) = tx.lock().unwrap().take() { let _ = tx.send(result); @@ -603,8 +622,7 @@ impl VmmBackend for VzBackend { let result = if err.is_null() { Ok(()) } else { - let desc = unsafe { &*err }.localizedDescription().to_string(); - Err(desc) + Err(format_vz_ns_error(err)) }; if let Some(tx) = tx.lock().unwrap().take() { let _ = tx.send(result); diff --git a/src/bin/voidbox.rs b/src/bin/voidbox.rs deleted file mode 100644 index 10ae36b..0000000 --- a/src/bin/voidbox.rs +++ /dev/null @@ -1,844 +0,0 @@ -use std::fs; -use std::io::{self, IsTerminal, Write}; -use std::net::SocketAddr; -use std::path::PathBuf; - -use void_box::daemon; -use void_box::runtime::run_file; -use void_box::spec::{load_spec, validate_spec}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .init(); - - let args: Vec = std::env::args().collect(); - - if args.len() < 2 { - print_usage(); - std::process::exit(1); - } - - match args[1].as_str() { - "serve" => cmd_serve(&args[2..]).await?, - "run" => cmd_run(&args[2..]).await?, - "validate" => cmd_validate(&args[2..])?, - "status" => cmd_status(&args[2..]).await?, - "logs" => cmd_logs(&args[2..]).await?, - "tui" => cmd_tui(&args[2..]).await?, - "snapshot" => cmd_snapshot(&args[2..]).await?, - "exec" => cmd_legacy_exec(&args[2..]).await?, - "workflow" => cmd_legacy_workflow(&args[2..]).await?, - "version" | "--version" | "-V" => println!("voidbox {}", env!("CARGO_PKG_VERSION")), - "help" | "--help" | "-h" => print_usage(), - _ => { - eprintln!("unknown command: {}", args[1]); - print_usage(); - std::process::exit(1); - } - } - - Ok(()) -} - -async fn cmd_serve(args: &[String]) -> Result<(), Box> { - let mut listen = "127.0.0.1:43100".to_string(); - if let Some(v) = arg_value(args, "--listen") { - listen = v; - } - let addr: SocketAddr = listen.parse()?; - daemon::serve(addr).await -} - -async fn cmd_run(args: &[String]) -> Result<(), Box> { - let file = arg_value(args, "--file") - .map(PathBuf::from) - .ok_or("run requires --file ")?; - let input = arg_value(args, "--input"); - - let spec = load_spec(&file)?; - print_startup_banner(&spec.sandbox); - - let report = run_file(&file, input, None, None, None, None, None).await?; - println!("name: {}", report.name); - println!("kind: {}", report.kind); - println!("success: {}", report.success); - println!("stages: {}", report.stages); - println!("cost_usd: {:.6}", report.total_cost_usd); - println!( - "tokens: {} in / {} out", - report.input_tokens, report.output_tokens - ); - println!("output:\n{}", report.output); - Ok(()) -} - -fn cmd_validate(args: &[String]) -> Result<(), Box> { - let file = arg_value(args, "--file") - .map(PathBuf::from) - .ok_or("validate requires --file ")?; - - let spec = load_spec(&file)?; - validate_spec(&spec)?; - println!( - "valid: {} (kind={:?}, api_version={})", - file.display(), - spec.kind, - spec.api_version - ); - Ok(()) -} - -async fn cmd_status(args: &[String]) -> Result<(), Box> { - let run_id = arg_value(args, "--run-id").ok_or("status requires --run-id ")?; - let daemon_url = arg_value(args, "--daemon").unwrap_or_else(|| "http://127.0.0.1:43100".into()); - - let url = format!("{}/v1/runs/{}", daemon_url.trim_end_matches('/'), run_id); - let body = reqwest::get(url).await?.text().await?; - println!("{}", pretty_json(&body)); - Ok(()) -} - -async fn cmd_logs(args: &[String]) -> Result<(), Box> { - let run_id = arg_value(args, "--run-id").ok_or("logs requires --run-id ")?; - let daemon_url = arg_value(args, "--daemon").unwrap_or_else(|| "http://127.0.0.1:43100".into()); - - let url = format!( - "{}/v1/runs/{}/events", - daemon_url.trim_end_matches('/'), - run_id - ); - let body = reqwest::get(url).await?.text().await?; - println!("{}", pretty_json(&body)); - Ok(()) -} - -async fn cmd_tui(args: &[String]) -> Result<(), Box> { - let daemon_url = arg_value(args, "--daemon").unwrap_or_else(|| "http://127.0.0.1:43100".into()); - let session_id = arg_value(args, "--session").unwrap_or_else(|| "default".into()); - - let mut current_run: Option = None; - let mut staged_input: Option = None; - - if let Some(file) = arg_value(args, "--file") { - let run = create_remote_run(&daemon_url, &file, None).await?; - println!("[tui] started {}", run); - let _ = append_remote_message( - &daemon_url, - &session_id, - "assistant", - &format!("started run {}", run), - ) - .await; - current_run = Some(run); - } - - print_logo_header(args); - println!("voidbox tui"); - println!( - "commands: /run , /input , /status, /logs, /cancel, /history, /help, /quit" - ); - - loop { - print!("> "); - io::stdout().flush()?; - - let mut line = String::new(); - if io::stdin().read_line(&mut line)? == 0 { - break; - } - - let line = line.trim(); - if line.is_empty() { - continue; - } - - let _ = append_remote_message(&daemon_url, &session_id, "user", line).await; - - if line == "/quit" || line == "/exit" { - break; - } - - if line == "/help" { - println!("/run "); - println!("/input "); - println!("/status"); - println!("/logs"); - println!("/cancel"); - println!("/history"); - println!("/quit"); - continue; - } - - if let Some(file) = line.strip_prefix("/run ") { - let run = create_remote_run(&daemon_url, file.trim(), staged_input.take()).await?; - println!("[tui] started {}", run); - let _ = append_remote_message( - &daemon_url, - &session_id, - "assistant", - &format!("started run {}", run), - ) - .await; - current_run = Some(run); - continue; - } - - if let Some(text) = line.strip_prefix("/input ") { - staged_input = Some(text.to_string()); - println!("[tui] staged input updated"); - let _ = append_remote_message( - &daemon_url, - &session_id, - "assistant", - "staged input updated", - ) - .await; - continue; - } - - if line == "/status" { - if let Some(run_id) = ¤t_run { - let url = format!("{}/v1/runs/{}", daemon_url.trim_end_matches('/'), run_id); - let body = reqwest::get(url).await?.text().await?; - println!("{}", pretty_json(&body)); - let _ = append_remote_message(&daemon_url, &session_id, "assistant", &body).await; - } else { - println!("[tui] no active run"); - } - continue; - } - - if line == "/logs" { - if let Some(run_id) = ¤t_run { - let url = format!( - "{}/v1/runs/{}/events", - daemon_url.trim_end_matches('/'), - run_id - ); - let body = reqwest::get(url).await?.text().await?; - println!("{}", pretty_json(&body)); - let _ = append_remote_message(&daemon_url, &session_id, "assistant", &body).await; - } else { - println!("[tui] no active run"); - } - continue; - } - - if line == "/cancel" { - if let Some(run_id) = ¤t_run { - let url = format!( - "{}/v1/runs/{}/cancel", - daemon_url.trim_end_matches('/'), - run_id - ); - let body = reqwest::Client::new() - .post(url) - .body("{}") - .send() - .await? - .text() - .await?; - println!("{}", pretty_json(&body)); - let _ = append_remote_message(&daemon_url, &session_id, "assistant", &body).await; - } else { - println!("[tui] no active run"); - } - continue; - } - - if line == "/history" { - let body = get_remote_messages(&daemon_url, &session_id).await?; - println!("{}", pretty_json(&body)); - continue; - } - - println!("assistant: use /commands (try /help)"); - let _ = append_remote_message( - &daemon_url, - &session_id, - "assistant", - "use /commands (try /help)", - ) - .await; - } - - Ok(()) -} - -async fn create_remote_run( - daemon_url: &str, - file: &str, - input: Option, -) -> Result> { - let url = format!("{}/v1/runs", daemon_url.trim_end_matches('/')); - let body = serde_json::json!({ "file": file, "input": input }).to_string(); - let resp = reqwest::Client::new() - .post(url) - .header("content-type", "application/json") - .body(body) - .send() - .await? - .text() - .await?; - - let value = serde_json::from_str::(&resp)?; - let run_id = value - .get("run_id") - .and_then(serde_json::Value::as_str) - .ok_or("missing run_id in daemon response")?; - - Ok(run_id.to_string()) -} - -async fn append_remote_message( - daemon_url: &str, - session_id: &str, - role: &str, - content: &str, -) -> Result<(), Box> { - let url = format!( - "{}/v1/sessions/{}/messages", - daemon_url.trim_end_matches('/'), - session_id - ); - let body = serde_json::json!({ - "role": role, - "content": content - }) - .to_string(); - - let _ = reqwest::Client::new() - .post(url) - .header("content-type", "application/json") - .body(body) - .send() - .await?; - - Ok(()) -} - -async fn get_remote_messages( - daemon_url: &str, - session_id: &str, -) -> Result> { - let url = format!( - "{}/v1/sessions/{}/messages", - daemon_url.trim_end_matches('/'), - session_id - ); - let body = reqwest::get(url).await?.text().await?; - Ok(body) -} - -async fn cmd_snapshot(args: &[String]) -> Result<(), Box> { - if args.is_empty() { - eprintln!("usage: voidbox snapshot [options] [--diff]"); - std::process::exit(1); - } - - match args[0].as_str() { - "create" => cmd_snapshot_create(&args[1..]).await, - "list" => cmd_snapshot_list(), - "delete" => cmd_snapshot_delete(&args[1..]), - _ => { - eprintln!("unknown snapshot subcommand: {}", args[0]); - eprintln!("usage: voidbox snapshot [--diff]"); - std::process::exit(1); - } - } -} - -async fn cmd_snapshot_create(args: &[String]) -> Result<(), Box> { - use void_box::snapshot_store; - - let kernel = arg_value(args, "--kernel") - .map(PathBuf::from) - .ok_or("snapshot create requires --kernel ")?; - let initramfs = arg_value(args, "--initramfs").map(PathBuf::from); - let memory_mb: usize = arg_value(args, "--memory") - .unwrap_or_else(|| "128".to_string()) - .parse()?; - let vcpus: usize = arg_value(args, "--vcpus") - .unwrap_or_else(|| "1".to_string()) - .parse()?; - - let is_diff = args.contains(&"--diff".to_string()); - - eprintln!( - "Creating {} snapshot: kernel={}, initramfs={:?}, memory={}MB, vcpus={}", - if is_diff { "diff" } else { "base" }, - kernel.display(), - initramfs.as_ref().map(|p| p.display()), - memory_mb, - vcpus - ); - - // Compute config hash - let config_hash = - snapshot_store::compute_config_hash(&kernel, initramfs.as_deref(), memory_mb, vcpus)?; - eprintln!("Config hash: {}", &config_hash[..16]); - - #[cfg(target_os = "linux")] - { - cmd_snapshot_create_linux( - args, - &kernel, - initramfs.as_ref(), - memory_mb, - vcpus, - is_diff, - &config_hash, - ) - .await - } - - #[cfg(target_os = "macos")] - { - cmd_snapshot_create_macos( - &kernel, - initramfs.as_ref(), - memory_mb, - vcpus, - is_diff, - &config_hash, - ) - .await - } - - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - { - let _ = ( - args, - kernel, - initramfs, - memory_mb, - vcpus, - is_diff, - config_hash, - ); - Err("snapshot create is not supported on this platform".into()) - } -} - -#[cfg(target_os = "linux")] -async fn cmd_snapshot_create_linux( - args: &[String], - kernel: &std::path::Path, - initramfs: Option<&PathBuf>, - memory_mb: usize, - vcpus: usize, - is_diff: bool, - config_hash: &str, -) -> Result<(), Box> { - use void_box::snapshot_store; - use void_box::vmm::config::VoidBoxConfig; - use void_box::vmm::snapshot; - use void_box::MicroVm; - - let _ = args; - - if is_diff { - // --- Diff snapshot flow --- - let base_dir = snapshot_store::snapshot_dir_for_hash(config_hash); - if !base_dir.join("state.bin").exists() { - eprintln!("No base snapshot found. Create one first:"); - eprintln!( - " voidbox snapshot create --kernel {} {}--memory {} --vcpus {}", - kernel.display(), - initramfs - .map(|p| format!("--initramfs {} ", p.display())) - .unwrap_or_default(), - memory_mb, - vcpus - ); - std::process::exit(1); - } - - let diff_dir_name = format!("{}-diff", &config_hash[..16]); - let diff_dir = snapshot_store::default_snapshot_dir().join(&diff_dir_name); - fs::create_dir_all(&diff_dir)?; - - if diff_dir.join("state.bin").exists() { - eprintln!("Diff snapshot already exists at {}", diff_dir.display()); - eprintln!( - "Delete it first with: voidbox snapshot delete {}-diff", - &config_hash[..16] - ); - std::process::exit(1); - } - - let start = std::time::Instant::now(); - eprintln!("Restoring VM from base snapshot..."); - let vm = MicroVm::from_snapshot(&base_dir).await?; - let restore_ms = start.elapsed().as_millis(); - eprintln!("VM restored in {}ms", restore_ms); - - eprintln!("Enabling dirty page tracking..."); - vm.enable_dirty_tracking()?; - - eprintln!("Waiting for guest-agent readiness..."); - let output = vm.exec("echo", &["snapshot-ready"]).await?; - if !output.success() { - return Err(format!("Guest-agent not ready: {}", output.stderr_str()).into()); - } - eprintln!( - "Guest-agent ready ({}ms total)", - start.elapsed().as_millis() - ); - - let snap_config = snapshot::SnapshotConfig { - memory_mb, - vcpus, - cid: vm.cid(), - vsock_mmio_base: 0xd080_0000, - network: VoidBoxConfig::new().network, - }; - - let snap_dir = vm - .snapshot_diff( - &diff_dir, - config_hash.to_string(), - snap_config, - config_hash.to_string(), - ) - .await?; - let total_ms = start.elapsed().as_millis(); - - let diff_mem_size = fs::metadata(snapshot::VmSnapshot::diff_memory_path(&snap_dir)) - .map(|m| m.len()) - .unwrap_or(0); - let base_mem_size = fs::metadata(snapshot::VmSnapshot::memory_path(&base_dir)) - .map(|m| m.len()) - .unwrap_or(1); - - let savings = if base_mem_size > 0 { - 100.0 - (diff_mem_size as f64 / base_mem_size as f64 * 100.0) - } else { - 0.0 - }; - - eprintln!("Diff snapshot created successfully:"); - eprintln!(" Hash: {}", &config_hash[..16]); - eprintln!(" Path: {}", snap_dir.display()); - eprintln!(" Duration: {}ms", total_ms); - eprintln!( - " Diff mem: {} KB ({:.1}% savings vs base)", - diff_mem_size / 1024, - savings - ); - } else { - // --- Base snapshot flow --- - let snapshot_dir = snapshot_store::snapshot_dir_for_hash(config_hash); - fs::create_dir_all(&snapshot_dir)?; - - if snapshot_dir.join("state.bin").exists() { - eprintln!("Snapshot already exists at {}", snapshot_dir.display()); - eprintln!( - "Delete it first with: voidbox snapshot delete {}", - &config_hash[..16] - ); - std::process::exit(1); - } - - let mut config = VoidBoxConfig::new() - .kernel(kernel) - .memory_mb(memory_mb) - .vcpus(vcpus) - .enable_vsock(true) - .vsock_backend(void_box::vmm::config::VsockBackendType::Userspace); - if let Some(initramfs) = initramfs { - config = config.initramfs(initramfs); - } - - let start = std::time::Instant::now(); - eprintln!("Booting VM..."); - let vm = MicroVm::new(config.clone()).await?; - let boot_ms = start.elapsed().as_millis(); - eprintln!("VM booted in {}ms, waiting for guest-agent...", boot_ms); - - let output = vm.exec("echo", &["snapshot-ready"]).await?; - if !output.success() { - return Err(format!("Guest-agent not ready: {}", output.stderr_str()).into()); - } - eprintln!( - "Guest-agent ready ({}ms total)", - start.elapsed().as_millis() - ); - - let snap_config = snapshot::SnapshotConfig { - memory_mb, - vcpus, - cid: vm.cid(), - vsock_mmio_base: 0xd080_0000, - network: config.network, - }; - - let snap_dir = vm - .snapshot(&snapshot_dir, config_hash.to_string(), snap_config) - .await?; - let total_ms = start.elapsed().as_millis(); - - eprintln!("Snapshot created successfully:"); - eprintln!(" Hash: {}", &config_hash[..16]); - eprintln!(" Path: {}", snap_dir.display()); - eprintln!(" Duration: {}ms", total_ms); - - let mem_size = fs::metadata(snapshot::VmSnapshot::memory_path(&snap_dir)) - .map(|m| m.len()) - .unwrap_or(0); - eprintln!(" Memory: {} MB", mem_size / (1024 * 1024)); - } - - Ok(()) -} - -#[cfg(target_os = "macos")] -async fn cmd_snapshot_create_macos( - kernel: &std::path::Path, - initramfs: Option<&PathBuf>, - memory_mb: usize, - vcpus: usize, - is_diff: bool, - config_hash: &str, -) -> Result<(), Box> { - use void_box::backend::vz::VzBackend; - use void_box::backend::{BackendConfig, VmmBackend}; - use void_box::snapshot_store; - - if is_diff { - eprintln!("Diff snapshots are not supported on macOS (VZ)."); - std::process::exit(1); - } - - let snapshot_dir = snapshot_store::snapshot_dir_for_hash(config_hash); - fs::create_dir_all(&snapshot_dir)?; - - if snapshot_store::snapshot_exists(&snapshot_dir) { - eprintln!("Snapshot already exists at {}", snapshot_dir.display()); - eprintln!( - "Delete it first with: voidbox snapshot delete {}", - &config_hash[..16] - ); - std::process::exit(1); - } - - let mut config = BackendConfig::minimal(kernel, memory_mb, vcpus); - if let Some(initramfs) = initramfs { - config = config.initramfs(initramfs); - } - - let start = std::time::Instant::now(); - eprintln!("Booting VM via Virtualization.framework..."); - let mut backend = VzBackend::new(); - backend.start(config).await?; - let boot_ms = start.elapsed().as_millis(); - eprintln!("VM booted in {}ms, waiting for guest-agent...", boot_ms); - - let output = backend - .exec("echo", &["snapshot-ready"], &[], &[], None, None) - .await?; - if !output.success() { - return Err(format!("Guest-agent not ready: {}", output.stderr_str()).into()); - } - eprintln!( - "Guest-agent ready ({}ms total)", - start.elapsed().as_millis() - ); - - eprintln!("Pausing VM and creating snapshot..."); - backend.pause()?; - backend.create_snapshot(&snapshot_dir)?; - let total_ms = start.elapsed().as_millis(); - - eprintln!("Snapshot created successfully:"); - eprintln!(" Hash: {}", &config_hash[..16]); - eprintln!(" Path: {}", snapshot_dir.display()); - eprintln!(" Duration: {}ms", total_ms); - - backend.stop().await?; - Ok(()) -} - -fn cmd_snapshot_list() -> Result<(), Box> { - use void_box::snapshot_store; - - let snapshots = snapshot_store::list_snapshots()?; - if snapshots.is_empty() { - println!("No snapshots found."); - return Ok(()); - } - - println!( - "{:<18} {:<8} {:<8} {:<10} PATH", - "HASH", "MEM(MB)", "VCPUS", "TYPE" - ); - for info in &snapshots { - let type_str = match info.snapshot_type { - snapshot_store::SnapshotType::Base => "base", - snapshot_store::SnapshotType::Diff => "diff", - }; - println!( - "{:<18} {:<8} {:<8} {:<10} {}", - &info.config_hash[..16.min(info.config_hash.len())], - info.memory_mb, - info.vcpus, - type_str, - info.dir.display(), - ); - } - Ok(()) -} - -fn cmd_snapshot_delete(args: &[String]) -> Result<(), Box> { - use void_box::snapshot_store; - - let hash_prefix = args - .first() - .ok_or("snapshot delete requires ")?; - - if snapshot_store::delete_snapshot(hash_prefix)? { - println!("Deleted snapshot matching '{}'", hash_prefix); - } else { - eprintln!("No snapshot found matching '{}'", hash_prefix); - std::process::exit(1); - } - Ok(()) -} - -async fn cmd_legacy_exec(args: &[String]) -> Result<(), Box> { - if args.is_empty() { - eprintln!("usage: voidbox exec [args...]"); - std::process::exit(1); - } - - eprintln!("[legacy] `exec` is deprecated; use `voidbox run --file ...`"); - - let program = &args[0]; - let cmd_args = args[1..].iter().map(String::as_str).collect::>(); - - let sandbox = if let Some(kernel) = std::env::var_os("VOID_BOX_KERNEL") { - let mut b = void_box::sandbox::Sandbox::local() - .kernel(kernel) - .network(true); - if let Some(initramfs) = std::env::var_os("VOID_BOX_INITRAMFS") { - b = b.initramfs(initramfs); - } - b.build() - .unwrap_or_else(|_| void_box::sandbox::Sandbox::mock().build().unwrap()) - } else { - void_box::sandbox::Sandbox::mock().build()? - }; - - let out = sandbox.exec(program, &cmd_args).await?; - print!("{}", out.stdout_str()); - eprint!("{}", out.stderr_str()); - std::process::exit(out.exit_code); -} - -async fn cmd_legacy_workflow(args: &[String]) -> Result<(), Box> { - eprintln!("[legacy] `workflow` is deprecated; use `voidbox run --file` with kind=workflow"); - - let action = args.first().map(String::as_str).unwrap_or("plan"); - let dir = args.get(1).map(String::as_str).unwrap_or("/workspace"); - - let sandbox = void_box::sandbox::Sandbox::mock().build()?; - - let out = match action { - "plan" => sandbox.exec("claude-code", &["plan", dir]).await?, - "apply" => sandbox.exec("claude-code", &["apply", dir]).await?, - _ => { - return Err(format!("unknown workflow action '{}', expected plan|apply", action).into()) - } - }; - - print!("{}", out.stdout_str()); - eprint!("{}", out.stderr_str()); - Ok(()) -} - -fn arg_value(args: &[String], key: &str) -> Option { - args.windows(2).find(|w| w[0] == key).map(|w| w[1].clone()) -} - -fn pretty_json(raw: &str) -> String { - serde_json::from_str::(raw) - .ok() - .and_then(|v| serde_json::to_string_pretty(&v).ok()) - .unwrap_or_else(|| raw.to_string()) -} - -fn print_usage() { - println!( - r#"voidbox - -USAGE: - voidbox serve [--listen 127.0.0.1:43100] - voidbox run --file [--input ] - voidbox validate --file - voidbox status --run-id [--daemon http://127.0.0.1:43100] - voidbox logs --run-id [--daemon http://127.0.0.1:43100] - voidbox tui [--file ] [--daemon http://127.0.0.1:43100] [--session default] - -SNAPSHOT: - voidbox snapshot create --kernel --initramfs --memory [--vcpus ] - voidbox snapshot list - voidbox snapshot delete - -LEGACY: - voidbox exec [args...] - voidbox workflow [dir] - -NOTES: - - Spec files: JSON (.json) and YAML (.yaml, .yml) are both supported. - - TUI logo file: set VOIDBOX_LOGO_ASCII_PATH or pass --logo-ascii . - - LLM override envs for `run`: VOIDBOX_LLM_PROVIDER, VOIDBOX_LLM_MODEL, VOIDBOX_LLM_BASE_URL, VOIDBOX_LLM_API_KEY_ENV."# - ); -} - -fn print_startup_banner(sandbox: &void_box::spec::SandboxSpec) { - let banner = concat!( - " ██╗ ██╗ ██████╗ ██╗██████╗ ██████╗ ██████╗ ██╗ ██╗\n", - " ██║ ██║██╔═══██╗██║██╔══██╗ ██╔══██╗██╔═══██╗╚██╗██╔╝\n", - " ██║ ██║██║ ██║██║██║ ██║ ██████╔╝██║ ██║ ╚███╔╝\n", - " ╚██╗ ██╔╝██║ ██║██║██║ ██║█████╗ ██╔══██╗██║ ██║ ██╔██╗\n", - " ╚████╔╝ ╚██████╔╝██║██████╔╝╚════╝ ██████╔╝╚██████╔╝██╔╝ ██╗\n", - " ╚═══╝ ╚═════╝ ╚═╝╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝", - ); - let version = env!("CARGO_PKG_VERSION"); - let net = if sandbox.network { "on" } else { "off" }; - let mut summary = format!( - " {}MB RAM · {} vCPUs · network={}", - sandbox.memory_mb, sandbox.vcpus, net - ); - if sandbox.image.is_some() { - summary.push_str(" · oci=yes"); - } - if std::io::stderr().is_terminal() { - eprintln!( - "\x1b[38;5;153m{} v{}\n\n{}\x1b[0m\n", - banner, version, summary - ); - } else { - eprintln!("{} v{}\n\n{}\n", banner, version, summary); - } -} - -fn print_logo_header(args: &[String]) { - let logo_path = arg_value(args, "--logo-ascii") - .or_else(|| std::env::var("VOIDBOX_LOGO_ASCII_PATH").ok()) - .unwrap_or_else(|| "assets/logo/void-box.txt".to_string()); - - if let Ok(text) = fs::read_to_string(&logo_path) { - if !text.trim().is_empty() { - println!("{}", text); - return; - } - } - - println!("⬢ VOID-BOX"); -} diff --git a/src/bin/voidbox/backend.rs b/src/bin/voidbox/backend.rs new file mode 100644 index 0000000..43e9f4d --- /dev/null +++ b/src/bin/voidbox/backend.rs @@ -0,0 +1,461 @@ +use std::path::Path; + +/// Errors from CLI backend operations. +#[derive(Debug, thiserror::Error)] +pub enum BackendError { + #[error("{0}")] + Local(String), + #[error("daemon unreachable at {url}: {detail}")] + DaemonUnreachable { url: String, detail: String }, + #[error("daemon error: {0}")] + DaemonError(String), +} + +/// Result of a `run` operation. +#[derive(Debug, Clone, serde::Serialize)] +pub struct RunResult { + pub name: String, + pub kind: String, + pub success: bool, + pub output: String, + pub stages: usize, + pub total_cost_usd: f64, + pub input_tokens: u64, + pub output_tokens: u64, +} + +impl std::fmt::Display for RunResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "name: {}", self.name)?; + writeln!(f, "kind: {}", self.kind)?; + writeln!(f, "success: {}", self.success)?; + writeln!(f, "stages: {}", self.stages)?; + writeln!(f, "cost_usd: {:.6}", self.total_cost_usd)?; + writeln!( + f, + "tokens: {} in / {} out", + self.input_tokens, self.output_tokens + )?; + write!(f, "output:\n{}", self.output) + } +} + +/// In-process execution: runs specs via `run_file` directly. +pub struct LocalBackend; + +impl LocalBackend { + pub async fn run( + file: &Path, + input: Option, + ) -> std::result::Result { + let report = void_box::runtime::run_file(file, input, None, None, None, None, None) + .await + .map_err(|e| BackendError::Local(e.to_string()))?; + + Ok(RunResult { + name: report.name, + kind: report.kind, + success: report.success, + output: report.output, + stages: report.stages, + total_cost_usd: report.total_cost_usd, + input_tokens: report.input_tokens, + output_tokens: report.output_tokens, + }) + } +} + +/// HTTP client for the daemon: all remote CLI access goes through here. +pub struct RemoteBackend { + pub daemon_url: String, + client: reqwest::Client, +} + +impl RemoteBackend { + pub fn new(daemon_url: String) -> Self { + Self { + daemon_url, + client: reqwest::Client::new(), + } + } + + fn base_url(&self) -> &str { + self.daemon_url.trim_end_matches('/') + } + + async fn get_text(&self, url: &str) -> Result { + self.client + .get(url) + .send() + .await + .map_err(|e| BackendError::DaemonUnreachable { + url: url.to_string(), + detail: e.to_string(), + })? + .text() + .await + .map_err(|e| BackendError::DaemonError(e.to_string())) + } + + /// Parse daemon JSON response bodies; empty body → `null`. + fn parse_json_body(body: &str) -> Result { + let t = body.trim(); + if t.is_empty() { + return Ok(serde_json::Value::Null); + } + serde_json::from_str(t).map_err(|e| BackendError::DaemonError(format!("invalid JSON: {e}"))) + } + + /// `GET /v1/runs/{run_id}/events` — run log stream. + pub async fn logs(&self, run_id: &str) -> Result { + let url = format!("{}/v1/runs/{}/events", self.base_url(), run_id); + let body = self.get_text(&url).await?; + Self::parse_json_body(&body) + } + + /// `GET /v1/runs/{run_id}` — run status. + pub async fn status(&self, run_id: &str) -> Result { + let url = format!("{}/v1/runs/{}", self.base_url(), run_id); + let body = self.get_text(&url).await?; + Self::parse_json_body(&body) + } + + /// `POST /v1/runs` — start a remote run; returns `run_id`. + pub async fn create_run( + &self, + file: &str, + input: Option, + ) -> Result { + let url = format!("{}/v1/runs", self.base_url()); + let body = serde_json::json!({ "file": file, "input": input }).to_string(); + let text = self + .client + .post(&url) + .header("content-type", "application/json") + .body(body) + .send() + .await + .map_err(|e| BackendError::DaemonUnreachable { + url: url.clone(), + detail: e.to_string(), + })? + .text() + .await + .map_err(|e| BackendError::DaemonError(e.to_string()))?; + + let value = serde_json::from_str::(&text) + .map_err(|e| BackendError::DaemonError(format!("invalid JSON from daemon: {e}")))?; + let run_id = value + .get("run_id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| BackendError::DaemonError("missing run_id in daemon response".into()))?; + Ok(run_id.to_string()) + } + + /// `POST /v1/sessions/{session_id}/messages` — append a chat message for the TUI session. + pub async fn append_message( + &self, + session_id: &str, + role: &str, + content: &str, + ) -> Result<(), BackendError> { + let url = format!("{}/v1/sessions/{}/messages", self.base_url(), session_id); + let body = serde_json::json!({ "role": role, "content": content }).to_string(); + self.client + .post(&url) + .header("content-type", "application/json") + .body(body) + .send() + .await + .map_err(|e| BackendError::DaemonUnreachable { + url: url.clone(), + detail: e.to_string(), + })?; + Ok(()) + } + + /// `GET /v1/sessions/{session_id}/messages` — session message history. + pub async fn get_messages(&self, session_id: &str) -> Result { + let url = format!("{}/v1/sessions/{}/messages", self.base_url(), session_id); + let body = self.get_text(&url).await?; + Self::parse_json_body(&body) + } + + /// `POST /v1/runs/{run_id}/cancel` — cancel a run. + pub async fn cancel_run(&self, run_id: &str) -> Result { + let url = format!("{}/v1/runs/{}/cancel", self.base_url(), run_id); + let text = self + .client + .post(&url) + .body("{}") + .send() + .await + .map_err(|e| BackendError::DaemonUnreachable { + url: url.clone(), + detail: e.to_string(), + })? + .text() + .await + .map_err(|e| BackendError::DaemonError(e.to_string()))?; + Self::parse_json_body(&text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::prelude::*; + + // ----------------------------- + // RunResult display + // ----------------------------- + + #[test] + fn run_result_display_format() { + let r = RunResult { + name: "test".into(), + kind: "agent".into(), + success: true, + output: "hello".into(), + stages: 2, + total_cost_usd: 0.123456, + input_tokens: 10, + output_tokens: 20, + }; + + let s = format!("{r}"); + assert!(s.contains("name: test")); + assert!(s.contains("kind: agent")); + assert!(s.contains("success: true")); + assert!(s.contains("stages: 2")); + assert!(s.contains("cost_usd: 0.123456")); + assert!(s.contains("tokens: 10 in / 20 out")); + assert!(s.contains("output:\nhello")); + } + + // ----------------------------- + // base_url normalization + // ----------------------------- + + #[test] + fn base_url_trims_trailing_slash() { + let b = RemoteBackend::new("http://localhost:1234/".into()); + assert_eq!(b.base_url(), "http://localhost:1234"); + } + + // ----------------------------- + // parse_json_body + // ----------------------------- + + #[test] + fn parse_json_body_empty_returns_null() { + let v = RemoteBackend::parse_json_body("").unwrap(); + assert_eq!(v, serde_json::Value::Null); + } + + #[test] + fn parse_json_body_valid_json() { + let v = RemoteBackend::parse_json_body(r#"{"a":1}"#).unwrap(); + assert_eq!(v["a"], 1); + } + + #[test] + fn parse_json_body_invalid_json_errors() { + let err = RemoteBackend::parse_json_body("not-json").unwrap_err(); + match err { + BackendError::DaemonError(msg) => { + assert!(msg.contains("invalid JSON")); + } + _ => panic!("expected DaemonError"), + } + } + + // ----------------------------- + // HTTP: status + // ----------------------------- + + #[tokio::test] + async fn status_success() { + let server = MockServer::start(); + + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/runs/run-1"); + then.status(200).body(r#"{"status":"ok"}"#); + }); + + let backend = RemoteBackend::new(server.base_url()); + let result = backend.status("run-1").await.unwrap(); + + assert_eq!(result["status"], "ok"); + mock.assert(); + } + + #[tokio::test] + async fn status_empty_body_returns_null() { + let server = MockServer::start(); + + server.mock(|when, then| { + when.method(GET).path("/v1/runs/run-1"); + then.status(200).body(""); + }); + + let backend = RemoteBackend::new(server.base_url()); + let result = backend.status("run-1").await.unwrap(); + + assert_eq!(result, serde_json::Value::Null); + } + + // ----------------------------- + // HTTP: logs + // ----------------------------- + + #[tokio::test] + async fn logs_success() { + let server = MockServer::start(); + + server.mock(|when, then| { + when.method(GET).path("/v1/runs/run-1/events"); + then.status(200).body(r#"{"events":[]}"#); + }); + + let backend = RemoteBackend::new(server.base_url()); + let result = backend.logs("run-1").await.unwrap(); + + assert!(result["events"].is_array()); + } + + // ----------------------------- + // create_run + // ----------------------------- + + #[tokio::test] + async fn create_run_success() { + let server = MockServer::start(); + + let mock = server.mock(|when, then| { + when.method(POST) + .path("/v1/runs") + .header("content-type", "application/json"); + then.status(200).body(r#"{"run_id":"abc123"}"#); + }); + + let backend = RemoteBackend::new(server.base_url()); + let run_id = backend.create_run("file.yaml", None).await.unwrap(); + + assert_eq!(run_id, "abc123"); + mock.assert(); + } + + #[tokio::test] + async fn create_run_missing_run_id_errors() { + let server = MockServer::start(); + + server.mock(|when, then| { + when.method(POST).path("/v1/runs"); + then.status(200).body(r#"{}"#); + }); + + let backend = RemoteBackend::new(server.base_url()); + let err = backend.create_run("file.yaml", None).await.unwrap_err(); + + match err { + BackendError::DaemonError(msg) => { + assert!(msg.contains("missing run_id")); + } + _ => panic!("expected DaemonError"), + } + } + + #[tokio::test] + async fn create_run_invalid_json_errors() { + let server = MockServer::start(); + + server.mock(|when, then| { + when.method(POST).path("/v1/runs"); + then.status(200).body("not-json"); + }); + + let backend = RemoteBackend::new(server.base_url()); + let err = backend.create_run("file.yaml", None).await.unwrap_err(); + + match err { + BackendError::DaemonError(msg) => { + assert!(msg.contains("invalid JSON")); + } + _ => panic!("expected DaemonError"), + } + } + + // ----------------------------- + // network failure + // ----------------------------- + + #[tokio::test] + async fn unreachable_daemon_returns_error() { + let backend = RemoteBackend::new("http://127.0.0.1:59999".into()); + + let err = backend.status("run-1").await.unwrap_err(); + + match err { + BackendError::DaemonUnreachable { .. } => {} + _ => panic!("expected DaemonUnreachable"), + } + } + + // ----------------------------- + // cancel_run + // ----------------------------- + + #[tokio::test] + async fn cancel_run_success() { + let server = MockServer::start(); + + server.mock(|when, then| { + when.method(POST).path("/v1/runs/run-1/cancel"); + then.status(200).body(r#"{"ok":true}"#); + }); + + let backend = RemoteBackend::new(server.base_url()); + let result = backend.cancel_run("run-1").await.unwrap(); + + assert_eq!(result["ok"], true); + } + + // ----------------------------- + // append_message + // ----------------------------- + + #[tokio::test] + async fn append_message_success() { + let server = MockServer::start(); + + let mock = server.mock(|when, then| { + when.method(POST).path("/v1/sessions/s1/messages"); + then.status(200); + }); + + let backend = RemoteBackend::new(server.base_url()); + backend.append_message("s1", "user", "hello").await.unwrap(); + + mock.assert(); + } + + // ----------------------------- + // get_messages + // ----------------------------- + + #[tokio::test] + async fn get_messages_success() { + let server = MockServer::start(); + + server.mock(|when, then| { + when.method(GET).path("/v1/sessions/s1/messages"); + then.status(200).body(r#"{"messages":[]}"#); + }); + + let backend = RemoteBackend::new(server.base_url()); + let result = backend.get_messages("s1").await.unwrap(); + + assert!(result["messages"].is_array()); + } +} diff --git a/src/bin/voidbox/banner.rs b/src/bin/voidbox/banner.rs new file mode 100644 index 0000000..9a5f437 --- /dev/null +++ b/src/bin/voidbox/banner.rs @@ -0,0 +1,149 @@ +use std::io::IsTerminal; + +use crate::output::OutputFormat; + +const ASCII_BANNER: &str = concat!( + " ██╗ ██╗ ██████╗ ██╗██████╗ ██████╗ ██████╗ ██╗ ██╗\n", + " ██║ ██║██╔═══██╗██║██╔══██╗ ██╔══██╗██╔═══██╗╚██╗██╔╝\n", + " ██║ ██║██║ ██║██║██║ ██║ ██████╔╝██║ ██║ ╚███╔╝\n", + " ╚██╗ ██╔╝██║ ██║██║██║ ██║█████╗ ██╔══██╗██║ ██║ ██╔██╗\n", + " ╚████╔╝ ╚██████╔╝██║██████╔╝╚════╝ ██████╔╝╚██████╔╝██╔╝ ██╗\n", + " ╚═══╝ ╚═════╝ ╚═╝╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝", +); + +/// Embedded fallback for the TUI logo (same as the run banner). +const EMBEDDED_LOGO: &str = "⬢ VOID-BOX"; + +/// Determine whether the ASCII banner should be shown. +/// +/// Show only when: +/// - subcommand is `run` (caller decides) +/// - output format is human +/// - `--no-banner` was not passed +/// - `VOIDBOX_NO_BANNER` is not set +/// - stderr is a terminal (interactive) +/// - stdin is a terminal (not piped) +pub fn should_show_banner(output: OutputFormat, config_banner: bool) -> bool { + should_show_banner_inner( + output, + config_banner, + std::io::stderr().is_terminal(), + std::io::stdin().is_terminal(), + ) +} + +fn should_show_banner_inner( + output: OutputFormat, + config_banner: bool, + stderr_is_tty: bool, + stdin_is_tty: bool, +) -> bool { + if output == OutputFormat::Json { + return false; + } + if !config_banner { + return false; + } + if !stderr_is_tty { + return false; + } + if !stdin_is_tty { + return false; + } + true +} + +/// Print the startup banner for `run` on stderr. +pub fn print_startup_banner(sandbox: &void_box::spec::SandboxSpec) { + let version = env!("CARGO_PKG_VERSION"); + let net = if sandbox.network { "on" } else { "off" }; + let mut summary = format!( + " {}MB RAM · {} vCPUs · network={}", + sandbox.memory_mb, sandbox.vcpus, net + ); + if sandbox.image.is_some() { + summary.push_str(" · oci=yes"); + } + if std::io::stderr().is_terminal() { + eprintln!( + "\x1b[38;5;153m{} v{}\n\n{}\x1b[0m\n", + ASCII_BANNER, version, summary + ); + } else { + eprintln!("{} v{}\n\n{}\n", ASCII_BANNER, version, summary); + } +} + +/// Resolve and print the TUI logo header. +/// +/// Resolution order: +/// 1. `--logo-ascii` CLI flag +/// 2. `VOIDBOX_LOGO_ASCII_PATH` env +/// 3. `/usr/share/voidbox/logo.txt` (packaged) +/// 4. Embedded fallback +pub fn print_logo_header(logo_cli: Option<&str>) { + let candidates: Vec> = vec![ + logo_cli.map(String::from), + std::env::var("VOIDBOX_LOGO_ASCII_PATH").ok(), + Some("/usr/share/voidbox/logo.txt".into()), + ]; + + for candidate in candidates.into_iter().flatten() { + if let Ok(text) = std::fs::read_to_string(&candidate) { + if !text.trim().is_empty() { + println!("{text}"); + return; + } + } + } + + println!("{EMBEDDED_LOGO}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_output_suppresses_banner() { + assert!(!should_show_banner(OutputFormat::Json, true)); + } + + #[test] + fn no_banner_config_suppresses_banner() { + assert!(!should_show_banner(OutputFormat::Human, false)); + } + + #[test] + fn banner_suppressed_when_either_stdio_not_tty() { + // Do not rely on real TTY detection (IDE/CI may attach a PTY). + assert!(!should_show_banner_inner( + OutputFormat::Human, + true, + false, + true + )); + assert!(!should_show_banner_inner( + OutputFormat::Human, + true, + true, + false + )); + assert!(!should_show_banner_inner( + OutputFormat::Human, + true, + false, + false + )); + } + + #[test] + fn banner_allowed_when_human_and_both_tty() { + assert!(should_show_banner_inner( + OutputFormat::Human, + true, + true, + true + )); + } +} diff --git a/src/bin/voidbox/cli_config.rs b/src/bin/voidbox/cli_config.rs new file mode 100644 index 0000000..3a03bc4 --- /dev/null +++ b/src/bin/voidbox/cli_config.rs @@ -0,0 +1,320 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use void_box::snapshot_store; + +/// Resolved filesystem paths used by the CLI. +#[derive(Debug, Clone)] +pub struct CliPaths { + pub state_dir: PathBuf, + pub log_dir: PathBuf, + pub snapshot_dir: PathBuf, + pub config_dir: PathBuf, +} + +impl CliPaths { + fn from_config(cfg: &VoidboxCliConfig) -> Self { + let base = Self::default_base(); + let home_override = std::env::var("VOIDBOX_HOME").ok().map(PathBuf::from); + + let state_dir = cfg + .paths + .state_dir + .clone() + .or_else(|| home_override.as_ref().map(|h| h.join("state"))) + .unwrap_or_else(|| base.state_dir.clone()); + + let log_dir = cfg + .paths + .log_dir + .clone() + .or_else(|| home_override.as_ref().map(|h| h.join("log"))) + .unwrap_or_else(|| base.log_dir.clone()); + + let snapshot_dir = cfg + .paths + .snapshot_dir + .clone() + .or_else(|| home_override.as_ref().map(|h| h.join("snapshots"))) + .unwrap_or_else(|| base.snapshot_dir.clone()); + + Self { + state_dir, + log_dir, + snapshot_dir, + config_dir: base.config_dir, + } + } + + fn default_base() -> Self { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .expect("HOME environment variable must be set"); + + // XDG Base Directory Specification + // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + + let config_dir = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".config")) + .join("voidbox"); + + let data_dir = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local").join("share")) + .join("voidbox"); + + let state_dir = std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local").join("state")) + .join("voidbox"); + + Self { + state_dir, + log_dir: data_dir.join("log"), + snapshot_dir: snapshot_store::default_snapshot_dir(), + config_dir, + } + } +} + +/// On-disk config file shape (YAML). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VoidboxCliConfig { + #[serde(default)] + pub log_level: Option, + #[serde(default)] + pub daemon_url: Option, + #[serde(default)] + pub banner: Option, + #[serde(default)] + pub paths: PathsConfig, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PathsConfig { + #[serde(default)] + pub state_dir: Option, + #[serde(default)] + pub log_dir: Option, + #[serde(default)] + pub snapshot_dir: Option, + #[serde(default)] + pub kernel: Option, + #[serde(default)] + pub initramfs: Option, +} + +/// Fully resolved configuration after merging all sources. +#[derive(Debug, Clone)] +pub struct ResolvedConfig { + pub log_level: String, + pub daemon_url: String, + pub banner: bool, + pub paths: CliPaths, + pub kernel: Option, + pub initramfs: Option, +} + +impl ResolvedConfig { + pub fn default_daemon_url() -> String { + "http://127.0.0.1:43100".into() + } +} + +/// Load and merge configuration from all sources. +/// +/// Precedence (highest wins): CLI flags → `VOIDBOX_*` env vars → +/// `VOIDBOX_CONFIG` file (merged as an overlay, not a full replacement) → +/// user config → system config → defaults. +pub fn load_and_merge( + cli_log_level: Option<&str>, + cli_daemon_url: Option<&str>, + cli_no_banner: bool, +) -> ResolvedConfig { + let mut merged = VoidboxCliConfig::default(); + + // System config: /etc/voidbox/config.yaml + if let Some(sys) = load_config_file(Path::new("/etc/voidbox/config.yaml")) { + merge_into(&mut merged, &sys); + } + + // User config: ~/.config/voidbox/config.yaml (or XDG equivalent) + let user_config_path = CliPaths::default_base().config_dir.join("config.yaml"); + if let Some(user) = load_config_file(&user_config_path) { + merge_into(&mut merged, &user); + } + + // VOIDBOX_CONFIG: optional extra YAML merged on top of system + user (highest among file sources). + if let Ok(explicit) = std::env::var("VOIDBOX_CONFIG") { + if let Some(cfg) = load_config_file(Path::new(&explicit)) { + merge_into(&mut merged, &cfg); + } + } + + // VOIDBOX_LOG_LEVEL env + if let Ok(level) = std::env::var("VOIDBOX_LOG_LEVEL") { + merged.log_level = Some(level); + } + + // VOIDBOX_DAEMON_URL env + if let Ok(url) = std::env::var("VOIDBOX_DAEMON_URL") { + merged.daemon_url = Some(url); + } + + // VOIDBOX_NO_BANNER env + if std::env::var("VOIDBOX_NO_BANNER") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + { + merged.banner = Some(false); + } + + // CLI flags (highest precedence) + if let Some(level) = cli_log_level { + merged.log_level = Some(level.to_string()); + } + if let Some(url) = cli_daemon_url { + merged.daemon_url = Some(url.to_string()); + } + if cli_no_banner { + merged.banner = Some(false); + } + + let paths = CliPaths::from_config(&merged); + + let log_level = merged + .log_level + .or_else(|| std::env::var("RUST_LOG").ok()) + .unwrap_or_else(|| "info".into()); + + ResolvedConfig { + log_level, + daemon_url: merged + .daemon_url + .unwrap_or_else(ResolvedConfig::default_daemon_url), + banner: merged.banner.unwrap_or(true), + kernel: merged.paths.kernel, + initramfs: merged.paths.initramfs, + paths, + } +} + +fn load_config_file(path: &Path) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + serde_yaml::from_str(&contents).ok() +} + +fn merge_into(base: &mut VoidboxCliConfig, overlay: &VoidboxCliConfig) { + if overlay.log_level.is_some() { + base.log_level.clone_from(&overlay.log_level); + } + if overlay.daemon_url.is_some() { + base.daemon_url.clone_from(&overlay.daemon_url); + } + if overlay.banner.is_some() { + base.banner = overlay.banner; + } + if overlay.paths.state_dir.is_some() { + base.paths.state_dir.clone_from(&overlay.paths.state_dir); + } + if overlay.paths.log_dir.is_some() { + base.paths.log_dir.clone_from(&overlay.paths.log_dir); + } + if overlay.paths.snapshot_dir.is_some() { + base.paths + .snapshot_dir + .clone_from(&overlay.paths.snapshot_dir); + } + if overlay.paths.kernel.is_some() { + base.paths.kernel.clone_from(&overlay.paths.kernel); + } + if overlay.paths.initramfs.is_some() { + base.paths.initramfs.clone_from(&overlay.paths.initramfs); + } +} + +/// Write a template config file to the given path. +pub fn write_template(path: &Path) -> std::io::Result<()> { + let template = VoidboxCliConfig { + log_level: Some("info".into()), + daemon_url: Some(ResolvedConfig::default_daemon_url()), + banner: Some(true), + paths: PathsConfig::default(), + }; + let yaml = serde_yaml::to_string(&template).unwrap_or_default(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, yaml) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn voidbox_config_style_overlay_preserves_unset_fields() { + // Regression: explicit file must merge like other layers, not replace `merged`. + let mut merged = VoidboxCliConfig { + log_level: Some("info".into()), + daemon_url: Some("http://from-user:1".into()), + banner: Some(true), + paths: PathsConfig::default(), + }; + let explicit = VoidboxCliConfig { + daemon_url: Some("http://from-explicit:2".into()), + ..Default::default() + }; + merge_into(&mut merged, &explicit); + assert_eq!(merged.log_level.as_deref(), Some("info")); + assert_eq!(merged.daemon_url.as_deref(), Some("http://from-explicit:2")); + assert_eq!(merged.banner, Some(true)); + } + + #[test] + fn test_merge_overlay_wins() { + let mut base = VoidboxCliConfig { + log_level: Some("info".into()), + daemon_url: Some("http://base:1234".into()), + banner: Some(true), + paths: PathsConfig::default(), + }; + let overlay = VoidboxCliConfig { + log_level: Some("debug".into()), + daemon_url: None, + banner: Some(false), + paths: PathsConfig { + snapshot_dir: Some(PathBuf::from("/custom/snapshots")), + ..Default::default() + }, + }; + merge_into(&mut base, &overlay); + assert_eq!(base.log_level.as_deref(), Some("debug")); + assert_eq!(base.daemon_url.as_deref(), Some("http://base:1234")); + assert_eq!(base.banner, Some(false)); + assert_eq!( + base.paths.snapshot_dir, + Some(PathBuf::from("/custom/snapshots")) + ); + } + + #[test] + fn test_default_paths_are_set() { + let paths = CliPaths::default_base(); + assert!(!paths.state_dir.as_os_str().is_empty()); + assert!(!paths.log_dir.as_os_str().is_empty()); + assert!(!paths.snapshot_dir.as_os_str().is_empty()); + } + + #[test] + fn test_write_template_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + write_template(&path).unwrap(); + let loaded = load_config_file(&path).unwrap(); + assert_eq!(loaded.log_level.as_deref(), Some("info")); + assert!(loaded.banner.unwrap()); + } +} diff --git a/src/bin/voidbox/main.rs b/src/bin/voidbox/main.rs new file mode 100644 index 0000000..57509f0 --- /dev/null +++ b/src/bin/voidbox/main.rs @@ -0,0 +1,1578 @@ +mod backend; +mod banner; +mod cli_config; +mod output; +mod snapshot; + +use std::io::{self, Write}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use clap::{Parser, Subcommand}; + +use backend::{LocalBackend, RemoteBackend}; +use cli_config::ResolvedConfig; +use output::{format_json_value, print_json_value, OutputFormat}; + +// --------------------------------------------------------------------------- +// CLI definition +// --------------------------------------------------------------------------- + +/// VoidBox — composable workflow sandbox with micro-VMs and native observability. +#[derive(Parser, Debug)] +#[command(name = "voidbox", version, about, long_about = None)] +struct Cli { + /// Output format: human (default) or json. + #[arg(long, global = true, default_value = "human")] + output: OutputFormat, + + /// Suppress the ASCII startup banner. + #[arg(long, global = true)] + no_banner: bool, + + /// Override log level (trace, debug, info, warn, error). + #[arg(long, global = true, env = "VOIDBOX_LOG_LEVEL")] + log_level: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Run a spec file (local, in-process). + Run { + /// Path to the spec file (YAML or JSON). + #[arg(long)] + file: PathBuf, + /// Optional input text for the run. + #[arg(long)] + input: Option, + }, + + /// Execute a command in a sandbox (legacy, deprecated). + Exec { + /// Program to execute. + program: String, + /// Arguments to the program. + args: Vec, + }, + + /// Validate a spec file without running it. + Validate { + /// Path to the spec file. + #[arg(long)] + file: PathBuf, + }, + + /// Inspect a spec file: validate and show resolved configuration. + Inspect { + /// Path to the spec file. + #[arg(long)] + file: PathBuf, + }, + + /// List skills defined in a spec file. + Skills { + /// Path to the spec file. + #[arg(long)] + file: PathBuf, + }, + + /// Query run status from the daemon (remote only). + Status { + /// Run ID to query. + #[arg(long)] + run_id: String, + /// Daemon URL override. + #[arg(long)] + daemon: Option, + }, + + /// Fetch run logs from the daemon (remote only). + Logs { + /// Run ID to query. + #[arg(long)] + run_id: String, + /// Daemon URL override. + #[arg(long)] + daemon: Option, + }, + + /// Interactive TUI (connects to daemon). + Tui { + /// Optional spec file to start a run immediately. + #[arg(long)] + file: Option, + /// Daemon URL override. + #[arg(long)] + daemon: Option, + /// Session ID. + #[arg(long, default_value = "default")] + session: String, + /// Path to a custom ASCII logo file. + #[arg(long)] + logo_ascii: Option, + }, + + /// Manage VM snapshots. + Snapshot { + #[command(subcommand)] + command: snapshot::SnapshotCommand, + }, + + /// Manage CLI configuration. + Config { + #[command(subcommand)] + command: ConfigCommand, + }, + + /// Print version information. + Version, + + /// Internal HTTP daemon (future `voidboxd`). + Serve { + /// Listen address. + #[arg(long, default_value = "127.0.0.1:43100")] + listen: String, + }, +} + +#[derive(Subcommand, Debug)] +enum ConfigCommand { + /// Write a template config file to ~/.config/voidbox/config.yaml. + Init, + /// Validate and display the resolved configuration. + Validate, +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + let output_format = cli.output; + + let config = cli_config::load_and_merge( + cli.log_level.as_deref(), + None, // daemon URL from CLI is per-subcommand + cli.no_banner, + ); + + init_tracing(&config); + + let daemon_url = resolved_daemon_url(&cli.command, &config); + let remote = RemoteBackend::new(daemon_url); + let exit_code = match run(cli, &config, &remote).await { + Ok(code) => code, + Err(e) => { + output::report_error(output_format, e.as_ref()); + 1 + } + }; + + std::process::exit(exit_code); +} + +/// Effective daemon URL for this invocation (`--daemon` on remote subcommands wins over config). +fn resolved_daemon_url(command: &Command, config: &ResolvedConfig) -> String { + match command { + Command::Status { daemon, .. } + | Command::Logs { daemon, .. } + | Command::Tui { daemon, .. } => { + daemon.clone().unwrap_or_else(|| config.daemon_url.clone()) + } + _ => config.daemon_url.clone(), + } +} + +async fn run( + cli: Cli, + config: &ResolvedConfig, + remote: &RemoteBackend, +) -> Result> { + let output = cli.output; + match cli.command { + Command::Run { file, input } => { + let spec = void_box::spec::load_spec(&file)?; + if banner::should_show_banner(output, config.banner) { + banner::print_startup_banner(&spec.sandbox); + } + let result = LocalBackend::run(&file, input).await?; + output::print_json_or_human(output, &result, |r| print!("{r}")); + Ok(0) + } + Command::Exec { program, args } => cmd_exec(&program, &args).await, + Command::Validate { file } => cmd_validate(output, &file).map(|_| 0), + Command::Inspect { file } => cmd_inspect(output, &file).map(|_| 0), + Command::Skills { file } => cmd_skills(output, &file).map(|_| 0), + Command::Status { run_id, .. } => cmd_status(output, remote, &run_id).await.map(|_| 0), + Command::Logs { run_id, .. } => cmd_logs(output, remote, &run_id).await.map(|_| 0), + Command::Tui { + file, + session, + logo_ascii, + .. + } => cmd_tui(remote, &session, file.as_deref(), logo_ascii.as_deref()) + .await + .map(|_| 0), + Command::Snapshot { command } => { + snapshot::handle(command, output, &config.paths.snapshot_dir) + .await + .map(|_| 0) + } + Command::Config { command } => cmd_config(command, output, config).map(|_| 0), + Command::Version => cmd_version(output).map(|_| 0), + Command::Serve { listen } => cmd_serve(&listen).await.map(|_| 0), + } +} + +// --------------------------------------------------------------------------- +// Tracing initialization +// --------------------------------------------------------------------------- + +fn init_tracing(config: &ResolvedConfig) { + use tracing_subscriber::fmt::writer::MakeWriterExt; + use tracing_subscriber::EnvFilter; + + let filter = EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info")); + + let log_dir = &config.paths.log_dir; + if log_dir.exists() || std::fs::create_dir_all(log_dir).is_ok() { + let file_appender = tracing_appender::rolling::daily(log_dir, "voidbox.log"); + let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender); + // Leak the guard so the appender lives for the process lifetime. + std::mem::forget(_guard); + + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(non_blocking.and(std::io::stderr)) + .init(); + } else { + tracing_subscriber::fmt().with_env_filter(filter).init(); + } +} + +// --------------------------------------------------------------------------- +// Command handlers +// --------------------------------------------------------------------------- + +async fn cmd_exec(program: &str, args: &[String]) -> Result> { + eprintln!("[legacy] `exec` is deprecated; use `voidbox run --file ...`"); + + let cmd_args: Vec<&str> = args.iter().map(String::as_str).collect(); + + let sandbox = if let Some(kernel) = std::env::var_os("VOID_BOX_KERNEL") { + let mut b = void_box::sandbox::Sandbox::local() + .kernel(kernel) + .network(true); + if let Some(initramfs) = std::env::var_os("VOID_BOX_INITRAMFS") { + b = b.initramfs(initramfs); + } + b.build()? + } else { + eprintln!("[exec] VOID_BOX_KERNEL not specified, falling back to mock"); + void_box::sandbox::Sandbox::mock().build()? + }; + + let out = sandbox.exec(program, &cmd_args).await?; + print!("{}", out.stdout_str()); + eprint!("{}", out.stderr_str()); + Ok(out.exit_code) +} + +fn cmd_validate(format: OutputFormat, file: &Path) -> Result<(), Box> { + let spec = void_box::spec::load_spec(file)?; + void_box::spec::validate_spec(&spec)?; + + #[derive(serde::Serialize)] + struct ValidateResult { + valid: bool, + file: String, + kind: String, + api_version: String, + } + + let result = ValidateResult { + valid: true, + file: file.display().to_string(), + kind: format!("{:?}", spec.kind).to_lowercase(), + api_version: spec.api_version.clone(), + }; + + output::print_json_or_human(format, &result, |r| { + println!( + "valid: {} (kind={}, api_version={})", + r.file, r.kind, r.api_version + ); + }); + Ok(()) +} + +fn cmd_inspect(format: OutputFormat, file: &Path) -> Result<(), Box> { + let spec = void_box::spec::load_spec(file)?; + void_box::spec::validate_spec(&spec)?; + + let kernel = spec + .sandbox + .kernel + .clone() + .or_else(|| std::env::var("VOID_BOX_KERNEL").ok()); + let initramfs = spec + .sandbox + .initramfs + .clone() + .or_else(|| std::env::var("VOID_BOX_INITRAMFS").ok()); + + #[derive(serde::Serialize)] + struct InspectReport { + file: String, + name: String, + kind: String, + api_version: String, + sandbox: SandboxReport, + } + + #[derive(serde::Serialize)] + struct SandboxReport { + mode: String, + kernel: Option, + initramfs: Option, + memory_mb: usize, + vcpus: usize, + network: bool, + image: Option, + snapshot: Option, + mounts: usize, + env_vars: usize, + } + + let report = InspectReport { + file: file.display().to_string(), + name: spec.name.clone(), + kind: format!("{:?}", spec.kind).to_lowercase(), + api_version: spec.api_version.clone(), + sandbox: SandboxReport { + mode: spec.sandbox.mode.clone(), + kernel, + initramfs, + memory_mb: spec.sandbox.memory_mb, + vcpus: spec.sandbox.vcpus, + network: spec.sandbox.network, + image: spec.sandbox.image.clone(), + snapshot: spec.sandbox.snapshot.clone(), + mounts: spec.sandbox.mounts.len(), + env_vars: spec.sandbox.env.len(), + }, + }; + + output::print_json_or_human(format, &report, |r| { + println!("File: {}", r.file); + println!("Name: {}", r.name); + println!("Kind: {}", r.kind); + println!("API: {}", r.api_version); + println!("--- Sandbox ---"); + println!(" Mode: {}", r.sandbox.mode); + println!( + " Kernel: {}", + r.sandbox.kernel.as_deref().unwrap_or("(env/default)") + ); + println!( + " Initramfs: {}", + r.sandbox.initramfs.as_deref().unwrap_or("(env/default)") + ); + println!(" Memory: {} MB", r.sandbox.memory_mb); + println!(" vCPUs: {}", r.sandbox.vcpus); + println!(" Network: {}", r.sandbox.network); + if let Some(img) = &r.sandbox.image { + println!(" Image: {}", img); + } + if let Some(snap) = &r.sandbox.snapshot { + println!(" Snapshot: {}", snap); + } + println!(" Mounts: {}", r.sandbox.mounts); + println!(" Env vars: {}", r.sandbox.env_vars); + }); + Ok(()) +} + +fn cmd_skills(format: OutputFormat, file: &Path) -> Result<(), Box> { + use void_box::spec::SkillEntry; + + #[derive(serde::Serialize)] + struct SkillRow { + source: String, + value: String, + } + + fn to_row(source: &str, entry: &SkillEntry) -> SkillRow { + match entry { + SkillEntry::Simple(s) => SkillRow { + source: source.into(), + value: s.clone(), + }, + SkillEntry::Oci { + image, + mount, + readonly, + } => SkillRow { + source: source.into(), + value: format!("oci:{image} → {mount} (ro={readonly})"), + }, + } + } + + let spec = void_box::spec::load_spec(file)?; + let mut skills: Vec = Vec::new(); + + if let Some(agent) = &spec.agent { + for entry in &agent.skills { + skills.push(to_row("agent", entry)); + } + } + + if let Some(pipeline) = &spec.pipeline { + for bx in &pipeline.boxes { + for entry in &bx.skills { + skills.push(to_row(&format!("pipeline:{}", bx.name), entry)); + } + } + } + + output::print_json_or_human(format, &skills, |rows: &Vec| { + if rows.is_empty() { + println!("No skills defined."); + return; + } + println!("{:<30} SKILL", "SOURCE"); + for row in rows { + println!("{:<30} {}", row.source, row.value); + } + }); + Ok(()) +} + +async fn cmd_status( + format: OutputFormat, + remote: &RemoteBackend, + run_id: &str, +) -> Result<(), Box> { + let value = remote.status(run_id).await?; + print_json_value(format, &value); + Ok(()) +} + +async fn cmd_logs( + format: OutputFormat, + remote: &RemoteBackend, + run_id: &str, +) -> Result<(), Box> { + let value = remote.logs(run_id).await?; + print_json_value(format, &value); + Ok(()) +} + +/// Parsed TUI command from a user input line. +#[derive(Debug, PartialEq)] +enum TuiCommand<'a> { + Quit, + Help, + Run(&'a str), + Input(&'a str), + Status, + Logs, + Cancel, + History, + Unknown, +} + +fn parse_tui_command(line: &str) -> TuiCommand<'_> { + let line = line.trim(); + match line { + "/quit" | "/exit" => TuiCommand::Quit, + "/help" => TuiCommand::Help, + "/status" => TuiCommand::Status, + "/logs" => TuiCommand::Logs, + "/cancel" => TuiCommand::Cancel, + "/history" => TuiCommand::History, + _ if line.starts_with("/run ") => TuiCommand::Run(line["/run ".len()..].trim()), + _ if line.starts_with("/input ") => TuiCommand::Input(&line["/input ".len()..]), + _ => TuiCommand::Unknown, + } +} + +async fn tui_persist(remote: &RemoteBackend, session_id: &str, role: &str, content: &str) { + if let Err(e) = remote.append_message(session_id, role, content).await { + eprintln!("[tui] warning: failed to persist message: {e}"); + } +} + +async fn cmd_tui( + remote: &RemoteBackend, + session_id: &str, + file: Option<&str>, + logo_ascii: Option<&str>, +) -> Result<(), Box> { + let mut current_run: Option = None; + let mut staged_input: Option = None; + + if let Some(file) = file { + let run = remote.create_run(file, None).await?; + println!("[tui] started {}", run); + tui_persist( + remote, + session_id, + "assistant", + &format!("started run {}", run), + ) + .await; + current_run = Some(run); + } + + banner::print_logo_header(logo_ascii); + println!("voidbox tui"); + println!( + "commands: /run , /input , /status, /logs, /cancel, /history, /help, /quit" + ); + + loop { + print!("> "); + io::stdout().flush()?; + + let mut line = String::new(); + if io::stdin().read_line(&mut line)? == 0 { + break; + } + + let line = line.trim(); + if line.is_empty() { + continue; + } + + tui_persist(remote, session_id, "user", line).await; + + match parse_tui_command(line) { + TuiCommand::Quit => break, + TuiCommand::Help => { + println!("/run "); + println!("/input "); + println!("/status"); + println!("/logs"); + println!("/cancel"); + println!("/history"); + println!("/quit"); + } + TuiCommand::Run(file) => { + let run = remote.create_run(file, staged_input.take()).await?; + println!("[tui] started {}", run); + tui_persist( + remote, + session_id, + "assistant", + &format!("started run {}", run), + ) + .await; + current_run = Some(run); + } + TuiCommand::Input(text) => { + staged_input = Some(text.to_string()); + println!("[tui] staged input updated"); + tui_persist(remote, session_id, "assistant", "staged input updated").await; + } + TuiCommand::Status => { + if let Some(run_id) = ¤t_run { + let body = remote.status(run_id).await?; + let text = format_json_value(&body); + println!("{text}"); + tui_persist(remote, session_id, "assistant", &text).await; + } else { + println!("[tui] no active run"); + } + } + TuiCommand::Logs => { + if let Some(run_id) = ¤t_run { + let body = remote.logs(run_id).await?; + let text = format_json_value(&body); + println!("{text}"); + tui_persist(remote, session_id, "assistant", &text).await; + } else { + println!("[tui] no active run"); + } + } + TuiCommand::Cancel => { + if let Some(run_id) = ¤t_run { + let body = remote.cancel_run(run_id).await?; + let text = format_json_value(&body); + println!("{text}"); + tui_persist(remote, session_id, "assistant", &text).await; + } else { + println!("[tui] no active run"); + } + } + TuiCommand::History => { + let body = remote.get_messages(session_id).await?; + println!("{}", format_json_value(&body)); + } + TuiCommand::Unknown => { + println!("assistant: use /commands (try /help)"); + tui_persist(remote, session_id, "assistant", "use /commands (try /help)").await; + } + } + } + + Ok(()) +} + +fn cmd_config( + command: ConfigCommand, + format: OutputFormat, + config: &ResolvedConfig, +) -> Result<(), Box> { + match command { + ConfigCommand::Init => { + let path = config.paths.config_dir.join("config.yaml"); + if path.exists() { + return Err(format!( + "config file already exists at {}; remove it first to re-initialize", + path.display() + ) + .into()); + } + cli_config::write_template(&path)?; + println!("Wrote config template to {}", path.display()); + Ok(()) + } + ConfigCommand::Validate => { + #[derive(serde::Serialize)] + struct ConfigReport { + log_level: String, + daemon_url: String, + banner: bool, + state_dir: String, + log_dir: String, + snapshot_dir: String, + config_dir: String, + kernel: Option, + initramfs: Option, + } + + let report = ConfigReport { + log_level: config.log_level.clone(), + daemon_url: config.daemon_url.clone(), + banner: config.banner, + state_dir: config.paths.state_dir.display().to_string(), + log_dir: config.paths.log_dir.display().to_string(), + snapshot_dir: config.paths.snapshot_dir.display().to_string(), + config_dir: config.paths.config_dir.display().to_string(), + kernel: config.kernel.as_ref().map(|p| p.display().to_string()), + initramfs: config.initramfs.as_ref().map(|p| p.display().to_string()), + }; + + output::print_json_or_human(format, &report, |r| { + println!("Resolved configuration:"); + println!(" log_level: {}", r.log_level); + println!(" daemon_url: {}", r.daemon_url); + println!(" banner: {}", r.banner); + println!(" state_dir: {}", r.state_dir); + println!(" log_dir: {}", r.log_dir); + println!(" snapshot_dir: {}", r.snapshot_dir); + println!(" config_dir: {}", r.config_dir); + println!( + " kernel: {}", + r.kernel.as_deref().unwrap_or("(not set)") + ); + println!( + " initramfs: {}", + r.initramfs.as_deref().unwrap_or("(not set)") + ); + }); + Ok(()) + } + } +} + +fn cmd_version(format: OutputFormat) -> Result<(), Box> { + let version = env!("CARGO_PKG_VERSION"); + + #[derive(serde::Serialize)] + struct VersionInfo { + version: String, + name: String, + } + + let info = VersionInfo { + version: version.into(), + name: "voidbox".into(), + }; + + output::print_json_or_human(format, &info, |_| { + println!("voidbox {version}"); + }); + Ok(()) +} + +/// Handler for `serve`: internal HTTP daemon (future `voidboxd`); see `Command::Serve`. +async fn cmd_serve(listen: &str) -> Result<(), Box> { + let addr: SocketAddr = listen.parse()?; + void_box::daemon::serve(addr).await +} + +// --------------------------------------------------------------------------- +// Clap argv parsing (no subprocess — exercises Parser / Subcommand wiring) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod cli_parse_tests { + use super::snapshot::SnapshotCommand; + use super::{Cli, Command, ConfigCommand, OutputFormat}; + use clap::Parser; + use std::path::PathBuf; + + #[test] + fn global_output_json_and_version_subcommand() { + let cli = Cli::try_parse_from(["voidbox", "--output", "json", "version"]).unwrap(); + assert_eq!(cli.output, OutputFormat::Json); + assert!(matches!(cli.command, Command::Version)); + } + + #[test] + fn no_banner_flag_parses() { + let cli = Cli::try_parse_from(["voidbox", "--no-banner", "version"]).unwrap(); + assert!(cli.no_banner); + } + + #[test] + fn validate_requires_file() { + let cli = Cli::try_parse_from(["voidbox", "validate", "--file", "spec.yaml"]).unwrap(); + match cli.command { + Command::Validate { file } => assert_eq!(file, PathBuf::from("spec.yaml")), + _ => panic!("expected Validate"), + } + } + + #[test] + fn run_without_file_errors() { + assert!(Cli::try_parse_from(["voidbox", "run"]).is_err()); + } + + #[test] + fn run_file_and_optional_input() { + let cli = Cli::try_parse_from([ + "voidbox", + "run", + "--file", + "workflow.yaml", + "--input", + "hello world", + ]) + .unwrap(); + match cli.command { + Command::Run { file, input } => { + assert_eq!(file, PathBuf::from("workflow.yaml")); + assert_eq!(input.as_deref(), Some("hello world")); + } + _ => panic!("expected Run"), + } + } + + #[test] + fn exec_program_and_trailing_args() { + let cli = Cli::try_parse_from(["voidbox", "exec", "echo", "a", "b"]).unwrap(); + match cli.command { + Command::Exec { program, args } => { + assert_eq!(program, "echo"); + assert_eq!(args, vec!["a".to_string(), "b".to_string()]); + } + _ => panic!("expected Exec"), + } + } + + #[test] + fn inspect_file() { + let cli = Cli::try_parse_from(["voidbox", "inspect", "--file", "spec.yaml"]).unwrap(); + match cli.command { + Command::Inspect { file } => assert_eq!(file, PathBuf::from("spec.yaml")), + _ => panic!("expected Inspect"), + } + } + + #[test] + fn skills_file() { + let cli = Cli::try_parse_from(["voidbox", "skills", "--file", "spec.yaml"]).unwrap(); + match cli.command { + Command::Skills { file } => assert_eq!(file, PathBuf::from("spec.yaml")), + _ => panic!("expected Skills"), + } + } + + #[test] + fn logs_run_id_and_daemon() { + let cli = Cli::try_parse_from([ + "voidbox", + "logs", + "--run-id", + "run-9", + "--daemon", + "http://127.0.0.1:43100", + ]) + .unwrap(); + match cli.command { + Command::Logs { run_id, daemon } => { + assert_eq!(run_id, "run-9"); + assert_eq!(daemon.as_deref(), Some("http://127.0.0.1:43100")); + } + _ => panic!("expected Logs"), + } + } + + #[test] + fn tui_defaults_and_overrides() { + let cli = Cli::try_parse_from(["voidbox", "tui"]).unwrap(); + match cli.command { + Command::Tui { + file, + daemon, + session, + logo_ascii, + } => { + assert!(file.is_none()); + assert!(daemon.is_none()); + assert_eq!(session, "default"); + assert!(logo_ascii.is_none()); + } + _ => panic!("expected Tui"), + } + + let cli = Cli::try_parse_from([ + "voidbox", + "tui", + "--file", + "x.yaml", + "--daemon", + "http://example:43100", + "--session", + "sess-1", + "--logo-ascii", + "/tmp/logo.txt", + ]) + .unwrap(); + match cli.command { + Command::Tui { + file, + daemon, + session, + logo_ascii, + } => { + assert_eq!(file.as_deref(), Some("x.yaml")); + assert_eq!(daemon.as_deref(), Some("http://example:43100")); + assert_eq!(session, "sess-1"); + assert_eq!(logo_ascii.as_deref(), Some("/tmp/logo.txt")); + } + _ => panic!("expected Tui"), + } + } + + #[test] + fn snapshot_create_flags() { + let cli = Cli::try_parse_from([ + "voidbox", + "snapshot", + "create", + "--kernel", + "/boot/vmlinuz", + "--initramfs", + "/tmp/init.cpio.gz", + "--memory", + "256", + "--vcpus", + "2", + "--diff", + ]) + .unwrap(); + match cli.command { + Command::Snapshot { command } => match command { + SnapshotCommand::Create { + kernel, + initramfs, + memory, + vcpus, + diff, + } => { + assert_eq!(kernel, PathBuf::from("/boot/vmlinuz")); + assert_eq!(initramfs, Some(PathBuf::from("/tmp/init.cpio.gz"))); + assert_eq!(memory, 256); + assert_eq!(vcpus, 2); + assert!(diff); + } + _ => panic!("expected Create"), + }, + _ => panic!("expected Snapshot"), + } + + let cli = Cli::try_parse_from(["voidbox", "snapshot", "create", "--kernel", "/k/vmlinux"]) + .unwrap(); + match cli.command { + Command::Snapshot { command } => match command { + SnapshotCommand::Create { + kernel, + initramfs, + memory, + vcpus, + diff, + } => { + assert_eq!(kernel, PathBuf::from("/k/vmlinux")); + assert!(initramfs.is_none()); + assert_eq!(memory, 512); + assert_eq!(vcpus, 1); + assert!(!diff); + } + _ => panic!("expected Create"), + }, + _ => panic!("expected Snapshot"), + } + } + + #[test] + fn config_init_subcommand() { + let cli = Cli::try_parse_from(["voidbox", "config", "init"]).unwrap(); + match cli.command { + Command::Config { command } => assert!(matches!(command, ConfigCommand::Init)), + _ => panic!("expected Config"), + } + } + + #[test] + fn global_log_level_override() { + let cli = Cli::try_parse_from(["voidbox", "--log-level", "debug", "version"]).unwrap(); + assert_eq!(cli.log_level.as_deref(), Some("debug")); + } + + #[test] + fn snapshot_list() { + let cli = Cli::try_parse_from(["voidbox", "snapshot", "list"]).unwrap(); + match cli.command { + Command::Snapshot { command } => { + assert!(matches!(command, SnapshotCommand::List)); + } + _ => panic!("expected Snapshot"), + } + } + + #[test] + fn snapshot_delete_hash_prefix() { + let cli = Cli::try_parse_from(["voidbox", "snapshot", "delete", "abc12"]).unwrap(); + match cli.command { + Command::Snapshot { command } => match command { + SnapshotCommand::Delete { hash_prefix } => assert_eq!(hash_prefix, "abc12"), + _ => panic!("expected Delete"), + }, + _ => panic!("expected Snapshot"), + } + } + + #[test] + fn serve_hidden_listen_override() { + let cli = Cli::try_parse_from(["voidbox", "serve", "--listen", "127.0.0.1:9999"]).unwrap(); + match cli.command { + Command::Serve { listen } => assert_eq!(listen, "127.0.0.1:9999"), + _ => panic!("expected Serve"), + } + } + + #[test] + fn config_validate_subcommand() { + let cli = Cli::try_parse_from(["voidbox", "config", "validate"]).unwrap(); + match cli.command { + Command::Config { command } => { + assert!(matches!(command, ConfigCommand::Validate)); + } + _ => panic!("expected Config"), + } + } + + #[test] + fn status_run_id_and_daemon() { + let cli = Cli::try_parse_from([ + "voidbox", + "status", + "--run-id", + "run-1", + "--daemon", + "http://127.0.0.1:43100", + ]) + .unwrap(); + match cli.command { + Command::Status { run_id, daemon } => { + assert_eq!(run_id, "run-1"); + assert_eq!(daemon.as_deref(), Some("http://127.0.0.1:43100")); + } + _ => panic!("expected Status"), + } + } +} + +// --------------------------------------------------------------------------- +// Behavioral tests — command execution, routing, error handling, output +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod behavior_tests { + use super::*; + use cli_config::{CliPaths, ResolvedConfig}; + use output::OutputFormat; + use std::path::PathBuf; + + /// Minimal `ResolvedConfig` pointing at an isolated temp directory. + fn fake_config(tmp: &std::path::Path) -> ResolvedConfig { + fake_config_with_daemon(tmp, "http://127.0.0.1:43100") + } + + fn fake_config_with_daemon(tmp: &std::path::Path, daemon_url: &str) -> ResolvedConfig { + ResolvedConfig { + log_level: "info".into(), + daemon_url: daemon_url.into(), + banner: false, + paths: CliPaths { + state_dir: tmp.join("state"), + log_dir: tmp.join("log"), + snapshot_dir: tmp.join("snapshots"), + config_dir: tmp.join("config"), + }, + kernel: None, + initramfs: None, + } + } + + /// Run a command through the full `run()` dispatcher and return the exit code. + async fn run_command( + command: Command, + format: OutputFormat, + ) -> Result> { + let tmp = tempfile::tempdir().unwrap(); + let config = fake_config(tmp.path()); + std::fs::create_dir_all(&config.paths.snapshot_dir).unwrap(); + let remote = backend::RemoteBackend::new(config.daemon_url.clone()); + let cli = Cli { + output: format, + no_banner: true, + log_level: None, + command, + }; + run(cli, &config, &remote).await + } + + /// Create an isolated snapshot dir inside a new tempdir. + fn isolated_snapshot_dir() -> (tempfile::TempDir, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let snap_dir = tmp.path().join("snapshots"); + std::fs::create_dir_all(&snap_dir).unwrap(); + (tmp, snap_dir) + } + + // ----------------------------------------------------------------------- + // resolved_daemon_url + // ----------------------------------------------------------------------- + + #[test] + fn resolved_daemon_url_prefers_cli_override() { + let tmp = tempfile::tempdir().unwrap(); + let config = fake_config_with_daemon(tmp.path(), "http://config-host:1000"); + + let cases: Vec<(Command, &str)> = vec![ + ( + Command::Status { + run_id: "r1".into(), + daemon: Some("http://cli-override:2000".into()), + }, + "http://cli-override:2000", + ), + ( + Command::Logs { + run_id: "r1".into(), + daemon: Some("http://cli-logs:3000".into()), + }, + "http://cli-logs:3000", + ), + ( + Command::Tui { + file: None, + daemon: Some("http://cli-tui:4000".into()), + session: "s".into(), + logo_ascii: None, + }, + "http://cli-tui:4000", + ), + ]; + + for (cmd, expected) in &cases { + assert_eq!( + resolved_daemon_url(cmd, &config), + *expected, + "CLI --daemon should override config for {:?}", + cmd + ); + } + } + + #[test] + fn resolved_daemon_url_falls_back_to_config() { + let tmp = tempfile::tempdir().unwrap(); + let config = fake_config_with_daemon(tmp.path(), "http://config-host:1000"); + + let remote_commands_without_override: Vec = vec![ + Command::Status { + run_id: "r1".into(), + daemon: None, + }, + Command::Logs { + run_id: "r1".into(), + daemon: None, + }, + Command::Tui { + file: None, + daemon: None, + session: "s".into(), + logo_ascii: None, + }, + ]; + + for cmd in &remote_commands_without_override { + assert_eq!( + resolved_daemon_url(cmd, &config), + "http://config-host:1000", + "remote command {:?} without --daemon should fall back to config", + cmd + ); + } + } + + #[test] + fn resolved_daemon_url_non_remote_command_uses_config() { + let tmp = tempfile::tempdir().unwrap(); + let config = fake_config_with_daemon(tmp.path(), "http://config-host:1000"); + + let non_remote_commands: Vec = vec![ + Command::Version, + Command::Config { + command: ConfigCommand::Validate, + }, + Command::Validate { + file: PathBuf::from("x.yaml"), + }, + Command::Inspect { + file: PathBuf::from("x.yaml"), + }, + Command::Skills { + file: PathBuf::from("x.yaml"), + }, + Command::Run { + file: PathBuf::from("x.yaml"), + input: None, + }, + Command::Exec { + program: "echo".into(), + args: vec![], + }, + Command::Serve { + listen: "127.0.0.1:9999".into(), + }, + Command::Snapshot { + command: snapshot::SnapshotCommand::List, + }, + ]; + + for cmd in &non_remote_commands { + assert_eq!( + resolved_daemon_url(cmd, &config), + "http://config-host:1000", + "non-remote command {:?} should use config daemon_url", + cmd + ); + } + } + + // ----------------------------------------------------------------------- + // run() command routing + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn run_version_returns_zero() { + assert_eq!( + run_command(Command::Version, OutputFormat::Human) + .await + .unwrap(), + 0 + ); + } + + #[tokio::test] + async fn run_version_json_returns_zero() { + assert_eq!( + run_command(Command::Version, OutputFormat::Json) + .await + .unwrap(), + 0 + ); + } + + #[tokio::test] + async fn run_config_validate_returns_zero() { + let cmd = Command::Config { + command: ConfigCommand::Validate, + }; + assert_eq!(run_command(cmd, OutputFormat::Human).await.unwrap(), 0); + } + + #[tokio::test] + async fn run_snapshot_list_returns_ok() { + let cmd = Command::Snapshot { + command: snapshot::SnapshotCommand::List, + }; + assert_eq!(run_command(cmd, OutputFormat::Human).await.unwrap(), 0); + } + + #[tokio::test] + async fn run_snapshot_list_json_returns_ok() { + let cmd = Command::Snapshot { + command: snapshot::SnapshotCommand::List, + }; + assert_eq!(run_command(cmd, OutputFormat::Json).await.unwrap(), 0); + } + + // ----------------------------------------------------------------------- + // run() failure paths + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn run_validate_nonexistent_file_returns_error() { + let cmd = Command::Validate { + file: PathBuf::from("nonexistent.yaml"), + }; + assert!(run_command(cmd, OutputFormat::Human).await.is_err()); + } + + #[tokio::test] + async fn run_inspect_nonexistent_file_returns_error() { + let cmd = Command::Inspect { + file: PathBuf::from("nonexistent.yaml"), + }; + assert!(run_command(cmd, OutputFormat::Human).await.is_err()); + } + + #[tokio::test] + async fn run_skills_nonexistent_file_returns_error() { + let cmd = Command::Skills { + file: PathBuf::from("nonexistent.yaml"), + }; + assert!(run_command(cmd, OutputFormat::Human).await.is_err()); + } + + #[tokio::test] + async fn run_run_nonexistent_file_returns_error() { + let cmd = Command::Run { + file: PathBuf::from("nonexistent.yaml"), + input: None, + }; + assert!(run_command(cmd, OutputFormat::Human).await.is_err()); + } + + #[tokio::test] + async fn run_validate_invalid_yaml_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let bad_file = tmp.path().join("bad.yaml"); + std::fs::write(&bad_file, "not: [valid: spec").unwrap(); + + let cmd = Command::Validate { file: bad_file }; + assert!(run_command(cmd, OutputFormat::Human).await.is_err()); + } + + #[tokio::test] + async fn run_validate_json_error_is_propagated() { + let cmd = Command::Validate { + file: PathBuf::from("nonexistent.yaml"), + }; + assert!(run_command(cmd, OutputFormat::Json).await.is_err()); + } + + #[tokio::test] + async fn run_snapshot_delete_nonexistent_returns_error() { + let cmd = Command::Snapshot { + command: snapshot::SnapshotCommand::Delete { + hash_prefix: "nonexistent".into(), + }, + }; + assert!(run_command(cmd, OutputFormat::Human).await.is_err()); + } + + #[tokio::test] + async fn run_config_init_twice_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let config = fake_config(tmp.path()); + std::fs::create_dir_all(&config.paths.snapshot_dir).unwrap(); + let remote = backend::RemoteBackend::new(config.daemon_url.clone()); + + let cli = Cli { + output: OutputFormat::Human, + no_banner: true, + log_level: None, + command: Command::Config { + command: ConfigCommand::Init, + }, + }; + run(cli, &config, &remote).await.unwrap(); + + let cli2 = Cli { + output: OutputFormat::Human, + no_banner: true, + log_level: None, + command: Command::Config { + command: ConfigCommand::Init, + }, + }; + assert!(run(cli2, &config, &remote).await.is_err()); + } + + // ----------------------------------------------------------------------- + // cmd_config behavior + // ----------------------------------------------------------------------- + + #[test] + fn cmd_config_init_fails_if_file_exists() { + let tmp = tempfile::tempdir().unwrap(); + let config = fake_config(tmp.path()); + + let config_file = config.paths.config_dir.join("config.yaml"); + std::fs::create_dir_all(&config.paths.config_dir).unwrap(); + std::fs::write(&config_file, "existing: true").unwrap(); + + let result = cmd_config(ConfigCommand::Init, OutputFormat::Human, &config); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("already exists"), + "expected 'already exists' in error, got: {msg}" + ); + } + + #[test] + fn cmd_config_init_succeeds_when_no_file() { + let tmp = tempfile::tempdir().unwrap(); + let config = fake_config(tmp.path()); + + cmd_config(ConfigCommand::Init, OutputFormat::Human, &config).unwrap(); + + let config_file = config.paths.config_dir.join("config.yaml"); + assert!( + config_file.exists(), + "template file should have been written" + ); + } + + #[test] + fn cmd_config_init_written_file_is_valid_yaml() { + let tmp = tempfile::tempdir().unwrap(); + let config = fake_config(tmp.path()); + + cmd_config(ConfigCommand::Init, OutputFormat::Human, &config).unwrap(); + + let config_file = config.paths.config_dir.join("config.yaml"); + let contents = std::fs::read_to_string(&config_file).unwrap(); + let parsed: serde_yaml::Value = serde_yaml::from_str(&contents).unwrap(); + assert!(parsed.is_mapping()); + } + + #[test] + fn cmd_config_validate_returns_ok_human() { + let tmp = tempfile::tempdir().unwrap(); + let config = fake_config(tmp.path()); + cmd_config(ConfigCommand::Validate, OutputFormat::Human, &config).unwrap(); + } + + #[test] + fn cmd_config_validate_returns_ok_json() { + let tmp = tempfile::tempdir().unwrap(); + let config = fake_config(tmp.path()); + cmd_config(ConfigCommand::Validate, OutputFormat::Json, &config).unwrap(); + } + + // ----------------------------------------------------------------------- + // cmd_version output modes + // ----------------------------------------------------------------------- + + #[test] + fn cmd_version_human_returns_ok() { + cmd_version(OutputFormat::Human).unwrap(); + } + + #[test] + fn cmd_version_json_returns_ok() { + cmd_version(OutputFormat::Json).unwrap(); + } + + // ----------------------------------------------------------------------- + // snapshot::handle — list on empty dir + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn snapshot_handle_list_empty_dir() { + let (_tmp, snap_dir) = isolated_snapshot_dir(); + snapshot::handle( + snapshot::SnapshotCommand::List, + OutputFormat::Human, + &snap_dir, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn snapshot_handle_list_json_empty_dir() { + let (_tmp, snap_dir) = isolated_snapshot_dir(); + snapshot::handle( + snapshot::SnapshotCommand::List, + OutputFormat::Json, + &snap_dir, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn snapshot_handle_delete_nonexistent_returns_error() { + let (_tmp, snap_dir) = isolated_snapshot_dir(); + let result = snapshot::handle( + snapshot::SnapshotCommand::Delete { + hash_prefix: "nonexistent".into(), + }, + OutputFormat::Human, + &snap_dir, + ) + .await; + assert!(result.is_err()); + } + + // ----------------------------------------------------------------------- + // Mock sandbox exec (exercises the same path as cmd_exec's mock branch) + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn mock_sandbox_echo_returns_zero() { + let sandbox = void_box::sandbox::Sandbox::mock().build().unwrap(); + let out = sandbox.exec("echo", &["hello"]).await.unwrap(); + assert_eq!(out.exit_code, 0); + assert_eq!(out.stdout_str(), "hello\n"); + } + + #[tokio::test] + async fn mock_sandbox_cat_missing_file_returns_nonzero() { + let sandbox = void_box::sandbox::Sandbox::mock().build().unwrap(); + let out = sandbox.exec("cat", &["missing.txt"]).await.unwrap(); + assert_ne!( + out.exit_code, 0, + "cat with a missing file should return non-zero" + ); + } + + #[tokio::test] + async fn mock_sandbox_unknown_program_returns_zero() { + let sandbox = void_box::sandbox::Sandbox::mock().build().unwrap(); + let out = sandbox.exec("__nonexistent__", &[]).await.unwrap(); + assert_eq!( + out.exit_code, 0, + "mock sandbox returns 0 for unknown programs" + ); + } + + // ----------------------------------------------------------------------- + // parse_tui_command + // ----------------------------------------------------------------------- + + #[test] + fn tui_parse_quit_variants() { + assert_eq!(parse_tui_command("/quit"), TuiCommand::Quit); + assert_eq!(parse_tui_command("/exit"), TuiCommand::Quit); + assert_eq!(parse_tui_command(" /quit "), TuiCommand::Quit); + } + + #[test] + fn tui_parse_help() { + assert_eq!(parse_tui_command("/help"), TuiCommand::Help); + } + + #[test] + fn tui_parse_run_extracts_file() { + assert_eq!( + parse_tui_command("/run spec.yaml"), + TuiCommand::Run("spec.yaml") + ); + assert_eq!( + parse_tui_command("/run extra-spaces.yaml "), + TuiCommand::Run("extra-spaces.yaml") + ); + } + + #[test] + fn tui_parse_input_preserves_text() { + assert_eq!( + parse_tui_command("/input hello world"), + TuiCommand::Input("hello world") + ); + } + + #[test] + fn tui_parse_status_logs_cancel_history() { + assert_eq!(parse_tui_command("/status"), TuiCommand::Status); + assert_eq!(parse_tui_command("/logs"), TuiCommand::Logs); + assert_eq!(parse_tui_command("/cancel"), TuiCommand::Cancel); + assert_eq!(parse_tui_command("/history"), TuiCommand::History); + } + + #[test] + fn tui_parse_unknown_command() { + assert_eq!(parse_tui_command("random text"), TuiCommand::Unknown); + assert_eq!(parse_tui_command("/unknown"), TuiCommand::Unknown); + } + + #[test] + fn tui_parse_empty_run_is_unknown() { + // "/run" without a space+argument is not a valid /run command + assert_eq!(parse_tui_command("/run"), TuiCommand::Unknown); + } + + // ----------------------------------------------------------------------- + // snapshot create — early failure paths + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn snapshot_create_nonexistent_kernel_returns_error() { + let cmd = snapshot::SnapshotCommand::Create { + kernel: PathBuf::from("/nonexistent/kernel"), + initramfs: None, + memory: 128, + vcpus: 1, + diff: false, + }; + let (_tmp, snap_dir) = isolated_snapshot_dir(); + let result = snapshot::handle(cmd, OutputFormat::Human, &snap_dir).await; + assert!(result.is_err(), "nonexistent kernel should fail early"); + } + + #[tokio::test] + async fn snapshot_create_nonexistent_initramfs_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let fake_kernel = tmp.path().join("vmlinuz"); + std::fs::write(&fake_kernel, b"fake-kernel-data").unwrap(); + + let cmd = snapshot::SnapshotCommand::Create { + kernel: fake_kernel, + initramfs: Some(PathBuf::from("/nonexistent/initramfs")), + memory: 128, + vcpus: 1, + diff: false, + }; + let (_tmp2, snap_dir) = isolated_snapshot_dir(); + let result = snapshot::handle(cmd, OutputFormat::Human, &snap_dir).await; + assert!(result.is_err(), "nonexistent initramfs should fail early"); + } +} diff --git a/src/bin/voidbox/output.rs b/src/bin/voidbox/output.rs new file mode 100644 index 0000000..42ceed0 --- /dev/null +++ b/src/bin/voidbox/output.rs @@ -0,0 +1,130 @@ +use std::fmt; + +use serde::Serialize; + +/// Output format for CLI commands. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OutputFormat { + #[default] + Human, + Json, +} + +impl fmt::Display for OutputFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OutputFormat::Human => write!(f, "human"), + OutputFormat::Json => write!(f, "json"), + } + } +} + +impl std::str::FromStr for OutputFormat { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s.to_ascii_lowercase().as_str() { + "human" => Ok(OutputFormat::Human), + "json" => Ok(OutputFormat::Json), + other => Err(format!( + "unknown output format '{other}', expected human|json" + )), + } + } +} + +/// Print a serializable value to stdout as JSON (pretty) or human table. +pub fn print_json_or_human( + format: OutputFormat, + value: &T, + human_fn: impl FnOnce(&T), +) { + match format { + OutputFormat::Human => human_fn(value), + OutputFormat::Json => match serde_json::to_string_pretty(value) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("error: failed to serialize JSON output: {e}"), + }, + } +} + +/// Structured error for JSON output on stderr. +#[derive(Debug, Serialize)] +pub struct CliError { + pub error: CliErrorDetail, +} + +#[derive(Debug, Serialize)] +pub struct CliErrorDetail { + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, +} + +impl CliError { + pub fn new(message: impl Into) -> Self { + Self { + error: CliErrorDetail { + message: message.into(), + code: None, + }, + } + } + + #[allow(dead_code)] + pub fn with_code(mut self, code: impl Into) -> Self { + self.error.code = Some(code.into()); + self + } +} + +/// Report an error to stderr, respecting output format. +pub fn report_error(format: OutputFormat, err: &dyn std::error::Error) { + match format { + OutputFormat::Human => eprintln!("error: {err}"), + OutputFormat::Json => { + let cli_err = CliError::new(err.to_string()); + if let Ok(json) = serde_json::to_string_pretty(&cli_err) { + eprintln!("{json}"); + } + } + } +} + +/// Format a structured JSON value for CLI display (pretty-print; fallback to compact). +pub fn format_json_value(value: &serde_json::Value) -> String { + serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()) +} + +/// Print a [`serde_json::Value`] to stdout respecting [`OutputFormat`] (e.g. daemon responses). +pub fn print_json_value(format: OutputFormat, value: &serde_json::Value) { + print_json_or_human(format, value, |v| { + println!("{}", format_json_value(v)); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output_format_parses_human_and_json_case_insensitive() { + assert_eq!( + "human".parse::().unwrap(), + OutputFormat::Human + ); + assert_eq!("JSON".parse::().unwrap(), OutputFormat::Json); + } + + #[test] + fn output_format_rejects_unknown() { + assert!("yaml".parse::().is_err()); + } + + #[test] + fn format_json_value_pretty_prints() { + let v = serde_json::json!({"a": 1}); + let s = format_json_value(&v); + assert!(s.contains('\n') || s.contains('1')); + assert!(s.contains("a")); + } +} diff --git a/src/bin/voidbox/snapshot.rs b/src/bin/voidbox/snapshot.rs new file mode 100644 index 0000000..8651c9b --- /dev/null +++ b/src/bin/voidbox/snapshot.rs @@ -0,0 +1,381 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use clap::Subcommand; + +use crate::output::OutputFormat; + +#[derive(Debug, Subcommand)] +pub enum SnapshotCommand { + /// Create a new snapshot from a cold-booted VM. + Create { + /// Path to the kernel image. + #[arg(long)] + kernel: PathBuf, + /// Path to the initramfs image. + #[arg(long)] + initramfs: Option, + /// Memory size in MB. + #[arg(long, default_value = "512")] + memory: usize, + /// Number of vCPUs. + #[arg(long, default_value = "1")] + vcpus: usize, + /// Create a differential snapshot on top of an existing base. + #[arg(long)] + diff: bool, + }, + /// List stored snapshots. + List, + /// Delete a snapshot by hash prefix. + Delete { + /// Hash prefix of the snapshot to delete. + hash_prefix: String, + }, +} + +pub async fn handle( + cmd: SnapshotCommand, + output: OutputFormat, + snapshot_dir: &Path, +) -> Result<(), Box> { + match cmd { + SnapshotCommand::Create { + kernel, + initramfs, + memory, + vcpus, + diff, + } => cmd_snapshot_create(&kernel, initramfs.as_deref(), memory, vcpus, diff).await, + SnapshotCommand::List => cmd_snapshot_list(output, snapshot_dir), + SnapshotCommand::Delete { hash_prefix } => cmd_snapshot_delete(&hash_prefix, snapshot_dir), + } +} + +async fn cmd_snapshot_create( + kernel: &Path, + initramfs: Option<&Path>, + memory_mb: usize, + vcpus: usize, + is_diff: bool, +) -> Result<(), Box> { + use void_box::snapshot_store; + + let config_hash = snapshot_store::compute_config_hash(kernel, initramfs, memory_mb, vcpus)?; + eprintln!( + "Creating {} snapshot: kernel={}, initramfs={:?}, memory={}MB, vcpus={}", + if is_diff { "diff" } else { "base" }, + kernel.display(), + initramfs.map(|p| p.display()), + memory_mb, + vcpus + ); + eprintln!("Config hash: {}", &config_hash[..16]); + + #[cfg(target_os = "linux")] + { + cmd_snapshot_create_linux(kernel, initramfs, memory_mb, vcpus, is_diff, &config_hash).await + } + + #[cfg(target_os = "macos")] + { + cmd_snapshot_create_macos(kernel, initramfs, memory_mb, vcpus, is_diff, &config_hash).await + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (kernel, initramfs, memory_mb, vcpus, is_diff, config_hash); + Err("snapshot create is not supported on this platform".into()) + } +} + +#[cfg(target_os = "linux")] +async fn cmd_snapshot_create_linux( + kernel: &Path, + initramfs: Option<&Path>, + memory_mb: usize, + vcpus: usize, + is_diff: bool, + config_hash: &str, +) -> Result<(), Box> { + use void_box::snapshot_store; + use void_box::vmm::config::VoidBoxConfig; + use void_box::vmm::snapshot; + use void_box::MicroVm; + + if is_diff { + let base_dir = snapshot_store::snapshot_dir_for_hash(config_hash); + if !base_dir.join("state.bin").exists() { + return Err(format!( + "no base snapshot found; create one first with: voidbox snapshot create --kernel {} {}--memory {} --vcpus {}", + kernel.display(), + initramfs + .map(|p| format!("--initramfs {} ", p.display())) + .unwrap_or_default(), + memory_mb, + vcpus + ) + .into()); + } + + let diff_dir_name = format!("{}-diff", &config_hash[..16]); + let diff_dir = snapshot_store::default_snapshot_dir().join(&diff_dir_name); + fs::create_dir_all(&diff_dir)?; + + if diff_dir.join("state.bin").exists() { + return Err(format!( + "diff snapshot already exists at {}; delete it first with: voidbox snapshot delete {}-diff", + diff_dir.display(), + &config_hash[..16] + ) + .into()); + } + + let start = std::time::Instant::now(); + eprintln!("Restoring VM from base snapshot..."); + let vm = MicroVm::from_snapshot(&base_dir).await?; + let restore_ms = start.elapsed().as_millis(); + eprintln!("VM restored in {}ms", restore_ms); + + eprintln!("Enabling dirty page tracking..."); + vm.enable_dirty_tracking()?; + + eprintln!("Waiting for guest-agent readiness..."); + let output = vm.exec("echo", &["snapshot-ready"]).await?; + if !output.success() { + return Err(format!("Guest-agent not ready: {}", output.stderr_str()).into()); + } + eprintln!( + "Guest-agent ready ({}ms total)", + start.elapsed().as_millis() + ); + + let snap_config = snapshot::SnapshotConfig { + memory_mb, + vcpus, + cid: vm.cid(), + vsock_mmio_base: 0xd080_0000, + network: VoidBoxConfig::new().network, + }; + + let snap_dir = vm + .snapshot_diff( + &diff_dir, + config_hash.to_string(), + snap_config, + config_hash.to_string(), + ) + .await?; + let total_ms = start.elapsed().as_millis(); + + let diff_mem_size = fs::metadata(snapshot::VmSnapshot::diff_memory_path(&snap_dir)) + .map(|m| m.len()) + .unwrap_or(0); + let base_mem_size = fs::metadata(snapshot::VmSnapshot::memory_path(&base_dir)) + .map(|m| m.len()) + .unwrap_or(1); + + let savings = if base_mem_size > 0 { + 100.0 - (diff_mem_size as f64 / base_mem_size as f64 * 100.0) + } else { + 0.0 + }; + + eprintln!("Diff snapshot created successfully:"); + eprintln!(" Hash: {}", &config_hash[..16]); + eprintln!(" Path: {}", snap_dir.display()); + eprintln!(" Duration: {}ms", total_ms); + eprintln!( + " Diff mem: {} KB ({:.1}% savings vs base)", + diff_mem_size / 1024, + savings + ); + } else { + let snapshot_dir = snapshot_store::snapshot_dir_for_hash(config_hash); + fs::create_dir_all(&snapshot_dir)?; + + if snapshot_dir.join("state.bin").exists() { + return Err(format!( + "snapshot already exists at {}; delete it first with: voidbox snapshot delete {}", + snapshot_dir.display(), + &config_hash[..16] + ) + .into()); + } + + let mut config = VoidBoxConfig::new() + .kernel(kernel) + .memory_mb(memory_mb) + .vcpus(vcpus) + .enable_vsock(true) + .vsock_backend(void_box::vmm::config::VsockBackendType::Userspace); + if let Some(initramfs) = initramfs { + config = config.initramfs(initramfs); + } + + let start = std::time::Instant::now(); + eprintln!("Booting VM..."); + let vm = MicroVm::new(config.clone()).await?; + let boot_ms = start.elapsed().as_millis(); + eprintln!("VM booted in {}ms, waiting for guest-agent...", boot_ms); + + let output = vm.exec("echo", &["snapshot-ready"]).await?; + if !output.success() { + return Err(format!("Guest-agent not ready: {}", output.stderr_str()).into()); + } + eprintln!( + "Guest-agent ready ({}ms total)", + start.elapsed().as_millis() + ); + + let snap_config = snapshot::SnapshotConfig { + memory_mb, + vcpus, + cid: vm.cid(), + vsock_mmio_base: 0xd080_0000, + network: config.network, + }; + + let snap_dir = vm + .snapshot(&snapshot_dir, config_hash.to_string(), snap_config) + .await?; + let total_ms = start.elapsed().as_millis(); + + eprintln!("Snapshot created successfully:"); + eprintln!(" Hash: {}", &config_hash[..16]); + eprintln!(" Path: {}", snap_dir.display()); + eprintln!(" Duration: {}ms", total_ms); + + let mem_size = fs::metadata(snapshot::VmSnapshot::memory_path(&snap_dir)) + .map(|m| m.len()) + .unwrap_or(0); + eprintln!(" Memory: {} MB", mem_size / (1024 * 1024)); + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +async fn cmd_snapshot_create_macos( + kernel: &Path, + initramfs: Option<&Path>, + memory_mb: usize, + vcpus: usize, + is_diff: bool, + config_hash: &str, +) -> Result<(), Box> { + use void_box::backend::vz::VzBackend; + use void_box::backend::{BackendConfig, VmmBackend}; + use void_box::snapshot_store; + + if is_diff { + return Err("diff snapshots are not supported on macOS (VZ)".into()); + } + + let snapshot_dir = snapshot_store::snapshot_dir_for_hash(config_hash); + fs::create_dir_all(&snapshot_dir)?; + + if snapshot_store::snapshot_exists(&snapshot_dir) { + return Err(format!( + "snapshot already exists at {}; delete it first with: voidbox snapshot delete {}", + snapshot_dir.display(), + &config_hash[..16] + ) + .into()); + } + + let mut config = BackendConfig::minimal(kernel, memory_mb, vcpus); + if let Some(initramfs) = initramfs { + config = config.initramfs(initramfs); + } + + let start = std::time::Instant::now(); + eprintln!("Booting VM via Virtualization.framework..."); + let mut backend = VzBackend::new(); + backend.start(config).await?; + let boot_ms = start.elapsed().as_millis(); + eprintln!("VM booted in {}ms, waiting for guest-agent...", boot_ms); + + let output = backend + .exec("echo", &["snapshot-ready"], &[], &[], None, None) + .await?; + if !output.success() { + return Err(format!("Guest-agent not ready: {}", output.stderr_str()).into()); + } + eprintln!( + "Guest-agent ready ({}ms total)", + start.elapsed().as_millis() + ); + + eprintln!("Creating snapshot..."); + backend.create_snapshot(&snapshot_dir)?; + let total_ms = start.elapsed().as_millis(); + + eprintln!("Snapshot created successfully:"); + eprintln!(" Hash: {}", &config_hash[..16]); + eprintln!(" Path: {}", snapshot_dir.display()); + eprintln!(" Duration: {}ms", total_ms); + + backend.stop().await?; + Ok(()) +} + +fn cmd_snapshot_list( + output: OutputFormat, + snapshot_dir: &Path, +) -> Result<(), Box> { + use void_box::snapshot_store; + + #[derive(serde::Serialize)] + struct SnapshotRow { + hash: String, + memory_mb: usize, + vcpus: usize, + snapshot_type: String, + path: String, + } + + let snapshots = snapshot_store::list_snapshots_in(snapshot_dir)?; + let rows: Vec = snapshots + .iter() + .map(|info| SnapshotRow { + hash: info.config_hash[..16.min(info.config_hash.len())].to_string(), + memory_mb: info.memory_mb, + vcpus: info.vcpus, + snapshot_type: info.snapshot_type.to_string(), + path: info.dir.display().to_string(), + }) + .collect(); + + crate::output::print_json_or_human(output, &rows, |rows| { + if rows.is_empty() { + println!("No snapshots found."); + return; + } + println!( + "{:<18} {:<8} {:<8} {:<10} PATH", + "HASH", "MEM(MB)", "VCPUS", "TYPE" + ); + for row in rows { + println!( + "{:<18} {:<8} {:<8} {:<10} {}", + row.hash, row.memory_mb, row.vcpus, row.snapshot_type, row.path, + ); + } + }); + Ok(()) +} + +fn cmd_snapshot_delete( + hash_prefix: &str, + snapshot_dir: &Path, +) -> Result<(), Box> { + use void_box::snapshot_store; + + if snapshot_store::delete_snapshot_in(snapshot_dir, hash_prefix)? { + println!("Deleted snapshot matching '{}'", hash_prefix); + Ok(()) + } else { + Err(format!("no snapshot found matching '{hash_prefix}'").into()) + } +} diff --git a/src/snapshot_store.rs b/src/snapshot_store.rs index 3f8dc1e..7d26abc 100644 --- a/src/snapshot_store.rs +++ b/src/snapshot_store.rs @@ -13,7 +13,12 @@ use tracing::debug; use crate::{Error, Result}; /// Default snapshot storage directory. +/// +/// Checks `VOIDBOX_HOME` first, then falls back to `$HOME/.void-box/snapshots`. pub fn default_snapshot_dir() -> PathBuf { + if let Ok(home) = std::env::var("VOIDBOX_HOME") { + return PathBuf::from(home).join("snapshots"); + } dirs_snapshot_base() } @@ -22,6 +27,55 @@ fn dirs_snapshot_base() -> PathBuf { PathBuf::from(home).join(".void-box").join("snapshots") } +/// Resolve the snapshot directory for a given config hash using a custom base. +pub fn snapshot_dir_for_hash_in(base: &Path, config_hash: &str) -> PathBuf { + base.join(&config_hash[..16.min(config_hash.len())]) +} + +/// List all stored snapshots under a custom base directory. +pub fn list_snapshots_in(base: &Path) -> Result> { + if !base.exists() { + return Ok(Vec::new()); + } + let mut infos = Vec::new(); + for entry in fs::read_dir(base)? { + let entry = entry?; + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + if dir.join("state.bin").exists() { + if let Some(info) = load_kvm_snapshot_info(&dir) { + infos.push(info); + } + continue; + } + if dir.join("vz_meta.json").exists() { + if let Some(info) = load_vz_snapshot_info(&dir) { + infos.push(info); + } + } + } + Ok(infos) +} + +/// Delete a snapshot by hash prefix under a custom base directory. +pub fn delete_snapshot_in(base: &Path, hash_prefix: &str) -> Result { + if !base.exists() { + return Ok(false); + } + for entry in fs::read_dir(base)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with(hash_prefix) || hash_prefix.starts_with(&name) { + fs::remove_dir_all(entry.path())?; + tracing::info!("Deleted snapshot {}", entry.path().display()); + return Ok(true); + } + } + Ok(false) +} + /// Snapshot type discriminator. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum SnapshotType { @@ -31,6 +85,15 @@ pub enum SnapshotType { Diff, } +impl std::fmt::Display for SnapshotType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SnapshotType::Base => f.write_str("base"), + SnapshotType::Diff => f.write_str("diff"), + } + } +} + /// Information about a stored snapshot (for listing). #[derive(Debug)] pub struct SnapshotInfo { @@ -73,14 +136,14 @@ pub fn compute_config_hash( /// Resolve the snapshot directory for a given config hash. pub fn snapshot_dir_for_hash(config_hash: &str) -> PathBuf { - dirs_snapshot_base().join(&config_hash[..16.min(config_hash.len())]) + default_snapshot_dir().join(&config_hash[..16.min(config_hash.len())]) } /// List all stored snapshots (both KVM and VZ). /// /// KVM snapshots are identified by `state.bin`, VZ snapshots by `vz_meta.json`. pub fn list_snapshots() -> Result> { - let base = dirs_snapshot_base(); + let base = default_snapshot_dir(); if !base.exists() { return Ok(Vec::new()); } @@ -209,7 +272,7 @@ fn load_vz_snapshot_info(dir: &Path) -> Option { /// Delete a snapshot by its config hash prefix. pub fn delete_snapshot(hash_prefix: &str) -> Result { - let base = dirs_snapshot_base(); + let base = default_snapshot_dir(); if !base.exists() { return Ok(false); } diff --git a/voidbox-oci/Cargo.toml b/voidbox-oci/Cargo.toml index a07b072..4afd2a6 100644 --- a/voidbox-oci/Cargo.toml +++ b/voidbox-oci/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.2" edition = "2021" [dependencies] -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] } sha2 = "0.10" flate2 = "1" tar = "0.4"