From fb74590147c8b417598ce88912f1074473ce53df Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 16 Jul 2026 15:01:54 +0200 Subject: [PATCH 1/7] test(e2e): add API conformance suite Signed-off-by: Evan Lezar # Conflicts: # e2e/rust/Cargo.toml # e2e/rust/e2e-kubernetes.sh # e2e/rust/e2e-podman.sh # e2e/rust/e2e-vm.sh # tasks/test.toml --- e2e/rust/CONFORMANCE.md | 35 + e2e/rust/Cargo.lock | 1213 +++++++++++++++++++-- e2e/rust/Cargo.toml | 16 +- e2e/rust/e2e-kubernetes.sh | 2 +- e2e/rust/e2e-podman.sh | 2 +- e2e/rust/e2e-vm.sh | 9 +- e2e/rust/src/bin/openshell-conformance.rs | 157 +++ e2e/rust/src/conformance.rs | 927 ++++++++++++++++ e2e/rust/src/lib.rs | 1 + e2e/rust/tests/conformance.rs | 44 + e2e/with-docker-gateway.sh | 4 + e2e/with-kube-gateway.sh | 2 + e2e/with-podman-gateway.sh | 4 + tasks/test.toml | 4 +- 14 files changed, 2333 insertions(+), 87 deletions(-) create mode 100644 e2e/rust/CONFORMANCE.md create mode 100644 e2e/rust/src/bin/openshell-conformance.rs create mode 100644 e2e/rust/src/conformance.rs create mode 100644 e2e/rust/tests/conformance.rs diff --git a/e2e/rust/CONFORMANCE.md b/e2e/rust/CONFORMANCE.md new file mode 100644 index 0000000000..aa885975cd --- /dev/null +++ b/e2e/rust/CONFORMANCE.md @@ -0,0 +1,35 @@ +# OpenShell conformance + +`openshell-conformance` is an internal CLI for validating the driver-agnostic +behavior of an existing OpenShell gateway installation. Its scenario engine +lives in the standalone `e2e/rust` package and is shared with the Rust +`e2e-api-conformance` test profile. + +The binary is not published or included in release packaging. Build and run it +through the e2e manifest: + +```shell +cargo run --manifest-path e2e/rust/Cargo.toml --bin openshell-conformance -- list +cargo run --manifest-path e2e/rust/Cargo.toml --bin openshell-conformance -- \ + run --gateway-endpoint http://127.0.0.1:50051 +cargo run --manifest-path e2e/rust/Cargo.toml --bin openshell-conformance -- \ + run --gateway-endpoint http://127.0.0.1:50051 --filter lifecycle --timeout 120 +``` + +The runner requires an explicit gateway endpoint, either through +`--gateway-endpoint` or `OPENSHELL_GATEWAY_ENDPOINT`. HTTPS gateways require +explicit `--tls-ca`, `--tls-cert`, and `--tls-key` paths. Each created sandbox +uses the name +`conformance--`. The runner performs best-effort cleanup, and +the prefix makes any sandbox left after an interruption easy to identify. + +The capabilities scenario remains deferred until the gateway exposes driver +capabilities through its public API. It is not included as a known-failing CI +scenario. + +The `e2e-api-conformance` profile invokes the scenario engine directly against +every gateway driver. + +Gateway provisioners export the endpoint and any mTLS paths through +`OPENSHELL_GATEWAY_ENDPOINT` and the `OPENSHELL_CONFORMANCE_TLS_*` variables. +Set `OPENSHELL_CONFORMANCE_TIMEOUT` to change the per-scenario timeout. diff --git a/e2e/rust/Cargo.lock b/e2e/rust/Cargo.lock index 679e2c326d..043f96a9ce 100644 --- a/e2e/rust/Cargo.lock +++ b/e2e/rust/Cargo.lock @@ -2,18 +2,170 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base64" version = "0.22.1" @@ -84,6 +236,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -96,6 +258,68 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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" @@ -127,20 +351,20 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "equivalent" @@ -155,7 +379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -164,6 +388,30 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -175,24 +423,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -201,32 +449,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", @@ -245,6 +493,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -268,12 +527,58 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "h2" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -282,9 +587,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -302,9 +607,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -335,6 +640,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -360,6 +666,19 @@ dependencies = [ "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" @@ -397,9 +716,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -411,9 +730,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -424,9 +743,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -438,16 +757,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -458,15 +778,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -505,9 +825,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -547,9 +885,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -566,12 +904,63 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + [[package]] name = "mio" version = "1.2.2" @@ -580,9 +969,15 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys", + "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" @@ -595,12 +990,53 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64", + "glob", + "ipnet", + "miette", + "nix", + "openshell-extension-core", + "prost", + "prost-types", + "protoc-bin-vendored", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", +] + [[package]] name = "openshell-e2e" version = "0.1.0" @@ -608,12 +1044,15 @@ dependencies = [ "base64", "bollard", "bytes", + "clap", "futures-util", "hex", "http-body-util", "hyper", "hyper-util", + "miette", "nix", + "openshell-core", "prost", "rand", "serde", @@ -624,9 +1063,34 @@ dependencies = [ "sha2", "tempfile", "tokio", + "tonic", "url", ] +[[package]] +name = "openshell-extension-core" +version = "0.0.0" +dependencies = [ + "hyper-util", + "serde", + "thiserror", + "tokio", + "tonic", + "tower", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -656,6 +1120,37 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -664,9 +1159,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -680,6 +1175,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -700,16 +1205,130 @@ dependencies = [ ] [[package]] -name = "prost-derive" -version = "0.14.4" +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn 2.0.119", + "pulldown-cmark", ] [[package]] @@ -771,6 +1390,55 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustix" version = "1.1.4" @@ -781,7 +1449,54 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -790,12 +1505,44 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -924,6 +1671,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -953,7 +1706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -962,6 +1715,39 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "2.0.119" @@ -984,6 +1770,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -1005,23 +1797,43 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "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 = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -1030,9 +1842,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -1052,18 +1864,39 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", +] + +[[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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", ] [[package]] @@ -1080,6 +1913,101 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "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-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" @@ -1093,9 +2021,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1117,12 +2057,42 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1141,6 +2111,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" @@ -1199,6 +2175,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[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" @@ -1208,6 +2193,70 @@ 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.57.1" @@ -1216,9 +2265,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" @@ -1245,18 +2294,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", @@ -1284,11 +2333,17 @@ dependencies = [ "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" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -1297,9 +2352,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -1308,13 +2363,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 19702aadf1..9adec9fcb4 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -1,9 +1,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Standalone crate — the empty [workspace] table prevents Cargo from -# treating this as part of the root workspace, avoiding Dockerfile -# and Cargo.lock coupling. +# Standalone crate — the empty [workspace] table keeps e2e targets and its +# lockfile out of the root workspace. The internal conformance runner +# intentionally reuses openshell-core protocol types through a path dependency. [workspace] [package] @@ -21,6 +21,7 @@ e2e = [] # existing E2E test targets; membership is defined by the e2e:cli-conformance # task so adding the feature does not remove tests from existing E2E suites. e2e-cli-conformance = ["e2e"] +e2e-api-conformance = ["e2e"] # Selects tests that rely on `host.openshell.internal` (the sandbox's stable # alias to the host running test fixtures). docker, podman, and vm wire the # alias unconditionally; the kube driver only does so when the chart's @@ -65,6 +66,11 @@ name = "vm_overlay" path = "tests/vm_overlay.rs" required-features = ["e2e-vm"] +[[test]] +name = "conformance" +path = "tests/conformance.rs" +required-features = ["e2e-api-conformance"] + [[test]] name = "custom_image" path = "tests/custom_image.rs" @@ -199,13 +205,17 @@ required-features = ["e2e-gpu"] base64 = "0.22" bollard = "0.20" bytes = "1" +clap = { version = "4.5", features = ["derive", "env"] } futures-util = "0.3" http-body-util = "0.1" hyper = { version = "1", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } prost = "0.14" +miette = "7" +openshell-core = { path = "../../crates/openshell-core", default-features = false } tokio = { version = "1.43", features = ["full"] } tempfile = "3" +tonic = { version = "0.14", features = ["tls-native-roots"] } sha1 = "0.10" sha2 = "0.10" hex = "0.4" diff --git a/e2e/rust/e2e-kubernetes.sh b/e2e/rust/e2e-kubernetes.sh index 8c148271cd..3ec7afbad4 100755 --- a/e2e/rust/e2e-kubernetes.sh +++ b/e2e/rust/e2e-kubernetes.sh @@ -19,7 +19,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -E2E_FEATURES="${OPENSHELL_E2E_KUBERNETES_FEATURES:-e2e,e2e-cli-conformance,e2e-host-gateway,e2e-kubernetes}" +E2E_FEATURES="${OPENSHELL_E2E_KUBERNETES_FEATURES:-e2e,e2e-api-conformance,e2e-cli-conformance,e2e-host-gateway,e2e-kubernetes}" # Docker and Podman build their local gateway and CLI together in the shared # gateway wrapper. Kubernetes consumes published gateway images, so only its diff --git a/e2e/rust/e2e-podman.sh b/e2e/rust/e2e-podman.sh index 8cadb409dc..a3bb09f84f 100755 --- a/e2e/rust/e2e-podman.sh +++ b/e2e/rust/e2e-podman.sh @@ -10,7 +10,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" E2E_TEST="${OPENSHELL_E2E_PODMAN_TEST:-}" -E2E_FEATURES="${OPENSHELL_E2E_PODMAN_FEATURES:-e2e-podman,e2e-cli-conformance}" +E2E_FEATURES="${OPENSHELL_E2E_PODMAN_FEATURES:-e2e-podman,e2e-api-conformance,e2e-cli-conformance}" DEFAULT_WORKLOAD_MANIFEST="${ROOT}/e2e/gpu/images/.build/workloads.yaml" if [ "${E2E_TEST}" = "gpu" ] && [ -z "${OPENSHELL_E2E_WORKLOAD_MANIFEST:-}" ] && [ ! -f "${DEFAULT_WORKLOAD_MANIFEST}" ]; then diff --git a/e2e/rust/e2e-vm.sh b/e2e/rust/e2e-vm.sh index 7eca7ddb37..29791b65aa 100755 --- a/e2e/rust/e2e-vm.sh +++ b/e2e/rust/e2e-vm.sh @@ -52,7 +52,7 @@ GATEWAY_BIN="${OPENSHELL_GATEWAY_BIN:-${ROOT}/target/debug/openshell-gateway}" DRIVER_BIN="${OPENSHELL_VM_DRIVER_BIN:-${ROOT}/target/debug/openshell-driver-vm}" CLI_BIN="${OPENSHELL_BIN:-${ROOT}/target/debug/openshell}" E2E_TEST_OVERRIDE="${OPENSHELL_E2E_VM_TEST:-}" -E2E_FEATURES="${OPENSHELL_E2E_VM_FEATURES:-e2e-vm,e2e-cli-conformance}" +E2E_FEATURES="${OPENSHELL_E2E_VM_FEATURES:-e2e-vm,e2e-api-conformance,e2e-cli-conformance}" SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-${COMMUNITY_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}}" # The VM driver places `compute-driver.sock` under `[openshell.drivers.vm].state_dir`. @@ -371,6 +371,9 @@ fi # The CLI uses the raw endpoint but still resolves matching metadata so it # can find the mTLS client bundle. +export OPENSHELL_CONFORMANCE_TLS_CA="${PKI_DIR}/ca.crt" +export OPENSHELL_CONFORMANCE_TLS_CERT="${PKI_DIR}/client/tls.crt" +export OPENSHELL_CONFORMANCE_TLS_KEY="${PKI_DIR}/client/tls.key" export OPENSHELL_E2E_DRIVER="vm" export OPENSHELL_E2E_VM_STATE_DIR="${RUN_STATE_DIR}" e2e_export_gateway_restart_metadata \ @@ -407,3 +410,7 @@ else run_e2e_test vm_overlay run_e2e_test vm_gateway_start fi + +if [ "${E2E_TEST_OVERRIDE}" != "conformance" ]; then + run_e2e_test conformance +fi diff --git a/e2e/rust/src/bin/openshell-conformance.rs b/e2e/rust/src/bin/openshell-conformance.rs new file mode 100644 index 0000000000..1a9c20cc56 --- /dev/null +++ b/e2e/rust/src/bin/openshell-conformance.rs @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Internal driver-agnostic conformance runner for `OpenShell` installations. + +use std::path::PathBuf; + +use clap::{Parser, Subcommand, ValueEnum}; +use miette::{Result, WrapErr}; +use openshell_e2e::conformance::ConnectionOptions; + +#[derive(Parser, Debug)] +#[command( + name = "openshell-conformance", + about = "Validate an OpenShell gateway and its configured compute driver" +)] +struct Cli { + /// Gateway endpoint to test. + #[arg(long, global = true, env = "OPENSHELL_GATEWAY_ENDPOINT")] + gateway_endpoint: Option, + + /// Path to the gateway CA certificate. + #[arg(long, global = true)] + tls_ca: Option, + + /// Path to the client certificate. + #[arg(long, global = true)] + tls_cert: Option, + + /// Path to the client private key. + #[arg(long, global = true)] + tls_key: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Run conformance scenarios against a gateway. + Run { + /// Only run scenarios whose name contains this substring. + #[arg(long, short = 'f')] + filter: Option, + + /// Maximum duration of each scenario, in seconds. + #[arg(long, default_value_t = 300)] + timeout: u64, + + /// Output format. + #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, + + /// List available conformance scenarios without connecting to a gateway. + List { + /// Output format. + #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + }, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum OutputFormat { + Table, + Yaml, + Json, +} + +impl OutputFormat { + const fn as_str(self) -> &'static str { + match self { + Self::Table => "table", + Self::Yaml => "yaml", + Self::Json => "json", + } + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Command::List { output } => openshell_e2e::conformance::conformance_list(output.as_str()), + Command::Run { + filter, + timeout, + output, + } => { + let gateway_endpoint = cli.gateway_endpoint.ok_or_else(|| { + miette::miette!( + "gateway endpoint is required; pass --gateway-endpoint or set \ + OPENSHELL_GATEWAY_ENDPOINT" + ) + })?; + let connection = ConnectionOptions { + tls_ca: cli.tls_ca, + tls_cert: cli.tls_cert, + tls_key: cli.tls_key, + }; + + openshell_e2e::conformance::conformance_run( + &gateway_endpoint, + &connection, + filter.as_deref(), + timeout, + output.as_str(), + ) + .await + .wrap_err_with(|| format!("conformance failed for {gateway_endpoint}")) + } + } +} + +#[cfg(test)] +mod tests { + use super::{Cli, Command, OutputFormat}; + use clap::Parser; + + #[test] + fn list_requires_no_gateway_arguments() { + let cli = Cli::try_parse_from(["openshell-conformance", "list"]).unwrap(); + assert!(matches!( + cli.command, + Command::List { + output: OutputFormat::Table + } + )); + } + + #[test] + fn run_accepts_filter_timeout_and_structured_output() { + let cli = Cli::try_parse_from([ + "openshell-conformance", + "run", + "--gateway-endpoint", + "http://127.0.0.1:50051", + "--filter", + "lifecycle", + "--timeout", + "30", + "--output", + "json", + ]) + .unwrap(); + + assert!(matches!( + cli.command, + Command::Run { + filter: Some(ref filter), + timeout: 30, + output: OutputFormat::Json, + } if filter == "lifecycle" + )); + } +} diff --git a/e2e/rust/src/conformance.rs b/e2e/rust/src/conformance.rs new file mode 100644 index 0000000000..c9cfb1c5ca --- /dev/null +++ b/e2e/rust/src/conformance.rs @@ -0,0 +1,927 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Driver-agnostic conformance scenarios for `OpenShell` gateway installations. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use futures_util::StreamExt; +use miette::{IntoDiagnostic, Result, WrapErr}; +use openshell_core::proto::{ + CreateSandboxRequest, DeleteSandboxRequest, GetSandboxRequest, GpuResourceRequirements, + ListSandboxesRequest, ResourceRequirements, SandboxPhase, SandboxSpec, WatchSandboxRequest, + open_shell_client::OpenShellClient, +}; +use openshell_core::{ObjectId, ObjectName}; +use tonic::Code; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; + +type GrpcClient = OpenShellClient; + +/// Explicit connection material for an existing gateway installation. +#[derive(Clone, Debug, Default)] +pub struct ConnectionOptions { + /// Path to the gateway CA certificate. + pub tls_ca: Option, + /// Path to the client certificate. + pub tls_cert: Option, + /// Path to the client private key. + pub tls_key: Option, +} + +async fn grpc_client(server: &str, options: &ConnectionOptions) -> Result { + let mut endpoint = Endpoint::from_shared(server.to_string()) + .into_diagnostic()? + .connect_timeout(Duration::from_secs(10)) + .http2_adaptive_window(true) + .http2_keep_alive_interval(Duration::from_secs(10)) + .keep_alive_while_idle(true); + + if server.starts_with("https://") { + let ca_path = options + .tls_ca + .as_ref() + .ok_or_else(|| miette::miette!("--tls-ca is required for HTTPS gateways"))?; + let cert_path = options + .tls_cert + .as_ref() + .ok_or_else(|| miette::miette!("--tls-cert is required for HTTPS gateways"))?; + let key_path = options + .tls_key + .as_ref() + .ok_or_else(|| miette::miette!("--tls-key is required for HTTPS gateways"))?; + let ca = std::fs::read(ca_path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read TLS CA from {}", ca_path.display()))?; + let cert = std::fs::read(cert_path) + .into_diagnostic() + .wrap_err_with(|| { + format!( + "failed to read TLS certificate from {}", + cert_path.display() + ) + })?; + let key = std::fs::read(key_path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read TLS key from {}", key_path.display()))?; + endpoint = endpoint + .tls_config( + ClientTlsConfig::new() + .ca_certificate(Certificate::from_pem(ca)) + .identity(Identity::from_pem(cert, key)), + ) + .into_diagnostic()?; + } + + let channel = endpoint + .connect() + .await + .into_diagnostic() + .wrap_err("failed to connect to gateway")?; + Ok(OpenShellClient::new(channel)) +} + +// ----------------------------------------------------------------------- +// Conformance suite +// ----------------------------------------------------------------------- + +/// A single conformance scenario definition. +struct Scenario { + /// Short lowercase-hyphenated name, used in sandbox naming and filtering. + name: &'static str, + /// Human-readable description shown in `conformance list`. + description: &'static str, + /// Sandbox-name stems that may be created by this scenario. + sandbox_name_stems: &'static [&'static str], +} + +fn all_scenarios() -> &'static [Scenario] { + &[ + Scenario { + name: "lifecycle", + description: "Create → running → stop → delete completes without error", + sandbox_name_stems: &["lifecycle"], + }, + Scenario { + name: "not-found", + description: "Get/stop/delete for an unknown sandbox ID returns an appropriate error", + sandbox_name_stems: &[], + }, + Scenario { + name: "idempotent-delete", + description: "Deleting an already-deleted sandbox returns NOT_FOUND", + sandbox_name_stems: &["idempotent-delete"], + }, + Scenario { + name: "validate", + description: "Invalid sandbox specs are rejected before creation", + sandbox_name_stems: &[], + }, + Scenario { + name: "concurrent", + description: "Two sandboxes created simultaneously do not interfere", + sandbox_name_stems: &["concurrent-a", "concurrent-b"], + }, + Scenario { + name: "labels", + description: "Labels are persisted on create and filter list results correctly", + sandbox_name_stems: &["labels-a", "labels-b"], + }, + ] +} + +impl Scenario { + fn sandbox_names(&self, run_id: &str) -> Vec { + self.sandbox_name_stems + .iter() + .map(|stem| format!("conformance-{stem}-{run_id}")) + .collect() + } +} + +fn select_scenarios(filter: Option<&str>) -> Result> { + let scenarios = all_scenarios() + .iter() + .filter(|scenario| filter.is_none_or(|value| scenario.name.contains(value))) + .collect::>(); + + if scenarios.is_empty() { + return Err(miette::miette!( + "conformance filter {:?} matched no scenarios", + filter.unwrap_or_default() + )); + } + + Ok(scenarios) +} + +async fn cleanup_sandboxes(client: &mut GrpcClient, sandbox_names: &[String]) -> Result<()> { + let mut failures = Vec::new(); + + for sandbox_name in sandbox_names { + match client + .delete_sandbox(DeleteSandboxRequest { + name: sandbox_name.clone(), + workspace: String::new(), + }) + .await + { + Ok(_) => {} + Err(status) if status.code() == Code::NotFound => {} + Err(status) => failures.push(format!( + "'{sandbox_name}': {} ({})", + status.code(), + status.message() + )), + } + } + + if failures.is_empty() { + Ok(()) + } else { + Err(miette::miette!( + "best-effort sandbox cleanup failed for {}", + failures.join(", ") + )) + } +} + +/// Outcome of a single conformance scenario. +#[derive(serde::Serialize)] +struct ScenarioResult { + name: String, + passed: bool, + message: String, + duration_ms: u64, +} + +pub async fn conformance_run( + server: &str, + connection: &ConnectionOptions, + filter: Option<&str>, + timeout_secs: u64, + output: &str, +) -> Result<()> { + use std::time::Instant; + + let scenarios = select_scenarios(filter)?; + let client = grpc_client(server, connection).await?; + + // Stable run-id for sandbox naming: seconds since Unix epoch, truncated + // to 8 hex digits. Keeps names Kubernetes RFC 1123 safe and short enough + // to read in `sandbox list` output. + let run_id = format!( + "{:08x}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + & u64::from(u32::MAX) + ); + + let mut results: Vec = Vec::new(); + let mut any_failed = false; + + for scenario in scenarios { + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_secs); + let mut client = client.clone(); + let sandbox_names = scenario.sandbox_names(&run_id); + + let mut outcome = + tokio::time::timeout(timeout, run_scenario(scenario.name, &mut client, &run_id)) + .await + .unwrap_or_else(|_| { + Err(miette::miette!("scenario timed out after {timeout_secs}s")) + }); + + if outcome.is_err() && !sandbox_names.is_empty() { + let cleanup = + tokio::time::timeout(timeout, cleanup_sandboxes(&mut client, &sandbox_names)) + .await + .unwrap_or_else(|_| { + Err(miette::miette!( + "sandbox cleanup timed out after {timeout_secs}s" + )) + }); + + if let Err(cleanup_error) = cleanup { + let scenario_error = outcome.expect_err("scenario outcome should be an error"); + outcome = Err(miette::miette!( + "{scenario_error}; additionally, {cleanup_error}" + )); + } + } + + let passed = outcome.is_ok(); + if !passed { + any_failed = true; + } + + results.push(ScenarioResult { + name: scenario.name.to_string(), + passed, + message: match &outcome { + Ok(()) => "ok".to_string(), + Err(e) => format!("{e}"), + }, + duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX), + }); + } + + match output { + "json" => println!( + "{}", + serde_json::to_string_pretty(&results).into_diagnostic()? + ), + "yaml" => print!("{}", serde_yml::to_string(&results).into_diagnostic()?), + "table" => { + for result in &results { + let status = if result.passed { "PASS" } else { "FAIL" }; + println!( + " [{status}] {} ({}ms) — {}", + result.name, result.duration_ms, result.message + ); + } + } + _ => return Err(miette::miette!("unsupported output format: {output}")), + } + + if any_failed { + return Err(miette::miette!("one or more conformance scenarios failed")); + } + Ok(()) +} + +/// Dispatch a scenario by name. +async fn run_scenario(name: &str, client: &mut GrpcClient, run_id: &str) -> Result<()> { + match name { + "lifecycle" => scenario_lifecycle(client, run_id).await, + "not-found" => scenario_not_found(client, run_id).await, + "idempotent-delete" => scenario_idempotent_delete(client, run_id).await, + "validate" => scenario_validate(client).await, + "concurrent" => scenario_concurrent(client, run_id).await, + "labels" => scenario_labels(client, run_id).await, + _ => Err(miette::miette!("scenario '{name}' is not yet implemented")), + } +} + +/// Poll `WatchSandbox` until the sandbox reaches Ready, returning an error on +/// Error phase or a closed stream. Does not perform cleanup — callers are +/// responsible for deleting the sandbox if this returns an error. +async fn wait_for_ready( + client: &mut GrpcClient, + sandbox_id: &str, + sandbox_name: &str, +) -> Result<()> { + let mut stream = client + .watch_sandbox(WatchSandboxRequest { + id: sandbox_id.to_string(), + follow_status: true, + follow_logs: false, + follow_events: false, + log_tail_lines: 0, + event_tail: 0, + stop_on_terminal: false, + log_since_ms: 0, + log_sources: vec![], + log_min_level: String::new(), + }) + .await + .into_diagnostic() + .wrap_err("watch_sandbox failed")? + .into_inner(); + + while let Some(item) = stream.next().await { + let evt = item + .into_diagnostic() + .wrap_err("watch_sandbox stream error")?; + if let Some(openshell_core::proto::sandbox_stream_event::Payload::Sandbox(s)) = evt.payload + { + match SandboxPhase::try_from(s.phase()).unwrap_or(SandboxPhase::Unknown) { + SandboxPhase::Ready => return Ok(()), + SandboxPhase::Error => { + return Err(miette::miette!( + "sandbox '{sandbox_name}' entered Error phase before becoming Ready" + )); + } + _ => {} + } + } + } + + Err(miette::miette!( + "watch stream ended before sandbox '{sandbox_name}' reached Ready" + )) +} + +/// Poll until the gateway no longer has a record for the sandbox. +async fn wait_for_not_found(client: &mut GrpcClient, sandbox_name: &str) -> Result<()> { + loop { + match client + .get_sandbox(GetSandboxRequest { + name: sandbox_name.to_string(), + workspace: String::new(), + }) + .await + { + Err(status) if status.code() == Code::NotFound => return Ok(()), + Err(status) => { + return Err(miette::miette!( + "get_sandbox returned {} while waiting for sandbox '{sandbox_name}' to be removed", + status.code() + )); + } + Ok(_) => tokio::time::sleep(Duration::from_millis(250)).await, + } + } +} + +/// Scenario: create → ready → delete. +/// +/// Creates a minimal sandbox, waits for it to reach the Ready phase, then +/// deletes it. Verifies that the sandbox appears in the list between create +/// and delete, and that delete reports it as deleted. +async fn scenario_lifecycle(client: &mut GrpcClient, run_id: &str) -> Result<()> { + let sandbox_name = format!("conformance-lifecycle-{run_id}"); + + // ── 1. Create ──────────────────────────────────────────────────────── + let response = client + .create_sandbox(CreateSandboxRequest { + name: sandbox_name.clone(), + spec: Some(SandboxSpec::default()), + labels: HashMap::default(), + annotations: HashMap::default(), + workspace: String::new(), + }) + .await + .into_diagnostic() + .wrap_err("create_sandbox failed")?; + + let sandbox = response + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("create_sandbox response missing sandbox"))?; + let sandbox_id = sandbox.object_id().to_string(); + + // ── 2. Wait for Ready ──────────────────────────────────────────────── + if let Err(e) = wait_for_ready(client, &sandbox_id, &sandbox_name).await { + let _ = client + .delete_sandbox(DeleteSandboxRequest { + name: sandbox_name.clone(), + workspace: String::new(), + }) + .await; + return Err(e); + } + + // ── 3. Verify it appears in the list ──────────────────────────────── + let list_response = client + .list_sandboxes(ListSandboxesRequest::default()) + .await + .into_diagnostic() + .wrap_err("list_sandboxes failed")?; + + let found = list_response + .into_inner() + .sandboxes + .iter() + .any(|s| s.object_name() == sandbox_name); + + if !found { + let _ = client + .delete_sandbox(DeleteSandboxRequest { + name: sandbox_name.clone(), + workspace: String::new(), + }) + .await; + return Err(miette::miette!( + "sandbox '{sandbox_name}' not found in list_sandboxes response after creation" + )); + } + + // ── 4. Delete ──────────────────────────────────────────────────────── + let del_response = client + .delete_sandbox(DeleteSandboxRequest { + name: sandbox_name.clone(), + workspace: String::new(), + }) + .await + .into_diagnostic() + .wrap_err("delete_sandbox failed")?; + + if !del_response.into_inner().deleted { + return Err(miette::miette!( + "delete_sandbox reported sandbox '{sandbox_name}' was not deleted" + )); + } + + Ok(()) +} + +/// Scenario: get and delete a sandbox that does not exist. +/// +/// Verifies that `GetSandbox` and `DeleteSandbox` return `NOT_FOUND` for a +/// name that was never created. +async fn scenario_not_found(client: &mut GrpcClient, run_id: &str) -> Result<()> { + let phantom_name = format!("conformance-not-found-{run_id}"); + + // ── 1. GetSandbox → NOT_FOUND ──────────────────────────────────────── + let err = client + .get_sandbox(GetSandboxRequest { + name: phantom_name.clone(), + workspace: String::new(), + }) + .await + .expect_err("get_sandbox on a non-existent sandbox should have returned NOT_FOUND"); + + if err.code() != Code::NotFound { + return Err(miette::miette!( + "get_sandbox returned {} instead of NOT_FOUND", + err.code() + )); + } + + // ── 2. DeleteSandbox → NOT_FOUND ──────────────────────────────────── + let err = client + .delete_sandbox(DeleteSandboxRequest { + name: phantom_name.clone(), + workspace: String::new(), + }) + .await + .expect_err("delete_sandbox on a non-existent sandbox should have returned NOT_FOUND"); + + if err.code() != Code::NotFound { + return Err(miette::miette!( + "delete_sandbox returned {} instead of NOT_FOUND", + err.code() + )); + } + + Ok(()) +} + +/// Scenario: delete an already-deleted sandbox returns `NOT_FOUND`. +/// +/// Creates a sandbox, waits for it to be Ready, deletes it (expecting +/// `deleted: true`), then deletes it again and asserts the second call returns +/// `NOT_FOUND` because the gateway record has already been removed. +async fn scenario_idempotent_delete(client: &mut GrpcClient, run_id: &str) -> Result<()> { + let sandbox_name = format!("conformance-idempotent-delete-{run_id}"); + + // ── 1. Create ──────────────────────────────────────────────────────── + let response = client + .create_sandbox(CreateSandboxRequest { + name: sandbox_name.clone(), + spec: Some(SandboxSpec::default()), + labels: HashMap::default(), + annotations: HashMap::default(), + workspace: String::new(), + }) + .await + .into_diagnostic() + .wrap_err("create_sandbox failed")?; + + let sandbox = response + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("create_sandbox response missing sandbox"))?; + let sandbox_id = sandbox.object_id().to_string(); + + // ── 2. Wait for Ready ──────────────────────────────────────────────── + if let Err(e) = wait_for_ready(client, &sandbox_id, &sandbox_name).await { + let _ = client + .delete_sandbox(DeleteSandboxRequest { + name: sandbox_name.clone(), + workspace: String::new(), + }) + .await; + return Err(e); + } + + // ── 3. First delete → deleted: true ────────────────────────────────── + let del1 = client + .delete_sandbox(DeleteSandboxRequest { + name: sandbox_name.clone(), + workspace: String::new(), + }) + .await + .into_diagnostic() + .wrap_err("first delete_sandbox failed")? + .into_inner(); + + if !del1.deleted { + return Err(miette::miette!( + "first delete_sandbox reported deleted=false for sandbox '{sandbox_name}'" + )); + } + + // ── 4. Wait for the gateway record to be removed ──────────────────── + wait_for_not_found(client, &sandbox_name).await?; + + // ── 5. Second delete → NOT_FOUND ──────────────────────────────────── + let err = client + .delete_sandbox(DeleteSandboxRequest { + name: sandbox_name.clone(), + workspace: String::new(), + }) + .await + .expect_err("second delete_sandbox should have returned NOT_FOUND"); + + if err.code() != Code::NotFound { + return Err(miette::miette!( + "second delete_sandbox returned {} instead of NOT_FOUND", + err.code() + )); + } + + Ok(()) +} + +/// Scenario: invalid sandbox specs are rejected before creation. +/// +/// Verifies that the gateway returns `INVALID_ARGUMENT` for two distinct +/// invalid inputs without creating any sandbox: a missing spec field and a +/// zero GPU count. +async fn scenario_validate(client: &mut GrpcClient) -> Result<()> { + // ── 1. spec=None → INVALID_ARGUMENT ────────────────────────────────── + let err = client + .create_sandbox(CreateSandboxRequest { + name: String::new(), + spec: None, + labels: HashMap::default(), + annotations: HashMap::default(), + workspace: String::new(), + }) + .await + .expect_err("create_sandbox with spec=None should have been rejected"); + + if err.code() != Code::InvalidArgument { + return Err(miette::miette!( + "create_sandbox(spec=None) returned {} instead of INVALID_ARGUMENT", + err.code() + )); + } + + // ── 2. gpu.count=0 → INVALID_ARGUMENT ──────────────────────────────── + let err2 = client + .create_sandbox(CreateSandboxRequest { + name: String::new(), + spec: Some(SandboxSpec { + resource_requirements: Some(ResourceRequirements { + gpu: Some(GpuResourceRequirements { count: Some(0) }), + }), + ..Default::default() + }), + labels: HashMap::default(), + annotations: HashMap::default(), + workspace: String::new(), + }) + .await + .expect_err("create_sandbox with gpu.count=0 should have been rejected"); + + if err2.code() != Code::InvalidArgument { + return Err(miette::miette!( + "create_sandbox(gpu.count=0) returned {} instead of INVALID_ARGUMENT", + err2.code() + )); + } + + Ok(()) +} + +/// Scenario: two sandboxes created simultaneously do not interfere. +/// +/// Issues two `CreateSandbox` calls concurrently, waits for both to reach +/// Ready in parallel, verifies both appear in `ListSandboxes`, then deletes +/// both. Any failure cleans up both sandboxes before returning. +async fn scenario_concurrent(client: &mut GrpcClient, run_id: &str) -> Result<()> { + let name_a = format!("conformance-concurrent-a-{run_id}"); + let name_b = format!("conformance-concurrent-b-{run_id}"); + + // ── 1. Create both sandboxes concurrently ──────────────────────────── + let mut client_a = client.clone(); + let mut client_b = client.clone(); + + let (resp_a, resp_b) = tokio::join!( + client_a.create_sandbox(CreateSandboxRequest { + name: name_a.clone(), + spec: Some(SandboxSpec::default()), + labels: HashMap::default(), + annotations: HashMap::default(), + workspace: String::new(), + }), + client_b.create_sandbox(CreateSandboxRequest { + name: name_b.clone(), + spec: Some(SandboxSpec::default()), + labels: HashMap::default(), + annotations: HashMap::default(), + workspace: String::new(), + }), + ); + + let sandbox_a = resp_a + .into_diagnostic() + .wrap_err("create_sandbox(a) failed")? + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("create_sandbox(a) response missing sandbox"))?; + let sandbox_b = resp_b + .into_diagnostic() + .wrap_err("create_sandbox(b) failed")? + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("create_sandbox(b) response missing sandbox"))?; + + let id_a = sandbox_a.object_id().to_string(); + let id_b = sandbox_b.object_id().to_string(); + + // ── 2. Wait for both to reach Ready concurrently ───────────────────── + let (ready_a, ready_b) = tokio::join!( + wait_for_ready(&mut client_a, &id_a, &name_a), + wait_for_ready(&mut client_b, &id_b, &name_b), + ); + + // Best-effort cleanup before surfacing watch errors. + if ready_a.is_err() || ready_b.is_err() { + let _ = client + .delete_sandbox(DeleteSandboxRequest { + name: name_a.clone(), + workspace: String::new(), + }) + .await; + let _ = client + .delete_sandbox(DeleteSandboxRequest { + name: name_b.clone(), + workspace: String::new(), + }) + .await; + ready_a?; + ready_b?; + } + + // ── 3. Both appear in the list ─────────────────────────────────────── + let sandboxes = client + .list_sandboxes(ListSandboxesRequest::default()) + .await + .into_diagnostic() + .wrap_err("list_sandboxes failed")? + .into_inner() + .sandboxes; + + let found_a = sandboxes.iter().any(|s| s.object_name() == name_a); + let found_b = sandboxes.iter().any(|s| s.object_name() == name_b); + + // ── 4. Delete both ─────────────────────────────────────────────────── + let _ = client + .delete_sandbox(DeleteSandboxRequest { + name: name_a.clone(), + workspace: String::new(), + }) + .await; + let _ = client + .delete_sandbox(DeleteSandboxRequest { + name: name_b.clone(), + workspace: String::new(), + }) + .await; + + if !found_a { + return Err(miette::miette!( + "sandbox '{name_a}' not found in list_sandboxes after concurrent create" + )); + } + if !found_b { + return Err(miette::miette!( + "sandbox '{name_b}' not found in list_sandboxes after concurrent create" + )); + } + + Ok(()) +} + +/// Scenario: labels are persisted on create and filter list results correctly. +/// +/// Creates two sandboxes with distinct label values, calls `ListSandboxes` +/// with a label selector, and verifies that only the matching sandbox is +/// returned. Labels are stored by the gateway on creation so no Ready wait +/// is required; both sandboxes are deleted after the assertion. +async fn scenario_labels(client: &mut GrpcClient, run_id: &str) -> Result<()> { + let name_a = format!("conformance-labels-a-{run_id}"); + let name_b = format!("conformance-labels-b-{run_id}"); + let label_key = "conformance-scenario".to_string(); + + // ── 1. Create two sandboxes with distinct label values ─────────────── + client + .create_sandbox(CreateSandboxRequest { + name: name_a.clone(), + spec: Some(SandboxSpec::default()), + labels: [(label_key.clone(), "labels-a".to_string())] + .into_iter() + .collect(), + annotations: HashMap::default(), + workspace: String::new(), + }) + .await + .into_diagnostic() + .wrap_err("create_sandbox(a) failed")?; + + client + .create_sandbox(CreateSandboxRequest { + name: name_b.clone(), + spec: Some(SandboxSpec::default()), + labels: [(label_key.clone(), "labels-b".to_string())] + .into_iter() + .collect(), + annotations: HashMap::default(), + workspace: String::new(), + }) + .await + .into_diagnostic() + .wrap_err("create_sandbox(b) failed")?; + + // ── 2. Filter by label — must return only sandbox A ────────────────── + let filtered = client + .list_sandboxes(ListSandboxesRequest { + label_selector: format!("{label_key}=labels-a"), + ..Default::default() + }) + .await + .into_diagnostic() + .wrap_err("list_sandboxes with label_selector failed")? + .into_inner() + .sandboxes; + + // ── 3. Cleanup ─────────────────────────────────────────────────────── + let _ = client + .delete_sandbox(DeleteSandboxRequest { + name: name_a.clone(), + workspace: String::new(), + }) + .await; + let _ = client + .delete_sandbox(DeleteSandboxRequest { + name: name_b.clone(), + workspace: String::new(), + }) + .await; + + // ── 4. Assert after cleanup so both sandboxes are always removed ────── + let found_a = filtered.iter().any(|s| s.object_name() == name_a); + let found_b = filtered.iter().any(|s| s.object_name() == name_b); + + if !found_a { + return Err(miette::miette!( + "sandbox '{name_a}' not found in label-filtered list (selector: {label_key}=labels-a)" + )); + } + if found_b { + return Err(miette::miette!( + "sandbox '{name_b}' appeared in label-filtered list but should have been excluded \ + (selector: {label_key}=labels-a)" + )); + } + + Ok(()) +} + +pub fn conformance_list(output: &str) -> Result<()> { + let scenarios = all_scenarios(); + + match output { + "json" => { + let values: Vec<_> = scenarios + .iter() + .map(|scenario| { + serde_json::json!({ + "name": scenario.name, + "description": scenario.description, + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&values).into_diagnostic()? + ); + } + "yaml" => { + let values: Vec<_> = scenarios + .iter() + .map(|scenario| { + serde_json::json!({ + "name": scenario.name, + "description": scenario.description, + }) + }) + .collect(); + print!("{}", serde_yml::to_string(&values).into_diagnostic()?); + } + "table" => { + println!("{} conformance scenarios:", scenarios.len()); + for scenario in scenarios { + println!(" {:<20} {}", scenario.name, scenario.description); + } + } + _ => return Err(miette::miette!("unsupported output format: {output}")), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{all_scenarios, select_scenarios}; + + #[test] + fn ci_scenario_set_contains_only_implemented_scenarios() { + let names = all_scenarios() + .iter() + .map(|scenario| scenario.name) + .collect::>(); + + assert_eq!( + names, + [ + "lifecycle", + "not-found", + "idempotent-delete", + "validate", + "concurrent", + "labels", + ] + ); + } + + #[test] + fn filter_must_match_at_least_one_scenario() { + let error = select_scenarios(Some("lifecylce")) + .err() + .expect("a filter typo should not produce an empty successful run"); + + assert!( + error.to_string().contains("matched no scenarios"), + "unexpected error: {error}" + ); + } + + #[test] + fn scenario_cleanup_inventory_covers_created_sandboxes() { + let names = all_scenarios() + .iter() + .flat_map(|scenario| scenario.sandbox_names("deadbeef")) + .collect::>(); + + assert_eq!( + names, + [ + "conformance-lifecycle-deadbeef", + "conformance-idempotent-delete-deadbeef", + "conformance-concurrent-a-deadbeef", + "conformance-concurrent-b-deadbeef", + "conformance-labels-a-deadbeef", + "conformance-labels-b-deadbeef", + ] + ); + } +} diff --git a/e2e/rust/src/lib.rs b/e2e/rust/src/lib.rs index 144bc75084..f6ef66893d 100644 --- a/e2e/rust/src/lib.rs +++ b/e2e/rust/src/lib.rs @@ -9,4 +9,5 @@ //! - Parsing CLI output (ANSI stripping, field extraction) //! - TCP port utilities (wait for port, find free port) +pub mod conformance; pub mod harness; diff --git a/e2e/rust/tests/conformance.rs b/e2e/rust/tests/conformance.rs new file mode 100644 index 0000000000..57cbcbd32a --- /dev/null +++ b/e2e/rust/tests/conformance.rs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Run the shared driver-agnostic conformance baseline against the gateway +//! registered by the surrounding e2e environment. + +use miette::Result; +use openshell_e2e::conformance::ConnectionOptions; +use std::path::PathBuf; + +#[tokio::test] +async fn gateway_conformance() -> Result<()> { + let gateway_endpoint = required_env("OPENSHELL_GATEWAY_ENDPOINT")?; + + openshell_e2e::conformance::conformance_run( + &gateway_endpoint, + &ConnectionOptions { + tls_ca: optional_env("OPENSHELL_CONFORMANCE_TLS_CA").map(PathBuf::from), + tls_cert: optional_env("OPENSHELL_CONFORMANCE_TLS_CERT").map(PathBuf::from), + tls_key: optional_env("OPENSHELL_CONFORMANCE_TLS_KEY").map(PathBuf::from), + }, + None, + conformance_timeout(), + "table", + ) + .await +} + +fn optional_env(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +fn required_env(name: &str) -> Result { + optional_env(name).ok_or_else(|| miette::miette!("{name} must be set by the e2e harness")) +} + +fn conformance_timeout() -> u64 { + std::env::var("OPENSHELL_CONFORMANCE_TIMEOUT") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(300) +} diff --git a/e2e/with-docker-gateway.sh b/e2e/with-docker-gateway.sh index 958ea1b0eb..5615c2c5d7 100755 --- a/e2e/with-docker-gateway.sh +++ b/e2e/with-docker-gateway.sh @@ -603,6 +603,10 @@ else fi export OPENSHELL_GATEWAY="${GATEWAY_NAME}" +export OPENSHELL_GATEWAY_ENDPOINT="${CLI_GATEWAY_ENDPOINT}" +export OPENSHELL_CONFORMANCE_TLS_CA="${PKI_DIR}/ca.crt" +export OPENSHELL_CONFORMANCE_TLS_CERT="${PKI_DIR}/client/tls.crt" +export OPENSHELL_CONFORMANCE_TLS_KEY="${PKI_DIR}/client/tls.key" export OPENSHELL_PROVISION_TIMEOUT="${OPENSHELL_PROVISION_TIMEOUT:-180}" if [ "${OIDC_MODE}" = "1" ] || [ -n "${OPENSHELL_OIDC_ISSUER:-}" ]; then diff --git a/e2e/with-kube-gateway.sh b/e2e/with-kube-gateway.sh index b6ebb7ca08..3711f39bd8 100755 --- a/e2e/with-kube-gateway.sh +++ b/e2e/with-kube-gateway.sh @@ -464,6 +464,7 @@ run_scenario() { "${LOCAL_PORT}" export OPENSHELL_GATEWAY="${GATEWAY_NAME}" + export OPENSHELL_GATEWAY_ENDPOINT="${GATEWAY_ENDPOINT}" export OPENSHELL_E2E_DRIVER="kubernetes" # Kubernetes e2e runs against k3d/kind-style Docker-backed clusters. Host # fixture containers must use the same Docker host so published ports and @@ -947,6 +948,7 @@ else "${LOCAL_PORT}" export OPENSHELL_GATEWAY="${GATEWAY_NAME}" + export OPENSHELL_GATEWAY_ENDPOINT="${GATEWAY_ENDPOINT}" export OPENSHELL_E2E_DRIVER="kubernetes" # Kubernetes e2e runs against k3d/kind-style Docker-backed clusters. Host # fixture containers must use the same Docker host so published ports and diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index bb22ee7376..7590581a02 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -557,6 +557,10 @@ else fi export OPENSHELL_GATEWAY="${GATEWAY_NAME}" +export OPENSHELL_GATEWAY_ENDPOINT="${CLI_GATEWAY_ENDPOINT}" +export OPENSHELL_CONFORMANCE_TLS_CA="${PKI_DIR}/ca.crt" +export OPENSHELL_CONFORMANCE_TLS_CERT="${PKI_DIR}/client/tls.crt" +export OPENSHELL_CONFORMANCE_TLS_KEY="${PKI_DIR}/client/tls.key" export OPENSHELL_PROVISION_TIMEOUT="${OPENSHELL_PROVISION_TIMEOUT:-300}" if [ "${OIDC_MODE}" = "1" ] || [ -n "${OPENSHELL_OIDC_ISSUER:-}" ]; then diff --git a/tasks/test.toml b/tasks/test.toml index ea77f23295..512baf6d6a 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -81,9 +81,9 @@ run = "uv run pytest python/" hide = true ["e2e:rust"] -description = "Run Rust CLI e2e tests against a Docker-backed gateway" +description = "Run Rust e2e, CLI conformance, and API conformance tests against a Docker-backed gateway" run = [ - "e2e/with-docker-gateway.sh cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-docker,e2e-cli-conformance", + "e2e/with-docker-gateway.sh cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-docker,e2e-api-conformance,e2e-cli-conformance", ] ["e2e:cli-conformance"] From 2a445951efd20a74d8402b7ce4027cbdd921ccfc Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 17 Jul 2026 14:59:07 +0200 Subject: [PATCH 2/7] test(e2e): add command execution conformance Signed-off-by: Evan Lezar # Conflicts: # e2e/rust/CONFORMANCE.md # e2e/rust/e2e-vm.sh # e2e/rust/tests/sandbox_lifecycle.rs --- e2e/rust/CONFORMANCE.md | 12 +++- e2e/rust/e2e-vm.sh | 6 +- e2e/rust/src/conformance.rs | 114 +++++++++++++++++++++++++++++++++++- e2e/rust/tests/smoke.rs | 4 +- tasks/test.toml | 4 +- 5 files changed, 127 insertions(+), 13 deletions(-) diff --git a/e2e/rust/CONFORMANCE.md b/e2e/rust/CONFORMANCE.md index aa885975cd..42e2519b43 100644 --- a/e2e/rust/CONFORMANCE.md +++ b/e2e/rust/CONFORMANCE.md @@ -27,8 +27,16 @@ The capabilities scenario remains deferred until the gateway exposes driver capabilities through its public API. It is not included as a known-failing CI scenario. -The `e2e-api-conformance` profile invokes the scenario engine directly against -every gateway driver. +The test profiles separate the surface being validated: + +- `e2e-api-conformance` invokes the scenario engine directly against every + gateway driver. This includes command execution through the streaming + `ExecSandbox` API. +- `e2e-cli-conformance` validates portable CLI behavior against the canonical + Docker-backed gateway, including the gateway smoke test. + +VM filesystem behavior is not driver-independent. The `vm_overlay` test remains +under the `e2e-vm` profile and runs alongside API conformance in the VM lane. Gateway provisioners export the endpoint and any mTLS paths through `OPENSHELL_GATEWAY_ENDPOINT` and the `OPENSHELL_CONFORMANCE_TLS_*` variables. diff --git a/e2e/rust/e2e-vm.sh b/e2e/rust/e2e-vm.sh index 29791b65aa..3de0418456 100755 --- a/e2e/rust/e2e-vm.sh +++ b/e2e/rust/e2e-vm.sh @@ -2,8 +2,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Run the Rust e2e smoke test against an openshell-gateway running the -# standalone VM compute driver (`openshell-driver-vm`). +# Run the Rust VM overlay test and API conformance suite against an +# openshell-gateway using the standalone VM compute driver. # # Architecture (post supervisor-initiated relay, PR #867): # * The gateway never dials the sandbox. Instead, the in-guest @@ -64,7 +64,7 @@ SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-${COMMUNITY_SANDBOX_IMAGE:-ghcr.io/nvi # so root state under `/tmp` unconditionally to keep UDS paths short. STATE_DIR_ROOT="/tmp" -# Smoke test timeouts. First boot extracts the embedded libkrun runtime +# E2E timeouts. First boot extracts the embedded libkrun runtime # (~60-90MB of zstd per architecture) and prepares an ext4 root disk from the # configured image. The guest then starts the sandbox supervisor directly; a cold # microVM is typically ready within ~15s after image preparation. diff --git a/e2e/rust/src/conformance.rs b/e2e/rust/src/conformance.rs index c9cfb1c5ca..3753044f22 100644 --- a/e2e/rust/src/conformance.rs +++ b/e2e/rust/src/conformance.rs @@ -10,9 +10,9 @@ use std::time::Duration; use futures_util::StreamExt; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ - CreateSandboxRequest, DeleteSandboxRequest, GetSandboxRequest, GpuResourceRequirements, - ListSandboxesRequest, ResourceRequirements, SandboxPhase, SandboxSpec, WatchSandboxRequest, - open_shell_client::OpenShellClient, + CreateSandboxRequest, DeleteSandboxRequest, ExecSandboxRequest, GetSandboxRequest, + GpuResourceRequirements, ListSandboxesRequest, ResourceRequirements, SandboxPhase, SandboxSpec, + WatchSandboxRequest, exec_sandbox_event, open_shell_client::OpenShellClient, }; use openshell_core::{ObjectId, ObjectName}; use tonic::Code; @@ -129,6 +129,11 @@ fn all_scenarios() -> &'static [Scenario] { description: "Labels are persisted on create and filter list results correctly", sandbox_name_stems: &["labels-a", "labels-b"], }, + Scenario { + name: "exec", + description: "A command executes in a ready sandbox and streams its output and exit status", + sandbox_name_stems: &["exec"], + }, ] } @@ -304,6 +309,7 @@ async fn run_scenario(name: &str, client: &mut GrpcClient, run_id: &str) -> Resu "validate" => scenario_validate(client).await, "concurrent" => scenario_concurrent(client, run_id).await, "labels" => scenario_labels(client, run_id).await, + "exec" => scenario_exec(client, run_id).await, _ => Err(miette::miette!("scenario '{name}' is not yet implemented")), } } @@ -827,6 +833,106 @@ async fn scenario_labels(client: &mut GrpcClient, run_id: &str) -> Result<()> { Ok(()) } +/// Scenario: execute a command in a ready sandbox through the gateway API. +/// +/// Verifies stdout and the final exit status independently, then deletes the +/// sandbox regardless of whether command execution succeeds. +async fn scenario_exec(client: &mut GrpcClient, run_id: &str) -> Result<()> { + const OUTPUT_MARKER: &str = "conformance-exec-ok"; + + let sandbox_name = format!("conformance-exec-{run_id}"); + let response = client + .create_sandbox(CreateSandboxRequest { + name: sandbox_name.clone(), + spec: Some(SandboxSpec::default()), + labels: HashMap::default(), + annotations: HashMap::default(), + workspace: String::new(), + }) + .await + .into_diagnostic() + .wrap_err("create_sandbox failed")?; + + let sandbox = response + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("create_sandbox response missing sandbox"))?; + let sandbox_id = sandbox.object_id().to_string(); + + if let Err(error) = wait_for_ready(client, &sandbox_id, &sandbox_name).await { + let _ = client + .delete_sandbox(DeleteSandboxRequest { + name: sandbox_name, + workspace: String::new(), + }) + .await; + return Err(error); + } + + let exec_result = async { + let mut stream = client + .exec_sandbox(ExecSandboxRequest { + sandbox_id, + command: vec![ + "sh".to_string(), + "-c".to_string(), + format!("printf {OUTPUT_MARKER}"), + ], + ..Default::default() + }) + .await + .into_diagnostic() + .wrap_err("exec_sandbox failed")? + .into_inner(); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut exit_code = None; + + while let Some(event) = stream.next().await { + match event + .into_diagnostic() + .wrap_err("exec_sandbox stream error")? + .payload + { + Some(exec_sandbox_event::Payload::Stdout(chunk)) => stdout.extend(chunk.data), + Some(exec_sandbox_event::Payload::Stderr(chunk)) => stderr.extend(chunk.data), + Some(exec_sandbox_event::Payload::Exit(exit)) => exit_code = Some(exit.exit_code), + None => {} + } + } + + let stdout = String::from_utf8_lossy(&stdout); + let stderr = String::from_utf8_lossy(&stderr); + if !stdout.contains(OUTPUT_MARKER) { + return Err(miette::miette!( + "exec_sandbox stdout did not contain '{OUTPUT_MARKER}'; stdout={stdout:?}, stderr={stderr:?}" + )); + } + if exit_code != Some(0) { + return Err(miette::miette!( + "exec_sandbox returned exit code {exit_code:?}; stdout={stdout:?}, stderr={stderr:?}" + )); + } + + Ok(()) + } + .await; + + let cleanup_result = client + .delete_sandbox(DeleteSandboxRequest { + name: sandbox_name, + workspace: String::new(), + }) + .await + .into_diagnostic() + .wrap_err("delete_sandbox after exec failed"); + + exec_result?; + cleanup_result?; + Ok(()) +} + pub fn conformance_list(output: &str) -> Result<()> { let scenarios = all_scenarios(); @@ -889,6 +995,7 @@ mod tests { "validate", "concurrent", "labels", + "exec", ] ); } @@ -921,6 +1028,7 @@ mod tests { "conformance-concurrent-b-deadbeef", "conformance-labels-a-deadbeef", "conformance-labels-b-deadbeef", + "conformance-exec-deadbeef", ] ); } diff --git a/e2e/rust/tests/smoke.rs b/e2e/rust/tests/smoke.rs index 172afa22b8..ab4817989a 100644 --- a/e2e/rust/tests/smoke.rs +++ b/e2e/rust/tests/smoke.rs @@ -6,9 +6,7 @@ //! Smoke test: verify the gateway is healthy, create a sandbox, exec a //! command inside it, and tear it down. //! -//! This test is cluster-agnostic — it works against any running gateway -//! (Docker-based cluster or openshell-driver-vm microVM). The `e2e:vm` mise -//! task uses it to validate the VM gateway after boot. +//! This test is cluster-agnostic and works against any running gateway. use std::process::Stdio; use std::time::Duration; diff --git a/tasks/test.toml b/tasks/test.toml index 512baf6d6a..e5ed244290 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -187,7 +187,7 @@ env = { OPENSHELL_E2E_KUBE_EXTRA_VALUES = "deploy/helm/openshell/ci/values-works run = "e2e/rust/e2e-kubernetes.sh" ["e2e:vm"] -description = "Start openshell-gateway with the VM compute driver and run VM e2e tests" +description = "Start openshell-gateway with the VM compute driver and run VM-specific and API conformance e2e tests" run = "e2e/rust/e2e-vm.sh" ["e2e:gateway:no-compute-drivers"] @@ -215,7 +215,7 @@ env = { OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER = "1", OPENSHELL_E2E_KUBE_BUILD_IM run = "e2e/rust/e2e-kubernetes.sh" ["e2e:docker"] -description = "Run smoke e2e against a standalone gateway with the Docker compute driver" +description = "Run CLI smoke conformance against a standalone gateway with the Docker compute driver" run = "e2e/rust/e2e-docker.sh" ["e2e:mechanistic-smoke"] From c366491439f2e8c9ef1c97ef23af25f77d7983d0 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 17 Jul 2026 15:27:35 +0200 Subject: [PATCH 3/7] test(e2e): add process hardening API conformance Signed-off-by: Evan Lezar --- e2e/rust/CONFORMANCE.md | 2 +- e2e/rust/src/conformance.rs | 177 +++++++++++++++++++------- e2e/rust/tests/core_dump_hardening.rs | 26 ---- 3 files changed, 130 insertions(+), 75 deletions(-) delete mode 100644 e2e/rust/tests/core_dump_hardening.rs diff --git a/e2e/rust/CONFORMANCE.md b/e2e/rust/CONFORMANCE.md index 42e2519b43..fcdc250075 100644 --- a/e2e/rust/CONFORMANCE.md +++ b/e2e/rust/CONFORMANCE.md @@ -31,7 +31,7 @@ The test profiles separate the surface being validated: - `e2e-api-conformance` invokes the scenario engine directly against every gateway driver. This includes command execution through the streaming - `ExecSandbox` API. + `ExecSandbox` API and process-hardening checks. - `e2e-cli-conformance` validates portable CLI behavior against the canonical Docker-backed gateway, including the gateway smoke test. diff --git a/e2e/rust/src/conformance.rs b/e2e/rust/src/conformance.rs index 3753044f22..b75bdf6417 100644 --- a/e2e/rust/src/conformance.rs +++ b/e2e/rust/src/conformance.rs @@ -134,6 +134,11 @@ fn all_scenarios() -> &'static [Scenario] { description: "A command executes in a ready sandbox and streams its output and exit status", sandbox_name_stems: &["exec"], }, + Scenario { + name: "process-hardening", + description: "Sandbox processes start with core dumps disabled", + sandbox_name_stems: &["process-hardening"], + }, ] } @@ -310,6 +315,7 @@ async fn run_scenario(name: &str, client: &mut GrpcClient, run_id: &str) -> Resu "concurrent" => scenario_concurrent(client, run_id).await, "labels" => scenario_labels(client, run_id).await, "exec" => scenario_exec(client, run_id).await, + "process-hardening" => scenario_process_hardening(client, run_id).await, _ => Err(miette::miette!("scenario '{name}' is not yet implemented")), } } @@ -833,17 +839,56 @@ async fn scenario_labels(client: &mut GrpcClient, run_id: &str) -> Result<()> { Ok(()) } -/// Scenario: execute a command in a ready sandbox through the gateway API. -/// -/// Verifies stdout and the final exit status independently, then deletes the -/// sandbox regardless of whether command execution succeeds. -async fn scenario_exec(client: &mut GrpcClient, run_id: &str) -> Result<()> { - const OUTPUT_MARKER: &str = "conformance-exec-ok"; +struct ExecResult { + stdout: String, + stderr: String, + exit_code: Option, +} - let sandbox_name = format!("conformance-exec-{run_id}"); +async fn exec_command( + client: &mut GrpcClient, + sandbox_id: &str, + command: Vec, +) -> Result { + let mut stream = client + .exec_sandbox(ExecSandboxRequest { + sandbox_id: sandbox_id.to_string(), + command, + ..Default::default() + }) + .await + .into_diagnostic() + .wrap_err("exec_sandbox failed")? + .into_inner(); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut exit_code = None; + + while let Some(event) = stream.next().await { + match event + .into_diagnostic() + .wrap_err("exec_sandbox stream error")? + .payload + { + Some(exec_sandbox_event::Payload::Stdout(chunk)) => stdout.extend(chunk.data), + Some(exec_sandbox_event::Payload::Stderr(chunk)) => stderr.extend(chunk.data), + Some(exec_sandbox_event::Payload::Exit(exit)) => exit_code = Some(exit.exit_code), + None => {} + } + } + + Ok(ExecResult { + stdout: String::from_utf8_lossy(&stdout).into_owned(), + stderr: String::from_utf8_lossy(&stderr).into_owned(), + exit_code, + }) +} + +async fn create_ready_sandbox(client: &mut GrpcClient, sandbox_name: &str) -> Result { let response = client .create_sandbox(CreateSandboxRequest { - name: sandbox_name.clone(), + name: sandbox_name.to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::default(), annotations: HashMap::default(), @@ -859,59 +904,53 @@ async fn scenario_exec(client: &mut GrpcClient, run_id: &str) -> Result<()> { .ok_or_else(|| miette::miette!("create_sandbox response missing sandbox"))?; let sandbox_id = sandbox.object_id().to_string(); - if let Err(error) = wait_for_ready(client, &sandbox_id, &sandbox_name).await { + if let Err(error) = wait_for_ready(client, &sandbox_id, sandbox_name).await { let _ = client .delete_sandbox(DeleteSandboxRequest { - name: sandbox_name, + name: sandbox_name.to_string(), workspace: String::new(), }) .await; return Err(error); } - let exec_result = async { - let mut stream = client - .exec_sandbox(ExecSandboxRequest { - sandbox_id, - command: vec![ - "sh".to_string(), - "-c".to_string(), - format!("printf {OUTPUT_MARKER}"), - ], - ..Default::default() - }) - .await - .into_diagnostic() - .wrap_err("exec_sandbox failed")? - .into_inner(); - - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - let mut exit_code = None; - - while let Some(event) = stream.next().await { - match event - .into_diagnostic() - .wrap_err("exec_sandbox stream error")? - .payload - { - Some(exec_sandbox_event::Payload::Stdout(chunk)) => stdout.extend(chunk.data), - Some(exec_sandbox_event::Payload::Stderr(chunk)) => stderr.extend(chunk.data), - Some(exec_sandbox_event::Payload::Exit(exit)) => exit_code = Some(exit.exit_code), - None => {} - } - } + Ok(sandbox_id) +} + +/// Scenario: execute a command in a ready sandbox through the gateway API. +/// +/// Verifies stdout and the final exit status independently, then deletes the +/// sandbox regardless of whether command execution succeeds. +async fn scenario_exec(client: &mut GrpcClient, run_id: &str) -> Result<()> { + const OUTPUT_MARKER: &str = "conformance-exec-ok"; + + let sandbox_name = format!("conformance-exec-{run_id}"); + let sandbox_id = create_ready_sandbox(client, &sandbox_name).await?; - let stdout = String::from_utf8_lossy(&stdout); - let stderr = String::from_utf8_lossy(&stderr); - if !stdout.contains(OUTPUT_MARKER) { + let exec_result = async { + let result = exec_command( + client, + &sandbox_id, + vec![ + "sh".to_string(), + "-c".to_string(), + format!("printf {OUTPUT_MARKER}"), + ], + ) + .await?; + if !result.stdout.contains(OUTPUT_MARKER) { return Err(miette::miette!( - "exec_sandbox stdout did not contain '{OUTPUT_MARKER}'; stdout={stdout:?}, stderr={stderr:?}" + "exec_sandbox stdout did not contain '{OUTPUT_MARKER}'; stdout={:?}, stderr={:?}", + result.stdout, + result.stderr, )); } - if exit_code != Some(0) { + if result.exit_code != Some(0) { return Err(miette::miette!( - "exec_sandbox returned exit code {exit_code:?}; stdout={stdout:?}, stderr={stderr:?}" + "exec_sandbox returned exit code {:?}; stdout={:?}, stderr={:?}", + result.exit_code, + result.stdout, + result.stderr, )); } @@ -933,6 +972,46 @@ async fn scenario_exec(client: &mut GrpcClient, run_id: &str) -> Result<()> { Ok(()) } +/// Scenario: sandbox processes start with core dumps disabled. +async fn scenario_process_hardening(client: &mut GrpcClient, run_id: &str) -> Result<()> { + const OUTPUT_MARKER: &str = "core-limit-ok"; + + let sandbox_name = format!("conformance-process-hardening-{run_id}"); + let sandbox_id = create_ready_sandbox(client, &sandbox_name).await?; + let command_result = exec_command( + client, + &sandbox_id, + vec![ + "sh".to_string(), + "-lc".to_string(), + format!("test \"$(ulimit -c)\" = 0 && printf {OUTPUT_MARKER}"), + ], + ) + .await; + + let cleanup_result = client + .delete_sandbox(DeleteSandboxRequest { + name: sandbox_name, + workspace: String::new(), + }) + .await + .into_diagnostic() + .wrap_err("delete_sandbox after process hardening check failed"); + + let result = command_result?; + cleanup_result?; + if result.exit_code != Some(0) || !result.stdout.contains(OUTPUT_MARKER) { + return Err(miette::miette!( + "sandbox process core-dump check failed with exit code {:?}; stdout={:?}, stderr={:?}", + result.exit_code, + result.stdout, + result.stderr, + )); + } + + Ok(()) +} + pub fn conformance_list(output: &str) -> Result<()> { let scenarios = all_scenarios(); @@ -996,6 +1075,7 @@ mod tests { "concurrent", "labels", "exec", + "process-hardening", ] ); } @@ -1029,6 +1109,7 @@ mod tests { "conformance-labels-a-deadbeef", "conformance-labels-b-deadbeef", "conformance-exec-deadbeef", + "conformance-process-hardening-deadbeef", ] ); } diff --git a/e2e/rust/tests/core_dump_hardening.rs b/e2e/rust/tests/core_dump_hardening.rs deleted file mode 100644 index a400b6c218..0000000000 --- a/e2e/rust/tests/core_dump_hardening.rs +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(feature = "e2e")] - -use openshell_e2e::harness::sandbox::SandboxGuard; - -#[tokio::test] -async fn sandbox_processes_disable_core_dumps() { - let mut sb = SandboxGuard::create(&[ - "--", - "sh", - "-lc", - "test \"$(ulimit -c)\" = 0 && echo core-limit-ok", - ]) - .await - .expect("sandbox create should succeed"); - - assert!( - sb.create_output.contains("core-limit-ok"), - "expected sandbox output to confirm core dumps are disabled:\n{}", - sb.create_output, - ); - - sb.cleanup().await; -} From 8e3107df1a0d9c7603903be2d85209dfa50066ac Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 17 Jul 2026 16:09:26 +0200 Subject: [PATCH 4/7] docs(e2e): diagram conformance test architecture Signed-off-by: Evan Lezar --- e2e/rust/CONFORMANCE.md | 70 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/e2e/rust/CONFORMANCE.md b/e2e/rust/CONFORMANCE.md index fcdc250075..c285c97ccb 100644 --- a/e2e/rust/CONFORMANCE.md +++ b/e2e/rust/CONFORMANCE.md @@ -35,6 +35,76 @@ The test profiles separate the surface being validated: - `e2e-cli-conformance` validates portable CLI behavior against the canonical Docker-backed gateway, including the gateway smoke test. +## Test architecture + +```mermaid +flowchart LR + subgraph Provisioning["Gateway instantiation"] + P["Provisioner
Docker · Podman · Kubernetes · VM"] + C["Connection context
endpoint · mTLS · gateway registration"] + L["Lifecycle control
managed gateway metadata"] + end + + subgraph Tests["Test execution"] + API["API conformance
direct gRPC scenarios"] + CLI["CLI conformance
portable user workflows"] + DS["Driver-specific tests
runtime and infrastructure assertions"] + RR["Shared restart/resume scenario
driver-specific hooks"] + end + + OCLI["openshell CLI"] + GW["OpenShell gateway"] + DR["Compute driver
Docker · Podman · Kubernetes · VM"] + SB["Sandbox supervisor
and runtime"] + + P -.->|"instantiates"| GW + P -.->|"configures"| DR + P --> C + P --> L + + C -.-> API + C -.-> CLI + C -.-> DS + C -.-> RR + L -.-> RR + + API -->|"tonic / gRPC"| GW + CLI --> OCLI + DS --> OCLI + RR -->|"application assertions"| OCLI + + OCLI -->|"gateway API"| GW + GW --> DR + DR --> SB + + DS -.->|"driver or runtime observations"| DR + DS -.->|"sandbox-specific assertions"| SB + RR -.->|"stop / start"| GW +``` + +Solid arrows show normal request paths. Dashed arrows show provisioning, +connection metadata, lifecycle control, or implementation-specific +observations. Update this diagram whenever a test moves between profiles, a +new test surface is added, or the boundary between provisioning and test +execution changes. + +## Driver coverage + +| Driver | API conformance | CLI conformance | Driver-specific intent | Possible evolution | +| --- | --- | --- | --- | --- | +| Docker | Full baseline | Canonical full CLI profile; the focused task runs smoke only | Custom images, Docker preflight, volumes, restart/resume, and host gateway | Keep as the canonical CLI lane; external-image checks could become optional CLI conformance | +| Podman | Full baseline | Not currently enabled | Podman re-adoption, volumes, token restart, and host gateway | Enable selected CLI conformance to prove portability; keep restart behavior operational | +| Kubernetes | Full baseline | Not currently enabled | Readiness, user namespaces, Kubernetes topology, and host gateway | Enable selected CLI conformance where cluster fixtures permit; keep pod and deployment assertions driver-specific | +| VM | Full baseline | Smoke workflow | Overlay persistence, TLS permissions, host gateway, and restart/resume | Expand selected CLI conformance; keep overlay assertions driver-specific and restart behavior operational | + +Potential cross-driver follow-ups include: + +- API security and network conformance for bypass detection and `NO_PROXY` +- CLI conformance for settings management, live policy updates, and provider + auto-creation +- optional CLI conformance for external community-image resolution +- operational conformance for provisioner-controlled gateway restart/resume + VM filesystem behavior is not driver-independent. The `vm_overlay` test remains under the `e2e-vm` profile and runs alongside API conformance in the VM lane. From c01a4d0aad68b8c95abe57cc99edd0c12728be5a Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 22 Jul 2026 18:07:08 +0200 Subject: [PATCH 5/7] fix(e2e): make conformance tests runnable in CI Signed-off-by: Evan Lezar --- e2e/rust/CONFORMANCE.md | 4 +- e2e/rust/Cargo.lock | 1 + e2e/rust/Cargo.toml | 1 + e2e/rust/src/conformance.rs | 75 +++++++++++++++++++++++++------------ 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/e2e/rust/CONFORMANCE.md b/e2e/rust/CONFORMANCE.md index c285c97ccb..10b981c005 100644 --- a/e2e/rust/CONFORMANCE.md +++ b/e2e/rust/CONFORMANCE.md @@ -19,8 +19,8 @@ cargo run --manifest-path e2e/rust/Cargo.toml --bin openshell-conformance -- \ The runner requires an explicit gateway endpoint, either through `--gateway-endpoint` or `OPENSHELL_GATEWAY_ENDPOINT`. HTTPS gateways require explicit `--tls-ca`, `--tls-cert`, and `--tls-key` paths. Each created sandbox -uses the name -`conformance--`. The runner performs best-effort cleanup, and +uses a compact `osct--` name that fits the gateway's +19-character routable-name limit. The runner performs best-effort cleanup, and the prefix makes any sandbox left after an interruption easy to identify. The capabilities scenario remains deferred until the gateway exposes driver diff --git a/e2e/rust/Cargo.lock b/e2e/rust/Cargo.lock index 043f96a9ce..c70cbe072a 100644 --- a/e2e/rust/Cargo.lock +++ b/e2e/rust/Cargo.lock @@ -1055,6 +1055,7 @@ dependencies = [ "openshell-core", "prost", "rand", + "rustls", "serde", "serde_json", "serde_yml", diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 9adec9fcb4..96d829ec32 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -220,6 +220,7 @@ sha1 = "0.10" sha2 = "0.10" hex = "0.4" rand = "0.9" +rustls = { version = "0.23", default-features = false, features = ["std", "logging", "tls12", "ring"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yml = "0.0.12" diff --git a/e2e/rust/src/conformance.rs b/e2e/rust/src/conformance.rs index b75bdf6417..fe25e2aefa 100644 --- a/e2e/rust/src/conformance.rs +++ b/e2e/rust/src/conformance.rs @@ -20,6 +20,9 @@ use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity type GrpcClient = OpenShellClient; +const MAX_SANDBOX_NAME_LEN: usize = 19; +const SANDBOX_NAME_PREFIX: &str = "osct"; + /// Explicit connection material for an existing gateway installation. #[derive(Clone, Debug, Default)] pub struct ConnectionOptions { @@ -32,6 +35,10 @@ pub struct ConnectionOptions { } async fn grpc_client(server: &str, options: &ConnectionOptions) -> Result { + rustls::crypto::ring::default_provider() + .install_default() + .map_err(|e| miette::miette!("failed to install rustls crypto provider: {e:?}"))?; + let mut endpoint = Endpoint::from_shared(server.to_string()) .into_diagnostic()? .connect_timeout(Duration::from_secs(10)) @@ -102,7 +109,7 @@ fn all_scenarios() -> &'static [Scenario] { Scenario { name: "lifecycle", description: "Create → running → stop → delete completes without error", - sandbox_name_stems: &["lifecycle"], + sandbox_name_stems: &["life"], }, Scenario { name: "not-found", @@ -112,7 +119,7 @@ fn all_scenarios() -> &'static [Scenario] { Scenario { name: "idempotent-delete", description: "Deleting an already-deleted sandbox returns NOT_FOUND", - sandbox_name_stems: &["idempotent-delete"], + sandbox_name_stems: &["del"], }, Scenario { name: "validate", @@ -122,12 +129,12 @@ fn all_scenarios() -> &'static [Scenario] { Scenario { name: "concurrent", description: "Two sandboxes created simultaneously do not interfere", - sandbox_name_stems: &["concurrent-a", "concurrent-b"], + sandbox_name_stems: &["con-a", "con-b"], }, Scenario { name: "labels", description: "Labels are persisted on create and filter list results correctly", - sandbox_name_stems: &["labels-a", "labels-b"], + sandbox_name_stems: &["lbl-a", "lbl-b"], }, Scenario { name: "exec", @@ -137,7 +144,7 @@ fn all_scenarios() -> &'static [Scenario] { Scenario { name: "process-hardening", description: "Sandbox processes start with core dumps disabled", - sandbox_name_stems: &["process-hardening"], + sandbox_name_stems: &["hard"], }, ] } @@ -146,11 +153,18 @@ impl Scenario { fn sandbox_names(&self, run_id: &str) -> Vec { self.sandbox_name_stems .iter() - .map(|stem| format!("conformance-{stem}-{run_id}")) + .map(|stem| sandbox_name(stem, run_id)) .collect() } } +fn sandbox_name(stem: &str, run_id: &str) -> String { + let stem_budget = + MAX_SANDBOX_NAME_LEN.saturating_sub(SANDBOX_NAME_PREFIX.len() + run_id.len() + 2); + let stem = &stem[..stem.len().min(stem_budget)]; + format!("{SANDBOX_NAME_PREFIX}-{stem}-{run_id}") +} + fn select_scenarios(filter: Option<&str>) -> Result> { let scenarios = all_scenarios() .iter() @@ -397,7 +411,7 @@ async fn wait_for_not_found(client: &mut GrpcClient, sandbox_name: &str) -> Resu /// deletes it. Verifies that the sandbox appears in the list between create /// and delete, and that delete reports it as deleted. async fn scenario_lifecycle(client: &mut GrpcClient, run_id: &str) -> Result<()> { - let sandbox_name = format!("conformance-lifecycle-{run_id}"); + let sandbox_name = sandbox_name("life", run_id); // ── 1. Create ──────────────────────────────────────────────────────── let response = client @@ -478,7 +492,7 @@ async fn scenario_lifecycle(client: &mut GrpcClient, run_id: &str) -> Result<()> /// Verifies that `GetSandbox` and `DeleteSandbox` return `NOT_FOUND` for a /// name that was never created. async fn scenario_not_found(client: &mut GrpcClient, run_id: &str) -> Result<()> { - let phantom_name = format!("conformance-not-found-{run_id}"); + let phantom_name = sandbox_name("miss", run_id); // ── 1. GetSandbox → NOT_FOUND ──────────────────────────────────────── let err = client @@ -521,7 +535,7 @@ async fn scenario_not_found(client: &mut GrpcClient, run_id: &str) -> Result<()> /// `deleted: true`), then deletes it again and asserts the second call returns /// `NOT_FOUND` because the gateway record has already been removed. async fn scenario_idempotent_delete(client: &mut GrpcClient, run_id: &str) -> Result<()> { - let sandbox_name = format!("conformance-idempotent-delete-{run_id}"); + let sandbox_name = sandbox_name("del", run_id); // ── 1. Create ──────────────────────────────────────────────────────── let response = client @@ -650,8 +664,8 @@ async fn scenario_validate(client: &mut GrpcClient) -> Result<()> { /// Ready in parallel, verifies both appear in `ListSandboxes`, then deletes /// both. Any failure cleans up both sandboxes before returning. async fn scenario_concurrent(client: &mut GrpcClient, run_id: &str) -> Result<()> { - let name_a = format!("conformance-concurrent-a-{run_id}"); - let name_b = format!("conformance-concurrent-b-{run_id}"); + let name_a = sandbox_name("con-a", run_id); + let name_b = sandbox_name("con-b", run_id); // ── 1. Create both sandboxes concurrently ──────────────────────────── let mut client_a = client.clone(); @@ -761,8 +775,8 @@ async fn scenario_concurrent(client: &mut GrpcClient, run_id: &str) -> Result<() /// returned. Labels are stored by the gateway on creation so no Ready wait /// is required; both sandboxes are deleted after the assertion. async fn scenario_labels(client: &mut GrpcClient, run_id: &str) -> Result<()> { - let name_a = format!("conformance-labels-a-{run_id}"); - let name_b = format!("conformance-labels-b-{run_id}"); + let name_a = sandbox_name("lbl-a", run_id); + let name_b = sandbox_name("lbl-b", run_id); let label_key = "conformance-scenario".to_string(); // ── 1. Create two sandboxes with distinct label values ─────────────── @@ -924,7 +938,7 @@ async fn create_ready_sandbox(client: &mut GrpcClient, sandbox_name: &str) -> Re async fn scenario_exec(client: &mut GrpcClient, run_id: &str) -> Result<()> { const OUTPUT_MARKER: &str = "conformance-exec-ok"; - let sandbox_name = format!("conformance-exec-{run_id}"); + let sandbox_name = sandbox_name("exec", run_id); let sandbox_id = create_ready_sandbox(client, &sandbox_name).await?; let exec_result = async { @@ -976,7 +990,7 @@ async fn scenario_exec(client: &mut GrpcClient, run_id: &str) -> Result<()> { async fn scenario_process_hardening(client: &mut GrpcClient, run_id: &str) -> Result<()> { const OUTPUT_MARKER: &str = "core-limit-ok"; - let sandbox_name = format!("conformance-process-hardening-{run_id}"); + let sandbox_name = sandbox_name("hard", run_id); let sandbox_id = create_ready_sandbox(client, &sandbox_name).await?; let command_result = exec_command( client, @@ -1056,7 +1070,9 @@ pub fn conformance_list(output: &str) -> Result<()> { #[cfg(test)] mod tests { - use super::{all_scenarios, select_scenarios}; + use std::collections::HashSet; + + use super::{MAX_SANDBOX_NAME_LEN, all_scenarios, sandbox_name, select_scenarios}; #[test] fn ci_scenario_set_contains_only_implemented_scenarios() { @@ -1102,15 +1118,26 @@ mod tests { assert_eq!( names, [ - "conformance-lifecycle-deadbeef", - "conformance-idempotent-delete-deadbeef", - "conformance-concurrent-a-deadbeef", - "conformance-concurrent-b-deadbeef", - "conformance-labels-a-deadbeef", - "conformance-labels-b-deadbeef", - "conformance-exec-deadbeef", - "conformance-process-hardening-deadbeef", + "osct-life-deadbeef", + "osct-del-deadbeef", + "osct-con-a-deadbeef", + "osct-con-b-deadbeef", + "osct-lbl-a-deadbeef", + "osct-lbl-b-deadbeef", + "osct-exec-deadbeef", + "osct-hard-deadbeef", ] ); + + assert!(names.iter().all(|name| name.len() <= MAX_SANDBOX_NAME_LEN)); + assert_eq!(names.iter().collect::>().len(), names.len()); + } + + #[test] + fn sandbox_names_trim_long_stems_to_the_gateway_limit() { + let name = sandbox_name("unexpectedly-long-scenario-name", "deadbeef"); + + assert_eq!(name, "osct-unexp-deadbeef"); + assert_eq!(name.len(), MAX_SANDBOX_NAME_LEN); } } From 04f713d95a511fbd0e007913eab5b0d9f83e0e97 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 20 Aug 2026 20:38:58 +0200 Subject: [PATCH 6/7] test(e2e): cover sandbox stop and start conformance Signed-off-by: Evan Lezar --- e2e/rust/CONFORMANCE.md | 12 +- e2e/rust/src/conformance.rs | 237 ++++++++++++++++++++++-------------- 2 files changed, 149 insertions(+), 100 deletions(-) diff --git a/e2e/rust/CONFORMANCE.md b/e2e/rust/CONFORMANCE.md index 10b981c005..afc2bf3f62 100644 --- a/e2e/rust/CONFORMANCE.md +++ b/e2e/rust/CONFORMANCE.md @@ -30,8 +30,9 @@ scenario. The test profiles separate the surface being validated: - `e2e-api-conformance` invokes the scenario engine directly against every - gateway driver. This includes command execution through the streaming - `ExecSandbox` API and process-hardening checks. + gateway driver. This includes stop/start lifecycle behavior, command + execution through the streaming `ExecSandbox` API, and process-hardening + checks. - `e2e-cli-conformance` validates portable CLI behavior against the canonical Docker-backed gateway, including the gateway smoke test. @@ -42,14 +43,12 @@ flowchart LR subgraph Provisioning["Gateway instantiation"] P["Provisioner
Docker · Podman · Kubernetes · VM"] C["Connection context
endpoint · mTLS · gateway registration"] - L["Lifecycle control
managed gateway metadata"] end subgraph Tests["Test execution"] API["API conformance
direct gRPC scenarios"] CLI["CLI conformance
portable user workflows"] DS["Driver-specific tests
runtime and infrastructure assertions"] - RR["Shared restart/resume scenario
driver-specific hooks"] end OCLI["openshell CLI"] @@ -60,18 +59,14 @@ flowchart LR P -.->|"instantiates"| GW P -.->|"configures"| DR P --> C - P --> L C -.-> API C -.-> CLI C -.-> DS - C -.-> RR - L -.-> RR API -->|"tonic / gRPC"| GW CLI --> OCLI DS --> OCLI - RR -->|"application assertions"| OCLI OCLI -->|"gateway API"| GW GW --> DR @@ -79,7 +74,6 @@ flowchart LR DS -.->|"driver or runtime observations"| DR DS -.->|"sandbox-specific assertions"| SB - RR -.->|"stop / start"| GW ``` Solid arrows show normal request paths. Dashed arrows show provisioning, diff --git a/e2e/rust/src/conformance.rs b/e2e/rust/src/conformance.rs index fe25e2aefa..9aead9e9f9 100644 --- a/e2e/rust/src/conformance.rs +++ b/e2e/rust/src/conformance.rs @@ -12,7 +12,8 @@ use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ CreateSandboxRequest, DeleteSandboxRequest, ExecSandboxRequest, GetSandboxRequest, GpuResourceRequirements, ListSandboxesRequest, ResourceRequirements, SandboxPhase, SandboxSpec, - WatchSandboxRequest, exec_sandbox_event, open_shell_client::OpenShellClient, + StartSandboxRequest, StopSandboxRequest, WatchSandboxRequest, exec_sandbox_event, + open_shell_client::OpenShellClient, }; use openshell_core::{ObjectId, ObjectName}; use tonic::Code; @@ -108,17 +109,17 @@ fn all_scenarios() -> &'static [Scenario] { &[ Scenario { name: "lifecycle", - description: "Create → running → stop → delete completes without error", + description: "Create → ready → stop → stopped → start → ready → delete", sandbox_name_stems: &["life"], }, Scenario { name: "not-found", - description: "Get/stop/delete for an unknown sandbox ID returns an appropriate error", + description: "Get/start/stop/delete for an unknown sandbox name returns NOT_FOUND", sandbox_name_stems: &[], }, Scenario { - name: "idempotent-delete", - description: "Deleting an already-deleted sandbox returns NOT_FOUND", + name: "repeat-delete", + description: "Repeating delete after gateway record removal returns NOT_FOUND", sandbox_name_stems: &["del"], }, Scenario { @@ -324,7 +325,7 @@ async fn run_scenario(name: &str, client: &mut GrpcClient, run_id: &str) -> Resu match name { "lifecycle" => scenario_lifecycle(client, run_id).await, "not-found" => scenario_not_found(client, run_id).await, - "idempotent-delete" => scenario_idempotent_delete(client, run_id).await, + "repeat-delete" => scenario_repeat_delete(client, run_id).await, "validate" => scenario_validate(client).await, "concurrent" => scenario_concurrent(client, run_id).await, "labels" => scenario_labels(client, run_id).await, @@ -334,13 +335,13 @@ async fn run_scenario(name: &str, client: &mut GrpcClient, run_id: &str) -> Resu } } -/// Poll `WatchSandbox` until the sandbox reaches Ready, returning an error on -/// Error phase or a closed stream. Does not perform cleanup — callers are -/// responsible for deleting the sandbox if this returns an error. -async fn wait_for_ready( +/// Poll `WatchSandbox` until the sandbox reaches the target phase, returning +/// an error on Error phase or a closed stream. Does not perform cleanup. +async fn wait_for_phase( client: &mut GrpcClient, sandbox_id: &str, sandbox_name: &str, + target_phase: SandboxPhase, ) -> Result<()> { let mut stream = client .watch_sandbox(WatchSandboxRequest { @@ -366,23 +367,50 @@ async fn wait_for_ready( .wrap_err("watch_sandbox stream error")?; if let Some(openshell_core::proto::sandbox_stream_event::Payload::Sandbox(s)) = evt.payload { - match SandboxPhase::try_from(s.phase()).unwrap_or(SandboxPhase::Unknown) { - SandboxPhase::Ready => return Ok(()), + let phase = SandboxPhase::try_from(s.phase()).unwrap_or(SandboxPhase::Unknown); + match phase { SandboxPhase::Error => { return Err(miette::miette!( - "sandbox '{sandbox_name}' entered Error phase before becoming Ready" + "sandbox '{sandbox_name}' entered Error phase before becoming {}", + target_phase.as_str_name() )); } + _ if phase == target_phase => return Ok(()), _ => {} } } } Err(miette::miette!( - "watch stream ended before sandbox '{sandbox_name}' reached Ready" + "watch stream ended before sandbox '{sandbox_name}' reached {}", + target_phase.as_str_name() )) } +async fn wait_for_ready( + client: &mut GrpcClient, + sandbox_id: &str, + sandbox_name: &str, +) -> Result<()> { + wait_for_phase(client, sandbox_id, sandbox_name, SandboxPhase::Ready).await +} + +fn expect_not_found( + result: std::result::Result, tonic::Status>, + operation: &str, +) -> Result<()> { + match result { + Err(status) if status.code() == Code::NotFound => Ok(()), + Err(status) => Err(miette::miette!( + "{operation} returned {} instead of NOT_FOUND", + status.code() + )), + Ok(_) => Err(miette::miette!( + "{operation} succeeded instead of returning NOT_FOUND" + )), + } +} + /// Poll until the gateway no longer has a record for the sandbox. async fn wait_for_not_found(client: &mut GrpcClient, sandbox_name: &str) -> Result<()> { loop { @@ -405,11 +433,10 @@ async fn wait_for_not_found(client: &mut GrpcClient, sandbox_name: &str) -> Resu } } -/// Scenario: create → ready → delete. +/// Scenario: create → ready → stop → stopped → start → ready → delete. /// -/// Creates a minimal sandbox, waits for it to reach the Ready phase, then -/// deletes it. Verifies that the sandbox appears in the list between create -/// and delete, and that delete reports it as deleted. +/// Creates a minimal sandbox and verifies the portable stop/start phase +/// transitions before deleting it. async fn scenario_lifecycle(client: &mut GrpcClient, run_id: &str) -> Result<()> { let sandbox_name = sandbox_name("life", run_id); @@ -432,99 +459,127 @@ async fn scenario_lifecycle(client: &mut GrpcClient, run_id: &str) -> Result<()> .ok_or_else(|| miette::miette!("create_sandbox response missing sandbox"))?; let sandbox_id = sandbox.object_id().to_string(); - // ── 2. Wait for Ready ──────────────────────────────────────────────── - if let Err(e) = wait_for_ready(client, &sandbox_id, &sandbox_name).await { - let _ = client - .delete_sandbox(DeleteSandboxRequest { + let scenario_result = async { + // ── 2. Wait for Ready ──────────────────────────────────────────── + wait_for_ready(client, &sandbox_id, &sandbox_name).await?; + + // ── 3. Stop and wait for Stopped ───────────────────────────────── + client + .stop_sandbox(StopSandboxRequest { name: sandbox_name.clone(), workspace: String::new(), }) - .await; - return Err(e); - } + .await + .into_diagnostic() + .wrap_err("stop_sandbox failed")?; + wait_for_phase(client, &sandbox_id, &sandbox_name, SandboxPhase::Stopped).await?; - // ── 3. Verify it appears in the list ──────────────────────────────── - let list_response = client - .list_sandboxes(ListSandboxesRequest::default()) - .await - .into_diagnostic() - .wrap_err("list_sandboxes failed")?; + // ── 4. Start and wait for Ready again ──────────────────────────── + client + .start_sandbox(StartSandboxRequest { + name: sandbox_name.clone(), + workspace: String::new(), + }) + .await + .into_diagnostic() + .wrap_err("start_sandbox failed")?; + wait_for_ready(client, &sandbox_id, &sandbox_name).await?; - let found = list_response - .into_inner() - .sandboxes - .iter() - .any(|s| s.object_name() == sandbox_name); + // ── 5. Verify it still appears in the list ─────────────────────── + let found = client + .list_sandboxes(ListSandboxesRequest::default()) + .await + .into_diagnostic() + .wrap_err("list_sandboxes failed")? + .into_inner() + .sandboxes + .iter() + .any(|sandbox| sandbox.object_name() == sandbox_name); - if !found { - let _ = client + if !found { + return Err(miette::miette!( + "sandbox '{sandbox_name}' not found after stop and start" + )); + } + + // ── 6. Delete ──────────────────────────────────────────────────── + let deleted = client .delete_sandbox(DeleteSandboxRequest { name: sandbox_name.clone(), workspace: String::new(), }) - .await; - return Err(miette::miette!( - "sandbox '{sandbox_name}' not found in list_sandboxes response after creation" - )); - } + .await + .into_diagnostic() + .wrap_err("delete_sandbox failed")? + .into_inner() + .deleted; + if !deleted { + return Err(miette::miette!( + "delete_sandbox reported sandbox '{sandbox_name}' was not deleted" + )); + } - // ── 4. Delete ──────────────────────────────────────────────────────── - let del_response = client - .delete_sandbox(DeleteSandboxRequest { - name: sandbox_name.clone(), - workspace: String::new(), - }) - .await - .into_diagnostic() - .wrap_err("delete_sandbox failed")?; + Ok(()) + } + .await; - if !del_response.into_inner().deleted { - return Err(miette::miette!( - "delete_sandbox reported sandbox '{sandbox_name}' was not deleted" - )); + if scenario_result.is_err() { + let _ = cleanup_sandboxes(client, std::slice::from_ref(&sandbox_name)).await; } - Ok(()) + scenario_result } -/// Scenario: get and delete a sandbox that does not exist. +/// Scenario: get, start, stop, and delete a sandbox that does not exist. /// -/// Verifies that `GetSandbox` and `DeleteSandbox` return `NOT_FOUND` for a -/// name that was never created. +/// Verifies that every name-based lifecycle operation returns `NOT_FOUND` for +/// a name that was never created. async fn scenario_not_found(client: &mut GrpcClient, run_id: &str) -> Result<()> { let phantom_name = sandbox_name("miss", run_id); // ── 1. GetSandbox → NOT_FOUND ──────────────────────────────────────── - let err = client - .get_sandbox(GetSandboxRequest { - name: phantom_name.clone(), - workspace: String::new(), - }) - .await - .expect_err("get_sandbox on a non-existent sandbox should have returned NOT_FOUND"); - - if err.code() != Code::NotFound { - return Err(miette::miette!( - "get_sandbox returned {} instead of NOT_FOUND", - err.code() - )); - } - - // ── 2. DeleteSandbox → NOT_FOUND ──────────────────────────────────── - let err = client - .delete_sandbox(DeleteSandboxRequest { - name: phantom_name.clone(), - workspace: String::new(), - }) - .await - .expect_err("delete_sandbox on a non-existent sandbox should have returned NOT_FOUND"); + expect_not_found( + client + .get_sandbox(GetSandboxRequest { + name: phantom_name.clone(), + workspace: String::new(), + }) + .await, + "get_sandbox", + )?; + + // ── 2. StartSandbox → NOT_FOUND ────────────────────────────────────── + expect_not_found( + client + .start_sandbox(StartSandboxRequest { + name: phantom_name.clone(), + workspace: String::new(), + }) + .await, + "start_sandbox", + )?; + + // ── 3. StopSandbox → NOT_FOUND ─────────────────────────────────────── + expect_not_found( + client + .stop_sandbox(StopSandboxRequest { + name: phantom_name.clone(), + workspace: String::new(), + }) + .await, + "stop_sandbox", + )?; - if err.code() != Code::NotFound { - return Err(miette::miette!( - "delete_sandbox returned {} instead of NOT_FOUND", - err.code() - )); - } + // ── 4. DeleteSandbox → NOT_FOUND ──────────────────────────────────── + expect_not_found( + client + .delete_sandbox(DeleteSandboxRequest { + name: phantom_name, + workspace: String::new(), + }) + .await, + "delete_sandbox", + )?; Ok(()) } @@ -534,7 +589,7 @@ async fn scenario_not_found(client: &mut GrpcClient, run_id: &str) -> Result<()> /// Creates a sandbox, waits for it to be Ready, deletes it (expecting /// `deleted: true`), then deletes it again and asserts the second call returns /// `NOT_FOUND` because the gateway record has already been removed. -async fn scenario_idempotent_delete(client: &mut GrpcClient, run_id: &str) -> Result<()> { +async fn scenario_repeat_delete(client: &mut GrpcClient, run_id: &str) -> Result<()> { let sandbox_name = sandbox_name("del", run_id); // ── 1. Create ──────────────────────────────────────────────────────── @@ -1086,7 +1141,7 @@ mod tests { [ "lifecycle", "not-found", - "idempotent-delete", + "repeat-delete", "validate", "concurrent", "labels", From cf9d7ef76856eddb57f4ccff801fdaceabbb3f1d Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 21 Aug 2026 13:32:52 +0200 Subject: [PATCH 7/7] chore(e2e): refresh conformance lockfile Signed-off-by: Evan Lezar --- e2e/rust/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/rust/Cargo.lock b/e2e/rust/Cargo.lock index c70cbe072a..de97cc9ad4 100644 --- a/e2e/rust/Cargo.lock +++ b/e2e/rust/Cargo.lock @@ -1025,6 +1025,7 @@ dependencies = [ "prost", "prost-types", "protoc-bin-vendored", + "rustix", "serde", "serde_json", "thiserror",