From 0b8cd5f22278fad6ee34987fe80149ed7ab99c19 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 15 Oct 2026 23:05:09 +0200 Subject: [PATCH 01/18] test(conformance): add SDK-driven testkit and sandbox-lifecycle suite Add a testkit for black-box conformance tests driven through the Rust SDK, and a first sandbox-lifecycle suite built on it. - testkit loads the system under test from a target file named by OPENSHELL_TEST_TARGET (gateway endpoint, TLS or mTLS material, bearer token, capabilities). mTLS targets get a tonic channel built by testkit and passed to OpenShellClient::from_parts, since the SDK does not speak mTLS. - Context tracks every sandbox a test creates and deletes them after the test, including on failure or panic; cleanup failures are reported separately. - #[testkit::test] wraps an async fn in a same-named #[tokio::test]. The doc comment is the required description, the function name is the test ID, and requires = [...] names target capabilities. A test whose requirement the target lacks fails rather than passes. - sandbox-lifecycle adds long_running_exec_delete: create, wait for Ready, exec, delete, and confirm the sandbox is gone from get and list. Pin aws-lc-rs 1.18.0 and aws-lc-sys 0.44.0 to the root workspace versions so the musl test archive links against the Nix-provided AWS-LC. Signed-off-by: Evan Lezar --- tests/suites/conformance/Cargo.lock | 2785 +++++++++++++++-- tests/suites/conformance/Cargo.toml | 2 +- .../conformance/sandbox-lifecycle/Cargo.toml | 15 + .../tests/sandbox_lifecycle.rs | 36 + .../conformance/testkit-macros/Cargo.toml | 21 + .../conformance/testkit-macros/src/lib.rs | 240 ++ tests/suites/conformance/testkit/Cargo.toml | 25 + .../suites/conformance/testkit/src/context.rs | 285 ++ tests/suites/conformance/testkit/src/error.rs | 112 + tests/suites/conformance/testkit/src/lib.rs | 45 + tests/suites/conformance/testkit/src/poll.rs | 90 + tests/suites/conformance/testkit/src/run.rs | 99 + .../suites/conformance/testkit/src/target.rs | 291 ++ 13 files changed, 3833 insertions(+), 213 deletions(-) create mode 100644 tests/suites/conformance/sandbox-lifecycle/Cargo.toml create mode 100644 tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs create mode 100644 tests/suites/conformance/testkit-macros/Cargo.toml create mode 100644 tests/suites/conformance/testkit-macros/src/lib.rs create mode 100644 tests/suites/conformance/testkit/Cargo.toml create mode 100644 tests/suites/conformance/testkit/src/context.rs create mode 100644 tests/suites/conformance/testkit/src/error.rs create mode 100644 tests/suites/conformance/testkit/src/lib.rs create mode 100644 tests/suites/conformance/testkit/src/poll.rs create mode 100644 tests/suites/conformance/testkit/src/run.rs create mode 100644 tests/suites/conformance/testkit/src/target.rs diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index 89d8c0d392..2c6a2eb277 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -2,24 +2,346 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + [[package]] name = "errno" version = "0.3.14" @@ -27,7 +349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -36,6 +358,156 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -44,390 +516,2279 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "noyalib" +version = "0.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "libm", + "memchr", + "rustc-hash", + "serde_core", + "smallvec", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.8", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openshell-conformance" +version = "0.0.0" +dependencies = [ + "rand 0.9.5", + "serde", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "openshell-conformance-sandbox-lifecycle" +version = "0.0.0" +dependencies = [ + "openshell-testkit", +] + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "glob", + "ipnet", + "miette", + "nix", + "openshell-extension-core", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "reqwest", + "rustix", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tonic-types", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "openshell-extension-core" +version = "0.0.0" +dependencies = [ + "hyper-util", + "serde", + "thiserror 2.0.21", + "tokio", + "tonic", + "tower", +] + +[[package]] +name = "openshell-policy-schema" +version = "0.0.0" +dependencies = [ + "miette", + "noyalib", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror 2.0.21", +] + +[[package]] +name = "openshell-sdk" +version = "0.0.0" +dependencies = [ + "async-stream", + "async-trait", + "futures", + "hyper", + "hyper-util", + "miette", + "oauth2", + "openshell-core", + "reqwest", + "rustls", + "serde", + "thiserror 2.0.21", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "tonic", + "tower", + "tracing", +] + +[[package]] +name = "openshell-test-conformance-cli" +version = "0.0.0" +dependencies = [ + "openshell-conformance", + "tokio", +] + +[[package]] +name = "openshell-testkit" +version = "0.0.0" +dependencies = [ + "futures", + "noyalib", + "openshell-sdk", + "openshell-testkit-macros", + "rand 0.9.5", + "serde", + "tokio", + "tonic", +] + +[[package]] +name = "openshell-testkit-macros" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8760a25b6ff9c620324822737e468478fa092234190d2e449760344354896ed9" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fa2624782ca04cd44f51554566717377acd240e4c0016d757dd74fccc9324f" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2417e9817fa237dab803ad4dda7357a111656e242959cc6b8f9a1a583367d42" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d189c34636356a46a7ed3188233dc8a88c431278cc54d4a19b096a2d270e985" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171e39f1e846e5f322ced1ac3b8d4cd3a3833ca24b6e5d58b3632574fe6204fa" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873cdcc097593432086661aa432b8078f1cd87bfb02847c332e98ae2c119e966" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb72df001783b8297847fe8f5f874ee400fd742c843d60583e8c23d96977c7f" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04652167eca899dda05f32f5481adeaf25c623a98ce2fc146a001cc59a2add7" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263a3f48f01e7309e857138bd47f785585b4a005e8e56c6d2824ce91195999c3" + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bbb29c624230c4bd1047bbdb2aa47e41c860e9665ce62ba9504eebe91bf867" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "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 = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "icu_segmenter", + "unicode-width 0.2.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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[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 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.21", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "lock_api" -version = "0.4.14" +name = "want" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ - "scopeguard", + "try-lock", ] [[package]] -name = "memchr" -version = "2.8.3" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "mio" -version = "1.2.3" +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "libc", - "wasi", - "windows-sys", + "wit-bindgen", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "wasm-bindgen" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] [[package]] -name = "openshell-conformance" -version = "0.0.0" +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ - "rand", - "serde", - "serde_json", - "tempfile", + "js-sys", "tokio", + "wasm-bindgen", ] [[package]] -name = "openshell-test-conformance-cli" -version = "0.0.0" +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ - "openshell-conformance", - "tokio", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "wasm-bindgen-macro-support" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ - "lock_api", - "parking_lot_core", + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "wasm-bindgen-shared" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "unicode-ident", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "web-sys" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "zerocopy", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "quote" -version = "1.0.47" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "rand" -version = "0.9.5" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "rand_chacha", - "rand_core", + "windows-link", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "ppv-lite86", - "rand_core", + "windows-link", ] [[package]] -name = "rand_core" -version = "0.9.5" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "getrandom", + "windows-targets", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "bitflags", + "windows-link", ] [[package]] -name = "rustix" -version = "1.1.5" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "serde" -version = "1.0.229" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "serde_core" -version = "1.0.229" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "serde_derive" -version = "1.0.229" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "serde_json" -version = "1.0.151" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "windows_x86_64_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "smallvec" -version = "1.16.1" +name = "windows_x86_64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "socket2" -version = "0.6.5" +name = "windows_x86_64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys", -] +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "syn" -version = "2.0.119" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "syn" -version = "3.0.5" +name = "writeable" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "yoke-derive" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", ] [[package]] -name = "tokio" -version = "1.53.1" +name = "zerocopy" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", + "zerocopy-derive", ] [[package]] -name = "tokio-macros" -version = "2.7.2" +name = "zerocopy-derive" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.119", ] [[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "zerofrom" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] [[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" +name = "zerofrom-derive" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ - "wit-bindgen", + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "zeroize" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "zerotrie" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ - "windows-link", + "displaydoc", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "zerocopy" -version = "0.8.57" +name = "zerovec" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ - "zerocopy-derive", + "serde", + "yoke", + "zerofrom", + "zerovec-derive", ] [[package]] -name = "zerocopy-derive" -version = "0.8.57" +name = "zerovec-derive" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] diff --git a/tests/suites/conformance/Cargo.toml b/tests/suites/conformance/Cargo.toml index 1c6b001096..7aee0b273f 100644 --- a/tests/suites/conformance/Cargo.toml +++ b/tests/suites/conformance/Cargo.toml @@ -3,4 +3,4 @@ [workspace] resolver = "2" -members = ["cli"] +members = ["cli", "sandbox-lifecycle", "testkit", "testkit-macros"] diff --git a/tests/suites/conformance/sandbox-lifecycle/Cargo.toml b/tests/suites/conformance/sandbox-lifecycle/Cargo.toml new file mode 100644 index 0000000000..cf0ed9b097 --- /dev/null +++ b/tests/suites/conformance/sandbox-lifecycle/Cargo.toml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-conformance-sandbox-lifecycle" +description = "Conformance suite: sandbox-lifecycle" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[dependencies] +openshell-testkit = { path = "../testkit" } diff --git a/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs b/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs new file mode 100644 index 0000000000..b635943bbf --- /dev/null +++ b/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Conformance suite: sandbox-lifecycle. + +use testkit::Context; + +/// A long-running sandbox becomes ready, runs a command with exec, and is gone +/// from get and list after delete. +#[testkit::test] +async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("lr") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + + let marker = format!("openshell-conformance-{}", cx.run_id()); + let output = sandbox.exec(["echo", marker.as_str()]).await?; + testkit::ensure!( + output.exit_code == 0, + "exec exited with {}; stderr: {}", + output.exit_code, + output.stderr + ); + testkit::ensure!( + output.stdout == format!("{marker}\n"), + "exec stdout was {:?}; expected {:?}", + output.stdout, + format!("{marker}\n") + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} diff --git a/tests/suites/conformance/testkit-macros/Cargo.toml b/tests/suites/conformance/testkit-macros/Cargo.toml new file mode 100644 index 0000000000..6f632680e2 --- /dev/null +++ b/tests/suites/conformance/testkit-macros/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-testkit-macros" +description = "The #[testkit::test] attribute for OpenShell conformance tests" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[lib] +name = "testkit_macros" +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } diff --git a/tests/suites/conformance/testkit-macros/src/lib.rs b/tests/suites/conformance/testkit-macros/src/lib.rs new file mode 100644 index 0000000000..6a52cb106a --- /dev/null +++ b/tests/suites/conformance/testkit-macros/src/lib.rs @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The `#[testkit::test]` attribute. +//! +//! A conformance test is an `async fn` taking `&mut testkit::Context` and +//! returning `testkit::Result`. Its doc comment is the test's description and +//! is required. The function name is the test ID. Requirements are named +//! after target capabilities: +//! +//! ```ignore +//! /// An exposed loopback HTTP service is reachable and goes away after delete. +//! #[testkit::test(requires = [service_exposure])] +//! async fn expose_service(cx: &mut Context) -> testkit::Result { /* ... */ } +//! ``` +//! +//! The attribute expands to a `#[tokio::test]` wrapper with the same name, so +//! the nextest test name is the test ID. The original function is nested in +//! the wrapper and passed to `testkit::run`. + +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::{format_ident, quote}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Expr, ExprLit, Ident, ItemFn, Lit, LitStr, Meta, Token, bracketed}; + +#[proc_macro_attribute] +pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { + expand(attr.into(), item.into()) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// Arguments accepted by `#[testkit::test(...)]`. +#[derive(Default)] +struct Args { + requires: Vec, +} + +impl Parse for Args { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut args = Self::default(); + while !input.is_empty() { + let key: Ident = input.parse()?; + if key != "requires" { + return Err(syn::Error::new( + key.span(), + format!("unknown argument `{key}`; expected `requires = [...]`"), + )); + } + input.parse::()?; + let content; + bracketed!(content in input); + args.requires + .extend(Punctuated::::parse_terminated(&content)?); + if !input.is_empty() { + input.parse::()?; + } + } + Ok(args) + } +} + +fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { + let args: Args = syn::parse2(attr)?; + let mut body: ItemFn = syn::parse2(item)?; + + if body.sig.asyncness.is_none() { + return Err(syn::Error::new_spanned( + body.sig.fn_token, + "conformance tests must be `async fn`", + )); + } + if body.sig.inputs.len() != 1 { + return Err(syn::Error::new_spanned( + &body.sig.inputs, + "conformance tests take exactly one argument: `cx: &mut testkit::Context`", + )); + } + + let id = body.sig.ident.clone(); + let description = description(&body)?; + let requires = args.requires.iter().map(|capability| { + let variant = Ident::new(&to_camel_case(&capability.to_string()), capability.span()); + quote!(::testkit::Capability::#variant) + }); + + let vis = body.vis.clone(); + let wrapper_attrs: Vec<_> = body + .attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .cloned() + .collect(); + body.attrs.retain(|attr| !attr.path().is_ident("doc")); + body.vis = syn::Visibility::Inherited; + body.sig.ident = format_ident!("body"); + let id_str = LitStr::new(&id.to_string(), id.span()); + + Ok(quote! { + #(#wrapper_attrs)* + #[::testkit::__private::tokio::test(crate = "::testkit::__private::tokio")] + #vis async fn #id() { + #body + ::testkit::run( + ::testkit::TestInfo { + id: #id_str, + description: #description, + requires: &[#(#requires),*], + }, + body, + ) + .await; + } + }) +} + +/// The test description: the doc comment, joined into one line. +fn description(item: &ItemFn) -> syn::Result { + let lines: Vec = item + .attrs + .iter() + .filter_map(|attr| match &attr.meta { + Meta::NameValue(meta) if meta.path.is_ident("doc") => match &meta.value { + Expr::Lit(ExprLit { + lit: Lit::Str(line), + .. + }) => Some(line.value().trim().to_string()), + _ => None, + }, + _ => None, + }) + .filter(|line| !line.is_empty()) + .collect(); + + if lines.is_empty() { + return Err(syn::Error::new( + item.sig.ident.span(), + "conformance tests need a doc comment describing the behavior they verify", + )); + } + Ok(LitStr::new(&lines.join(" "), Span::call_site())) +} + +fn to_camel_case(snake: &str) -> String { + snake + .split('_') + .map(|word| { + let mut chars = word.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_uppercase().chain(chars).collect() + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::expand; + + fn expand_str(attr: &str, item: &str) -> syn::Result { + expand(attr.parse().unwrap(), item.parse().unwrap()).map(|tokens| tokens.to_string()) + } + + #[test] + fn expands_to_wrapper_with_same_name() { + let out = expand_str( + "", + "/// Does a thing.\nasync fn does_a_thing(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap(); + assert!(out.contains("async fn does_a_thing ()"), "{out}"); + assert!(out.contains("async fn body (cx : & mut Context)"), "{out}"); + assert!(out.contains("id : \"does_a_thing\""), "{out}"); + assert!(out.contains("description : \"Does a thing.\""), "{out}"); + } + + #[test] + fn joins_multi_line_doc_comments() { + let out = expand_str( + "", + "/// First line\n/// second line.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap(); + assert!(out.contains("\"First line second line.\""), "{out}"); + } + + #[test] + fn maps_requirements_to_capabilities() { + let out = expand_str( + "requires = [service_exposure, gpu]", + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap(); + assert!( + out.contains( + "requires : & [:: testkit :: Capability :: ServiceExposure , :: testkit :: Capability :: Gpu]" + ), + "{out}" + ); + } + + #[test] + fn rejects_missing_doc_comment() { + let err = expand_str( + "", + "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("doc comment"), "{err}"); + } + + #[test] + fn rejects_sync_fn() { + let err = expand_str( + "", + "/// Doc.\nfn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("async fn"), "{err}"); + } + + #[test] + fn rejects_wrong_arity() { + let err = + expand_str("", "/// Doc.\nasync fn t() -> testkit::Result { Ok(()) }").unwrap_err(); + assert!(err.to_string().contains("exactly one argument"), "{err}"); + } + + #[test] + fn rejects_unknown_argument() { + let err = expand_str( + "timeout = [x]", + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("unknown argument"), "{err}"); + } +} diff --git a/tests/suites/conformance/testkit/Cargo.toml b/tests/suites/conformance/testkit/Cargo.toml new file mode 100644 index 0000000000..b92cd65c15 --- /dev/null +++ b/tests/suites/conformance/testkit/Cargo.toml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-testkit" +description = "Shared support for OpenShell conformance tests" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[lib] +name = "testkit" + +[dependencies] +openshell-sdk = { path = "../../../../crates/openshell-sdk" } +openshell-testkit-macros = { path = "../testkit-macros" } +futures = "0.3" +rand = "0.9" +serde = { version = "1", features = ["derive"] } +serde_yml = { package = "noyalib", version = "0.0.53", default-features = false, features = ["std", "compat-serde-yaml"] } +tokio = { version = "1.43", features = ["macros", "rt", "time"] } +tonic = { version = "0.14", features = ["tls-native-roots"] } diff --git a/tests/suites/conformance/testkit/src/context.rs b/tests/suites/conformance/testkit/src/context.rs new file mode 100644 index 0000000000..dc9a74fa3e --- /dev/null +++ b/tests/suites/conformance/testkit/src/context.rs @@ -0,0 +1,285 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Per-test context: the SDK client, the run ID, and the resources to clean up. + +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use openshell_sdk::{ + DeleteOptions, ExecOptions, ListOptions, SandboxPhase, SandboxRef, SandboxRestartPolicy, + SandboxSpec, SdkError, WorkspaceScopedClient, +}; +use rand::Rng; + +use crate::error::{Error, Result, ResultExt}; + +/// Sandbox creation includes image pulls on a cold environment. +pub const READY_TIMEOUT: Duration = Duration::from_mins(10); +pub const DELETE_TIMEOUT: Duration = Duration::from_mins(2); +pub const EXEC_TIMEOUT: Duration = Duration::from_mins(2); +const CLEANUP_TIMEOUT: Duration = Duration::from_mins(2); + +/// Names of sandboxes created by a test and not yet confirmed deleted. +type Registry = Arc>>; + +/// What a test uses to act on the system under test. +pub struct Context { + client: WorkspaceScopedClient, + run_id: String, + sandboxes: Registry, +} + +impl Context { + pub(crate) fn new(client: WorkspaceScopedClient) -> Self { + Self { + client, + run_id: generate_run_id(), + sandboxes: Registry::default(), + } + } + + /// The ID unique to this test run, used in resource names. + pub fn run_id(&self) -> &str { + &self.run_id + } + + /// The SDK client, for operations testkit does not wrap. + pub fn client(&self) -> &WorkspaceScopedClient { + &self.client + } + + /// A resource name unique to this run: `ct--`. + pub fn name(&self, suffix: &str) -> String { + format!("ct-{}-{suffix}", self.run_id) + } + + /// Start describing a sandbox named `cx.name(suffix)`. + pub fn sandbox(&self, suffix: &str) -> SandboxBuilder { + SandboxBuilder { + client: self.client.clone(), + sandboxes: Arc::clone(&self.sandboxes), + spec: SandboxSpec { + name: Some(self.name(suffix)), + ..SandboxSpec::default() + }, + } + } + + /// Fail unless the sandbox is gone from both get and list. + pub async fn expect_absent(&self, sandbox: &Sandbox) -> Result { + let name = sandbox.name(); + match self.client.get_sandbox(name).await { + Err(SdkError::NotFound { .. }) => {} + Err(error) => return Err(Error::from(error).context(format!("get sandbox {name}"))), + Ok(found) => { + return Err(Error::msg(format!( + "sandbox {name} still exists (id {}, phase {:?})", + found.id, found.phase + ))); + } + } + let listed = self + .client + .list_all_sandboxes(ListOptions::default()) + .await + .context("list sandboxes")?; + crate::ensure!( + !listed.iter().any(|entry| entry.name == name), + "sandbox {name} is absent from get but still listed" + ); + Ok(()) + } + + /// Delete every sandbox the test created and has not deleted. Returns the + /// cleanup failures; they are reported separately from the test result. + pub(crate) async fn cleanup(&self) -> Vec { + let started = Instant::now(); + let names: Vec = lock(&self.sandboxes).iter().cloned().collect(); + let mut failures = Vec::new(); + for name in names { + let remaining = CLEANUP_TIMEOUT.saturating_sub(started.elapsed()); + let result = async { + self.client + .delete_sandbox( + &name, + DeleteOptions { + allow_missing: true, + }, + ) + .await?; + self.client.wait_deleted(&name, remaining, None).await + } + .await; + match result { + Ok(()) => { + lock(&self.sandboxes).remove(&name); + } + Err(error) => failures.push(format!("delete sandbox {name}: {error}")), + } + } + failures + } +} + +/// A sandbox to create. +pub struct SandboxBuilder { + client: WorkspaceScopedClient, + sandboxes: Registry, + spec: SandboxSpec, +} + +impl SandboxBuilder { + /// The sandbox's main command. Empty selects the gateway's default. + #[must_use] + pub fn command(mut self, command: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.spec.command = command.into_iter().map(Into::into).collect(); + self + } + + #[must_use] + pub fn restart_policy(mut self, policy: SandboxRestartPolicy) -> Self { + self.spec.restart_policy = policy; + self + } + + /// Full access to the spec for fields without a builder method. + #[must_use] + pub fn spec(mut self, update: impl FnOnce(&mut SandboxSpec)) -> Self { + update(&mut self.spec); + self + } + + /// Create the sandbox. It is tracked for cleanup before the request is + /// sent, so a create that fails after the gateway accepted it is still + /// cleaned up. + pub async fn create(self) -> Result { + let name = self.spec.name.clone().unwrap_or_default(); + lock(&self.sandboxes).insert(name.clone()); + let created = self + .client + .create_sandbox(self.spec) + .await + .context(format!("create sandbox {name}"))?; + Ok(Sandbox { + client: self.client, + sandboxes: self.sandboxes, + name, + id: created.id, + }) + } +} + +/// A sandbox created by the test. +pub struct Sandbox { + client: WorkspaceScopedClient, + sandboxes: Registry, + name: String, + id: String, +} + +/// The buffered output of an exec. +#[derive(Debug)] +pub struct ExecOutput { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +impl Sandbox { + pub fn name(&self) -> &str { + &self.name + } + + pub fn id(&self) -> &str { + &self.id + } + + /// Wait until the sandbox is Ready. A sandbox that completes or fails + /// instead is an error. + pub async fn wait_ready(&self) -> Result { + let snapshot = self + .client + .wait_ready(&self.name, READY_TIMEOUT) + .await + .context(format!("wait for sandbox {} to be ready", self.name))?; + crate::ensure!( + snapshot.phase == SandboxPhase::Ready, + "sandbox {} reached phase {:?}; expected Ready", + self.name, + snapshot.phase + ); + Ok(snapshot) + } + + /// Run a command in the sandbox and buffer its output. + pub async fn exec(&self, command: I) -> Result + where + I: IntoIterator, + S: Into, + { + let command: Vec = command.into_iter().map(Into::into).collect(); + let options = ExecOptions { + timeout: Some(EXEC_TIMEOUT), + ..ExecOptions::default() + }; + let result = self + .client + .exec(&self.name, &command, options) + .await + .context(format!("exec {command:?} in sandbox {}", self.name))?; + Ok(ExecOutput { + exit_code: result.exit_code, + stdout: String::from_utf8_lossy(&result.stdout).into_owned(), + stderr: String::from_utf8_lossy(&result.stderr).into_owned(), + }) + } + + /// Delete the sandbox and wait until it is gone. + pub async fn delete(&self) -> Result { + self.client + .delete_sandbox(&self.name, DeleteOptions::default()) + .await + .context(format!("delete sandbox {}", self.name))?; + self.client + .wait_deleted(&self.name, DELETE_TIMEOUT, Some(&self.id)) + .await + .context(format!("wait for sandbox {} to be deleted", self.name))?; + lock(&self.sandboxes).remove(&self.name); + Ok(()) + } +} + +fn lock(registry: &Registry) -> std::sync::MutexGuard<'_, BTreeSet> { + registry.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Short, lowercase, and DNS-label safe, so names work on every driver. +fn generate_run_id() -> String { + const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut rng = rand::rng(); + (0..8) + .map(|_| char::from(ALPHABET[rng.random_range(0..ALPHABET.len())])) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_ids_are_dns_label_safe() { + let id = generate_run_id(); + assert_eq!(id.len(), 8); + assert!( + id.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()), + "{id}" + ); + } +} diff --git a/tests/suites/conformance/testkit/src/error.rs b/tests/suites/conformance/testkit/src/error.rs new file mode 100644 index 0000000000..75f00e5fda --- /dev/null +++ b/tests/suites/conformance/testkit/src/error.rs @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Test failures. + +use std::fmt; + +use openshell_sdk::SdkError; + +/// A test failure: a message with the context it happened in. +#[derive(Debug)] +pub struct Error { + message: String, +} + +/// The result of a test or test step. +pub type Result = std::result::Result; + +impl Error { + pub fn msg(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + pub fn from_display(error: impl fmt::Display) -> Self { + Self::msg(error.to_string()) + } + + #[must_use] + pub fn context(self, context: impl fmt::Display) -> Self { + Self::msg(format!("{context}: {}", self.message)) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(error: SdkError) -> Self { + Self::from_display(error) + } +} + +/// Add context to a failing step. +pub trait ResultExt { + fn context(self, context: impl fmt::Display) -> Result; +} + +impl> ResultExt for std::result::Result { + fn context(self, context: impl fmt::Display) -> Result { + self.map_err(|error| error.into().context(context)) + } +} + +/// Fail the test unless `cond` holds. +/// +/// ```ignore +/// testkit::ensure!(output.exit_code == 0, "exec exited with {}", output.exit_code); +/// ``` +#[macro_export] +macro_rules! ensure { + ($cond:expr $(,)?) => { + if !$cond { + return Err($crate::Error::msg(concat!("condition failed: ", stringify!($cond)))); + } + }; + ($cond:expr, $($arg:tt)+) => { + if !$cond { + return Err($crate::Error::msg(format!($($arg)+))); + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_prefixes_message() { + let result: Result = Err(Error::msg("boom")); + let err = result.context("step two").context("test").unwrap_err(); + assert_eq!(err.to_string(), "test: step two: boom"); + } + + #[test] + fn ensure_returns_error_with_message() { + fn check(value: i32) -> Result { + ensure!(value == 1, "value was {value}"); + Ok(()) + } + assert!(check(1).is_ok()); + assert_eq!(check(2).unwrap_err().to_string(), "value was 2"); + } + + #[test] + fn ensure_without_message_names_condition() { + fn check(value: i32) -> Result { + ensure!(value == 1); + Ok(()) + } + assert_eq!( + check(2).unwrap_err().to_string(), + "condition failed: value == 1" + ); + } +} diff --git a/tests/suites/conformance/testkit/src/lib.rs b/tests/suites/conformance/testkit/src/lib.rs new file mode 100644 index 0000000000..1618683e37 --- /dev/null +++ b/tests/suites/conformance/testkit/src/lib.rs @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared support for `OpenShell` conformance tests. +//! +//! Conformance tests are black-box tests of a running `OpenShell` system, +//! driven through the Rust SDK. Each test is an `async fn` marked +//! `#[testkit::test]`: +//! +//! ```ignore +//! use testkit::Context; +//! +//! /// A long-running sandbox becomes ready, runs exec, and is gone after delete. +//! #[testkit::test] +//! async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { +//! let sandbox = cx.sandbox("lr").command(["sleep", "infinity"]).create().await?; +//! sandbox.wait_ready().await?; +//! // ... +//! Ok(()) +//! } +//! ``` +//! +//! Tests read the system under test from the target file named by +//! `OPENSHELL_TEST_TARGET` (see [`target`]). + +mod context; +mod error; +mod poll; +mod run; +pub mod target; + +pub use context::{ + Context, DELETE_TIMEOUT, EXEC_TIMEOUT, ExecOutput, READY_TIMEOUT, Sandbox, SandboxBuilder, +}; +pub use error::{Error, Result, ResultExt}; +pub use openshell_sdk as sdk; +pub use poll::{Poll, poll_until}; +pub use run::{TestInfo, run}; +pub use target::{Capability, Target}; +pub use testkit_macros::test; + +#[doc(hidden)] +pub mod __private { + pub use tokio; +} diff --git a/tests/suites/conformance/testkit/src/poll.rs b/tests/suites/conformance/testkit/src/poll.rs new file mode 100644 index 0000000000..1f46f49c37 --- /dev/null +++ b/tests/suites/conformance/testkit/src/poll.rs @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Waiting for observable state. + +use std::time::{Duration, Instant}; + +use crate::error::{Error, Result}; + +/// The outcome of one observation. +pub enum Poll { + /// The condition holds. + Ready(T), + /// Not yet; the string describes what was observed, for the timeout report. + Pending(String), +} + +/// Observe until the condition holds or `timeout` elapses. +/// +/// An error from `observe` fails immediately. On timeout, the error reports +/// the last observation. +pub async fn poll_until( + what: &str, + timeout: Duration, + interval: Duration, + mut observe: impl AsyncFnMut() -> Result>, +) -> Result { + let started = Instant::now(); + loop { + match observe().await? { + Poll::Ready(value) => return Ok(value), + Poll::Pending(observation) if started.elapsed() >= timeout => { + return Err(Error::msg(format!( + "timed out after {timeout:?} waiting for {what}; last observation: {observation}" + ))); + } + Poll::Pending(_) => tokio::time::sleep(interval).await, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn returns_when_ready() { + let mut calls = 0; + let value = poll_until( + "three calls", + Duration::from_secs(5), + Duration::ZERO, + async || { + calls += 1; + Ok(if calls == 3 { + Poll::Ready(calls) + } else { + Poll::Pending(format!("call {calls}")) + }) + }, + ) + .await + .unwrap(); + assert_eq!(value, 3); + } + + #[tokio::test] + async fn reports_last_observation_on_timeout() { + let err = poll_until::<()>("never", Duration::ZERO, Duration::ZERO, async || { + Ok(Poll::Pending("still waiting".to_string())) + }) + .await + .unwrap_err(); + assert!(err.to_string().contains("waiting for never"), "{err}"); + assert!(err.to_string().contains("still waiting"), "{err}"); + } + + #[tokio::test] + async fn fails_fast_on_error() { + let err = poll_until::<()>( + "error", + Duration::from_mins(1), + Duration::ZERO, + async || Err(Error::msg("broken")), + ) + .await + .unwrap_err(); + assert_eq!(err.to_string(), "broken"); + } +} diff --git a/tests/suites/conformance/testkit/src/run.rs b/tests/suites/conformance/testkit/src/run.rs new file mode 100644 index 0000000000..bc6b6515ec --- /dev/null +++ b/tests/suites/conformance/testkit/src/run.rs @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Running one test: load the target, check requirements, run, clean up. + +use std::panic::AssertUnwindSafe; + +use futures::FutureExt; + +use crate::context::Context; +use crate::error::Result; +use crate::target::{Capability, Target}; + +/// Identity of a test, generated by `#[testkit::test]`. +#[derive(Clone, Copy, Debug)] +pub struct TestInfo { + pub id: &'static str, + pub description: &'static str, + pub requires: &'static [Capability], +} + +/// Run a test body against the target named by `OPENSHELL_TEST_TARGET`. +/// +/// Cleanup runs whether the body passes, fails, or panics. The test fails if +/// the body fails, or if cleanup fails after the body passed. +/// +/// # Panics +/// +/// Panics to fail the test. +pub async fn run(info: TestInfo, body: F) +where + F: AsyncFnOnce(&mut Context) -> Result, +{ + let id = info.id; + eprintln!("[{id}] {}", info.description); + + let target = Target::from_env().unwrap_or_else(|error| panic!("[{id}] {error}")); + let missing = missing_requirements(&info, &target); + // The runner excludes tests the target can't support. Reaching here means + // it didn't, so fail rather than report a pass for an untested behavior. + assert!( + missing.is_empty(), + "[{id}] requires {}, which the target does not provide", + missing.join(", ") + ); + + let client = target + .connect() + .await + .unwrap_or_else(|error| panic!("[{id}] {error}")); + let mut cx = Context::new(client.workspace(&target.workspace)); + eprintln!("[{id}] run id {}", cx.run_id()); + + let outcome = AssertUnwindSafe(body(&mut cx)).catch_unwind().await; + let cleanup_failures = cx.cleanup().await; + for failure in &cleanup_failures { + eprintln!("[{id}] cleanup failed: {failure}"); + } + + match outcome { + Err(panic) => std::panic::resume_unwind(panic), + Ok(Err(error)) => panic!("[{id}] {error}"), + Ok(Ok(())) => assert!( + cleanup_failures.is_empty(), + "[{id}] passed, but cleanup failed: {}", + cleanup_failures.join("; ") + ), + } +} + +fn missing_requirements(info: &TestInfo, target: &Target) -> Vec<&'static str> { + info.requires + .iter() + .filter(|capability| !target.capabilities.provides(**capability)) + .map(|capability| capability.name()) + .collect() +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn reports_missing_requirements() { + let target = Target::parse( + "gateway:\n endpoint: http://x\ncapabilities:\n gpu: true\n", + PathBuf::new(), + ) + .unwrap(); + let info = TestInfo { + id: "t", + description: "d", + requires: &[Capability::Gpu, Capability::ServiceExposure], + }; + assert_eq!(missing_requirements(&info, &target), ["service_exposure"]); + } +} diff --git a/tests/suites/conformance/testkit/src/target.rs b/tests/suites/conformance/testkit/src/target.rs new file mode 100644 index 0000000000..dc95090524 --- /dev/null +++ b/tests/suites/conformance/testkit/src/target.rs @@ -0,0 +1,291 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The target: how tests reach the system under test, and what it provides. +//! +//! An environment writes a target file; tests find it through +//! [`TARGET_ENV`]. Relative paths in the file resolve against its directory. +//! +//! ```yaml +//! gateway: +//! endpoint: https://127.0.0.1:17670 +//! tls: +//! ca: ca.crt +//! client_cert: client.crt +//! client_key: client.key +//! workspace: default +//! capabilities: +//! compute_driver: docker +//! service_exposure: true +//! ``` + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use openshell_sdk::{AuthConfig, ClientConfig, EdgeAuthInterceptor, OpenShellClient}; +use serde::Deserialize; +use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity}; + +use crate::error::{Error, Result, ResultExt}; + +/// Environment variable naming the target file. +pub const TARGET_ENV: &str = "OPENSHELL_TEST_TARGET"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Target { + pub gateway: Gateway, + #[serde(default = "default_workspace")] + pub workspace: String, + #[serde(default)] + pub capabilities: Capabilities, + #[serde(skip)] + base_dir: PathBuf, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Gateway { + /// Gateway URL; the scheme selects plaintext (`http`) or TLS (`https`). + pub endpoint: String, + #[serde(default)] + pub tls: Option, + #[serde(default)] + pub auth: Option, +} + +/// TLS material. A client certificate and key together select mTLS. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Tls { + pub ca: Option, + pub client_cert: Option, + pub client_key: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Auth { + pub bearer_token_file: PathBuf, +} + +/// Facts about the environment, declared by the environment definition. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Capabilities { + pub arch: Option, + pub compute_driver: Option, + #[serde(default)] + pub rootless: bool, + #[serde(default)] + pub gpu: bool, + #[serde(default)] + pub service_exposure: bool, +} + +/// A capability a test can require with `#[testkit::test(requires = [...])]`. +/// +/// Variants are the snake-case requirement names in CamelCase, so an unknown +/// requirement fails to compile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Capability { + Gpu, + ServiceExposure, +} + +impl Capability { + pub const fn name(self) -> &'static str { + match self { + Self::Gpu => "gpu", + Self::ServiceExposure => "service_exposure", + } + } +} + +impl Capabilities { + pub const fn provides(&self, capability: Capability) -> bool { + match capability { + Capability::Gpu => self.gpu, + Capability::ServiceExposure => self.service_exposure, + } + } +} + +fn default_workspace() -> String { + "default".to_string() +} + +// Mirrors the SDK's endpoint settings for channels testkit builds itself. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10); + +impl Target { + /// Load the target named by [`TARGET_ENV`]. + pub fn from_env() -> Result { + let path = std::env::var_os(TARGET_ENV).ok_or_else(|| { + Error::msg(format!( + "{TARGET_ENV} is not set; it must name the target file for the system under test" + )) + })?; + Self::from_path(Path::new(&path)) + } + + pub fn from_path(path: &Path) -> Result { + let yaml = std::fs::read_to_string(path) + .map_err(Error::from_display) + .context(format!("read target file {}", path.display()))?; + let base_dir = path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + Self::parse(&yaml, base_dir).context(format!("load target file {}", path.display())) + } + + /// Parse a target, resolving relative paths against `base_dir`. + pub fn parse(yaml: &str, base_dir: PathBuf) -> Result { + let mut target: Self = serde_yml::from_str(yaml).map_err(Error::from_display)?; + target.base_dir = base_dir; + target.validate()?; + Ok(target) + } + + fn validate(&self) -> Result { + let gateway = &self.gateway; + let Some(tls) = &gateway.tls else { + return Ok(()); + }; + if tls.client_cert.is_some() != tls.client_key.is_some() { + return Err(Error::msg( + "gateway.tls.client_cert and gateway.tls.client_key must be set together", + )); + } + if !gateway.endpoint.starts_with("https://") { + return Err(Error::msg(format!( + "gateway.tls is set but gateway.endpoint {:?} is not https", + gateway.endpoint + ))); + } + Ok(()) + } + + fn resolve(&self, path: &Path) -> PathBuf { + self.base_dir.join(path) + } + + fn read(&self, path: &Path) -> Result> { + let path = self.resolve(path); + std::fs::read(&path) + .map_err(Error::from_display) + .context(format!("read {}", path.display())) + } + + /// Connect to the target's gateway. + pub async fn connect(&self) -> Result { + let gateway = &self.gateway; + let tls = gateway.tls.as_ref(); + let ca = tls + .and_then(|tls| tls.ca.as_deref()) + .map(|path| self.read(path)) + .transpose()?; + let token = gateway + .auth + .as_ref() + .map(|auth| { + self.read(&auth.bearer_token_file) + .map(|bytes| String::from_utf8_lossy(&bytes).trim().to_string()) + }) + .transpose()?; + + let identity = match tls.map(|tls| (&tls.client_cert, &tls.client_key)) { + Some((Some(cert), Some(key))) => { + Some(Identity::from_pem(self.read(cert)?, self.read(key)?)) + } + _ => None, + }; + + let Some(identity) = identity else { + let mut config = ClientConfig::new(gateway.endpoint.clone()); + config.ca_cert = ca; + config.auth = token.map(AuthConfig::oidc); + return OpenShellClient::connect(config) + .await + .context(format!("connect to {}", gateway.endpoint)); + }; + + // The SDK does not speak mTLS, so build the channel here and hand it + // to the SDK. Every SDK call, including exec, uses this channel. + let tls_config = ca.map_or_else( + || ClientTlsConfig::new().with_enabled_roots(), + |ca| ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca)), + ); + let channel = Endpoint::from_shared(gateway.endpoint.clone()) + .map_err(Error::from_display)? + .connect_timeout(CONNECT_TIMEOUT) + .http2_adaptive_window(true) + .http2_keep_alive_interval(KEEP_ALIVE_INTERVAL) + .keep_alive_while_idle(true) + .tls_config(tls_config.identity(identity)) + .map_err(Error::from_display)? + .connect() + .await + .map_err(Error::from_display) + .context(format!("connect to {} with mTLS", gateway.endpoint))?; + let interceptor = EdgeAuthInterceptor::new(token.as_deref(), None)?; + Ok(OpenShellClient::from_parts(channel, interceptor)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(yaml: &str) -> Result { + Target::parse(yaml, PathBuf::from("/targets/vm")) + } + + #[test] + fn parses_mtls_target_and_resolves_relative_paths() { + let target = parse( + "gateway:\n endpoint: https://127.0.0.1:17670\n tls:\n ca: ca.crt\n client_cert: client.crt\n client_key: /abs/client.key\ncapabilities:\n compute_driver: docker\n service_exposure: true\n", + ) + .unwrap(); + let tls = target.gateway.tls.as_ref().unwrap(); + assert_eq!(target.workspace, "default"); + assert_eq!( + target.resolve(tls.ca.as_deref().unwrap()), + PathBuf::from("/targets/vm/ca.crt") + ); + assert_eq!( + target.resolve(tls.client_key.as_deref().unwrap()), + PathBuf::from("/abs/client.key") + ); + assert!(target.capabilities.provides(Capability::ServiceExposure)); + assert!(!target.capabilities.provides(Capability::Gpu)); + } + + #[test] + fn parses_plaintext_target() { + let target = parse("gateway:\n endpoint: http://127.0.0.1:17670\n").unwrap(); + assert!(target.gateway.tls.is_none()); + assert!(!target.capabilities.provides(Capability::ServiceExposure)); + } + + #[test] + fn rejects_unknown_fields() { + let err = parse("gateway:\n endpoint: http://x\n endpiont: http://y\n").unwrap_err(); + assert!(err.to_string().contains("endpiont"), "{err}"); + } + + #[test] + fn rejects_half_configured_client_identity() { + let err = parse("gateway:\n endpoint: https://x\n tls:\n client_cert: client.crt\n") + .unwrap_err(); + assert!(err.to_string().contains("set together"), "{err}"); + } + + #[test] + fn rejects_tls_on_plaintext_endpoint() { + let err = parse("gateway:\n endpoint: http://x\n tls:\n ca: ca.crt\n").unwrap_err(); + assert!(err.to_string().contains("not https"), "{err}"); + } +} From f4259ebaf221d102529e676ebcaa6024a70fc24c Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 15 Oct 2026 23:21:07 +0200 Subject: [PATCH 02/18] test(tmachine): add session API, test targets, and environment definitions Give the conformance test runner a way to act on a running test machine, and describe the environments it can run in. tmachine: - Add a library target exposing Session: up (set up and install from cached disks, boot a throwaway overlay, wait for SSH), exec, copy_in, copy_out, collect_diagnostics, shell, run_playbooks, and down. QEMU stays a child of the session, so a dropped session cannot leak a VM. - Rebuild `tmachine test` on Session; the CLI is unchanged. - Use password-only SSH so keys from the caller's agent or ~/.ssh cannot exhaust the guest's authentication attempts. - Let TMACHINE_FIRMWARE_CODE/VARS be set at runtime, falling back to the compiled-in paths, so library users built outside Nix can boot ARM guests. Ansible: - openshell_client writes /etc/openshell-test/gateway.yaml describing the registered gateway (endpoint, plus mTLS paths when client credentials exist). The runner combines it with environment capabilities into the target file tests read. Runner: - Add tests/runner with the environment definition loader and tests/environments/docker.yaml (Ubuntu 24.04 Docker rootful deb, amd64 and arm64). IDs must match their fields, tmachine environments must name their tmachine environment and installer, and an architecture the host cannot boot is reported as unsupported. Signed-off-by: Evan Lezar --- .../roles/openshell_client/defaults/main.yaml | 6 + .../roles/openshell_client/tasks/main.yaml | 37 +++ tests/environments/docker.yaml | 20 ++ tests/runner/Cargo.lock | 146 +++++++++ tests/runner/Cargo.toml | 19 ++ tests/runner/src/environments.rs | 288 ++++++++++++++++++ tests/runner/src/lib.rs | 8 + tests/tmachine/Cargo.toml | 2 +- tests/tmachine/src/lib.rs | 13 + tests/tmachine/src/main.rs | 13 +- tests/tmachine/src/qemu/mod.rs | 2 + tests/tmachine/src/qemu/session.rs | 260 ++++++++++++++++ tests/tmachine/src/qemu/test.rs | 53 +--- tests/tmachine/src/qemu/vm.rs | 28 +- 14 files changed, 839 insertions(+), 56 deletions(-) create mode 100644 tests/ansible/roles/openshell_client/defaults/main.yaml create mode 100644 tests/environments/docker.yaml create mode 100644 tests/runner/Cargo.lock create mode 100644 tests/runner/Cargo.toml create mode 100644 tests/runner/src/environments.rs create mode 100644 tests/runner/src/lib.rs create mode 100644 tests/tmachine/src/lib.rs create mode 100644 tests/tmachine/src/qemu/session.rs diff --git a/tests/ansible/roles/openshell_client/defaults/main.yaml b/tests/ansible/roles/openshell_client/defaults/main.yaml new file mode 100644 index 0000000000..8e90954947 --- /dev/null +++ b/tests/ansible/roles/openshell_client/defaults/main.yaml @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +openshell_client_config_dir: >- + /home/{{ ansible_user }}/.config/openshell/gateways/{{ openshell_client_gateway_name | default('tmachine') }} diff --git a/tests/ansible/roles/openshell_client/tasks/main.yaml b/tests/ansible/roles/openshell_client/tasks/main.yaml index 6586f23ac2..66ce6464de 100644 --- a/tests/ansible/roles/openshell_client/tasks/main.yaml +++ b/tests/ansible/roles/openshell_client/tasks/main.yaml @@ -12,3 +12,40 @@ - --local - --name - "{{ openshell_client_gateway_name | default('tmachine') }}" + +# Describe the registered gateway for conformance tests. The test runner +# combines this with the environment's capabilities into the target file +# that tests read through OPENSHELL_TEST_TARGET. +- name: Read registered gateway metadata + ansible.builtin.slurp: + src: "{{ openshell_client_config_dir }}/metadata.json" + register: openshell_client_metadata + +- name: Check for registered mTLS client credentials + ansible.builtin.stat: + path: "{{ openshell_client_config_dir }}/mtls/tls.crt" + register: openshell_client_mtls + +- name: Create test target directory + become: true + ansible.builtin.file: + path: /etc/openshell-test + state: directory + mode: "0755" + +- name: Write gateway section of the test target + become: true + ansible.builtin.copy: + dest: /etc/openshell-test/gateway.yaml + mode: "0644" + content: | + # Written by the openshell_client role. + {{ {'gateway': openshell_client_target_gateway} | to_nice_yaml(indent=2) }} + vars: + openshell_client_target_tls: + ca: "{{ openshell_client_config_dir }}/mtls/ca.crt" + client_cert: "{{ openshell_client_config_dir }}/mtls/tls.crt" + client_key: "{{ openshell_client_config_dir }}/mtls/tls.key" + openshell_client_target_gateway: >- + {{ {'endpoint': (openshell_client_metadata.content | b64decode | from_json).gateway_endpoint} + | combine({'tls': openshell_client_target_tls} if openshell_client_mtls.stat.exists else {}) }} diff --git a/tests/environments/docker.yaml b/tests/environments/docker.yaml new file mode 100644 index 0000000000..d847b84faa --- /dev/null +++ b/tests/environments/docker.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Docker-backed environments. The ID must equal +# .-... +environments: + - id: tmachine.ubuntu2404-amd64.docker-rootful.deb + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: amd64 } + runtime: docker-rootful + installer: deb + tmachine: { environment: ubuntu-docker-rootful, installer: deb } + capabilities: { compute_driver: docker, rootless: false, gpu: false } + - id: tmachine.ubuntu2404-arm64.docker-rootful.deb + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: arm64 } + runtime: docker-rootful + installer: deb + tmachine: { environment: ubuntu-docker-rootful, installer: deb } + capabilities: { compute_driver: docker, rootless: false, gpu: false } diff --git a/tests/runner/Cargo.lock b/tests/runner/Cargo.lock new file mode 100644 index 0000000000..41626bb911 --- /dev/null +++ b/tests/runner/Cargo.lock @@ -0,0 +1,146 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "noyalib" +version = "0.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" +dependencies = [ + "hashbrown", + "indexmap", + "libm", + "memchr", + "rustc-hash", + "serde_core", + "smallvec", +] + +[[package]] +name = "openshell-test-runner" +version = "0.0.0" +dependencies = [ + "anyhow", + "noyalib", + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" diff --git a/tests/runner/Cargo.toml b/tests/runner/Cargo.toml new file mode 100644 index 0000000000..2260710398 --- /dev/null +++ b/tests/runner/Cargo.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-test-runner" +description = "Runs OpenShell test plans against provisioned environments" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_yml = { package = "noyalib", version = "0.0.53", default-features = false, features = ["std", "compat-serde-yaml"] } + +[workspace] diff --git a/tests/runner/src/environments.rs b/tests/runner/src/environments.rs new file mode 100644 index 0000000000..026381e7ab --- /dev/null +++ b/tests/runner/src/environments.rs @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Environment definitions: `tests/environments/*.yaml`. +//! +//! Each entry describes one environment a plan can name: the machine, the +//! container runtime, the installer, how the backend provisions it, and the +//! capabilities it gives tests. Capabilities pass through to the target file +//! unchanged; testkit validates their names. + +use std::collections::BTreeMap; +use std::fmt; +use std::path::Path; + +use anyhow::{Context, Result, bail, ensure}; +use serde::Deserialize; + +/// All environments, keyed by ID. +#[derive(Debug, Default)] +pub struct Catalog { + environments: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + environments: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Environment { + pub id: String, + pub backend: Backend, + pub machine: Machine, + pub runtime: String, + pub installer: String, + /// Backend parameters when `backend` is `tmachine`. + #[serde(default)] + pub tmachine: Option, + #[serde(default)] + pub capabilities: BTreeMap, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Backend { + Tmachine, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Machine { + pub os: String, + pub version: String, + pub arch: Arch, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Arch { + Amd64, + Arm64, +} + +/// The tmachine environment and installer that provision this environment. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Tmachine { + pub environment: String, + pub installer: String, +} + +impl fmt::Display for Backend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Tmachine => "tmachine", + }) + } +} + +impl fmt::Display for Arch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Amd64 => "amd64", + Self::Arm64 => "arm64", + }) + } +} + +impl Arch { + /// The architecture of the machine running this process. + pub const fn host() -> Option { + if cfg!(target_arch = "x86_64") { + Some(Self::Amd64) + } else if cfg!(target_arch = "aarch64") { + Some(Self::Arm64) + } else { + None + } + } +} + +impl Environment { + /// The ID implied by the environment's fields. + pub fn expected_id(&self) -> String { + format!( + "{}.{}{}-{}.{}.{}", + self.backend, + self.machine.os, + self.machine.version.replace('.', ""), + self.machine.arch, + self.runtime, + self.installer + ) + } + + fn validate(&self) -> Result<()> { + let expected = self.expected_id(); + ensure!( + self.id == expected, + "environment {:?} does not match its fields; expected ID {expected:?}", + self.id + ); + match self.backend { + Backend::Tmachine => ensure!( + self.tmachine.is_some(), + "environment {:?} uses the tmachine backend but has no `tmachine` section", + self.id + ), + } + Ok(()) + } + + /// Why the backend can't provision this environment on this host, if it + /// can't. tmachine boots guests of the host's architecture only. + pub fn unsupported_reason(&self, host: Option) -> Option { + match self.backend { + Backend::Tmachine if host != Some(self.machine.arch) => Some(format!( + "tmachine boots {} guests only on {} hosts", + self.machine.arch, self.machine.arch + )), + Backend::Tmachine => None, + } + } +} + +impl Catalog { + /// Load every `*.yaml` file in `dir`. + pub fn load_dir(dir: &Path) -> Result { + let mut paths: Vec<_> = std::fs::read_dir(dir) + .with_context(|| format!("read environments directory {}", dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>() + .with_context(|| format!("read environments directory {}", dir.display()))?; + paths.retain(|path| path.extension().is_some_and(|ext| ext == "yaml")); + paths.sort(); + + let mut catalog = Self::default(); + for path in paths { + let yaml = std::fs::read_to_string(&path) + .with_context(|| format!("read {}", path.display()))?; + catalog + .add_yaml(&yaml) + .with_context(|| format!("load environments from {}", path.display()))?; + } + Ok(catalog) + } + + fn add_yaml(&mut self, yaml: &str) -> Result<()> { + let file: File = serde_yml::from_str(yaml)?; + for environment in file.environments { + environment.validate()?; + if self.environments.contains_key(&environment.id) { + bail!("environment {:?} is defined more than once", environment.id); + } + self.environments + .insert(environment.id.clone(), environment); + } + Ok(()) + } + + pub fn get(&self, id: &str) -> Option<&Environment> { + self.environments.get(id) + } + + pub fn iter(&self) -> impl Iterator { + self.environments.values() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + const UBUNTU_DOCKER: &str = " +environments: + - id: tmachine.ubuntu2404-amd64.docker-rootful.deb + backend: tmachine + machine: { os: ubuntu, version: \"24.04\", arch: amd64 } + runtime: docker-rootful + installer: deb + tmachine: { environment: ubuntu-docker-rootful, installer: deb } + capabilities: { compute_driver: docker, gpu: false } +"; + + fn catalog(yaml: &str) -> Result { + let mut catalog = Catalog::default(); + catalog.add_yaml(yaml)?; + Ok(catalog) + } + + #[test] + fn loads_environment() { + let catalog = catalog(UBUNTU_DOCKER).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + assert_eq!(env.machine.arch, Arch::Amd64); + assert_eq!( + env.tmachine, + Some(Tmachine { + environment: "ubuntu-docker-rootful".to_string(), + installer: "deb".to_string(), + }) + ); + assert_eq!( + env.capabilities.get("compute_driver"), + Some(&serde_yml::Value::from("docker")) + ); + } + + #[test] + fn rejects_id_that_does_not_match_fields() { + let yaml = UBUNTU_DOCKER.replace("docker-rootful.deb", "docker-rootless.deb"); + let err = catalog(&yaml).unwrap_err(); + assert!( + err.to_string().contains("does not match its fields"), + "{err}" + ); + } + + #[test] + fn rejects_tmachine_environment_without_tmachine_section() { + let yaml = UBUNTU_DOCKER.replace( + " tmachine: { environment: ubuntu-docker-rootful, installer: deb }\n", + "", + ); + let err = catalog(&yaml).unwrap_err(); + assert!(err.to_string().contains("no `tmachine` section"), "{err}"); + } + + #[test] + fn rejects_unknown_fields() { + let yaml = UBUNTU_DOCKER.replace("runtime:", "runtme:"); + assert!(catalog(&yaml).is_err()); + } + + #[test] + fn rejects_duplicate_ids() { + let mut catalog = catalog(UBUNTU_DOCKER).unwrap(); + let err = catalog.add_yaml(UBUNTU_DOCKER).unwrap_err(); + assert!(err.to_string().contains("more than once"), "{err}"); + } + + #[test] + fn reports_arch_mismatch_as_unsupported() { + let catalog = catalog(UBUNTU_DOCKER).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + assert_eq!(env.unsupported_reason(Some(Arch::Amd64)), None); + assert!(env.unsupported_reason(Some(Arch::Arm64)).is_some()); + } + + #[test] + fn repository_environments_are_valid() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../environments"); + let catalog = Catalog::load_dir(&dir).unwrap(); + assert!( + catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .is_some() + ); + } +} diff --git a/tests/runner/src/lib.rs b/tests/runner/src/lib.rs new file mode 100644 index 0000000000..db3c9f1035 --- /dev/null +++ b/tests/runner/src/lib.rs @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Runs `OpenShell` test plans: expands a plan into jobs, provisions each +//! environment through its backend, runs the selected suites inside it, and +//! reports the results. + +pub mod environments; diff --git a/tests/tmachine/Cargo.toml b/tests/tmachine/Cargo.toml index 46358ea4e0..785ee5ea91 100644 --- a/tests/tmachine/Cargo.toml +++ b/tests/tmachine/Cargo.toml @@ -18,6 +18,6 @@ directories = { version = "6.0.0", default-features = false } serde = { version = "1.0.229", default-features = false, features = ["std", "derive"] } serde-saphyr = { version = "1.2.0", default-features = false, features = ["deserialize"] } tempfile = { version = "3.27.0", default-features = false } -tokio = { version = "1.53.1", default-features = false, features = ["macros", "rt", "process", "net", "io-util"] } +tokio = { version = "1.53.1", default-features = false, features = ["macros", "rt", "process", "net", "io-util", "time"] } [workspace] diff --git a/tests/tmachine/src/lib.rs b/tests/tmachine/src/lib.rs new file mode 100644 index 0000000000..ff1db593bc --- /dev/null +++ b/tests/tmachine/src/lib.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Isolated QEMU test machines provisioned with Ansible. +//! +//! The `tmachine` binary drives one-shot `setup`, `install`, and `test` +//! commands. Callers that need to act on a running machine use [`Session`]. + +pub mod ansible; +pub mod config; +mod qemu; + +pub use qemu::{Session, install, setup, test}; diff --git a/tests/tmachine/src/main.rs b/tests/tmachine/src/main.rs index 3c0d1a4eee..69f9f006c1 100644 --- a/tests/tmachine/src/main.rs +++ b/tests/tmachine/src/main.rs @@ -5,11 +5,8 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use config::{Config, Environment, Installer, Machine}; - -mod ansible; -mod config; -mod qemu; +use tmachine::config::{Config, Environment, Installer, Machine}; +use tmachine::{install, setup, test}; #[derive(Parser)] #[command(name = "tmachine")] @@ -45,7 +42,7 @@ async fn main() -> Result<()> { match cli.command { Command::Setup { environment } => { let (machine, environment) = find_environment(&config, &environment)?; - qemu::setup(&machine, &environment).await?; + setup(&machine, &environment).await?; } Command::Install { environment, @@ -53,7 +50,7 @@ async fn main() -> Result<()> { } => { let (machine, environment) = find_environment(&config, &environment)?; let installer = find_installer(&config, &installer)?; - qemu::install(&machine, &environment, &installer).await?; + install(&machine, &environment, &installer).await?; } Command::Test { environment, @@ -67,7 +64,7 @@ async fn main() -> Result<()> { .iter() .find(|candidate| candidate.name == testsuite) .with_context(|| format!("testsuite {testsuite:?} is not defined"))?; - qemu::test(&machine, &environment, &installer, testsuite).await?; + test(&machine, &environment, &installer, testsuite).await?; } } diff --git a/tests/tmachine/src/qemu/mod.rs b/tests/tmachine/src/qemu/mod.rs index c13ebbf56b..5be890226c 100644 --- a/tests/tmachine/src/qemu/mod.rs +++ b/tests/tmachine/src/qemu/mod.rs @@ -5,10 +5,12 @@ mod ansible_hash; mod img; mod install; mod layer; +mod session; mod setup; mod test; mod vm; pub use install::install; +pub use session::Session; pub use setup::setup; pub use test::test; diff --git a/tests/tmachine/src/qemu/session.rs b/tests/tmachine/src/qemu/session.rs new file mode 100644 index 0000000000..25d4b74a14 --- /dev/null +++ b/tests/tmachine/src/qemu/session.rs @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! A running machine that callers act on over SSH. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{ExitStatus, Output, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail, ensure}; +use tempfile::{TempDir, tempdir}; +use tokio::process::Command; + +use crate::config::{Environment, Installer, Machine}; + +use super::img::QemuImage; +use super::install::install; +use super::layer::run_playbooks; +use super::vm::QemuVm; + +// Must match the QEMU port forward in `vm.rs` and `ansible/inventory.ini`. +const SSH_HOST: &str = "127.0.0.1"; +const SSH_PORT: &str = "2222"; +const SSH_USER: &str = "tmachine"; +const SSH_PASSWORD: &str = "tmachine"; +// Password authentication only: keys from the caller's agent or ~/.ssh would +// otherwise use up the guest's authentication attempts first. +const SSH_OPTIONS: [&str; 14] = [ + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", + "-o", + "ConnectTimeout=5", + "-o", + "PreferredAuthentications=password", + "-o", + "PubkeyAuthentication=no", + "-o", + "IdentityAgent=none", +]; +const SSH_READY_TIMEOUT: Duration = Duration::from_secs(300); +const SSH_READY_INTERVAL: Duration = Duration::from_secs(2); + +/// A machine booted from a throwaway overlay of the installed disk. +/// +/// The VM stops when the session is dropped, so a caller that fails or +/// panics cannot leak it. Call [`Session::down`] for a clean shutdown. +pub struct Session { + // Declared before `_dir` so the VM stops before its disk is removed. + vm: Option, + _image: QemuImage, + _dir: TempDir, +} + +impl Session { + /// Set up and install the environment (reusing cached disks), boot a VM + /// from a throwaway overlay, and wait until it accepts SSH. + pub async fn up( + machine: &Machine, + environment: &Environment, + installer: &Installer, + ) -> Result { + let install_disk = install(machine, environment, installer).await?; + let dir = tempdir().context("create session directory")?; + let image = QemuImage::create(&install_disk, dir.path().join("session.qcow2")).await; + let vm = QemuVm::start(&image).await; + let mut session = Self { + vm: Some(vm), + _image: image, + _dir: dir, + }; + session.wait_for_ssh().await?; + Ok(session) + } + + async fn wait_for_ssh(&mut self) -> Result<()> { + let started = Instant::now(); + loop { + if let Some(status) = self.vm.as_mut().and_then(QemuVm::try_wait) { + bail!( + "QEMU exited with {status} before SSH was ready; is another tmachine VM using port {SSH_PORT}?" + ); + } + match self.exec(&["true"]).await { + Ok(output) if output.status.success() => return Ok(()), + Ok(_) | Err(_) if started.elapsed() < SSH_READY_TIMEOUT => { + tokio::time::sleep(SSH_READY_INTERVAL).await; + } + Ok(output) => bail!( + "SSH was not ready after {SSH_READY_TIMEOUT:?}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + Err(error) => { + return Err( + error.context(format!("SSH was not ready after {SSH_READY_TIMEOUT:?}")) + ); + } + } + } + } + + /// Run Ansible playbooks against the machine. + pub async fn run_playbooks( + &self, + playbooks: &[PathBuf], + inputs: &BTreeMap, + ) -> Result<()> { + run_playbooks(playbooks, inputs).await + } + + /// Run a command in the machine as the `tmachine` user and capture its + /// output. A non-zero exit is returned in the output, not as an error. + pub async fn exec(&self, argv: &[&str]) -> Result { + ensure!(!argv.is_empty(), "exec needs a command"); + let remote = argv + .iter() + .map(|arg| shell_quote(arg)) + .collect::>() + .join(" "); + ssh_command("ssh", &["-p", SSH_PORT]) + .arg(format!("{SSH_USER}@{SSH_HOST}")) + .arg("--") + .arg(remote) + .stdin(Stdio::null()) + .output() + .await + .with_context(|| format!("run {argv:?} in tmachine VM")) + } + + /// Copy a local file or directory into the machine. + pub async fn copy_in(&self, src: &Path, dst: &str) -> Result<()> { + let remote = format!("{SSH_USER}@{SSH_HOST}:{dst}"); + scp(src.as_os_str().to_string_lossy().as_ref(), &remote) + .await + .with_context(|| format!("copy {} to {dst} in tmachine VM", src.display())) + } + + /// Copy a file or directory out of the machine. + pub async fn copy_out(&self, src: &str, dst: &Path) -> Result<()> { + let remote = format!("{SSH_USER}@{SSH_HOST}:{src}"); + scp(&remote, dst.as_os_str().to_string_lossy().as_ref()) + .await + .with_context(|| format!("copy {src} from tmachine VM to {}", dst.display())) + } + + /// Write the OpenShell gateway's journal to `dir/gateway.log`. + pub async fn collect_diagnostics(&self, dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir) + .with_context(|| format!("create diagnostics directory {}", dir.display()))?; + let output = self + .exec(&[ + "sudo", + "-n", + "journalctl", + "--no-pager", + "--lines", + "2000", + "_SYSTEMD_UNIT=openshell-gateway.service", + "+", + "_SYSTEMD_USER_UNIT=openshell-gateway.service", + ]) + .await?; + let log = dir.join("gateway.log"); + let mut contents = output.stdout; + contents.extend_from_slice(&output.stderr); + std::fs::write(&log, contents).with_context(|| format!("write {}", log.display())) + } + + /// Open an interactive SSH shell in the machine. + pub async fn shell(&self) -> Result { + ssh_command("ssh", &["-tt", "-p", SSH_PORT]) + .arg(format!("{SSH_USER}@{SSH_HOST}")) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await + .context("open SSH shell in tmachine VM") + } + + /// Power the machine off and wait for QEMU to exit. + pub async fn down(mut self) { + if let Some(vm) = self.vm.take() { + vm.shutdown().await; + vm.wait().await; + } + } +} + +fn ssh_command(program: &str, args: &[&str]) -> Command { + let mut command = Command::new("sshpass"); + command + .env("SSHPASS", SSH_PASSWORD) + .arg("-e") + .arg(program) + .args(args) + .args(SSH_OPTIONS); + command +} + +async fn scp(src: &str, dst: &str) -> Result<()> { + let output = ssh_command("scp", &["-r", "-P", SSH_PORT]) + .arg(src) + .arg(dst) + .stdin(Stdio::null()) + .output() + .await + .context("run scp")?; + ensure!( + output.status.success(), + "scp exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(()) +} + +/// Quote an argument for the remote POSIX shell that SSH runs commands in. +fn shell_quote(arg: &str) -> String { + let safe = !arg.is_empty() + && arg + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%".contains(&b)); + if safe { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', r"'\''")) + } +} + +#[cfg(test)] +mod tests { + use super::shell_quote; + + #[test] + fn leaves_plain_arguments_unquoted() { + assert_eq!(shell_quote("cargo-nextest"), "cargo-nextest"); + assert_eq!(shell_quote("/var/lib/x.tar.zst"), "/var/lib/x.tar.zst"); + assert_eq!(shell_quote("KEY=value"), "KEY=value"); + } + + #[test] + fn quotes_shell_metacharacters() { + assert_eq!( + shell_quote("binary(a) | binary(b)"), + "'binary(a) | binary(b)'" + ); + assert_eq!(shell_quote("$HOME"), "'$HOME'"); + assert_eq!(shell_quote(""), "''"); + } + + #[test] + fn escapes_single_quotes() { + assert_eq!(shell_quote("it's"), r"'it'\''s'"); + } +} diff --git a/tests/tmachine/src/qemu/test.rs b/tests/tmachine/src/qemu/test.rs index ac8a8bdfc0..cd4cb95d48 100644 --- a/tests/tmachine/src/qemu/test.rs +++ b/tests/tmachine/src/qemu/test.rs @@ -1,18 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::process::Stdio; - -use anyhow::{Context, Result}; -use tempfile::tempdir; -use tokio::process::Command; +use anyhow::Result; use crate::config::{Environment, Installer, Machine, Testsuite}; -use super::img::QemuImage; -use super::install::install; -use super::layer::run_playbooks; -use super::vm::QemuVm; +use super::session::Session; pub async fn test( machine: &Machine, @@ -20,46 +13,20 @@ pub async fn test( installer: &Installer, testsuite: &Testsuite, ) -> Result<()> { - let install_disk = install(machine, environment, installer).await?; - let test_dir = tempdir().unwrap(); - let test_disk = test_dir.path().join("test.qcow2"); - let image = QemuImage::create(&install_disk, test_disk).await; - let vm = QemuVm::start(&image).await; - - run_playbooks(&testsuite.playbooks, &testsuite.inputs).await?; + let session = Session::up(machine, environment, installer).await?; + session + .run_playbooks(&testsuite.playbooks, &testsuite.inputs) + .await?; if testsuite.interactive { println!("Opening an SSH shell in the tmachine VM."); - let ssh_status = Command::new("sshpass") - .env("SSHPASS", "tmachine") - .args([ - "-e", - "ssh", - "-tt", - "-p", - "2222", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "LogLevel=ERROR", - "tmachine@127.0.0.1", - ]) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .await - .context("open SSH shell in tmachine VM")?; - - vm.shutdown().await; - vm.wait().await; + let ssh_status = session.shell().await; + session.down().await; + let ssh_status = ssh_status?; anyhow::ensure!(ssh_status.success(), "SSH shell exited with {ssh_status}"); return Ok(()); } - vm.shutdown().await; - vm.wait().await; + session.down().await; Ok(()) } diff --git a/tests/tmachine/src/qemu/vm.rs b/tests/tmachine/src/qemu/vm.rs index 1e035632e2..c65ba7d7b4 100644 --- a/tests/tmachine/src/qemu/vm.rs +++ b/tests/tmachine/src/qemu/vm.rs @@ -38,6 +38,11 @@ impl QemuVm { Self { child, runtime_dir } } + /// The exit status, if QEMU has already exited. + pub(super) fn try_wait(&mut self) -> Option { + self.child.try_wait().ok().flatten() + } + pub(super) async fn wait(mut self) { let status = self.child.wait().await.unwrap(); assert!(status.success()); @@ -117,8 +122,10 @@ fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) - .arg(format!("unix:{},server=on,wait=off", qmp_socket.display())); if let Some(firmware_vars) = firmware_vars { - let firmware_code = option_env!("TMACHINE_FIRMWARE_CODE") - .expect("TMACHINE_FIRMWARE_CODE must be set for ARM builds"); + let firmware_code = firmware_path( + "TMACHINE_FIRMWARE_CODE", + option_env!("TMACHINE_FIRMWARE_CODE"), + ); command .arg("-drive") .arg(format!( @@ -154,11 +161,24 @@ fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) - command } +/// A firmware path from the runtime environment, falling back to the value +/// compiled in. The fallback lets library users built outside Nix (such as +/// the test runner) supply firmware at runtime. +#[cfg(target_arch = "aarch64")] +fn firmware_path(name: &str, compiled: Option<&'static str>) -> String { + std::env::var(name) + .ok() + .or_else(|| compiled.map(str::to_string)) + .unwrap_or_else(|| panic!("{name} must be set for ARM builds")) +} + fn prepare_firmware(runtime_dir: &Path) -> Option { #[cfg(target_arch = "aarch64")] { - let template = option_env!("TMACHINE_FIRMWARE_VARS") - .expect("TMACHINE_FIRMWARE_VARS must be set for ARM builds"); + let template = firmware_path( + "TMACHINE_FIRMWARE_VARS", + option_env!("TMACHINE_FIRMWARE_VARS"), + ); let firmware_vars = runtime_dir.join("firmware-vars.fd"); fs::copy(template, &firmware_vars).unwrap(); fs::set_permissions(&firmware_vars, fs::Permissions::from_mode(0o600)).unwrap(); From 898ca460ed954850d468f146d06083067fafc5aa Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 15 Oct 2026 23:34:23 +0200 Subject: [PATCH 03/18] test(runner): run test plans in tmachine environments Add the openshell-test runner, which runs a test plan end to end: nix run .#test-runner -- test --plan tests/plans/cpu-tests.yaml - Plans (tests/plans/*.yaml) select families, suites, and environments. Unknown families, suites, environments, and fields fail before anything is built or booted. - The catalog is read from source: each tests/*.rs file of a family workspace containing #[testkit::test] functions is a suite. Parsing moves into testkit-spec, shared by the macro and the runner, so they cannot disagree about a test's ID, description, or requirements. - Jobs are grouped by environment so each one is provisioned once. Tests whose requirements the environment lacks are excluded by exact name and reported as unsupported, as are environments the host cannot boot. - Each family is cross-built into a nextest archive bundle for the guest, checked against the catalog, run in a tmachine session against the target formed from the installer's gateway section and the environment's capabilities, and its JUnit report, log, and (on failure) gateway diagnostics are copied to artifacts/test-results////. - --dry-run prints resolved jobs and filtersets; --env and --suite narrow a plan; --keep leaves each VM up after its tests; `catalog` prints every suite and test as JSON. - The test-runner Nix package provides the cross toolchains, QEMU, Ansible, firmware, and tmachine configuration. Document test plans and conformance suites in TESTING.md. Signed-off-by: Evan Lezar --- TESTING.md | 58 ++ flake.nix | 3 +- tests/config.nix | 32 +- tests/plans/cpu-tests.yaml | 9 + tests/runner/Cargo.lock | 670 +++++++++++++++++- tests/runner/Cargo.toml | 11 + tests/runner/src/bundle.rs | 163 +++++ tests/runner/src/catalog.rs | 223 ++++++ tests/runner/src/execute.rs | 258 +++++++ tests/runner/src/jobs.rs | 400 +++++++++++ tests/runner/src/lib.rs | 6 + tests/runner/src/main.rs | 234 ++++++ tests/runner/src/plan.rs | 106 +++ tests/runner/src/report.rs | 128 ++++ tests/suites/conformance/Cargo.lock | 9 + tests/suites/conformance/Cargo.toml | 2 +- .../conformance/testkit-macros/Cargo.toml | 1 + .../conformance/testkit-macros/src/lib.rs | 132 +--- .../conformance/testkit-spec/Cargo.toml | 19 + .../conformance/testkit-spec/src/lib.rs | 226 ++++++ 20 files changed, 2566 insertions(+), 124 deletions(-) create mode 100644 tests/plans/cpu-tests.yaml create mode 100644 tests/runner/src/bundle.rs create mode 100644 tests/runner/src/catalog.rs create mode 100644 tests/runner/src/execute.rs create mode 100644 tests/runner/src/jobs.rs create mode 100644 tests/runner/src/main.rs create mode 100644 tests/runner/src/plan.rs create mode 100644 tests/runner/src/report.rs create mode 100644 tests/suites/conformance/testkit-spec/Cargo.toml create mode 100644 tests/suites/conformance/testkit-spec/src/lib.rs diff --git a/TESTING.md b/TESTING.md index 771b602103..8cb81545b9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -387,6 +387,64 @@ Secrets and Vault storage backends one at a time: mise run e2e:kubernetes:credential-drivers ``` +### Test plans (`tests/plans/`) + +A test plan selects suites and the environments to run them in. The runner +(`tests/runner`) builds each family's suites for the guest, boots each +environment in a tmachine VM, runs the suites inside it, and copies the +results back: + +```shell +nix run .#build-artifacts +nix run .#test-runner -- test --plan tests/plans/cpu-tests.yaml +``` + +```yaml +matrix: + - type: conformance # family: a workspace under tests/suites/ + suites: [sandbox-lifecycle] # omit to run every suite in the family + environments: [tmachine.ubuntu2404-arm64.docker-rootful.deb] +``` + +Add `--dry-run` to print the resolved jobs and nextest filtersets without +building or booting anything, `--env` or `--suite` to narrow a plan, and +`--keep` to leave each VM running after its tests. Results, including JUnit +reports and gateway logs for failures, go to +`artifacts/test-results////`. + +Environments are defined in `tests/environments/*.yaml`. tmachine boots guests +of the host's architecture only, so the runner reports environments for other +architectures as unsupported instead of failing. + +Conformance suites (`tests/suites/conformance/`) are black-box tests that drive +the gateway through the Rust SDK. Each suite is one test binary of +`#[testkit::test]` functions: + +```rust +/// A long-running sandbox becomes ready, runs a command with exec, and is gone +/// from get and list after delete. +#[testkit::test] +async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { + // ... +} +``` + +The function name is the test ID, and the doc comment is its required +description. Declare capabilities a test needs with +`#[testkit::test(requires = [service_exposure])]`; the runner excludes the test +in environments without them and reports it as unsupported. Tests read the +system under test from the target file named by `OPENSHELL_TEST_TARGET`, so you +can also run a suite directly against a gateway you already have: + +```shell +OPENSHELL_TEST_TARGET=target.yaml cargo nextest run \ + --manifest-path tests/suites/conformance/Cargo.toml \ + -p openshell-conformance-sandbox-lifecycle +``` + +`cargo run --manifest-path tests/runner/Cargo.toml --bin openshell-test -- catalog` +prints every suite and test with its description and requirements. + ### Kubernetes E2E (`e2e/rust/e2e-kubernetes.sh`) Kubernetes e2e tests deploy an OpenShell gateway into a real Kubernetes cluster diff --git a/flake.nix b/flake.nix index 21b2e1b091..f1ab2d3a62 100644 --- a/flake.nix +++ b/flake.nix @@ -127,7 +127,7 @@ firmwarePkgs = testGuestPkgs; }; testMachines = import ./tests/config.nix { - inherit pkgs toolchains; + inherit pkgs toolchains rustToolchain; qemuPkgs = tmachineRuntimePkgs; firmwarePkgs = tmachineRuntimePkgs; }; @@ -199,6 +199,7 @@ tmachine = testMachines.package; tmachine-config = testMachines.config; tmachine-unwrapped = testMachines.unwrapped; + test-runner = testMachines.testRunner; }; devShells = { diff --git a/tests/config.nix b/tests/config.nix index a5318cb875..46a016d545 100644 --- a/tests/config.nix +++ b/tests/config.nix @@ -4,6 +4,7 @@ { pkgs, toolchains, + rustToolchain, qemuPkgs ? pkgs, firmwarePkgs ? pkgs, }: @@ -232,9 +233,38 @@ let exec ${tmachine}/bin/tmachine --config ${config} "$@" ''; }; + # Runs test plans with tests/runner. It cross-builds guest test bundles and + # drives tmachine sessions, so it needs the toolchains and the VM tools. + testRunner = pkgs.writeShellApplication { + name = "test-runner"; + runtimeInputs = [ + qemu + pkgs.ansible + pkgs.cargo-nextest + pkgs.coreutils + pkgs.git + pkgs.gnutar + pkgs.sshpass + rustToolchain + ]; + runtimeEnv = + pkgs.lib.foldl' (env: toolchain: env // toolchain.env) { } (builtins.attrValues toolchains) + // { + TMACHINE_CONFIG = "${config}"; + } + // pkgs.lib.optionalAttrs isAarch64 { + TMACHINE_FIRMWARE_CODE = firmwarePkgs.OVMF.firmware; + TMACHINE_FIRMWARE_VARS = firmwarePkgs.OVMF.variables; + }; + text = '' + root=$(git rev-parse --show-toplevel) + export ANSIBLE_CONFIG="$root/tests/ansible/ansible.cfg" + exec cargo run --quiet --manifest-path "$root/tests/runner/Cargo.toml" --bin openshell-test -- "$@" + ''; + }; in { package = runner; unwrapped = tmachine; - inherit config; + inherit config testRunner; } diff --git a/tests/plans/cpu-tests.yaml b/tests/plans/cpu-tests.yaml new file mode 100644 index 0000000000..1736f07afd --- /dev/null +++ b/tests/plans/cpu-tests.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +matrix: + - type: conformance + suites: [sandbox-lifecycle] + environments: + - tmachine.ubuntu2404-amd64.docker-rootful.deb + - tmachine.ubuntu2404-arm64.docker-rootful.deb diff --git a/tests/runner/Cargo.lock b/tests/runner/Cargo.lock index 41626bb911..4e7794e7a3 100644 --- a/tests/runner/Cargo.lock +++ b/tests/runner/Cargo.lock @@ -2,24 +2,304 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +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 = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +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", +] + +[[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", +] + [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + [[package]] name = "equivalent" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "granit-parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "indexmap" version = "2.14.2" @@ -32,18 +312,68 @@ dependencies = [ "serde_core", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + [[package]] name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "noyalib" version = "0.0.53" @@ -59,15 +389,63 @@ dependencies = [ "smallvec", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openshell-test-runner" version = "0.0.0" dependencies = [ "anyhow", + "clap", "noyalib", + "openshell-testkit-spec", "serde", + "serde_json", + "tempfile", + "tmachine", + "tokio", + "toml", +] + +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.119", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -86,12 +464,53 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + [[package]] name = "rustc-hash" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -102,6 +521,20 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8" +dependencies = [ + "annotate-snippets", + "encoding_rs_io", + "granit-parser", + "num-traits", + "serde_core", + "smallvec", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -119,15 +552,86 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "smallvec" version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.9" @@ -139,8 +643,172 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tmachine" +version = "0.1.0" +dependencies = [ + "anyhow", + "blake3", + "clap", + "directories", + "serde", + "serde-saphyr", + "tempfile", + "tokio", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "unicode-ident" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/runner/Cargo.toml b/tests/runner/Cargo.toml index 2260710398..6dd5f27094 100644 --- a/tests/runner/Cargo.toml +++ b/tests/runner/Cargo.toml @@ -11,9 +11,20 @@ license = "Apache-2.0" repository = "https://github.com/NVIDIA/OpenShell" publish = false +[[bin]] +name = "openshell-test" +path = "src/main.rs" + [dependencies] anyhow = "1" +clap = { version = "4", features = ["derive"] } +openshell-testkit-spec = { path = "../suites/conformance/testkit-spec" } serde = { version = "1", features = ["derive"] } +serde_json = "1" serde_yml = { package = "noyalib", version = "0.0.53", default-features = false, features = ["std", "compat-serde-yaml"] } +tempfile = "3" +tmachine = { path = "../tmachine" } +tokio = { version = "1", features = ["macros", "rt"] } +toml = "0.9" [workspace] diff --git a/tests/runner/src/bundle.rs b/tests/runner/src/bundle.rs new file mode 100644 index 0000000000..5a2b9f50d6 --- /dev/null +++ b/tests/runner/src/bundle.rs @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Building a family's test bundle for the guest. +//! +//! A bundle is a tar holding a nextest archive built for the guest target, +//! the family workspace's `Cargo.toml` files (for `--workspace-remap`), and a +//! `nextest` config with the runner's `JUnit` profile. The guest needs only the +//! `cargo-nextest` binary to run it. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail, ensure}; +use serde::Deserialize; +use tempfile::TempDir; + +use crate::catalog::Family; +use crate::environments::Arch; + +/// The nextest profile the runner uses in the guest. +pub const PROFILE: &str = "openshell"; + +const NEXTEST_CONFIG: &str = r#"# Written by the OpenShell test runner. +[profile.openshell] +fail-fast = false + +[profile.openshell.junit] +path = "junit.xml" +"#; + +pub struct Bundle { + pub path: PathBuf, + _dir: TempDir, +} + +/// The Rust target for guests of `arch`. Static musl binaries don't depend +/// on the guest's libc. +pub const fn guest_target(arch: Arch) -> &'static str { + match arch { + Arch::Amd64 => "x86_64-unknown-linux-musl", + Arch::Arm64 => "aarch64-unknown-linux-musl", + } +} + +/// Build the bundle for `packages`, and check that the archive contains a +/// binary for every suite in `binaries`. +pub fn build( + family: &Family, + packages: &BTreeSet, + binaries: &BTreeSet, + target: &str, +) -> Result { + let dir = tempfile::tempdir().context("create bundle directory")?; + let stage = dir.path().join("stage"); + std::fs::create_dir(&stage)?; + let manifest = family.workspace.join("Cargo.toml"); + let archive = stage.join("tests.tar.zst"); + + let mut command = Command::new("cargo"); + command + .args(["nextest", "archive", "--manifest-path"]) + .arg(&manifest) + .args(["--target", target, "--archive-file"]) + .arg(&archive); + for package in packages { + command.args(["-p", package]); + } + run(&mut command).with_context(|| format!("build {} test archive", family.name))?; + + let archived = archived_binaries(&manifest, &archive)?; + let missing: Vec<_> = binaries.difference(&archived).collect(); + ensure!( + missing.is_empty(), + "the {} catalog lists suites the archive has no test binary for: {missing:?}; archived: {archived:?}", + family.name + ); + + copy_manifests(&family.workspace, &stage)?; + std::fs::write(stage.join("nextest.toml"), NEXTEST_CONFIG)?; + + let path = dir.path().join(format!("{}-tests.tar", family.name)); + run(Command::new("tar") + .arg("-C") + .arg(&stage) + .arg("-cf") + .arg(&path) + .arg(".")) + .context("pack test bundle")?; + Ok(Bundle { path, _dir: dir }) +} + +#[derive(Deserialize)] +struct ListOutput { + #[serde(rename = "rust-binaries")] + rust_binaries: std::collections::BTreeMap, +} + +#[derive(Deserialize)] +struct ListSuite { + #[serde(rename = "binary-name")] + binary_name: String, + kind: String, +} + +/// Names of the test binaries in an archive, read without executing them. +fn archived_binaries(manifest: &Path, archive: &Path) -> Result> { + let output = Command::new("cargo") + .args(["nextest", "list", "--manifest-path"]) + .arg(manifest) + .arg("--archive-file") + .arg(archive) + .args(["--list-type", "binaries-only", "--message-format", "json"]) + .output() + .context("run cargo nextest list")?; + ensure!( + output.status.success(), + "cargo nextest list exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + let list: ListOutput = + serde_json::from_slice(&output.stdout).context("parse cargo nextest list output")?; + Ok(list + .rust_binaries + .into_values() + .filter(|suite| suite.kind == "test") + .map(|suite| suite.binary_name) + .collect()) +} + +/// Copy every `Cargo.toml` under `workspace` into `stage`, keeping relative +/// paths and skipping build output. +fn copy_manifests(workspace: &Path, stage: &Path) -> Result<()> { + fn walk(root: &Path, dir: &Path, stage: &Path) -> Result<()> { + for entry in std::fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? { + let path = entry?.path(); + let name = path.file_name().unwrap_or_default(); + if path.is_dir() { + if name != "target" && name != ".git" { + walk(root, &path, stage)?; + } + } else if name == "Cargo.toml" { + let dest = stage.join(path.strip_prefix(root)?); + std::fs::create_dir_all(dest.parent().unwrap_or(stage))?; + std::fs::copy(&path, &dest).with_context(|| format!("copy {}", path.display()))?; + } + } + Ok(()) + } + walk(workspace, workspace, stage) +} + +fn run(command: &mut Command) -> Result<()> { + let status = command + .status() + .with_context(|| format!("run {command:?}"))?; + if !status.success() { + bail!("{command:?} exited with {status}"); + } + Ok(()) +} diff --git a/tests/runner/src/catalog.rs b/tests/runner/src/catalog.rs new file mode 100644 index 0000000000..104021e4bb --- /dev/null +++ b/tests/runner/src/catalog.rs @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The test catalog: every family, suite, and test, read from source. +//! +//! A family is a Cargo workspace under `tests/suites/`. A suite is one test +//! binary (`/tests/.rs`) containing `#[testkit::test]` +//! functions; the suite name is the binary name with `_` replaced by `-`. +//! Tests are parsed with `testkit-spec`, the same parser the macro uses, so +//! the runner never executes test binaries on the host to learn about them. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +/// Families the runner knows how to run, and their workspaces relative to +/// the repository root. +const FAMILIES: &[(&str, &str)] = &[("conformance", "tests/suites/conformance")]; + +#[derive(Debug, Serialize)] +pub struct Family { + pub name: String, + #[serde(skip)] + pub workspace: PathBuf, + pub suites: BTreeMap, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Suite { + pub suite: String, + pub binary: String, + pub package: String, + pub tests: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Test { + pub id: String, + pub description: String, + pub requires: Vec, +} + +/// Every known family, keyed by name. +#[derive(Debug, Serialize)] +pub struct Catalog { + pub families: BTreeMap, +} + +impl Catalog { + pub fn load(root: &Path) -> Result { + let mut families = BTreeMap::new(); + for (name, workspace) in FAMILIES { + let family = Family::load(name, &root.join(workspace)) + .with_context(|| format!("load {name} test catalog"))?; + families.insert((*name).to_string(), family); + } + Ok(Self { families }) + } + + pub fn family(&self, name: &str) -> Result<&Family> { + self.families.get(name).with_context(|| { + format!( + "unknown test family {name:?}; known families: {}", + self.families.keys().cloned().collect::>().join(", ") + ) + }) + } +} + +#[derive(Deserialize)] +struct WorkspaceManifest { + workspace: WorkspaceSection, +} + +#[derive(Deserialize)] +struct WorkspaceSection { + members: Vec, +} + +#[derive(Deserialize)] +struct PackageManifest { + package: PackageSection, +} + +#[derive(Deserialize)] +struct PackageSection { + name: String, +} + +impl Family { + fn load(name: &str, workspace: &Path) -> Result { + let manifest: WorkspaceManifest = read_toml(&workspace.join("Cargo.toml"))?; + let mut suites = BTreeMap::new(); + for member in manifest.workspace.members { + let member_dir = workspace.join(&member); + for suite in member_suites(&member_dir)? { + if let Some(existing) = suites.insert(suite.suite.clone(), suite) { + bail!( + "suite {:?} is defined more than once (again in {member})", + existing.suite + ); + } + } + } + Ok(Self { + name: name.to_string(), + workspace: workspace.to_path_buf(), + suites, + }) + } + + pub fn suite(&self, name: &str) -> Result<&Suite> { + self.suites.get(name).with_context(|| { + format!( + "unknown {} suite {name:?}; known suites: {}", + self.name, + self.suites.keys().cloned().collect::>().join(", ") + ) + }) + } + + /// Packages containing the given suites. + pub fn packages<'a>(&self, suites: impl IntoIterator) -> BTreeSet { + suites + .into_iter() + .filter_map(|suite| self.suites.get(suite)) + .map(|suite| suite.package.clone()) + .collect() + } +} + +/// Suites in one workspace member: its `tests/*.rs` files that contain +/// `#[testkit::test]` functions. +fn member_suites(member_dir: &Path) -> Result> { + let tests_dir = member_dir.join("tests"); + if !tests_dir.is_dir() { + return Ok(Vec::new()); + } + let package: PackageManifest = read_toml(&member_dir.join("Cargo.toml"))?; + + let mut files: Vec<_> = std::fs::read_dir(&tests_dir) + .with_context(|| format!("read {}", tests_dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>()?; + files.retain(|path| path.extension().is_some_and(|ext| ext == "rs")); + files.sort(); + + let mut suites = Vec::new(); + for file in files { + let source = + std::fs::read_to_string(&file).with_context(|| format!("read {}", file.display()))?; + let specs = testkit_spec::scan(&source) + .map_err(|error| anyhow::anyhow!("{}: {error}", file.display()))?; + if specs.is_empty() { + continue; + } + let binary = file + .file_stem() + .and_then(|stem| stem.to_str()) + .with_context(|| format!("test file name {} is not UTF-8", file.display()))? + .to_string(); + suites.push(Suite { + suite: binary.replace('_', "-"), + binary, + package: package.package.name.clone(), + tests: specs + .into_iter() + .map(|spec| Test { + id: spec.id.to_string(), + description: spec.description, + requires: spec.requires.iter().map(ToString::to_string).collect(), + }) + .collect(), + }); + } + Ok(suites) +} + +fn read_toml(path: &Path) -> Result { + let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + toml::from_str(&text).with_context(|| format!("parse {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") + } + + #[test] + fn repository_catalog_has_sandbox_lifecycle() { + let catalog = Catalog::load(&repo_root()).unwrap(); + let family = catalog.family("conformance").unwrap(); + let suite = family.suite("sandbox-lifecycle").unwrap(); + assert_eq!(suite.binary, "sandbox_lifecycle"); + assert_eq!(suite.package, "openshell-conformance-sandbox-lifecycle"); + assert!( + suite + .tests + .iter() + .any(|test| test.id == "long_running_exec_delete" && !test.description.is_empty()) + ); + // CLI-driven tests in the same workspace are not testkit suites. + assert!(family.suites.keys().all(|name| name != "file-transfer")); + } + + #[test] + fn unknown_names_list_known_ones() { + let catalog = Catalog::load(&repo_root()).unwrap(); + let err = catalog.family("features").unwrap_err(); + assert!(err.to_string().contains("conformance"), "{err}"); + let err = catalog + .family("conformance") + .unwrap() + .suite("sandbox-lifecyle") + .unwrap_err(); + assert!(err.to_string().contains("sandbox-lifecycle"), "{err}"); + } +} diff --git a/tests/runner/src/execute.rs b/tests/runner/src/execute.rs new file mode 100644 index 0000000000..d6d4f846a9 --- /dev/null +++ b/tests/runner/src/execute.rs @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Running a job: provision the environment, run each family's bundle in it, +//! and bring the results back. + +use std::collections::BTreeMap; +use std::path::Path; +use std::process::Output; + +use anyhow::{Context, Result, bail, ensure}; +use tmachine::Session; +use tmachine::config::Config; + +use crate::bundle::{Bundle, PROFILE}; +use crate::jobs::{FamilyJob, Job}; +use crate::report::{Outcome, Status, junit_counts}; + +/// Where the installer describes the gateway in the guest. +const GATEWAY_SECTION: &str = "/etc/openshell-test/gateway.yaml"; +const GUEST_ROOT: &str = "/var/tmp/openshell-tests"; + +pub struct Options<'a> { + pub tmachine: &'a Config, + pub bundles: &'a BTreeMap, + pub results: &'a Path, + pub keep: bool, +} + +/// Run one job. Every family gets an outcome, even when provisioning fails. +pub async fn run(job: &Job, options: &Options<'_>) -> Vec { + let env_id = &job.environment.id; + let outcome = |family: &FamilyJob, status| Outcome { + environment: env_id.clone(), + family: family.family.clone(), + status, + unsupported_tests: family + .suites + .iter() + .map(|suite| suite.unsupported.len()) + .sum(), + }; + + if let Some(reason) = &job.unsupported { + return job + .families + .iter() + .map(|family| outcome(family, Status::Unsupported(reason.clone()))) + .collect(); + } + if !job.families.iter().any(FamilyJob::has_tests) { + return job + .families + .iter() + .map(|family| outcome(family, Status::NothingToRun)) + .collect(); + } + + eprintln!("==> {env_id}: provisioning"); + let session = match provision(job, options.tmachine).await { + Ok(session) => session, + Err(error) => { + let message = format!("provision environment: {error:#}"); + eprintln!("==> {env_id}: {message}"); + return job + .families + .iter() + .map(|family| outcome(family, Status::Error(message.clone()))) + .collect(); + } + }; + + let mut outcomes = Vec::new(); + for family in &job.families { + if !family.has_tests() { + outcomes.push(outcome(family, Status::NothingToRun)); + continue; + } + let dir = options.results.join(env_id).join(&family.family); + eprintln!( + "==> {env_id}: running {} ({})", + family.family, + family.filterset() + ); + let status = match run_family(&session, job, family, options, &dir).await { + Ok(status) => status, + Err(error) => Status::Error(format!("{error:#}")), + }; + if status.is_failure() + && let Err(error) = session.collect_diagnostics(&dir.join("diagnostics")).await + { + eprintln!("==> {env_id}: collecting diagnostics failed: {error:#}"); + } + eprintln!("==> {env_id}: {} {status:?}", family.family); + outcomes.push(outcome(family, status)); + } + + if options.keep { + eprintln!( + "==> {env_id}: keeping the VM. Connect with `ssh -p 2222 tmachine@127.0.0.1` (password `tmachine`). Press Enter to tear it down." + ); + let mut line = String::new(); + let _ = std::io::stdin().read_line(&mut line); + } + eprintln!("==> {env_id}: tearing down"); + session.down().await; + outcomes +} + +async fn provision(job: &Job, config: &Config) -> Result { + let params = job + .environment + .tmachine + .as_ref() + .context("environment has no tmachine section")?; + let environment = config + .environments + .iter() + .find(|candidate| candidate.name == params.environment) + .with_context(|| { + format!( + "tmachine environment {:?} is not defined", + params.environment + ) + })?; + let machine = config + .machines + .iter() + .find(|candidate| candidate.name == environment.machine) + .with_context(|| format!("tmachine machine {:?} is not defined", environment.machine))?; + let installer = config + .installers + .iter() + .find(|candidate| candidate.name == params.installer) + .with_context(|| format!("tmachine installer {:?} is not defined", params.installer))?; + Session::up(machine, environment, installer).await +} + +async fn run_family( + session: &Session, + job: &Job, + family: &FamilyJob, + options: &Options<'_>, + dir: &Path, +) -> Result { + if dir.exists() { + std::fs::remove_dir_all(dir).with_context(|| format!("clear {}", dir.display()))?; + } + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + let bundle = options + .bundles + .get(&family.family) + .with_context(|| format!("no bundle was built for {}", family.family))?; + + // The installer knows how to reach the gateway; the environment + // definition knows what it provides. Together they are the target. + let gateway = checked( + "read the installer's gateway description", + session.exec(&["cat", GATEWAY_SECTION]).await?, + )?; + let capabilities = serde_yml::to_string(&BTreeMap::from([( + "capabilities", + &job.environment.capabilities, + )]))?; + let target = dir.join("target.yaml"); + std::fs::write(&target, format!("{gateway}{capabilities}"))?; + + let guest = format!("{GUEST_ROOT}/{}", family.family); + checked( + "prepare guest directory", + session + .exec(&[ + "sh", + "-c", + &format!("rm -rf {guest} && mkdir -p {guest}/extract"), + ]) + .await?, + )?; + session + .copy_in(&bundle.path, &format!("{guest}/bundle.tar")) + .await?; + session + .copy_in(&target, &format!("{guest}/target.yaml")) + .await?; + checked( + "unpack bundle", + session + .exec(&["tar", "-C", &guest, "-xf", &format!("{guest}/bundle.tar")]) + .await?, + )?; + + let filterset = family.filterset(); + let output = session + .exec(&[ + "env", + &format!("OPENSHELL_TEST_TARGET={guest}/target.yaml"), + "cargo-nextest", + "nextest", + "run", + "--archive-file", + &format!("{guest}/tests.tar.zst"), + "--workspace-remap", + &guest, + "--extract-to", + &format!("{guest}/extract"), + "--extract-overwrite", + "--config-file", + &format!("{guest}/nextest.toml"), + "--profile", + PROFILE, + "--no-capture", + "-E", + &filterset, + ]) + .await?; + let mut log = output.stdout.clone(); + log.extend_from_slice(&output.stderr); + std::fs::write(dir.join("nextest.log"), &log)?; + + let junit = dir.join("junit.xml"); + let found = checked( + "find JUnit report", + session + .exec(&["find", &guest, "-name", "junit.xml"]) + .await?, + )?; + let counts = match found.lines().next() { + Some(path) => { + session.copy_out(path, &junit).await?; + junit_counts(&std::fs::read_to_string(&junit)?) + } + None => None, + }; + + Ok(match (output.status.success(), counts) { + (true, Some((tests, 0))) => Status::Passed { tests }, + (true, counts) => bail!( + "nextest succeeded but the JUnit report is missing or reports failures ({counts:?}); see {}", + dir.join("nextest.log").display() + ), + (false, Some((tests, failures))) if failures > 0 => Status::Failed { tests, failures }, + (false, _) => Status::Error(format!( + "nextest exited with {}; see {}", + output.status, + dir.join("nextest.log").display() + )), + }) +} + +fn checked(step: &str, output: Output) -> Result { + ensure!( + output.status.success(), + "{step}: exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + String::from_utf8(output.stdout).with_context(|| format!("{step}: output is not UTF-8")) +} diff --git a/tests/runner/src/jobs.rs b/tests/runner/src/jobs.rs new file mode 100644 index 0000000000..5cd76d1ba7 --- /dev/null +++ b/tests/runner/src/jobs.rs @@ -0,0 +1,400 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Expanding a plan into jobs. +//! +//! Jobs are grouped by environment so each environment is provisioned once +//! and runs every family and suite the plan selects for it. Tests whose +//! requirements the environment lacks are excluded by exact name and +//! reported as unsupported; libtest has no runtime skip, so this is the only +//! place they can be skipped honestly. + +use std::collections::BTreeMap; + +use anyhow::{Result, bail}; + +use crate::catalog::{Catalog, Suite}; +use crate::environments::{Arch, Catalog as Environments, Environment}; +use crate::plan::Plan; + +/// Everything the plan runs in one environment. +#[derive(Debug)] +pub struct Job { + pub environment: Environment, + /// Why the environment can't be provisioned on this host, if it can't. + pub unsupported: Option, + pub families: Vec, +} + +/// The suites of one family selected for an environment. +#[derive(Debug)] +pub struct FamilyJob { + pub family: String, + pub suites: Vec, +} + +#[derive(Debug)] +pub struct SuiteJob { + pub suite: Suite, + /// Test IDs to run. + pub run: Vec, + /// Tests excluded because the environment lacks a capability they need. + pub unsupported: Vec, +} + +#[derive(Debug)] +pub struct UnsupportedTest { + pub id: String, + pub missing: Vec, +} + +/// Filters applied on top of the plan, from the command line. +#[derive(Debug, Default)] +pub struct Narrowing { + pub environments: Vec, + pub suites: Vec, +} + +/// Validate the plan against the catalogs and expand it into jobs. +pub fn expand( + plan: &Plan, + catalog: &Catalog, + environments: &Environments, + host: Option, + narrowing: &Narrowing, +) -> Result> { + // environment ID -> family -> suite names, in plan order where it matters. + let mut selected: BTreeMap>> = BTreeMap::new(); + + for entry in &plan.matrix { + let family = catalog.family(&entry.family)?; + let suites: Vec = match &entry.suites { + Some(suites) => { + for suite in suites { + family.suite(suite)?; + } + suites.clone() + } + None => family.suites.keys().cloned().collect(), + }; + for id in &entry.environments { + if environments.get(id).is_none() { + bail!( + "unknown environment {id:?}; known environments: {}", + environments + .iter() + .map(|env| env.id.as_str()) + .collect::>() + .join(", ") + ); + } + let family_suites = selected + .entry(id.clone()) + .or_default() + .entry(entry.family.clone()) + .or_default(); + for suite in &suites { + if !family_suites.contains(suite) { + family_suites.push(suite.clone()); + } + } + } + } + + for id in &narrowing.environments { + if !selected.contains_key(id) { + bail!("--env {id:?} is not in the plan"); + } + } + for suite in &narrowing.suites { + let in_plan = selected + .values() + .flat_map(BTreeMap::values) + .any(|suites| suites.contains(suite)); + if !in_plan { + bail!("--suite {suite:?} is not in the plan"); + } + } + + let mut jobs = Vec::new(); + for (id, families) in selected { + if !narrowing.environments.is_empty() && !narrowing.environments.contains(&id) { + continue; + } + let environment = environments.get(&id).expect("validated above").clone(); + let mut family_jobs = Vec::new(); + for (family_name, suites) in families { + let family = catalog.family(&family_name)?; + let suites: Vec = suites + .iter() + .filter(|suite| narrowing.suites.is_empty() || narrowing.suites.contains(suite)) + .map(|suite| suite_job(family.suite(suite).expect("validated above"), &environment)) + .collect(); + if !suites.is_empty() { + family_jobs.push(FamilyJob { + family: family_name, + suites, + }); + } + } + if family_jobs.is_empty() { + continue; + } + jobs.push(Job { + unsupported: environment.unsupported_reason(host), + environment, + families: family_jobs, + }); + } + Ok(jobs) +} + +fn suite_job(suite: &Suite, environment: &Environment) -> SuiteJob { + let mut run = Vec::new(); + let mut unsupported = Vec::new(); + for test in &suite.tests { + let missing: Vec = test + .requires + .iter() + .filter(|capability| !provides(environment, capability)) + .cloned() + .collect(); + if missing.is_empty() { + run.push(test.id.clone()); + } else { + unsupported.push(UnsupportedTest { + id: test.id.clone(), + missing, + }); + } + } + SuiteJob { + suite: suite.clone(), + run, + unsupported, + } +} + +/// Requirements name boolean capabilities. +fn provides(environment: &Environment, capability: &str) -> bool { + environment.capabilities.get(capability) == Some(&serde_yml::Value::Bool(true)) +} + +impl FamilyJob { + /// Whether any test will run. + pub fn has_tests(&self) -> bool { + self.suites.iter().any(|suite| !suite.run.is_empty()) + } + + /// The nextest filterset selecting exactly the tests to run. + pub fn filterset(&self) -> String { + self.suites + .iter() + .filter(|suite| !suite.run.is_empty()) + .map(|suite| { + let binary = format!("binary(={})", suite.suite.binary); + if suite.unsupported.is_empty() { + binary + } else { + let tests = suite + .run + .iter() + .map(|id| format!("test(={id})")) + .collect::>() + .join(" | "); + format!("({binary} & ({tests}))") + } + }) + .collect::>() + .join(" | ") + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::catalog::{Family, Test}; + + fn catalog() -> Catalog { + let suite = |name: &str, tests: &[(&str, &[&str])]| Suite { + suite: name.to_string(), + binary: name.replace('-', "_"), + package: format!("pkg-{name}"), + tests: tests + .iter() + .map(|(id, requires)| Test { + id: (*id).to_string(), + description: "d".to_string(), + requires: requires.iter().map(ToString::to_string).collect(), + }) + .collect(), + }; + let family = Family { + name: "conformance".to_string(), + workspace: PathBuf::new(), + suites: [ + suite( + "sandbox-lifecycle", + &[("long_running", &[]), ("expose", &["service_exposure"])], + ), + suite("providers", &[("create_provider", &[])]), + ] + .into_iter() + .map(|suite| (suite.suite.clone(), suite)) + .collect(), + }; + Catalog { + families: [("conformance".to_string(), family)].into(), + } + } + + fn environments() -> Environments { + use std::fmt::Write as _; + + let mut yaml = String::from("environments:\n"); + for arch in ["amd64", "arm64"] { + let _ = write!( + yaml, + " - id: tmachine.ubuntu2404-{arch}.docker-rootful.deb\n backend: tmachine\n machine: {{ os: ubuntu, version: \"24.04\", arch: {arch} }}\n runtime: docker-rootful\n installer: deb\n tmachine: {{ environment: ubuntu-docker-rootful, installer: deb }}\n capabilities: {{ compute_driver: docker }}\n" + ); + } + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("docker.yaml"), yaml).unwrap(); + Environments::load_dir(dir.path()).unwrap() + } + + const AMD64: &str = "tmachine.ubuntu2404-amd64.docker-rootful.deb"; + const ARM64: &str = "tmachine.ubuntu2404-arm64.docker-rootful.deb"; + + fn expand_plan(yaml: &str, narrowing: &Narrowing) -> Result> { + expand( + &Plan::parse(yaml).unwrap(), + &catalog(), + &environments(), + Some(Arch::Amd64), + narrowing, + ) + } + + #[test] + fn excludes_tests_with_unmet_requirements() { + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n suites: [sandbox-lifecycle]\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap(); + assert_eq!(jobs.len(), 1); + let suite = &jobs[0].families[0].suites[0]; + assert_eq!(suite.run, ["long_running"]); + assert_eq!(suite.unsupported[0].id, "expose"); + assert_eq!(suite.unsupported[0].missing, ["service_exposure"]); + assert_eq!( + jobs[0].families[0].filterset(), + "(binary(=sandbox_lifecycle) & (test(=long_running)))" + ); + } + + #[test] + fn omitted_suites_select_the_whole_family() { + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap(); + let suites: Vec<_> = jobs[0].families[0] + .suites + .iter() + .map(|suite| suite.suite.suite.as_str()) + .collect(); + assert_eq!(suites, ["providers", "sandbox-lifecycle"]); + assert_eq!( + jobs[0].families[0].filterset(), + "binary(=providers) | (binary(=sandbox_lifecycle) & (test(=long_running)))" + ); + } + + #[test] + fn groups_entries_by_environment() { + let jobs = expand_plan( + &format!( + "matrix:\n - type: conformance\n suites: [providers]\n environments: [{AMD64}, {ARM64}]\n - type: conformance\n suites: [providers, sandbox-lifecycle]\n environments: [{AMD64}]\n" + ), + &Narrowing::default(), + ) + .unwrap(); + assert_eq!(jobs.len(), 2); + let amd64 = jobs.iter().find(|job| job.environment.id == AMD64).unwrap(); + assert_eq!(amd64.families.len(), 1); + assert_eq!(amd64.families[0].suites.len(), 2); + assert!(amd64.unsupported.is_none()); + let arm64 = jobs.iter().find(|job| job.environment.id == ARM64).unwrap(); + assert!(arm64.unsupported.is_some()); + } + + #[test] + fn narrows_to_environment_and_suite() { + let narrowing = Narrowing { + environments: vec![AMD64.to_string()], + suites: vec!["providers".to_string()], + }; + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n environments: [{AMD64}, {ARM64}]\n"), + &narrowing, + ) + .unwrap(); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].families[0].suites.len(), 1); + assert_eq!(jobs[0].families[0].filterset(), "binary(=providers)"); + } + + #[test] + fn rejects_unknown_names() { + let unknown_env = expand_plan( + "matrix:\n - type: conformance\n environments: [nope]\n", + &Narrowing::default(), + ) + .unwrap_err(); + assert!( + unknown_env.to_string().contains("unknown environment"), + "{unknown_env}" + ); + + let unknown_suite = expand_plan( + &format!("matrix:\n - type: conformance\n suites: [sandbox-lifecyle]\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap_err(); + assert!( + unknown_suite + .to_string() + .contains("unknown conformance suite"), + "{unknown_suite}" + ); + + let unknown_family = expand_plan( + &format!("matrix:\n - type: features\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap_err(); + assert!( + unknown_family.to_string().contains("unknown test family"), + "{unknown_family}" + ); + + let narrowing = Narrowing { + environments: vec![ARM64.to_string()], + suites: vec![], + }; + let not_in_plan = expand_plan( + &format!("matrix:\n - type: conformance\n environments: [{AMD64}]\n"), + &narrowing, + ) + .unwrap_err(); + assert!( + not_in_plan.to_string().contains("not in the plan"), + "{not_in_plan}" + ); + } +} diff --git a/tests/runner/src/lib.rs b/tests/runner/src/lib.rs index db3c9f1035..bb46d294ce 100644 --- a/tests/runner/src/lib.rs +++ b/tests/runner/src/lib.rs @@ -5,4 +5,10 @@ //! environment through its backend, runs the selected suites inside it, and //! reports the results. +pub mod bundle; +pub mod catalog; pub mod environments; +pub mod execute; +pub mod jobs; +pub mod plan; +pub mod report; diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs new file mode 100644 index 0000000000..7e8faaa587 --- /dev/null +++ b/tests/runner/src/main.rs @@ -0,0 +1,234 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `openshell-test`: run `OpenShell` test plans. +//! +//! Run it through `nix run .#test-runner -- `, which provides the +//! guest cross-compilation toolchain, QEMU, Ansible, and the tmachine +//! configuration. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use openshell_test_runner::bundle::{self, Bundle}; +use openshell_test_runner::catalog::Catalog; +use openshell_test_runner::environments::{Arch, Catalog as Environments}; +use openshell_test_runner::execute::{self, Options}; +use openshell_test_runner::jobs::{self, Job, Narrowing}; +use openshell_test_runner::plan::Plan; +use openshell_test_runner::report; + +#[derive(Parser)] +#[command(name = "openshell-test", about = "Run OpenShell test plans")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run a test plan. + Test { + /// The plan to run, e.g. tests/plans/cpu-tests.yaml. + #[arg(long)] + plan: PathBuf, + /// Print the resolved jobs without building or provisioning anything. + #[arg(long)] + dry_run: bool, + /// Keep each environment running after its tests until Enter is pressed. + #[arg(long)] + keep: bool, + /// Only run these environments from the plan. + #[arg(long = "env", value_name = "ID")] + environments: Vec, + /// Only run these suites from the plan. + #[arg(long = "suite", value_name = "NAME")] + suites: Vec, + /// Results directory [default: artifacts/test-results/]. + #[arg(long)] + results: Option, + }, + /// Print the test catalog as JSON. + Catalog, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + match run(Cli::parse()).await { + Ok(code) => code, + Err(error) => { + eprintln!("error: {error:#}"); + ExitCode::FAILURE + } + } +} + +async fn run(cli: Cli) -> Result { + let root = repo_root()?; + let catalog = Catalog::load(&root)?; + + match cli.command { + Command::Catalog => { + println!("{}", serde_json::to_string_pretty(&catalog)?); + Ok(ExitCode::SUCCESS) + } + Command::Test { + plan, + dry_run, + keep, + environments, + suites, + results, + } => { + let plan_path = plan; + let plan = Plan::load(&plan_path)?; + let environments_catalog = Environments::load_dir(&root.join("tests/environments"))?; + let host = Arch::host(); + let narrowing = Narrowing { + environments, + suites, + }; + let jobs = jobs::expand(&plan, &catalog, &environments_catalog, host, &narrowing)?; + print_jobs(&jobs); + if dry_run { + return Ok(ExitCode::SUCCESS); + } + + let results = match results { + Some(results) => results, + None => root.join("artifacts/test-results").join( + plan_path + .file_stem() + .context("plan path has no file name")?, + ), + }; + std::fs::create_dir_all(&results) + .with_context(|| format!("create {}", results.display()))?; + std::fs::write( + results.join("suites.json"), + serde_json::to_string_pretty(&catalog)?, + )?; + + let bundles = build_bundles(&jobs, &catalog, host)?; + let tmachine = load_tmachine(&root)?; + let options = Options { + tmachine: &tmachine, + bundles: &bundles, + results: &results, + keep, + }; + let mut outcomes = Vec::new(); + for job in &jobs { + outcomes.extend(execute::run(job, &options).await); + } + + eprintln!("{}", report::summary(&outcomes)); + eprintln!("Results: {}", results.display()); + Ok( + if outcomes.iter().any(|outcome| outcome.status.is_failure()) { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + }, + ) + } + } +} + +fn print_jobs(jobs: &[Job]) { + for job in jobs { + eprintln!("{}", job.environment.id); + if let Some(reason) = &job.unsupported { + eprintln!(" unsupported on this host: {reason}"); + } + for family in &job.families { + eprintln!(" {}: -E '{}'", family.family, family.filterset()); + for suite in &family.suites { + eprintln!(" {}: {}", suite.suite.suite, suite.run.join(", ")); + for test in &suite.unsupported { + eprintln!( + " {}: {} unsupported (needs {})", + suite.suite.suite, + test.id, + test.missing.join(", ") + ); + } + } + } + } +} + +/// Build one bundle per family, covering every suite that will run. +fn build_bundles( + jobs: &[Job], + catalog: &Catalog, + host: Option, +) -> Result> { + let mut suites: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + for job in jobs.iter().filter(|job| job.unsupported.is_none()) { + for family in &job.families { + for suite in family.suites.iter().filter(|suite| !suite.run.is_empty()) { + suites + .entry(family.family.as_str()) + .or_default() + .insert(suite.suite.suite.as_str()); + } + } + } + + let mut bundles = BTreeMap::new(); + if suites.is_empty() { + return Ok(bundles); + } + let target = bundle::guest_target(host.context("the host architecture is not supported")?); + for (family_name, suite_names) in suites { + let family = catalog.family(family_name)?; + let binaries = suite_names + .iter() + .map(|name| family.suite(name).map(|suite| suite.binary.clone())) + .collect::>()?; + eprintln!("==> building {family_name} bundle for {target}"); + let bundle = bundle::build( + family, + &family.packages(suite_names.iter().copied()), + &binaries, + target, + )?; + bundles.insert(family_name.to_string(), bundle); + } + Ok(bundles) +} + +/// Load the tmachine configuration and prepare the process to drive it. +/// +/// tmachine configs name playbooks and inputs relative to `tests/`, and its +/// Ansible runs read `ANSIBLE_CONFIG`. +fn load_tmachine(root: &Path) -> Result { + let path = std::env::var_os("TMACHINE_CONFIG").context( + "TMACHINE_CONFIG is not set; run through `nix run .#test-runner`, which provides it", + )?; + let config = tmachine::config::Config::load(Path::new(&path))?; + let tests = root.join("tests"); + std::env::set_current_dir(&tests).with_context(|| format!("enter {}", tests.display()))?; + if std::env::var_os("ANSIBLE_CONFIG").is_none() { + // SAFETY: single-threaded at this point; no other thread reads the + // environment concurrently. + unsafe { std::env::set_var("ANSIBLE_CONFIG", tests.join("ansible/ansible.cfg")) }; + } + Ok(config) +} + +fn repo_root() -> Result { + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("run git rev-parse")?; + anyhow::ensure!( + output.status.success(), + "not inside the OpenShell repository" + ); + Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim())) +} diff --git a/tests/runner/src/plan.rs b/tests/runner/src/plan.rs new file mode 100644 index 0000000000..ba172cfe70 --- /dev/null +++ b/tests/runner/src/plan.rs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Test plans: `tests/plans/*.yaml`. +//! +//! ```yaml +//! matrix: +//! - type: conformance +//! suites: [sandbox-lifecycle] # omit for every suite in the family +//! environments: [tmachine.ubuntu2404-amd64.docker-rootful.deb] +//! ``` + +use std::path::Path; + +use anyhow::{Context, Result, ensure}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Plan { + pub matrix: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Entry { + /// The test family, e.g. `conformance`. + #[serde(rename = "type")] + pub family: String, + /// Suites to run. `None` selects every suite in the family. + #[serde(default)] + pub suites: Option>, + pub environments: Vec, +} + +impl Plan { + pub fn load(path: &Path) -> Result { + let yaml = std::fs::read_to_string(path) + .with_context(|| format!("read plan {}", path.display()))?; + Self::parse(&yaml).with_context(|| format!("load plan {}", path.display())) + } + + pub fn parse(yaml: &str) -> Result { + let plan: Self = serde_yml::from_str(yaml)?; + ensure!(!plan.matrix.is_empty(), "plan has an empty matrix"); + for (index, entry) in plan.matrix.iter().enumerate() { + ensure!( + !entry.environments.is_empty(), + "matrix entry {index} ({}) names no environments", + entry.family + ); + ensure!( + entry + .suites + .as_ref() + .is_none_or(|suites| !suites.is_empty()), + "matrix entry {index} ({}) has an empty suite list; omit `suites` to select every suite", + entry.family + ); + } + Ok(plan) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_plan() { + let plan = Plan::parse( + "matrix:\n - type: conformance\n suites: [sandbox-lifecycle]\n environments: [e1, e2]\n - type: conformance\n environments: [e3]\n", + ) + .unwrap(); + assert_eq!(plan.matrix.len(), 2); + assert_eq!(plan.matrix[0].family, "conformance"); + assert_eq!( + plan.matrix[0].suites.as_deref(), + Some(&["sandbox-lifecycle".to_string()][..]) + ); + assert_eq!(plan.matrix[1].suites, None); + } + + #[test] + fn rejects_unknown_fields() { + let err = + Plan::parse("matrix:\n - type: conformance\n suite: [x]\n environments: [e]\n") + .unwrap_err(); + assert!(format!("{err:#}").contains("suite"), "{err:#}"); + } + + #[test] + fn rejects_entry_without_environments() { + let err = + Plan::parse("matrix:\n - type: conformance\n environments: []\n").unwrap_err(); + assert!(err.to_string().contains("names no environments"), "{err}"); + } + + #[test] + fn rejects_empty_suite_list() { + let err = + Plan::parse("matrix:\n - type: conformance\n suites: []\n environments: [e]\n") + .unwrap_err(); + assert!(err.to_string().contains("empty suite list"), "{err}"); + } +} diff --git a/tests/runner/src/report.rs b/tests/runner/src/report.rs new file mode 100644 index 0000000000..1b6e685259 --- /dev/null +++ b/tests/runner/src/report.rs @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Job outcomes and the run summary. + +use std::fmt::Write as _; + +/// The outcome of one family in one environment. +#[derive(Debug)] +pub struct Outcome { + pub environment: String, + pub family: String, + pub status: Status, + /// Tests excluded because the environment lacks a capability. + pub unsupported_tests: usize, +} + +#[derive(Debug)] +pub enum Status { + Passed { + tests: u32, + }, + Failed { + tests: u32, + failures: u32, + }, + /// The environment can't be provisioned on this host. + Unsupported(String), + /// Every selected test needs a capability the environment lacks. + NothingToRun, + /// The runner could not complete the job. + Error(String), +} + +impl Status { + pub const fn is_failure(&self) -> bool { + matches!(self, Self::Failed { .. } | Self::Error(_)) + } +} + +/// Test and failure counts from a `JUnit` report's root element. +pub fn junit_counts(xml: &str) -> Option<(u32, u32)> { + let start = xml.find("')?; + let root = &xml[start..end]; + let attr = |name: &str| -> Option { + let key = format!(" {name}=\""); + let value_start = root.find(&key)? + key.len(); + let value_end = value_start + root[value_start..].find('"')?; + root[value_start..value_end].parse().ok() + }; + let tests = attr("tests")?; + let failures = attr("failures").unwrap_or(0) + attr("errors").unwrap_or(0); + Some((tests, failures)) +} + +/// A human-readable summary of every outcome. +pub fn summary(outcomes: &[Outcome]) -> String { + let mut out = String::from("\nSummary\n"); + for outcome in outcomes { + let status = match &outcome.status { + Status::Passed { tests } => format!("PASS {tests} passed"), + Status::Failed { tests, failures } => { + format!("FAIL {failures} of {tests} failed") + } + Status::Unsupported(reason) => format!("UNSUPPORTED {reason}"), + Status::NothingToRun => { + "UNSUPPORTED every selected test needs a missing capability".to_string() + } + Status::Error(error) => format!("ERROR {error}"), + }; + let unsupported = if outcome.unsupported_tests > 0 { + format!(" ({} tests unsupported)", outcome.unsupported_tests) + } else { + String::new() + }; + let _ = writeln!( + out, + " {} / {}: {status}{unsupported}", + outcome.environment, outcome.family + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_junit_counts() { + let xml = r#" + +"#; + assert_eq!(junit_counts(xml), Some((3, 2))); + } + + #[test] + fn missing_root_has_no_counts() { + assert_eq!(junit_counts(""), None); + } + + #[test] + fn summarizes_outcomes() { + let text = summary(&[ + Outcome { + environment: "env-a".to_string(), + family: "conformance".to_string(), + status: Status::Passed { tests: 2 }, + unsupported_tests: 1, + }, + Outcome { + environment: "env-b".to_string(), + family: "conformance".to_string(), + status: Status::Unsupported("arch".to_string()), + unsupported_tests: 0, + }, + ]); + assert!( + text.contains("env-a / conformance: PASS 2 passed (1 tests unsupported)"), + "{text}" + ); + assert!( + text.contains("env-b / conformance: UNSUPPORTED arch"), + "{text}" + ); + } +} diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index 2c6a2eb277..b54bc0a615 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -1247,11 +1247,20 @@ dependencies = [ name = "openshell-testkit-macros" version = "0.0.0" dependencies = [ + "openshell-testkit-spec", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/tests/suites/conformance/Cargo.toml b/tests/suites/conformance/Cargo.toml index 7aee0b273f..a3f32e8884 100644 --- a/tests/suites/conformance/Cargo.toml +++ b/tests/suites/conformance/Cargo.toml @@ -3,4 +3,4 @@ [workspace] resolver = "2" -members = ["cli", "sandbox-lifecycle", "testkit", "testkit-macros"] +members = ["cli", "sandbox-lifecycle", "testkit", "testkit-macros", "testkit-spec"] diff --git a/tests/suites/conformance/testkit-macros/Cargo.toml b/tests/suites/conformance/testkit-macros/Cargo.toml index 6f632680e2..eea132ec5e 100644 --- a/tests/suites/conformance/testkit-macros/Cargo.toml +++ b/tests/suites/conformance/testkit-macros/Cargo.toml @@ -16,6 +16,7 @@ name = "testkit_macros" proc-macro = true [dependencies] +openshell-testkit-spec = { path = "../testkit-spec" } proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full"] } diff --git a/tests/suites/conformance/testkit-macros/src/lib.rs b/tests/suites/conformance/testkit-macros/src/lib.rs index 6a52cb106a..7a2cabd68c 100644 --- a/tests/suites/conformance/testkit-macros/src/lib.rs +++ b/tests/suites/conformance/testkit-macros/src/lib.rs @@ -16,14 +16,13 @@ //! //! The attribute expands to a `#[tokio::test]` wrapper with the same name, so //! the nextest test name is the test ID. The original function is nested in -//! the wrapper and passed to `testkit::run`. +//! the wrapper and passed to `testkit::run`. Parsing lives in `testkit-spec`, +//! which the test runner's catalog also uses. use proc_macro::TokenStream; -use proc_macro2::{Span, TokenStream as TokenStream2}; +use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; -use syn::parse::{Parse, ParseStream}; -use syn::punctuated::Punctuated; -use syn::{Expr, ExprLit, Ident, ItemFn, Lit, LitStr, Meta, Token, bracketed}; +use syn::{Ident, ItemFn, LitStr}; #[proc_macro_attribute] pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { @@ -32,62 +31,20 @@ pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } -/// Arguments accepted by `#[testkit::test(...)]`. -#[derive(Default)] -struct Args { - requires: Vec, -} - -impl Parse for Args { - fn parse(input: ParseStream<'_>) -> syn::Result { - let mut args = Self::default(); - while !input.is_empty() { - let key: Ident = input.parse()?; - if key != "requires" { - return Err(syn::Error::new( - key.span(), - format!("unknown argument `{key}`; expected `requires = [...]`"), - )); - } - input.parse::()?; - let content; - bracketed!(content in input); - args.requires - .extend(Punctuated::::parse_terminated(&content)?); - if !input.is_empty() { - input.parse::()?; - } - } - Ok(args) - } -} - fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { - let args: Args = syn::parse2(attr)?; let mut body: ItemFn = syn::parse2(item)?; + let spec = testkit_spec::parse(attr, &body)?; - if body.sig.asyncness.is_none() { - return Err(syn::Error::new_spanned( - body.sig.fn_token, - "conformance tests must be `async fn`", - )); - } - if body.sig.inputs.len() != 1 { - return Err(syn::Error::new_spanned( - &body.sig.inputs, - "conformance tests take exactly one argument: `cx: &mut testkit::Context`", - )); - } - - let id = body.sig.ident.clone(); - let description = description(&body)?; - let requires = args.requires.iter().map(|capability| { + let id = spec.id; + let id_str = LitStr::new(&id.to_string(), id.span()); + let description = LitStr::new(&spec.description, id.span()); + let requires = spec.requires.iter().map(|capability| { let variant = Ident::new(&to_camel_case(&capability.to_string()), capability.span()); quote!(::testkit::Capability::#variant) }); let vis = body.vis.clone(); - let wrapper_attrs: Vec<_> = body + let doc_attrs: Vec<_> = body .attrs .iter() .filter(|attr| attr.path().is_ident("doc")) @@ -96,10 +53,9 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { body.attrs.retain(|attr| !attr.path().is_ident("doc")); body.vis = syn::Visibility::Inherited; body.sig.ident = format_ident!("body"); - let id_str = LitStr::new(&id.to_string(), id.span()); Ok(quote! { - #(#wrapper_attrs)* + #(#doc_attrs)* #[::testkit::__private::tokio::test(crate = "::testkit::__private::tokio")] #vis async fn #id() { #body @@ -116,33 +72,6 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { }) } -/// The test description: the doc comment, joined into one line. -fn description(item: &ItemFn) -> syn::Result { - let lines: Vec = item - .attrs - .iter() - .filter_map(|attr| match &attr.meta { - Meta::NameValue(meta) if meta.path.is_ident("doc") => match &meta.value { - Expr::Lit(ExprLit { - lit: Lit::Str(line), - .. - }) => Some(line.value().trim().to_string()), - _ => None, - }, - _ => None, - }) - .filter(|line| !line.is_empty()) - .collect(); - - if lines.is_empty() { - return Err(syn::Error::new( - item.sig.ident.span(), - "conformance tests need a doc comment describing the behavior they verify", - )); - } - Ok(LitStr::new(&lines.join(" "), Span::call_site())) -} - fn to_camel_case(snake: &str) -> String { snake .split('_') @@ -176,16 +105,6 @@ mod tests { assert!(out.contains("description : \"Does a thing.\""), "{out}"); } - #[test] - fn joins_multi_line_doc_comments() { - let out = expand_str( - "", - "/// First line\n/// second line.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", - ) - .unwrap(); - assert!(out.contains("\"First line second line.\""), "{out}"); - } - #[test] fn maps_requirements_to_capabilities() { let out = expand_str( @@ -202,7 +121,7 @@ mod tests { } #[test] - fn rejects_missing_doc_comment() { + fn reports_spec_errors() { let err = expand_str( "", "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", @@ -210,31 +129,4 @@ mod tests { .unwrap_err(); assert!(err.to_string().contains("doc comment"), "{err}"); } - - #[test] - fn rejects_sync_fn() { - let err = expand_str( - "", - "/// Doc.\nfn t(cx: &mut Context) -> testkit::Result { Ok(()) }", - ) - .unwrap_err(); - assert!(err.to_string().contains("async fn"), "{err}"); - } - - #[test] - fn rejects_wrong_arity() { - let err = - expand_str("", "/// Doc.\nasync fn t() -> testkit::Result { Ok(()) }").unwrap_err(); - assert!(err.to_string().contains("exactly one argument"), "{err}"); - } - - #[test] - fn rejects_unknown_argument() { - let err = expand_str( - "timeout = [x]", - "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", - ) - .unwrap_err(); - assert!(err.to_string().contains("unknown argument"), "{err}"); - } } diff --git a/tests/suites/conformance/testkit-spec/Cargo.toml b/tests/suites/conformance/testkit-spec/Cargo.toml new file mode 100644 index 0000000000..640503c38a --- /dev/null +++ b/tests/suites/conformance/testkit-spec/Cargo.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-testkit-spec" +description = "Parses #[testkit::test] functions for the macro and the test catalog" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[lib] +name = "testkit_spec" + +[dependencies] +proc-macro2 = "1" +syn = { version = "2", features = ["full"] } diff --git a/tests/suites/conformance/testkit-spec/src/lib.rs b/tests/suites/conformance/testkit-spec/src/lib.rs new file mode 100644 index 0000000000..e6108f45be --- /dev/null +++ b/tests/suites/conformance/testkit-spec/src/lib.rs @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The single definition of what a `#[testkit::test]` function declares. +//! +//! The `#[testkit::test]` macro and the test runner's catalog both parse +//! tests through this crate, so they cannot disagree about a test's ID, +//! description, or requirements. + +use proc_macro2::TokenStream; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Attribute, Expr, ExprLit, Ident, Item, ItemFn, Lit, Meta, Token, bracketed}; + +/// What a test declares. +#[derive(Clone, Debug)] +pub struct TestSpec { + /// The function name, which is the test ID. + pub id: Ident, + /// The doc comment, joined into one line. + pub description: String, + /// Capability names, as written in `requires = [...]`. + pub requires: Vec, +} + +/// Arguments accepted by `#[testkit::test(...)]`. +#[derive(Default)] +struct Args { + requires: Vec, +} + +impl Parse for Args { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut args = Self::default(); + while !input.is_empty() { + let key: Ident = input.parse()?; + if key != "requires" { + return Err(syn::Error::new( + key.span(), + format!("unknown argument `{key}`; expected `requires = [...]`"), + )); + } + input.parse::()?; + let content; + bracketed!(content in input); + args.requires + .extend(Punctuated::::parse_terminated(&content)?); + if !input.is_empty() { + input.parse::()?; + } + } + Ok(args) + } +} + +/// Parse a test from the attribute's arguments and the function it marks. +pub fn parse(args: TokenStream, item: &ItemFn) -> syn::Result { + let args: Args = syn::parse2(args)?; + + if item.sig.asyncness.is_none() { + return Err(syn::Error::new_spanned( + item.sig.fn_token, + "conformance tests must be `async fn`", + )); + } + if item.sig.inputs.len() != 1 { + return Err(syn::Error::new_spanned( + &item.sig.inputs, + "conformance tests take exactly one argument: `cx: &mut testkit::Context`", + )); + } + + let description = doc_comment(&item.attrs); + if description.is_empty() { + return Err(syn::Error::new( + item.sig.ident.span(), + "conformance tests need a doc comment describing the behavior they verify", + )); + } + + Ok(TestSpec { + id: item.sig.ident.clone(), + description, + requires: args.requires, + }) +} + +/// Find every `#[testkit::test]` function in a source file. +pub fn scan(source: &str) -> syn::Result> { + let file = syn::parse_file(source)?; + let mut tests = Vec::new(); + for item in &file.items { + let Item::Fn(item) = item else { + continue; + }; + let Some(attr) = item.attrs.iter().find(|attr| is_testkit_test(attr)) else { + continue; + }; + let args = match &attr.meta { + Meta::Path(_) => TokenStream::new(), + Meta::List(list) => list.tokens.clone(), + Meta::NameValue(meta) => { + return Err(syn::Error::new_spanned( + meta, + "expected `#[testkit::test]` or `#[testkit::test(...)]`", + )); + } + }; + tests.push(parse(args, item)?); + } + Ok(tests) +} + +fn is_testkit_test(attr: &Attribute) -> bool { + let segments: Vec<_> = attr.path().segments.iter().collect(); + segments.len() == 2 && segments[0].ident == "testkit" && segments[1].ident == "test" +} + +/// The doc comment, joined into one line. Empty if there is none. +pub fn doc_comment(attrs: &[Attribute]) -> String { + attrs + .iter() + .filter_map(|attr| match &attr.meta { + Meta::NameValue(meta) if meta.path.is_ident("doc") => match &meta.value { + Expr::Lit(ExprLit { + lit: Lit::Str(line), + .. + }) => Some(line.value().trim().to_string()), + _ => None, + }, + _ => None, + }) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_str(args: &str, item: &str) -> syn::Result { + parse(args.parse().unwrap(), &syn::parse_str(item).unwrap()) + } + + #[test] + fn parses_id_description_and_requirements() { + let spec = parse_str( + "requires = [service_exposure, gpu]", + "/// First line\n/// second line.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap(); + assert_eq!(spec.id, "t"); + assert_eq!(spec.description, "First line second line."); + assert_eq!(spec.requires, ["service_exposure", "gpu"]); + } + + #[test] + fn rejects_missing_doc_comment() { + let err = parse_str( + "", + "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("doc comment"), "{err}"); + } + + #[test] + fn rejects_sync_fn() { + let err = parse_str( + "", + "/// Doc.\nfn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("async fn"), "{err}"); + } + + #[test] + fn rejects_wrong_arity() { + let err = + parse_str("", "/// Doc.\nasync fn t() -> testkit::Result { Ok(()) }").unwrap_err(); + assert!(err.to_string().contains("exactly one argument"), "{err}"); + } + + #[test] + fn rejects_unknown_argument() { + let err = parse_str( + "timeout = [x]", + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("unknown argument"), "{err}"); + } + + #[test] + fn scans_only_testkit_tests() { + let tests = scan( + " + use testkit::Context; + + /// Plain. + #[testkit::test] + async fn plain(cx: &mut Context) -> testkit::Result { Ok(()) } + + /// Needs exposure. + #[testkit::test(requires = [service_exposure])] + async fn exposed(cx: &mut Context) -> testkit::Result { Ok(()) } + + #[test] + fn not_a_conformance_test() {} + + async fn helper(cx: &mut Context) {} + ", + ) + .unwrap(); + let ids: Vec<_> = tests.iter().map(|test| test.id.to_string()).collect(); + assert_eq!(ids, ["plain", "exposed"]); + assert_eq!(tests[1].requires, ["service_exposure"]); + } + + #[test] + fn scan_reports_invalid_tests() { + let err = scan("#[testkit::test]\nasync fn t(cx: &mut Context) {}").unwrap_err(); + assert!(err.to_string().contains("doc comment"), "{err}"); + } +} From a37ebe91747b10f5603d55221c6fa6851c38771c Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 15 Oct 2026 23:44:58 +0200 Subject: [PATCH 04/18] build(cargo): keep conformance lockfile aligned with the root `cargo --locked` only checks a lockfile against its own manifest. The conformance suites build openshell-sdk from a separate workspace with its own Cargo.lock, so the two lockfiles could resolve shared crates to different versions without any check failing. That is how aws-lc-sys 0.46 was picked up, which does not link against the AWS-LC Nix provides. - Add check_cargo_lockfile_alignment.py: aligned workspaces must lock every registry crate they share with the root at a version the root lockfile has. `--fix` re-locks a workspace from the root lockfile, which keeps coupled crate families such as futures-* consistent. - Enroll tests/suites/conformance and re-lock it from the root lockfile. The other workspaces that path-depend on root crates also drift and can be enrolled once aligned. - Run the check in rust:lockfiles:check and the Cargo lockfiles CI job, and its tests in `mise run test`. - Build test archives and run the test runner with --locked so a stale lockfile fails instead of being re-resolved. Signed-off-by: Evan Lezar --- .github/workflows/branch-checks.yml | 4 + TESTING.md | 11 + tasks/rust.toml | 7 +- .../scripts/check_cargo_lockfile_alignment.py | 169 ++++ .../check_cargo_lockfile_alignment_test.py | 85 ++ tasks/test.toml | 5 + tests/artifacts.nix | 1 + tests/config.nix | 2 +- tests/runner/src/bundle.rs | 2 +- tests/suites/conformance/Cargo.lock | 796 ++++++++++-------- 10 files changed, 737 insertions(+), 345 deletions(-) create mode 100644 tasks/scripts/check_cargo_lockfile_alignment.py create mode 100644 tasks/scripts/check_cargo_lockfile_alignment_test.py diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 075cf5b2c8..fc782509fc 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -239,6 +239,10 @@ jobs: shell: nix develop -c bash -euo pipefail {0} run: tasks/scripts/check-cargo-lockfiles.sh + - name: Verify Cargo lockfile alignment + shell: nix develop -c bash -euo pipefail {0} + run: uv run tasks/scripts/check_cargo_lockfile_alignment.py + rust-lint: name: Rust lint (${{ matrix.system }}) needs: pr_metadata diff --git a/TESTING.md b/TESTING.md index 8cb81545b9..a08a100ddf 100644 --- a/TESTING.md +++ b/TESTING.md @@ -48,6 +48,17 @@ mise run test:rust # cargo test --workspace Rust validation checks tracked Cargo lockfiles; run `mise run rust:lockfiles:check` to check them directly. If one is stale, refresh it with Cargo using its adjacent manifest, review the diff, and commit the update. +Workspaces that build root crates through path dependencies keep their own +lockfiles, which `--locked` does not compare with the root's. The check also +requires the workspaces listed in +`tasks/scripts/check_cargo_lockfile_alignment.py` to lock shared crates at the +root's versions. If it fails, re-lock them from the root lockfile and commit the +result: + +```shell +uv run tasks/scripts/check_cargo_lockfile_alignment.py --fix +``` + ### PostgreSQL-backed tests Tests that need a real PostgreSQL server, such as advisory-lock concurrency diff --git a/tasks/rust.toml b/tasks/rust.toml index 32928b8f69..7e5882dcf5 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -18,8 +18,11 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts hide = true ["rust:lockfiles:check"] -description = "Verify all tracked Cargo lockfiles are current" -run = "tasks/scripts/check-cargo-lockfiles.sh" +description = "Verify all tracked Cargo lockfiles are current and aligned with the root" +run = [ + "tasks/scripts/check-cargo-lockfiles.sh", + "uv run tasks/scripts/check_cargo_lockfile_alignment.py", +] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/check-cargo-lockfiles.ps1" hide = true diff --git a/tasks/scripts/check_cargo_lockfile_alignment.py b/tasks/scripts/check_cargo_lockfile_alignment.py new file mode 100644 index 0000000000..218b20446c --- /dev/null +++ b/tasks/scripts/check_cargo_lockfile_alignment.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# /// script +# requires-python = ">=3.11" +# /// + +"""Check that aligned Cargo workspaces lock shared crates like the root does. + +Some workspaces outside the root build root crates through path +dependencies, for example the conformance suites build `openshell-sdk`. Each +workspace has its own Cargo.lock, and `cargo --locked` only checks a lockfile +against its own manifest, so nothing stops the two lockfiles from resolving +the same crate to different versions. The root's versions are the ones CI and +the Nix toolchain are built for; a newer `aws-lc-sys`, for example, does not +link against the AWS-LC that Nix provides. + +This check fails when an aligned workspace locks a registry crate that the +root lockfile also contains at a version the root lockfile does not have. +With `--fix`, it re-locks each aligned workspace starting from the root +lockfile. +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path + +# Workspaces whose shared crates must match the root lockfile. Add a +# workspace here once its lockfile has been aligned with `--fix`. +ALIGNED_WORKSPACES = ("tests/suites/conformance",) + + +@dataclass(frozen=True) +class Mismatch: + workspace: str + name: str + version: str + root_versions: tuple[str, ...] + + def target(self) -> str | None: + """The root version semver-compatible with the locked one, if any.""" + key = compat_key(self.version) + candidates = [v for v in self.root_versions if compat_key(v) == key] + return max(candidates, key=version_tuple) if candidates else None + + +def version_tuple(version: str) -> tuple[int, ...]: + core = re.split(r"[-+]", version, maxsplit=1)[0] + return tuple(int(part) for part in core.split(".")) + + +def compat_key(version: str) -> tuple[int, ...]: + """Cargo's caret-compatibility class: the leftmost non-zero component.""" + major, minor, patch = (*version_tuple(version), 0, 0, 0)[:3] + if major: + return (major,) + if minor: + return (0, minor) + return (0, 0, patch) + + +def registry_versions(lockfile: Path) -> dict[str, set[str]]: + """Versions of registry (non-path) packages in a lockfile, by name.""" + data = tomllib.loads(lockfile.read_text()) + versions: dict[str, set[str]] = {} + for package in data.get("package", []): + if "source" in package: + versions.setdefault(package["name"], set()).add(package["version"]) + return versions + + +def mismatches(root: Path, workspace: str) -> list[Mismatch]: + root_versions = registry_versions(root / "Cargo.lock") + found = [] + for name, versions in sorted( + registry_versions(root / workspace / "Cargo.lock").items() + ): + shared = root_versions.get(name) + if shared is None: + continue + for version in sorted(versions - shared, key=version_tuple): + found.append( + Mismatch( + workspace, name, version, tuple(sorted(shared, key=version_tuple)) + ) + ) + return found + + +def fix(root: Path, workspace: str) -> list[Mismatch]: + """Re-lock the workspace starting from the root lockfile. + + Coupled crates such as the `futures-*` family pin each other, so they + cannot be moved one at a time. Seeding the workspace lockfile with the + root's lets Cargo keep every root version that still fits, add only the + crates the root does not have, and drop the rest. Returns what is still + misaligned afterwards. + """ + shutil.copyfile(root / "Cargo.lock", root / workspace / "Cargo.lock") + subprocess.run( + [ + "cargo", + "metadata", + "--format-version", + "1", + "--manifest-path", + str(root / workspace / "Cargo.toml"), + ], + check=True, + stdout=subprocess.DEVNULL, + ) + return mismatches(root, workspace) + + +def report(problems: list[Mismatch]) -> None: + for m in problems: + target = m.target() + hint = ( + f"; root has {target}" + if target + else f"; root has {', '.join(m.root_versions)}" + ) + print( + f"error: {m.workspace}: {m.name} {m.version} is not in the root Cargo.lock{hint}", + file=sys.stderr, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--fix", action="store_true", help="update aligned lockfiles to root versions" + ) + args = parser.parse_args(argv) + + root = Path( + subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ) + problems: list[Mismatch] = [] + for workspace in ALIGNED_WORKSPACES: + print(f"Checking {workspace}/Cargo.lock against Cargo.lock") + problems += fix(root, workspace) if args.fix else mismatches(root, workspace) + + if problems: + report(problems) + if not args.fix: + print( + "Run `uv run tasks/scripts/check_cargo_lockfile_alignment.py --fix` to align them.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tasks/scripts/check_cargo_lockfile_alignment_test.py b/tasks/scripts/check_cargo_lockfile_alignment_test.py new file mode 100644 index 0000000000..955f31db9e --- /dev/null +++ b/tasks/scripts/check_cargo_lockfile_alignment_test.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the Cargo lockfile alignment check.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +_SPEC = importlib.util.spec_from_file_location( + "check_cargo_lockfile_alignment", + Path(__file__).with_name("check_cargo_lockfile_alignment.py"), +) +assert _SPEC and _SPEC.loader +alignment = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = alignment +_SPEC.loader.exec_module(alignment) + +REGISTRY = 'source = "registry+https://github.com/rust-lang/crates.io-index"' + + +def lockfile(path: Path, packages: list[tuple[str, str, bool]]) -> None: + """Write a lockfile of (name, version, from_registry) packages.""" + entries = [] + for name, version, from_registry in packages: + entry = f'[[package]]\nname = "{name}"\nversion = "{version}"\n' + if from_registry: + entry += REGISTRY + "\n" + entries.append(entry) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("version = 4\n\n" + "\n".join(entries)) + + +def test_compat_key_follows_cargo_caret_rules() -> None: + assert alignment.compat_key("1.52.1") == (1,) + assert alignment.compat_key("0.44.0") == (0, 44) + assert alignment.compat_key("0.0.3") == (0, 0, 3) + assert alignment.compat_key("1.0.4+wasi-0.2.12") == (1,) + + +def test_reports_shared_registry_crates_at_other_versions(tmp_path: Path) -> None: + lockfile( + tmp_path / "Cargo.lock", + [ + ("tokio", "1.52.1", True), + ("rand", "0.8.6", True), + ("rand", "0.9.4", True), + ("openshell-sdk", "0.0.0", False), + ], + ) + lockfile( + tmp_path / "ws" / "Cargo.lock", + [ + ("tokio", "1.53.1", True), + ("rand", "0.9.5", True), + ("rand", "0.8.6", True), + ("only-here", "2.0.0", True), + ("openshell-sdk", "0.0.0", False), + ], + ) + + found = alignment.mismatches(tmp_path, "ws") + + assert [(m.name, m.version, m.target()) for m in found] == [ + ("rand", "0.9.5", "0.9.4"), + ("tokio", "1.53.1", "1.52.1"), + ] + + +def test_no_target_without_a_compatible_root_version(tmp_path: Path) -> None: + lockfile(tmp_path / "Cargo.lock", [("syn", "1.0.109", True)]) + lockfile(tmp_path / "ws" / "Cargo.lock", [("syn", "2.0.119", True)]) + + (found,) = alignment.mismatches(tmp_path, "ws") + + assert found.target() is None + + +def test_aligned_lockfiles_pass(tmp_path: Path) -> None: + lockfile(tmp_path / "Cargo.lock", [("tokio", "1.52.1", True)]) + lockfile(tmp_path / "ws" / "Cargo.lock", [("tokio", "1.52.1", True)]) + + assert alignment.mismatches(tmp_path, "ws") == [] diff --git a/tasks/test.toml b/tasks/test.toml index c1df20262a..bd46029fe6 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -23,6 +23,7 @@ depends = [ "test:docs-website", "test:docs-nav", "test:maintainer-approval", + "test:cargo-lockfile-alignment", ] ["test:docs-website"] @@ -31,6 +32,10 @@ description = "Test the docs-website sync script" # dependencies and the script's runtime dependency, which live outside the project env. run = "uv run --no-project --with pytest --with pytest-asyncio --with pyyaml pytest tasks/scripts/sync_docs_website_test.py" +["test:cargo-lockfile-alignment"] +description = "Test the Cargo lockfile alignment check" +run = "uv run --no-project --with pytest pytest tasks/scripts/check_cargo_lockfile_alignment_test.py" + ["test:docs-nav"] description = "Test the docs navigation check" run = "uv run --no-project --with pytest --with pytest-asyncio --with pyyaml pytest tasks/scripts/check_docs_nav_test.py" diff --git a/tests/artifacts.nix b/tests/artifacts.nix index ef5e26988c..d7187cb7a6 100644 --- a/tests/artifacts.nix +++ b/tests/artifacts.nix @@ -58,6 +58,7 @@ let mkdir -p "$(dirname "$output")" cd "$root" cargo nextest archive \ + --locked \ --manifest-path "$manifest_path" \ --target ${target} \ -p ${package} \ diff --git a/tests/config.nix b/tests/config.nix index 46a016d545..f8fe19578a 100644 --- a/tests/config.nix +++ b/tests/config.nix @@ -259,7 +259,7 @@ let text = '' root=$(git rev-parse --show-toplevel) export ANSIBLE_CONFIG="$root/tests/ansible/ansible.cfg" - exec cargo run --quiet --manifest-path "$root/tests/runner/Cargo.toml" --bin openshell-test -- "$@" + exec cargo run --quiet --locked --manifest-path "$root/tests/runner/Cargo.toml" --bin openshell-test -- "$@" ''; }; in diff --git a/tests/runner/src/bundle.rs b/tests/runner/src/bundle.rs index 5a2b9f50d6..ccb5ae600c 100644 --- a/tests/runner/src/bundle.rs +++ b/tests/runner/src/bundle.rs @@ -60,7 +60,7 @@ pub fn build( let mut command = Command::new("cargo"); command - .args(["nextest", "archive", "--manifest-path"]) + .args(["nextest", "archive", "--locked", "--manifest-path"]) .arg(&manifest) .args(["--target", target, "--archive-file"]) .arg(&archive); diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index b54bc0a615..138130647e 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -19,27 +19,27 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "android_system_properties" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.104" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "async-stream" @@ -60,18 +60,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "async-trait" -version = "0.1.92" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] @@ -82,9 +82,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" @@ -182,12 +182,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "bitflags" version = "2.13.2" @@ -205,9 +199,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.3" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytes" @@ -217,9 +211,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.8.0" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -235,9 +229,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" @@ -299,9 +293,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "digest" @@ -315,13 +309,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.7" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] @@ -332,15 +326,15 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.19.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "equivalent" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" @@ -354,15 +348,15 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.5.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -399,9 +393,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -414,9 +408,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -424,15 +418,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -441,38 +435,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -522,13 +516,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "wasip2", + "wasip3", ] [[package]] @@ -539,15 +535,15 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.4" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.4.20" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -573,9 +569,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -585,9 +581,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", @@ -595,9 +591,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.1.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", @@ -605,9 +601,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.5" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", @@ -630,9 +626,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.12.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -652,9 +648,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.10" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", @@ -681,17 +677,16 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.21" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.23.1", + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", - "httparse", "hyper", "ipnet", "libc", @@ -729,9 +724,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", @@ -743,43 +738,22 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", - "serde", "tinystr", "writeable", "zerovec", ] -[[package]] -name = "icu_locale_fallback" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" -dependencies = [ - "icu_locale_core", - "icu_locale_fallback_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_fallback_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" - [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -791,17 +765,16 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -812,20 +785,18 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", - "serde", - "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -834,26 +805,10 @@ dependencies = [ ] [[package]] -name = "icu_segmenter" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" -dependencies = [ - "icu_collections", - "icu_locale_fallback", - "icu_provider", - "icu_segmenter_data", - "potential_utf", - "smallvec", - "utf8_iter", - "zerovec", -] - -[[package]] -name = "icu_segmenter_data" +name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "idna" @@ -868,9 +823,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -878,21 +833,31 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.2" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown 0.17.0", "serde", "serde_core", ] [[package]] name = "ipnet" -version = "2.12.2" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] [[package]] name = "is_ci" @@ -917,25 +882,31 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.35" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.106" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", "wasm-bindgen", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.189" @@ -956,9 +927,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -983,9 +954,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "miette" @@ -1014,7 +985,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1034,9 +1005,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -1067,7 +1038,7 @@ version = "0.0.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" dependencies = [ - "hashbrown 0.17.1", + "hashbrown 0.17.0", "indexmap", "libm", "memchr", @@ -1091,11 +1062,11 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "getrandom 0.2.17", "http", - "rand 0.8.8", + "rand 0.8.6", "reqwest", "serde", "serde_json", @@ -1124,7 +1095,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "openshell-conformance" version = "0.0.0" dependencies = [ - "rand 0.9.5", + "rand 0.9.4", "serde", "serde_json", "tempfile", @@ -1143,7 +1114,7 @@ name = "openshell-core" version = "0.0.0" dependencies = [ "async-trait", - "base64 0.22.1", + "base64", "chrono", "glob", "ipnet", @@ -1161,7 +1132,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror 2.0.21", + "thiserror 2.0.20", "tokio", "tokio-stream", "tonic", @@ -1179,7 +1150,7 @@ version = "0.0.0" dependencies = [ "hyper-util", "serde", - "thiserror 2.0.21", + "thiserror 2.0.20", "tokio", "tonic", "tower", @@ -1194,7 +1165,7 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", - "thiserror 2.0.21", + "thiserror 2.0.20", ] [[package]] @@ -1212,7 +1183,7 @@ dependencies = [ "reqwest", "rustls", "serde", - "thiserror 2.0.21", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-tungstenite", @@ -1237,7 +1208,7 @@ dependencies = [ "noyalib", "openshell-sdk", "openshell-testkit-macros", - "rand 0.9.5", + "rand 0.9.4", "serde", "tokio", "tonic", @@ -1250,7 +1221,7 @@ dependencies = [ "openshell-testkit-spec", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1258,7 +1229,7 @@ name = "openshell-testkit-spec" version = "0.0.0" dependencies = [ "proc-macro2", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1269,9 +1240,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "owo-colors" -version = "4.4.0" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "parking_lot" @@ -1315,22 +1286,22 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.13" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.13" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1341,18 +1312,16 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "potential_utf" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ - "serde_core", - "writeable", "zerovec", ] @@ -1372,23 +1341,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "proc-macro2" -version = "1.0.107" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "prost" -version = "0.14.4" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", "prost-derive", @@ -1396,9 +1365,9 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.4" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", "itertools", @@ -1411,37 +1380,37 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.119", + "syn 2.0.117", "tempfile", ] [[package]] name = "prost-derive" -version = "0.14.4" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "prost-types" -version = "0.14.4" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ "prost", ] [[package]] name = "protoc-bin-vendored" -version = "3.3.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8760a25b6ff9c620324822737e468478fa092234190d2e449760344354896ed9" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" dependencies = [ "protoc-bin-vendored-linux-aarch_64", "protoc-bin-vendored-linux-ppcle_64", @@ -1455,57 +1424,57 @@ dependencies = [ [[package]] name = "protoc-bin-vendored-linux-aarch_64" -version = "3.3.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fa2624782ca04cd44f51554566717377acd240e4c0016d757dd74fccc9324f" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" [[package]] name = "protoc-bin-vendored-linux-ppcle_64" -version = "3.3.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2417e9817fa237dab803ad4dda7357a111656e242959cc6b8f9a1a583367d42" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" [[package]] name = "protoc-bin-vendored-linux-s390_64" -version = "3.3.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d189c34636356a46a7ed3188233dc8a88c431278cc54d4a19b096a2d270e985" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" [[package]] name = "protoc-bin-vendored-linux-x86_32" -version = "3.3.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171e39f1e846e5f322ced1ac3b8d4cd3a3833ca24b6e5d58b3632574fe6204fa" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" [[package]] name = "protoc-bin-vendored-linux-x86_64" -version = "3.3.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873cdcc097593432086661aa432b8078f1cd87bfb02847c332e98ae2c119e966" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" [[package]] name = "protoc-bin-vendored-macos-aarch_64" -version = "3.3.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeb72df001783b8297847fe8f5f874ee400fd742c843d60583e8c23d96977c7f" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" [[package]] name = "protoc-bin-vendored-macos-x86_64" -version = "3.3.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b04652167eca899dda05f32f5481adeaf25c623a98ce2fc146a001cc59a2add7" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" [[package]] name = "protoc-bin-vendored-win32" -version = "3.3.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "263a3f48f01e7309e857138bd47f785585b4a005e8e56c6d2824ce91195999c3" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" [[package]] name = "pulldown-cmark" -version = "0.13.4" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ "bitflags", "memchr", @@ -1514,18 +1483,18 @@ dependencies = [ [[package]] name = "pulldown-cmark-to-cmark" -version = "22.0.3" +version = "22.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84bbb29c624230c4bd1047bbdb2aa47e41c860e9665ce62ba9504eebe91bf867" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" dependencies = [ "pulldown-cmark", ] [[package]] name = "quote" -version = "1.0.47" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1544,9 +1513,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.8" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -1555,9 +1524,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.5" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -1612,9 +1581,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.13.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1624,9 +1593,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.18" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1635,9 +1604,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.11" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -1645,7 +1614,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", @@ -1694,21 +1663,21 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.28" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.3" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -1734,9 +1703,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -1755,9 +1724,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "zeroize", ] @@ -1776,9 +1745,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -1824,6 +1793,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1856,9 +1831,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -1892,9 +1867,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.7" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", @@ -1914,9 +1889,9 @@ dependencies = [ [[package]] name = "shlex" -version = "2.0.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" @@ -1936,15 +1911,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1985,9 +1960,9 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "syn" -version = "2.0.119" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2016,13 +1991,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.14.0" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] @@ -2032,7 +2007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2050,11 +2025,11 @@ dependencies = [ [[package]] name = "textwrap" -version = "0.16.4" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ - "icu_segmenter", + "unicode-linebreak", "unicode-width 0.2.2", ] @@ -2069,11 +2044,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.21" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.21", + "thiserror-impl 2.0.20", ] [[package]] @@ -2084,14 +2059,14 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.21" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -2100,20 +2075,19 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", - "serde_core", "zerovec", ] [[package]] name = "tokio" -version = "1.53.1" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -2128,20 +2102,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.2" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] name = "tokio-rustls" -version = "0.26.6" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -2149,9 +2123,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.19" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -2176,9 +2150,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.20" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -2196,7 +2170,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", - "base64 0.22.1", + "base64", "bytes", "h2", "http", @@ -2228,7 +2202,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2253,7 +2227,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn 2.0.119", + "syn 2.0.117", "tempfile", "tonic-build", ] @@ -2290,20 +2264,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.11" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", + "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", - "url", ] [[package]] @@ -2337,7 +2311,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2366,11 +2340,11 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.5", + "rand 0.9.4", "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.21", + "thiserror 2.0.20", "utf-8", ] @@ -2382,9 +2356,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" -version = "2.10.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" @@ -2392,6 +2366,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-width" version = "0.1.14" @@ -2404,6 +2384,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -2437,11 +2423,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.28.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -2454,9 +2440,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "want" -version = "0.3.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ "try-lock", ] @@ -2469,18 +2455,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.4+wasi-0.2.12" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.129" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -2491,20 +2486,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.79" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", - "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.129" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2512,9 +2506,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.129" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", @@ -2525,18 +2519,52 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.129" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.106" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -2563,7 +2591,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2574,7 +2602,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2683,23 +2711,111 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" -version = "0.6.4" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2708,82 +2824,80 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.4" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "zerofrom" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", "zerofrom", - "zerovec", ] [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ - "serde", "yoke", "zerofrom", "zerovec-derive", @@ -2791,17 +2905,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" From d3d315c3b2fcd642034292f28eea659a1b4b8a1b Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 08:47:16 +0200 Subject: [PATCH 05/18] fix(tests): build tmachine on x86 and keep results paths absolute firmware_path() was compiled only on aarch64, but command() calls it on every architecture, so tmachine and the test runner failed to build on x86. Drop the architecture guard; the runtime override is harmless there. Resolve a relative --results directory before load_tmachine() changes into tests/, so all of a run's artifacts land in one place. Signed-off-by: Evan Lezar --- tests/runner/src/main.rs | 4 +++- tests/tmachine/src/qemu/vm.rs | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index 7e8faaa587..b0e0592301 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -97,8 +97,10 @@ async fn run(cli: Cli) -> Result { return Ok(ExitCode::SUCCESS); } + // Absolute, because `load_tmachine` changes the working directory. let results = match results { - Some(results) => results, + Some(results) => std::path::absolute(&results) + .with_context(|| format!("resolve {}", results.display()))?, None => root.join("artifacts/test-results").join( plan_path .file_stem() diff --git a/tests/tmachine/src/qemu/vm.rs b/tests/tmachine/src/qemu/vm.rs index c65ba7d7b4..34751bb9a8 100644 --- a/tests/tmachine/src/qemu/vm.rs +++ b/tests/tmachine/src/qemu/vm.rs @@ -164,7 +164,6 @@ fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) - /// A firmware path from the runtime environment, falling back to the value /// compiled in. The fallback lets library users built outside Nix (such as /// the test runner) supply firmware at runtime. -#[cfg(target_arch = "aarch64")] fn firmware_path(name: &str, compiled: Option<&'static str>) -> String { std::env::var(name) .ok() From 1e1a58791a717bc07dc2b2dca1a8e9928e933396 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 09:05:34 +0200 Subject: [PATCH 06/18] refactor(tests): move testkit out of the conformance workspace testkit is not specific to conformance: the target file, gateway connection, sandbox cleanup and the #[testkit::test] metadata apply to every test family, and the runner already reached into the conformance workspace for testkit-spec. - Move testkit, testkit-macros and testkit-spec to tests/testkit, a separate workspace like tests/runner and tests/tmachine. Suites and the runner depend on it by path. - Enroll tests/testkit in the lockfile alignment check and seed its lockfile from the root lockfile. - Make the wording family-neutral and name sandboxes test-- instead of ct--. - Describe testkit and its location in TESTING.md. Signed-off-by: Evan Lezar --- TESTING.md | 5 +- .../scripts/check_cargo_lockfile_alignment.py | 2 +- tests/runner/Cargo.toml | 2 +- tests/suites/conformance/Cargo.toml | 2 +- .../conformance/sandbox-lifecycle/Cargo.toml | 2 +- tests/testkit/Cargo.lock | 2895 +++++++++++++++++ tests/testkit/Cargo.toml | 8 + .../testkit-macros/Cargo.toml | 2 +- .../testkit-macros/src/lib.rs | 2 +- .../testkit-spec/Cargo.toml | 0 .../testkit-spec/src/lib.rs | 8 +- .../testkit/Cargo.toml | 4 +- .../testkit/src/context.rs | 4 +- .../testkit/src/error.rs | 0 .../testkit/src/lib.rs | 8 +- .../testkit/src/poll.rs | 0 .../testkit/src/run.rs | 0 .../testkit/src/target.rs | 0 18 files changed, 2925 insertions(+), 19 deletions(-) create mode 100644 tests/testkit/Cargo.lock create mode 100644 tests/testkit/Cargo.toml rename tests/{suites/conformance => testkit}/testkit-macros/Cargo.toml (86%) rename tests/{suites/conformance => testkit}/testkit-macros/src/lib.rs (98%) rename tests/{suites/conformance => testkit}/testkit-spec/Cargo.toml (100%) rename tests/{suites/conformance => testkit}/testkit-spec/src/lib.rs (96%) rename tests/{suites/conformance => testkit}/testkit/Cargo.toml (85%) rename tests/{suites/conformance => testkit}/testkit/src/context.rs (98%) rename tests/{suites/conformance => testkit}/testkit/src/error.rs (100%) rename tests/{suites/conformance => testkit}/testkit/src/lib.rs (81%) rename tests/{suites/conformance => testkit}/testkit/src/poll.rs (100%) rename tests/{suites/conformance => testkit}/testkit/src/run.rs (100%) rename tests/{suites/conformance => testkit}/testkit/src/target.rs (100%) diff --git a/TESTING.md b/TESTING.md index a08a100ddf..0a8d308bea 100644 --- a/TESTING.md +++ b/TESTING.md @@ -429,7 +429,10 @@ architectures as unsupported instead of failing. Conformance suites (`tests/suites/conformance/`) are black-box tests that drive the gateway through the Rust SDK. Each suite is one test binary of -`#[testkit::test]` functions: +`#[testkit::test]` functions. `testkit` (`tests/testkit/`) is the support +library shared by every family: it reads the target, connects to the gateway, +creates and cleans up sandboxes, and provides the attribute. The runner parses +tests with the same `testkit-spec` crate as the attribute. ```rust /// A long-running sandbox becomes ready, runs a command with exec, and is gone diff --git a/tasks/scripts/check_cargo_lockfile_alignment.py b/tasks/scripts/check_cargo_lockfile_alignment.py index 218b20446c..a900cc345f 100644 --- a/tasks/scripts/check_cargo_lockfile_alignment.py +++ b/tasks/scripts/check_cargo_lockfile_alignment.py @@ -35,7 +35,7 @@ # Workspaces whose shared crates must match the root lockfile. Add a # workspace here once its lockfile has been aligned with `--fix`. -ALIGNED_WORKSPACES = ("tests/suites/conformance",) +ALIGNED_WORKSPACES = ("tests/suites/conformance", "tests/testkit") @dataclass(frozen=True) diff --git a/tests/runner/Cargo.toml b/tests/runner/Cargo.toml index 6dd5f27094..a3df622b2b 100644 --- a/tests/runner/Cargo.toml +++ b/tests/runner/Cargo.toml @@ -18,7 +18,7 @@ path = "src/main.rs" [dependencies] anyhow = "1" clap = { version = "4", features = ["derive"] } -openshell-testkit-spec = { path = "../suites/conformance/testkit-spec" } +openshell-testkit-spec = { path = "../testkit/testkit-spec" } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yml = { package = "noyalib", version = "0.0.53", default-features = false, features = ["std", "compat-serde-yaml"] } diff --git a/tests/suites/conformance/Cargo.toml b/tests/suites/conformance/Cargo.toml index a3f32e8884..66e085a8d4 100644 --- a/tests/suites/conformance/Cargo.toml +++ b/tests/suites/conformance/Cargo.toml @@ -3,4 +3,4 @@ [workspace] resolver = "2" -members = ["cli", "sandbox-lifecycle", "testkit", "testkit-macros", "testkit-spec"] +members = ["cli", "sandbox-lifecycle"] diff --git a/tests/suites/conformance/sandbox-lifecycle/Cargo.toml b/tests/suites/conformance/sandbox-lifecycle/Cargo.toml index cf0ed9b097..28ea01f225 100644 --- a/tests/suites/conformance/sandbox-lifecycle/Cargo.toml +++ b/tests/suites/conformance/sandbox-lifecycle/Cargo.toml @@ -12,4 +12,4 @@ repository = "https://github.com/NVIDIA/OpenShell" publish = false [dependencies] -openshell-testkit = { path = "../testkit" } +openshell-testkit = { path = "../../../testkit/testkit" } diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock new file mode 100644 index 0000000000..821ef20857 --- /dev/null +++ b/tests/testkit/Cargo.lock @@ -0,0 +1,2895 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "noyalib" +version = "0.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" +dependencies = [ + "hashbrown 0.17.0", + "indexmap", + "libm", + "memchr", + "rustc-hash", + "serde_core", + "smallvec", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64", + "chrono", + "glob", + "ipnet", + "miette", + "nix", + "openshell-extension-core", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "reqwest", + "rustix", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tonic-types", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "openshell-extension-core" +version = "0.0.0" +dependencies = [ + "hyper-util", + "serde", + "thiserror 2.0.20", + "tokio", + "tonic", + "tower", +] + +[[package]] +name = "openshell-policy-schema" +version = "0.0.0" +dependencies = [ + "miette", + "noyalib", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror 2.0.20", +] + +[[package]] +name = "openshell-sdk" +version = "0.0.0" +dependencies = [ + "async-stream", + "async-trait", + "futures", + "hyper", + "hyper-util", + "miette", + "oauth2", + "openshell-core", + "reqwest", + "rustls", + "serde", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "tonic", + "tower", + "tracing", +] + +[[package]] +name = "openshell-testkit" +version = "0.0.0" +dependencies = [ + "futures", + "noyalib", + "openshell-sdk", + "openshell-testkit-macros", + "rand 0.9.4", + "serde", + "tokio", + "tonic", +] + +[[package]] +name = "openshell-testkit-macros" +version = "0.0.0" +dependencies = [ + "openshell-testkit-spec", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "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-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "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.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[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 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.117", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/testkit/Cargo.toml b/tests/testkit/Cargo.toml new file mode 100644 index 0000000000..5eff75f9ea --- /dev/null +++ b/tests/testkit/Cargo.toml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Shared support for the test families under tests/suites/. Families depend +# on these crates by path; the runner uses testkit-spec to build its catalog. +[workspace] +resolver = "2" +members = ["testkit", "testkit-macros", "testkit-spec"] diff --git a/tests/suites/conformance/testkit-macros/Cargo.toml b/tests/testkit/testkit-macros/Cargo.toml similarity index 86% rename from tests/suites/conformance/testkit-macros/Cargo.toml rename to tests/testkit/testkit-macros/Cargo.toml index eea132ec5e..1a6c1d839a 100644 --- a/tests/suites/conformance/testkit-macros/Cargo.toml +++ b/tests/testkit/testkit-macros/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "openshell-testkit-macros" -description = "The #[testkit::test] attribute for OpenShell conformance tests" +description = "The #[testkit::test] attribute for OpenShell end-to-end test suites" version = "0.0.0" edition = "2024" rust-version = "1.94" diff --git a/tests/suites/conformance/testkit-macros/src/lib.rs b/tests/testkit/testkit-macros/src/lib.rs similarity index 98% rename from tests/suites/conformance/testkit-macros/src/lib.rs rename to tests/testkit/testkit-macros/src/lib.rs index 7a2cabd68c..6789f75ccf 100644 --- a/tests/suites/conformance/testkit-macros/src/lib.rs +++ b/tests/testkit/testkit-macros/src/lib.rs @@ -3,7 +3,7 @@ //! The `#[testkit::test]` attribute. //! -//! A conformance test is an `async fn` taking `&mut testkit::Context` and +//! A test is an `async fn` taking `&mut testkit::Context` and //! returning `testkit::Result`. Its doc comment is the test's description and //! is required. The function name is the test ID. Requirements are named //! after target capabilities: diff --git a/tests/suites/conformance/testkit-spec/Cargo.toml b/tests/testkit/testkit-spec/Cargo.toml similarity index 100% rename from tests/suites/conformance/testkit-spec/Cargo.toml rename to tests/testkit/testkit-spec/Cargo.toml diff --git a/tests/suites/conformance/testkit-spec/src/lib.rs b/tests/testkit/testkit-spec/src/lib.rs similarity index 96% rename from tests/suites/conformance/testkit-spec/src/lib.rs rename to tests/testkit/testkit-spec/src/lib.rs index e6108f45be..759ebe9abf 100644 --- a/tests/suites/conformance/testkit-spec/src/lib.rs +++ b/tests/testkit/testkit-spec/src/lib.rs @@ -60,13 +60,13 @@ pub fn parse(args: TokenStream, item: &ItemFn) -> syn::Result { if item.sig.asyncness.is_none() { return Err(syn::Error::new_spanned( item.sig.fn_token, - "conformance tests must be `async fn`", + "testkit tests must be `async fn`", )); } if item.sig.inputs.len() != 1 { return Err(syn::Error::new_spanned( &item.sig.inputs, - "conformance tests take exactly one argument: `cx: &mut testkit::Context`", + "testkit tests take exactly one argument: `cx: &mut testkit::Context`", )); } @@ -74,7 +74,7 @@ pub fn parse(args: TokenStream, item: &ItemFn) -> syn::Result { if description.is_empty() { return Err(syn::Error::new( item.sig.ident.span(), - "conformance tests need a doc comment describing the behavior they verify", + "testkit tests need a doc comment describing the behavior they verify", )); } @@ -207,7 +207,7 @@ mod tests { async fn exposed(cx: &mut Context) -> testkit::Result { Ok(()) } #[test] - fn not_a_conformance_test() {} + fn not_a_testkit_test() {} async fn helper(cx: &mut Context) {} ", diff --git a/tests/suites/conformance/testkit/Cargo.toml b/tests/testkit/testkit/Cargo.toml similarity index 85% rename from tests/suites/conformance/testkit/Cargo.toml rename to tests/testkit/testkit/Cargo.toml index b92cd65c15..465a49057f 100644 --- a/tests/suites/conformance/testkit/Cargo.toml +++ b/tests/testkit/testkit/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "openshell-testkit" -description = "Shared support for OpenShell conformance tests" +description = "Shared support for OpenShell end-to-end test suites" version = "0.0.0" edition = "2024" rust-version = "1.94" @@ -15,7 +15,7 @@ publish = false name = "testkit" [dependencies] -openshell-sdk = { path = "../../../../crates/openshell-sdk" } +openshell-sdk = { path = "../../../crates/openshell-sdk" } openshell-testkit-macros = { path = "../testkit-macros" } futures = "0.3" rand = "0.9" diff --git a/tests/suites/conformance/testkit/src/context.rs b/tests/testkit/testkit/src/context.rs similarity index 98% rename from tests/suites/conformance/testkit/src/context.rs rename to tests/testkit/testkit/src/context.rs index dc9a74fa3e..86f66d24d0 100644 --- a/tests/suites/conformance/testkit/src/context.rs +++ b/tests/testkit/testkit/src/context.rs @@ -50,9 +50,9 @@ impl Context { &self.client } - /// A resource name unique to this run: `ct--`. + /// A resource name unique to this run: `test--`. pub fn name(&self, suffix: &str) -> String { - format!("ct-{}-{suffix}", self.run_id) + format!("test-{}-{suffix}", self.run_id) } /// Start describing a sandbox named `cx.name(suffix)`. diff --git a/tests/suites/conformance/testkit/src/error.rs b/tests/testkit/testkit/src/error.rs similarity index 100% rename from tests/suites/conformance/testkit/src/error.rs rename to tests/testkit/testkit/src/error.rs diff --git a/tests/suites/conformance/testkit/src/lib.rs b/tests/testkit/testkit/src/lib.rs similarity index 81% rename from tests/suites/conformance/testkit/src/lib.rs rename to tests/testkit/testkit/src/lib.rs index 1618683e37..d6a92c03d2 100644 --- a/tests/suites/conformance/testkit/src/lib.rs +++ b/tests/testkit/testkit/src/lib.rs @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Shared support for `OpenShell` conformance tests. +//! Shared support for `OpenShell` end-to-end test suites. //! -//! Conformance tests are black-box tests of a running `OpenShell` system, -//! driven through the Rust SDK. Each test is an `async fn` marked -//! `#[testkit::test]`: +//! The suites under `tests/suites/` (conformance and the other families) test +//! a running `OpenShell` system through the Rust SDK. Each test is an +//! `async fn` marked `#[testkit::test]`: //! //! ```ignore //! use testkit::Context; diff --git a/tests/suites/conformance/testkit/src/poll.rs b/tests/testkit/testkit/src/poll.rs similarity index 100% rename from tests/suites/conformance/testkit/src/poll.rs rename to tests/testkit/testkit/src/poll.rs diff --git a/tests/suites/conformance/testkit/src/run.rs b/tests/testkit/testkit/src/run.rs similarity index 100% rename from tests/suites/conformance/testkit/src/run.rs rename to tests/testkit/testkit/src/run.rs diff --git a/tests/suites/conformance/testkit/src/target.rs b/tests/testkit/testkit/src/target.rs similarity index 100% rename from tests/suites/conformance/testkit/src/target.rs rename to tests/testkit/testkit/src/target.rs From 6919ab4ac04be1e8b2793d0dd69cd303bbb6d74b Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 09:12:48 +0200 Subject: [PATCH 07/18] fix(tests): wait for the gateway before running an environment's tests A tmachine session booted from a cached install layer restarts the gateway, and the runner started nextest as soon as SSH answered. The first test then failed to connect before the gateway listener was bound. - Add Target::wait_ready to testkit, which retries a health check until the gateway reports healthy, and a testkit-ready binary that runs it. - Build testkit-ready for the guest, ship it in each bundle, and run it once per environment before nextest. A gateway that never becomes healthy is an environment error, not a test failure. - Include error sources in testkit errors, so a connect failure says why instead of only "transport error". Signed-off-by: Evan Lezar --- TESTING.md | 5 ++ tests/runner/src/bundle.rs | 82 ++++++++++++++++++- tests/runner/src/execute.rs | 22 ++++- tests/runner/src/main.rs | 6 +- .../testkit/testkit/src/bin/testkit-ready.rs | 42 ++++++++++ tests/testkit/testkit/src/error.rs | 50 ++++++++++- tests/testkit/testkit/src/target.rs | 66 +++++++++++++-- 7 files changed, 261 insertions(+), 12 deletions(-) create mode 100644 tests/testkit/testkit/src/bin/testkit-ready.rs diff --git a/TESTING.md b/TESTING.md index 0a8d308bea..40e62a8920 100644 --- a/TESTING.md +++ b/TESTING.md @@ -423,6 +423,11 @@ building or booting anything, `--env` or `--suite` to narrow a plan, and reports and gateway logs for failures, go to `artifacts/test-results////`. +Before running an environment's suites, the runner waits up to 60 seconds for +its gateway to report healthy (`testkit-ready`, shipped in each bundle). A +gateway that never becomes healthy is reported as an environment error, not as +test failures. + Environments are defined in `tests/environments/*.yaml`. tmachine boots guests of the host's architecture only, so the runner reports environments for other architectures as unsupported instead of failing. diff --git a/tests/runner/src/bundle.rs b/tests/runner/src/bundle.rs index ccb5ae600c..c7d8b3b4a3 100644 --- a/tests/runner/src/bundle.rs +++ b/tests/runner/src/bundle.rs @@ -4,8 +4,9 @@ //! Building a family's test bundle for the guest. //! //! A bundle is a tar holding a nextest archive built for the guest target, -//! the family workspace's `Cargo.toml` files (for `--workspace-remap`), and a -//! `nextest` config with the runner's `JUnit` profile. The guest needs only the +//! the family workspace's `Cargo.toml` files (for `--workspace-remap`), a +//! `nextest` config with the runner's `JUnit` profile, and `testkit-ready`, +//! which waits for the gateway before the tests run. The guest needs only the //! `cargo-nextest` binary to run it. use std::collections::BTreeSet; @@ -22,6 +23,9 @@ use crate::environments::Arch; /// The nextest profile the runner uses in the guest. pub const PROFILE: &str = "openshell"; +/// The readiness check every bundle carries, at the bundle root. +pub const READY_BINARY: &str = "testkit-ready"; + const NEXTEST_CONFIG: &str = r#"# Written by the OpenShell test runner. [profile.openshell] fail-fast = false @@ -44,13 +48,65 @@ pub const fn guest_target(arch: Arch) -> &'static str { } } +/// Build `testkit-ready` for the guest and return its path. +pub fn build_ready(root: &Path, target: &str) -> Result { + let output = Command::new("cargo") + .args(["build", "--locked", "--manifest-path"]) + .arg(root.join("tests/testkit/Cargo.toml")) + .args(["-p", "openshell-testkit", "--bin", READY_BINARY]) + .args([ + "--target", + target, + "--message-format", + "json-render-diagnostics", + ]) + .stderr(std::process::Stdio::inherit()) + .output() + .context("run cargo build")?; + ensure!( + output.status.success(), + "build {READY_BINARY} exited with {}", + output.status + ); + built_executable(&output.stdout, READY_BINARY) + .with_context(|| format!("cargo build did not report a {READY_BINARY} executable")) +} + +/// The executable cargo reports building for the binary target `name`. +fn built_executable(messages: &[u8], name: &str) -> Option { + #[derive(Deserialize)] + struct Message { + reason: String, + target: Option, + executable: Option, + } + #[derive(Deserialize)] + struct Target { + name: String, + } + + serde_json::Deserializer::from_slice(messages) + .into_iter::() + .filter_map(std::result::Result::ok) + .filter(|message| message.reason == "compiler-artifact") + .filter(|message| { + message + .target + .as_ref() + .is_some_and(|target| target.name == name) + }) + .find_map(|message| message.executable) +} + /// Build the bundle for `packages`, and check that the archive contains a -/// binary for every suite in `binaries`. +/// binary for every suite in `binaries`. `ready` is the guest's +/// `testkit-ready`. pub fn build( family: &Family, packages: &BTreeSet, binaries: &BTreeSet, target: &str, + ready: &Path, ) -> Result { let dir = tempfile::tempdir().context("create bundle directory")?; let stage = dir.path().join("stage"); @@ -79,6 +135,8 @@ pub fn build( copy_manifests(&family.workspace, &stage)?; std::fs::write(stage.join("nextest.toml"), NEXTEST_CONFIG)?; + std::fs::copy(ready, stage.join(READY_BINARY)) + .with_context(|| format!("copy {}", ready.display()))?; let path = dir.path().join(format!("{}-tests.tar", family.name)); run(Command::new("tar") @@ -161,3 +219,21 @@ fn run(command: &mut Command) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn built_executable_finds_the_named_binary() { + let messages = br#"{"reason":"compiler-artifact","target":{"name":"testkit"},"executable":null} +{"reason":"compiler-artifact","target":{"name":"testkit-ready"},"executable":"/t/debug/testkit-ready"} +{"reason":"build-finished","success":true} +"#; + assert_eq!( + built_executable(messages, "testkit-ready"), + Some(PathBuf::from("/t/debug/testkit-ready")) + ); + assert_eq!(built_executable(messages, "other"), None); + } +} diff --git a/tests/runner/src/execute.rs b/tests/runner/src/execute.rs index d6d4f846a9..102608d28f 100644 --- a/tests/runner/src/execute.rs +++ b/tests/runner/src/execute.rs @@ -12,13 +12,15 @@ use anyhow::{Context, Result, bail, ensure}; use tmachine::Session; use tmachine::config::Config; -use crate::bundle::{Bundle, PROFILE}; +use crate::bundle::{Bundle, PROFILE, READY_BINARY}; use crate::jobs::{FamilyJob, Job}; use crate::report::{Outcome, Status, junit_counts}; /// Where the installer describes the gateway in the guest. const GATEWAY_SECTION: &str = "/etc/openshell-test/gateway.yaml"; const GUEST_ROOT: &str = "/var/tmp/openshell-tests"; +/// How long the gateway has to become healthy after the environment is up. +const READY_TIMEOUT_SECS: u64 = 60; pub struct Options<'a> { pub tmachine: &'a Config, @@ -189,11 +191,27 @@ async fn run_family( .await?, )?; + // A booted guest restarts its gateway, so wait for it once here rather + // than in every test. Failing here is an environment error, not a test + // failure. + let target_env = format!("OPENSHELL_TEST_TARGET={guest}/target.yaml"); + checked( + "wait for the gateway", + session + .exec(&[ + "env", + &target_env, + &format!("{guest}/{READY_BINARY}"), + &READY_TIMEOUT_SECS.to_string(), + ]) + .await?, + )?; + let filterset = family.filterset(); let output = session .exec(&[ "env", - &format!("OPENSHELL_TEST_TARGET={guest}/target.yaml"), + &target_env, "cargo-nextest", "nextest", "run", diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index b0e0592301..910dfbc9cc 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -114,7 +114,7 @@ async fn run(cli: Cli) -> Result { serde_json::to_string_pretty(&catalog)?, )?; - let bundles = build_bundles(&jobs, &catalog, host)?; + let bundles = build_bundles(&root, &jobs, &catalog, host)?; let tmachine = load_tmachine(&root)?; let options = Options { tmachine: &tmachine, @@ -165,6 +165,7 @@ fn print_jobs(jobs: &[Job]) { /// Build one bundle per family, covering every suite that will run. fn build_bundles( + root: &Path, jobs: &[Job], catalog: &Catalog, host: Option, @@ -186,6 +187,8 @@ fn build_bundles( return Ok(bundles); } let target = bundle::guest_target(host.context("the host architecture is not supported")?); + eprintln!("==> building {} for {target}", bundle::READY_BINARY); + let ready = bundle::build_ready(root, target)?; for (family_name, suite_names) in suites { let family = catalog.family(family_name)?; let binaries = suite_names @@ -198,6 +201,7 @@ fn build_bundles( &family.packages(suite_names.iter().copied()), &binaries, target, + &ready, )?; bundles.insert(family_name.to_string(), bundle); } diff --git a/tests/testkit/testkit/src/bin/testkit-ready.rs b/tests/testkit/testkit/src/bin/testkit-ready.rs new file mode 100644 index 0000000000..a7c4b94a16 --- /dev/null +++ b/tests/testkit/testkit/src/bin/testkit-ready.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Wait until the target's gateway is ready for tests. +//! +//! Usage: `testkit-ready [TIMEOUT_SECS]` (default 60), with the target file +//! named by `OPENSHELL_TEST_TARGET`. Run it once per environment before the +//! test binaries; exits non-zero with the last error if the gateway is not +//! healthy in time. + +use std::process::ExitCode; +use std::time::Duration; + +use testkit::Target; + +const DEFAULT_TIMEOUT_SECS: u64 = 60; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let timeout = match std::env::args().nth(1).map(|arg| arg.parse::()) { + None => DEFAULT_TIMEOUT_SECS, + Some(Ok(secs)) => secs, + Some(Err(error)) => { + eprintln!("testkit-ready: invalid timeout: {error}"); + return ExitCode::from(2); + } + }; + let result = match Target::from_env() { + Ok(target) => target.wait_ready(Duration::from_secs(timeout)).await, + Err(error) => Err(error), + }; + match result { + Ok(health) => { + eprintln!("testkit-ready: gateway {} is healthy", health.version); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("testkit-ready: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/tests/testkit/testkit/src/error.rs b/tests/testkit/testkit/src/error.rs index 75f00e5fda..dad7fe35ad 100644 --- a/tests/testkit/testkit/src/error.rs +++ b/tests/testkit/testkit/src/error.rs @@ -27,6 +27,22 @@ impl Error { Self::msg(error.to_string()) } + /// An error with its sources, so a wrapper such as tonic's + /// "transport error" still says what went wrong underneath. + pub fn from_error(error: &(dyn std::error::Error + 'static)) -> Self { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + let cause_message = cause.to_string(); + // Some errors repeat their source's message in their own. + if !message.ends_with(&cause_message) { + message = format!("{message}: {cause_message}"); + } + source = cause.source(); + } + Self::msg(message) + } + #[must_use] pub fn context(self, context: impl fmt::Display) -> Self { Self::msg(format!("{context}: {}", self.message)) @@ -43,7 +59,7 @@ impl std::error::Error for Error {} impl From for Error { fn from(error: SdkError) -> Self { - Self::from_display(error) + Self::from_error(&error) } } @@ -88,6 +104,38 @@ mod tests { assert_eq!(err.to_string(), "test: step two: boom"); } + #[derive(Debug)] + struct Wrapper(Option>, &'static str); + + impl fmt::Display for Wrapper { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.1) + } + } + + impl std::error::Error for Wrapper { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.0.as_deref() + } + } + + #[test] + fn from_error_includes_sources() { + let refused = Wrapper(None, "connection refused"); + let transport = Wrapper(Some(Box::new(refused)), "transport error"); + assert_eq!( + Error::from_error(&transport).to_string(), + "transport error: connection refused" + ); + } + + #[test] + fn from_error_skips_repeated_source_messages() { + let inner = Wrapper(None, "refused"); + let outer = Wrapper(Some(Box::new(inner)), "connect: refused"); + assert_eq!(Error::from_error(&outer).to_string(), "connect: refused"); + } + #[test] fn ensure_returns_error_with_message() { fn check(value: i32) -> Result { diff --git a/tests/testkit/testkit/src/target.rs b/tests/testkit/testkit/src/target.rs index dc95090524..12c82ebb87 100644 --- a/tests/testkit/testkit/src/target.rs +++ b/tests/testkit/testkit/src/target.rs @@ -20,9 +20,11 @@ //! ``` use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::time::{Duration, Instant}; -use openshell_sdk::{AuthConfig, ClientConfig, EdgeAuthInterceptor, OpenShellClient}; +use openshell_sdk::{ + AuthConfig, ClientConfig, EdgeAuthInterceptor, Health, OpenShellClient, ServiceStatus, +}; use serde::Deserialize; use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity}; @@ -118,6 +120,7 @@ fn default_workspace() -> String { // Mirrors the SDK's endpoint settings for channels testkit builds itself. const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10); +const READY_INTERVAL: Duration = Duration::from_secs(1); impl Target { /// Load the target named by [`TARGET_ENV`]. @@ -219,20 +222,54 @@ impl Target { |ca| ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca)), ); let channel = Endpoint::from_shared(gateway.endpoint.clone()) - .map_err(Error::from_display)? + .map_err(|error| Error::from_error(&error))? .connect_timeout(CONNECT_TIMEOUT) .http2_adaptive_window(true) .http2_keep_alive_interval(KEEP_ALIVE_INTERVAL) .keep_alive_while_idle(true) .tls_config(tls_config.identity(identity)) - .map_err(Error::from_display)? + .map_err(|error| Error::from_error(&error)) + .context("configure mTLS")? .connect() .await - .map_err(Error::from_display) + .map_err(|error| Error::from_error(&error)) .context(format!("connect to {} with mTLS", gateway.endpoint))?; let interceptor = EdgeAuthInterceptor::new(token.as_deref(), None)?; Ok(OpenShellClient::from_parts(channel, interceptor)) } + + /// Wait until the gateway accepts a connection and reports itself + /// healthy, retrying until `timeout`. + /// + /// This is a readiness check for whoever brings the environment up, run + /// once before its tests. Tests themselves connect once and fail fast. + pub async fn wait_ready(&self, timeout: Duration) -> Result { + let started = Instant::now(); + loop { + let error = match self.check_health().await { + Ok(health) => return Ok(health), + Err(error) => error, + }; + if started.elapsed() >= timeout { + return Err(error.context(format!( + "gateway at {} not ready after {}s", + self.gateway.endpoint, + timeout.as_secs() + ))); + } + tokio::time::sleep(READY_INTERVAL).await; + } + } + + async fn check_health(&self) -> Result { + let health = self.connect().await?.health().await.context("health")?; + crate::ensure!( + health.status == ServiceStatus::Healthy, + "gateway reports {:?}", + health.status + ); + Ok(health) + } } #[cfg(test)] @@ -288,4 +325,23 @@ mod tests { let err = parse("gateway:\n endpoint: http://x\n tls:\n ca: ca.crt\n").unwrap_err(); assert!(err.to_string().contains("not https"), "{err}"); } + + #[tokio::test] + async fn wait_ready_reports_the_last_error_after_timeout() { + // Nothing listens on a port bound and then released. + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let target = parse(&format!("gateway:\n endpoint: http://127.0.0.1:{port}\n")).unwrap(); + let err = target.wait_ready(Duration::ZERO).await.unwrap_err(); + let message = err.to_string(); + assert!( + message.starts_with(&format!( + "gateway at http://127.0.0.1:{port} not ready after 0s: " + )), + "{message}" + ); + } } From cf72f11083cd5f8cf52144ad94aa2979ea7bab1c Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 10:02:12 +0200 Subject: [PATCH 08/18] ci(tests): run the e2e suites and test infrastructure checks in CI - Add e2e-suites.yml, a reusable matrix job that runs the e2e suites a plan in tests/plans/ selects, on each runner, and uploads the JUnit reports and diagnostics. Branch E2E runs it for cpu-tests on ubuntu-24.04 with the other integration jobs. It is advisory and not part of core-e2e-result until it has a passing history on amd64. - Format, lint and test tests/testkit, tests/runner and tests/tmachine in Branch Checks and the matching mise tasks. Nothing ran their unit tests before. - Describe the job in CI.md. Signed-off-by: Evan Lezar --- .github/workflows/branch-checks.yml | 9 +++ .github/workflows/branch-e2e.yml | 18 ++++++ .github/workflows/e2e-suites.yml | 91 +++++++++++++++++++++++++++++ CI.md | 8 +++ tasks/rust.toml | 9 +++ tasks/test.toml | 3 + 6 files changed, 138 insertions(+) create mode 100644 .github/workflows/e2e-suites.yml diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index fc782509fc..d72d17b8f4 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -220,6 +220,9 @@ jobs: cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/runner/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/tmachine/Cargo.toml --all -- --check cargo-lockfiles: name: Cargo lockfiles @@ -279,6 +282,9 @@ jobs: cargo clippy --locked --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path examples/governance-interceptor/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/runner/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/tmachine/Cargo.toml --all-targets -- -D warnings rust-test: name: Rust test (${{ matrix.system }}) @@ -316,6 +322,9 @@ jobs: run: | cargo nextest run --locked --profile ci --workspace --features openshell-server/test-support cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path tests/testkit/Cargo.toml + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path tests/runner/Cargo.toml + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path tests/tmachine/Cargo.toml rust-postgres-test: name: Rust PostgreSQL tests diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index db4c96b936..15258ea1eb 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -303,6 +303,24 @@ jobs: {"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"driver-podman"} ] + # Run the SDK-driven e2e suites that each plan in tests/plans/ selects, on + # each runner. Advisory until it has a passing history on amd64, so it is + # not part of core-e2e-result. + e2e-suites: + needs: prepare-integration + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-suites.yml + with: + source-sha: ${{ needs.prepare-integration.outputs.source_sha }} + integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} + matrix: >- + [ + {"plan":"cpu-tests","runner":"ubuntu-24.04"} + ] + docker-e2e: needs: [pr_metadata, build-binaries, build-images] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' diff --git a/.github/workflows/e2e-suites.yml b/.github/workflows/e2e-suites.yml new file mode 100644 index 0000000000..abaa7281e2 --- /dev/null +++ b/.github/workflows/e2e-suites.yml @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: E2E Suites + +on: + workflow_call: + inputs: + source-sha: + description: Source revision of the prepared candidate artifacts + required: true + type: string + integration-inputs-artifact-id: + description: Immutable ID of the prepared integration inputs artifact + required: true + type: string + matrix: + description: >- + JSON array of {"plan", "runner"} entries. A plan is a file stem in + tests/plans/ that selects the suites and environments to run. + required: true + type: string + +permissions: + actions: read + contents: read + packages: read + +# github.workflow is the caller's name here, so scope the group to this job +# to avoid colliding with (and cancelling) the calling workflow. +concurrency: + group: ${{ github.workflow }}-e2e-suites-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-suites: + name: E2E suites (${{ matrix.plan }}, ${{ matrix.runner }}) + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.matrix) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs['source-sha'] }} + persist-credentials: false + + - name: Enable KVM access + run: | + set -euo pipefail + if [[ ! -c /dev/kvm ]]; then + echo "::error::The runner did not expose /dev/kvm" + exit 1 + fi + sudo chmod 0666 /dev/kvm + exec 3<>/dev/kvm + exec 3>&- + + - uses: ./.github/actions/setup-nix + + - name: Cache tmachine disks + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/.cache/tmachine + key: tmachine-${{ runner.os }}-${{ runner.arch }} + + - name: Download integration inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs['integration-inputs-artifact-id'] }} + path: artifacts + + - name: Run suites + env: + PLAN: ${{ matrix.plan }} + run: | + # Retry dependency preparation, then run the plan once. The runner + # builds the suites for the guest, so it needs no prepared archives. + nix build --no-link .#test-runner || nix build --no-link .#test-runner + nix run .#test-runner -- test --plan "tests/plans/${PLAN}.yaml" --results artifacts/test-results + + - name: Upload results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: e2e-results-${{ matrix.plan }}-${{ matrix.runner }} + path: artifacts/test-results + if-no-files-found: ignore + retention-days: 7 diff --git a/CI.md b/CI.md index b886bd70e3..b459b322ef 100644 --- a/CI.md +++ b/CI.md @@ -156,6 +156,13 @@ retain the binary installer because their fixtures configure its system service, local HTTP gateway, and CLI path. The manual Integration Tests workflow defaults to the package installers and downloads the packages selected by its matrix. +Core integration qualification also runs the SDK-driven e2e suites +(`e2e-suites.yml`) on the same prepared inputs. Each matrix entry pairs a plan +from `tests/plans/`, which selects the suites and environments, with a runner. +The test runner builds the suites for the guest itself, and each entry uploads +JUnit reports and failure diagnostics as `e2e-results--`. The job +is advisory and is not part of the `OpenShell / E2E` status yet. + Three opt-in labels enable the long-running E2E suites: - `test:e2e` runs the Docker, rootless Podman, Kubernetes, and VM E2E suites @@ -554,6 +561,7 @@ The bot's full administrator documentation is internal to NVIDIA. The only comma |---|---| | `.github/workflows/branch-checks.yml` | Required non-E2E checks. Triggers on `push: pull-request/[0-9]+` for PR mirrors and `merge_group` for queued merges. | | `.github/workflows/branch-e2e.yml` | Standard, GPU, Kubernetes HA, and Kubernetes credential-driver E2E. PR mirror pushes use `test:e2e`, `test:e2e-gpu`, and `test:e2e-kubernetes` labels; merge groups do not run E2E. | +| `.github/workflows/e2e-suites.yml` | Reusable matrix job that runs the e2e suites selected by plans in `tests/plans/` on the given runners and uploads `e2e-results--`. | | `.github/workflows/build-binaries.yml`, `build-vm-driver.yml` | Shared binary matrices used by branch and release workflows. The VM driver remains separate because its build consumes the runtime binaries. | | `.github/workflows/build-images.yml` | Builds and pushes multi-platform images, then uploads the same OCI images as workflow artifacts. | | `.github/workflows/package-release-binaries.yml` | Packages raw build artifacts into release tarballs without rebuilding them. | diff --git a/tasks/rust.toml b/tasks/rust.toml index 7e5882dcf5..d7b4675265 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -35,6 +35,9 @@ run = [ "cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path examples/governance-interceptor/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/runner/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/tmachine/Cargo.toml --all-targets -- -D warnings", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -46,6 +49,9 @@ run = [ "cargo fmt --manifest-path e2e/rust/Cargo.toml --all", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all", "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all", + "cargo fmt --manifest-path tests/testkit/Cargo.toml --all", + "cargo fmt --manifest-path tests/runner/Cargo.toml --all", + "cargo fmt --manifest-path tests/tmachine/Cargo.toml --all", ] hide = true @@ -56,6 +62,9 @@ run = [ "cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/runner/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/tmachine/Cargo.toml --all -- --check", ] hide = true diff --git a/tasks/test.toml b/tasks/test.toml index bd46029fe6..fc31db272b 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -122,6 +122,9 @@ run = [ "cargo test --workspace --exclude openshell-server", "cargo test -p openshell-server --features test-support", "cargo nextest run --config-file .config/nextest.toml --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", + "cargo nextest run --config-file .config/nextest.toml --manifest-path tests/testkit/Cargo.toml", + "cargo nextest run --config-file .config/nextest.toml --manifest-path tests/runner/Cargo.toml", + "cargo nextest run --config-file .config/nextest.toml --manifest-path tests/tmachine/Cargo.toml", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" hide = true From e78fa790cd617dc140fa0f08bf3da25034fe8e7e Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 14:20:17 +0200 Subject: [PATCH 09/18] test(conformance): add run_to_completion_exit_delete Cover a sandbox whose main command exits 0 under restart policy Never: it reaches Completed with exit code 0, is not restarted, and is gone from get and list after delete. Assertions use the phase and exit code from the public contract, not condition reasons. Add Sandbox::wait_finished and Sandbox::get to testkit. Signed-off-by: Evan Lezar --- .../tests/sandbox_lifecycle.rs | 44 +++++++++++++++++++ tests/testkit/testkit/src/context.rs | 29 ++++++++++++ 2 files changed, 73 insertions(+) diff --git a/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs b/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs index b635943bbf..dbc2038ea2 100644 --- a/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs +++ b/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs @@ -4,6 +4,7 @@ //! Conformance suite: sandbox-lifecycle. use testkit::Context; +use testkit::sdk::{SandboxPhase, SandboxRestartPolicy}; /// A long-running sandbox becomes ready, runs a command with exec, and is gone /// from get and list after delete. @@ -34,3 +35,46 @@ async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { sandbox.delete().await?; cx.expect_absent(&sandbox).await } + +/// A sandbox whose main command exits 0, with restart policy Never, reaches +/// Completed with exit code 0 and is not restarted. It is gone from get and +/// list after delete. +#[testkit::test] +async fn run_to_completion_exit_delete(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("rtc") + .command(["sh", "-c", "exit 0"]) + .restart_policy(SandboxRestartPolicy::Never) + .create() + .await?; + + let finished = sandbox.wait_finished().await?; + testkit::ensure!( + finished.phase == SandboxPhase::Completed, + "sandbox finished in phase {:?} with exit code {:?}; expected Completed", + finished.phase, + finished.exit_code + ); + testkit::ensure!( + finished.exit_code == Some(0), + "exit code was {:?}; expected Some(0)", + finished.exit_code + ); + testkit::ensure!( + finished.restart_count == 0, + "restart count was {}; expected 0", + finished.restart_count + ); + + // Completed is final under Never: a later read must not show a restart. + let again = sandbox.get().await?; + testkit::ensure!( + again.phase == SandboxPhase::Completed && again.restart_count == 0, + "sandbox left Completed: phase {:?}, restart count {}", + again.phase, + again.restart_count + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} diff --git a/tests/testkit/testkit/src/context.rs b/tests/testkit/testkit/src/context.rs index 86f66d24d0..5e17d34913 100644 --- a/tests/testkit/testkit/src/context.rs +++ b/tests/testkit/testkit/src/context.rs @@ -14,12 +14,14 @@ use openshell_sdk::{ use rand::Rng; use crate::error::{Error, Result, ResultExt}; +use crate::poll::{Poll, poll_until}; /// Sandbox creation includes image pulls on a cold environment. pub const READY_TIMEOUT: Duration = Duration::from_mins(10); pub const DELETE_TIMEOUT: Duration = Duration::from_mins(2); pub const EXEC_TIMEOUT: Duration = Duration::from_mins(2); const CLEANUP_TIMEOUT: Duration = Duration::from_mins(2); +const STATUS_INTERVAL: Duration = Duration::from_secs(1); /// Names of sandboxes created by a test and not yet confirmed deleted. type Registry = Arc>>; @@ -217,6 +219,33 @@ impl Sandbox { Ok(snapshot) } + /// Wait until the main process has finished: the sandbox is Completed or + /// in Error. Returns that snapshot; the test decides which outcome it + /// expects. Includes provisioning, so it uses the ready timeout. + pub async fn wait_finished(&self) -> Result { + poll_until( + &format!("sandbox {} to finish", self.name), + READY_TIMEOUT, + STATUS_INTERVAL, + async || { + let snapshot = self.get().await?; + Ok(match snapshot.phase { + SandboxPhase::Completed | SandboxPhase::Error => Poll::Ready(snapshot), + phase => Poll::Pending(format!("phase {phase:?}")), + }) + }, + ) + .await + } + + /// The sandbox's current state. + pub async fn get(&self) -> Result { + self.client + .get_sandbox(&self.name) + .await + .context(format!("get sandbox {}", self.name)) + } + /// Run a command in the sandbox and buffer its output. pub async fn exec(&self, command: I) -> Result where From 342f261ec8d9a2134f40e02609cf8cf2f2caeee8 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 14:34:52 +0200 Subject: [PATCH 10/18] test(testkit): require a stable random ID for every test Test names change as the contract is better understood, which breaks result history and anything that refers to a test. Give every test a required ID, `t-` plus eight random lowercase letters or digits, that never changes or is reused, including across renames and moves between suites and families. Random IDs avoid allocation conflicts between parallel changes. - testkit-spec parses and validates `id`; a missing or malformed ID is a compile error that suggests a fresh one. - The function name becomes `name` and stays the nextest test name used for selection. - The runner's catalog rejects duplicate IDs across all families and adds each test's ID to its JUnit test case as a `test_id` property. - Assign IDs to the existing sandbox-lifecycle tests and document the rule in TESTING.md. Signed-off-by: Evan Lezar --- TESTING.md | 15 +- tests/runner/src/catalog.rs | 66 ++++++- tests/runner/src/execute.rs | 6 +- tests/runner/src/jobs.rs | 30 +++- tests/runner/src/main.rs | 2 +- tests/runner/src/report.rs | 86 +++++++++ .../tests/sandbox_lifecycle.rs | 4 +- tests/testkit/testkit-macros/src/lib.rs | 28 +-- tests/testkit/testkit-spec/src/lib.rs | 168 +++++++++++++++--- tests/testkit/testkit/src/lib.rs | 2 +- tests/testkit/testkit/src/run.rs | 24 +-- 11 files changed, 367 insertions(+), 64 deletions(-) diff --git a/TESTING.md b/TESTING.md index 40e62a8920..5909fa2799 100644 --- a/TESTING.md +++ b/TESTING.md @@ -442,14 +442,23 @@ tests with the same `testkit-spec` crate as the attribute. ```rust /// A long-running sandbox becomes ready, runs a command with exec, and is gone /// from get and list after delete. -#[testkit::test] +#[testkit::test(id = "t-3tfdx240")] async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { // ... } ``` -The function name is the test ID, and the doc comment is its required -description. Declare capabilities a test needs with +Every test needs a stable `id`: `t-` followed by eight random lowercase letters +or digits. Results, waivers, and coverage records refer to tests by this ID, so +never change or reuse it, including when you rename the test or move it to +another suite or family. Omit `id` when you write a new test; the compile error +suggests a fresh one. IDs must be unique across all families, which the runner +checks, and the runner adds each test's ID to its JUnit test case as the +`test_id` property. + +The function name is the nextest test name and is what `--suite` and nextest +filters select. Rename it when a clearer name helps; the ID keeps the history. +The doc comment is the required description. Declare capabilities a test needs with `#[testkit::test(requires = [service_exposure])]`; the runner excludes the test in environments without them and reports it as unsupported. Tests read the system under test from the target file named by `OPENSHELL_TEST_TARGET`, so you diff --git a/tests/runner/src/catalog.rs b/tests/runner/src/catalog.rs index 104021e4bb..e6d999e4d7 100644 --- a/tests/runner/src/catalog.rs +++ b/tests/runner/src/catalog.rs @@ -8,6 +8,8 @@ //! functions; the suite name is the binary name with `_` replaced by `-`. //! Tests are parsed with `testkit-spec`, the same parser the macro uses, so //! the runner never executes test binaries on the host to learn about them. +//! Test IDs are unique across every family, so a test can move between +//! suites and families without changing its ID. use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; @@ -37,7 +39,10 @@ pub struct Suite { #[derive(Clone, Debug, Serialize)] pub struct Test { + /// The stable test ID. pub id: String, + /// The function name, which is also the nextest test name. + pub name: String, pub description: String, pub requires: Vec, } @@ -56,7 +61,25 @@ impl Catalog { .with_context(|| format!("load {name} test catalog"))?; families.insert((*name).to_string(), family); } - Ok(Self { families }) + let catalog = Self { families }; + catalog.check_unique_ids()?; + Ok(catalog) + } + + /// Fail if two tests anywhere in the catalog share an ID. + fn check_unique_ids(&self) -> Result<()> { + let mut seen: BTreeMap<&str, String> = BTreeMap::new(); + for family in self.families.values() { + for suite in family.suites.values() { + for test in &suite.tests { + let location = format!("{}/{}/{}", family.name, suite.suite, test.name); + if let Some(first) = seen.insert(&test.id, location.clone()) { + bail!("test ID {} is used by both {first} and {location}", test.id); + } + } + } + } + Ok(()) } pub fn family(&self, name: &str) -> Result<&Family> { @@ -168,7 +191,8 @@ fn member_suites(member_dir: &Path) -> Result> { tests: specs .into_iter() .map(|spec| Test { - id: spec.id.to_string(), + id: spec.id, + name: spec.name.to_string(), description: spec.description, requires: spec.requires.iter().map(ToString::to_string).collect(), }) @@ -202,12 +226,48 @@ mod tests { suite .tests .iter() - .any(|test| test.id == "long_running_exec_delete" && !test.description.is_empty()) + .any(|test| test.name == "long_running_exec_delete" + && testkit_spec::is_valid_id(&test.id) + && !test.description.is_empty()) ); // CLI-driven tests in the same workspace are not testkit suites. assert!(family.suites.keys().all(|name| name != "file-transfer")); } + #[test] + fn duplicate_ids_are_rejected_across_families() { + let test = |name: &str| Test { + id: "t-00000001".to_string(), + name: name.to_string(), + description: "d".to_string(), + requires: Vec::new(), + }; + let family = |name: &str, test_name: &str| Family { + name: name.to_string(), + workspace: PathBuf::new(), + suites: BTreeMap::from([( + "s".to_string(), + Suite { + suite: "s".to_string(), + binary: "s".to_string(), + package: "p".to_string(), + tests: vec![test(test_name)], + }, + )]), + }; + let catalog = Catalog { + families: BTreeMap::from([ + ("a".to_string(), family("a", "first")), + ("b".to_string(), family("b", "second")), + ]), + }; + let err = catalog.check_unique_ids().unwrap_err().to_string(); + assert!( + err.contains("a/s/first") && err.contains("b/s/second"), + "{err}" + ); + } + #[test] fn unknown_names_list_known_ones() { let catalog = Catalog::load(&repo_root()).unwrap(); diff --git a/tests/runner/src/execute.rs b/tests/runner/src/execute.rs index 102608d28f..81702d088a 100644 --- a/tests/runner/src/execute.rs +++ b/tests/runner/src/execute.rs @@ -14,7 +14,7 @@ use tmachine::config::Config; use crate::bundle::{Bundle, PROFILE, READY_BINARY}; use crate::jobs::{FamilyJob, Job}; -use crate::report::{Outcome, Status, junit_counts}; +use crate::report::{Outcome, Status, annotate_junit, junit_counts}; /// Where the installer describes the gateway in the guest. const GATEWAY_SECTION: &str = "/etc/openshell-test/gateway.yaml"; @@ -245,7 +245,9 @@ async fn run_family( let counts = match found.lines().next() { Some(path) => { session.copy_out(path, &junit).await?; - junit_counts(&std::fs::read_to_string(&junit)?) + let xml = annotate_junit(&std::fs::read_to_string(&junit)?, &family.test_ids()); + std::fs::write(&junit, &xml)?; + junit_counts(&xml) } None => None, }; diff --git a/tests/runner/src/jobs.rs b/tests/runner/src/jobs.rs index 5cd76d1ba7..bcd1061307 100644 --- a/tests/runner/src/jobs.rs +++ b/tests/runner/src/jobs.rs @@ -36,7 +36,7 @@ pub struct FamilyJob { #[derive(Debug)] pub struct SuiteJob { pub suite: Suite, - /// Test IDs to run. + /// Names of the tests to run. pub run: Vec, /// Tests excluded because the environment lacks a capability they need. pub unsupported: Vec, @@ -44,7 +44,7 @@ pub struct SuiteJob { #[derive(Debug)] pub struct UnsupportedTest { - pub id: String, + pub name: String, pub missing: Vec, } @@ -160,10 +160,10 @@ fn suite_job(suite: &Suite, environment: &Environment) -> SuiteJob { .cloned() .collect(); if missing.is_empty() { - run.push(test.id.clone()); + run.push(test.name.clone()); } else { unsupported.push(UnsupportedTest { - id: test.id.clone(), + name: test.name.clone(), missing, }); } @@ -186,6 +186,20 @@ impl FamilyJob { self.suites.iter().any(|suite| !suite.run.is_empty()) } + /// Stable test IDs keyed by nextest `JUnit` `(classname, name)`. + pub fn test_ids(&self) -> BTreeMap<(String, String), String> { + self.suites + .iter() + .flat_map(|job| { + let classname = format!("{}::{}", job.suite.package, job.suite.binary); + job.suite + .tests + .iter() + .map(move |test| ((classname.clone(), test.name.clone()), test.id.clone())) + }) + .collect() + } + /// The nextest filterset selecting exactly the tests to run. pub fn filterset(&self) -> String { self.suites @@ -224,8 +238,10 @@ mod tests { package: format!("pkg-{name}"), tests: tests .iter() - .map(|(id, requires)| Test { - id: (*id).to_string(), + .enumerate() + .map(|(index, (name, requires))| Test { + id: format!("t-{index:08}"), + name: (*name).to_string(), description: "d".to_string(), requires: requires.iter().map(ToString::to_string).collect(), }) @@ -288,7 +304,7 @@ mod tests { assert_eq!(jobs.len(), 1); let suite = &jobs[0].families[0].suites[0]; assert_eq!(suite.run, ["long_running"]); - assert_eq!(suite.unsupported[0].id, "expose"); + assert_eq!(suite.unsupported[0].name, "expose"); assert_eq!(suite.unsupported[0].missing, ["service_exposure"]); assert_eq!( jobs[0].families[0].filterset(), diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index 910dfbc9cc..dbc3a4df82 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -154,7 +154,7 @@ fn print_jobs(jobs: &[Job]) { eprintln!( " {}: {} unsupported (needs {})", suite.suite.suite, - test.id, + test.name, test.missing.join(", ") ); } diff --git a/tests/runner/src/report.rs b/tests/runner/src/report.rs index 1b6e685259..b7f4350d0b 100644 --- a/tests/runner/src/report.rs +++ b/tests/runner/src/report.rs @@ -3,6 +3,7 @@ //! Job outcomes and the run summary. +use std::collections::BTreeMap; use std::fmt::Write as _; /// The outcome of one family in one environment. @@ -54,6 +55,60 @@ pub fn junit_counts(xml: &str) -> Option<(u32, u32)> { Some((tests, failures)) } +/// The `JUnit` property that carries a test's stable ID. +pub const TEST_ID_PROPERTY: &str = "test_id"; + +/// Add each test's stable ID to its `` as a property. +/// +/// `ids` maps a nextest `(classname, name)` pair (`::`, test +/// name) to the test's ID. Test cases not in `ids` are left unchanged. The +/// property is the test case's first child, where pytest's +/// `record_property` puts it. +pub fn annotate_junit(xml: &str, ids: &BTreeMap<(String, String), String>) -> String { + let mut out = String::with_capacity(xml.len()); + let mut rest = xml; + while let Some(start) = rest.find("') else { + break; + }; + let end = start + len; + let tag = &rest[start..end]; + out.push_str(&rest[..start]); + let key = attr(tag, "classname").zip(attr(tag, "name")); + match key.and_then(|(classname, name)| ids.get(&(classname.to_string(), name.to_string()))) + { + Some(id) => { + let properties = format!( + "" + ); + match tag.strip_suffix('/') { + Some(open) => { + let _ = write!(out, "{open}>{properties}"); + } + None => { + let _ = write!(out, "{tag}>{properties}"); + } + } + } + None => { + out.push_str(tag); + out.push('>'); + } + } + rest = &rest[end + 1..]; + } + out.push_str(rest); + out +} + +/// An attribute value from a start tag. nextest escapes `"` in values. +fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let key = format!(" {name}=\""); + let start = tag.find(&key)? + key.len(); + let end = start + tag[start..].find('"')?; + Some(&tag[start..end]) +} + /// A human-readable summary of every outcome. pub fn summary(outcomes: &[Outcome]) -> String { let mut out = String::from("\nSummary\n"); @@ -95,6 +150,37 @@ mod tests { assert_eq!(junit_counts(xml), Some((3, 2))); } + #[test] + fn annotates_test_cases_with_ids() { + let xml = r#" + + + + "#; + let ids = BTreeMap::from([ + ( + ("p::b".to_string(), "passed".to_string()), + "t-00000001".to_string(), + ), + ( + ("p::b".to_string(), "failed".to_string()), + "t-00000002".to_string(), + ), + ]); + let out = annotate_junit(xml, &ids); + assert!(out.contains( + r#""# + ), "{out}"); + assert!(out.contains( + r#""# + ), "{out}"); + assert!( + out.contains(r#""#), + "{out}" + ); + assert!(out.ends_with(""), "{out}"); + } + #[test] fn missing_root_has_no_counts() { assert_eq!(junit_counts(""), None); diff --git a/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs b/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs index dbc2038ea2..150ee4581e 100644 --- a/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs +++ b/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs @@ -8,7 +8,7 @@ use testkit::sdk::{SandboxPhase, SandboxRestartPolicy}; /// A long-running sandbox becomes ready, runs a command with exec, and is gone /// from get and list after delete. -#[testkit::test] +#[testkit::test(id = "t-3tfdx240")] async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { let sandbox = cx .sandbox("lr") @@ -39,7 +39,7 @@ async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { /// A sandbox whose main command exits 0, with restart policy Never, reaches /// Completed with exit code 0 and is not restarted. It is gone from get and /// list after delete. -#[testkit::test] +#[testkit::test(id = "t-nyiaw3g8")] async fn run_to_completion_exit_delete(cx: &mut Context) -> testkit::Result { let sandbox = cx .sandbox("rtc") diff --git a/tests/testkit/testkit-macros/src/lib.rs b/tests/testkit/testkit-macros/src/lib.rs index 6789f75ccf..eca2cdd8cd 100644 --- a/tests/testkit/testkit-macros/src/lib.rs +++ b/tests/testkit/testkit-macros/src/lib.rs @@ -5,17 +5,18 @@ //! //! A test is an `async fn` taking `&mut testkit::Context` and //! returning `testkit::Result`. Its doc comment is the test's description and -//! is required. The function name is the test ID. Requirements are named -//! after target capabilities: +//! is required. `id` is the required stable test ID: random, never changed, +//! and kept when the test is renamed or moved. A missing ID's compile error +//! suggests a fresh one. Requirements are named after target capabilities: //! //! ```ignore //! /// An exposed loopback HTTP service is reachable and goes away after delete. -//! #[testkit::test(requires = [service_exposure])] +//! #[testkit::test(id = "t-4f8k2m0q", requires = [service_exposure])] //! async fn expose_service(cx: &mut Context) -> testkit::Result { /* ... */ } //! ``` //! //! The attribute expands to a `#[tokio::test]` wrapper with the same name, so -//! the nextest test name is the test ID. The original function is nested in +//! the nextest test name is the function name. The original function is nested in //! the wrapper and passed to `testkit::run`. Parsing lives in `testkit-spec`, //! which the test runner's catalog also uses. @@ -35,9 +36,10 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { let mut body: ItemFn = syn::parse2(item)?; let spec = testkit_spec::parse(attr, &body)?; - let id = spec.id; - let id_str = LitStr::new(&id.to_string(), id.span()); - let description = LitStr::new(&spec.description, id.span()); + let name = spec.name; + let name_str = LitStr::new(&name.to_string(), name.span()); + let id = LitStr::new(&spec.id, name.span()); + let description = LitStr::new(&spec.description, name.span()); let requires = spec.requires.iter().map(|capability| { let variant = Ident::new(&to_camel_case(&capability.to_string()), capability.span()); quote!(::testkit::Capability::#variant) @@ -57,11 +59,12 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { Ok(quote! { #(#doc_attrs)* #[::testkit::__private::tokio::test(crate = "::testkit::__private::tokio")] - #vis async fn #id() { + #vis async fn #name() { #body ::testkit::run( ::testkit::TestInfo { - id: #id_str, + id: #id, + name: #name_str, description: #description, requires: &[#(#requires),*], }, @@ -95,20 +98,21 @@ mod tests { #[test] fn expands_to_wrapper_with_same_name() { let out = expand_str( - "", + "id = \"t-0123abcd\"", "/// Does a thing.\nasync fn does_a_thing(cx: &mut Context) -> testkit::Result { Ok(()) }", ) .unwrap(); assert!(out.contains("async fn does_a_thing ()"), "{out}"); assert!(out.contains("async fn body (cx : & mut Context)"), "{out}"); - assert!(out.contains("id : \"does_a_thing\""), "{out}"); + assert!(out.contains("id : \"t-0123abcd\""), "{out}"); + assert!(out.contains("name : \"does_a_thing\""), "{out}"); assert!(out.contains("description : \"Does a thing.\""), "{out}"); } #[test] fn maps_requirements_to_capabilities() { let out = expand_str( - "requires = [service_exposure, gpu]", + "id = \"t-0123abcd\", requires = [service_exposure, gpu]", "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", ) .unwrap(); diff --git a/tests/testkit/testkit-spec/src/lib.rs b/tests/testkit/testkit-spec/src/lib.rs index 759ebe9abf..c9084b6afa 100644 --- a/tests/testkit/testkit-spec/src/lib.rs +++ b/tests/testkit/testkit-spec/src/lib.rs @@ -5,18 +5,29 @@ //! //! The `#[testkit::test]` macro and the test runner's catalog both parse //! tests through this crate, so they cannot disagree about a test's ID, -//! description, or requirements. +//! name, description, or requirements. + +use std::hash::{BuildHasher, Hasher}; use proc_macro2::TokenStream; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; -use syn::{Attribute, Expr, ExprLit, Ident, Item, ItemFn, Lit, Meta, Token, bracketed}; +use syn::{Attribute, Expr, ExprLit, Ident, Item, ItemFn, Lit, LitStr, Meta, Token, bracketed}; + +/// Prefix of every test ID. +pub const ID_PREFIX: &str = "t-"; +/// Number of random characters after the prefix. +const ID_LEN: usize = 8; +const ID_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; /// What a test declares. #[derive(Clone, Debug)] pub struct TestSpec { - /// The function name, which is the test ID. - pub id: Ident, + /// The stable test ID from `id = "..."`. It never changes, including when + /// the test is renamed or moved to another suite or family. + pub id: String, + /// The function name, which is also the nextest test name. + pub name: Ident, /// The doc comment, joined into one line. pub description: String, /// Capability names, as written in `requires = [...]`. @@ -26,6 +37,7 @@ pub struct TestSpec { /// Arguments accepted by `#[testkit::test(...)]`. #[derive(Default)] struct Args { + id: Option, requires: Vec, } @@ -34,17 +46,25 @@ impl Parse for Args { let mut args = Self::default(); while !input.is_empty() { let key: Ident = input.parse()?; - if key != "requires" { + input.parse::()?; + if key == "id" { + if args.id.is_some() { + return Err(syn::Error::new(key.span(), "duplicate argument `id`")); + } + args.id = Some(input.parse()?); + } else if key == "requires" { + let content; + bracketed!(content in input); + args.requires + .extend(Punctuated::::parse_terminated(&content)?); + } else { return Err(syn::Error::new( key.span(), - format!("unknown argument `{key}`; expected `requires = [...]`"), + format!( + "unknown argument `{key}`; expected `id = \"...\"` or `requires = [...]`" + ), )); } - input.parse::()?; - let content; - bracketed!(content in input); - args.requires - .extend(Punctuated::::parse_terminated(&content)?); if !input.is_empty() { input.parse::()?; } @@ -78,13 +98,62 @@ pub fn parse(args: TokenStream, item: &ItemFn) -> syn::Result { )); } + let Some(id) = args.id else { + return Err(syn::Error::new( + item.sig.ident.span(), + format!( + "testkit tests need a stable `id`; for a new test use `id = \"{}\"`", + generate_id() + ), + )); + }; + if !is_valid_id(&id.value()) { + return Err(syn::Error::new( + id.span(), + format!( + "test IDs are `{ID_PREFIX}` followed by {ID_LEN} lowercase letters or digits; for a new test use `id = \"{}\"`", + generate_id() + ), + )); + } + Ok(TestSpec { - id: item.sig.ident.clone(), + id: id.value(), + name: item.sig.ident.clone(), description, requires: args.requires, }) } +/// Whether `id` has the test ID form: the prefix and lowercase base-36 characters. +pub fn is_valid_id(id: &str) -> bool { + id.strip_prefix(ID_PREFIX).is_some_and(|rest| { + rest.len() == ID_LEN + && rest + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) +} + +/// A fresh random test ID, offered in the error for a missing or invalid one. +/// Uses the standard library's per-process random hash keys, so this crate +/// needs no RNG dependency. +pub fn generate_id() -> String { + let mut hasher = std::collections::hash_map::RandomState::new().build_hasher(); + hasher.write_u128( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()), + ); + let mut value = hasher.finish(); + let mut id = String::from(ID_PREFIX); + for _ in 0..ID_LEN { + id.push(char::from(ID_ALPHABET[(value % 36) as usize])); + value /= 36; + } + id +} + /// Find every `#[testkit::test]` function in a source file. pub fn scan(source: &str) -> syn::Result> { let file = syn::parse_file(source)?; @@ -143,22 +212,74 @@ mod tests { parse(args.parse().unwrap(), &syn::parse_str(item).unwrap()) } + const ID: &str = "id = \"t-0123abcd\""; + #[test] - fn parses_id_description_and_requirements() { + fn parses_id_name_description_and_requirements() { let spec = parse_str( - "requires = [service_exposure, gpu]", + "id = \"t-0123abcd\", requires = [service_exposure, gpu]", "/// First line\n/// second line.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", ) .unwrap(); - assert_eq!(spec.id, "t"); + assert_eq!(spec.id, "t-0123abcd"); + assert_eq!(spec.name, "t"); assert_eq!(spec.description, "First line second line."); assert_eq!(spec.requires, ["service_exposure", "gpu"]); } #[test] - fn rejects_missing_doc_comment() { + fn rejects_missing_id_and_suggests_one() { let err = parse_str( "", + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("need a stable `id`"), "{err}"); + let suggested = err.split('"').nth(1).unwrap(); + assert!(is_valid_id(suggested), "{err}"); + } + + #[test] + fn rejects_malformed_id() { + for id in [ + "t-0123ABCD", + "t-0123abc", + "x-0123abcd", + "0123abcd", + "t-0123abcde", + ] { + let err = parse_str( + &format!("id = \"{id}\""), + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("test IDs are"), "{id}: {err}"); + } + } + + #[test] + fn rejects_duplicate_id_argument() { + let err = parse_str( + "id = \"t-0123abcd\", id = \"t-0123abce\"", + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("duplicate argument"), "{err}"); + } + + #[test] + fn generated_ids_are_valid_and_distinct() { + let a = generate_id(); + let b = generate_id(); + assert!(is_valid_id(&a), "{a}"); + assert_ne!(a, b); + } + + #[test] + fn rejects_missing_doc_comment() { + let err = parse_str( + ID, "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", ) .unwrap_err(); @@ -168,7 +289,7 @@ mod tests { #[test] fn rejects_sync_fn() { let err = parse_str( - "", + ID, "/// Doc.\nfn t(cx: &mut Context) -> testkit::Result { Ok(()) }", ) .unwrap_err(); @@ -178,14 +299,14 @@ mod tests { #[test] fn rejects_wrong_arity() { let err = - parse_str("", "/// Doc.\nasync fn t() -> testkit::Result { Ok(()) }").unwrap_err(); + parse_str(ID, "/// Doc.\nasync fn t() -> testkit::Result { Ok(()) }").unwrap_err(); assert!(err.to_string().contains("exactly one argument"), "{err}"); } #[test] fn rejects_unknown_argument() { let err = parse_str( - "timeout = [x]", + "id = \"t-0123abcd\", timeout = [x]", "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", ) .unwrap_err(); @@ -199,11 +320,11 @@ mod tests { use testkit::Context; /// Plain. - #[testkit::test] + #[testkit::test(id = \"t-00000001\")] async fn plain(cx: &mut Context) -> testkit::Result { Ok(()) } /// Needs exposure. - #[testkit::test(requires = [service_exposure])] + #[testkit::test(id = \"t-00000002\", requires = [service_exposure])] async fn exposed(cx: &mut Context) -> testkit::Result { Ok(()) } #[test] @@ -213,8 +334,9 @@ mod tests { ", ) .unwrap(); - let ids: Vec<_> = tests.iter().map(|test| test.id.to_string()).collect(); - assert_eq!(ids, ["plain", "exposed"]); + let names: Vec<_> = tests.iter().map(|test| test.name.to_string()).collect(); + assert_eq!(names, ["plain", "exposed"]); + assert_eq!(tests[1].id, "t-00000002"); assert_eq!(tests[1].requires, ["service_exposure"]); } diff --git a/tests/testkit/testkit/src/lib.rs b/tests/testkit/testkit/src/lib.rs index d6a92c03d2..51068178c9 100644 --- a/tests/testkit/testkit/src/lib.rs +++ b/tests/testkit/testkit/src/lib.rs @@ -11,7 +11,7 @@ //! use testkit::Context; //! //! /// A long-running sandbox becomes ready, runs exec, and is gone after delete. -//! #[testkit::test] +//! #[testkit::test(id = "t-3tfdx240")] //! async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { //! let sandbox = cx.sandbox("lr").command(["sleep", "infinity"]).create().await?; //! sandbox.wait_ready().await?; diff --git a/tests/testkit/testkit/src/run.rs b/tests/testkit/testkit/src/run.rs index bc6b6515ec..e98104e7e1 100644 --- a/tests/testkit/testkit/src/run.rs +++ b/tests/testkit/testkit/src/run.rs @@ -14,7 +14,10 @@ use crate::target::{Capability, Target}; /// Identity of a test, generated by `#[testkit::test]`. #[derive(Clone, Copy, Debug)] pub struct TestInfo { + /// The stable test ID. pub id: &'static str, + /// The function name, which is also the nextest test name. + pub name: &'static str, pub description: &'static str, pub requires: &'static [Capability], } @@ -31,38 +34,38 @@ pub async fn run(info: TestInfo, body: F) where F: AsyncFnOnce(&mut Context) -> Result, { - let id = info.id; - eprintln!("[{id}] {}", info.description); + let name = info.name; + eprintln!("[{name}] {} ({})", info.description, info.id); - let target = Target::from_env().unwrap_or_else(|error| panic!("[{id}] {error}")); + let target = Target::from_env().unwrap_or_else(|error| panic!("[{name}] {error}")); let missing = missing_requirements(&info, &target); // The runner excludes tests the target can't support. Reaching here means // it didn't, so fail rather than report a pass for an untested behavior. assert!( missing.is_empty(), - "[{id}] requires {}, which the target does not provide", + "[{name}] requires {}, which the target does not provide", missing.join(", ") ); let client = target .connect() .await - .unwrap_or_else(|error| panic!("[{id}] {error}")); + .unwrap_or_else(|error| panic!("[{name}] {error}")); let mut cx = Context::new(client.workspace(&target.workspace)); - eprintln!("[{id}] run id {}", cx.run_id()); + eprintln!("[{name}] run id {}", cx.run_id()); let outcome = AssertUnwindSafe(body(&mut cx)).catch_unwind().await; let cleanup_failures = cx.cleanup().await; for failure in &cleanup_failures { - eprintln!("[{id}] cleanup failed: {failure}"); + eprintln!("[{name}] cleanup failed: {failure}"); } match outcome { Err(panic) => std::panic::resume_unwind(panic), - Ok(Err(error)) => panic!("[{id}] {error}"), + Ok(Err(error)) => panic!("[{name}] {error}"), Ok(Ok(())) => assert!( cleanup_failures.is_empty(), - "[{id}] passed, but cleanup failed: {}", + "[{name}] passed, but cleanup failed: {}", cleanup_failures.join("; ") ), } @@ -90,7 +93,8 @@ mod tests { ) .unwrap(); let info = TestInfo { - id: "t", + id: "t-00000000", + name: "t", description: "d", requires: &[Capability::Gpu, Capability::ServiceExposure], }; From 6efaa24da7118efbdf210367698932a83a3d9989 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 14:58:09 +0200 Subject: [PATCH 11/18] docs(tests): state what a conformance test may assert Conformance tests assert only what the proto comments and published docs promise, use the phase and exit code rather than condition reasons, check negatives without sleeping, and gate driver-dependent behavior on a capability. Also correct which selectors use the test name. Signed-off-by: Evan Lezar --- TESTING.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/TESTING.md b/TESTING.md index 5909fa2799..4b6dff9b08 100644 --- a/TESTING.md +++ b/TESTING.md @@ -456,8 +456,18 @@ suggests a fresh one. IDs must be unique across all families, which the runner checks, and the runner adds each test's ID to its JUnit test case as the `test_id` property. -The function name is the nextest test name and is what `--suite` and nextest -filters select. Rename it when a clearer name helps; the ID keeps the history. +A conformance test asserts only what the public contract promises: the +comments in `proto/` and the published docs, such as the sandbox lifecycle in +`docs/how-it-works/sandboxes/overview.mdx`. Do not encode current gateway +behavior the contract doesn't state; file an issue to document it instead. +Assert on a sandbox's `phase` and `exit_code` rather than condition reasons, +which the API leaves to the platform. Check that something did not happen +(for example, `restart_count` is 0 and a second read shows the same phase) +instead of sleeping and watching. Behavior the docs leave to the compute +driver needs a capability in `requires`. + +The function name is the nextest test name and is what nextest filters +select. Rename it when a clearer name helps; the ID keeps the history. The doc comment is the required description. Declare capabilities a test needs with `#[testkit::test(requires = [service_exposure])]`; the runner excludes the test in environments without them and reports it as unsupported. Tests read the From 56bdba49bf3c1fdf6042c77760e223e5690196a1 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 15:24:47 +0200 Subject: [PATCH 12/18] test(runner): derive the expected test matrix and add waivers A test is expected in every environment that provides the capabilities it requires; nobody lists combinations, and plans only select from the result. - `matrix` prints every test against every environment: where it runs, or why not (missing capability, waiver, or with --host, this machine). - `matrix` and `test` fail when a test runs in no defined environment, or when tests require a capability no environment declares. - tests/waivers.yaml skips a test where a known bug breaks it. Each waiver names the test ID, an environment selector, the tracking issue and a reason. Waived tests are skipped in every plan and listed in the run summary; --ignore-waivers runs them anyway. - Environment selectors match environments by their fields. Signed-off-by: Evan Lezar --- TESTING.md | 31 ++++ tests/runner/src/catalog.rs | 10 ++ tests/runner/src/environments.rs | 83 +++++++++ tests/runner/src/execute.rs | 17 +- tests/runner/src/jobs.rs | 63 +++---- tests/runner/src/lib.rs | 2 + tests/runner/src/main.rs | 116 +++++++++++- tests/runner/src/matrix.rs | 292 +++++++++++++++++++++++++++++++ tests/runner/src/report.rs | 21 ++- tests/runner/src/waivers.rs | 184 +++++++++++++++++++ tests/waivers.yaml | 11 ++ 11 files changed, 782 insertions(+), 48 deletions(-) create mode 100644 tests/runner/src/matrix.rs create mode 100644 tests/runner/src/waivers.rs create mode 100644 tests/waivers.yaml diff --git a/TESTING.md b/TESTING.md index 4b6dff9b08..b7fc811597 100644 --- a/TESTING.md +++ b/TESTING.md @@ -432,6 +432,37 @@ Environments are defined in `tests/environments/*.yaml`. tmachine boots guests of the host's architecture only, so the runner reports environments for other architectures as unsupported instead of failing. +Nobody lists which tests run where. A test is expected in every environment +whose capabilities include everything the test `requires`; plans only choose +which of those to run. To see where each test runs, or why it doesn't, run: + +```shell +nix run .#test-runner -- matrix # add --host to apply this machine's limits +``` + +`matrix` and `test` fail if a test runs in no defined environment, or if tests +require a capability that no environment declares. When you add a capability, +declare it in every environment that provides it in the same change. If you +are unsure whether an environment provides it, declare it: a wrong claim shows +up as a test failure, while a wrong omission silently drops coverage. + +To skip a test where it fails because of a known bug, add a waiver to +`tests/waivers.yaml` with the test's ID, an environment selector, the issue +tracking the bug, and a reason: + +```yaml +waivers: + - test: t-3tfdx240 + environments: { runtime: podman-rootless } # fields of the environment; omit for all + issue: https://github.com/NVIDIA/OpenShell/issues/NNNN + reason: what fails, and where +``` + +Waived tests are skipped in every plan and listed in the run summary. Run with +`--ignore-waivers` to check whether a fix landed, and remove the waiver when +it has. Use waivers only for bugs: a feature an environment lacks is a missing +capability, and running less on a given occasion is a choice of plan. + Conformance suites (`tests/suites/conformance/`) are black-box tests that drive the gateway through the Rust SDK. Each suite is one test binary of `#[testkit::test]` functions. `testkit` (`tests/testkit/`) is the support diff --git a/tests/runner/src/catalog.rs b/tests/runner/src/catalog.rs index e6d999e4d7..a2b4929cd4 100644 --- a/tests/runner/src/catalog.rs +++ b/tests/runner/src/catalog.rs @@ -66,6 +66,16 @@ impl Catalog { Ok(catalog) } + /// Every test, with its family and suite. + pub fn tests(&self) -> impl Iterator { + self.families.values().flat_map(|family| { + family + .suites + .values() + .flat_map(move |suite| suite.tests.iter().map(move |test| (family, suite, test))) + }) + } + /// Fail if two tests anywhere in the catalog share an ID. fn check_unique_ids(&self) -> Result<()> { let mut seen: BTreeMap<&str, String> = BTreeMap::new(); diff --git a/tests/runner/src/environments.rs b/tests/runner/src/environments.rs index 026381e7ab..e88234f736 100644 --- a/tests/runner/src/environments.rs +++ b/tests/runner/src/environments.rs @@ -71,6 +71,70 @@ pub struct Tmachine { pub installer: String, } +/// Selects environments by their fields. An omitted field matches any value, +/// so an empty selector matches every environment. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Selector { + pub id: Option, + pub backend: Option, + pub os: Option, + pub version: Option, + pub arch: Option, + pub runtime: Option, + pub installer: Option, +} + +impl Selector { + pub fn matches(&self, environment: &Environment) -> bool { + self.id.as_ref().is_none_or(|id| *id == environment.id) + && self + .backend + .is_none_or(|backend| backend == environment.backend) + && self + .os + .as_ref() + .is_none_or(|os| *os == environment.machine.os) + && self + .version + .as_ref() + .is_none_or(|version| *version == environment.machine.version) + && self + .arch + .is_none_or(|arch| arch == environment.machine.arch) + && self + .runtime + .as_ref() + .is_none_or(|runtime| *runtime == environment.runtime) + && self + .installer + .as_ref() + .is_none_or(|installer| *installer == environment.installer) + } +} + +impl fmt::Display for Selector { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let fields: Vec = [ + ("id", self.id.clone()), + ("backend", self.backend.map(|backend| backend.to_string())), + ("os", self.os.clone()), + ("version", self.version.clone()), + ("arch", self.arch.map(|arch| arch.to_string())), + ("runtime", self.runtime.clone()), + ("installer", self.installer.clone()), + ] + .into_iter() + .filter_map(|(name, value)| value.map(|value| format!("{name}={value}"))) + .collect(); + if fields.is_empty() { + f.write_str("all environments") + } else { + f.write_str(&fields.join(", ")) + } + } +} + impl fmt::Display for Backend { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { @@ -275,6 +339,25 @@ environments: assert!(env.unsupported_reason(Some(Arch::Arm64)).is_some()); } + #[test] + fn selectors_match_by_field() { + let catalog = catalog(UBUNTU_DOCKER).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + let select = |yaml: &str| serde_yml::from_str::(yaml).unwrap(); + assert!(select("{}").matches(env)); + assert!(select("{ runtime: docker-rootful, arch: amd64 }").matches(env)); + assert!(!select("{ runtime: docker-rootful, arch: arm64 }").matches(env)); + assert!(!select("{ installer: rpm }").matches(env)); + assert_eq!(select("{}").to_string(), "all environments"); + assert_eq!( + select("{ arch: amd64, runtime: docker-rootful }").to_string(), + "arch=amd64, runtime=docker-rootful" + ); + assert!(serde_yml::from_str::("{ runtme: x }").is_err()); + } + #[test] fn repository_environments_are_valid() { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../environments"); diff --git a/tests/runner/src/execute.rs b/tests/runner/src/execute.rs index 81702d088a..65c109c864 100644 --- a/tests/runner/src/execute.rs +++ b/tests/runner/src/execute.rs @@ -14,6 +14,7 @@ use tmachine::config::Config; use crate::bundle::{Bundle, PROFILE, READY_BINARY}; use crate::jobs::{FamilyJob, Job}; +use crate::matrix::Exclusion; use crate::report::{Outcome, Status, annotate_junit, junit_counts}; /// Where the installer describes the gateway in the guest. @@ -29,6 +30,15 @@ pub struct Options<'a> { pub keep: bool, } +fn count(family: &FamilyJob, kind: impl Fn(&Exclusion) -> bool) -> usize { + family + .suites + .iter() + .flat_map(|suite| &suite.excluded) + .filter(|test| kind(&test.reason)) + .count() +} + /// Run one job. Every family gets an outcome, even when provisioning fails. pub async fn run(job: &Job, options: &Options<'_>) -> Vec { let env_id = &job.environment.id; @@ -36,11 +46,8 @@ pub async fn run(job: &Job, options: &Options<'_>) -> Vec { environment: env_id.clone(), family: family.family.clone(), status, - unsupported_tests: family - .suites - .iter() - .map(|suite| suite.unsupported.len()) - .sum(), + unsupported_tests: count(family, |reason| matches!(reason, Exclusion::Missing(_))), + waived_tests: count(family, |reason| matches!(reason, Exclusion::Waived(_))), }; if let Some(reason) = &job.unsupported { diff --git a/tests/runner/src/jobs.rs b/tests/runner/src/jobs.rs index bcd1061307..04c8baced5 100644 --- a/tests/runner/src/jobs.rs +++ b/tests/runner/src/jobs.rs @@ -5,9 +5,9 @@ //! //! Jobs are grouped by environment so each environment is provisioned once //! and runs every family and suite the plan selects for it. Tests whose -//! requirements the environment lacks are excluded by exact name and -//! reported as unsupported; libtest has no runtime skip, so this is the only -//! place they can be skipped honestly. +//! requirements the environment lacks, or that a waiver skips there, are +//! excluded by exact name and reported with the reason; libtest has no +//! runtime skip, so this is the only place they can be skipped honestly. use std::collections::BTreeMap; @@ -15,7 +15,9 @@ use anyhow::{Result, bail}; use crate::catalog::{Catalog, Suite}; use crate::environments::{Arch, Catalog as Environments, Environment}; +use crate::matrix::{Exclusion, exclusion}; use crate::plan::Plan; +use crate::waivers::Waivers; /// Everything the plan runs in one environment. #[derive(Debug)] @@ -38,14 +40,14 @@ pub struct SuiteJob { pub suite: Suite, /// Names of the tests to run. pub run: Vec, - /// Tests excluded because the environment lacks a capability they need. - pub unsupported: Vec, + /// Tests excluded in this environment, with the reason. + pub excluded: Vec, } #[derive(Debug)] -pub struct UnsupportedTest { +pub struct ExcludedTest { pub name: String, - pub missing: Vec, + pub reason: Exclusion, } /// Filters applied on top of the plan, from the command line. @@ -60,6 +62,7 @@ pub fn expand( plan: &Plan, catalog: &Catalog, environments: &Environments, + waivers: &Waivers, host: Option, narrowing: &Narrowing, ) -> Result> { @@ -128,7 +131,13 @@ pub fn expand( let suites: Vec = suites .iter() .filter(|suite| narrowing.suites.is_empty() || narrowing.suites.contains(suite)) - .map(|suite| suite_job(family.suite(suite).expect("validated above"), &environment)) + .map(|suite| { + suite_job( + family.suite(suite).expect("validated above"), + &environment, + waivers, + ) + }) .collect(); if !suites.is_empty() { family_jobs.push(FamilyJob { @@ -149,37 +158,25 @@ pub fn expand( Ok(jobs) } -fn suite_job(suite: &Suite, environment: &Environment) -> SuiteJob { +fn suite_job(suite: &Suite, environment: &Environment, waivers: &Waivers) -> SuiteJob { let mut run = Vec::new(); - let mut unsupported = Vec::new(); + let mut excluded = Vec::new(); for test in &suite.tests { - let missing: Vec = test - .requires - .iter() - .filter(|capability| !provides(environment, capability)) - .cloned() - .collect(); - if missing.is_empty() { - run.push(test.name.clone()); - } else { - unsupported.push(UnsupportedTest { + match exclusion(test, environment, waivers) { + None => run.push(test.name.clone()), + Some(reason) => excluded.push(ExcludedTest { name: test.name.clone(), - missing, - }); + reason, + }), } } SuiteJob { suite: suite.clone(), run, - unsupported, + excluded, } } -/// Requirements name boolean capabilities. -fn provides(environment: &Environment, capability: &str) -> bool { - environment.capabilities.get(capability) == Some(&serde_yml::Value::Bool(true)) -} - impl FamilyJob { /// Whether any test will run. pub fn has_tests(&self) -> bool { @@ -207,7 +204,7 @@ impl FamilyJob { .filter(|suite| !suite.run.is_empty()) .map(|suite| { let binary = format!("binary(={})", suite.suite.binary); - if suite.unsupported.is_empty() { + if suite.excluded.is_empty() { binary } else { let tests = suite @@ -289,6 +286,7 @@ mod tests { &Plan::parse(yaml).unwrap(), &catalog(), &environments(), + &Waivers::default(), Some(Arch::Amd64), narrowing, ) @@ -304,8 +302,11 @@ mod tests { assert_eq!(jobs.len(), 1); let suite = &jobs[0].families[0].suites[0]; assert_eq!(suite.run, ["long_running"]); - assert_eq!(suite.unsupported[0].name, "expose"); - assert_eq!(suite.unsupported[0].missing, ["service_exposure"]); + assert_eq!(suite.excluded[0].name, "expose"); + assert_eq!( + suite.excluded[0].reason, + Exclusion::Missing(vec!["service_exposure".to_string()]) + ); assert_eq!( jobs[0].families[0].filterset(), "(binary(=sandbox_lifecycle) & (test(=long_running)))" diff --git a/tests/runner/src/lib.rs b/tests/runner/src/lib.rs index bb46d294ce..e6f5ae0c8e 100644 --- a/tests/runner/src/lib.rs +++ b/tests/runner/src/lib.rs @@ -10,5 +10,7 @@ pub mod catalog; pub mod environments; pub mod execute; pub mod jobs; +pub mod matrix; pub mod plan; pub mod report; +pub mod waivers; diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs index dbc3a4df82..66175b2b2f 100644 --- a/tests/runner/src/main.rs +++ b/tests/runner/src/main.rs @@ -11,15 +11,17 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::process::ExitCode; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; use openshell_test_runner::bundle::{self, Bundle}; use openshell_test_runner::catalog::Catalog; use openshell_test_runner::environments::{Arch, Catalog as Environments}; use openshell_test_runner::execute::{self, Options}; use openshell_test_runner::jobs::{self, Job, Narrowing}; +use openshell_test_runner::matrix::{self, Exclusion, Matrix}; use openshell_test_runner::plan::Plan; use openshell_test_runner::report; +use openshell_test_runner::waivers::Waivers; #[derive(Parser)] #[command(name = "openshell-test", about = "Run OpenShell test plans")] @@ -50,6 +52,21 @@ enum Command { /// Results directory [default: artifacts/test-results/]. #[arg(long)] results: Option, + /// Run waived tests too, e.g. to check whether a fix landed. + #[arg(long)] + ignore_waivers: bool, + }, + /// Print every test against every environment: where it runs, or why not. + Matrix { + /// Also exclude environments this machine can't provision. + #[arg(long)] + host: bool, + /// Ignore tests/waivers.yaml. + #[arg(long)] + ignore_waivers: bool, + /// Print JSON instead of text. + #[arg(long)] + json: bool, }, /// Print the test catalog as JSON. Catalog, @@ -69,12 +86,50 @@ async fn main() -> ExitCode { async fn run(cli: Cli) -> Result { let root = repo_root()?; let catalog = Catalog::load(&root)?; + let environments_catalog = Environments::load_dir(&root.join("tests/environments"))?; + let load_waivers = |ignore: bool| -> Result { + if ignore { + return Ok(Waivers::default()); + } + Waivers::load( + &root.join("tests/waivers.yaml"), + &catalog, + &environments_catalog, + ) + }; match cli.command { Command::Catalog => { println!("{}", serde_json::to_string_pretty(&catalog)?); Ok(ExitCode::SUCCESS) } + Command::Matrix { + host, + ignore_waivers, + json, + } => { + let waivers = load_waivers(ignore_waivers)?; + let matrix = Matrix::build( + &catalog, + &environments_catalog, + &waivers, + host.then(Arch::host), + ); + if json { + println!("{}", serde_json::to_string_pretty(&matrix)?); + } else { + print!("{}", matrix.render()); + } + let problems = matrix::problems(&catalog, &environments_catalog); + for problem in &problems { + eprintln!("error: {problem}"); + } + Ok(if problems.is_empty() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) + } Command::Test { plan, dry_run, @@ -82,16 +137,31 @@ async fn run(cli: Cli) -> Result { environments, suites, results, + ignore_waivers, } => { + let problems = matrix::problems(&catalog, &environments_catalog); + if !problems.is_empty() { + bail!( + "the test matrix is inconsistent; see `matrix`:\n {}", + problems.join("\n ") + ); + } + let waivers = load_waivers(ignore_waivers)?; let plan_path = plan; let plan = Plan::load(&plan_path)?; - let environments_catalog = Environments::load_dir(&root.join("tests/environments"))?; let host = Arch::host(); let narrowing = Narrowing { environments, suites, }; - let jobs = jobs::expand(&plan, &catalog, &environments_catalog, host, &narrowing)?; + let jobs = jobs::expand( + &plan, + &catalog, + &environments_catalog, + &waivers, + host, + &narrowing, + )?; print_jobs(&jobs); if dry_run { return Ok(ExitCode::SUCCESS); @@ -128,6 +198,7 @@ async fn run(cli: Cli) -> Result { } eprintln!("{}", report::summary(&outcomes)); + print_waived(&jobs); eprintln!("Results: {}", results.display()); Ok( if outcomes.iter().any(|outcome| outcome.status.is_failure()) { @@ -150,12 +221,10 @@ fn print_jobs(jobs: &[Job]) { eprintln!(" {}: -E '{}'", family.family, family.filterset()); for suite in &family.suites { eprintln!(" {}: {}", suite.suite.suite, suite.run.join(", ")); - for test in &suite.unsupported { + for test in &suite.excluded { eprintln!( - " {}: {} unsupported (needs {})", - suite.suite.suite, - test.name, - test.missing.join(", ") + " {}: {} excluded ({})", + suite.suite.suite, test.name, test.reason ); } } @@ -163,6 +232,37 @@ fn print_jobs(jobs: &[Job]) { } } +/// List the waivers that skipped tests, so they stay in view. +fn print_waived(jobs: &[Job]) { + let waived: Vec = jobs + .iter() + .filter(|job| job.unsupported.is_none()) + .flat_map(|job| { + job.families.iter().flat_map(move |family| { + family.suites.iter().flat_map(move |suite| { + suite + .excluded + .iter() + .filter_map(move |test| match &test.reason { + Exclusion::Waived(waiver) => Some(format!( + " {} / {}: {} ({})", + job.environment.id, test.name, waiver.reason, waiver.issue + )), + _ => None, + }) + }) + }) + }) + .collect(); + if !waived.is_empty() { + eprintln!("Waived (skipped; run with --ignore-waivers to check fixes):"); + for line in waived { + eprintln!("{line}"); + } + eprintln!(); + } +} + /// Build one bundle per family, covering every suite that will run. fn build_bundles( root: &Path, diff --git a/tests/runner/src/matrix.rs b/tests/runner/src/matrix.rs new file mode 100644 index 0000000000..1c6b862d73 --- /dev/null +++ b/tests/runner/src/matrix.rs @@ -0,0 +1,292 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The expected matrix: every test against every defined environment. +//! +//! A test is expected in every environment that provides the capabilities +//! it requires. Nobody lists combinations: tests say what they need, +//! environments say what they provide. Waivers then skip known bugs, and +//! the host check excludes environments this machine can't boot. Plans only +//! select from the result; they never decide what is expected. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::{self, Write as _}; + +use serde::Serialize; + +use crate::catalog::{Catalog, Test}; +use crate::environments::{Arch, Catalog as Environments, Environment}; +use crate::waivers::{Waiver, Waivers}; + +/// Why a test does not run in an environment. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Exclusion { + /// The environment lacks capabilities the test requires. + Missing(Vec), + /// A waiver skips the test there. + Waived(Waiver), + /// This machine can't provision the environment. + Host(String), +} + +impl fmt::Display for Exclusion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing(capabilities) => write!(f, "needs {}", capabilities.join(", ")), + Self::Waived(waiver) => write!(f, "waived: {} ({})", waiver.reason, waiver.issue), + Self::Host(reason) => write!(f, "host: {reason}"), + } + } +} + +/// Capabilities the test requires that the environment doesn't provide. +/// Requirements name boolean capabilities. +pub fn missing_capabilities(test: &Test, environment: &Environment) -> Vec { + test.requires + .iter() + .filter(|capability| { + environment.capabilities.get(capability.as_str()) != Some(&serde_yml::Value::Bool(true)) + }) + .cloned() + .collect() +} + +/// Why the test doesn't run in the environment, ignoring the host: a missing +/// capability first, then a waiver. +pub fn exclusion(test: &Test, environment: &Environment, waivers: &Waivers) -> Option { + let missing = missing_capabilities(test, environment); + if !missing.is_empty() { + return Some(Exclusion::Missing(missing)); + } + waivers + .find(&test.id, environment) + .map(|waiver| Exclusion::Waived(waiver.clone())) +} + +/// Problems that make the matrix inconsistent, judged by capabilities alone: +/// a test no defined environment can run, and a capability tests require +/// that no environment declares. Waivers and the host don't count; a test +/// only needs to be runnable somewhere in principle. +pub fn problems(catalog: &Catalog, environments: &Environments) -> Vec { + let declared: BTreeSet<&str> = environments + .iter() + .flat_map(|env| { + env.capabilities + .iter() + .filter(|(_, value)| **value == serde_yml::Value::Bool(true)) + .map(|(name, _)| name.as_str()) + }) + .collect(); + + let mut undeclared: BTreeMap<&str, Vec> = BTreeMap::new(); + let mut problems = Vec::new(); + for (family, suite, test) in catalog.tests() { + let location = format!( + "{}/{}/{} ({})", + family.name, suite.suite, test.name, test.id + ); + for capability in &test.requires { + if !declared.contains(capability.as_str()) { + undeclared + .entry(capability) + .or_default() + .push(location.clone()); + } + } + if !environments + .iter() + .any(|env| missing_capabilities(test, env).is_empty()) + { + problems.push(format!( + "{location} runs in no defined environment; requires {}", + test.requires.join(", ") + )); + } + } + for (capability, tests) in undeclared { + problems.push(format!( + "capability {capability} is required by {} but no environment declares it", + tests.join(", ") + )); + } + problems +} + +/// Every test against every environment. +#[derive(Debug, Serialize)] +pub struct Matrix { + pub environments: Vec, + pub tests: Vec, +} + +#[derive(Debug, Serialize)] +pub struct Row { + pub family: String, + pub suite: String, + pub name: String, + pub id: String, + /// Keyed by environment ID: `run`, or why the test doesn't run there. + pub cells: BTreeMap, +} + +impl Matrix { + /// Build the matrix. With `host`, also exclude environments that machine + /// can't provision. + pub fn build( + catalog: &Catalog, + environments: &Environments, + waivers: &Waivers, + host: Option>, + ) -> Self { + let tests = catalog + .tests() + .map(|(family, suite, test)| Row { + family: family.name.clone(), + suite: suite.suite.clone(), + name: test.name.clone(), + id: test.id.clone(), + cells: environments + .iter() + .map(|env| { + let exclusion = exclusion(test, env, waivers).or_else(|| { + host.and_then(|host| env.unsupported_reason(host).map(Exclusion::Host)) + }); + let cell = exclusion.map_or_else(|| "run".to_string(), |e| e.to_string()); + (env.id.clone(), cell) + }) + .collect(), + }) + .collect(); + Self { + environments: environments.iter().map(|env| env.id.clone()).collect(), + tests, + } + } + + /// The matrix as text: each test, then where it runs or why not. + pub fn render(&self) -> String { + let width = self.environments.iter().map(String::len).max().unwrap_or(0); + let mut out = String::new(); + let mut runs = 0; + let mut excluded = 0; + for row in &self.tests { + let _ = writeln!( + out, + "{}/{}/{} ({})", + row.family, row.suite, row.name, row.id + ); + for (env, cell) in &row.cells { + let _ = writeln!(out, " {env:width$} {cell}"); + if cell == "run" { + runs += 1; + } else { + excluded += 1; + } + } + } + let _ = writeln!( + out, + "\n{} tests x {} environments: {runs} run, {excluded} excluded", + self.tests.len(), + self.environments.len() + ); + out + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::catalog::{Family, Suite}; + + fn environments(capabilities: &str) -> Environments { + let yaml = format!( + "environments:\n - id: tmachine.ubuntu2404-amd64.docker-rootful.deb\n backend: tmachine\n machine: {{ os: ubuntu, version: \"24.04\", arch: amd64 }}\n runtime: docker-rootful\n installer: deb\n tmachine: {{ environment: ubuntu-docker-rootful, installer: deb }}\n capabilities: {capabilities}\n" + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("docker.yaml"), yaml).unwrap(); + Environments::load_dir(dir.path()).unwrap() + } + + fn catalog(tests: &[(&str, &[&str])]) -> Catalog { + let suite = Suite { + suite: "s".to_string(), + binary: "s".to_string(), + package: "p".to_string(), + tests: tests + .iter() + .enumerate() + .map(|(index, (name, requires))| Test { + id: format!("t-{index:08}"), + name: (*name).to_string(), + description: "d".to_string(), + requires: requires.iter().map(ToString::to_string).collect(), + }) + .collect(), + }; + let family = Family { + name: "conformance".to_string(), + workspace: PathBuf::new(), + suites: [("s".to_string(), suite)].into(), + }; + Catalog { + families: [("conformance".to_string(), family)].into(), + } + } + + #[test] + fn reports_tests_no_environment_can_run() { + let catalog = catalog(&[("plain", &[]), ("exposed", &["service_exposure"])]); + let found = problems(&catalog, &environments("{ service_exposure: false }")); + assert_eq!(found.len(), 2, "{found:?}"); + assert!( + found[0].contains("conformance/s/exposed (t-00000001) runs in no defined environment") + ); + assert!(found[1].contains("capability service_exposure is required by")); + + assert!(problems(&catalog, &environments("{ service_exposure: true }")).is_empty()); + } + + #[test] + fn cells_give_the_reason_a_test_does_not_run() { + let catalog = catalog(&[("plain", &[]), ("exposed", &["service_exposure"])]); + let environments = environments("{ compute_driver: docker }"); + let env = "tmachine.ubuntu2404-amd64.docker-rootful.deb"; + + let matrix = Matrix::build(&catalog, &environments, &Waivers::default(), None); + assert_eq!(matrix.tests[0].cells[env], "run"); + assert_eq!(matrix.tests[1].cells[env], "needs service_exposure"); + + let on_arm = Matrix::build( + &catalog, + &environments, + &Waivers::default(), + Some(Some(Arch::Arm64)), + ); + assert!( + on_arm.tests[0].cells[env].starts_with("host: "), + "{:?}", + on_arm.tests[0].cells + ); + // A missing capability is reported before the host. + assert_eq!(on_arm.tests[1].cells[env], "needs service_exposure"); + + let text = matrix.render(); + assert!(text.contains("conformance/s/plain (t-00000000)"), "{text}"); + assert!( + text.contains("2 tests x 1 environments: 1 run, 1 excluded"), + "{text}" + ); + } + + #[test] + fn repository_matrix_is_consistent() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let catalog = Catalog::load(&root).unwrap(); + let environments = Environments::load_dir(&root.join("tests/environments")).unwrap(); + let problems = problems(&catalog, &environments); + assert!(problems.is_empty(), "{problems:#?}"); + } +} diff --git a/tests/runner/src/report.rs b/tests/runner/src/report.rs index b7f4350d0b..14821bad08 100644 --- a/tests/runner/src/report.rs +++ b/tests/runner/src/report.rs @@ -14,6 +14,8 @@ pub struct Outcome { pub status: Status, /// Tests excluded because the environment lacks a capability. pub unsupported_tests: usize, + /// Tests a waiver skips in this environment. + pub waived_tests: usize, } #[derive(Debug)] @@ -124,10 +126,17 @@ pub fn summary(outcomes: &[Outcome]) -> String { } Status::Error(error) => format!("ERROR {error}"), }; - let unsupported = if outcome.unsupported_tests > 0 { - format!(" ({} tests unsupported)", outcome.unsupported_tests) - } else { + let mut excluded = Vec::new(); + if outcome.unsupported_tests > 0 { + excluded.push(format!("{} tests unsupported", outcome.unsupported_tests)); + } + if outcome.waived_tests > 0 { + excluded.push(format!("{} waived", outcome.waived_tests)); + } + let unsupported = if excluded.is_empty() { String::new() + } else { + format!(" ({})", excluded.join(", ")) }; let _ = writeln!( out, @@ -194,16 +203,20 @@ mod tests { family: "conformance".to_string(), status: Status::Passed { tests: 2 }, unsupported_tests: 1, + waived_tests: 1, }, Outcome { environment: "env-b".to_string(), family: "conformance".to_string(), status: Status::Unsupported("arch".to_string()), unsupported_tests: 0, + waived_tests: 0, }, ]); assert!( - text.contains("env-a / conformance: PASS 2 passed (1 tests unsupported)"), + text.contains( + "env-a / conformance: PASS 2 passed (1 tests unsupported, 1 waived)" + ), "{text}" ); assert!( diff --git a/tests/runner/src/waivers.rs b/tests/runner/src/waivers.rs new file mode 100644 index 0000000000..04c1ba8493 --- /dev/null +++ b/tests/runner/src/waivers.rs @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Waivers: `tests/waivers.yaml`. +//! +//! A waiver excludes a test from the environments it selects because of a +//! known bug, tracked by an issue. It is the only explicit exclusion: a +//! feature an environment doesn't have is a missing capability, a machine +//! that can't boot an environment is the host check, and running less on a +//! given occasion is plan selection. Waived tests are skipped in every plan. + +use std::collections::BTreeSet; +use std::path::Path; + +use anyhow::{Context, Result, bail, ensure}; +use serde::Deserialize; + +use crate::catalog::Catalog; +use crate::environments::{Catalog as Environments, Environment, Selector}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + #[serde(default)] + waivers: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Waiver { + /// The test's stable ID. + pub test: String, + /// The environments the test is skipped in. Omit for all. + #[serde(default)] + pub environments: Selector, + /// The issue tracking the bug. + pub issue: String, + pub reason: String, +} + +#[derive(Debug, Default)] +pub struct Waivers { + waivers: Vec, +} + +impl Waivers { + /// Load and validate waivers. A missing file means no waivers. + pub fn load(path: &Path, catalog: &Catalog, environments: &Environments) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let yaml = + std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + Self::parse(&yaml, catalog, environments) + .with_context(|| format!("load waivers from {}", path.display())) + } + + fn parse(yaml: &str, catalog: &Catalog, environments: &Environments) -> Result { + let file: File = serde_yml::from_str(yaml)?; + let ids: BTreeSet<&str> = catalog + .tests() + .map(|(_, _, test)| test.id.as_str()) + .collect(); + for (index, waiver) in file.waivers.iter().enumerate() { + let at = format!("waiver for {}", waiver.test); + ensure!( + ids.contains(waiver.test.as_str()), + "{at}: no test has this ID; remove the waiver if the test is gone" + ); + ensure!( + environments + .iter() + .any(|env| waiver.environments.matches(env)), + "{at}: no environment matches {}", + waiver.environments + ); + ensure!( + waiver.issue.starts_with("https://"), + "{at}: `issue` must link the issue tracking the bug" + ); + ensure!(!waiver.reason.trim().is_empty(), "{at}: `reason` is empty"); + if file.waivers[..index] + .iter() + .any(|other| other.test == waiver.test && other.environments == waiver.environments) + { + bail!("{at}: duplicate waiver for {}", waiver.environments); + } + } + Ok(Self { + waivers: file.waivers, + }) + } + + /// The waiver that skips this test in this environment, if any. + pub fn find(&self, test_id: &str, environment: &Environment) -> Option<&Waiver> { + self.waivers + .iter() + .find(|waiver| waiver.test == test_id && waiver.environments.matches(environment)) + } + + pub fn iter(&self) -> impl Iterator { + self.waivers.iter() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") + } + + fn parse(yaml: &str) -> Result { + let catalog = Catalog::load(&root()).unwrap(); + let environments = Environments::load_dir(&root().join("tests/environments")).unwrap(); + Waivers::parse(yaml, &catalog, &environments) + } + + // long_running_exec_delete + const TEST: &str = "t-3tfdx240"; + + #[test] + fn finds_waivers_by_test_and_environment() { + let waivers = parse(&format!( + "waivers:\n - test: {TEST}\n environments: {{ arch: arm64 }}\n issue: https://github.com/NVIDIA/OpenShell/issues/1\n reason: broken on arm64\n" + )) + .unwrap(); + let environments = Environments::load_dir(&root().join("tests/environments")).unwrap(); + let amd64 = environments + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + let arm64 = environments + .get("tmachine.ubuntu2404-arm64.docker-rootful.deb") + .unwrap(); + assert!(waivers.find(TEST, amd64).is_none()); + assert_eq!(waivers.find(TEST, arm64).unwrap().reason, "broken on arm64"); + assert!(waivers.find("t-00000000", arm64).is_none()); + } + + #[test] + fn rejects_invalid_waivers() { + let cases = [ + ( + "waivers:\n - test: t-00000000\n issue: https://x\n reason: r\n".to_string(), + "no test has this ID", + ), + ( + format!( + "waivers:\n - test: {TEST}\n environments: {{ runtime: nope }}\n issue: https://x\n reason: r\n" + ), + "no environment matches", + ), + ( + format!("waivers:\n - test: {TEST}\n issue: '#123'\n reason: r\n"), + "must link the issue", + ), + ( + format!("waivers:\n - test: {TEST}\n issue: https://x\n reason: ' '\n"), + "`reason` is empty", + ), + ( + format!( + "waivers:\n - test: {TEST}\n issue: https://x\n reason: r\n - test: {TEST}\n issue: https://y\n reason: r\n" + ), + "duplicate waiver", + ), + ]; + for (yaml, expected) in cases { + let err = format!("{:#}", parse(&yaml).unwrap_err()); + assert!(err.contains(expected), "{expected}: {err}"); + } + assert!(parse("waivers:\n - test: x\n issu: y\n").is_err()); + } + + #[test] + fn repository_waivers_are_valid() { + let catalog = Catalog::load(&root()).unwrap(); + let environments = Environments::load_dir(&root().join("tests/environments")).unwrap(); + Waivers::load(&root().join("tests/waivers.yaml"), &catalog, &environments).unwrap(); + } +} diff --git a/tests/waivers.yaml b/tests/waivers.yaml new file mode 100644 index 0000000000..77b614a193 --- /dev/null +++ b/tests/waivers.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Known bugs that skip a test in some environments. See TESTING.md. +# +# waivers: +# - test: t-xxxxxxxx # the test's stable ID +# environments: { runtime: podman-rootless } # omit for all environments +# issue: https://github.com/NVIDIA/OpenShell/issues/NNNN +# reason: what fails and why +waivers: [] From b0b46269b86761215e320e90784e54641767ec8f Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 15:46:26 +0200 Subject: [PATCH 13/18] docs(tests): put new end-to-end tests under tests/ tests/ is the new test surface: suites, their support library, the plan runner, and provisioning. e2e/ holds the existing harness, which is being migrated into tests/ and only takes fixes until it is empty. Signed-off-by: Evan Lezar --- AGENTS.md | 4 ++++ TESTING.md | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 0a21595b85..b1d809fcee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,10 @@ ocsf_emit!(event); - If you change sandbox infrastructure, ensure the relevant sandbox e2e path succeeds. +## End-to-End Tests + +- Add new end-to-end tests under `tests/suites/`, following [TESTING.md](TESTING.md). `e2e/` is being migrated to `tests/` and only takes fixes. + ## Network Sockets - On latency-sensitive TCP streams, disable Nagle's algorithm so small diff --git a/TESTING.md b/TESTING.md index b7fc811597..93169795ab 100644 --- a/TESTING.md +++ b/TESTING.md @@ -16,8 +16,15 @@ crates/*/tests/ # Rust integration tests python/openshell/ # Python unit tests (*_test.py suffix) e2e/python/ # Python E2E tests (test_*.py prefix) e2e/rust/ # Rust CLI E2E tests +tests/suites/ # End-to-end suites, run by test plans +tests/testkit/ # Shared support for the suites +tests/runner/ # Test plan runner, with tests/{plans,environments} +tests/tmachine/ # VM environments, provisioned with tests/ansible ``` +New end-to-end tests go under `tests/`. The suites in `e2e/` are being +migrated there; `e2e/` only takes fixes until it is empty. + ## Rust Tests Unit tests live inline with `#[cfg(test)] mod tests` blocks. Integration tests From fca35009f19df7868f343df3630c27a36f3cfbe9 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 16:34:56 +0200 Subject: [PATCH 14/18] test(conformance): run the CLI conformance tests as testkit suites Conformance covers every public client interface. Behavior that exists only in the CLI, such as file upload and download, is tested through the CLI the environment installed, alongside the SDK. - testkit drives that CLI (`cx.cli`). The target gains a `cli` section, written by the openshell_client role, and testkit-ready checks that `openshell status` reports the gateway connected. The CLI is part of every target rather than a capability. - Port the nine tests in tests/suites/conformance/cli: four lifecycle tests in sandbox-lifecycle, a CLI-driven file-transfer suite, and a policy-advisor suite using testkit helpers over the generated gRPC client for policies, settings, and draft rules. - Add the persistent_workspace capability for workspace persistence across stop and start, which the docs leave to the driver. - Filter suites by package and binary, since the CLI package has test binaries with the same names. - Wait up to five minutes for the gateway, matching the installers. The CLI suite stays for the developer e2e scripts. Signed-off-by: Evan Lezar --- TESTING.md | 27 +- .../roles/openshell_client/tasks/main.yaml | 12 +- tests/environments/docker.yaml | 4 +- tests/runner/src/catalog.rs | 7 +- tests/runner/src/execute.rs | 4 +- tests/runner/src/jobs.rs | 16 +- tests/suites/conformance/Cargo.lock | 18 + tests/suites/conformance/Cargo.toml | 2 +- .../conformance/file-transfer/Cargo.toml | 16 + .../file-transfer/tests/file_transfer.rs | 418 ++++++++++++++++++ .../conformance/policy-advisor/Cargo.toml | 17 + .../policy-advisor/tests/policy_advisor.rs | 411 +++++++++++++++++ .../tests/sandbox_lifecycle.rs | 133 ++++++ tests/testkit/Cargo.lock | 1 + tests/testkit/testkit/Cargo.toml | 3 +- .../testkit/testkit/src/bin/testkit-ready.rs | 18 +- tests/testkit/testkit/src/cli.rs | 187 ++++++++ tests/testkit/testkit/src/context.rs | 242 +++++++++- tests/testkit/testkit/src/lib.rs | 5 +- tests/testkit/testkit/src/run.rs | 2 +- tests/testkit/testkit/src/target.rs | 88 ++++ 21 files changed, 1595 insertions(+), 36 deletions(-) create mode 100644 tests/suites/conformance/file-transfer/Cargo.toml create mode 100644 tests/suites/conformance/file-transfer/tests/file_transfer.rs create mode 100644 tests/suites/conformance/policy-advisor/Cargo.toml create mode 100644 tests/suites/conformance/policy-advisor/tests/policy_advisor.rs create mode 100644 tests/testkit/testkit/src/cli.rs diff --git a/TESTING.md b/TESTING.md index 93169795ab..6ad080b83c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -430,10 +430,10 @@ building or booting anything, `--env` or `--suite` to narrow a plan, and reports and gateway logs for failures, go to `artifacts/test-results////`. -Before running an environment's suites, the runner waits up to 60 seconds for -its gateway to report healthy (`testkit-ready`, shipped in each bundle). A -gateway that never becomes healthy is reported as an environment error, not as -test failures. +Before running an environment's suites, the runner waits up to five minutes for +its gateway to report healthy and for the environment's CLI to reach it +(`testkit-ready`, shipped in each bundle). Failing either is reported as an +environment error, not as test failures. Environments are defined in `tests/environments/*.yaml`. tmachine boots guests of the host's architecture only, so the runner reports environments for other @@ -470,8 +470,10 @@ Waived tests are skipped in every plan and listed in the run summary. Run with it has. Use waivers only for bugs: a feature an environment lacks is a missing capability, and running less on a given occasion is a choice of plan. -Conformance suites (`tests/suites/conformance/`) are black-box tests that drive -the gateway through the Rust SDK. Each suite is one test binary of +Conformance suites (`tests/suites/conformance/`) are black-box tests of the +public client interfaces: the gateway API through the Rust SDK, and the +`openshell` CLI the environment installed. The suites are `sandbox-lifecycle`, +`file-transfer`, and `policy-advisor`. Each suite is one test binary of `#[testkit::test]` functions. `testkit` (`tests/testkit/`) is the support library shared by every family: it reads the target, connects to the gateway, creates and cleans up sandboxes, and provides the attribute. The runner parses @@ -504,6 +506,19 @@ which the API leaves to the platform. Check that something did not happen instead of sleeping and watching. Behavior the docs leave to the compute driver needs a capability in `requires`. +Act through the interface that makes the promise. Upload, download, connect, +and port forwarding exist only in the CLI, so those tests call `cx.cli([...])` +and assert through the SDK where they can; rely on the CLI's exit code and +`--output json`, not its table text. For API calls the SDK does not wrap yet, +such as settings and draft policy, add a testkit helper over the generated gRPC +client rather than calling it from tests. + +Every environment provides its CLI: the installer's `openshell_client` role +writes a `cli:` section (binary and gateway name) next to `gateway:` in the +target, and `testkit-ready` checks that `openshell status` reports the gateway +connected. The CLI is part of the target, not a capability, so CLI-driven tests +run everywhere. + The function name is the nextest test name and is what nextest filters select. Rename it when a clearer name helps; the ID keeps the history. The doc comment is the required description. Declare capabilities a test needs with diff --git a/tests/ansible/roles/openshell_client/tasks/main.yaml b/tests/ansible/roles/openshell_client/tasks/main.yaml index 66ce6464de..df7319eed6 100644 --- a/tests/ansible/roles/openshell_client/tasks/main.yaml +++ b/tests/ansible/roles/openshell_client/tasks/main.yaml @@ -26,6 +26,11 @@ path: "{{ openshell_client_config_dir }}/mtls/tls.crt" register: openshell_client_mtls +- name: Find the installed OpenShell CLI + ansible.builtin.shell: command -v openshell + changed_when: false + register: openshell_client_binary + - name: Create test target directory become: true ansible.builtin.file: @@ -33,15 +38,18 @@ state: directory mode: "0755" -- name: Write gateway section of the test target +- name: Write gateway and CLI sections of the test target become: true ansible.builtin.copy: dest: /etc/openshell-test/gateway.yaml mode: "0644" content: | # Written by the openshell_client role. - {{ {'gateway': openshell_client_target_gateway} | to_nice_yaml(indent=2) }} + {{ {'gateway': openshell_client_target_gateway, 'cli': openshell_client_target_cli} | to_nice_yaml(indent=2) }} vars: + openshell_client_target_cli: + binary: "{{ openshell_client_binary.stdout | trim }}" + gateway: "{{ openshell_client_gateway_name | default('tmachine') }}" openshell_client_target_tls: ca: "{{ openshell_client_config_dir }}/mtls/ca.crt" client_cert: "{{ openshell_client_config_dir }}/mtls/tls.crt" diff --git a/tests/environments/docker.yaml b/tests/environments/docker.yaml index d847b84faa..e7259566a7 100644 --- a/tests/environments/docker.yaml +++ b/tests/environments/docker.yaml @@ -10,11 +10,11 @@ environments: runtime: docker-rootful installer: deb tmachine: { environment: ubuntu-docker-rootful, installer: deb } - capabilities: { compute_driver: docker, rootless: false, gpu: false } + capabilities: { compute_driver: docker, rootless: false, gpu: false, persistent_workspace: true } - id: tmachine.ubuntu2404-arm64.docker-rootful.deb backend: tmachine machine: { os: ubuntu, version: "24.04", arch: arm64 } runtime: docker-rootful installer: deb tmachine: { environment: ubuntu-docker-rootful, installer: deb } - capabilities: { compute_driver: docker, rootless: false, gpu: false } + capabilities: { compute_driver: docker, rootless: false, gpu: false, persistent_workspace: true } diff --git a/tests/runner/src/catalog.rs b/tests/runner/src/catalog.rs index a2b4929cd4..3e244d039c 100644 --- a/tests/runner/src/catalog.rs +++ b/tests/runner/src/catalog.rs @@ -241,7 +241,12 @@ mod tests { && !test.description.is_empty()) ); // CLI-driven tests in the same workspace are not testkit suites. - assert!(family.suites.keys().all(|name| name != "file-transfer")); + assert!( + family + .suites + .values() + .all(|suite| suite.package != "openshell-test-conformance-cli") + ); } #[test] diff --git a/tests/runner/src/execute.rs b/tests/runner/src/execute.rs index 65c109c864..dc24013e7d 100644 --- a/tests/runner/src/execute.rs +++ b/tests/runner/src/execute.rs @@ -21,7 +21,9 @@ use crate::report::{Outcome, Status, annotate_junit, junit_counts}; const GATEWAY_SECTION: &str = "/etc/openshell-test/gateway.yaml"; const GUEST_ROOT: &str = "/var/tmp/openshell-tests"; /// How long the gateway has to become healthy after the environment is up. -const READY_TIMEOUT_SECS: u64 = 60; +// Matches the installers' own wait: an HA k3s gateway booted from a cached +// disk can take minutes to report healthy. +const READY_TIMEOUT_SECS: u64 = 300; pub struct Options<'a> { pub tmachine: &'a Config, diff --git a/tests/runner/src/jobs.rs b/tests/runner/src/jobs.rs index 04c8baced5..1234f4b91b 100644 --- a/tests/runner/src/jobs.rs +++ b/tests/runner/src/jobs.rs @@ -203,7 +203,12 @@ impl FamilyJob { .iter() .filter(|suite| !suite.run.is_empty()) .map(|suite| { - let binary = format!("binary(={})", suite.suite.binary); + // Name the package too: other packages in the family can have test + // binaries with the same name. + let binary = format!( + "binary_id(={}::{})", + suite.suite.package, suite.suite.binary + ); if suite.excluded.is_empty() { binary } else { @@ -309,7 +314,7 @@ mod tests { ); assert_eq!( jobs[0].families[0].filterset(), - "(binary(=sandbox_lifecycle) & (test(=long_running)))" + "(binary_id(=pkg-sandbox-lifecycle::sandbox_lifecycle) & (test(=long_running)))" ); } @@ -328,7 +333,7 @@ mod tests { assert_eq!(suites, ["providers", "sandbox-lifecycle"]); assert_eq!( jobs[0].families[0].filterset(), - "binary(=providers) | (binary(=sandbox_lifecycle) & (test(=long_running)))" + "binary_id(=pkg-providers::providers) | (binary_id(=pkg-sandbox-lifecycle::sandbox_lifecycle) & (test(=long_running)))" ); } @@ -363,7 +368,10 @@ mod tests { .unwrap(); assert_eq!(jobs.len(), 1); assert_eq!(jobs[0].families[0].suites.len(), 1); - assert_eq!(jobs[0].families[0].filterset(), "binary(=providers)"); + assert_eq!( + jobs[0].families[0].filterset(), + "binary_id(=pkg-providers::providers)" + ); } #[test] diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index 138130647e..f3b33f0910 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -1102,6 +1102,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "openshell-conformance-file-transfer" +version = "0.0.0" +dependencies = [ + "openshell-testkit", + "tempfile", +] + +[[package]] +name = "openshell-conformance-policy-advisor" +version = "0.0.0" +dependencies = [ + "openshell-testkit", + "serde_json", + "tokio", +] + [[package]] name = "openshell-conformance-sandbox-lifecycle" version = "0.0.0" @@ -1210,6 +1227,7 @@ dependencies = [ "openshell-testkit-macros", "rand 0.9.4", "serde", + "serde_json", "tokio", "tonic", ] diff --git a/tests/suites/conformance/Cargo.toml b/tests/suites/conformance/Cargo.toml index 66e085a8d4..aabae4b41a 100644 --- a/tests/suites/conformance/Cargo.toml +++ b/tests/suites/conformance/Cargo.toml @@ -3,4 +3,4 @@ [workspace] resolver = "2" -members = ["cli", "sandbox-lifecycle"] +members = ["cli", "file-transfer", "policy-advisor", "sandbox-lifecycle"] diff --git a/tests/suites/conformance/file-transfer/Cargo.toml b/tests/suites/conformance/file-transfer/Cargo.toml new file mode 100644 index 0000000000..a1dbf90867 --- /dev/null +++ b/tests/suites/conformance/file-transfer/Cargo.toml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-conformance-file-transfer" +description = "Conformance suite: file-transfer" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[dependencies] +openshell-testkit = { path = "../../../testkit/testkit" } +tempfile = "3" diff --git a/tests/suites/conformance/file-transfer/tests/file_transfer.rs b/tests/suites/conformance/file-transfer/tests/file_transfer.rs new file mode 100644 index 0000000000..cecd960de1 --- /dev/null +++ b/tests/suites/conformance/file-transfer/tests/file_transfer.rs @@ -0,0 +1,418 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Conformance suite: file-transfer. +//! +//! Upload and download exist only in the CLI, which moves files over the +//! sandbox's SSH session, so these tests act through `cx.cli`. They depend on +//! the supervisor, the sandbox image, and the driver's filesystem, which is +//! why they run in every environment. + +use std::fs; +use std::path::{Path, PathBuf}; + +use tempfile::TempDir; +use testkit::{CliOutput, Context, Error, Sandbox}; + +const LARGE_FILE_SIZE: usize = 512 * 1024; + +/// Files and directories round-trip through upload and download unchanged, +/// uploads into existing directories land inside them, and downloads of +/// single files and directories reproduce them locally. +#[testkit::test(id = "t-ifeaivyq")] +async fn upload_download_round_trip(cx: &mut Context) -> testkit::Result { + let ft = Transfer::new(cx, "fr").await?; + ft.upload_into_existing_directories().await?; + ft.round_trip().await?; + ft.download_file().await?; + ft.download_directory().await?; + ft.sandbox.delete().await +} + +/// Uploads from a Git work tree skip ignored files, a single file uploads +/// alone, and an upload whose selection is entirely ignored is rejected +/// unless `--no-git-ignore` is given. +#[testkit::test(id = "t-0no9ex2m")] +async fn upload_respects_gitignore(cx: &mut Context) -> testkit::Result { + let ft = Transfer::new(cx, "fg").await?; + ft.gitignore_filtering().await?; + ft.single_file_from_git_repo().await?; + ft.ignored_directory_requires_override().await?; + ft.sandbox.delete().await +} + +/// Downloads refuse sandbox paths that resolve outside the workspace, and +/// file names that look like command-line options download as plain files. +#[testkit::test(id = "t-9gr3oxym")] +async fn download_stays_inside_workspace(cx: &mut Context) -> testkit::Result { + let ft = Transfer::new(cx, "fs").await?; + ft.reject_workspace_escape().await?; + ft.download_dash_leading_name().await?; + ft.sandbox.delete().await +} + +/// A sandbox, a directory in its workspace, and a local scratch directory. +struct Transfer<'a> { + cx: &'a Context, + sandbox: Sandbox, + remote_root: String, + local: TempDir, +} + +impl<'a> Transfer<'a> { + async fn new(cx: &'a Context, suffix: &str) -> testkit::Result { + let sandbox = cx + .sandbox(suffix) + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + let remote_root = format!("/sandbox/file-transfer-{}-{suffix}", cx.run_id()); + let local = tempfile::tempdir() + .map_err(|error| Error::msg(format!("create a local scratch directory: {error}")))?; + let ft = Self { + cx, + sandbox, + remote_root, + local, + }; + ft.sh(&format!("mkdir -p '{}'", ft.remote_root)).await?; + Ok(ft) + } + + fn local(&self, name: &str) -> PathBuf { + self.local.path().join(name) + } + + /// Run a shell script in the sandbox and require it to succeed. + async fn sh(&self, script: &str) -> testkit::Result { + let output = self.sandbox.exec(["sh", "-c", script]).await?; + testkit::ensure!( + output.exit_code == 0, + "`{script}` exited with {}; stderr: {}", + output.exit_code, + output.stderr + ); + Ok(()) + } + + async fn upload( + &self, + source: &Path, + destination: &str, + no_git_ignore: bool, + ) -> testkit::Result { + let mut args = vec![ + "sandbox".to_string(), + "upload".to_string(), + self.sandbox.name().to_string(), + source.display().to_string(), + destination.to_string(), + ]; + if no_git_ignore { + args.push("--no-git-ignore".to_string()); + } + self.cx.cli(args).await + } + + async fn download(&self, source: &str, destination: &Path) -> testkit::Result { + self.cx + .cli([ + "sandbox", + "download", + self.sandbox.name(), + source, + &destination.display().to_string(), + ]) + .await + } + + /// Upload into existing directories, including a symlink to one and one + /// named with a trailing slash; the file must land inside each. Check the + /// destination's type, not only the exit status: tar can succeed after + /// replacing an empty directory with a file. + async fn upload_into_existing_directories(&self) -> testkit::Result { + let source = self.local("config.json"); + write(&source, "upload-destination-payload")?; + let root = format!("{}/upload destinations", self.remote_root); + self.sh(&format!( + "mkdir -p '{root}/empty' '{root}/populated' '{root}/target' '{root}/slash' && \ + printf keep > '{root}/populated/keep.txt' && \ + ln -s target '{root}/link'" + )) + .await?; + + for directory in ["empty", "populated", "link", "slash/"] { + let destination = format!("{root}/{directory}"); + self.upload(&source, &destination, true).await?.success()?; + self.sh(&format!( + "test -d '{destination}' && \ + test \"$(cat '{destination}/config.json')\" = upload-destination-payload && \ + test \"$(cat '{root}/populated/keep.txt')\" = keep && \ + test -L '{root}/link'" + )) + .await?; + } + + // A missing destination is a rename; uploading again must replace + // that file rather than treat it as a directory. + let renamed = format!("{root}/renamed.json"); + for _ in ["rename", "overwrite"] { + self.upload(&source, &renamed, true).await?.success()?; + self.sh(&format!( + "test -f '{renamed}' && test \"$(cat '{renamed}')\" = upload-destination-payload" + )) + .await?; + } + Ok(()) + } + + /// A directory with a nested file and a large binary file, and a single + /// file, survive upload then download. + async fn round_trip(&self) -> testkit::Result { + let source = self.local("roundtrip-upload"); + create_dir_all(&source.join("subdir"))?; + write(&source.join("greeting.txt"), "hello-from-local")?; + write(&source.join("subdir/nested.txt"), "nested-content")?; + let large: Vec = (0u8..=250).cycle().take(LARGE_FILE_SIZE).collect(); + fs::write(source.join("large.bin"), &large) + .map_err(|error| Error::msg(format!("write large.bin: {error}")))?; + + let remote = format!("{}/roundtrip", self.remote_root); + let uploaded = self.upload(&source, &remote, false).await?.success()?; + let warning = message(&uploaded); + testkit::ensure!( + warning.contains("outside a Git work tree") + && warning.contains(".gitignore rules are not applied"), + "upload outside a Git work tree did not warn that filtering is off: {warning}" + ); + + let destination = self.local("roundtrip-download"); + create_dir_all(&destination)?; + self.download(&format!("{remote}/roundtrip-upload"), &destination) + .await? + .success()?; + require_text(&destination.join("greeting.txt"), "hello-from-local")?; + require_text(&destination.join("subdir/nested.txt"), "nested-content")?; + let actual = fs::read(destination.join("large.bin")) + .map_err(|error| Error::msg(format!("read large.bin: {error}")))?; + testkit::ensure!( + actual == large, + "large file changed in the round trip: sent {} bytes, received {}", + large.len(), + actual.len() + ); + + let single = self.local("single.txt"); + write(&single, "single-file-payload")?; + let remote_single = format!("{}/single.txt", self.remote_root); + self.upload(&single, &remote_single, false) + .await? + .success()?; + let single_destination = self.local("single-download"); + create_dir_all(&single_destination)?; + self.download(&remote_single, &single_destination) + .await? + .success()?; + require_text( + &single_destination.join("single.txt"), + "single-file-payload", + ) + } + + async fn download_file(&self) -> testkit::Result { + let remote = format!("{}/download-file.txt", self.remote_root); + self.sh(&format!("printf greeting-payload > '{remote}'")) + .await?; + let destination = self.local("download-file"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("download-file.txt"), "greeting-payload") + } + + async fn download_directory(&self) -> testkit::Result { + let remote = format!("{}/tree", self.remote_root); + self.sh(&format!( + "mkdir -p '{remote}/sub' && printf top-level > '{remote}/root.txt' && \ + printf nested > '{remote}/sub/child.txt'" + )) + .await?; + let destination = self.local("download-directory"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("root.txt"), "top-level")?; + require_text(&destination.join("sub/child.txt"), "nested") + } + + async fn gitignore_filtering(&self) -> testkit::Result { + let repository = self.local("filter-repo"); + create_dir_all(&repository.join("build"))?; + git(&repository, &["init", "--quiet"])?; + write(&repository.join(".gitignore"), "*.log\nbuild/\n")?; + write(&repository.join("tracked.txt"), "i-am-tracked")?; + write(&repository.join("ignored.log"), "i-should-be-filtered")?; + write(&repository.join("build/output.bin"), "build-artifact")?; + git(&repository, &["add", "."])?; + + let remote = format!("{}/filtered", self.remote_root); + self.upload(&repository, &remote, false).await?.success()?; + let destination = self.local("filter-download"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + let uploaded = destination.join("filter-repo"); + require_text(&uploaded.join("tracked.txt"), "i-am-tracked")?; + require_exists(&uploaded.join(".gitignore"))?; + require_absent(&uploaded.join("ignored.log"))?; + require_absent(&uploaded.join("build")) + } + + async fn single_file_from_git_repo(&self) -> testkit::Result { + let repository = self.local("single-repo"); + create_dir_all(&repository.join("nested"))?; + git(&repository, &["init", "--quiet"])?; + write(&repository.join(".gitignore"), "*.log\n")?; + write( + &repository.join("nested/config.txt"), + "single-file-from-repo", + )?; + write(&repository.join("tracked.txt"), "should-not-upload")?; + write(&repository.join("ignored.log"), "ignored")?; + + let remote = format!("{}/single-from-repo", self.remote_root); + self.upload(&repository.join("nested/config.txt"), &remote, false) + .await? + .success()?; + let destination = self.local("single-repo-download"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("config.txt"), "single-file-from-repo")?; + require_absent(&destination.join("tracked.txt"))?; + require_absent(&destination.join("ignored.log")) + } + + async fn ignored_directory_requires_override(&self) -> testkit::Result { + let remote_seed = format!("{}/runs/test.json", self.remote_root); + self.sh(&format!( + "mkdir -p '{}/runs' && printf downloaded-payload > '{remote_seed}'", + self.remote_root + )) + .await?; + + let repository = self.local("override-repo"); + let runs = repository.join("runs"); + create_dir_all(&runs)?; + git(&repository, &["init", "--quiet"])?; + write(&repository.join(".gitignore"), "runs/\n")?; + self.download(&remote_seed, &runs).await?.success()?; + require_exists(&runs.join("test.json"))?; + + let remote = format!("{}/reuploaded", self.remote_root); + let rejected = self.upload(&runs, &remote, false).await?.failure()?; + let output = message(&rejected); + testkit::ensure!( + output.contains("filtering selected no files") && output.contains("--no-git-ignore"), + "the rejected upload did not explain the --no-git-ignore override: {output}" + ); + self.sh(&format!("test ! -e '{remote}'")).await?; + + self.upload(&runs, &remote, true).await?.success()?; + let destination = self.local("override-download"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("runs/test.json"), "downloaded-payload") + } + + async fn reject_workspace_escape(&self) -> testkit::Result { + let etc_link = format!("{}/etc-link", self.remote_root); + let passwd_link = format!("{}/passwd-link", self.remote_root); + self.sh(&format!( + "ln -s /etc '{etc_link}' && ln -s /etc/passwd '{passwd_link}'" + )) + .await?; + let destination = self.local("workspace-escape"); + create_dir_all(&destination)?; + + for source in [etc_link.clone(), passwd_link, format!("{etc_link}/passwd")] { + let rejected = self.download(&source, &destination).await?.failure()?; + let output = message(&rejected); + testkit::ensure!( + output.contains("resolves to") + && output.contains("outside the") + && output.contains("sandbox workspace"), + "download of {source:?} was not rejected as outside the workspace: {output}" + ); + } + require_absent(&destination.join("passwd"))?; + require_absent(&destination.join("etc-link")) + } + + async fn download_dash_leading_name(&self) -> testkit::Result { + let remote = format!("{}/--checkpoint-action=evil", self.remote_root); + self.sh(&format!("printf dash-payload > '{remote}'")) + .await?; + let destination = self.local("dash-leading"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text( + &destination.join("--checkpoint-action=evil"), + "dash-payload", + ) + } +} + +/// The CLI's stdout and stderr as one line of words. Error reports wrap to +/// the terminal width and draw a gutter, so match against this instead. +fn message(output: &CliOutput) -> String { + format!("{} {}", output.stdout, output.stderr) + .replace(['│', '×', '╰', '─', '╭', '├'], " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn git(repository: &Path, args: &[&str]) -> testkit::Result { + let output = std::process::Command::new("git") + .args(args) + .current_dir(repository) + .output() + .map_err(|error| Error::msg(format!("run git {}: {error}", args.join(" "))))?; + testkit::ensure!( + output.status.success(), + "git {} failed in {}: {}", + args.join(" "), + repository.display(), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + +fn write(path: &Path, contents: &str) -> testkit::Result { + fs::write(path, contents) + .map_err(|error| Error::msg(format!("write {}: {error}", path.display()))) +} + +fn create_dir_all(path: &Path) -> testkit::Result { + fs::create_dir_all(path) + .map_err(|error| Error::msg(format!("create {}: {error}", path.display()))) +} + +fn require_text(path: &Path, expected: &str) -> testkit::Result { + let actual = fs::read_to_string(path) + .map_err(|error| Error::msg(format!("read {}: {error}", path.display())))?; + testkit::ensure!( + actual == expected, + "{} contains {actual:?}; expected {expected:?}", + path.display() + ); + Ok(()) +} + +fn require_exists(path: &Path) -> testkit::Result { + testkit::ensure!(path.exists(), "{} does not exist", path.display()); + Ok(()) +} + +fn require_absent(path: &Path) -> testkit::Result { + testkit::ensure!(!path.exists(), "{} exists but should not", path.display()); + Ok(()) +} diff --git a/tests/suites/conformance/policy-advisor/Cargo.toml b/tests/suites/conformance/policy-advisor/Cargo.toml new file mode 100644 index 0000000000..8cd3f8f4bc --- /dev/null +++ b/tests/suites/conformance/policy-advisor/Cargo.toml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-conformance-policy-advisor" +description = "Conformance suite: policy-advisor" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[dependencies] +openshell-testkit = { path = "../../../testkit/testkit" } +serde_json = "1" +tokio = { version = "1", features = ["time"] } diff --git a/tests/suites/conformance/policy-advisor/tests/policy_advisor.rs b/tests/suites/conformance/policy-advisor/tests/policy_advisor.rs new file mode 100644 index 0000000000..b494add4f7 --- /dev/null +++ b/tests/suites/conformance/policy-advisor/tests/policy_advisor.rs @@ -0,0 +1,411 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Conformance suite: policy-advisor. +//! +//! The sandbox's `policy.local` API and the draft policy a denied connection +//! produces. Requests to `policy.local` run inside the sandbox; settings and +//! drafts go through the gateway's gRPC API. + +use std::time::{Duration, Instant}; + +use serde_json::Value; +use testkit::sdk::raw::proto; +use testkit::{Context, Error, Sandbox}; + +const READY_TIMEOUT: Duration = Duration::from_mins(4); +const PROPOSAL_TIMEOUT: Duration = Duration::from_secs(90); +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const PROPOSALS_ENABLED: &str = "agent_policy_proposals_enabled"; + +/// With proposals enabled, `policy.local` serves the current policy and +/// recent denials, accepts a proposed rule, reports its state, and the rule +/// reaches the reviewer's draft policy. +#[testkit::test(id = "t-p2jo0tvz")] +async fn policy_local_accepts_proposals(cx: &mut Context) -> testkit::Result { + let sandbox = create(cx, "pl", None).await?; + enable_proposals(&sandbox).await?; + let binary = bash_path(&sandbox).await?; + + // The current-policy route is local; proposal lookup also needs the + // supervisor's session with the gateway, so wait for both. + let started = Instant::now(); + let readiness = format!("/v1/proposals/test-{}-readiness", cx.run_id()); + loop { + let ready = async { + let current = policy_local(&sandbox, "GET", "/v1/policy/current", "", 200).await?; + testkit::ensure!( + current["format"] == "yaml" + && current["policy_yaml"] + .as_str() + .is_some_and(|yaml| yaml.contains("version: 1")), + "policy.local returned an invalid current policy: {current}" + ); + let lookup = policy_local(&sandbox, "GET", &readiness, "", 404).await?; + testkit::ensure!( + lookup["error"] == "chunk_not_found", + "policy.local proposal lookup returned {lookup}" + ); + Ok(()) + } + .await; + match ready { + Ok(()) => break, + Err(error) if started.elapsed() >= READY_TIMEOUT => return Err(error), + Err(_) => tokio::time::sleep(POLL_INTERVAL).await, + } + } + + let denials = policy_local(&sandbox, "GET", "/v1/denials?last=1", "", 200).await?; + testkit::ensure!( + denials["denials"].is_array() && denials["log_available"].is_boolean(), + "policy.local returned an invalid denials response: {denials}" + ); + + let rule_name = format!("conformance_local_{}", cx.run_id()); + let payload = serde_json::json!({ + "intent_summary": "Allow Bash to read the conformance path on example.invalid.", + "operations": [{ + "addRule": { + "ruleName": &rule_name, + "rule": { + "name": &rule_name, + "endpoints": [{ + "host": "example.invalid", + "port": 443, + "protocol": "rest", + "enforcement": "enforce", + "rules": [{"allow": {"method": "GET", "path": "/conformance"}}] + }], + "binaries": [{"path": &binary}] + } + } + }] + }) + .to_string(); + let submitted = policy_local(&sandbox, "POST", "/v1/proposals", &payload, 202).await?; + let chunk_id = submitted["accepted_chunk_ids"] + .as_array() + .filter(|ids| ids.len() == 1) + .and_then(|ids| ids[0].as_str()) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + Error::msg(format!( + "policy.local did not accept one proposal: {submitted}" + )) + })? + .to_string(); + testkit::ensure!( + submitted["status"] == "submitted" + && submitted["accepted_chunks"] == 1 + && submitted["rejected_chunks"] == 0, + "policy.local did not submit one rule: {submitted}" + ); + + let state = policy_local( + &sandbox, + "GET", + &format!("/v1/proposals/{chunk_id}"), + "", + 200, + ) + .await?; + testkit::ensure!( + state["chunk_id"] == chunk_id.as_str() + && state["rule_name"] == rule_name.as_str() + && state["binary"] == binary.as_str() + && matches!(state["status"].as_str(), Some("pending" | "approved")), + "policy.local returned the wrong proposal: {state}" + ); + + let draft = sandbox.draft_policy().await?; + testkit::ensure!( + draft + .chunks + .iter() + .any(|chunk| chunk.id == chunk_id && chunk.rule_name == rule_name), + "the submitted rule {rule_name} ({chunk_id}) is not in the draft policy: {:?}", + draft + .chunks + .iter() + .map(|chunk| (&chunk.id, &chunk.rule_name)) + .collect::>() + ); + Ok(()) +} + +/// In a sandbox with no network rules, a denied connection to an IP address +/// produces one mechanistic L4 draft rule for that endpoint and binary. +#[testkit::test(id = "t-gpw7sqdx")] +async fn denied_address_produces_draft_rule(cx: &mut Context) -> testkit::Result { + let sandbox = create(cx, "mp", Some(no_network_policy())).await?; + enable_proposals(&sandbox).await?; + + let config = sandbox.config().await?; + let rules = config + .policy + .as_ref() + .map(|policy| policy.network_policies.len()) + .unwrap_or_default(); + testkit::ensure!( + rules == 0, + "the effective policy has {rules} network rules; expected none" + ); + + let probe = sandbox + .exec([ + "bash", + "-c", + "printf 'BINARY=%s\\n' \"$(readlink -f /proc/$$/exe)\"; \ + if exec 3<>/dev/tcp/1.1.1.1/443; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi", + ]) + .await?; + testkit::ensure!( + probe.exit_code == 0 && probe.stdout.lines().any(|line| line == "DENIED"), + "the connection to 1.1.1.1:443 was not denied: stdout {:?}, stderr {:?}", + probe.stdout, + probe.stderr + ); + let binary = probe + .stdout + .lines() + .find_map(|line| line.strip_prefix("BINARY=")) + .filter(|binary| is_bash_path(binary)) + .ok_or_else(|| Error::msg(format!("no Bash path in probe output: {:?}", probe.stdout)))? + .to_string(); + + expect_draft( + &sandbox, + "allow_1_1_1_1_443", + ("1.1.1.1", 443), + &binary, + &probe.stderr, + ) + .await +} + +/// In a sandbox with no network rules, a denied connection to a new hostname +/// stays denied and produces one mechanistic L4 draft rule for it. +#[testkit::test(id = "t-mchrkrfm")] +async fn denied_hostname_produces_draft_rule(cx: &mut Context) -> testkit::Result { + let sandbox = create(cx, "nh", Some(no_network_policy())).await?; + let binary = bash_path(&sandbox).await?; + + let probe = sandbox + .exec([ + "bash", + "-c", + "if exec 3<>/dev/tcp/pypi.org/80; then echo UNEXPECTED_ALLOWED; exit 1; else echo DENIED; fi", + ]) + .await?; + testkit::ensure!( + probe.exit_code == 0 && probe.stdout.lines().any(|line| line == "DENIED"), + "the connection to pypi.org:80 was not denied: stdout {:?}, stderr {:?}", + probe.stdout, + probe.stderr + ); + + expect_draft( + &sandbox, + "allow_pypi_org_80", + ("pypi.org", 80), + &binary, + &probe.stderr, + ) + .await +} + +async fn create( + cx: &Context, + suffix: &str, + policy: Option, +) -> testkit::Result { + let mut builder = cx + .sandbox(suffix) + .command(["sh", "-c", "exec sleep infinity"]); + if let Some(policy) = policy { + builder = builder.policy(policy); + } + let sandbox = builder.create().await?; + sandbox.wait_ready().await?; + Ok(sandbox) +} + +/// Filesystem access the tests need, and no network rules. +fn no_network_policy() -> proto::SandboxPolicy { + let paths = |paths: &[&str]| paths.iter().map(ToString::to_string).collect(); + proto::SandboxPolicy { + version: 1, + filesystem: Some(proto::FilesystemPolicy { + include_workdir: true, + read_only: paths(&[ + "/usr", + "/bin", + "/lib", + "/lib64", + "/proc", + "/dev/urandom", + "/app", + "/etc", + "/var/log", + ]), + read_write: paths(&["/sandbox", "/tmp", "/dev/null"]), + }), + landlock: Some(proto::LandlockPolicy { + compatibility: "best_effort".to_string(), + }), + ..proto::SandboxPolicy::default() + } +} + +/// Enable proposals for the sandbox and check the effective value, which a +/// global override could change. +async fn enable_proposals(sandbox: &Sandbox) -> testkit::Result { + sandbox + .set_setting( + PROPOSALS_ENABLED, + proto::setting_value::Value::BoolValue(true), + ) + .await?; + let config = sandbox.config().await?; + let value = config + .settings + .get(PROPOSALS_ENABLED) + .and_then(|setting| setting.value.as_ref()) + .and_then(|value| value.value.clone()); + testkit::ensure!( + value == Some(proto::setting_value::Value::BoolValue(true)), + "effective {PROPOSALS_ENABLED} is {value:?}; check for a global override" + ); + Ok(()) +} + +async fn bash_path(sandbox: &Sandbox) -> testkit::Result { + let output = sandbox + .exec([ + "bash", + "-c", + "printf '%s\\n' \"$(readlink -f /proc/$$/exe)\"", + ]) + .await?; + let path = output.stdout.trim(); + testkit::ensure!( + output.exit_code == 0 && is_bash_path(path), + "expected one absolute Bash path; got {:?}, stderr {:?}", + output.stdout, + output.stderr + ); + Ok(path.to_string()) +} + +fn is_bash_path(path: &str) -> bool { + path.starts_with('/') && !path.contains('\n') && path.rsplit('/').next() == Some("bash") +} + +/// Send an HTTP request to `policy.local` from inside the sandbox and parse +/// the JSON body, requiring `expected_status`. +async fn policy_local( + sandbox: &Sandbox, + method: &str, + path: &str, + body: &str, + expected_status: u16, +) -> testkit::Result { + const SCRIPT: &str = "method=$1; path=$2; body=$3; exec 3<>/dev/tcp/policy.local/80 || exit 1; \ + printf '%s %s HTTP/1.1\\r\\nHost: policy.local\\r\\nContent-Type: application/json\\r\\nContent-Length: %s\\r\\nConnection: close\\r\\n\\r\\n' \"$method\" \"$path\" \"${#body}\" >&3; \ + printf '%s' \"$body\" >&3; cat <&3"; + let output = sandbox + .exec([ + "bash", + "-c", + SCRIPT, + "policy-local-http", + method, + path, + body, + ]) + .await?; + testkit::ensure!( + output.exit_code == 0, + "{method} http://policy.local{path} exited {}; stderr: {}", + output.exit_code, + output.stderr + ); + let (headers, json) = output.stdout.split_once("\r\n\r\n").ok_or_else(|| { + Error::msg(format!( + "{method} http://policy.local{path} returned no complete HTTP response: {:?}", + output.stdout + )) + })?; + testkit::ensure!( + headers.starts_with(&format!("HTTP/1.1 {expected_status} ")) + && headers.lines().any(|line| { + line.to_ascii_lowercase() + .starts_with("content-type: application/json") + }), + "{method} http://policy.local{path} did not return {expected_status} with JSON: {headers}" + ); + serde_json::from_str(json).map_err(|error| { + Error::msg(format!( + "{method} http://policy.local{path} returned invalid JSON ({error}): {json}" + )) + }) +} + +/// Wait for the draft policy to contain a chunk, then require exactly one: +/// pending or approved, for `rule`, scoped to `binary` and an L4 `endpoint`. +async fn expect_draft( + sandbox: &Sandbox, + rule: &str, + endpoint: (&str, u32), + binary: &str, + probe_stderr: &str, +) -> testkit::Result { + let started = Instant::now(); + let chunks = loop { + match sandbox.draft_policy().await { + Ok(draft) if !draft.chunks.is_empty() => break draft.chunks, + Ok(_) | Err(_) if started.elapsed() < PROPOSAL_TIMEOUT => { + tokio::time::sleep(POLL_INTERVAL).await; + } + Ok(_) => { + return Err(Error::msg(format!( + "no draft rule for {}:{} after {PROPOSAL_TIMEOUT:?}; probe stderr: {probe_stderr}", + endpoint.0, endpoint.1 + ))); + } + Err(error) => return Err(error), + } + }; + + testkit::ensure!( + chunks.len() == 1, + "expected one draft rule; got {chunks:#?}" + ); + let chunk = &chunks[0]; + let proposed = chunk.proposed_rule.as_ref(); + let endpoints: Vec<(&str, u32, &str)> = proposed + .map(|rule| { + rule.endpoints + .iter() + .map(|e| (e.host.as_str(), e.port, e.protocol.as_str())) + .collect() + }) + .unwrap_or_default(); + let binaries: Vec<&str> = proposed + .map(|rule| rule.binaries.iter().map(|b| b.path.as_str()).collect()) + .unwrap_or_default(); + testkit::ensure!( + matches!(chunk.status.as_str(), "pending" | "approved") + && chunk.rule_name == rule + && chunk.binary == binary + && binaries == [binary] + // An empty protocol is an L4 rule. + && endpoints == [(endpoint.0, endpoint.1, "")] + && chunk.rationale.contains(&format!("{}:{}", endpoint.0, endpoint.1)), + "expected one pending or approved L4 draft rule {rule} for {binary} and {}:{}; got {chunk:#?}", + endpoint.0, + endpoint.1 + ); + Ok(()) +} diff --git a/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs b/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs index 150ee4581e..cd7468eb28 100644 --- a/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs +++ b/tests/suites/conformance/sandbox-lifecycle/tests/sandbox_lifecycle.rs @@ -78,3 +78,136 @@ async fn run_to_completion_exit_delete(cx: &mut Context) -> testkit::Result { sandbox.delete().await?; cx.expect_absent(&sandbox).await } + +/// A sandbox created with the gateway's default command becomes Ready, is +/// listed as Ready, runs a command with exec, and is gone after delete. +#[testkit::test(id = "t-cum9h58h")] +async fn default_command_sandbox_is_listed_ready(cx: &mut Context) -> testkit::Result { + let sandbox = cx.sandbox("dc").create().await?; + let ready = sandbox.wait_ready().await?; + testkit::ensure!( + ready.name == sandbox.name(), + "get returned sandbox {:?}; expected {:?}", + ready.name, + sandbox.name() + ); + cx.expect_listed(&sandbox, SandboxPhase::Ready).await?; + + let marker = format!("openshell-conformance-{}", cx.run_id()); + let output = sandbox.exec(["echo", marker.as_str()]).await?; + testkit::ensure!( + output.exit_code == 0 && output.stdout == format!("{marker}\n"), + "exec exited {} with stdout {:?}; stderr: {}", + output.exit_code, + output.stdout, + output.stderr + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} + +/// A stopped sandbox rejects exec, accepts a second stop, and returns to +/// Ready when started. +#[testkit::test(id = "t-aco01yxf")] +async fn restart_stopped_sandbox(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("rs") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + + sandbox.stop().await?; + match sandbox.exec(["true"]).await { + Err(_) => {} + Ok(output) => testkit::ensure!( + output.exit_code != 0, + "exec succeeded while the sandbox was stopped" + ), + } + // Stop is idempotent. + let again = sandbox.stop().await?; + testkit::ensure!( + again.phase == SandboxPhase::Stopped, + "a second stop left the sandbox {:?}", + again.phase + ); + + sandbox.start().await?; + let output = sandbox.exec(["echo", "started"]).await?; + testkit::ensure!( + output.exit_code == 0 && output.stdout == "started\n", + "exec after start exited {} with stdout {:?}; stderr: {}", + output.exit_code, + output.stdout, + output.stderr + ); + Ok(()) +} + +/// Files in the workspace survive stop and start, and start runs the main +/// command again. +#[testkit::test(id = "t-lyu3wcxl", requires = [persistent_workspace])] +async fn stopped_sandbox_keeps_workspace(cx: &mut Context) -> testkit::Result { + const SENTINEL: &str = "/sandbox/.openshell-stop-start-sentinel"; + const RUN_COUNT: &str = "/sandbox/.openshell-main-run-count"; + let main = format!( + "count=0; test ! -f '{RUN_COUNT}' || count=$(cat '{RUN_COUNT}'); \ + count=$((count + 1)); printf '%s\\n' \"$count\" > '{RUN_COUNT}'; \ + exec sleep infinity" + ); + let sandbox = cx + .sandbox("kw") + .command(["sh", "-c", main.as_str()]) + .create() + .await?; + sandbox.wait_ready().await?; + + let sentinel = format!("openshell-stop-start-{}", cx.run_id()); + let write = sandbox + .exec([ + "sh", + "-c", + &format!("printf '%s\\n' '{sentinel}' > '{SENTINEL}' && sync"), + ]) + .await?; + testkit::ensure!( + write.exit_code == 0, + "writing the sentinel exited {}; stderr: {}", + write.exit_code, + write.stderr + ); + + sandbox.stop().await?; + sandbox.start().await?; + + let read = sandbox.exec(["cat", SENTINEL]).await?; + testkit::ensure!( + read.stdout == format!("{sentinel}\n"), + "sentinel after restart is {:?}; stderr: {}", + read.stdout, + read.stderr + ); + let runs = sandbox.exec(["cat", RUN_COUNT]).await?; + testkit::ensure!( + runs.stdout == "2\n", + "main command ran {:?} times; expected 2", + runs.stdout.trim() + ); + Ok(()) +} + +/// A stopped sandbox can be deleted, and is then gone from get and list. +#[testkit::test(id = "t-n63qv7x9")] +async fn stopped_sandbox_can_be_deleted(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("sd") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + sandbox.stop().await?; + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index 821ef20857..ad5dff7cb7 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -1184,6 +1184,7 @@ dependencies = [ "openshell-testkit-macros", "rand 0.9.4", "serde", + "serde_json", "tokio", "tonic", ] diff --git a/tests/testkit/testkit/Cargo.toml b/tests/testkit/testkit/Cargo.toml index 465a49057f..7e1f4a2210 100644 --- a/tests/testkit/testkit/Cargo.toml +++ b/tests/testkit/testkit/Cargo.toml @@ -20,6 +20,7 @@ openshell-testkit-macros = { path = "../testkit-macros" } futures = "0.3" rand = "0.9" serde = { version = "1", features = ["derive"] } +serde_json = "1" serde_yml = { package = "noyalib", version = "0.0.53", default-features = false, features = ["std", "compat-serde-yaml"] } -tokio = { version = "1.43", features = ["macros", "rt", "time"] } +tokio = { version = "1.43", features = ["macros", "process", "rt", "time"] } tonic = { version = "0.14", features = ["tls-native-roots"] } diff --git a/tests/testkit/testkit/src/bin/testkit-ready.rs b/tests/testkit/testkit/src/bin/testkit-ready.rs index a7c4b94a16..5d4e8aea1f 100644 --- a/tests/testkit/testkit/src/bin/testkit-ready.rs +++ b/tests/testkit/testkit/src/bin/testkit-ready.rs @@ -6,7 +6,7 @@ //! Usage: `testkit-ready [TIMEOUT_SECS]` (default 60), with the target file //! named by `OPENSHELL_TEST_TARGET`. Run it once per environment before the //! test binaries; exits non-zero with the last error if the gateway is not -//! healthy in time. +//! healthy in time, or if the environment's CLI does not reach it. use std::process::ExitCode; use std::time::Duration; @@ -25,13 +25,19 @@ async fn main() -> ExitCode { return ExitCode::from(2); } }; - let result = match Target::from_env() { - Ok(target) => target.wait_ready(Duration::from_secs(timeout)).await, - Err(error) => Err(error), - }; + let result = async { + let target = Target::from_env()?; + let health = target.wait_ready(Duration::from_secs(timeout)).await?; + target.check_cli().await?; + testkit::Result::Ok(health) + } + .await; match result { Ok(health) => { - eprintln!("testkit-ready: gateway {} is healthy", health.version); + eprintln!( + "testkit-ready: gateway {} is healthy and the CLI reaches it", + health.version + ); ExitCode::SUCCESS } Err(error) => { diff --git a/tests/testkit/testkit/src/cli.rs b/tests/testkit/testkit/src/cli.rs new file mode 100644 index 0000000000..10f6c9168c --- /dev/null +++ b/tests/testkit/testkit/src/cli.rs @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Driving the `openshell` CLI the environment installed. +//! +//! Some promised behavior exists only in the CLI: file upload and download, +//! interactive connect, and port forwarding. Tests act through the CLI for +//! those and observe through the SDK where they can. Rely on the CLI's exit +//! code and structured output (`--output json`), not on its table text. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use serde::de::DeserializeOwned; + +use crate::error::{Error, Result, ResultExt}; +use crate::target::CliTarget; + +/// How long one CLI invocation may take. +pub const CLI_TIMEOUT: Duration = Duration::from_mins(5); + +/// Runs the target's CLI against the target's gateway. +#[derive(Clone, Debug)] +pub struct Cli { + binary: PathBuf, + gateway: Option, +} + +/// The buffered result of one CLI invocation. +#[derive(Debug)] +pub struct CliOutput { + pub command: String, + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +impl Cli { + pub(crate) fn new(target: &CliTarget, binary: PathBuf) -> Self { + Self { + binary, + gateway: target.gateway.clone(), + } + } + + /// Run the CLI with `args` and buffer its output. A non-zero exit is + /// returned, not raised; use [`CliOutput::success`] to require success. + pub async fn run(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.run_in(None, args).await + } + + /// Like [`Cli::run`], with `dir` as the working directory. + pub async fn run_in(&self, dir: Option<&Path>, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let args: Vec<_> = args + .into_iter() + .map(|arg| arg.as_ref().to_os_string()) + .collect(); + let command = std::iter::once(self.binary.as_os_str()) + .chain(args.iter().map(std::ffi::OsString::as_os_str)) + .map(|part| part.to_string_lossy()) + .collect::>() + .join(" "); + + let mut process = tokio::process::Command::new(&self.binary); + process + .args(&args) + .env("NO_COLOR", "1") + // Select the target's gateway, never one from the caller's + // environment. + .env_remove("OPENSHELL_GATEWAY_ENDPOINT") + .stdin(Stdio::null()) + .kill_on_drop(true); + match &self.gateway { + Some(gateway) => process.env("OPENSHELL_GATEWAY", gateway), + None => process.env_remove("OPENSHELL_GATEWAY"), + }; + if let Some(dir) = dir { + process.current_dir(dir); + } + + let output = tokio::time::timeout(CLI_TIMEOUT, process.output()) + .await + .map_err(|_| Error::msg(format!("timed out after {CLI_TIMEOUT:?}"))) + .context(format!("run {command}"))? + .map_err(|error| Error::from_error(&error)) + .context(format!("run {command}"))?; + Ok(CliOutput { + command, + // A signal-terminated CLI has no code; report it as a failure. + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } +} + +impl CliOutput { + /// Fail unless the command exited 0. + pub fn success(self) -> Result { + crate::ensure!( + self.exit_code == 0, + "`{}` exited with {}; stderr: {}", + self.command, + self.exit_code, + self.stderr.trim() + ); + Ok(self) + } + + /// Fail unless the command exited non-zero, e.g. when a request must be + /// rejected. + pub fn failure(self) -> Result { + crate::ensure!( + self.exit_code != 0, + "`{}` succeeded but was expected to fail; stdout: {}", + self.command, + self.stdout.trim() + ); + Ok(self) + } + + /// Parse stdout as JSON, from a command run with `--output json`. + pub fn json(&self) -> Result { + serde_json::from_str(&self.stdout) + .map_err(|error| Error::from_error(&error)) + .context(format!("parse JSON output of `{}`", self.command)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cli(binary: &str) -> Cli { + Cli { + binary: PathBuf::from(binary), + gateway: Some("tmachine".to_string()), + } + } + + #[tokio::test] + async fn runs_with_the_target_gateway_selected() { + let output = cli("sh") + .run(["-c", "echo \"$OPENSHELL_GATEWAY\"; exit 3"]) + .await + .unwrap(); + assert_eq!(output.stdout, "tmachine\n"); + assert_eq!(output.exit_code, 3); + let err = output.success().unwrap_err(); + assert!(err.to_string().contains("exited with 3"), "{err}"); + } + + #[tokio::test] + async fn parses_json_output() { + let output = cli("sh") + .run(["-c", "echo '{\"status\": \"connected\"}'"]) + .await + .unwrap() + .success() + .unwrap(); + let value: serde_json::Value = output.json().unwrap(); + assert_eq!(value["status"], "connected"); + } + + #[tokio::test] + async fn reports_a_missing_binary() { + let err = cli("/nonexistent/openshell") + .run(["status"]) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("run /nonexistent/openshell status"), + "{err}" + ); + } +} diff --git a/tests/testkit/testkit/src/context.rs b/tests/testkit/testkit/src/context.rs index 5e17d34913..f6a0404bcc 100644 --- a/tests/testkit/testkit/src/context.rs +++ b/tests/testkit/testkit/src/context.rs @@ -7,12 +7,14 @@ use std::collections::BTreeSet; use std::sync::{Arc, Mutex, PoisonError}; use std::time::{Duration, Instant}; +use openshell_sdk::raw::{AuthedGrpcClient, proto}; use openshell_sdk::{ - DeleteOptions, ExecOptions, ListOptions, SandboxPhase, SandboxRef, SandboxRestartPolicy, - SandboxSpec, SdkError, WorkspaceScopedClient, + DeleteOptions, ExecOptions, ListOptions, OpenShellClient, SandboxPhase, SandboxRef, + SandboxRestartPolicy, SandboxSpec, SdkError, WorkspaceScopedClient, }; use rand::Rng; +use crate::cli::{Cli, CliOutput}; use crate::error::{Error, Result, ResultExt}; use crate::poll::{Poll, poll_until}; @@ -20,6 +22,8 @@ use crate::poll::{Poll, poll_until}; pub const READY_TIMEOUT: Duration = Duration::from_mins(10); pub const DELETE_TIMEOUT: Duration = Duration::from_mins(2); pub const EXEC_TIMEOUT: Duration = Duration::from_mins(2); +/// Stopping includes stopping compute, which can take a while on some drivers. +pub const STOP_TIMEOUT: Duration = Duration::from_mins(5); const CLEANUP_TIMEOUT: Duration = Duration::from_mins(2); const STATUS_INTERVAL: Duration = Duration::from_secs(1); @@ -29,19 +33,51 @@ type Registry = Arc>>; /// What a test uses to act on the system under test. pub struct Context { client: WorkspaceScopedClient, + grpc: Grpc, + cli: Result, run_id: String, sandboxes: Registry, } impl Context { - pub(crate) fn new(client: WorkspaceScopedClient) -> Self { + pub(crate) fn new(client: &OpenShellClient, workspace: &str, cli: Result) -> Self { Self { - client, + client: client.workspace(workspace), + grpc: Grpc { + client: client.clone(), + workspace: workspace.to_string(), + }, + cli, run_id: generate_run_id(), sandboxes: Registry::default(), } } + /// Run the environment's `openshell` CLI against the target's gateway. + /// A non-zero exit is returned, not raised. + pub async fn cli(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.cli_driver()?.run(args).await + } + + /// Like [`Context::cli`], with `dir` as the working directory. + pub async fn cli_in(&self, dir: &std::path::Path, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.cli_driver()?.run_in(Some(dir), args).await + } + + fn cli_driver(&self) -> Result<&Cli> { + self.cli + .as_ref() + .map_err(|error| Error::msg(error.to_string())) + } + /// The ID unique to this test run, used in resource names. pub fn run_id(&self) -> &str { &self.run_id @@ -61,6 +97,8 @@ impl Context { pub fn sandbox(&self, suffix: &str) -> SandboxBuilder { SandboxBuilder { client: self.client.clone(), + grpc: self.grpc.clone(), + policy: None, sandboxes: Arc::clone(&self.sandboxes), spec: SandboxSpec { name: Some(self.name(suffix)), @@ -94,6 +132,25 @@ impl Context { Ok(()) } + /// Fail unless `list` shows the sandbox in `phase`. + pub async fn expect_listed(&self, sandbox: &Sandbox, phase: SandboxPhase) -> Result { + let name = sandbox.name(); + let listed = self + .client + .list_all_sandboxes(ListOptions::default()) + .await + .context("list sandboxes")?; + let entry = listed.iter().find(|entry| entry.name == name); + crate::ensure!(entry.is_some(), "sandbox {name} is not listed"); + let entry = entry.expect("checked above"); + crate::ensure!( + entry.phase == phase, + "sandbox {name} is listed in phase {:?}; expected {phase:?}", + entry.phase + ); + Ok(()) + } + /// Delete every sandbox the test created and has not deleted. Returns the /// cleanup failures; they are reported separately from the test result. pub(crate) async fn cleanup(&self) -> Vec { @@ -125,9 +182,28 @@ impl Context { } } +/// The generated gRPC client, for public API the SDK does not wrap yet. +#[derive(Clone)] +struct Grpc { + client: OpenShellClient, + workspace: String, +} + +impl Grpc { + async fn client(&self) -> Result { + Ok(self.client.raw_grpc_fresh().await?) + } + + fn scope(&self) -> Option { + Some(proto::workspace_selector(&self.workspace)) + } +} + /// A sandbox to create. pub struct SandboxBuilder { client: WorkspaceScopedClient, + grpc: Grpc, + policy: Option, sandboxes: Registry, spec: SandboxSpec, } @@ -150,6 +226,13 @@ impl SandboxBuilder { self } + /// Create the sandbox with this policy instead of the gateway's default. + #[must_use] + pub fn policy(mut self, policy: proto::SandboxPolicy) -> Self { + self.policy = Some(policy); + self + } + /// Full access to the spec for fields without a builder method. #[must_use] pub fn spec(mut self, update: impl FnOnce(&mut SandboxSpec)) -> Self { @@ -163,23 +246,72 @@ impl SandboxBuilder { pub async fn create(self) -> Result { let name = self.spec.name.clone().unwrap_or_default(); lock(&self.sandboxes).insert(name.clone()); - let created = self - .client - .create_sandbox(self.spec) - .await - .context(format!("create sandbox {name}"))?; + let id = match self.policy { + None => { + self.client + .create_sandbox(self.spec) + .await + .context(format!("create sandbox {name}"))? + .id + } + // The SDK's spec has no policy field, so create through gRPC. + Some(policy) => { + crate::ensure!( + self.spec.image.is_none() + && self.spec.environment.is_empty() + && self.spec.providers.is_empty() + && !self.spec.gpu + && self.spec.service_exposures.is_empty(), + "testkit creates a sandbox with a policy from its name, command, restart policy, and policy only" + ); + let request = proto::CreateSandboxRequest { + name: name.clone(), + workspace_scope: self.grpc.scope(), + spec: Some(proto::SandboxSpec { + command: self.spec.command, + tty: self.spec.tty, + policy: Some(policy), + restart_policy: restart_policy(self.spec.restart_policy) as i32, + ..proto::SandboxSpec::default() + }), + ..proto::CreateSandboxRequest::default() + }; + self.grpc + .client() + .await? + .create_sandbox(request) + .await + .map_err(|status| Error::from_error(&status)) + .context(format!("create sandbox {name}"))?; + self.client + .get_sandbox(&name) + .await + .context(format!("get sandbox {name}"))? + .id + } + }; Ok(Sandbox { client: self.client, + grpc: self.grpc, sandboxes: self.sandboxes, name, - id: created.id, + id, }) } } +const fn restart_policy(policy: SandboxRestartPolicy) -> proto::SandboxRestartPolicy { + match policy { + SandboxRestartPolicy::Never => proto::SandboxRestartPolicy::Never, + SandboxRestartPolicy::OnFailure => proto::SandboxRestartPolicy::OnFailure, + SandboxRestartPolicy::Always => proto::SandboxRestartPolicy::Always, + } +} + /// A sandbox created by the test. pub struct Sandbox { client: WorkspaceScopedClient, + grpc: Grpc, sandboxes: Registry, name: String, id: String, @@ -238,6 +370,96 @@ impl Sandbox { .await } + /// Stop the sandbox and wait until it is Stopped. + pub async fn stop(&self) -> Result { + self.client + .stop_sandbox(&self.name) + .await + .context(format!("stop sandbox {}", self.name))?; + self.wait_phase(SandboxPhase::Stopped, STOP_TIMEOUT).await + } + + /// Start the sandbox and wait until it is Ready again. + pub async fn start(&self) -> Result { + self.client + .start_sandbox(&self.name) + .await + .context(format!("start sandbox {}", self.name))?; + self.wait_ready().await + } + + /// Wait until the sandbox reaches `phase`. + pub async fn wait_phase(&self, phase: SandboxPhase, timeout: Duration) -> Result { + poll_until( + &format!("sandbox {} to be {phase:?}", self.name), + timeout, + STATUS_INTERVAL, + async || { + let snapshot = self.get().await?; + Ok(if snapshot.phase == phase { + Poll::Ready(snapshot) + } else { + Poll::Pending(format!("phase {:?}", snapshot.phase)) + }) + }, + ) + .await + } + + /// Set a sandbox-scoped setting. + pub async fn set_setting(&self, key: &str, value: proto::setting_value::Value) -> Result { + let request = proto::UpdateConfigRequest { + sandbox: self.name.clone(), + workspace_scope: self.grpc.scope(), + setting_key: key.to_string(), + setting_value: Some(proto::SettingValue { value: Some(value) }), + ..proto::UpdateConfigRequest::default() + }; + self.grpc + .client() + .await? + .update_config(request) + .await + .map_err(|status| Error::from_error(&status)) + .context(format!("set {key} on sandbox {}", self.name))?; + Ok(()) + } + + /// The sandbox's effective policy and settings. + pub async fn config(&self) -> Result { + let request = proto::GetSandboxConfigRequest { + name: self.name.clone(), + workspace_scope: self.grpc.scope(), + }; + Ok(self + .grpc + .client() + .await? + .get_sandbox_config(request) + .await + .map_err(|status| Error::from_error(&status)) + .context(format!("get config of sandbox {}", self.name))? + .into_inner()) + } + + /// The sandbox's draft policy chunks, all statuses. + pub async fn draft_policy(&self) -> Result { + let request = proto::GetDraftPolicyRequest { + sandbox: self.name.clone(), + workspace_scope: self.grpc.scope(), + status_filter: String::new(), + }; + Ok(self + .grpc + .client() + .await? + .get_draft_policy(request) + .await + .map_err(|status| Error::from_error(&status)) + .context(format!("get draft policy of sandbox {}", self.name))? + .into_inner()) + } + /// The sandbox's current state. pub async fn get(&self) -> Result { self.client diff --git a/tests/testkit/testkit/src/lib.rs b/tests/testkit/testkit/src/lib.rs index 51068178c9..b54e0cf76a 100644 --- a/tests/testkit/testkit/src/lib.rs +++ b/tests/testkit/testkit/src/lib.rs @@ -23,14 +23,17 @@ //! Tests read the system under test from the target file named by //! `OPENSHELL_TEST_TARGET` (see [`target`]). +mod cli; mod context; mod error; mod poll; mod run; pub mod target; +pub use cli::{CLI_TIMEOUT, Cli, CliOutput}; pub use context::{ - Context, DELETE_TIMEOUT, EXEC_TIMEOUT, ExecOutput, READY_TIMEOUT, Sandbox, SandboxBuilder, + Context, DELETE_TIMEOUT, EXEC_TIMEOUT, ExecOutput, READY_TIMEOUT, STOP_TIMEOUT, Sandbox, + SandboxBuilder, }; pub use error::{Error, Result, ResultExt}; pub use openshell_sdk as sdk; diff --git a/tests/testkit/testkit/src/run.rs b/tests/testkit/testkit/src/run.rs index e98104e7e1..d44acb806e 100644 --- a/tests/testkit/testkit/src/run.rs +++ b/tests/testkit/testkit/src/run.rs @@ -51,7 +51,7 @@ where .connect() .await .unwrap_or_else(|error| panic!("[{name}] {error}")); - let mut cx = Context::new(client.workspace(&target.workspace)); + let mut cx = Context::new(&client, &target.workspace, target.cli()); eprintln!("[{name}] run id {}", cx.run_id()); let outcome = AssertUnwindSafe(body(&mut cx)).catch_unwind().await; diff --git a/tests/testkit/testkit/src/target.rs b/tests/testkit/testkit/src/target.rs index 12c82ebb87..f783d8f117 100644 --- a/tests/testkit/testkit/src/target.rs +++ b/tests/testkit/testkit/src/target.rs @@ -13,6 +13,9 @@ //! ca: ca.crt //! client_cert: client.crt //! client_key: client.key +//! cli: +//! binary: /usr/bin/openshell # default: `openshell` on PATH +//! gateway: tmachine # the name the gateway is registered under //! workspace: default //! capabilities: //! compute_driver: docker @@ -28,6 +31,7 @@ use openshell_sdk::{ use serde::Deserialize; use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity}; +use crate::cli::Cli; use crate::error::{Error, Result, ResultExt}; /// Environment variable naming the target file. @@ -37,6 +41,11 @@ pub const TARGET_ENV: &str = "OPENSHELL_TEST_TARGET"; #[serde(deny_unknown_fields)] pub struct Target { pub gateway: Gateway, + /// The CLI the environment installed. Every environment provides one; + /// it is optional here only so SDK-only suites can run against a + /// hand-written target. + #[serde(default)] + pub cli: Option, #[serde(default = "default_workspace")] pub workspace: String, #[serde(default)] @@ -65,6 +74,22 @@ pub struct Tls { pub client_key: Option, } +/// The installed `openshell` CLI, configured for the target's gateway. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliTarget { + /// A path, resolved like other target paths, or a name found on PATH. + #[serde(default = "default_cli_binary")] + pub binary: PathBuf, + /// The gateway name to select. Omit to use the CLI's active gateway. + #[serde(default)] + pub gateway: Option, +} + +fn default_cli_binary() -> PathBuf { + PathBuf::from("openshell") +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Auth { @@ -83,6 +108,10 @@ pub struct Capabilities { pub gpu: bool, #[serde(default)] pub service_exposure: bool, + /// Files in the sandbox workspace survive stop and start. The docs leave + /// this to the compute driver. + #[serde(default)] + pub persistent_workspace: bool, } /// A capability a test can require with `#[testkit::test(requires = [...])]`. @@ -92,6 +121,7 @@ pub struct Capabilities { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Capability { Gpu, + PersistentWorkspace, ServiceExposure, } @@ -99,6 +129,7 @@ impl Capability { pub const fn name(self) -> &'static str { match self { Self::Gpu => "gpu", + Self::PersistentWorkspace => "persistent_workspace", Self::ServiceExposure => "service_exposure", } } @@ -108,6 +139,7 @@ impl Capabilities { pub const fn provides(&self, capability: Capability) -> bool { match capability { Capability::Gpu => self.gpu, + Capability::PersistentWorkspace => self.persistent_workspace, Capability::ServiceExposure => self.service_exposure, } } @@ -175,6 +207,39 @@ impl Target { self.base_dir.join(path) } + /// The target's CLI. Fails if the target file has no `cli` section. + pub fn cli(&self) -> Result { + let cli = self.cli.as_ref().ok_or_else(|| { + Error::msg("the target file has no `cli` section; the environment must provide its CLI") + })?; + // A bare name is looked up on PATH; anything with a separator is a + // path relative to the target file. + let binary = if cli.binary.components().count() > 1 { + self.resolve(&cli.binary) + } else { + cli.binary.clone() + }; + Ok(Cli::new(cli, binary)) + } + + /// Check that the target's CLI reaches the gateway: `openshell status` + /// must report it connected. + pub async fn check_cli(&self) -> Result { + let output = self + .cli()? + .run(["status", "--output", "json"]) + .await? + .success()?; + let status: serde_json::Value = output.json()?; + crate::ensure!( + status["status"] == "connected", + "`{}` reports {}", + output.command, + status + ); + Ok(()) + } + fn read(&self, path: &Path) -> Result> { let path = self.resolve(path); std::fs::read(&path) @@ -307,6 +372,29 @@ mod tests { assert!(!target.capabilities.provides(Capability::ServiceExposure)); } + #[test] + fn parses_cli_section() { + let target = parse( + "gateway:\n endpoint: http://x\ncli:\n binary: bin/openshell\n gateway: tmachine\n", + ) + .unwrap(); + let cli = target.cli.as_ref().unwrap(); + assert_eq!(cli.gateway.as_deref(), Some("tmachine")); + assert!(target.cli().is_ok()); + + let target = parse("gateway:\n endpoint: http://x\ncli: {}\n").unwrap(); + assert_eq!( + target.cli.as_ref().unwrap().binary, + PathBuf::from("openshell") + ); + + let err = parse("gateway:\n endpoint: http://x\n") + .unwrap() + .cli() + .unwrap_err(); + assert!(err.to_string().contains("no `cli` section"), "{err}"); + } + #[test] fn rejects_unknown_fields() { let err = parse("gateway:\n endpoint: http://x\n endpiont: http://y\n").unwrap_err(); From a29ce0fa85998c7204b5e8c6a80f562432533f1d Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 16:36:14 +0200 Subject: [PATCH 15/18] test(tests): define k3s and Podman environments for the test plans Add Ubuntu K3s and HA K3s environments for both architectures, and Fedora rootful and rootless Podman environments for amd64, where RPMs are built. The cpu-tests plan runs every conformance suite on all of them. Signed-off-by: Evan Lezar --- tests/environments/k3s.yaml | 32 ++++++++++++++++++++++++++++++++ tests/environments/podman.yaml | 19 +++++++++++++++++++ tests/plans/cpu-tests.yaml | 7 ++++++- 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/environments/k3s.yaml create mode 100644 tests/environments/podman.yaml diff --git a/tests/environments/k3s.yaml b/tests/environments/k3s.yaml new file mode 100644 index 0000000000..cb753e4339 --- /dev/null +++ b/tests/environments/k3s.yaml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +environments: + - id: tmachine.ubuntu2404-amd64.k3s.k3s + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: amd64 } + runtime: k3s + installer: k3s + tmachine: { environment: ubuntu-k3s, installer: k3s } + capabilities: { compute_driver: kubernetes, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.ubuntu2404-arm64.k3s.k3s + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: arm64 } + runtime: k3s + installer: k3s + tmachine: { environment: ubuntu-k3s, installer: k3s } + capabilities: { compute_driver: kubernetes, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.ubuntu2404-amd64.k3s.k3s-ha + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: amd64 } + runtime: k3s + installer: k3s-ha + tmachine: { environment: ubuntu-k3s, installer: k3s-ha } + capabilities: { compute_driver: kubernetes, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.ubuntu2404-arm64.k3s.k3s-ha + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: arm64 } + runtime: k3s + installer: k3s-ha + tmachine: { environment: ubuntu-k3s, installer: k3s-ha } + capabilities: { compute_driver: kubernetes, rootless: false, gpu: false, persistent_workspace: true } diff --git a/tests/environments/podman.yaml b/tests/environments/podman.yaml new file mode 100644 index 0000000000..d605727e9d --- /dev/null +++ b/tests/environments/podman.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# RPMs are built for x86_64 only, so Podman environments are amd64 only. +environments: + - id: tmachine.fedora44-amd64.podman-rootful.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: amd64 } + runtime: podman-rootful + installer: rpm + tmachine: { environment: fedora-podman-rootful, installer: rpm } + capabilities: { compute_driver: podman, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.fedora44-amd64.podman-rootless.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: amd64 } + runtime: podman-rootless + installer: rpm + tmachine: { environment: fedora-podman-rootless, installer: rpm } + capabilities: { compute_driver: podman, rootless: true, gpu: false, persistent_workspace: true } diff --git a/tests/plans/cpu-tests.yaml b/tests/plans/cpu-tests.yaml index 1736f07afd..ddd6c2df21 100644 --- a/tests/plans/cpu-tests.yaml +++ b/tests/plans/cpu-tests.yaml @@ -3,7 +3,12 @@ matrix: - type: conformance - suites: [sandbox-lifecycle] environments: - tmachine.ubuntu2404-amd64.docker-rootful.deb - tmachine.ubuntu2404-arm64.docker-rootful.deb + - tmachine.ubuntu2404-amd64.k3s.k3s + - tmachine.ubuntu2404-arm64.k3s.k3s + - tmachine.ubuntu2404-amd64.k3s.k3s-ha + - tmachine.ubuntu2404-arm64.k3s.k3s-ha + - tmachine.fedora44-amd64.podman-rootful.rpm + - tmachine.fedora44-amd64.podman-rootless.rpm From bd8184a6407819443414f8e3034230e4572165b9 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 16:37:14 +0200 Subject: [PATCH 16/18] ci(tests): run the conformance lanes through the test runner conformance-integration in Branch E2E, Release Dev, and Release Qualification now runs the cpu-tests plan through e2e-suites.yml, one job per amd64 environment, replacing the tmachine CLI conformance run. A matrix entry can name an environment to run on its own. The separate advisory e2e-suites job is removed. Signed-off-by: Evan Lezar --- .github/workflows/branch-e2e.yml | 36 ++++++--------------- .github/workflows/e2e-suites.yml | 17 +++++++--- .github/workflows/release-dev.yml | 17 +++++----- .github/workflows/release-qualification.yml | 17 +++++----- CI.md | 18 +++++++---- 5 files changed, 50 insertions(+), 55 deletions(-) diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index 15258ea1eb..c1620adfce 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -240,25 +240,25 @@ jobs: deb-artifact-name: deb-linux-amd64 rpm-artifact-name: rpm-linux-x86_64 - # Run driver-independent conformance tests. + # Run the conformance suites the cpu-tests plan selects, one job per amd64 + # environment. conformance-integration: needs: prepare-integration permissions: actions: read contents: read packages: read - uses: ./.github/workflows/integration-runner.yml + uses: ./.github/workflows/e2e-suites.yml with: - category: conformance source-sha: ${{ needs.prepare-integration.outputs.source_sha }} integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} - test-matrix: >- + matrix: >- [ - {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, - {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, - {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.docker-rootful.deb"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.k3s.k3s"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.k3s.k3s-ha"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.fedora44-amd64.podman-rootful.rpm"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.fedora44-amd64.podman-rootless.rpm"} ] # Run feature-specific integration tests: @@ -303,24 +303,6 @@ jobs: {"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"driver-podman"} ] - # Run the SDK-driven e2e suites that each plan in tests/plans/ selects, on - # each runner. Advisory until it has a passing history on amd64, so it is - # not part of core-e2e-result. - e2e-suites: - needs: prepare-integration - permissions: - actions: read - contents: read - packages: read - uses: ./.github/workflows/e2e-suites.yml - with: - source-sha: ${{ needs.prepare-integration.outputs.source_sha }} - integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} - matrix: >- - [ - {"plan":"cpu-tests","runner":"ubuntu-24.04"} - ] - docker-e2e: needs: [pr_metadata, build-binaries, build-images] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' diff --git a/.github/workflows/e2e-suites.yml b/.github/workflows/e2e-suites.yml index abaa7281e2..973fa6c108 100644 --- a/.github/workflows/e2e-suites.yml +++ b/.github/workflows/e2e-suites.yml @@ -16,8 +16,10 @@ on: type: string matrix: description: >- - JSON array of {"plan", "runner"} entries. A plan is a file stem in - tests/plans/ that selects the suites and environments to run. + JSON array of {"plan", "runner"} entries, each with an optional + "environment". A plan is a file stem in tests/plans/ that selects + the suites and environments to run; "environment" narrows an entry + to one of them, so environments run as parallel jobs. required: true type: string @@ -34,7 +36,7 @@ concurrency: jobs: e2e-suites: - name: E2E suites (${{ matrix.plan }}, ${{ matrix.runner }}) + name: E2E suites (${{ matrix.plan }}, ${{ matrix.environment || matrix.runner }}) strategy: fail-fast: false matrix: @@ -75,17 +77,22 @@ jobs: - name: Run suites env: PLAN: ${{ matrix.plan }} + ENVIRONMENT: ${{ matrix.environment || '' }} run: | # Retry dependency preparation, then run the plan once. The runner # builds the suites for the guest, so it needs no prepared archives. nix build --no-link .#test-runner || nix build --no-link .#test-runner - nix run .#test-runner -- test --plan "tests/plans/${PLAN}.yaml" --results artifacts/test-results + args=(test --plan "tests/plans/${PLAN}.yaml" --results artifacts/test-results) + if [[ -n "${ENVIRONMENT}" ]]; then + args+=(--env "${ENVIRONMENT}") + fi + nix run .#test-runner -- "${args[@]}" - name: Upload results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: e2e-results-${{ matrix.plan }}-${{ matrix.runner }} + name: e2e-results-${{ matrix.plan }}-${{ matrix.environment || matrix.runner }} path: artifacts/test-results if-no-files-found: ignore retention-days: 7 diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index aec77972ee..82d5edebf9 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -112,24 +112,25 @@ jobs: deb-artifact-name: deb-linux-amd64 rpm-artifact-name: rpm-linux-x86_64 + # Run the conformance suites the cpu-tests plan selects, one job per amd64 + # environment. conformance-integration: needs: prepare-integration permissions: actions: read contents: read packages: read - uses: ./.github/workflows/integration-runner.yml + uses: ./.github/workflows/e2e-suites.yml with: - category: conformance source-sha: ${{ needs.prepare-integration.outputs.source_sha }} integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} - test-matrix: >- + matrix: >- [ - {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, - {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, - {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.docker-rootful.deb"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.k3s.k3s"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.k3s.k3s-ha"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.fedora44-amd64.podman-rootful.rpm"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.fedora44-amd64.podman-rootless.rpm"} ] feature-specific-integration: diff --git a/.github/workflows/release-qualification.yml b/.github/workflows/release-qualification.yml index 3dc0ca3c9e..ab681168b4 100644 --- a/.github/workflows/release-qualification.yml +++ b/.github/workflows/release-qualification.yml @@ -65,24 +65,25 @@ jobs: deb-artifact-name: deb-linux-amd64 rpm-artifact-name: rpm-linux-x86_64 + # Run the conformance suites the cpu-tests plan selects, one job per amd64 + # environment. conformance-integration: needs: prepare-integration permissions: actions: read contents: read packages: read - uses: ./.github/workflows/integration-runner.yml + uses: ./.github/workflows/e2e-suites.yml with: - category: conformance source-sha: ${{ needs.prepare-integration.outputs.source_sha }} integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} - test-matrix: >- + matrix: >- [ - {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, - {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, - {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, - {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.docker-rootful.deb"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.k3s.k3s"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.ubuntu2404-amd64.k3s.k3s-ha"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.fedora44-amd64.podman-rootful.rpm"}, + {"plan":"cpu-tests","runner":"ubuntu-24.04","environment":"tmachine.fedora44-amd64.podman-rootless.rpm"} ] feature-specific-integration: diff --git a/CI.md b/CI.md index b459b322ef..fe7f535933 100644 --- a/CI.md +++ b/CI.md @@ -156,12 +156,16 @@ retain the binary installer because their fixtures configure its system service, local HTTP gateway, and CLI path. The manual Integration Tests workflow defaults to the package installers and downloads the packages selected by its matrix. -Core integration qualification also runs the SDK-driven e2e suites -(`e2e-suites.yml`) on the same prepared inputs. Each matrix entry pairs a plan -from `tests/plans/`, which selects the suites and environments, with a runner. -The test runner builds the suites for the guest itself, and each entry uploads -JUnit reports and failure diagnostics as `e2e-results--`. The job -is advisory and is not part of the `OpenShell / E2E` status yet. +The conformance lanes (`conformance-integration`) run the e2e suites through +`e2e-suites.yml` on the same prepared inputs. Each matrix entry pairs a plan +from `tests/plans/`, which selects the suites and environments, with a runner, +and optionally narrows it to one environment so environments run as parallel +jobs. The test runner builds the suites for the guest itself, and each entry +uploads JUnit reports and failure diagnostics as +`e2e-results--`. Branch E2E, Release Dev, and Release +Qualification run the `cpu-tests` plan on the five amd64 environments: Ubuntu +with Docker (deb), K3s and HA K3s, and Fedora with rootful and rootless Podman +(rpm). Three opt-in labels enable the long-running E2E suites: @@ -561,7 +565,7 @@ The bot's full administrator documentation is internal to NVIDIA. The only comma |---|---| | `.github/workflows/branch-checks.yml` | Required non-E2E checks. Triggers on `push: pull-request/[0-9]+` for PR mirrors and `merge_group` for queued merges. | | `.github/workflows/branch-e2e.yml` | Standard, GPU, Kubernetes HA, and Kubernetes credential-driver E2E. PR mirror pushes use `test:e2e`, `test:e2e-gpu`, and `test:e2e-kubernetes` labels; merge groups do not run E2E. | -| `.github/workflows/e2e-suites.yml` | Reusable matrix job that runs the e2e suites selected by plans in `tests/plans/` on the given runners and uploads `e2e-results--`. | +| `.github/workflows/e2e-suites.yml` | Reusable matrix job that runs the e2e suites selected by plans in `tests/plans/` on the given runners, optionally one environment per job, and uploads `e2e-results--`. Used by the conformance lanes. | | `.github/workflows/build-binaries.yml`, `build-vm-driver.yml` | Shared binary matrices used by branch and release workflows. The VM driver remains separate because its build consumes the runtime binaries. | | `.github/workflows/build-images.yml` | Builds and pushes multi-platform images, then uploads the same OCI images as workflow artifacts. | | `.github/workflows/package-release-binaries.yml` | Packages raw build artifacts into release tarballs without rebuilding them. | From 2e916794774b1d4baf1303ac0f844180deadc42c Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 16:43:13 +0200 Subject: [PATCH 17/18] test(tests): define arm64 Podman environments Release builds produce aarch64 RPMs, so Fedora with Podman is a supported arm64 combination. The environments run where arm64 RPMs are available; CI's conformance lanes stay on amd64. Signed-off-by: Evan Lezar --- tests/environments/podman.yaml | 15 ++++++++++++++- tests/plans/cpu-tests.yaml | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/environments/podman.yaml b/tests/environments/podman.yaml index d605727e9d..e87f648350 100644 --- a/tests/environments/podman.yaml +++ b/tests/environments/podman.yaml @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# RPMs are built for x86_64 only, so Podman environments are amd64 only. environments: - id: tmachine.fedora44-amd64.podman-rootful.rpm backend: tmachine @@ -10,6 +9,13 @@ environments: installer: rpm tmachine: { environment: fedora-podman-rootful, installer: rpm } capabilities: { compute_driver: podman, rootless: false, gpu: false, persistent_workspace: true } + - id: tmachine.fedora44-arm64.podman-rootful.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: arm64 } + runtime: podman-rootful + installer: rpm + tmachine: { environment: fedora-podman-rootful, installer: rpm } + capabilities: { compute_driver: podman, rootless: false, gpu: false, persistent_workspace: true } - id: tmachine.fedora44-amd64.podman-rootless.rpm backend: tmachine machine: { os: fedora, version: "44", arch: amd64 } @@ -17,3 +23,10 @@ environments: installer: rpm tmachine: { environment: fedora-podman-rootless, installer: rpm } capabilities: { compute_driver: podman, rootless: true, gpu: false, persistent_workspace: true } + - id: tmachine.fedora44-arm64.podman-rootless.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: arm64 } + runtime: podman-rootless + installer: rpm + tmachine: { environment: fedora-podman-rootless, installer: rpm } + capabilities: { compute_driver: podman, rootless: true, gpu: false, persistent_workspace: true } diff --git a/tests/plans/cpu-tests.yaml b/tests/plans/cpu-tests.yaml index ddd6c2df21..a620812f05 100644 --- a/tests/plans/cpu-tests.yaml +++ b/tests/plans/cpu-tests.yaml @@ -11,4 +11,6 @@ matrix: - tmachine.ubuntu2404-amd64.k3s.k3s-ha - tmachine.ubuntu2404-arm64.k3s.k3s-ha - tmachine.fedora44-amd64.podman-rootful.rpm + - tmachine.fedora44-arm64.podman-rootful.rpm - tmachine.fedora44-amd64.podman-rootless.rpm + - tmachine.fedora44-arm64.podman-rootless.rpm From 673928804d5156c4049ab8cf940ca992dd072b19 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 16 Oct 2026 17:34:29 +0200 Subject: [PATCH 18/18] fix(tests): install git in every tmachine environment The CLI's sandbox upload runs git to apply .gitignore rules, and the file-transfer conformance tests create Git work trees. The tmachine conformance playbook installed git before its tests; the test runner path has no such step, and Fedora cloud images lack git. Install it during environment setup so it is cached in the setup disk. Signed-off-by: Evan Lezar --- tests/ansible/playbooks/nextest.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/ansible/playbooks/nextest.yaml b/tests/ansible/playbooks/nextest.yaml index 6813d5b96f..3c7c50d678 100644 --- a/tests/ansible/playbooks/nextest.yaml +++ b/tests/ansible/playbooks/nextest.yaml @@ -50,3 +50,11 @@ - name: Verify cargo-nextest ansible.builtin.command: cargo-nextest --version changed_when: false + + # The CLI's upload runs git to apply .gitignore rules, and the + # file-transfer tests create Git work trees. Fedora cloud images lack git. + - name: Install test dependencies + ansible.builtin.package: + name: + - git + state: present