From 50a6c0f3d50f43fad880e7ec877602589f419642 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Mon, 19 Oct 2026 13:18:12 -0700 Subject: [PATCH 01/19] test(testkit): add the testkit workspace for end-to-end suites Add tests/testkit as its own Cargo workspace: testkit, testkit-macros, and testkit-spec. testkit reads a target file, connects to the gateway through openshell-sdk, creates and cleans up sandboxes, and runs the installed CLI. #[testkit::test] requires a stable ID and a doc comment and declares capability requirements. testkit-ready waits for a target's gateway and CLI before tests run. Nothing uses testkit yet. The lockfile is seeded from the root lockfile so shared crates resolve to the same versions. Signed-off-by: Matthew Grossman --- tests/testkit/Cargo.lock | 4261 +++++++++++++++++ tests/testkit/Cargo.toml | 8 + tests/testkit/README.md | 118 + tests/testkit/testkit-macros/Cargo.toml | 22 + tests/testkit/testkit-macros/src/lib.rs | 136 + tests/testkit/testkit-spec/Cargo.toml | 19 + tests/testkit/testkit-spec/src/lib.rs | 348 ++ tests/testkit/testkit/Cargo.toml | 26 + .../testkit/testkit/src/bin/testkit-ready.rs | 48 + tests/testkit/testkit/src/cli.rs | 187 + tests/testkit/testkit/src/context.rs | 536 +++ tests/testkit/testkit/src/error.rs | 160 + tests/testkit/testkit/src/lib.rs | 48 + tests/testkit/testkit/src/poll.rs | 90 + tests/testkit/testkit/src/run.rs | 104 + tests/testkit/testkit/src/target.rs | 435 ++ 16 files changed, 6546 insertions(+) create mode 100644 tests/testkit/Cargo.lock create mode 100644 tests/testkit/Cargo.toml create mode 100644 tests/testkit/README.md create mode 100644 tests/testkit/testkit-macros/Cargo.toml create mode 100644 tests/testkit/testkit-macros/src/lib.rs create mode 100644 tests/testkit/testkit-spec/Cargo.toml create mode 100644 tests/testkit/testkit-spec/src/lib.rs create mode 100644 tests/testkit/testkit/Cargo.toml create mode 100644 tests/testkit/testkit/src/bin/testkit-ready.rs create mode 100644 tests/testkit/testkit/src/cli.rs create mode 100644 tests/testkit/testkit/src/context.rs create mode 100644 tests/testkit/testkit/src/error.rs create mode 100644 tests/testkit/testkit/src/lib.rs create mode 100644 tests/testkit/testkit/src/poll.rs create mode 100644 tests/testkit/testkit/src/run.rs create mode 100644 tests/testkit/testkit/src/target.rs diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock new file mode 100644 index 0000000000..fcad94df66 --- /dev/null +++ b/tests/testkit/Cargo.lock @@ -0,0 +1,4261 @@ +# 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 = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[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 = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-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 = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[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", + "untrusted 0.7.1", + "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 = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[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 = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[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 = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[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 = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[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 = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "ctutils", +] + +[[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 = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[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 = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "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 = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[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", + "rand_core 0.10.1", + "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 = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[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 = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1 0.10.6", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[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 = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[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-http-proxy" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd1d471ea2f65ba45eddb1d6ab7d58ac2671d2d4ac14da5c6516ae1d97e1327a" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[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", + "log", + "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 0.22.1", + "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 = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +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 = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem 3.0.6", + "serde", + "serde_json", + "signature", + "simple_asn1", +] + +[[package]] +name = "k8s-openapi" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5" +dependencies = [ + "base64 0.22.1", + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "kube" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4eb20010536b48abe97fec37d23d43069bcbe9686adcf9932202327bc5ca6e" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-derive", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc2ed952042df20d15ac2fe9614d0ec14b6118eab89633985d4b36e688dccf1" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem 3.0.6", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff0d0793db58e70ca6d689489183816cb3aa481673e7433dc618cf7e8007c675" +dependencies = [ + "chrono", + "form_urlencoded", + "http", + "json-patch", + "k8s-openapi", + "schemars", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "kube-derive" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c562f58dc9f7ca5feac8a6ee5850ca221edd6f04ce0dd2ee873202a88cd494c9" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.117", +] + +[[package]] +name = "kube-runtime" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f34cfab9b4bd8633062e0e85edb81df23cb09f159f2e31c60b069ae826ffdc" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "async-trait", + "backon", + "educe", + "futures", + "hashbrown 0.15.5", + "hostname", + "json-patch", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.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 = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[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 = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.2", +] + +[[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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[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 = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[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-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[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.6", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "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 = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[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 0.22.1", + "chrono", + "glob", + "ipnet", + "miette", + "nix", + "openshell-crypto", + "openshell-extension-core", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "reqwest", + "rustix", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tonic-types", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "openshell-crypto" +version = "0.0.0" +dependencies = [ + "aws-lc-rs", + "jsonwebtoken", + "kube", + "pem 3.0.6", + "rcgen", + "rustls", + "serde", + "sqlx", + "thiserror 2.0.20", + "tonic", + "x509-parser", +] + +[[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", + "openshell-crypto", + "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", + "serde_json", + "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 = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +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 = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[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 = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +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" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[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 = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "aws-lc-rs", + "pem 4.0.0", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[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 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 0.9.0", + "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 = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[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 0.9.0", +] + +[[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 = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[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-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[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_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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 = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.20", + "time", +] + +[[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" +dependencies = [ + "serde", +] + +[[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 = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "thiserror 2.0.20", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "crc", + "digest 0.11.2", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.20", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.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 = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.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", + "slab", + "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.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 = [ + "base64 0.22.1", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "mime", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[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 = [ + "log", + "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 0.10.6", + "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 = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[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-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[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 = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[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 = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "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 = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[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 = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[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..c7c226277a --- /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. +[workspace] +resolver = "2" +members = ["testkit", "testkit-macros", "testkit-spec"] diff --git a/tests/testkit/README.md b/tests/testkit/README.md new file mode 100644 index 0000000000..8161a13578 --- /dev/null +++ b/tests/testkit/README.md @@ -0,0 +1,118 @@ +# testkit + +Shared support for end-to-end test suites under `tests/suites/`. Suites test +a running OpenShell gateway through the Rust SDK and the installed `openshell` +CLI. No suite uses testkit yet; existing suites move to it one at a time. + +This is a separate Cargo workspace with its own lockfile: + +| Crate | Purpose | +|---|---| +| `testkit` | Reads the target file, connects to the gateway, creates and cleans up sandboxes, runs the CLI. Also builds `testkit-ready`. | +| `testkit-macros` | The `#[testkit::test]` attribute. | +| `testkit-spec` | Parses `#[testkit::test]` functions, shared by the attribute and tools that list tests. | + +## Writing a test + +A test is an `async fn` that takes `&mut testkit::Context` and returns +`testkit::Result`: + +```rust +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(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?; + // ... + Ok(()) +} +``` + +- **`id` is required.** Use `t-` followed by eight random lowercase letters or + digits. Results and coverage records refer to tests by this ID, so never + change or reuse it, including when you rename or move the test. Omit `id` + on a new test and the compile error suggests a fresh one. +- **The doc comment is required.** It is the test's description. +- **`requires` declares capabilities** the test needs, for example + `#[testkit::test(id = "...", requires = [service_exposure])]`. The names are + the boolean fields of `capabilities` in the target file. An unknown name + fails to compile. If the target lacks a required capability, the test + fails rather than passing untested, so exclude it when you select tests. +- **The function name is the nextest test name.** Rename it freely; the ID + keeps the history. + +Cleanup runs after every test, whether it passes, fails, or panics. It only +deletes sandboxes created through `cx.sandbox(...)`. Delete anything else a +test creates, such as providers, in the test itself. + +## What a conformance test may assert + +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 gateway behavior +the contract does not state; file an issue to document it instead. + +- Assert on a sandbox's `phase` and `exit_code`, not condition reasons, which + the API leaves to the platform. +- To check that something did not happen, read state (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`. +- Act through the interface that makes the promise. Upload, download, + connect, and port forwarding exist only in the CLI, so call + `cx.cli([...])` and rely on its exit code and `--output json`, not its + table text. +- For API calls the SDK does not wrap yet, add a testkit helper over the + generated gRPC client rather than calling it from tests. + +## Running against a gateway + +Tests read the system under test from the target file named by +`OPENSHELL_TEST_TARGET`. Relative paths resolve against the file's directory. + +```yaml +gateway: + endpoint: https://127.0.0.1:17670 # http:// for plaintext + tls: # optional; cert and key together select mTLS + ca: ca.crt + client_cert: client.crt + client_key: client.key + auth: # optional + bearer_token_file: token +cli: # needed by tests that call cx.cli + binary: /usr/bin/openshell # default: `openshell` on PATH + gateway: local # default: the CLI's active gateway +workspace: default +capabilities: + compute_driver: docker + service_exposure: true + persistent_workspace: false + gpu: false +``` + +Wait for the gateway, then run a suite. Run tests that share a gateway one at +a time: + +```shell +export OPENSHELL_TEST_TARGET=$PWD/target.yaml +cargo run --manifest-path tests/testkit/Cargo.toml --bin testkit-ready -- 300 +cargo nextest run --manifest-path /Cargo.toml --test-threads 1 +``` + +`testkit-ready [TIMEOUT_SECS]` exits non-zero if the gateway is not healthy in +time or the target's CLI does not report it connected. + +## Checks + +```shell +cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check +cargo clippy --locked --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings +cargo nextest run --locked --config-file .config/nextest.toml --manifest-path tests/testkit/Cargo.toml +``` + +`mise run rust:lint`, `mise run rust:format:check`, and `mise run test` include +these, and Branch Checks runs them. diff --git a/tests/testkit/testkit-macros/Cargo.toml b/tests/testkit/testkit-macros/Cargo.toml new file mode 100644 index 0000000000..1a6c1d839a --- /dev/null +++ b/tests/testkit/testkit-macros/Cargo.toml @@ -0,0 +1,22 @@ +# 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 end-to-end test suites" +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] +openshell-testkit-spec = { path = "../testkit-spec" } +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } diff --git a/tests/testkit/testkit-macros/src/lib.rs b/tests/testkit/testkit-macros/src/lib.rs new file mode 100644 index 0000000000..ec74a0091d --- /dev/null +++ b/tests/testkit/testkit-macros/src/lib.rs @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The `#[testkit::test]` attribute. +//! +//! 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. `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(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 function name. The original function is nested in +//! the wrapper and passed to `testkit::run`. Parsing lives in `testkit-spec`, +//! so tools that list tests share the macro's rules. + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use syn::{Ident, ItemFn, LitStr}; + +#[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() +} + +fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { + let mut body: ItemFn = syn::parse2(item)?; + let spec = testkit_spec::parse(attr, &body)?; + + 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) + }); + + let vis = body.vis.clone(); + let doc_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"); + + Ok(quote! { + #(#doc_attrs)* + #[::testkit::__private::tokio::test(crate = "::testkit::__private::tokio")] + #vis async fn #name() { + #body + ::testkit::run( + ::testkit::TestInfo { + id: #id, + name: #name_str, + description: #description, + requires: &[#(#requires),*], + }, + body, + ) + .await; + } + }) +} + +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( + "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 : \"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( + "id = \"t-0123abcd\", 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 reports_spec_errors() { + let err = expand_str( + "", + "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("doc comment"), "{err}"); + } +} diff --git a/tests/testkit/testkit-spec/Cargo.toml b/tests/testkit/testkit-spec/Cargo.toml new file mode 100644 index 0000000000..c680ed0eed --- /dev/null +++ b/tests/testkit/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 test-listing tools" +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/testkit/testkit-spec/src/lib.rs b/tests/testkit/testkit-spec/src/lib.rs new file mode 100644 index 0000000000..59d23d2343 --- /dev/null +++ b/tests/testkit/testkit-spec/src/lib.rs @@ -0,0 +1,348 @@ +// 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 parses tests through this crate. Tools that +//! list tests without compiling them use it too, so they cannot disagree +//! with the macro about a test's ID, 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, 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 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 = [...]`. + pub requires: Vec, +} + +/// Arguments accepted by `#[testkit::test(...)]`. +#[derive(Default)] +struct Args { + id: Option, + 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()?; + 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 `id = \"...\"` or `requires = [...]`" + ), + )); + } + 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, + "testkit tests must be `async fn`", + )); + } + if item.sig.inputs.len() != 1 { + return Err(syn::Error::new_spanned( + &item.sig.inputs, + "testkit 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(), + "testkit tests need a doc comment describing the behavior they verify", + )); + } + + 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: 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)?; + 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()) + } + + const ID: &str = "id = \"t-0123abcd\""; + + #[test] + fn parses_id_name_description_and_requirements() { + let spec = parse_str( + "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-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_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(); + assert!(err.to_string().contains("doc comment"), "{err}"); + } + + #[test] + fn rejects_sync_fn() { + let err = parse_str( + ID, + "/// 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(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( + "id = \"t-0123abcd\", 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(id = \"t-00000001\")] + async fn plain(cx: &mut Context) -> testkit::Result { Ok(()) } + + /// Needs exposure. + #[testkit::test(id = \"t-00000002\", requires = [service_exposure])] + async fn exposed(cx: &mut Context) -> testkit::Result { Ok(()) } + + #[test] + fn not_a_testkit_test() {} + + async fn helper(cx: &mut Context) {} + ", + ) + .unwrap(); + 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"]); + } + + #[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}"); + } +} diff --git a/tests/testkit/testkit/Cargo.toml b/tests/testkit/testkit/Cargo.toml new file mode 100644 index 0000000000..7e1f4a2210 --- /dev/null +++ b/tests/testkit/testkit/Cargo.toml @@ -0,0 +1,26 @@ +# 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 end-to-end test suites" +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_json = "1" +serde_yml = { package = "noyalib", version = "0.0.53", default-features = false, features = ["std", "compat-serde-yaml"] } +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 new file mode 100644 index 0000000000..5d4e8aea1f --- /dev/null +++ b/tests/testkit/testkit/src/bin/testkit-ready.rs @@ -0,0 +1,48 @@ +// 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, or if the environment's CLI does not reach it. + +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 = 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 and the CLI reaches it", + health.version + ); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("testkit-ready: {error}"); + ExitCode::FAILURE + } + } +} 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 new file mode 100644 index 0000000000..f6a0404bcc --- /dev/null +++ b/tests/testkit/testkit/src/context.rs @@ -0,0 +1,536 @@ +// 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::raw::{AuthedGrpcClient, proto}; +use openshell_sdk::{ + 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}; + +/// 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); +/// 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); + +/// 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, + grpc: Grpc, + cli: Result, + run_id: String, + sandboxes: Registry, +} + +impl Context { + pub(crate) fn new(client: &OpenShellClient, workspace: &str, cli: Result) -> Self { + Self { + 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 + } + + /// The SDK client, for operations testkit does not wrap. + pub fn client(&self) -> &WorkspaceScopedClient { + &self.client + } + + /// A resource name unique to this run: `test--`. + pub fn name(&self, suffix: &str) -> String { + format!("test-{}-{suffix}", self.run_id) + } + + /// Start describing a sandbox named `cx.name(suffix)`. + 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)), + ..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(()) + } + + /// 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 { + 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 + } +} + +/// 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, +} + +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 + } + + /// 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 { + 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 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, + }) + } +} + +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, +} + +/// 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) + } + + /// 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 + } + + /// 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 + .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 + 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/testkit/testkit/src/error.rs b/tests/testkit/testkit/src/error.rs new file mode 100644 index 0000000000..dad7fe35ad --- /dev/null +++ b/tests/testkit/testkit/src/error.rs @@ -0,0 +1,160 @@ +// 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()) + } + + /// 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)) + } +} + +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_error(&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"); + } + + #[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 { + 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/testkit/testkit/src/lib.rs b/tests/testkit/testkit/src/lib.rs new file mode 100644 index 0000000000..b54e0cf76a --- /dev/null +++ b/tests/testkit/testkit/src/lib.rs @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared support for `OpenShell` end-to-end test suites. +//! +//! 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; +//! +//! /// A long-running sandbox becomes ready, runs exec, and is gone after delete. +//! #[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?; +//! // ... +//! Ok(()) +//! } +//! ``` +//! +//! 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, STOP_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/testkit/testkit/src/poll.rs b/tests/testkit/testkit/src/poll.rs new file mode 100644 index 0000000000..1f46f49c37 --- /dev/null +++ b/tests/testkit/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/testkit/testkit/src/run.rs b/tests/testkit/testkit/src/run.rs new file mode 100644 index 0000000000..9b21720099 --- /dev/null +++ b/tests/testkit/testkit/src/run.rs @@ -0,0 +1,104 @@ +// 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 { + /// 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], +} + +/// 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 name = info.name; + eprintln!("[{name}] {} ({})", info.description, info.id); + + let target = Target::from_env().unwrap_or_else(|error| panic!("[{name}] {error}")); + let missing = missing_requirements(&info, &target); + // Whoever selects tests must exclude those the target can't support. + // Reaching here means they didn't, so fail rather than report a pass for + // an untested behavior. + assert!( + missing.is_empty(), + "[{name}] requires {}, which the target does not provide", + missing.join(", ") + ); + + let client = target + .connect() + .await + .unwrap_or_else(|error| panic!("[{name}] {error}")); + 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; + let cleanup_failures = cx.cleanup().await; + for failure in &cleanup_failures { + eprintln!("[{name}] cleanup failed: {failure}"); + } + + match outcome { + Err(panic) => std::panic::resume_unwind(panic), + Ok(Err(error)) => panic!("[{name}] {error}"), + Ok(Ok(())) => assert!( + cleanup_failures.is_empty(), + "[{name}] 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-00000000", + name: "t", + description: "d", + requires: &[Capability::Gpu, Capability::ServiceExposure], + }; + assert_eq!(missing_requirements(&info, &target), ["service_exposure"]); + } +} diff --git a/tests/testkit/testkit/src/target.rs b/tests/testkit/testkit/src/target.rs new file mode 100644 index 0000000000..f783d8f117 --- /dev/null +++ b/tests/testkit/testkit/src/target.rs @@ -0,0 +1,435 @@ +// 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 +//! cli: +//! binary: /usr/bin/openshell # default: `openshell` on PATH +//! gateway: tmachine # the name the gateway is registered under +//! workspace: default +//! capabilities: +//! compute_driver: docker +//! service_exposure: true +//! ``` + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use openshell_sdk::{ + AuthConfig, ClientConfig, EdgeAuthInterceptor, Health, OpenShellClient, ServiceStatus, +}; +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. +pub const TARGET_ENV: &str = "OPENSHELL_TEST_TARGET"; + +#[derive(Debug, Deserialize)] +#[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)] + 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, +} + +/// 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 { + 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, + /// 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 = [...])]`. +/// +/// 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, + PersistentWorkspace, + ServiceExposure, +} + +impl Capability { + pub const fn name(self) -> &'static str { + match self { + Self::Gpu => "gpu", + Self::PersistentWorkspace => "persistent_workspace", + Self::ServiceExposure => "service_exposure", + } + } +} + +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, + } + } +} + +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); +const READY_INTERVAL: Duration = Duration::from_secs(1); + +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) + } + + /// 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) + .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| 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| Error::from_error(&error)) + .context("configure mTLS")? + .connect() + .await + .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)] +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 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(); + 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}"); + } + + #[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 94539c41c1cc7269fa9706a812217a18aee507ad Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Mon, 19 Oct 2026 13:18:12 -0700 Subject: [PATCH 02/19] ci(tests): format, lint and test testkit in Branch Checks Nothing else builds testkit, so check it in Branch Checks and the matching mise tasks to catch openshell-sdk changes that break it. Signed-off-by: Matthew Grossman --- .github/workflows/branch-checks.yml | 3 +++ tasks/rust.toml | 3 +++ tasks/test.toml | 1 + 3 files changed, 7 insertions(+) diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 8fe743684c..218d3d2657 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -221,6 +221,7 @@ jobs: 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 examples/isolation-backend-v8/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check cargo-lockfiles: name: Cargo lockfiles @@ -277,6 +278,7 @@ jobs: 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 examples/isolation-backend-v8/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings rust-test: name: Rust test (${{ matrix.system }}) @@ -325,6 +327,7 @@ 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 rust-postgres-test: name: Rust PostgreSQL tests diff --git a/tasks/rust.toml b/tasks/rust.toml index 89f2061bc6..682e478fca 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -33,6 +33,7 @@ run = [ "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 examples/isolation-backend-v8/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -45,6 +46,7 @@ run = [ "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 examples/isolation-backend-v8/Cargo.toml --all", + "cargo fmt --manifest-path tests/testkit/Cargo.toml --all", ] hide = true @@ -56,6 +58,7 @@ run = [ "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 examples/isolation-backend-v8/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check", ] hide = true diff --git a/tasks/test.toml b/tasks/test.toml index 95e049d9ab..dc4fd88fc5 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -119,6 +119,7 @@ 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", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" hide = true From 21c4aa95ea73c6577a25d390f36b13e4a18f5d58 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Mon, 19 Oct 2026 13:18:12 -0700 Subject: [PATCH 03/19] docs(tests): describe how to write and run testkit tests Signed-off-by: Matthew Grossman --- TESTING.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/TESTING.md b/TESTING.md index f71854fa22..02711c1123 100644 --- a/TESTING.md +++ b/TESTING.md @@ -16,6 +16,7 @@ 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/testkit/ # Support library for SDK-driven E2E suites ``` ## Rust Tests @@ -129,6 +130,17 @@ requires mTLS. runtime. It covers the current schema and the Podman in-tree versus external driver configuration boundary. +### testkit (`tests/testkit/`) + +`testkit` is the support library for SDK-driven end-to-end suites under +`tests/suites/`. Tests are `#[testkit::test]` functions with a required stable +`id`, a required doc comment, and optional `requires = [...]` capabilities. +They run against the gateway described by the target file named in +`OPENSHELL_TEST_TARGET`. No suite uses it yet. See +[tests/testkit/README.md](tests/testkit/README.md) for how to write tests, what +a conformance test may assert, and how to run against a hand-written target +file. + ### Python E2E (`e2e/python/`) `mise run e2e:python` builds `openshell/e2e-python:dev` from From 69bdadaf0809095629a9012fef7478ac7e9e1616 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Mon, 19 Oct 2026 14:18:47 -0700 Subject: [PATCH 04/19] fix(testkit): give each sandbox its own cleanup timeout Cleanup shared one two-minute budget across all of a test's sandboxes, so a slow delete left the rest with no time and could leak them on a shared gateway. Delete them concurrently, each with the full timeout. Signed-off-by: Matthew Grossman --- tests/testkit/testkit/src/context.rs | 36 +++++++++++++++------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/tests/testkit/testkit/src/context.rs b/tests/testkit/testkit/src/context.rs index f6a0404bcc..8471d9590d 100644 --- a/tests/testkit/testkit/src/context.rs +++ b/tests/testkit/testkit/src/context.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use std::sync::{Arc, Mutex, PoisonError}; -use std::time::{Duration, Instant}; +use std::time::Duration; use openshell_sdk::raw::{AuthedGrpcClient, proto}; use openshell_sdk::{ @@ -153,27 +153,29 @@ impl Context { /// Delete every sandbox the test created and has not deleted. Returns the /// cleanup failures; they are reported separately from the test result. + /// + /// Sandboxes are deleted concurrently, each with the full + /// [`CLEANUP_TIMEOUT`], so a slow delete cannot use up the time for the + /// others and cleanup takes about one timeout however many there are. pub(crate) async fn cleanup(&self) -> Vec { - let started = Instant::now(); let names: Vec = lock(&self.sandboxes).iter().cloned().collect(); + let results = futures::future::join_all(names.iter().map(|name| async move { + self.client + .delete_sandbox( + name, + DeleteOptions { + allow_missing: true, + }, + ) + .await?; + self.client.wait_deleted(name, CLEANUP_TIMEOUT, None).await + })) + .await; 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; + for (name, result) in names.iter().zip(results) { match result { Ok(()) => { - lock(&self.sandboxes).remove(&name); + lock(&self.sandboxes).remove(name); } Err(error) => failures.push(format!("delete sandbox {name}: {error}")), } From 489720f376e2fbc8edb5f2f01765aae4f0379c63 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Mon, 19 Oct 2026 14:18:47 -0700 Subject: [PATCH 05/19] docs(testkit): show how to exclude tests by capability Signed-off-by: Matthew Grossman --- tests/testkit/README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/testkit/README.md b/tests/testkit/README.md index 8161a13578..34b2945465 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -40,7 +40,8 @@ async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { `#[testkit::test(id = "...", requires = [service_exposure])]`. The names are the boolean fields of `capabilities` in the target file. An unknown name fails to compile. If the target lacks a required capability, the test - fails rather than passing untested, so exclude it when you select tests. + fails rather than passing untested. See + [Excluding tests by capability](#excluding-tests-by-capability). - **The function name is the nextest test name.** Rename it freely; the ID keeps the history. @@ -106,6 +107,21 @@ cargo nextest run --manifest-path /Cargo.toml --test-threads 1 `testkit-ready [TIMEOUT_SECS]` exits non-zero if the gateway is not healthy in time or the target's CLI does not report it connected. +### Excluding tests by capability + +Nothing selects tests by capability yet, so a test whose `requires` the target +does not provide fails with `requires , which the target does not +provide`. Find those tests and exclude them by name with a nextest filter: + +```shell +grep -rn 'requires = \[' /tests +cargo nextest run --manifest-path /Cargo.toml --test-threads 1 \ + -E 'not test(=stopped_sandbox_keeps_workspace)' +``` + +Combine names with `or` inside the parentheses, for example +`-E 'not (test(=a) or test(=b))'`. + ## Checks ```shell From 675e15b9e07c22183bbe367f9728457ab053c23a Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Mon, 19 Oct 2026 14:18:48 -0700 Subject: [PATCH 06/19] docs: list tests/testkit in the project structure tables Signed-off-by: Matthew Grossman --- AGENTS.md | 1 + CONTRIBUTING.md | 1 + 2 files changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1235fe1183..8ab23c2a90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `python/openshell/` | Python SDK | Python bindings and CLI packaging | | `sdk/typescript/` | TypeScript SDK | Native Connect client, curated sandbox API, and generated protobuf types | | `proto/` | Protobuf definitions | gRPC service contracts | +| `tests/testkit/` | E2E testkit | Support library and `#[testkit::test]` attribute for SDK-driven E2E suites | | `deploy/` | Docker, Helm, K8s | Dockerfiles, Helm chart, manifests | | `docs/` | Published docs | MDX pages, navigation, and content assets | | `fern/` | Docs site config | Fern site config, components, and theme assets | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4050bc3f4c..4dfea77b67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -344,6 +344,7 @@ These are the primary `mise` tasks for day-to-day development: | `sdk/typescript/` | TypeScript SDK (Connect client and generated protobuf bindings) | | `proto/` | Protocol buffer definitions and [public API conventions](proto/README.md) | | `tasks/` | `mise` task definitions and build scripts | +| `tests/testkit/` | Support library for SDK-driven end-to-end suites | | `deploy/` | Dockerfiles, Helm chart, Kubernetes manifests | | `docs/` | Published Fern docs source, navigation, and content assets | | `fern/` | Fern site config, components, and theme assets | From 1f1c9e011f61741322ca7b8b49314578e36df73a Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Mon, 19 Oct 2026 14:32:56 -0700 Subject: [PATCH 07/19] refactor(testkit): drop test IDs and capabilities for now Nothing reads test IDs or capability requirements until a runner, waivers, or result history exist, so remove the id and requires arguments and the target file's capabilities section. Both can return as optional attribute arguments without changing existing tests. With them gone, testkit-spec only checked the function shape and read the doc comment, so fold it into testkit-macros. Signed-off-by: Matthew Grossman --- tests/testkit/Cargo.lock | 9 - tests/testkit/Cargo.toml | 6 +- tests/testkit/testkit-macros/Cargo.toml | 1 - tests/testkit/testkit-macros/src/lib.rs | 127 +++++---- tests/testkit/testkit-spec/Cargo.toml | 19 -- tests/testkit/testkit-spec/src/lib.rs | 348 ------------------------ tests/testkit/testkit/src/lib.rs | 9 +- tests/testkit/testkit/src/run.rs | 49 +--- tests/testkit/testkit/src/target.rs | 61 +---- 9 files changed, 93 insertions(+), 536 deletions(-) delete mode 100644 tests/testkit/testkit-spec/Cargo.toml delete mode 100644 tests/testkit/testkit-spec/src/lib.rs diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index fcad94df66..d7fa1f856a 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -1984,20 +1984,11 @@ dependencies = [ 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" diff --git a/tests/testkit/Cargo.toml b/tests/testkit/Cargo.toml index c7c226277a..17a5e5b052 100644 --- a/tests/testkit/Cargo.toml +++ b/tests/testkit/Cargo.toml @@ -1,8 +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. +# Shared support for end-to-end test suites. Suites depend on these crates +# by path. [workspace] resolver = "2" -members = ["testkit", "testkit-macros", "testkit-spec"] +members = ["testkit", "testkit-macros"] diff --git a/tests/testkit/testkit-macros/Cargo.toml b/tests/testkit/testkit-macros/Cargo.toml index 1a6c1d839a..d6e9cdf0de 100644 --- a/tests/testkit/testkit-macros/Cargo.toml +++ b/tests/testkit/testkit-macros/Cargo.toml @@ -16,7 +16,6 @@ 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/testkit/testkit-macros/src/lib.rs b/tests/testkit/testkit-macros/src/lib.rs index ec74a0091d..17d0a1b3ee 100644 --- a/tests/testkit/testkit-macros/src/lib.rs +++ b/tests/testkit/testkit-macros/src/lib.rs @@ -3,27 +3,24 @@ //! The `#[testkit::test]` attribute. //! -//! 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. `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: +//! 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: //! //! ```ignore //! /// An exposed loopback HTTP service is reachable and goes away after delete. -//! #[testkit::test(id = "t-4f8k2m0q", requires = [service_exposure])] +//! #[testkit::test] //! 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 function name. The original function is nested in -//! the wrapper and passed to `testkit::run`. Parsing lives in `testkit-spec`, -//! so tools that list tests share the macro's rules. +//! the nextest test name is the function name. The original function is nested +//! in the wrapper and passed to `testkit::run`. use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; -use syn::{Ident, ItemFn, LitStr}; +use syn::{Attribute, Expr, ExprLit, ItemFn, Lit, LitStr, Meta}; #[proc_macro_attribute] pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { @@ -34,16 +31,35 @@ pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { let mut body: ItemFn = syn::parse2(item)?; - let spec = testkit_spec::parse(attr, &body)?; + if !attr.is_empty() { + return Err(syn::Error::new_spanned( + attr, + "`#[testkit::test]` takes no arguments", + )); + } + if body.sig.asyncness.is_none() { + return Err(syn::Error::new_spanned( + body.sig.fn_token, + "testkit tests must be `async fn`", + )); + } + if body.sig.inputs.len() != 1 { + return Err(syn::Error::new_spanned( + &body.sig.inputs, + "testkit tests take exactly one argument: `cx: &mut testkit::Context`", + )); + } + let description = doc_comment(&body.attrs); + if description.is_empty() { + return Err(syn::Error::new( + body.sig.ident.span(), + "testkit tests need a doc comment describing the behavior they verify", + )); + } - let name = spec.name; + let name = body.sig.ident.clone(); 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) - }); + let description = LitStr::new(&description, name.span()); let vis = body.vis.clone(); let doc_attrs: Vec<_> = body @@ -63,10 +79,8 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { #body ::testkit::run( ::testkit::TestInfo { - id: #id, name: #name_str, description: #description, - requires: &[#(#requires),*], }, body, ) @@ -75,16 +89,23 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { }) } -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() - }) +/// The doc comment, joined into one line. Empty if there is none. +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, }) - .collect() + .filter(|line| !line.is_empty()) + .collect::>() + .join(" ") } #[cfg(test)] @@ -98,34 +119,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(()) }", + "", + "/// First line\n/// second line.\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 : \"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( - "id = \"t-0123abcd\", 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.contains("description : \"First line second line.\""), "{out}" ); } #[test] - fn reports_spec_errors() { + fn rejects_missing_doc_comment() { let err = expand_str( "", "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", @@ -133,4 +141,31 @@ 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_arguments() { + let err = expand_str( + "id = \"t-0123abcd\"", + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("takes no arguments"), "{err}"); + } } diff --git a/tests/testkit/testkit-spec/Cargo.toml b/tests/testkit/testkit-spec/Cargo.toml deleted file mode 100644 index c680ed0eed..0000000000 --- a/tests/testkit/testkit-spec/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -# 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 test-listing tools" -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/testkit/testkit-spec/src/lib.rs b/tests/testkit/testkit-spec/src/lib.rs deleted file mode 100644 index 59d23d2343..0000000000 --- a/tests/testkit/testkit-spec/src/lib.rs +++ /dev/null @@ -1,348 +0,0 @@ -// 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 parses tests through this crate. Tools that -//! list tests without compiling them use it too, so they cannot disagree -//! with the macro about a test's ID, 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, 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 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 = [...]`. - pub requires: Vec, -} - -/// Arguments accepted by `#[testkit::test(...)]`. -#[derive(Default)] -struct Args { - id: Option, - 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()?; - 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 `id = \"...\"` or `requires = [...]`" - ), - )); - } - 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, - "testkit tests must be `async fn`", - )); - } - if item.sig.inputs.len() != 1 { - return Err(syn::Error::new_spanned( - &item.sig.inputs, - "testkit 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(), - "testkit tests need a doc comment describing the behavior they verify", - )); - } - - 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: 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)?; - 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()) - } - - const ID: &str = "id = \"t-0123abcd\""; - - #[test] - fn parses_id_name_description_and_requirements() { - let spec = parse_str( - "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-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_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(); - assert!(err.to_string().contains("doc comment"), "{err}"); - } - - #[test] - fn rejects_sync_fn() { - let err = parse_str( - ID, - "/// 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(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( - "id = \"t-0123abcd\", 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(id = \"t-00000001\")] - async fn plain(cx: &mut Context) -> testkit::Result { Ok(()) } - - /// Needs exposure. - #[testkit::test(id = \"t-00000002\", requires = [service_exposure])] - async fn exposed(cx: &mut Context) -> testkit::Result { Ok(()) } - - #[test] - fn not_a_testkit_test() {} - - async fn helper(cx: &mut Context) {} - ", - ) - .unwrap(); - 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"]); - } - - #[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}"); - } -} diff --git a/tests/testkit/testkit/src/lib.rs b/tests/testkit/testkit/src/lib.rs index b54e0cf76a..310b76703d 100644 --- a/tests/testkit/testkit/src/lib.rs +++ b/tests/testkit/testkit/src/lib.rs @@ -3,15 +3,14 @@ //! Shared support for `OpenShell` end-to-end test suites. //! -//! 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]`: +//! End-to-end suites test a running `OpenShell` system 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(id = "t-3tfdx240")] +//! #[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?; @@ -39,7 +38,7 @@ 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 target::Target; pub use testkit_macros::test; #[doc(hidden)] diff --git a/tests/testkit/testkit/src/run.rs b/tests/testkit/testkit/src/run.rs index 9b21720099..1611b658d7 100644 --- a/tests/testkit/testkit/src/run.rs +++ b/tests/testkit/testkit/src/run.rs @@ -1,7 +1,7 @@ // 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. +//! Running one test: load the target, run, clean up. use std::panic::AssertUnwindSafe; @@ -9,17 +9,14 @@ use futures::FutureExt; use crate::context::Context; use crate::error::Result; -use crate::target::{Capability, Target}; +use crate::target::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], } /// Run a test body against the target named by `OPENSHELL_TEST_TARGET`. @@ -35,18 +32,9 @@ where F: AsyncFnOnce(&mut Context) -> Result, { let name = info.name; - eprintln!("[{name}] {} ({})", info.description, info.id); + eprintln!("[{name}] {}", info.description); let target = Target::from_env().unwrap_or_else(|error| panic!("[{name}] {error}")); - let missing = missing_requirements(&info, &target); - // Whoever selects tests must exclude those the target can't support. - // Reaching here means they didn't, so fail rather than report a pass for - // an untested behavior. - assert!( - missing.is_empty(), - "[{name}] requires {}, which the target does not provide", - missing.join(", ") - ); let client = target .connect() @@ -71,34 +59,3 @@ where ), } } - -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-00000000", - name: "t", - description: "d", - requires: &[Capability::Gpu, Capability::ServiceExposure], - }; - assert_eq!(missing_requirements(&info, &target), ["service_exposure"]); - } -} diff --git a/tests/testkit/testkit/src/target.rs b/tests/testkit/testkit/src/target.rs index f783d8f117..0997db0545 100644 --- a/tests/testkit/testkit/src/target.rs +++ b/tests/testkit/testkit/src/target.rs @@ -1,7 +1,7 @@ // 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. +//! The target: how tests reach the system under test. //! //! An environment writes a target file; tests find it through //! [`TARGET_ENV`]. Relative paths in the file resolve against its directory. @@ -17,9 +17,6 @@ //! binary: /usr/bin/openshell # default: `openshell` on PATH //! gateway: tmachine # the name the gateway is registered under //! workspace: default -//! capabilities: -//! compute_driver: docker -//! service_exposure: true //! ``` use std::path::{Path, PathBuf}; @@ -48,8 +45,6 @@ pub struct Target { pub cli: Option, #[serde(default = "default_workspace")] pub workspace: String, - #[serde(default)] - pub capabilities: Capabilities, #[serde(skip)] base_dir: PathBuf, } @@ -96,55 +91,6 @@ 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, - /// 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 = [...])]`. -/// -/// 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, - PersistentWorkspace, - ServiceExposure, -} - -impl Capability { - pub const fn name(self) -> &'static str { - match self { - Self::Gpu => "gpu", - Self::PersistentWorkspace => "persistent_workspace", - Self::ServiceExposure => "service_exposure", - } - } -} - -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, - } - } -} - fn default_workspace() -> String { "default".to_string() } @@ -348,7 +294,7 @@ mod tests { #[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", + "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\n", ) .unwrap(); let tls = target.gateway.tls.as_ref().unwrap(); @@ -361,15 +307,12 @@ mod tests { 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] From 35e356e8cc50d22b5e84efb77e51470517865053 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Mon, 19 Oct 2026 14:32:56 -0700 Subject: [PATCH 08/19] docs(testkit): describe what testkit provides today Signed-off-by: Matthew Grossman --- TESTING.md | 13 +++----- tests/testkit/README.md | 73 +++++++++-------------------------------- 2 files changed, 21 insertions(+), 65 deletions(-) diff --git a/TESTING.md b/TESTING.md index 02711c1123..ec2b6102e7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -132,14 +132,11 @@ driver configuration boundary. ### testkit (`tests/testkit/`) -`testkit` is the support library for SDK-driven end-to-end suites under -`tests/suites/`. Tests are `#[testkit::test]` functions with a required stable -`id`, a required doc comment, and optional `requires = [...]` capabilities. -They run against the gateway described by the target file named in -`OPENSHELL_TEST_TARGET`. No suite uses it yet. See -[tests/testkit/README.md](tests/testkit/README.md) for how to write tests, what -a conformance test may assert, and how to run against a hand-written target -file. +`testkit` is the support library for SDK-driven end-to-end suites. Tests are +`#[testkit::test]` functions with a required doc comment. They run against the +gateway described by the target file named in `OPENSHELL_TEST_TARGET`. See +[tests/testkit/README.md](tests/testkit/README.md) for how to write and run +tests. ### Python E2E (`e2e/python/`) diff --git a/tests/testkit/README.md b/tests/testkit/README.md index 34b2945465..e01e8d023e 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -1,8 +1,7 @@ # testkit -Shared support for end-to-end test suites under `tests/suites/`. Suites test -a running OpenShell gateway through the Rust SDK and the installed `openshell` -CLI. No suite uses testkit yet; existing suites move to it one at a time. +Shared support for end-to-end test suites. Suites test a running OpenShell +gateway through the Rust SDK and the installed `openshell` CLI. This is a separate Cargo workspace with its own lockfile: @@ -10,7 +9,6 @@ This is a separate Cargo workspace with its own lockfile: |---|---| | `testkit` | Reads the target file, connects to the gateway, creates and cleans up sandboxes, runs the CLI. Also builds `testkit-ready`. | | `testkit-macros` | The `#[testkit::test]` attribute. | -| `testkit-spec` | Parses `#[testkit::test]` functions, shared by the attribute and tools that list tests. | ## Writing a test @@ -22,7 +20,7 @@ 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(id = "t-3tfdx240")] +#[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?; @@ -31,44 +29,25 @@ async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { } ``` -- **`id` is required.** Use `t-` followed by eight random lowercase letters or - digits. Results and coverage records refer to tests by this ID, so never - change or reuse it, including when you rename or move the test. Omit `id` - on a new test and the compile error suggests a fresh one. - **The doc comment is required.** It is the test's description. -- **`requires` declares capabilities** the test needs, for example - `#[testkit::test(id = "...", requires = [service_exposure])]`. The names are - the boolean fields of `capabilities` in the target file. An unknown name - fails to compile. If the target lacks a required capability, the test - fails rather than passing untested. See - [Excluding tests by capability](#excluding-tests-by-capability). -- **The function name is the nextest test name.** Rename it freely; the ID - keeps the history. +- **The function name is the nextest test name.** Cleanup runs after every test, whether it passes, fails, or panics. It only deletes sandboxes created through `cx.sandbox(...)`. Delete anything else a test creates, such as providers, in the test itself. -## What a conformance test may assert - -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 gateway behavior -the contract does not state; file an issue to document it instead. - -- Assert on a sandbox's `phase` and `exit_code`, not condition reasons, which - the API leaves to the platform. -- To check that something did not happen, read state (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`. -- Act through the interface that makes the promise. Upload, download, - connect, and port forwarding exist only in the CLI, so call - `cx.cli([...])` and rely on its exit code and `--output json`, not its - table text. -- For API calls the SDK does not wrap yet, add a testkit helper over the - generated gRPC client rather than calling it from tests. +## What a test can do + +`Context` currently provides: + +- `cx.sandbox(suffix)` to build and create a sandbox with a run-unique name. + The returned `Sandbox` can wait for ready or finished, stop, start, exec, + read its config and draft policy, set a setting, and delete. +- `cx.expect_listed` and `cx.expect_absent` to check a sandbox in list and get. +- `cx.cli([...])` to run the installed CLI against the same gateway, with + `success()`, `failure()`, and `json()` on the output. +- `cx.client()` for SDK calls testkit does not wrap. +- `poll_until` for waiting on other state. ## Running against a gateway @@ -88,11 +67,6 @@ cli: # needed by tests that call cx.cli binary: /usr/bin/openshell # default: `openshell` on PATH gateway: local # default: the CLI's active gateway workspace: default -capabilities: - compute_driver: docker - service_exposure: true - persistent_workspace: false - gpu: false ``` Wait for the gateway, then run a suite. Run tests that share a gateway one at @@ -107,21 +81,6 @@ cargo nextest run --manifest-path /Cargo.toml --test-threads 1 `testkit-ready [TIMEOUT_SECS]` exits non-zero if the gateway is not healthy in time or the target's CLI does not report it connected. -### Excluding tests by capability - -Nothing selects tests by capability yet, so a test whose `requires` the target -does not provide fails with `requires , which the target does not -provide`. Find those tests and exclude them by name with a nextest filter: - -```shell -grep -rn 'requires = \[' /tests -cargo nextest run --manifest-path /Cargo.toml --test-threads 1 \ - -E 'not test(=stopped_sandbox_keeps_workspace)' -``` - -Combine names with `or` inside the parentheses, for example -`-E 'not (test(=a) or test(=b))'`. - ## Checks ```shell From 237601d060b6857ba48898ff2dde803340aeb089 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Mon, 19 Oct 2026 15:23:39 -0700 Subject: [PATCH 09/19] refactor(testkit): find the gateway the way the CLI does Replace the target file (OPENSHELL_TEST_TARGET) with the CLI's own gateway selection: OPENSHELL_GATEWAY_ENDPOINT, then OPENSHELL_GATEWAY, then the active gateway. A registered gateway's endpoint, auth mode, and mTLS material come from its CLI config directory. cx.cli runs OPENSHELL_BIN against the same gateway and workspace as the SDK. Suites now run unchanged under e2e/with-docker-gateway.sh and e2e/with-podman-gateway.sh, and environments need no extra file. Signed-off-by: Matthew Grossman --- TESTING.md | 5 +- tests/testkit/Cargo.lock | 19 +- tests/testkit/README.md | 41 +- tests/testkit/testkit/Cargo.toml | 5 +- .../testkit/testkit/src/bin/testkit-ready.rs | 19 +- tests/testkit/testkit/src/cli.rs | 72 +++- tests/testkit/testkit/src/context.rs | 14 +- tests/testkit/testkit/src/gateway.rs | 299 ++++++++++++++ tests/testkit/testkit/src/lib.rs | 10 +- tests/testkit/testkit/src/run.rs | 15 +- tests/testkit/testkit/src/target.rs | 378 ------------------ 11 files changed, 427 insertions(+), 450 deletions(-) create mode 100644 tests/testkit/testkit/src/gateway.rs delete mode 100644 tests/testkit/testkit/src/target.rs diff --git a/TESTING.md b/TESTING.md index ec2b6102e7..ae54e04294 100644 --- a/TESTING.md +++ b/TESTING.md @@ -133,8 +133,9 @@ driver configuration boundary. ### testkit (`tests/testkit/`) `testkit` is the support library for SDK-driven end-to-end suites. Tests are -`#[testkit::test]` functions with a required doc comment. They run against the -gateway described by the target file named in `OPENSHELL_TEST_TARGET`. See +`#[testkit::test]` functions with a required doc comment. They find the +gateway the same way the CLI does, through `OPENSHELL_GATEWAY`, +`OPENSHELL_GATEWAY_ENDPOINT`, or the active gateway. See [tests/testkit/README.md](tests/testkit/README.md) for how to write and run tests. diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index d7fa1f856a..b0fa677e1b 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -1863,6 +1863,22 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openshell-bootstrap" +version = "0.0.0" +dependencies = [ + "bytes", + "miette", + "openshell-core", + "openshell-crypto", + "rcgen", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "openshell-core" version = "0.0.0" @@ -1970,7 +1986,8 @@ name = "openshell-testkit" version = "0.0.0" dependencies = [ "futures", - "noyalib", + "openshell-bootstrap", + "openshell-core", "openshell-sdk", "openshell-testkit-macros", "rand 0.9.4", diff --git a/tests/testkit/README.md b/tests/testkit/README.md index e01e8d023e..eb1125132f 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -7,7 +7,7 @@ This is a separate Cargo workspace with its own lockfile: | Crate | Purpose | |---|---| -| `testkit` | Reads the target file, connects to the gateway, creates and cleans up sandboxes, runs the CLI. Also builds `testkit-ready`. | +| `testkit` | Finds and connects to the gateway, creates and cleans up sandboxes, runs the CLI. Also builds `testkit-ready`. | | `testkit-macros` | The `#[testkit::test]` attribute. | ## Writing a test @@ -51,35 +51,36 @@ test creates, such as providers, in the test itself. ## Running against a gateway -Tests read the system under test from the target file named by -`OPENSHELL_TEST_TARGET`. Relative paths resolve against the file's directory. - -```yaml -gateway: - endpoint: https://127.0.0.1:17670 # http:// for plaintext - tls: # optional; cert and key together select mTLS - ca: ca.crt - client_cert: client.crt - client_key: client.key - auth: # optional - bearer_token_file: token -cli: # needed by tests that call cx.cli - binary: /usr/bin/openshell # default: `openshell` on PATH - gateway: local # default: the CLI's active gateway -workspace: default -``` +Tests find the gateway the same way the `openshell` CLI does: + +1. `OPENSHELL_GATEWAY_ENDPOINT`: a gateway URL. Plaintext, or TLS without a + client certificate. +2. `OPENSHELL_GATEWAY`: the name of a gateway registered with the CLI. +3. The CLI's active gateway, set by `openshell gateway select`. + +A registered gateway's endpoint and auth mode come from +`$XDG_CONFIG_HOME/openshell/gateways//metadata.json`. For mTLS, the CA +and client certificate come from its `mtls/` directory. testkit supports +plaintext and mTLS gateways. `OPENSHELL_WORKSPACE` selects the workspace +(default `default`), and `OPENSHELL_BIN` selects the CLI that `cx.cli` runs +(default `openshell` on PATH). The CLI always targets the same gateway and +workspace as the SDK. Each test logs the gateway it used. Wait for the gateway, then run a suite. Run tests that share a gateway one at a time: ```shell -export OPENSHELL_TEST_TARGET=$PWD/target.yaml +export OPENSHELL_GATEWAY= cargo run --manifest-path tests/testkit/Cargo.toml --bin testkit-ready -- 300 cargo nextest run --manifest-path /Cargo.toml --test-threads 1 ``` `testkit-ready [TIMEOUT_SECS]` exits non-zero if the gateway is not healthy in -time or the target's CLI does not report it connected. +time or the CLI does not report it connected. + +`e2e/with-docker-gateway.sh` and `e2e/with-podman-gateway.sh` start an +ephemeral gateway and export `OPENSHELL_GATEWAY` and `OPENSHELL_BIN`, so a +suite runs under them unchanged. ## Checks diff --git a/tests/testkit/testkit/Cargo.toml b/tests/testkit/testkit/Cargo.toml index 7e1f4a2210..f33c63674a 100644 --- a/tests/testkit/testkit/Cargo.toml +++ b/tests/testkit/testkit/Cargo.toml @@ -15,12 +15,13 @@ publish = false name = "testkit" [dependencies] +openshell-bootstrap = { path = "../../../crates/openshell-bootstrap" } +openshell-core = { path = "../../../crates/openshell-core", default-features = false } openshell-sdk = { path = "../../../crates/openshell-sdk" } openshell-testkit-macros = { path = "../testkit-macros" } futures = "0.3" rand = "0.9" -serde = { version = "1", features = ["derive"] } +serde = "1" 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", "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 5d4e8aea1f..9675d1cb95 100644 --- a/tests/testkit/testkit/src/bin/testkit-ready.rs +++ b/tests/testkit/testkit/src/bin/testkit-ready.rs @@ -1,17 +1,17 @@ // 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. +//! Wait until the 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, or if the environment's CLI does not reach it. +//! Usage: `testkit-ready [TIMEOUT_SECS]` (default 60), with the gateway +//! selected as for tests. Run it once per environment before the test +//! binaries; exits non-zero with the last error if the gateway is not healthy +//! in time, or if the CLI does not reach it. use std::process::ExitCode; use std::time::Duration; -use testkit::Target; +use testkit::{Cli, Gateway}; const DEFAULT_TIMEOUT_SECS: u64 = 60; @@ -26,9 +26,10 @@ async fn main() -> ExitCode { } }; let result = async { - let target = Target::from_env()?; - let health = target.wait_ready(Duration::from_secs(timeout)).await?; - target.check_cli().await?; + let gateway = Gateway::from_env()?; + eprintln!("testkit-ready: {}", gateway.describe()); + let health = gateway.wait_ready(Duration::from_secs(timeout)).await?; + Cli::from_env(&gateway).check_connected().await?; testkit::Result::Ok(health) } .await; diff --git a/tests/testkit/testkit/src/cli.rs b/tests/testkit/testkit/src/cli.rs index 10f6c9168c..6b4fadc240 100644 --- a/tests/testkit/testkit/src/cli.rs +++ b/tests/testkit/testkit/src/cli.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Driving the `openshell` CLI the environment installed. +//! Driving the installed `openshell` CLI. //! //! Some promised behavior exists only in the CLI: file upload and download, //! interactive connect, and port forwarding. Tests act through the CLI for @@ -16,16 +16,18 @@ use std::time::Duration; use serde::de::DeserializeOwned; use crate::error::{Error, Result, ResultExt}; -use crate::target::CliTarget; +use crate::gateway::{ENDPOINT_ENV, GATEWAY_ENV, Gateway, WORKSPACE_ENV}; /// How long one CLI invocation may take. pub const CLI_TIMEOUT: Duration = Duration::from_mins(5); +/// The CLI binary to run. Defaults to `openshell` on PATH. +pub const BIN_ENV: &str = "OPENSHELL_BIN"; -/// Runs the target's CLI against the target's gateway. +/// Runs the CLI against the gateway and workspace the tests use. #[derive(Clone, Debug)] pub struct Cli { binary: PathBuf, - gateway: Option, + gateway: Gateway, } /// The buffered result of one CLI invocation. @@ -38,13 +40,31 @@ pub struct CliOutput { } impl Cli { - pub(crate) fn new(target: &CliTarget, binary: PathBuf) -> Self { + /// The CLI named by [`BIN_ENV`], pointed at `gateway`. + pub fn from_env(gateway: &Gateway) -> Self { + let binary = std::env::var_os(BIN_ENV) + .filter(|value| !value.is_empty()) + .map_or_else(|| PathBuf::from("openshell"), PathBuf::from); Self { binary, - gateway: target.gateway.clone(), + gateway: gateway.clone(), } } + /// Check that the CLI reaches the gateway: `openshell status` must report + /// it connected. + pub async fn check_connected(&self) -> Result { + let output = self.run(["status", "--output", "json"]).await?.success()?; + let status: serde_json::Value = output.json()?; + crate::ensure!( + status["status"] == "connected", + "`{}` reports {}", + output.command, + status + ); + Ok(()) + } + /// 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 @@ -75,14 +95,15 @@ impl Cli { process .args(&args) .env("NO_COLOR", "1") - // Select the target's gateway, never one from the caller's - // environment. - .env_remove("OPENSHELL_GATEWAY_ENDPOINT") + .env(WORKSPACE_ENV, &self.gateway.workspace) .stdin(Stdio::null()) .kill_on_drop(true); - match &self.gateway { - Some(gateway) => process.env("OPENSHELL_GATEWAY", gateway), - None => process.env_remove("OPENSHELL_GATEWAY"), + // Select exactly the gateway the SDK connected to. + match &self.gateway.name { + Some(name) => process.env(GATEWAY_ENV, name).env_remove(ENDPOINT_ENV), + None => process + .env(ENDPOINT_ENV, &self.gateway.endpoint) + .env_remove(GATEWAY_ENV), }; if let Some(dir) = dir { process.current_dir(dir); @@ -144,22 +165,41 @@ mod tests { fn cli(binary: &str) -> Cli { Cli { binary: PathBuf::from(binary), - gateway: Some("tmachine".to_string()), + gateway: Gateway::named_for_test("tmachine"), } } #[tokio::test] - async fn runs_with_the_target_gateway_selected() { + async fn runs_with_the_gateway_selected() { let output = cli("sh") - .run(["-c", "echo \"$OPENSHELL_GATEWAY\"; exit 3"]) + .run([ + "-c", + "echo \"$OPENSHELL_GATEWAY|${OPENSHELL_GATEWAY_ENDPOINT-unset}|$OPENSHELL_WORKSPACE\"; exit 3", + ]) .await .unwrap(); - assert_eq!(output.stdout, "tmachine\n"); + assert_eq!(output.stdout, "tmachine|unset|default\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 runs_with_a_raw_endpoint_selected() { + let cli = Cli { + binary: PathBuf::from("sh"), + gateway: Gateway::endpoint_for_test("http://127.0.0.1:1"), + }; + let output = cli + .run([ + "-c", + "echo \"${OPENSHELL_GATEWAY-unset}|$OPENSHELL_GATEWAY_ENDPOINT\"", + ]) + .await + .unwrap(); + assert_eq!(output.stdout, "unset|http://127.0.0.1:1\n"); + } + #[tokio::test] async fn parses_json_output() { let output = cli("sh") diff --git a/tests/testkit/testkit/src/context.rs b/tests/testkit/testkit/src/context.rs index 8471d9590d..a4b2110420 100644 --- a/tests/testkit/testkit/src/context.rs +++ b/tests/testkit/testkit/src/context.rs @@ -34,13 +34,13 @@ type Registry = Arc>>; pub struct Context { client: WorkspaceScopedClient, grpc: Grpc, - cli: Result, + cli: Cli, run_id: String, sandboxes: Registry, } impl Context { - pub(crate) fn new(client: &OpenShellClient, workspace: &str, cli: Result) -> Self { + pub(crate) fn new(client: &OpenShellClient, workspace: &str, cli: Cli) -> Self { Self { client: client.workspace(workspace), grpc: Grpc { @@ -60,7 +60,7 @@ impl Context { I: IntoIterator, S: AsRef, { - self.cli_driver()?.run(args).await + self.cli.run(args).await } /// Like [`Context::cli`], with `dir` as the working directory. @@ -69,13 +69,7 @@ impl Context { 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())) + self.cli.run_in(Some(dir), args).await } /// The ID unique to this test run, used in resource names. diff --git a/tests/testkit/testkit/src/gateway.rs b/tests/testkit/testkit/src/gateway.rs new file mode 100644 index 0000000000..a42a57b920 --- /dev/null +++ b/tests/testkit/testkit/src/gateway.rs @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The gateway under test, found the same way the `openshell` CLI finds it: +//! +//! 1. `OPENSHELL_GATEWAY_ENDPOINT`: a gateway URL. Plaintext, or TLS without +//! a client certificate. +//! 2. `OPENSHELL_GATEWAY`: the name of a gateway registered with the CLI. +//! 3. The CLI's active gateway, set by `openshell gateway select`. +//! +//! A registered gateway's endpoint and auth mode come from its +//! `metadata.json`. For mTLS, the CA and client certificate come from its +//! `mtls/` directory. `OPENSHELL_WORKSPACE` selects the workspace. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use openshell_bootstrap::GatewayMetadata; +use openshell_sdk::{ClientConfig, EdgeAuthInterceptor, Health, OpenShellClient, ServiceStatus}; +use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity}; + +use crate::error::{Error, Result, ResultExt}; + +pub const ENDPOINT_ENV: &str = "OPENSHELL_GATEWAY_ENDPOINT"; +pub const GATEWAY_ENV: &str = "OPENSHELL_GATEWAY"; +pub const WORKSPACE_ENV: &str = "OPENSHELL_WORKSPACE"; +const DEFAULT_WORKSPACE: &str = "default"; + +// 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); + +#[derive(Clone, Debug)] +pub struct Gateway { + /// The registered name, or `None` for a raw endpoint. + pub name: Option, + pub endpoint: String, + pub workspace: String, + /// The directory holding `ca.crt`, `tls.crt`, and `tls.key` for mTLS. + mtls_dir: Option, +} + +impl Gateway { + /// Find the gateway from the environment, as the CLI does. + pub fn from_env() -> Result { + let workspace = env(WORKSPACE_ENV).unwrap_or_else(|| DEFAULT_WORKSPACE.to_string()); + if let Some(endpoint) = env(ENDPOINT_ENV) { + return Ok(Self::from_endpoint(endpoint, workspace)); + } + let name = env(GATEWAY_ENV) + .or_else(openshell_bootstrap::load_active_gateway) + .ok_or_else(|| { + Error::msg(format!( + "no gateway selected; set {GATEWAY_ENV} to a registered gateway, \ + {ENDPOINT_ENV} to a gateway URL, or select one with \ + `openshell gateway select`" + )) + })?; + let metadata = openshell_bootstrap::load_gateway_metadata(&name) + .map_err(Error::from_display) + .context(format!("load gateway {name}"))?; + let mtls_dir = openshell_core::paths::openshell_config_dir() + .map_err(Error::from_display)? + .join("gateways") + .join(&name) + .join("mtls"); + Self::from_metadata(name, &metadata, mtls_dir, workspace) + } + + fn from_endpoint(endpoint: String, workspace: String) -> Self { + Self { + name: None, + endpoint, + workspace, + mtls_dir: None, + } + } + + fn from_metadata( + name: String, + metadata: &GatewayMetadata, + mtls_dir: PathBuf, + workspace: String, + ) -> Result { + let endpoint = metadata.gateway_endpoint.clone(); + let mtls_dir = match metadata.auth_mode.as_deref() { + None | Some("mtls") if endpoint.starts_with("https://") => Some(mtls_dir), + None | Some("mtls" | "plaintext") => None, + Some(mode) => { + return Err(Error::msg(format!( + "gateway {name} uses {mode} auth; testkit supports plaintext and mTLS gateways" + ))); + } + }; + Ok(Self { + name: Some(name), + endpoint, + workspace, + mtls_dir, + }) + } + + #[cfg(test)] + pub(crate) fn named_for_test(name: &str) -> Self { + Self { + name: Some(name.to_string()), + endpoint: "https://127.0.0.1:1".to_string(), + workspace: DEFAULT_WORKSPACE.to_string(), + mtls_dir: None, + } + } + + #[cfg(test)] + pub(crate) fn endpoint_for_test(endpoint: &str) -> Self { + Self::from_endpoint(endpoint.to_string(), DEFAULT_WORKSPACE.to_string()) + } + + /// One line naming the gateway, for test logs. + pub fn describe(&self) -> String { + let auth = match (&self.mtls_dir, self.endpoint.starts_with("https://")) { + (Some(dir), _) => format!("mTLS from {}", dir.display()), + (None, true) => "TLS".to_string(), + (None, false) => "plaintext".to_string(), + }; + let name = self + .name + .as_deref() + .map_or_else(|| format!("from {ENDPOINT_ENV}"), |name| name.to_string()); + format!( + "gateway {name} at {} ({auth}), workspace {}", + self.endpoint, self.workspace + ) + } + + /// Connect to the gateway. + pub async fn connect(&self) -> Result { + let Some(dir) = &self.mtls_dir else { + return OpenShellClient::connect(ClientConfig::new(self.endpoint.clone())) + .await + .context(format!("connect to {}", self.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 ca = Certificate::from_pem(read(&dir.join("ca.crt"))?); + let identity = Identity::from_pem(read(&dir.join("tls.crt"))?, read(&dir.join("tls.key"))?); + let channel = Endpoint::from_shared(self.endpoint.clone()) + .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(ClientTlsConfig::new().ca_certificate(ca).identity(identity)) + .map_err(|error| Error::from_error(&error)) + .context("configure mTLS")? + .connect() + .await + .map_err(|error| Error::from_error(&error)) + .context(format!("connect to {} with mTLS", self.endpoint))?; + let interceptor = EdgeAuthInterceptor::new(None, 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.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) + } +} + +fn env(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +fn read(path: &Path) -> Result> { + std::fs::read(path) + .map_err(Error::from_display) + .context(format!("read {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn metadata(endpoint: &str, auth_mode: Option<&str>) -> GatewayMetadata { + serde_json::from_value(serde_json::json!({ + "name": "gw", + "gateway_endpoint": endpoint, + "is_remote": false, + "gateway_port": 0, + "auth_mode": auth_mode, + })) + .unwrap() + } + + fn from_metadata(endpoint: &str, auth_mode: Option<&str>) -> Result { + Gateway::from_metadata( + "gw".to_string(), + &metadata(endpoint, auth_mode), + PathBuf::from("/config/gateways/gw/mtls"), + "default".to_string(), + ) + } + + #[test] + fn https_gateway_uses_mtls_by_default() { + for mode in [None, Some("mtls")] { + let gateway = from_metadata("https://127.0.0.1:17670", mode).unwrap(); + assert_eq!( + gateway.mtls_dir, + Some(PathBuf::from("/config/gateways/gw/mtls")) + ); + assert_eq!( + gateway.describe(), + "gateway gw at https://127.0.0.1:17670 (mTLS from /config/gateways/gw/mtls), workspace default" + ); + } + } + + #[test] + fn plaintext_gateway_has_no_mtls() { + for (endpoint, mode) in [ + ("http://127.0.0.1:18080", Some("plaintext")), + ("http://127.0.0.1:18080", None), + ] { + let gateway = from_metadata(endpoint, mode).unwrap(); + assert_eq!(gateway.mtls_dir, None); + assert!(gateway.describe().contains("(plaintext)")); + } + } + + #[test] + fn rejects_unsupported_auth_modes() { + let err = from_metadata("https://gw.example.com", Some("cloudflare_jwt")).unwrap_err(); + assert!( + err.to_string().contains("uses cloudflare_jwt auth"), + "{err}" + ); + } + + #[test] + fn raw_endpoint_has_no_name() { + let gateway = Gateway::from_endpoint("http://127.0.0.1:1".to_string(), "ws".to_string()); + assert_eq!( + gateway.describe(), + "gateway from OPENSHELL_GATEWAY_ENDPOINT at http://127.0.0.1:1 (plaintext), workspace ws" + ); + } + + #[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 gateway = + Gateway::from_endpoint(format!("http://127.0.0.1:{port}"), "default".to_string()); + let err = gateway.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}" + ); + } +} diff --git a/tests/testkit/testkit/src/lib.rs b/tests/testkit/testkit/src/lib.rs index 310b76703d..168fdbe894 100644 --- a/tests/testkit/testkit/src/lib.rs +++ b/tests/testkit/testkit/src/lib.rs @@ -19,26 +19,26 @@ //! } //! ``` //! -//! Tests read the system under test from the target file named by -//! `OPENSHELL_TEST_TARGET` (see [`target`]). +//! Tests find the gateway the same way the `openshell` CLI does (see +//! [`gateway`]). mod cli; mod context; mod error; +pub mod gateway; mod poll; mod run; -pub mod target; -pub use cli::{CLI_TIMEOUT, Cli, CliOutput}; +pub use cli::{BIN_ENV, CLI_TIMEOUT, Cli, CliOutput}; pub use context::{ Context, DELETE_TIMEOUT, EXEC_TIMEOUT, ExecOutput, READY_TIMEOUT, STOP_TIMEOUT, Sandbox, SandboxBuilder, }; pub use error::{Error, Result, ResultExt}; +pub use gateway::Gateway; pub use openshell_sdk as sdk; pub use poll::{Poll, poll_until}; pub use run::{TestInfo, run}; -pub use target::Target; pub use testkit_macros::test; #[doc(hidden)] diff --git a/tests/testkit/testkit/src/run.rs b/tests/testkit/testkit/src/run.rs index 1611b658d7..731ead0557 100644 --- a/tests/testkit/testkit/src/run.rs +++ b/tests/testkit/testkit/src/run.rs @@ -1,15 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Running one test: load the target, run, clean up. +//! Running one test: find the gateway, run, clean up. use std::panic::AssertUnwindSafe; use futures::FutureExt; +use crate::cli::Cli; use crate::context::Context; use crate::error::Result; -use crate::target::Target; +use crate::gateway::Gateway; /// Identity of a test, generated by `#[testkit::test]`. #[derive(Clone, Copy, Debug)] @@ -19,7 +20,7 @@ pub struct TestInfo { pub description: &'static str, } -/// Run a test body against the target named by `OPENSHELL_TEST_TARGET`. +/// Run a test body against the gateway found by [`Gateway::from_env`]. /// /// Cleanup runs whether the body passes, fails, or panics. The test fails if /// the body fails, or if cleanup fails after the body passed. @@ -34,13 +35,13 @@ where let name = info.name; eprintln!("[{name}] {}", info.description); - let target = Target::from_env().unwrap_or_else(|error| panic!("[{name}] {error}")); - - let client = target + let gateway = Gateway::from_env().unwrap_or_else(|error| panic!("[{name}] {error}")); + eprintln!("[{name}] {}", gateway.describe()); + let client = gateway .connect() .await .unwrap_or_else(|error| panic!("[{name}] {error}")); - let mut cx = Context::new(&client, &target.workspace, target.cli()); + let mut cx = Context::new(&client, &gateway.workspace, Cli::from_env(&gateway)); 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 deleted file mode 100644 index 0997db0545..0000000000 --- a/tests/testkit/testkit/src/target.rs +++ /dev/null @@ -1,378 +0,0 @@ -// 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. -//! -//! 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 -//! cli: -//! binary: /usr/bin/openshell # default: `openshell` on PATH -//! gateway: tmachine # the name the gateway is registered under -//! workspace: default -//! ``` - -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -use openshell_sdk::{ - AuthConfig, ClientConfig, EdgeAuthInterceptor, Health, OpenShellClient, ServiceStatus, -}; -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. -pub const TARGET_ENV: &str = "OPENSHELL_TEST_TARGET"; - -#[derive(Debug, Deserialize)] -#[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(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, -} - -/// 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 { - pub bearer_token_file: PathBuf, -} - -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); -const READY_INTERVAL: Duration = Duration::from_secs(1); - -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) - } - - /// 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) - .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| 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| Error::from_error(&error)) - .context("configure mTLS")? - .connect() - .await - .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)] -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\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") - ); - } - - #[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()); - } - - #[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(); - 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}"); - } - - #[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 9ae29ab9e325e9edc77bcee62825c798d3dc2cb1 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Tue, 20 Oct 2026 21:54:09 -0700 Subject: [PATCH 10/19] fix(testkit): keep labels when creating a sandbox with a policy The policy path creates the sandbox through gRPC and dropped the spec's labels without an error. Pass them in the create request. Signed-off-by: Matthew Grossman --- tests/testkit/testkit/src/context.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/testkit/testkit/src/context.rs b/tests/testkit/testkit/src/context.rs index a4b2110420..add5f25799 100644 --- a/tests/testkit/testkit/src/context.rs +++ b/tests/testkit/testkit/src/context.rs @@ -53,7 +53,7 @@ impl Context { } } - /// Run the environment's `openshell` CLI against the target's gateway. + /// Run the `openshell` CLI against the test's gateway and workspace. /// A non-zero exit is returned, not raised. pub async fn cli(&self, args: I) -> Result where @@ -258,10 +258,11 @@ impl SandboxBuilder { && 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" + "testkit creates a sandbox with a policy from its name, labels, command, restart policy, and policy only" ); let request = proto::CreateSandboxRequest { name: name.clone(), + labels: self.spec.labels, workspace_scope: self.grpc.scope(), spec: Some(proto::SandboxSpec { command: self.spec.command, From 2c1d7a96c94d570c0c1517f19ee93ae0a632f757 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 12:22:13 -0700 Subject: [PATCH 11/19] fix(testkit): use the registered gateway's credentials for an endpoint When OPENSHELL_GATEWAY_ENDPOINT is set, the CLI takes credentials from the gateway named by OPENSHELL_GATEWAY, or else the registered gateway with that endpoint. testkit connected without a client certificate instead, so an mTLS endpoint such as the one e2e/rust/e2e-vm.sh exports worked for cx.cli but not for the SDK. Resolve it the way the CLI does, and pass both the name and the endpoint to cx.cli. Signed-off-by: Matthew Grossman --- tests/testkit/README.md | 6 +- tests/testkit/testkit/src/cli.rs | 11 ++- tests/testkit/testkit/src/gateway.rs | 134 +++++++++++++++++++++++---- 3 files changed, 127 insertions(+), 24 deletions(-) diff --git a/tests/testkit/README.md b/tests/testkit/README.md index eb1125132f..14f7f694ea 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -53,8 +53,10 @@ test creates, such as providers, in the test itself. Tests find the gateway the same way the `openshell` CLI does: -1. `OPENSHELL_GATEWAY_ENDPOINT`: a gateway URL. Plaintext, or TLS without a - client certificate. +1. `OPENSHELL_GATEWAY_ENDPOINT`: a gateway URL. Its credentials come from the + gateway named by `OPENSHELL_GATEWAY`, or else the registered gateway with + that endpoint. A URL no gateway is registered for is used as is: + plaintext, or TLS without a client certificate. 2. `OPENSHELL_GATEWAY`: the name of a gateway registered with the CLI. 3. The CLI's active gateway, set by `openshell gateway select`. diff --git a/tests/testkit/testkit/src/cli.rs b/tests/testkit/testkit/src/cli.rs index 6b4fadc240..6c75c5328b 100644 --- a/tests/testkit/testkit/src/cli.rs +++ b/tests/testkit/testkit/src/cli.rs @@ -100,11 +100,14 @@ impl Cli { .kill_on_drop(true); // Select exactly the gateway the SDK connected to. match &self.gateway.name { - Some(name) => process.env(GATEWAY_ENV, name).env_remove(ENDPOINT_ENV), - None => process - .env(ENDPOINT_ENV, &self.gateway.endpoint) - .env_remove(GATEWAY_ENV), + Some(name) => process.env(GATEWAY_ENV, name), + None => process.env_remove(GATEWAY_ENV), }; + if self.gateway.endpoint_from_env { + process.env(ENDPOINT_ENV, &self.gateway.endpoint); + } else { + process.env_remove(ENDPOINT_ENV); + } if let Some(dir) = dir { process.current_dir(dir); } diff --git a/tests/testkit/testkit/src/gateway.rs b/tests/testkit/testkit/src/gateway.rs index a42a57b920..783acf4b90 100644 --- a/tests/testkit/testkit/src/gateway.rs +++ b/tests/testkit/testkit/src/gateway.rs @@ -3,8 +3,10 @@ //! The gateway under test, found the same way the `openshell` CLI finds it: //! -//! 1. `OPENSHELL_GATEWAY_ENDPOINT`: a gateway URL. Plaintext, or TLS without -//! a client certificate. +//! 1. `OPENSHELL_GATEWAY_ENDPOINT`: a gateway URL. Its credentials come from +//! the gateway named by `OPENSHELL_GATEWAY`, or else the registered gateway +//! with that endpoint. A URL no gateway is registered for is used as is: +//! plaintext, or TLS without a client certificate. //! 2. `OPENSHELL_GATEWAY`: the name of a gateway registered with the CLI. //! 3. The CLI's active gateway, set by `openshell gateway select`. //! @@ -33,12 +35,14 @@ const READY_INTERVAL: Duration = Duration::from_secs(1); #[derive(Clone, Debug)] pub struct Gateway { - /// The registered name, or `None` for a raw endpoint. + /// The registered name, or `None` for an unregistered endpoint. pub name: Option, pub endpoint: String, pub workspace: String, /// The directory holding `ca.crt`, `tls.crt`, and `tls.key` for mTLS. mtls_dir: Option, + /// The endpoint came from [`ENDPOINT_ENV`], so the CLI must get it too. + pub(crate) endpoint_from_env: bool, } impl Gateway { @@ -46,7 +50,21 @@ impl Gateway { pub fn from_env() -> Result { let workspace = env(WORKSPACE_ENV).unwrap_or_else(|| DEFAULT_WORKSPACE.to_string()); if let Some(endpoint) = env(ENDPOINT_ENV) { - return Ok(Self::from_endpoint(endpoint, workspace)); + let Some(name) = env(GATEWAY_ENV).or_else(|| find_registered(&endpoint)) else { + return Ok(Self::from_endpoint(endpoint, workspace)); + }; + // Like the CLI, a named gateway need not be registered yet. + let auth_mode = openshell_bootstrap::get_gateway_metadata(&name) + .and_then(|metadata| metadata.auth_mode); + let mtls_dir = mtls_dir(&name)?; + return Self::registered( + name, + endpoint, + auth_mode.as_deref(), + mtls_dir, + workspace, + true, + ); } let name = env(GATEWAY_ENV) .or_else(openshell_bootstrap::load_active_gateway) @@ -60,12 +78,15 @@ impl Gateway { let metadata = openshell_bootstrap::load_gateway_metadata(&name) .map_err(Error::from_display) .context(format!("load gateway {name}"))?; - let mtls_dir = openshell_core::paths::openshell_config_dir() - .map_err(Error::from_display)? - .join("gateways") - .join(&name) - .join("mtls"); - Self::from_metadata(name, &metadata, mtls_dir, workspace) + let mtls_dir = mtls_dir(&name)?; + Self::registered( + name, + metadata.gateway_endpoint, + metadata.auth_mode.as_deref(), + mtls_dir, + workspace, + false, + ) } fn from_endpoint(endpoint: String, workspace: String) -> Self { @@ -74,17 +95,19 @@ impl Gateway { endpoint, workspace, mtls_dir: None, + endpoint_from_env: true, } } - fn from_metadata( + fn registered( name: String, - metadata: &GatewayMetadata, + endpoint: String, + auth_mode: Option<&str>, mtls_dir: PathBuf, workspace: String, + endpoint_from_env: bool, ) -> Result { - let endpoint = metadata.gateway_endpoint.clone(); - let mtls_dir = match metadata.auth_mode.as_deref() { + let mtls_dir = match auth_mode { None | Some("mtls") if endpoint.starts_with("https://") => Some(mtls_dir), None | Some("mtls" | "plaintext") => None, Some(mode) => { @@ -98,6 +121,7 @@ impl Gateway { endpoint, workspace, mtls_dir, + endpoint_from_env, }) } @@ -108,6 +132,7 @@ impl Gateway { endpoint: "https://127.0.0.1:1".to_string(), workspace: DEFAULT_WORKSPACE.to_string(), mtls_dir: None, + endpoint_from_env: false, } } @@ -196,6 +221,36 @@ impl Gateway { } } +fn mtls_dir(name: &str) -> Result { + Ok(openshell_core::paths::openshell_config_dir() + .map_err(Error::from_display)? + .join("gateways") + .join(name) + .join("mtls")) +} + +/// The registered gateway with `endpoint`, preferring the active one, as the +/// CLI does. +fn find_registered(endpoint: &str) -> Option { + let active = openshell_bootstrap::load_active_gateway() + .and_then(|name| openshell_bootstrap::get_gateway_metadata(&name)); + let all = openshell_bootstrap::list_gateways().unwrap_or_default(); + match_endpoint(endpoint, active, all) +} + +fn match_endpoint( + endpoint: &str, + active: Option, + all: Vec, +) -> Option { + let endpoint = endpoint.trim_end_matches('/'); + active + .into_iter() + .chain(all) + .find(|metadata| metadata.gateway_endpoint.trim_end_matches('/') == endpoint) + .map(|metadata| metadata.name) +} + fn env(name: &str) -> Option { std::env::var(name) .ok() @@ -212,9 +267,9 @@ fn read(path: &Path) -> Result> { mod tests { use super::*; - fn metadata(endpoint: &str, auth_mode: Option<&str>) -> GatewayMetadata { + fn metadata(name: &str, endpoint: &str, auth_mode: Option<&str>) -> GatewayMetadata { serde_json::from_value(serde_json::json!({ - "name": "gw", + "name": name, "gateway_endpoint": endpoint, "is_remote": false, "gateway_port": 0, @@ -224,11 +279,13 @@ mod tests { } fn from_metadata(endpoint: &str, auth_mode: Option<&str>) -> Result { - Gateway::from_metadata( + Gateway::registered( "gw".to_string(), - &metadata(endpoint, auth_mode), + endpoint.to_string(), + auth_mode, PathBuf::from("/config/gateways/gw/mtls"), "default".to_string(), + false, ) } @@ -277,6 +334,47 @@ mod tests { ); } + #[test] + fn endpoint_finds_the_registered_gateway() { + let all = vec![ + metadata("other", "https://127.0.0.1:2", None), + metadata("gw", "https://127.0.0.1:1/", None), + ]; + assert_eq!( + match_endpoint("https://127.0.0.1:1", None, all.clone()), + Some("gw".to_string()) + ); + assert_eq!( + match_endpoint("https://127.0.0.1:3", None, all.clone()), + None + ); + // The active gateway wins when two share an endpoint. + let active = metadata("active", "https://127.0.0.1:2", None); + assert_eq!( + match_endpoint("https://127.0.0.1:2", Some(active), all), + Some("active".to_string()) + ); + } + + #[test] + fn endpoint_from_env_keeps_the_gateway_credentials() { + let gateway = Gateway::registered( + "gw".to_string(), + "https://localhost:1".to_string(), + None, + PathBuf::from("/config/gateways/gw/mtls"), + "default".to_string(), + true, + ) + .unwrap(); + assert_eq!(gateway.endpoint, "https://localhost:1"); + assert!(gateway.endpoint_from_env); + assert_eq!( + gateway.mtls_dir, + Some(PathBuf::from("/config/gateways/gw/mtls")) + ); + } + #[tokio::test] async fn wait_ready_reports_the_last_error_after_timeout() { // Nothing listens on a port bound and then released. From 5714c299266595defc41e83e1b777a55a2a570cf Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 12:22:14 -0700 Subject: [PATCH 12/19] docs(tests): list testkit's gateway variables in precedence order OPENSHELL_GATEWAY_ENDPOINT takes precedence over OPENSHELL_GATEWAY. Also mention OPENSHELL_BIN. Signed-off-by: Matthew Grossman --- TESTING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/TESTING.md b/TESTING.md index ae54e04294..e6754df317 100644 --- a/TESTING.md +++ b/TESTING.md @@ -134,8 +134,9 @@ driver configuration boundary. `testkit` is the support library for SDK-driven end-to-end suites. Tests are `#[testkit::test]` functions with a required doc comment. They find the -gateway the same way the CLI does, through `OPENSHELL_GATEWAY`, -`OPENSHELL_GATEWAY_ENDPOINT`, or the active gateway. See +gateway the same way the CLI does, through `OPENSHELL_GATEWAY_ENDPOINT`, +`OPENSHELL_GATEWAY`, or the active gateway, and `cx.cli` runs the CLI named +by `OPENSHELL_BIN`. See [tests/testkit/README.md](tests/testkit/README.md) for how to write and run tests. From 64f54cc7afe5a71d137ba7e82b505788eb4ec3ca Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 12:22:14 -0700 Subject: [PATCH 13/19] fix(testkit): apply other attributes to the generated test #[testkit::test] left attributes such as #[ignore] and #[cfg] on the nested body function, where they have no effect on the test. Move them to the generated test, and reject a second test attribute such as Signed-off-by: Matthew Grossman #[test] or #[tokio::test]. --- tests/testkit/README.md | 2 + tests/testkit/testkit-macros/src/lib.rs | 59 +++++++++++++++++++++---- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/tests/testkit/README.md b/tests/testkit/README.md index 14f7f694ea..9fdd9c52af 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -31,6 +31,8 @@ async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { - **The doc comment is required.** It is the test's description. - **The function name is the nextest test name.** +- **Other attributes apply to the test.** For example, `#[ignore = "reason"]` + skips it unless run with `--run-ignored`. Cleanup runs after every test, whether it passes, fails, or panics. It only deletes sandboxes created through `cx.sandbox(...)`. Delete anything else a diff --git a/tests/testkit/testkit-macros/src/lib.rs b/tests/testkit/testkit-macros/src/lib.rs index 17d0a1b3ee..a3a16cd05d 100644 --- a/tests/testkit/testkit-macros/src/lib.rs +++ b/tests/testkit/testkit-macros/src/lib.rs @@ -15,7 +15,8 @@ //! //! The attribute expands to a `#[tokio::test]` wrapper with the same name, so //! the nextest test name is the function name. The original function is nested -//! in the wrapper and passed to `testkit::run`. +//! in the wrapper and passed to `testkit::run`. Other attributes, such as +//! `#[ignore]` or `#[cfg(...)]`, move to the wrapper. use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; @@ -49,6 +50,12 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { "testkit tests take exactly one argument: `cx: &mut testkit::Context`", )); } + if let Some(attr) = body.attrs.iter().find(|attr| is_test_attr(attr)) { + return Err(syn::Error::new_spanned( + attr, + "`#[testkit::test]` already makes this a test; remove this attribute", + )); + } let description = doc_comment(&body.attrs); if description.is_empty() { return Err(syn::Error::new( @@ -62,18 +69,12 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { let description = LitStr::new(&description, name.span()); let vis = body.vis.clone(); - let doc_attrs: Vec<_> = body - .attrs - .iter() - .filter(|attr| attr.path().is_ident("doc")) - .cloned() - .collect(); - body.attrs.retain(|attr| !attr.path().is_ident("doc")); + let attrs = std::mem::take(&mut body.attrs); body.vis = syn::Visibility::Inherited; body.sig.ident = format_ident!("body"); Ok(quote! { - #(#doc_attrs)* + #(#attrs)* #[::testkit::__private::tokio::test(crate = "::testkit::__private::tokio")] #vis async fn #name() { #body @@ -89,6 +90,14 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { }) } +/// `#[test]`, `#[tokio::test]`, or another test attribute. +fn is_test_attr(attr: &Attribute) -> bool { + attr.path() + .segments + .last() + .is_some_and(|segment| segment.ident == "test") +} + /// The doc comment, joined into one line. Empty if there is none. fn doc_comment(attrs: &[Attribute]) -> String { attrs @@ -159,6 +168,38 @@ mod tests { assert!(err.to_string().contains("exactly one argument"), "{err}"); } + #[test] + fn forwards_other_attributes_to_the_wrapper() { + let out = expand_str( + "", + "/// Doc.\n#[ignore = \"needs persistent workspaces\"]\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap(); + let (wrapper, body) = out.split_once("async fn body").unwrap(); + assert!( + wrapper.contains("# [ignore = \"needs persistent workspaces\"]"), + "{out}" + ); + assert!(!body.contains("ignore"), "{out}"); + } + + #[test] + fn rejects_another_test_attribute() { + for attr in ["#[test]", "#[tokio::test]"] { + let err = expand_str( + "", + &format!( + "/// Doc.\n{attr}\nasync fn t(cx: &mut Context) -> testkit::Result {{ Ok(()) }}" + ), + ) + .unwrap_err(); + assert!( + err.to_string().contains("already makes this a test"), + "{err}" + ); + } + } + #[test] fn rejects_arguments() { let err = expand_str( From d6adcd6a28b96c230b17816d24f1f74a902f22f9 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 12:22:14 -0700 Subject: [PATCH 14/19] fix(testkit): show usage for testkit-ready --help Signed-off-by: Matthew Grossman --- tests/testkit/testkit/src/bin/testkit-ready.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/testkit/testkit/src/bin/testkit-ready.rs b/tests/testkit/testkit/src/bin/testkit-ready.rs index 9675d1cb95..4f41b3bed4 100644 --- a/tests/testkit/testkit/src/bin/testkit-ready.rs +++ b/tests/testkit/testkit/src/bin/testkit-ready.rs @@ -14,16 +14,23 @@ use std::time::Duration; use testkit::{Cli, Gateway}; const DEFAULT_TIMEOUT_SECS: u64 = 60; +const USAGE: &str = "usage: testkit-ready [TIMEOUT_SECS] (default 60)"; #[tokio::main(flavor = "current_thread")] async fn main() -> ExitCode { - let timeout = match std::env::args().nth(1).map(|arg| arg.parse::()) { + let timeout = match std::env::args().nth(1).as_deref() { None => DEFAULT_TIMEOUT_SECS, - Some(Ok(secs)) => secs, - Some(Err(error)) => { - eprintln!("testkit-ready: invalid timeout: {error}"); - return ExitCode::from(2); + Some("-h" | "--help") => { + println!("{USAGE}"); + return ExitCode::SUCCESS; } + Some(arg) => match arg.parse::() { + Ok(secs) => secs, + Err(_) => { + eprintln!("testkit-ready: invalid timeout {arg:?}; {USAGE}"); + return ExitCode::from(2); + } + }, }; let result = async { let gateway = Gateway::from_env()?; From f2c07e7dc730209b438869bd3f3c4321fa40dfc9 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 12:22:14 -0700 Subject: [PATCH 15/19] test(testkit): cover cleanup after pass, failure, and panic Signed-off-by: Matthew Grossman --- tests/testkit/testkit/src/run.rs | 81 +++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/tests/testkit/testkit/src/run.rs b/tests/testkit/testkit/src/run.rs index 731ead0557..39bba582b0 100644 --- a/tests/testkit/testkit/src/run.rs +++ b/tests/testkit/testkit/src/run.rs @@ -43,9 +43,20 @@ where .unwrap_or_else(|error| panic!("[{name}] {error}")); let mut cx = Context::new(&client, &gateway.workspace, Cli::from_env(&gateway)); eprintln!("[{name}] run id {}", cx.run_id()); + run_with_cleanup(name, &mut cx, body, async |cx: &mut Context| { + cx.cleanup().await + }) + .await; +} - let outcome = AssertUnwindSafe(body(&mut cx)).catch_unwind().await; - let cleanup_failures = cx.cleanup().await; +/// Run `body`, then `cleanup` whether the body passed, failed, or panicked. +async fn run_with_cleanup(name: &str, cx: &mut C, body: F, cleanup: G) +where + F: AsyncFnOnce(&mut C) -> Result, + G: AsyncFnOnce(&mut C) -> Vec, +{ + let outcome = AssertUnwindSafe(body(cx)).catch_unwind().await; + let cleanup_failures = cleanup(cx).await; for failure in &cleanup_failures { eprintln!("[{name}] cleanup failed: {failure}"); } @@ -60,3 +71,69 @@ where ), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + + /// Runs `body` with a cleanup that records it ran and fails with + /// `cleanup_failures`. Returns whether cleanup ran and the panic message. + async fn run_test( + body: impl AsyncFnOnce(&mut bool) -> Result, + cleanup_failures: Vec, + ) -> (bool, Option) { + let mut cleaned_up = false; + let outcome = AssertUnwindSafe(run_with_cleanup( + "t", + &mut cleaned_up, + body, + async |cleaned_up: &mut bool| { + *cleaned_up = true; + cleanup_failures + }, + )) + .catch_unwind() + .await; + let message = outcome.err().map(|panic| { + panic + .downcast_ref::() + .cloned() + .or_else(|| panic.downcast_ref::<&str>().map(ToString::to_string)) + .unwrap_or_default() + }); + (cleaned_up, message) + } + + #[tokio::test] + async fn passes_after_cleanup() { + let (cleaned_up, panic) = run_test(async |_| Ok(()), vec![]).await; + assert!(cleaned_up); + assert_eq!(panic, None); + } + + #[tokio::test] + async fn cleans_up_after_failure() { + let (cleaned_up, panic) = run_test(async |_| Err(Error::msg("boom")), vec![]).await; + assert!(cleaned_up); + assert_eq!(panic.as_deref(), Some("[t] boom")); + } + + #[tokio::test] + async fn cleans_up_after_panic() { + let (cleaned_up, panic) = run_test(async |_| panic!("kaboom"), vec![]).await; + assert!(cleaned_up); + assert_eq!(panic.as_deref(), Some("kaboom")); + } + + #[tokio::test] + async fn fails_when_cleanup_fails_after_pass() { + let failures = vec!["delete sandbox a: gone wrong".to_string()]; + let (cleaned_up, panic) = run_test(async |_| Ok(()), failures).await; + assert!(cleaned_up); + assert_eq!( + panic.as_deref(), + Some("[t] passed, but cleanup failed: delete sandbox a: gone wrong") + ); + } +} From fbf6029d3a6f9ff479d80d25abadacf65b2b07d5 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 12:36:48 -0700 Subject: [PATCH 16/19] fix(testkit): reject #[should_panic] on testkit tests A testkit test panics on any failure, including an unreachable gateway or a failed cleanup, so #[should_panic] would let such a failure pass. Reject it and point to checking the expected failure in the body. Signed-off-by: Matthew Grossman --- tests/testkit/README.md | 5 ++++- tests/testkit/testkit-macros/src/lib.rs | 26 ++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/testkit/README.md b/tests/testkit/README.md index 9fdd9c52af..fd6897013d 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -32,7 +32,10 @@ async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { - **The doc comment is required.** It is the test's description. - **The function name is the nextest test name.** - **Other attributes apply to the test.** For example, `#[ignore = "reason"]` - skips it unless run with `--run-ignored`. + skips it unless run with `--run-ignored`. `#[should_panic]` is rejected, + because a testkit test panics on any failure. To check that something is + refused, assert the failure in the body, for example with + `cx.cli([...]).await?.failure()?` and `testkit::ensure!`. Cleanup runs after every test, whether it passes, fails, or panics. It only deletes sandboxes created through `cx.sandbox(...)`. Delete anything else a diff --git a/tests/testkit/testkit-macros/src/lib.rs b/tests/testkit/testkit-macros/src/lib.rs index a3a16cd05d..ae104ebd15 100644 --- a/tests/testkit/testkit-macros/src/lib.rs +++ b/tests/testkit/testkit-macros/src/lib.rs @@ -16,7 +16,9 @@ //! The attribute expands to a `#[tokio::test]` wrapper with the same name, so //! the nextest test name is the function name. The original function is nested //! in the wrapper and passed to `testkit::run`. Other attributes, such as -//! `#[ignore]` or `#[cfg(...)]`, move to the wrapper. +//! `#[ignore]` or `#[cfg(...)]`, move to the wrapper. `#[should_panic]` is +//! rejected: a testkit test panics on any failure, so it would pass for the +//! wrong reason. Check the expected failure in the body instead. use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; @@ -56,6 +58,18 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { "`#[testkit::test]` already makes this a test; remove this attribute", )); } + if let Some(attr) = body + .attrs + .iter() + .find(|attr| attr.path().is_ident("should_panic")) + { + return Err(syn::Error::new_spanned( + attr, + "testkit tests panic on any failure, so `#[should_panic]` would pass for the \ + wrong reason; check the expected failure in the body, for example with \ + `CliOutput::failure` and `testkit::ensure!`", + )); + } let description = doc_comment(&body.attrs); if description.is_empty() { return Err(syn::Error::new( @@ -200,6 +214,16 @@ mod tests { } } + #[test] + fn rejects_should_panic() { + let err = expand_str( + "", + "/// Doc.\n#[should_panic(expected = \"denied\")]\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("`#[should_panic]`"), "{err}"); + } + #[test] fn rejects_arguments() { let err = expand_str( From c2dc21c2c0e51c7c92bd2886b2ebc22505a99cf5 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 16:32:56 -0700 Subject: [PATCH 17/19] test(testkit): keep only the macro's misuse checks as unit tests Suites built on testkit exercise gateway resolution, the CLI wrapper, polling, errors, and cleanup against a real gateway. Keep one unit test for the macro's compile errors, which a passing suite never reaches, and drop the test-only seams the other tests needed. Signed-off-by: Matthew Grossman --- tests/testkit/testkit-macros/src/lib.rs | 126 +++++-------------- tests/testkit/testkit/src/cli.rs | 68 ---------- tests/testkit/testkit/src/context.rs | 16 --- tests/testkit/testkit/src/error.rs | 66 ---------- tests/testkit/testkit/src/gateway.rs | 158 ------------------------ tests/testkit/testkit/src/poll.rs | 50 -------- tests/testkit/testkit/src/run.rs | 81 +----------- 7 files changed, 34 insertions(+), 531 deletions(-) diff --git a/tests/testkit/testkit-macros/src/lib.rs b/tests/testkit/testkit-macros/src/lib.rs index ae104ebd15..fc3dbbdb68 100644 --- a/tests/testkit/testkit-macros/src/lib.rs +++ b/tests/testkit/testkit-macros/src/lib.rs @@ -135,102 +135,40 @@ fn doc_comment(attrs: &[Attribute]) -> String { 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( - "", - "/// First line\n/// second line.\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("name : \"does_a_thing\""), "{out}"); - assert!( - out.contains("description : \"First line second line.\""), - "{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}"); - } - + // A suite only compiles correct tests, so it never reaches these errors. #[test] - fn forwards_other_attributes_to_the_wrapper() { - let out = expand_str( - "", - "/// Doc.\n#[ignore = \"needs persistent workspaces\"]\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", - ) - .unwrap(); - let (wrapper, body) = out.split_once("async fn body").unwrap(); - assert!( - wrapper.contains("# [ignore = \"needs persistent workspaces\"]"), - "{out}" - ); - assert!(!body.contains("ignore"), "{out}"); - } - - #[test] - fn rejects_another_test_attribute() { - for attr in ["#[test]", "#[tokio::test]"] { - let err = expand_str( + fn rejects_misuse() { + let ok_fn = "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }"; + for (attr, item, error) in [ + ( + "id = \"t-1\"", + format!("/// Doc.\n{ok_fn}"), + "takes no arguments", + ), + ("", ok_fn.to_string(), "doc comment"), + ( "", - &format!( - "/// Doc.\n{attr}\nasync fn t(cx: &mut Context) -> testkit::Result {{ Ok(()) }}" - ), - ) - .unwrap_err(); - assert!( - err.to_string().contains("already makes this a test"), - "{err}" - ); + "/// Doc.\nfn t(cx: &mut Context) -> testkit::Result { Ok(()) }".to_string(), + "async fn", + ), + ( + "", + "/// Doc.\nasync fn t() -> testkit::Result { Ok(()) }".to_string(), + "exactly one argument", + ), + ( + "", + format!("/// Doc.\n#[tokio::test]\n{ok_fn}"), + "already makes this a test", + ), + ( + "", + format!("/// Doc.\n#[should_panic]\n{ok_fn}"), + "`#[should_panic]`", + ), + ] { + let err = expand(attr.parse().unwrap(), item.parse().unwrap()).unwrap_err(); + assert!(err.to_string().contains(error), "{item}: {err}"); } } - - #[test] - fn rejects_should_panic() { - let err = expand_str( - "", - "/// Doc.\n#[should_panic(expected = \"denied\")]\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", - ) - .unwrap_err(); - assert!(err.to_string().contains("`#[should_panic]`"), "{err}"); - } - - #[test] - fn rejects_arguments() { - let err = expand_str( - "id = \"t-0123abcd\"", - "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", - ) - .unwrap_err(); - assert!(err.to_string().contains("takes no arguments"), "{err}"); - } } diff --git a/tests/testkit/testkit/src/cli.rs b/tests/testkit/testkit/src/cli.rs index 6c75c5328b..7bedd9a3f2 100644 --- a/tests/testkit/testkit/src/cli.rs +++ b/tests/testkit/testkit/src/cli.rs @@ -160,71 +160,3 @@ impl CliOutput { .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: Gateway::named_for_test("tmachine"), - } - } - - #[tokio::test] - async fn runs_with_the_gateway_selected() { - let output = cli("sh") - .run([ - "-c", - "echo \"$OPENSHELL_GATEWAY|${OPENSHELL_GATEWAY_ENDPOINT-unset}|$OPENSHELL_WORKSPACE\"; exit 3", - ]) - .await - .unwrap(); - assert_eq!(output.stdout, "tmachine|unset|default\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 runs_with_a_raw_endpoint_selected() { - let cli = Cli { - binary: PathBuf::from("sh"), - gateway: Gateway::endpoint_for_test("http://127.0.0.1:1"), - }; - let output = cli - .run([ - "-c", - "echo \"${OPENSHELL_GATEWAY-unset}|$OPENSHELL_GATEWAY_ENDPOINT\"", - ]) - .await - .unwrap(); - assert_eq!(output.stdout, "unset|http://127.0.0.1:1\n"); - } - - #[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 add5f25799..1980ff3f94 100644 --- a/tests/testkit/testkit/src/context.rs +++ b/tests/testkit/testkit/src/context.rs @@ -515,19 +515,3 @@ fn generate_run_id() -> String { .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/testkit/testkit/src/error.rs b/tests/testkit/testkit/src/error.rs index dad7fe35ad..70a702ddc1 100644 --- a/tests/testkit/testkit/src/error.rs +++ b/tests/testkit/testkit/src/error.rs @@ -92,69 +92,3 @@ macro_rules! ensure { } }; } - -#[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"); - } - - #[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 { - 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/testkit/testkit/src/gateway.rs b/tests/testkit/testkit/src/gateway.rs index 783acf4b90..c44ca12a1b 100644 --- a/tests/testkit/testkit/src/gateway.rs +++ b/tests/testkit/testkit/src/gateway.rs @@ -17,7 +17,6 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; -use openshell_bootstrap::GatewayMetadata; use openshell_sdk::{ClientConfig, EdgeAuthInterceptor, Health, OpenShellClient, ServiceStatus}; use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity}; @@ -125,22 +124,6 @@ impl Gateway { }) } - #[cfg(test)] - pub(crate) fn named_for_test(name: &str) -> Self { - Self { - name: Some(name.to_string()), - endpoint: "https://127.0.0.1:1".to_string(), - workspace: DEFAULT_WORKSPACE.to_string(), - mtls_dir: None, - endpoint_from_env: false, - } - } - - #[cfg(test)] - pub(crate) fn endpoint_for_test(endpoint: &str) -> Self { - Self::from_endpoint(endpoint.to_string(), DEFAULT_WORKSPACE.to_string()) - } - /// One line naming the gateway, for test logs. pub fn describe(&self) -> String { let auth = match (&self.mtls_dir, self.endpoint.starts_with("https://")) { @@ -235,14 +218,6 @@ fn find_registered(endpoint: &str) -> Option { let active = openshell_bootstrap::load_active_gateway() .and_then(|name| openshell_bootstrap::get_gateway_metadata(&name)); let all = openshell_bootstrap::list_gateways().unwrap_or_default(); - match_endpoint(endpoint, active, all) -} - -fn match_endpoint( - endpoint: &str, - active: Option, - all: Vec, -) -> Option { let endpoint = endpoint.trim_end_matches('/'); active .into_iter() @@ -262,136 +237,3 @@ fn read(path: &Path) -> Result> { .map_err(Error::from_display) .context(format!("read {}", path.display())) } - -#[cfg(test)] -mod tests { - use super::*; - - fn metadata(name: &str, endpoint: &str, auth_mode: Option<&str>) -> GatewayMetadata { - serde_json::from_value(serde_json::json!({ - "name": name, - "gateway_endpoint": endpoint, - "is_remote": false, - "gateway_port": 0, - "auth_mode": auth_mode, - })) - .unwrap() - } - - fn from_metadata(endpoint: &str, auth_mode: Option<&str>) -> Result { - Gateway::registered( - "gw".to_string(), - endpoint.to_string(), - auth_mode, - PathBuf::from("/config/gateways/gw/mtls"), - "default".to_string(), - false, - ) - } - - #[test] - fn https_gateway_uses_mtls_by_default() { - for mode in [None, Some("mtls")] { - let gateway = from_metadata("https://127.0.0.1:17670", mode).unwrap(); - assert_eq!( - gateway.mtls_dir, - Some(PathBuf::from("/config/gateways/gw/mtls")) - ); - assert_eq!( - gateway.describe(), - "gateway gw at https://127.0.0.1:17670 (mTLS from /config/gateways/gw/mtls), workspace default" - ); - } - } - - #[test] - fn plaintext_gateway_has_no_mtls() { - for (endpoint, mode) in [ - ("http://127.0.0.1:18080", Some("plaintext")), - ("http://127.0.0.1:18080", None), - ] { - let gateway = from_metadata(endpoint, mode).unwrap(); - assert_eq!(gateway.mtls_dir, None); - assert!(gateway.describe().contains("(plaintext)")); - } - } - - #[test] - fn rejects_unsupported_auth_modes() { - let err = from_metadata("https://gw.example.com", Some("cloudflare_jwt")).unwrap_err(); - assert!( - err.to_string().contains("uses cloudflare_jwt auth"), - "{err}" - ); - } - - #[test] - fn raw_endpoint_has_no_name() { - let gateway = Gateway::from_endpoint("http://127.0.0.1:1".to_string(), "ws".to_string()); - assert_eq!( - gateway.describe(), - "gateway from OPENSHELL_GATEWAY_ENDPOINT at http://127.0.0.1:1 (plaintext), workspace ws" - ); - } - - #[test] - fn endpoint_finds_the_registered_gateway() { - let all = vec![ - metadata("other", "https://127.0.0.1:2", None), - metadata("gw", "https://127.0.0.1:1/", None), - ]; - assert_eq!( - match_endpoint("https://127.0.0.1:1", None, all.clone()), - Some("gw".to_string()) - ); - assert_eq!( - match_endpoint("https://127.0.0.1:3", None, all.clone()), - None - ); - // The active gateway wins when two share an endpoint. - let active = metadata("active", "https://127.0.0.1:2", None); - assert_eq!( - match_endpoint("https://127.0.0.1:2", Some(active), all), - Some("active".to_string()) - ); - } - - #[test] - fn endpoint_from_env_keeps_the_gateway_credentials() { - let gateway = Gateway::registered( - "gw".to_string(), - "https://localhost:1".to_string(), - None, - PathBuf::from("/config/gateways/gw/mtls"), - "default".to_string(), - true, - ) - .unwrap(); - assert_eq!(gateway.endpoint, "https://localhost:1"); - assert!(gateway.endpoint_from_env); - assert_eq!( - gateway.mtls_dir, - Some(PathBuf::from("/config/gateways/gw/mtls")) - ); - } - - #[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 gateway = - Gateway::from_endpoint(format!("http://127.0.0.1:{port}"), "default".to_string()); - let err = gateway.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}" - ); - } -} diff --git a/tests/testkit/testkit/src/poll.rs b/tests/testkit/testkit/src/poll.rs index 1f46f49c37..838d89526d 100644 --- a/tests/testkit/testkit/src/poll.rs +++ b/tests/testkit/testkit/src/poll.rs @@ -38,53 +38,3 @@ pub async fn poll_until( } } } - -#[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/testkit/testkit/src/run.rs b/tests/testkit/testkit/src/run.rs index 39bba582b0..731ead0557 100644 --- a/tests/testkit/testkit/src/run.rs +++ b/tests/testkit/testkit/src/run.rs @@ -43,20 +43,9 @@ where .unwrap_or_else(|error| panic!("[{name}] {error}")); let mut cx = Context::new(&client, &gateway.workspace, Cli::from_env(&gateway)); eprintln!("[{name}] run id {}", cx.run_id()); - run_with_cleanup(name, &mut cx, body, async |cx: &mut Context| { - cx.cleanup().await - }) - .await; -} -/// Run `body`, then `cleanup` whether the body passed, failed, or panicked. -async fn run_with_cleanup(name: &str, cx: &mut C, body: F, cleanup: G) -where - F: AsyncFnOnce(&mut C) -> Result, - G: AsyncFnOnce(&mut C) -> Vec, -{ - let outcome = AssertUnwindSafe(body(cx)).catch_unwind().await; - let cleanup_failures = cleanup(cx).await; + let outcome = AssertUnwindSafe(body(&mut cx)).catch_unwind().await; + let cleanup_failures = cx.cleanup().await; for failure in &cleanup_failures { eprintln!("[{name}] cleanup failed: {failure}"); } @@ -71,69 +60,3 @@ where ), } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::error::Error; - - /// Runs `body` with a cleanup that records it ran and fails with - /// `cleanup_failures`. Returns whether cleanup ran and the panic message. - async fn run_test( - body: impl AsyncFnOnce(&mut bool) -> Result, - cleanup_failures: Vec, - ) -> (bool, Option) { - let mut cleaned_up = false; - let outcome = AssertUnwindSafe(run_with_cleanup( - "t", - &mut cleaned_up, - body, - async |cleaned_up: &mut bool| { - *cleaned_up = true; - cleanup_failures - }, - )) - .catch_unwind() - .await; - let message = outcome.err().map(|panic| { - panic - .downcast_ref::() - .cloned() - .or_else(|| panic.downcast_ref::<&str>().map(ToString::to_string)) - .unwrap_or_default() - }); - (cleaned_up, message) - } - - #[tokio::test] - async fn passes_after_cleanup() { - let (cleaned_up, panic) = run_test(async |_| Ok(()), vec![]).await; - assert!(cleaned_up); - assert_eq!(panic, None); - } - - #[tokio::test] - async fn cleans_up_after_failure() { - let (cleaned_up, panic) = run_test(async |_| Err(Error::msg("boom")), vec![]).await; - assert!(cleaned_up); - assert_eq!(panic.as_deref(), Some("[t] boom")); - } - - #[tokio::test] - async fn cleans_up_after_panic() { - let (cleaned_up, panic) = run_test(async |_| panic!("kaboom"), vec![]).await; - assert!(cleaned_up); - assert_eq!(panic.as_deref(), Some("kaboom")); - } - - #[tokio::test] - async fn fails_when_cleanup_fails_after_pass() { - let failures = vec!["delete sandbox a: gone wrong".to_string()]; - let (cleaned_up, panic) = run_test(async |_| Ok(()), failures).await; - assert!(cleaned_up); - assert_eq!( - panic.as_deref(), - Some("[t] passed, but cleanup failed: delete sandbox a: gone wrong") - ); - } -} From 5cf4df209815ceadac41331b22e37ab4a83b2966 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Thu, 22 Oct 2026 09:51:47 -0700 Subject: [PATCH 18/19] refactor(testkit): connect through the SDK's mTLS support The SDK's ClientConfig now takes a client identity, so testkit no longer builds its own channel. It still resolves the registered gateway and reads its credential files, then hands the CA and client certificate to OpenShellClient::connect. That keeps TLS behavior, connect timeouts and keep-alive settings in the SDK, and drops testkit's direct tonic dependency. Signed-off-by: Matthew Grossman --- tests/testkit/Cargo.lock | 1 - tests/testkit/testkit/Cargo.toml | 1 - tests/testkit/testkit/src/gateway.rs | 43 ++++++++++------------------ 3 files changed, 15 insertions(+), 30 deletions(-) diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index b0fa677e1b..e36d9c7b09 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -1994,7 +1994,6 @@ dependencies = [ "serde", "serde_json", "tokio", - "tonic", ] [[package]] diff --git a/tests/testkit/testkit/Cargo.toml b/tests/testkit/testkit/Cargo.toml index f33c63674a..cfcf0391ee 100644 --- a/tests/testkit/testkit/Cargo.toml +++ b/tests/testkit/testkit/Cargo.toml @@ -24,4 +24,3 @@ rand = "0.9" serde = "1" serde_json = "1" tokio = { version = "1.43", features = ["macros", "process", "rt", "time"] } -tonic = { version = "0.14", features = ["tls-native-roots"] } diff --git a/tests/testkit/testkit/src/gateway.rs b/tests/testkit/testkit/src/gateway.rs index c44ca12a1b..5b868d4c2c 100644 --- a/tests/testkit/testkit/src/gateway.rs +++ b/tests/testkit/testkit/src/gateway.rs @@ -17,8 +17,7 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; -use openshell_sdk::{ClientConfig, EdgeAuthInterceptor, Health, OpenShellClient, ServiceStatus}; -use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity}; +use openshell_sdk::{ClientConfig, ClientIdentity, Health, OpenShellClient, ServiceStatus}; use crate::error::{Error, Result, ResultExt}; @@ -28,8 +27,6 @@ pub const WORKSPACE_ENV: &str = "OPENSHELL_WORKSPACE"; const DEFAULT_WORKSPACE: &str = "default"; // 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); #[derive(Clone, Debug)] @@ -142,32 +139,22 @@ impl Gateway { } /// Connect to the gateway. + /// + /// The registered gateway's CA and client certificate are read from its + /// CLI config directory and handed to the SDK, which owns the transport + /// (TLS, connect timeout, keep-alive). pub async fn connect(&self) -> Result { - let Some(dir) = &self.mtls_dir else { - return OpenShellClient::connect(ClientConfig::new(self.endpoint.clone())) - .await - .context(format!("connect to {}", self.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 ca = Certificate::from_pem(read(&dir.join("ca.crt"))?); - let identity = Identity::from_pem(read(&dir.join("tls.crt"))?, read(&dir.join("tls.key"))?); - let channel = Endpoint::from_shared(self.endpoint.clone()) - .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(ClientTlsConfig::new().ca_certificate(ca).identity(identity)) - .map_err(|error| Error::from_error(&error)) - .context("configure mTLS")? - .connect() + let mut config = ClientConfig::new(self.endpoint.clone()); + if let Some(dir) = &self.mtls_dir { + config.ca_cert = Some(read(&dir.join("ca.crt"))?); + config.client_identity = Some(ClientIdentity::new( + read(&dir.join("tls.crt"))?, + read(&dir.join("tls.key"))?, + )); + } + OpenShellClient::connect(config) .await - .map_err(|error| Error::from_error(&error)) - .context(format!("connect to {} with mTLS", self.endpoint))?; - let interceptor = EdgeAuthInterceptor::new(None, None)?; - Ok(OpenShellClient::from_parts(channel, interceptor)) + .context(format!("connect to {}", self.endpoint)) } /// Wait until the gateway accepts a connection and reports itself From 6427db9d7e60c64da76b90e3f13c8ac33754bbb4 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Thu, 22 Oct 2026 09:51:54 -0700 Subject: [PATCH 19/19] fix(testkit): bound readiness and cleanup with timeouts wait_ready checked elapsed time only after a health call returned, and cleanup started its timeout only after the delete returned, so a gateway that accepts a connection but never answers could hang either one. Wrap each whole operation in tokio::time::timeout: the readiness wait, including every connect and health call, and each sandbox's delete plus wait for deletion. A test covers a peer that accepts connections and never replies. Signed-off-by: Matthew Grossman --- tests/testkit/testkit/src/context.rs | 26 ++++++---- tests/testkit/testkit/src/gateway.rs | 72 ++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 24 deletions(-) diff --git a/tests/testkit/testkit/src/context.rs b/tests/testkit/testkit/src/context.rs index 1980ff3f94..24e3f0a37a 100644 --- a/tests/testkit/testkit/src/context.rs +++ b/tests/testkit/testkit/src/context.rs @@ -153,16 +153,24 @@ impl Context { /// others and cleanup takes about one timeout however many there are. pub(crate) async fn cleanup(&self) -> Vec { let names: Vec = lock(&self.sandboxes).iter().cloned().collect(); + // The timeout wraps the whole delete, not just the wait for it, so a + // gateway that never answers the delete cannot hang cleanup. let results = futures::future::join_all(names.iter().map(|name| async move { - self.client - .delete_sandbox( - name, - DeleteOptions { - allow_missing: true, - }, - ) - .await?; - self.client.wait_deleted(name, CLEANUP_TIMEOUT, None).await + let delete = async { + self.client + .delete_sandbox( + name, + DeleteOptions { + allow_missing: true, + }, + ) + .await?; + self.client.wait_deleted(name, CLEANUP_TIMEOUT, None).await + }; + match tokio::time::timeout(CLEANUP_TIMEOUT, delete).await { + Ok(result) => result.map_err(|error| error.to_string()), + Err(_) => Err(format!("timed out after {}s", CLEANUP_TIMEOUT.as_secs())), + } })) .await; let mut failures = Vec::new(); diff --git a/tests/testkit/testkit/src/gateway.rs b/tests/testkit/testkit/src/gateway.rs index 5b868d4c2c..ef40cb1bf9 100644 --- a/tests/testkit/testkit/src/gateway.rs +++ b/tests/testkit/testkit/src/gateway.rs @@ -15,7 +15,7 @@ //! `mtls/` directory. `OPENSHELL_WORKSPACE` selects the workspace. use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; +use std::time::Duration; use openshell_sdk::{ClientConfig, ClientIdentity, Health, OpenShellClient, ServiceStatus}; @@ -160,24 +160,35 @@ impl Gateway { /// Wait until the gateway accepts a connection and reports itself /// healthy, retrying until `timeout`. /// + /// The whole wait, including each connect and health call, is bounded by + /// `timeout`, so a gateway that accepts connections but never answers + /// cannot hang it. + /// /// 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.endpoint, - timeout.as_secs() - ))); + let mut last_error = None; + let outcome = tokio::time::timeout(timeout, async { + loop { + match self.check_health().await { + Ok(health) => return health, + Err(error) => last_error = Some(error), + } + tokio::time::sleep(READY_INTERVAL).await; } - tokio::time::sleep(READY_INTERVAL).await; - } + }) + .await; + outcome.map_err(|_| { + let message = format!( + "gateway at {} not ready after {}s", + self.endpoint, + timeout.as_secs() + ); + match last_error.take() { + Some(error) => error.context(message), + None => Error::msg(message), + } + }) } async fn check_health(&self) -> Result { @@ -224,3 +235,34 @@ fn read(path: &Path) -> Result> { .map_err(Error::from_display) .context(format!("read {}", path.display())) } + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + use std::time::{Duration, Instant}; + + use super::Gateway; + + /// A peer that accepts connections and never answers must not hang the + /// readiness wait: the timeout covers the connect and health calls too. + #[tokio::test] + async fn wait_ready_gives_up_on_a_gateway_that_never_answers() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + std::thread::spawn(move || { + // Hold every connection open without reading or replying. + let held: Vec<_> = listener.incoming().flatten().collect(); + drop(held); + }); + + let gateway = Gateway::from_endpoint(endpoint, "default".to_string()); + let started = Instant::now(); + let error = gateway + .wait_ready(Duration::from_secs(1)) + .await + .expect_err("an unresponsive gateway is not ready"); + + assert!(started.elapsed() < Duration::from_secs(5), "{error}"); + assert!(error.to_string().contains("not ready after 1s"), "{error}"); + } +}