From 0b1a89ac5343e386db473389ab9ee3bc868a7bbe Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 21:14:11 -0700 Subject: [PATCH 01/16] feat(testkit): add optional test IDs and testkit-spec `#[testkit::test]` takes an optional `id = "t-0123abcd"` argument that gives a test a stable identity surviving renames and moves. The attribute's parsing moves into a new testkit-spec crate, which the test runner's catalog also uses to list tests from source without compiling them. The scanner refuses testkit tests inside inline modules. `#[should_panic]` stays rejected. There are no per-environment requirements: a test that cannot pass everywhere belongs in another family, and a broken one is `#[ignore]`d with an issue link. The testkit lockfile is also realigned with the root lockfile. Co-authored-by: Evan Lezar Signed-off-by: Matthew Grossman --- AGENTS.md | 2 +- tests/testkit/Cargo.lock | 17 +- tests/testkit/Cargo.toml | 2 +- tests/testkit/README.md | 16 + tests/testkit/testkit-macros/Cargo.toml | 1 + tests/testkit/testkit-macros/src/lib.rs | 91 ++--- tests/testkit/testkit-spec/Cargo.toml | 19 + tests/testkit/testkit-spec/src/lib.rs | 486 ++++++++++++++++++++++++ tests/testkit/testkit/src/lib.rs | 2 +- tests/testkit/testkit/src/run.rs | 7 +- 10 files changed, 579 insertions(+), 64 deletions(-) create mode 100644 tests/testkit/testkit-spec/Cargo.toml create mode 100644 tests/testkit/testkit-spec/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index d1652c2606..26997d5c28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `python/openshell/` | Python SDK | Python bindings and CLI packaging | | `sdk/typescript/` | TypeScript SDK | Native Connect client, curated sandbox API, and generated protobuf types | | `proto/` | Protobuf definitions | gRPC service contracts | -| `tests/testkit/` | E2E testkit | Support library and `#[testkit::test]` attribute for SDK-driven E2E suites | +| `tests/testkit/` | E2E testkit | Support library, `#[testkit::test]` attribute, and `testkit-spec` parser for SDK-driven E2E suites | | `deploy/` | Docker, Helm, K8s | Dockerfiles, Helm chart, manifests | | `docs/` | Published docs | MDX pages, navigation, and content assets | | `fern/` | Docs site config | Fern site config, components, and theme assets | diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index e36d9c7b09..3f1a0b11de 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -2000,11 +2000,20 @@ dependencies = [ name = "openshell-testkit-macros" version = "0.0.0" dependencies = [ + "openshell-testkit-spec", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -2630,9 +2639,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -3876,9 +3885,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/tests/testkit/Cargo.toml b/tests/testkit/Cargo.toml index 17a5e5b052..468fefde14 100644 --- a/tests/testkit/Cargo.toml +++ b/tests/testkit/Cargo.toml @@ -5,4 +5,4 @@ # by path. [workspace] resolver = "2" -members = ["testkit", "testkit-macros"] +members = ["testkit", "testkit-macros", "testkit-spec"] diff --git a/tests/testkit/README.md b/tests/testkit/README.md index fd6897013d..ddd26b011e 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -9,6 +9,7 @@ This is a separate Cargo workspace with its own lockfile: |---|---| | `testkit` | Finds and connects to the gateway, creates and cleans up sandboxes, runs the CLI. Also builds `testkit-ready`. | | `testkit-macros` | The `#[testkit::test]` attribute. | +| `testkit-spec` | Parses `#[testkit::test]` functions. The macro and a test runner's catalog can both use it, so tools can list tests, with their ID and description, without compiling them. | ## Writing a test @@ -31,11 +32,26 @@ async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { - **The doc comment is required.** It is the test's description. - **The function name is the nextest test name.** +- **Keep tests at the top level of the file.** `testkit_spec::scan`, which + lists tests from source for tools such as a test runner's catalog, doesn't + look inside `mod` blocks yet, so it refuses a test there. +- **No `#[cfg]` or `#[cfg_attr]`** on a test, or `#![cfg]` on a file with + tests. Listing from source can't tell whether a test is compiled, so a + runner would expect a test that never runs. A test that only applies to some + environments belongs in a `features` or `drivers` suite. +- **No other tests in the file.** A file with `#[testkit::test]` functions + can't also have `#[test]` or `#[tokio::test]` functions. nextest would run + them, but a runner listing tests from source wouldn't expect them. - **Other attributes apply to the test.** For example, `#[ignore = "reason"]` skips it unless run with `--run-ignored`. `#[should_panic]` is rejected, because a testkit test panics on any failure. To check that something is refused, assert the failure in the body, for example with `cx.cli([...]).await?.failure()?` and `testkit::ensure!`. +- **`id` is optional.** `#[testkit::test(id = "t-0123abcd")]` gives a test an + identity that survives renames and moves. Tools that need one, such as + result history, use it; without it the function name is the + identity. An ID is `t-` and eight lowercase letters or digits, unique across + all suites. A malformed ID's compile error suggests a fresh one. Cleanup runs after every test, whether it passes, fails, or panics. It only deletes sandboxes created through `cx.sandbox(...)`. Delete anything else a diff --git a/tests/testkit/testkit-macros/Cargo.toml b/tests/testkit/testkit-macros/Cargo.toml index d6e9cdf0de..1a6c1d839a 100644 --- a/tests/testkit/testkit-macros/Cargo.toml +++ b/tests/testkit/testkit-macros/Cargo.toml @@ -16,6 +16,7 @@ name = "testkit_macros" proc-macro = true [dependencies] +openshell-testkit-spec = { path = "../testkit-spec" } proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full"] } diff --git a/tests/testkit/testkit-macros/src/lib.rs b/tests/testkit/testkit-macros/src/lib.rs index fc3dbbdb68..4810efd8df 100644 --- a/tests/testkit/testkit-macros/src/lib.rs +++ b/tests/testkit/testkit-macros/src/lib.rs @@ -13,17 +13,22 @@ //! async fn expose_service(cx: &mut Context) -> testkit::Result { /* ... */ } //! ``` //! +//! `id = "t-0123abcd"` is optional. It gives the test a stable identity that +//! survives renames and moves, for tools such as result history. +//! //! The attribute expands to a `#[tokio::test]` wrapper with the same name, so //! the nextest test name is the function name. The original function is nested -//! in the wrapper and passed to `testkit::run`. Other attributes, such as -//! `#[ignore]` or `#[cfg(...)]`, move to the wrapper. `#[should_panic]` is -//! rejected: a testkit test panics on any failure, so it would pass for the -//! wrong reason. Check the expected failure in the body instead. +//! in the wrapper and passed to `testkit::run`. Parsing is done by +//! `testkit-spec`, which a test runner's catalog can also use. Other +//! attributes, such as `#[ignore]` or `#[cfg(...)]`, move to the wrapper. +//! `#[should_panic]` is rejected: a testkit test panics on any failure, so it +//! would pass for the wrong reason. Check the expected failure in the body +//! instead. use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; -use syn::{Attribute, Expr, ExprLit, ItemFn, Lit, LitStr, Meta}; +use syn::{Attribute, ItemFn, LitStr}; #[proc_macro_attribute] pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { @@ -34,24 +39,7 @@ pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { let mut body: ItemFn = syn::parse2(item)?; - if !attr.is_empty() { - return Err(syn::Error::new_spanned( - attr, - "`#[testkit::test]` takes no arguments", - )); - } - if body.sig.asyncness.is_none() { - return Err(syn::Error::new_spanned( - body.sig.fn_token, - "testkit tests must be `async fn`", - )); - } - if body.sig.inputs.len() != 1 { - return Err(syn::Error::new_spanned( - &body.sig.inputs, - "testkit tests take exactly one argument: `cx: &mut testkit::Context`", - )); - } + let spec = testkit_spec::parse(attr, &body)?; if let Some(attr) = body.attrs.iter().find(|attr| is_test_attr(attr)) { return Err(syn::Error::new_spanned( attr, @@ -70,17 +58,17 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { `CliOutput::failure` and `testkit::ensure!`", )); } - let description = doc_comment(&body.attrs); - if description.is_empty() { - return Err(syn::Error::new( - body.sig.ident.span(), - "testkit tests need a doc comment describing the behavior they verify", - )); - } let name = body.sig.ident.clone(); let name_str = LitStr::new(&name.to_string(), name.span()); - let description = LitStr::new(&description, name.span()); + let description = LitStr::new(&spec.description, name.span()); + let id = match &spec.id { + Some(id) => { + let id = LitStr::new(id, name.span()); + quote! { ::core::option::Option::Some(#id) } + } + None => quote! { ::core::option::Option::None }, + }; let vis = body.vis.clone(); let attrs = std::mem::take(&mut body.attrs); @@ -95,6 +83,7 @@ fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result { ::testkit::run( ::testkit::TestInfo { name: #name_str, + id: #id, description: #description, }, body, @@ -112,39 +101,29 @@ fn is_test_attr(attr: &Attribute) -> bool { .is_some_and(|segment| segment.ident == "test") } -/// The doc comment, joined into one line. Empty if there is none. -fn doc_comment(attrs: &[Attribute]) -> String { - attrs - .iter() - .filter_map(|attr| match &attr.meta { - Meta::NameValue(meta) if meta.path.is_ident("doc") => match &meta.value { - Expr::Lit(ExprLit { - lit: Lit::Str(line), - .. - }) => Some(line.value().trim().to_string()), - _ => None, - }, - _ => None, - }) - .filter(|line| !line.is_empty()) - .collect::>() - .join(" ") -} - #[cfg(test)] mod tests { use super::expand; + fn expand_str(attr: &str, item: &str) -> syn::Result { + expand(attr.parse().unwrap(), item.parse().unwrap()).map(|tokens| tokens.to_string()) + } + + const ITEM: &str = "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }"; + + #[test] + fn carries_the_id() { + let out = expand_str("id = \"t-0123abcd\"", ITEM).unwrap(); + assert!(out.contains("Some (\"t-0123abcd\")"), "{out}"); + } + // A suite only compiles correct tests, so it never reaches these errors. #[test] fn rejects_misuse() { let ok_fn = "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }"; for (attr, item, error) in [ - ( - "id = \"t-1\"", - format!("/// Doc.\n{ok_fn}"), - "takes no arguments", - ), + ("id = \"nope\"", ITEM.to_string(), "test IDs are"), + ("speed = fast", ITEM.to_string(), "unknown argument"), ("", ok_fn.to_string(), "doc comment"), ( "", @@ -167,7 +146,7 @@ mod tests { "`#[should_panic]`", ), ] { - let err = expand(attr.parse().unwrap(), item.parse().unwrap()).unwrap_err(); + let err = expand_str(attr, &item).unwrap_err(); assert!(err.to_string().contains(error), "{item}: {err}"); } } diff --git a/tests/testkit/testkit-spec/Cargo.toml b/tests/testkit/testkit-spec/Cargo.toml new file mode 100644 index 0000000000..640503c38a --- /dev/null +++ b/tests/testkit/testkit-spec/Cargo.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-testkit-spec" +description = "Parses #[testkit::test] functions for the macro and the test catalog" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[lib] +name = "testkit_spec" + +[dependencies] +proc-macro2 = "1" +syn = { version = "2", features = ["full"] } diff --git a/tests/testkit/testkit-spec/src/lib.rs b/tests/testkit/testkit-spec/src/lib.rs new file mode 100644 index 0000000000..981b3b91a1 --- /dev/null +++ b/tests/testkit/testkit-spec/src/lib.rs @@ -0,0 +1,486 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The single definition of what a `#[testkit::test]` function declares. +//! +//! The `#[testkit::test]` macro parses tests through this crate. A test +//! runner's catalog that does the same cannot disagree with the macro about a +//! test's ID, name, or description. Tools can list tests with +//! this crate without compiling them. +//! +//! Adapted from the crate of the same name by Evan Lezar +//! (NVIDIA/OpenShell#4629). Unlike there, the ID is optional. + +use std::hash::{BuildHasher, Hasher}; + +use proc_macro2::TokenStream; +use syn::parse::{Parse, ParseStream}; +use syn::{Attribute, Expr, ExprLit, Ident, Item, ItemFn, ItemMod, Lit, LitStr, Meta, Token}; + +/// Prefix of every test ID. +pub const ID_PREFIX: &str = "t-"; +/// Number of random characters after the prefix. +const ID_LEN: usize = 8; +const ID_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + +/// What a test declares. +#[derive(Clone, Debug)] +pub struct TestSpec { + /// The stable test ID from `id = "..."`, if the test has one. It never + /// changes, including when the test is renamed or moved to another suite + /// or family. Without one, the function name is the test's identity. + pub id: Option, + /// The function name, which is also the nextest test name. + pub name: Ident, + /// The doc comment, joined into one line. + pub description: String, + /// Whether the function has `#[ignore]`. nextest skips such a test unless + /// run with `--run-ignored`, so a runner can leave it out of what it expects. + pub ignored: bool, +} + +/// Arguments accepted by `#[testkit::test(...)]`. +#[derive(Default)] +struct Args { + id: Option, +} + +impl Parse for Args { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut args = Self::default(); + while !input.is_empty() { + let key: Ident = input.parse()?; + input.parse::()?; + if key == "id" { + if args.id.is_some() { + return Err(syn::Error::new(key.span(), "duplicate argument `id`")); + } + args.id = Some(input.parse()?); + } else { + return Err(syn::Error::new( + key.span(), + format!("unknown argument `{key}`; expected `id = \"...\"`"), + )); + } + if !input.is_empty() { + input.parse::()?; + } + } + Ok(args) + } +} + +/// Parse a test from the attribute's arguments and the function it marks. +/// +/// # Errors +/// +/// Returns an error for a function that is not an `async fn` with exactly one +/// argument, has no doc comment, has a `#[cfg]` or `#[cfg_attr]` attribute, or +/// whose `id` argument is malformed. +pub fn parse(args: TokenStream, item: &ItemFn) -> syn::Result { + let args: Args = syn::parse2(args)?; + + if let Some(attr) = item.attrs.iter().find(|attr| is_conditional(attr)) { + return Err(syn::Error::new_spanned(attr, CONDITIONAL_TEST)); + } + + if item.sig.asyncness.is_none() { + return Err(syn::Error::new_spanned( + item.sig.fn_token, + "testkit tests must be `async fn`", + )); + } + if item.sig.inputs.len() != 1 { + return Err(syn::Error::new_spanned( + &item.sig.inputs, + "testkit tests take exactly one argument: `cx: &mut testkit::Context`", + )); + } + + let description = doc_comment(&item.attrs); + if description.is_empty() { + return Err(syn::Error::new( + item.sig.ident.span(), + "testkit tests need a doc comment describing the behavior they verify", + )); + } + + let id = match args.id { + Some(id) if is_valid_id(&id.value()) => Some(id.value()), + Some(id) => { + return Err(syn::Error::new( + id.span(), + format!( + "test IDs are `{ID_PREFIX}` followed by {ID_LEN} lowercase letters or digits; for a new test use `id = \"{}\"`", + generate_id() + ), + )); + } + None => None, + }; + Ok(TestSpec { + id, + name: item.sig.ident.clone(), + description, + ignored: item.attrs.iter().any(|attr| attr.path().is_ident("ignore")), + }) +} + +/// Whether `id` has the test ID form: the prefix and lowercase base-36 characters. +#[must_use] +pub fn is_valid_id(id: &str) -> bool { + id.strip_prefix(ID_PREFIX).is_some_and(|rest| { + rest.len() == ID_LEN + && rest + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) +} + +/// A fresh random test ID, offered in the error for an invalid one, and for +/// tools that assign IDs. Uses the standard library's per-process random hash +/// keys, so this crate needs no RNG dependency. +#[must_use] +pub fn generate_id() -> String { + let mut hasher = std::collections::hash_map::RandomState::new().build_hasher(); + hasher.write_u128( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()), + ); + let mut value = hasher.finish(); + let mut id = String::from(ID_PREFIX); + for _ in 0..ID_LEN { + id.push(char::from(ID_ALPHABET[(value % 36) as usize])); + value /= 36; + } + id +} + +/// Find every `#[testkit::test]` function in a source file. +/// +/// # Errors +/// +/// Returns an error if the file does not parse, a marked function is not a +/// valid test, a marked function is inside an inline `mod`, or the file has +/// tests and either an inner `#![cfg]` or `#![cfg_attr]` attribute or another +/// kind of test such as `#[test]` or `#[tokio::test]`. +pub fn scan(source: &str) -> syn::Result> { + let file = syn::parse_file(source)?; + let mut tests = Vec::new(); + for item in &file.items { + if let Item::Mod(module) = item { + reject_nested_tests(module)?; + } + let Item::Fn(item) = item else { + continue; + }; + let Some(attr) = item.attrs.iter().find(|attr| is_testkit_test(attr)) else { + continue; + }; + let args = match &attr.meta { + Meta::Path(_) => TokenStream::new(), + Meta::List(list) => list.tokens.clone(), + Meta::NameValue(meta) => { + return Err(syn::Error::new_spanned( + meta, + "expected `#[testkit::test]` or `#[testkit::test(...)]`", + )); + } + }; + tests.push(parse(args, item)?); + } + if !tests.is_empty() + && let Some(attr) = file.attrs.iter().find(|attr| is_conditional(attr)) + { + return Err(syn::Error::new_spanned(attr, CONDITIONAL_TEST)); + } + if !tests.is_empty() + && let Some(name) = find_other_test(&file.items) + { + return Err(syn::Error::new_spanned( + name, + "a file with `#[testkit::test]` functions can't have other tests: nextest runs \ + them, but tools that list tests from source don't expect them. Make it a \ + `#[testkit::test]`, or move it to a separate test file", + )); + } + Ok(tests) +} + +/// The first function, at any depth, marked as a test by something other +/// than `#[testkit::test]`, such as `#[test]` or `#[tokio::test]`. +fn find_other_test(items: &[Item]) -> Option<&syn::Ident> { + items.iter().find_map(|item| match item { + Item::Fn(item) => item + .attrs + .iter() + .any(|attr| !is_testkit_test(attr) && is_test_attribute(attr)) + .then_some(&item.sig.ident), + Item::Mod(module) => module + .content + .as_ref() + .and_then(|(_, items)| find_other_test(items)), + _ => None, + }) +} + +/// Whether an attribute marks a test, judged by its last path segment: +/// `#[test]`, `#[tokio::test]`, `#[rstest]`, and so on. +fn is_test_attribute(attr: &Attribute) -> bool { + attr.path() + .segments + .last() + .is_some_and(|segment| segment.ident == "test" || segment.ident == "rstest") +} + +/// Why conditional compilation is refused. [`scan`] reads source without +/// compiling it, so it can't tell whether a `cfg` keeps a test in the guest +/// build, and a runner would expect a test that never runs. +const CONDITIONAL_TEST: &str = "testkit tests can't use `#[cfg]` or `#[cfg_attr]`: tests are \ + listed from source, which can't tell whether they are compiled. Put a test that only \ + applies to some environments in a `features` or `drivers` suite, or use \ + `#[ignore = \"\"]` to skip it everywhere"; + +fn is_conditional(attr: &Attribute) -> bool { + attr.path().is_ident("cfg") || attr.path().is_ident("cfg_attr") +} + +/// Refuse tests inside inline modules, so a tool listing tests with [`scan`] +/// cannot silently miss tests that nextest runs. Refusing is only the simplest +/// option for now; `scan` could look inside modules instead if suites want to +/// group tests that way. +fn reject_nested_tests(module: &ItemMod) -> syn::Result<()> { + for item in module.content.iter().flat_map(|(_, items)| items) { + match item { + Item::Fn(item) if item.attrs.iter().any(is_testkit_test) => { + return Err(syn::Error::new_spanned( + &item.sig.ident, + "`#[testkit::test]` functions must be at the top level of the file, \ + not inside a `mod`", + )); + } + Item::Mod(module) => reject_nested_tests(module)?, + _ => {} + } + } + Ok(()) +} + +fn is_testkit_test(attr: &Attribute) -> bool { + let segments: Vec<_> = attr.path().segments.iter().collect(); + segments.len() == 2 && segments[0].ident == "testkit" && segments[1].ident == "test" +} + +/// The doc comment, joined into one line. Empty if there is none. +#[must_use] +pub fn doc_comment(attrs: &[Attribute]) -> String { + attrs + .iter() + .filter_map(|attr| match &attr.meta { + Meta::NameValue(meta) if meta.path.is_ident("doc") => match &meta.value { + Expr::Lit(ExprLit { + lit: Lit::Str(line), + .. + }) => Some(line.value().trim().to_string()), + _ => None, + }, + _ => None, + }) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_str(args: &str, item: &str) -> syn::Result { + parse(args.parse().unwrap(), &syn::parse_str(item).unwrap()) + } + + const ITEM: &str = "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }"; + + #[test] + fn parses_a_test_without_arguments() { + let spec = parse_str( + "", + "/// First line\n/// second line.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap(); + assert_eq!(spec.id, None); + assert_eq!(spec.name, "t"); + assert_eq!(spec.description, "First line second line."); + } + + #[test] + fn records_an_ignored_test() { + let spec = parse_str("", "/// Doc.\n#[ignore = \"needs a GPU\"]\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }").unwrap(); + assert!(spec.ignored); + let spec = parse_str( + "", + "/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap(); + assert!(!spec.ignored); + } + + #[test] + fn parses_an_id() { + let spec = parse_str("id = \"t-0123abcd\"", ITEM).unwrap(); + assert_eq!(spec.id.as_deref(), Some("t-0123abcd")); + } + + #[test] + fn rejects_malformed_id() { + for id in [ + "t-0123ABCD", + "t-0123abc", + "x-0123abcd", + "0123abcd", + "t-0123abcde", + ] { + let err = parse_str(&format!("id = \"{id}\""), ITEM).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("test IDs are"), "{id}: {message}"); + let suggested = message.split('"').nth(1).unwrap(); + assert!(is_valid_id(suggested), "{message}"); + } + } + + #[test] + fn rejects_duplicate_id_argument() { + let err = parse_str("id = \"t-0123abcd\", id = \"t-0123abce\"", ITEM).unwrap_err(); + assert!(err.to_string().contains("duplicate argument"), "{err}"); + } + + #[test] + fn generated_ids_are_valid_and_distinct() { + let a = generate_id(); + let b = generate_id(); + assert!(is_valid_id(&a), "{a}"); + assert_ne!(a, b); + } + + #[test] + fn rejects_missing_doc_comment() { + let err = parse_str( + "", + "async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("doc comment"), "{err}"); + } + + #[test] + fn rejects_sync_fn() { + let err = parse_str( + "", + "/// Doc.\nfn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("async fn"), "{err}"); + } + + #[test] + fn rejects_wrong_arity() { + let err = + parse_str("", "/// Doc.\nasync fn t() -> testkit::Result { Ok(()) }").unwrap_err(); + assert!(err.to_string().contains("exactly one argument"), "{err}"); + } + + #[test] + fn rejects_unknown_argument() { + let err = parse_str("timeout = [x]", ITEM).unwrap_err(); + assert!(err.to_string().contains("unknown argument"), "{err}"); + } + + #[test] + fn scans_only_testkit_tests() { + let tests = scan( + " + use testkit::Context; + + /// Plain. + #[testkit::test] + async fn plain(cx: &mut Context) -> testkit::Result { Ok(()) } + + /// With an id. + #[testkit::test(id = \"t-00000001\")] + async fn identified(cx: &mut Context) -> testkit::Result { Ok(()) } + + async fn helper(cx: &mut Context) {} + ", + ) + .unwrap(); + let names: Vec<_> = tests.iter().map(|test| test.name.to_string()).collect(); + assert_eq!(names, ["plain", "identified"]); + assert_eq!(tests[0].id, None); + assert_eq!(tests[1].id.as_deref(), Some("t-00000001")); + } + + #[test] + fn scan_rejects_other_tests_alongside_testkit_tests() { + for other in [ + "#[test]\nfn other() {}", + "#[tokio::test]\nasync fn other() {}", + "#[cfg(test)]\nmod tests {\n#[test]\nfn other() {}\n}", + ] { + let err = scan(&format!( + "/// Doc.\n#[testkit::test]\nasync fn t(cx: &mut Context) -> testkit::Result {{ Ok(()) }}\n{other}" + )) + .unwrap_err(); + assert!( + err.to_string().contains("can't have other tests"), + "{other}: {err}" + ); + } + // A file without testkit tests is not a testkit suite, so it is left alone. + assert!(scan("#[test]\nfn other() {}").unwrap().is_empty()); + } + + #[test] + fn scan_rejects_tests_in_modules() { + let err = scan( + "mod outer { mod inner {\n/// Doc.\n#[testkit::test]\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }\n} }", + ) + .unwrap_err(); + assert!(err.to_string().contains("top level"), "{err}"); + } + + #[test] + fn rejects_conditional_tests() { + for attr in [ + "#[cfg(any())]", + "#[cfg(target_os = \"linux\")]", + "#[cfg_attr(target_os = \"linux\", ignore)]", + ] { + let source = format!( + "/// Doc.\n{attr}\n#[testkit::test]\nasync fn t(cx: &mut Context) -> testkit::Result {{ Ok(()) }}" + ); + let err = scan(&source).unwrap_err(); + assert!(err.to_string().contains("`#[cfg]`"), "{attr}: {err}"); + } + } + + #[test] + fn scan_rejects_conditional_files_with_tests() { + let err = scan( + "#![cfg(target_os = \"linux\")]\n/// Doc.\n#[testkit::test]\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }", + ) + .unwrap_err(); + assert!(err.to_string().contains("`#[cfg]`"), "{err}"); + assert!( + scan("#![cfg(target_os = \"linux\")]\nfn helper() {}") + .unwrap() + .is_empty() + ); + } + + #[test] + fn scan_reports_invalid_tests() { + let err = scan("#[testkit::test]\nasync fn t(cx: &mut Context) {}").unwrap_err(); + assert!(err.to_string().contains("doc comment"), "{err}"); + } +} diff --git a/tests/testkit/testkit/src/lib.rs b/tests/testkit/testkit/src/lib.rs index 168fdbe894..d870044329 100644 --- a/tests/testkit/testkit/src/lib.rs +++ b/tests/testkit/testkit/src/lib.rs @@ -20,7 +20,7 @@ //! ``` //! //! Tests find the gateway the same way the `openshell` CLI does (see -//! [`gateway`]). +//! [`gateway`]). `id = "t-..."` optionally gives a test a stable identity. mod cli; mod context; diff --git a/tests/testkit/testkit/src/run.rs b/tests/testkit/testkit/src/run.rs index 731ead0557..ab808d7333 100644 --- a/tests/testkit/testkit/src/run.rs +++ b/tests/testkit/testkit/src/run.rs @@ -17,6 +17,8 @@ use crate::gateway::Gateway; pub struct TestInfo { /// The function name, which is also the nextest test name. pub name: &'static str, + /// The stable test ID, if the test declares one. + pub id: Option<&'static str>, pub description: &'static str, } @@ -33,7 +35,10 @@ where F: AsyncFnOnce(&mut Context) -> Result, { let name = info.name; - eprintln!("[{name}] {}", info.description); + match info.id { + Some(id) => eprintln!("[{name}] {} ({id})", info.description), + None => eprintln!("[{name}] {}", info.description), + } let gateway = Gateway::from_env().unwrap_or_else(|error| panic!("[{name}] {error}")); eprintln!("[{name}] {}", gateway.describe()); From 15786f4b1504da9962f83d252760a8aa250990ff Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 21:14:52 -0700 Subject: [PATCH 02/16] feat(tmachine): add the Session API and a test-runner package Add a library API that boots an environment, runs commands in it with timeouts, copies files in and out, collects diagnostics and tears it down, so a caller can drive a machine without writing an Ansible playbook per suite. SSH and exec timeouts and QEMU cleanup on SIGTERM keep a hung guest or a cancelled CI job from leaving QEMU running. Add the `test-runner` flake package, which runs tests/runner with the toolchains and VM tools it needs. The Session API and test-runner package are adapted from NVIDIA/OpenShell#4629. Co-authored-by: Evan Lezar Signed-off-by: Matthew Grossman --- flake.nix | 3 +- tests/tmachine/Cargo.lock | 1 + tests/tmachine/Cargo.toml | 3 +- tests/tmachine/config.nix | 36 +++- tests/tmachine/src/ansible.rs | 28 ++- tests/tmachine/src/lib.rs | 17 ++ tests/tmachine/src/main.rs | 15 +- tests/tmachine/src/qemu/cleanup.rs | 127 +++++++++++ tests/tmachine/src/qemu/layer.rs | 12 +- tests/tmachine/src/qemu/mod.rs | 5 + tests/tmachine/src/qemu/session.rs | 328 +++++++++++++++++++++++++++++ tests/tmachine/src/qemu/test.rs | 53 +---- tests/tmachine/src/qemu/vm.rs | 219 ++++++++++++++++--- 13 files changed, 757 insertions(+), 90 deletions(-) create mode 100644 tests/tmachine/src/lib.rs create mode 100644 tests/tmachine/src/qemu/cleanup.rs create mode 100644 tests/tmachine/src/qemu/session.rs diff --git a/flake.nix b/flake.nix index 7fac354cac..60ae691dc5 100644 --- a/flake.nix +++ b/flake.nix @@ -127,7 +127,7 @@ firmwarePkgs = testGuestPkgs; }; testMachines = import ./tests/tmachine/config.nix { - inherit pkgs toolchains; + inherit pkgs toolchains rustToolchain; qemuPkgs = tmachineRuntimePkgs; firmwarePkgs = tmachineRuntimePkgs; }; @@ -203,6 +203,7 @@ tmachine = testMachines.package; tmachine-config = testMachines.config; tmachine-unwrapped = testMachines.unwrapped; + openshell-test-runner = testMachines.testRunner; }; devShells = { diff --git a/tests/tmachine/Cargo.lock b/tests/tmachine/Cargo.lock index 9116e42ee8..527613035f 100644 --- a/tests/tmachine/Cargo.lock +++ b/tests/tmachine/Cargo.lock @@ -579,6 +579,7 @@ dependencies = [ "blake3", "clap", "directories", + "libc", "serde", "serde-saphyr", "tempfile", diff --git a/tests/tmachine/Cargo.toml b/tests/tmachine/Cargo.toml index 46358ea4e0..3d6508cf42 100644 --- a/tests/tmachine/Cargo.toml +++ b/tests/tmachine/Cargo.toml @@ -15,9 +15,10 @@ anyhow = { version = "1.0.100", default-features = false, features = ["std"] } blake3 = { version = "1.8.7", default-features = false, features = ["std"] } clap = { version = "4.6.6", default-features = false, features = ["std", "derive", "help", "usage", "color"] } directories = { version = "6.0.0", default-features = false } +libc = { version = "0.2", default-features = false } serde = { version = "1.0.229", default-features = false, features = ["std", "derive"] } serde-saphyr = { version = "1.2.0", default-features = false, features = ["deserialize"] } tempfile = { version = "3.27.0", default-features = false } -tokio = { version = "1.53.1", default-features = false, features = ["macros", "rt", "process", "net", "io-util"] } +tokio = { version = "1.53.1", default-features = false, features = ["macros", "rt", "process", "net", "io-util", "signal", "time"] } [workspace] diff --git a/tests/tmachine/config.nix b/tests/tmachine/config.nix index 8f048e16af..ee9f70943e 100644 --- a/tests/tmachine/config.nix +++ b/tests/tmachine/config.nix @@ -4,6 +4,7 @@ { pkgs, toolchains, + rustToolchain, qemuPkgs ? pkgs, firmwarePkgs ? pkgs, }: @@ -232,9 +233,42 @@ let exec ${tmachine}/bin/tmachine --config ${config} "$@" ''; }; + + # Runs test plans with tests/runner + # (`nix run .#openshell-test-runner -- test --plan tests/plans/cpu-tests.yaml`). + # It cross-builds guest test bundles and drives + # tmachine sessions, so it needs the toolchains and the VM tools. The runner + # itself is built by cargo on first use. By Evan Lezar (NVIDIA/OpenShell#4629). + testRunner = pkgs.writeShellApplication { + name = "openshell-test-runner"; + runtimeInputs = [ + qemu + pkgs.ansible + pkgs.cargo-nextest + pkgs.coreutils + pkgs.git + pkgs.gnutar + pkgs.sshpass + rustToolchain + ]; + runtimeEnv = + pkgs.lib.foldl' (env: toolchain: env // toolchain.env) { } (builtins.attrValues toolchains) + // { + TMACHINE_CONFIG = "${config}"; + } + // pkgs.lib.optionalAttrs isAarch64 { + TMACHINE_FIRMWARE_CODE = firmwarePkgs.OVMF.firmware; + TMACHINE_FIRMWARE_VARS = firmwarePkgs.OVMF.variables; + }; + text = '' + root=$(git rev-parse --show-toplevel) + export ANSIBLE_CONFIG="$root/tests/tmachine/ansible/ansible.cfg" + exec cargo run --locked --quiet --manifest-path "$root/tests/runner/Cargo.toml" --bin openshell-test-runner -- "$@" + ''; + }; in { package = runner; unwrapped = tmachine; - inherit config; + inherit config testRunner; } diff --git a/tests/tmachine/src/ansible.rs b/tests/tmachine/src/ansible.rs index 868b763d5b..3301934954 100644 --- a/tests/tmachine/src/ansible.rs +++ b/tests/tmachine/src/ansible.rs @@ -4,14 +4,14 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, ensure}; use tokio::process::Command; pub fn requirements_path() -> PathBuf { PathBuf::from(std::env::var("ANSIBLE_CONFIG").unwrap()).with_file_name("requirements.yaml") } -pub async fn install_roles() { +pub async fn install_roles() -> Result<()> { let status = Command::new("ansible-galaxy") .arg("role") .arg("install") @@ -20,13 +20,18 @@ pub async fn install_roles() { .arg(requirements_path()) .status() .await - .unwrap(); + .context("run ansible-galaxy")?; - assert!(status.success()); + ensure!(status.success(), "ansible-galaxy exited with {status}"); + Ok(()) } pub async fn run(playbook: &Path, inputs: &BTreeMap) -> Result<()> { let mut command = Command::new("ansible-playbook"); + // The VM's SSH forward may not be the inventory's default port. + command + .arg("--extra-vars") + .arg(format!("ansible_port={}", crate::ssh_port())); for (name, path) in inputs { let value = match std::fs::canonicalize(path) { Ok(path) => path, @@ -42,8 +47,19 @@ pub async fn run(playbook: &Path, inputs: &BTreeMap) -> Result< .arg(format!("{name}={}", value.display())); } - let status = command.arg(playbook).status().await.unwrap(); + // Stop ansible-playbook if the caller is dropped, for example when the + // runner is cancelled. + command.kill_on_drop(true); + let status = command + .arg(playbook) + .status() + .await + .context("run ansible-playbook")?; - assert!(status.success()); + ensure!( + status.success(), + "ansible-playbook {} exited with {status}", + playbook.display() + ); Ok(()) } diff --git a/tests/tmachine/src/lib.rs b/tests/tmachine/src/lib.rs new file mode 100644 index 0000000000..716b903922 --- /dev/null +++ b/tests/tmachine/src/lib.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Isolated QEMU test machines provisioned with Ansible. +//! +//! The `tmachine` binary drives one-shot `setup`, `install`, and `test` +//! commands. Callers that need to act on a running machine use [`Session`]. +//! A binary that starts machines should call [`cleanup_on_signals`] once, so a +//! `SIGTERM` does not leave QEMU running. +//! +//! The session API is by Evan Lezar (NVIDIA/OpenShell#4629). + +pub mod ansible; +pub mod config; +mod qemu; + +pub use qemu::{DEFAULT_SSH_PORT, Session, cleanup_on_signals, install, setup, ssh_port, test}; diff --git a/tests/tmachine/src/main.rs b/tests/tmachine/src/main.rs index 3c0d1a4eee..c69e3a29e6 100644 --- a/tests/tmachine/src/main.rs +++ b/tests/tmachine/src/main.rs @@ -5,11 +5,8 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use config::{Config, Environment, Installer, Machine}; - -mod ansible; -mod config; -mod qemu; +use tmachine::config::{Config, Environment, Installer, Machine}; +use tmachine::{install, setup, test}; #[derive(Parser)] #[command(name = "tmachine")] @@ -40,12 +37,14 @@ enum Command { #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { let cli = Cli::parse(); + // A SIGTERM from CI cancellation must not leave QEMU holding the SSH port. + tmachine::cleanup_on_signals(); let config = Config::load(&cli.config)?; match cli.command { Command::Setup { environment } => { let (machine, environment) = find_environment(&config, &environment)?; - qemu::setup(&machine, &environment).await?; + setup(&machine, &environment).await?; } Command::Install { environment, @@ -53,7 +52,7 @@ async fn main() -> Result<()> { } => { let (machine, environment) = find_environment(&config, &environment)?; let installer = find_installer(&config, &installer)?; - qemu::install(&machine, &environment, &installer).await?; + install(&machine, &environment, &installer).await?; } Command::Test { environment, @@ -67,7 +66,7 @@ async fn main() -> Result<()> { .iter() .find(|candidate| candidate.name == testsuite) .with_context(|| format!("testsuite {testsuite:?} is not defined"))?; - qemu::test(&machine, &environment, &installer, testsuite).await?; + test(&machine, &environment, &installer, testsuite).await?; } } diff --git a/tests/tmachine/src/qemu/cleanup.rs b/tests/tmachine/src/qemu/cleanup.rs new file mode 100644 index 0000000000..08fd822570 --- /dev/null +++ b/tests/tmachine/src/qemu/cleanup.rs @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Keeping QEMU from outliving tmachine. +//! +//! Dropping a VM handle stops QEMU, but a destructor never runs when the +//! process is killed by a signal. Two mechanisms cover that: +//! +//! - On Linux, QEMU asks the kernel to kill it when its parent dies +//! (`PR_SET_PDEATHSIG`), whatever the reason, including `SIGKILL`. +//! - [`cleanup_on_signals`] kills every tracked QEMU on `SIGTERM`, `SIGINT` +//! and `SIGHUP` and exits, which also covers hosts without that kernel +//! feature. +//! +//! A leaked QEMU holds the SSH forward port, so the next run on that host +//! fails until someone kills it by hand. + +use std::sync::Mutex; + +use tokio::process::Command; +use tokio::signal::unix::{SignalKind, signal}; + +static LIVE: Mutex> = Mutex::new(Vec::new()); + +pub(super) fn track(pid: u32) { + if let Ok(mut live) = LIVE.lock() { + live.push(pid); + } +} + +pub(super) fn forget(pid: u32) { + if let Ok(mut live) = LIVE.lock() { + live.retain(|candidate| *candidate != pid); + } +} + +/// Kill every QEMU this process started and has not yet reaped. +fn kill_all() { + let pids = LIVE.lock().map(|live| live.clone()).unwrap_or_default(); + for pid in pids { + if let Ok(pid) = libc::pid_t::try_from(pid) { + // SAFETY: `kill` has no memory-safety preconditions. A recycled + // PID is not a concern: entries are removed when the VM handle + // that owns the child is dropped, and the child is reaped by it. + unsafe { libc::kill(pid, libc::SIGKILL) }; + } + } +} + +/// Make a child die when this process dies, on platforms that support it. +#[cfg(target_os = "linux")] +pub(super) fn die_with_parent(command: &mut Command) { + // SAFETY: the closure runs between fork and exec and calls only + // `prctl`, which is async-signal-safe. + unsafe { + command.pre_exec(|| { + // PR_SET_PDEATHSIG fires when the thread that spawned the child + // exits, so the runtime must keep its spawning thread alive: + // tmachine's binaries use a current-thread runtime. + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } +} + +#[cfg(not(target_os = "linux"))] +pub(super) fn die_with_parent(_command: &mut Command) {} + +/// Kill every VM started through tmachine when the process receives +/// `SIGTERM`, `SIGINT` or `SIGHUP`, then exit with the conventional status. +/// +/// Call once from a binary's `main`, inside the Tokio runtime. +pub fn cleanup_on_signals() { + for (kind, number) in [ + (SignalKind::terminate(), libc::SIGTERM), + (SignalKind::interrupt(), libc::SIGINT), + (SignalKind::hangup(), libc::SIGHUP), + ] { + let Ok(mut stream) = signal(kind) else { + eprintln!("tmachine: could not install the signal {number} handler; VMs may leak"); + continue; + }; + tokio::spawn(async move { + stream.recv().await; + kill_all(); + std::process::exit(128 + number); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracks_and_forgets_pids() { + // Out of range for a `pid_t`, so a concurrent `kill_all` skips them. + let (kept, dropped) = (u32::MAX - 1, u32::MAX - 2); + track(kept); + track(dropped); + forget(dropped); + let live = LIVE.lock().unwrap().clone(); + assert!(live.contains(&kept)); + assert!(!live.contains(&dropped)); + forget(kept); + } + + #[tokio::test(flavor = "current_thread")] + async fn kill_all_stops_tracked_children_and_handlers_install() { + // Installing handlers must not change behavior until a signal arrives. + cleanup_on_signals(); + let child = Command::new("sleep") + .arg("60") + .kill_on_drop(true) + .spawn() + .unwrap(); + let pid = child.id().unwrap(); + track(pid); + kill_all(); + let mut child = child; + let status = child.wait().await.unwrap(); + assert!(!status.success()); + forget(pid); + } +} diff --git a/tests/tmachine/src/qemu/layer.rs b/tests/tmachine/src/qemu/layer.rs index 9b1273a836..e6ec03650d 100644 --- a/tests/tmachine/src/qemu/layer.rs +++ b/tests/tmachine/src/qemu/layer.rs @@ -10,7 +10,7 @@ use blake3::Hasher; use directories::ProjectDirs; use super::img::QemuImage; -use super::vm::QemuVm; +use super::vm::{LAYER_POWER_OFF_TIMEOUT, QemuVm}; pub(super) async fn cached_layer( base_image: &Path, @@ -29,7 +29,7 @@ pub(super) async fn cached_layer( } if use_galaxy { - crate::ansible::install_roles().await; + crate::ansible::install_roles().await?; } let temporary_disk = disks_dir.join(format!("{hash}.tmp")); @@ -38,10 +38,12 @@ pub(super) async fn cached_layer( } let image = QemuImage::create(base_image, temporary_disk.clone()).await; - let vm = QemuVm::start(&image).await; + let vm = QemuVm::start(&image).await?; run_playbooks(playbooks, inputs).await?; - vm.shutdown().await; - vm.wait().await; + // A layer is only cached after a clean power-off, so failing here is an + // error rather than something to log and move past. + vm.shutdown().await.context("power off the VM")?; + vm.wait(LAYER_POWER_OFF_TIMEOUT).await?; std::fs::rename(temporary_disk, &disk).unwrap(); Ok(disk) diff --git a/tests/tmachine/src/qemu/mod.rs b/tests/tmachine/src/qemu/mod.rs index c13ebbf56b..4dac339bc4 100644 --- a/tests/tmachine/src/qemu/mod.rs +++ b/tests/tmachine/src/qemu/mod.rs @@ -2,13 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 mod ansible_hash; +mod cleanup; mod img; mod install; mod layer; +mod session; mod setup; mod test; mod vm; +pub use cleanup::cleanup_on_signals; pub use install::install; +pub use session::Session; pub use setup::setup; pub use test::test; +pub use vm::{DEFAULT_SSH_PORT, ssh_port}; diff --git a/tests/tmachine/src/qemu/session.rs b/tests/tmachine/src/qemu/session.rs new file mode 100644 index 0000000000..37fb72cd9c --- /dev/null +++ b/tests/tmachine/src/qemu/session.rs @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! A running machine that callers act on over SSH. +//! +//! The session API is by Evan Lezar (NVIDIA/OpenShell#4629). Timeouts on +//! every SSH operation, the configurable SSH port, and a `down` that cannot +//! panic were added when it landed. + +use std::collections::BTreeMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::process::{ExitStatus, Output, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail, ensure}; +use tempfile::{TempDir, tempdir}; +use tokio::process::Command; + +use crate::config::{Environment, Installer, Machine}; + +use super::img::QemuImage; +use super::install::install; +use super::layer::run_playbooks; +use super::vm::{QemuVm, ssh_port}; + +// The port is the QEMU forward in `vm.rs`; `ansible.rs` passes the same port +// to Ansible. +const SSH_HOST: &str = "127.0.0.1"; +const SSH_USER: &str = "tmachine"; +const SSH_PASSWORD: &str = "tmachine"; +// Password authentication only: keys from the caller's agent or ~/.ssh would +// otherwise use up the guest's authentication attempts first. The keep-alive +// options make SSH give up on a guest that stopped answering, after about a +// minute, instead of waiting forever. +const SSH_OPTIONS: &[&str] = &[ + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", + "-o", + "ConnectTimeout=5", + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=4", + "-o", + "PreferredAuthentications=password", + "-o", + "PubkeyAuthentication=no", + "-o", + "IdentityAgent=none", +]; +const SSH_READY_TIMEOUT: Duration = Duration::from_secs(300); +const SSH_READY_INTERVAL: Duration = Duration::from_secs(2); +/// How long one probe for SSH readiness may take. +const SSH_PROBE_TIMEOUT: Duration = Duration::from_secs(30); + +/// The longest [`Session::exec`] and the file copies wait for a command. +/// Use [`Session::exec_timeout`] for commands that run longer, such as a +/// whole test suite. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(600); + +/// A machine booted from a throwaway overlay of the installed disk. +/// +/// The VM stops when the session is dropped, so a caller that fails or +/// panics cannot leak it. Call [`Session::down`] for a clean shutdown. +pub struct Session { + // Declared before `_dir` so the VM stops before its disk is removed. + vm: Option, + _image: QemuImage, + _dir: TempDir, +} + +impl Session { + /// Set up and install the environment (reusing cached disks), boot a VM + /// from a throwaway overlay, and wait until it accepts SSH. + pub async fn up( + machine: &Machine, + environment: &Environment, + installer: &Installer, + ) -> Result { + let install_disk = install(machine, environment, installer).await?; + let dir = tempdir().context("create session directory")?; + let image = QemuImage::create(&install_disk, dir.path().join("session.qcow2")).await; + let vm = QemuVm::start(&image).await?; + let mut session = Self { + vm: Some(vm), + _image: image, + _dir: dir, + }; + session.wait_for_ssh().await?; + Ok(session) + } + + async fn wait_for_ssh(&mut self) -> Result<()> { + let started = Instant::now(); + loop { + if let Some(status) = self.vm.as_mut().and_then(QemuVm::try_wait) { + bail!( + "QEMU exited with {status} before SSH was ready; is another tmachine VM using port {}?", + ssh_port() + ); + } + match self.exec_timeout(&["true"], SSH_PROBE_TIMEOUT).await { + Ok(output) if output.status.success() => return Ok(()), + Ok(_) | Err(_) if started.elapsed() < SSH_READY_TIMEOUT => { + tokio::time::sleep(SSH_READY_INTERVAL).await; + } + Ok(output) => bail!( + "SSH was not ready after {SSH_READY_TIMEOUT:?}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + Err(error) => { + return Err( + error.context(format!("SSH was not ready after {SSH_READY_TIMEOUT:?}")) + ); + } + } + } + } + + /// Run Ansible playbooks against the machine. + pub async fn run_playbooks( + &self, + playbooks: &[PathBuf], + inputs: &BTreeMap, + ) -> Result<()> { + run_playbooks(playbooks, inputs).await + } + + /// Run a command in the machine as the `tmachine` user and capture its + /// output, giving up after [`DEFAULT_TIMEOUT`]. A non-zero exit is + /// returned in the output, not as an error. + pub async fn exec(&self, argv: &[&str]) -> Result { + self.exec_timeout(argv, DEFAULT_TIMEOUT).await + } + + /// Like [`Session::exec`], with an explicit timeout. On timeout the local + /// `sshpass` process is killed and the result is an error. The remote + /// command is not guaranteed to stop with it (there is no TTY to hang up), + /// so treat the machine as unusable after a timeout and shut it down. + pub async fn exec_timeout(&self, argv: &[&str], timeout: Duration) -> Result { + ensure!(!argv.is_empty(), "exec needs a command"); + let remote = argv + .iter() + .map(|arg| shell_quote(arg)) + .collect::>() + .join(" "); + let mut command = ssh_command("ssh", &["-p", &ssh_port().to_string()]); + command + .arg(format!("{SSH_USER}@{SSH_HOST}")) + .arg("--") + .arg(remote) + .stdin(Stdio::null()); + output_within(command, timeout) + .await + .with_context(|| format!("run {argv:?} in tmachine VM")) + } + + /// Copy a local file or directory into the machine. + pub async fn copy_in(&self, src: &Path, dst: &str) -> Result<()> { + let remote = format!("{SSH_USER}@{SSH_HOST}:{dst}"); + scp(src.as_os_str().to_string_lossy().as_ref(), &remote) + .await + .with_context(|| format!("copy {} to {dst} in tmachine VM", src.display())) + } + + /// Copy a file or directory out of the machine. + pub async fn copy_out(&self, src: &str, dst: &Path) -> Result<()> { + let remote = format!("{SSH_USER}@{SSH_HOST}:{src}"); + scp(&remote, dst.as_os_str().to_string_lossy().as_ref()) + .await + .with_context(|| format!("copy {src} from tmachine VM to {}", dst.display())) + } + + /// Write the OpenShell gateway's journal to `dir/gateway.log`. + pub async fn collect_diagnostics(&self, dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir) + .with_context(|| format!("create diagnostics directory {}", dir.display()))?; + let output = self + .exec(&[ + "sudo", + "-n", + "journalctl", + "--no-pager", + "--lines", + "2000", + "_SYSTEMD_UNIT=openshell-gateway.service", + "+", + "_SYSTEMD_USER_UNIT=openshell-gateway.service", + ]) + .await?; + let log = dir.join("gateway.log"); + let mut contents = output.stdout; + contents.extend_from_slice(&output.stderr); + std::fs::write(&log, contents).with_context(|| format!("write {}", log.display())) + } + + /// Open an interactive SSH shell in the machine. + pub async fn shell(&self) -> Result { + ssh_command("ssh", &["-tt", "-p", &ssh_port().to_string()]) + .arg(format!("{SSH_USER}@{SSH_HOST}")) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await + .context("open SSH shell in tmachine VM") + } + + /// Power the machine off and wait for QEMU to exit. + /// + /// This never panics and never fails: teardown has no useful recovery, + /// so a guest that does not power off cleanly is killed and the problem + /// is logged to stderr. + pub async fn down(mut self) { + if let Some(vm) = self.vm.take() { + vm.power_off_or_kill().await; + } + } +} + +fn ssh_command(program: &str, args: &[&str]) -> Command { + let mut command = Command::new("sshpass"); + command + .env("SSHPASS", SSH_PASSWORD) + .arg("-e") + .arg(program) + .args(args) + .args(SSH_OPTIONS); + // Dropping the future that waits for the process, as a timeout does, stops it. + command.kill_on_drop(true); + command +} + +/// Run a command to completion and capture its output, or give up after +/// `timeout` and kill it. +async fn output_within(mut command: Command, timeout: Duration) -> Result { + bounded(timeout, command.output()).await?.context("run") +} + +/// Wait for `work`, or fail after `timeout`. +async fn bounded(timeout: Duration, work: impl Future) -> Result { + match tokio::time::timeout(timeout, work).await { + Ok(value) => Ok(value), + Err(_) => bail!("timed out after {timeout:?}"), + } +} + +async fn scp(src: &str, dst: &str) -> Result<()> { + let mut command = ssh_command("scp", &["-r", "-P", &ssh_port().to_string()]); + command.arg(src).arg(dst).stdin(Stdio::null()); + let output = output_within(command, DEFAULT_TIMEOUT) + .await + .context("run scp")?; + ensure!( + output.status.success(), + "scp exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(()) +} + +/// Quote an argument for the remote POSIX shell that SSH runs commands in. +fn shell_quote(arg: &str) -> String { + let safe = !arg.is_empty() + && arg + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%".contains(&b)); + if safe { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', r"'\''")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn leaves_plain_arguments_unquoted() { + assert_eq!(shell_quote("cargo-nextest"), "cargo-nextest"); + assert_eq!(shell_quote("/var/lib/x.tar.zst"), "/var/lib/x.tar.zst"); + assert_eq!(shell_quote("KEY=value"), "KEY=value"); + } + + #[test] + fn quotes_shell_metacharacters() { + assert_eq!( + shell_quote("binary(a) | binary(b)"), + "'binary(a) | binary(b)'" + ); + assert_eq!(shell_quote("$HOME"), "'$HOME'"); + assert_eq!(shell_quote(""), "''"); + } + + #[test] + fn escapes_single_quotes() { + assert_eq!(shell_quote("it's"), r"'it'\''s'"); + } + + #[tokio::test(flavor = "current_thread")] + async fn a_command_that_outlives_its_timeout_is_an_error_and_is_killed() { + let mut command = Command::new("sleep"); + command.arg("60").kill_on_drop(true); + let started = Instant::now(); + let error = output_within(command, Duration::from_millis(100)) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("timed out"), "{error:#}"); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[tokio::test(flavor = "current_thread")] + async fn a_command_within_its_timeout_returns_its_output() { + let mut command = Command::new("echo"); + command.arg("hi"); + let output = output_within(command, Duration::from_secs(10)) + .await + .unwrap(); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "hi"); + } +} diff --git a/tests/tmachine/src/qemu/test.rs b/tests/tmachine/src/qemu/test.rs index ac8a8bdfc0..cd4cb95d48 100644 --- a/tests/tmachine/src/qemu/test.rs +++ b/tests/tmachine/src/qemu/test.rs @@ -1,18 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::process::Stdio; - -use anyhow::{Context, Result}; -use tempfile::tempdir; -use tokio::process::Command; +use anyhow::Result; use crate::config::{Environment, Installer, Machine, Testsuite}; -use super::img::QemuImage; -use super::install::install; -use super::layer::run_playbooks; -use super::vm::QemuVm; +use super::session::Session; pub async fn test( machine: &Machine, @@ -20,46 +13,20 @@ pub async fn test( installer: &Installer, testsuite: &Testsuite, ) -> Result<()> { - let install_disk = install(machine, environment, installer).await?; - let test_dir = tempdir().unwrap(); - let test_disk = test_dir.path().join("test.qcow2"); - let image = QemuImage::create(&install_disk, test_disk).await; - let vm = QemuVm::start(&image).await; - - run_playbooks(&testsuite.playbooks, &testsuite.inputs).await?; + let session = Session::up(machine, environment, installer).await?; + session + .run_playbooks(&testsuite.playbooks, &testsuite.inputs) + .await?; if testsuite.interactive { println!("Opening an SSH shell in the tmachine VM."); - let ssh_status = Command::new("sshpass") - .env("SSHPASS", "tmachine") - .args([ - "-e", - "ssh", - "-tt", - "-p", - "2222", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "LogLevel=ERROR", - "tmachine@127.0.0.1", - ]) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .await - .context("open SSH shell in tmachine VM")?; - - vm.shutdown().await; - vm.wait().await; + let ssh_status = session.shell().await; + session.down().await; + let ssh_status = ssh_status?; anyhow::ensure!(ssh_status.success(), "SSH shell exited with {ssh_status}"); return Ok(()); } - vm.shutdown().await; - vm.wait().await; + session.down().await; Ok(()) } diff --git a/tests/tmachine/src/qemu/vm.rs b/tests/tmachine/src/qemu/vm.rs index 1e035632e2..1ba16b3e49 100644 --- a/tests/tmachine/src/qemu/vm.rs +++ b/tests/tmachine/src/qemu/vm.rs @@ -3,82 +3,219 @@ #[cfg(target_arch = "aarch64")] use std::fs; +use std::net::{Ipv4Addr, TcpListener}; #[cfg(target_arch = "aarch64")] use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::path::PathBuf; use std::process::Stdio; +use std::time::Duration; +use anyhow::{Context, Result, bail, ensure}; use tempfile::{TempDir, tempdir}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::process::{Child, Command}; +use super::cleanup; use super::img::QemuImage; +/// The host port forwarded to the guest's SSH server unless +/// `TMACHINE_SSH_PORT` says otherwise. +pub const DEFAULT_SSH_PORT: u16 = 2222; + +/// How long the QMP power-off request has to be acknowledged. A guest or QEMU +/// that stops answering must not hang teardown. +const QMP_TIMEOUT: Duration = Duration::from_secs(10); + +/// How long a powered-off guest has to exit at teardown before QEMU is +/// killed. +const POWER_OFF_TIMEOUT: Duration = Duration::from_secs(120); + +/// How long a guest being saved as a cached layer has to power off. A heavily +/// provisioned guest (Docker, k3s) can take minutes to stop its services, and +/// a layer that is killed instead is not cached, so this is much longer than +/// [`POWER_OFF_TIMEOUT`]. It still bounds a guest that never stops. +pub(super) const LAYER_POWER_OFF_TIMEOUT: Duration = Duration::from_secs(600); + +/// The host port forwarded to the guest's SSH server. +/// +/// Concurrent tmachine runs on one host need different ports; set +/// `TMACHINE_SSH_PORT` to run them side by side. +pub fn ssh_port() -> u16 { + let value = std::env::var("TMACHINE_SSH_PORT").ok(); + let port = parse_ssh_port(value.as_deref()); + if let Some(value) = value.as_deref().filter(|_| port == DEFAULT_SSH_PORT) + && value.trim() != DEFAULT_SSH_PORT.to_string() + { + eprintln!( + "tmachine: ignoring TMACHINE_SSH_PORT={value:?}, which is not a port from 1 to 65535; using {DEFAULT_SSH_PORT}" + ); + } + port +} + +fn parse_ssh_port(value: Option<&str>) -> u16 { + value + .and_then(|value| value.trim().parse().ok()) + .filter(|port| *port != 0) + .unwrap_or(DEFAULT_SSH_PORT) +} + pub(super) struct QemuVm { child: Child, runtime_dir: TempDir, + pid: Option, +} + +impl Drop for QemuVm { + fn drop(&mut self) { + // `kill_on_drop` stops QEMU; stop tracking it for signal cleanup. + if let Some(pid) = self.pid.take() { + cleanup::forget(pid); + } + } } impl QemuVm { - pub(super) async fn start(image: &QemuImage) -> Self { - let runtime_dir = tempdir().unwrap(); + pub(super) async fn start(image: &QemuImage) -> Result { + let port = ssh_port(); + // QEMU reports a taken port only by exiting, which is slow to notice + // and hard to diagnose. + TcpListener::bind((Ipv4Addr::LOCALHOST, port)).with_context(|| { + format!( + "host port {port} is not available for the VM's SSH forward; is another tmachine VM running? Set TMACHINE_SSH_PORT to use another port" + ) + })?; + + let runtime_dir = tempdir().context("create VM runtime directory")?; let qmp_socket = runtime_dir.path().join("qmp.sock"); - assert!( + ensure!( qmp_socket.as_os_str().as_encoded_bytes().len() < 108, "QMP socket path is too long: {} (set TMPDIR to a shorter path)", qmp_socket.display() ); let firmware_vars = prepare_firmware(runtime_dir.path()); - let child = command(image, &qmp_socket, firmware_vars.as_deref()) + let child = command(image, &qmp_socket, firmware_vars.as_deref(), port) .spawn() - .unwrap(); + .context("start QEMU")?; + let pid = child.id(); + if let Some(pid) = pid { + cleanup::track(pid); + } + + Ok(Self { + child, + runtime_dir, + pid, + }) + } + + /// The exit status, if QEMU has already exited. + pub(super) fn try_wait(&mut self) -> Option { + self.child.try_wait().ok().flatten() + } - Self { child, runtime_dir } + /// Wait for QEMU to exit after a power-off request, and check it exited + /// cleanly. QEMU is killed if it does not exit within `timeout`. + pub(super) async fn wait(mut self, timeout: Duration) -> Result<()> { + match tokio::time::timeout(timeout, self.child.wait()).await { + Ok(status) => { + let status = status.context("wait for QEMU")?; + ensure!(status.success(), "QEMU exited with {status}"); + Ok(()) + } + Err(_) => { + self.kill().await; + bail!("QEMU did not exit within {timeout:?} of the power-off request; killed it") + } + } } - pub(super) async fn wait(mut self) { - let status = self.child.wait().await.unwrap(); - assert!(status.success()); + /// Stop QEMU immediately and reap it. + pub(super) async fn kill(&mut self) { + let _ = self.child.kill().await; } - pub(super) async fn shutdown(&self) { + /// Ask the guest to power off over QMP. + pub(super) async fn shutdown(&self) -> Result<()> { let stream = UnixStream::connect(self.runtime_dir.path().join("qmp.sock")) .await - .unwrap(); + .context("connect to QMP")?; let (reader, mut writer) = stream.into_split(); let mut reader = BufReader::new(reader); let mut response = String::new(); - reader.read_line(&mut response).await.unwrap(); + reader + .read_line(&mut response) + .await + .context("read QMP greeting")?; writer .write_all(b"{\"execute\":\"qmp_capabilities\"}\r\n") .await - .unwrap(); + .context("negotiate QMP capabilities")?; response.clear(); - reader.read_line(&mut response).await.unwrap(); - assert!(response.contains("\"return\"")); + reader + .read_line(&mut response) + .await + .context("read QMP capabilities response")?; + ensure!( + response.contains("\"return\""), + "unexpected QMP response: {response}" + ); writer .write_all(b"{\"execute\":\"system_powerdown\"}\r\n") .await - .unwrap(); + .context("send system_powerdown")?; loop { response.clear(); - let bytes_read = reader.read_line(&mut response).await.unwrap(); - assert_ne!(bytes_read, 0); + let bytes_read = reader + .read_line(&mut response) + .await + .context("read system_powerdown response")?; + ensure!( + bytes_read != 0, + "QMP closed before system_powerdown returned" + ); if response.contains("\"return\"") { - break; + return Ok(()); + } + } + } + + /// Power the guest off and wait for QEMU to exit. Never panics: a guest + /// that can't be powered off cleanly is killed, and the problem is + /// logged. Use this when tearing down, where there is nothing left to do + /// about a failure. + pub(super) async fn power_off_or_kill(mut self) { + let shutdown = match tokio::time::timeout(QMP_TIMEOUT, self.shutdown()).await { + Ok(result) => result, + Err(_) => Err(anyhow::anyhow!("QMP did not answer within {QMP_TIMEOUT:?}")), + }; + match shutdown { + Ok(()) => { + if let Err(error) = self.wait(POWER_OFF_TIMEOUT).await { + eprintln!("tmachine: {error:#}"); + } + } + Err(error) => { + eprintln!("tmachine: could not power off the VM cleanly, killing it: {error:#}"); + self.kill().await; } } } } -fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) -> Command { +fn command( + image: &QemuImage, + qmp_socket: &Path, + firmware_vars: Option<&Path>, + ssh_port: u16, +) -> Command { let qemu = if cfg!(target_arch = "aarch64") { "qemu-system-aarch64" } else { @@ -117,8 +254,10 @@ fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) - .arg(format!("unix:{},server=on,wait=off", qmp_socket.display())); if let Some(firmware_vars) = firmware_vars { - let firmware_code = option_env!("TMACHINE_FIRMWARE_CODE") - .expect("TMACHINE_FIRMWARE_CODE must be set for ARM builds"); + let firmware_code = firmware_path( + "TMACHINE_FIRMWARE_CODE", + option_env!("TMACHINE_FIRMWARE_CODE"), + ); command .arg("-drive") .arg(format!( @@ -142,7 +281,7 @@ fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) - .arg("-device") .arg("virtio-rng-pci") .arg("-netdev") - .arg("user,id=net0,hostfwd=tcp:127.0.0.1:2222-:22") + .arg(format!("user,id=net0,hostfwd=tcp:127.0.0.1:{ssh_port}-:22")) .arg("-device") .arg("virtio-net-pci,netdev=net0"); @@ -150,15 +289,30 @@ fn command(image: &QemuImage, qmp_socket: &Path, firmware_vars: Option<&Path>) - .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); + // Dropping the handle stops QEMU; the parent-death signal covers the + // cases where no destructor runs. command.kill_on_drop(true); + cleanup::die_with_parent(&mut command); command } +/// A firmware path from the runtime environment, falling back to the value +/// compiled in. The fallback lets library users built outside Nix (such as +/// the test runner) supply firmware at runtime. +fn firmware_path(name: &str, compiled: Option<&'static str>) -> String { + std::env::var(name) + .ok() + .or_else(|| compiled.map(str::to_string)) + .unwrap_or_else(|| panic!("{name} must be set for ARM builds")) +} + fn prepare_firmware(runtime_dir: &Path) -> Option { #[cfg(target_arch = "aarch64")] { - let template = option_env!("TMACHINE_FIRMWARE_VARS") - .expect("TMACHINE_FIRMWARE_VARS must be set for ARM builds"); + let template = firmware_path( + "TMACHINE_FIRMWARE_VARS", + option_env!("TMACHINE_FIRMWARE_VARS"), + ); let firmware_vars = runtime_dir.join("firmware-vars.fd"); fs::copy(template, &firmware_vars).unwrap(); fs::set_permissions(&firmware_vars, fs::Permissions::from_mode(0o600)).unwrap(); @@ -171,3 +325,18 @@ fn prepare_firmware(runtime_dir: &Path) -> Option { None } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ssh_port_defaults_and_overrides() { + assert_eq!(parse_ssh_port(None), DEFAULT_SSH_PORT); + assert_eq!(parse_ssh_port(Some("2300")), 2300); + assert_eq!(parse_ssh_port(Some(" 2301 ")), 2301); + assert_eq!(parse_ssh_port(Some("0")), DEFAULT_SSH_PORT); + assert_eq!(parse_ssh_port(Some("not-a-port")), DEFAULT_SSH_PORT); + assert_eq!(parse_ssh_port(Some("70000")), DEFAULT_SSH_PORT); + } +} From b82623ff97a9038c914eeb39006ea320b54573e4 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 21:17:09 -0700 Subject: [PATCH 03/16] build(cargo): add a check that aligned workspaces lock shared crates like the root `cargo --locked` only checks a lockfile against its own manifest. A workspace outside the root that builds root crates through path dependencies, such as the testkit suites building `openshell-sdk`, has its own Cargo.lock, so the two lockfiles can resolve a shared crate to different versions without any check failing. That is how aws-lc-sys 0.46 was picked up in #4629, which does not link against the AWS-LC that Nix provides. check_cargo_lockfile_alignment.py fails when an aligned workspace locks a registry crate that the root lockfile also contains at a version the root lockfile does not have. With --fix it re-locks the workspace starting from the root lockfile. tests/testkit is the first aligned workspace. The script and its tests come from #4629 (a37ebe917). Co-authored-by: Evan Lezar Signed-off-by: Matthew Grossman --- .../scripts/check_cargo_lockfile_alignment.py | 171 ++++++++++++++++++ .../check_cargo_lockfile_alignment_test.py | 85 +++++++++ 2 files changed, 256 insertions(+) create mode 100644 tasks/scripts/check_cargo_lockfile_alignment.py create mode 100644 tasks/scripts/check_cargo_lockfile_alignment_test.py diff --git a/tasks/scripts/check_cargo_lockfile_alignment.py b/tasks/scripts/check_cargo_lockfile_alignment.py new file mode 100644 index 0000000000..49282c6fce --- /dev/null +++ b/tasks/scripts/check_cargo_lockfile_alignment.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# /// script +# requires-python = ">=3.11" +# /// + +"""Check that aligned Cargo workspaces lock shared crates like the root does. + +Some workspaces outside the root build root crates through path +dependencies, for example the testkit suites build `openshell-sdk`. Each +workspace has its own Cargo.lock, and `cargo --locked` only checks a lockfile +against its own manifest, so nothing stops the two lockfiles from resolving +the same crate to different versions. The root's versions are the ones CI and +the Nix toolchain are built for; a newer `aws-lc-sys`, for example, does not +link against the AWS-LC that Nix provides. + +This check fails when an aligned workspace locks a registry crate that the +root lockfile also contains at a version the root lockfile does not have. +With `--fix`, it re-locks each aligned workspace starting from the root +lockfile. +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path + +# Workspaces whose shared crates must match the root lockfile. Add a +# workspace here once its lockfile has been aligned with `--fix`. +ALIGNED_WORKSPACES = ( + "tests/testkit", +) + + +@dataclass(frozen=True) +class Mismatch: + workspace: str + name: str + version: str + root_versions: tuple[str, ...] + + def target(self) -> str | None: + """The root version semver-compatible with the locked one, if any.""" + key = compat_key(self.version) + candidates = [v for v in self.root_versions if compat_key(v) == key] + return max(candidates, key=version_tuple) if candidates else None + + +def version_tuple(version: str) -> tuple[int, ...]: + core = re.split(r"[-+]", version, maxsplit=1)[0] + return tuple(int(part) for part in core.split(".")) + + +def compat_key(version: str) -> tuple[int, ...]: + """Cargo's caret-compatibility class: the leftmost non-zero component.""" + major, minor, patch = (*version_tuple(version), 0, 0, 0)[:3] + if major: + return (major,) + if minor: + return (0, minor) + return (0, 0, patch) + + +def registry_versions(lockfile: Path) -> dict[str, set[str]]: + """Versions of registry (non-path) packages in a lockfile, by name.""" + data = tomllib.loads(lockfile.read_text()) + versions: dict[str, set[str]] = {} + for package in data.get("package", []): + if "source" in package: + versions.setdefault(package["name"], set()).add(package["version"]) + return versions + + +def mismatches(root: Path, workspace: str) -> list[Mismatch]: + root_versions = registry_versions(root / "Cargo.lock") + found = [] + for name, versions in sorted( + registry_versions(root / workspace / "Cargo.lock").items() + ): + shared = root_versions.get(name) + if shared is None: + continue + for version in sorted(versions - shared, key=version_tuple): + found.append( + Mismatch( + workspace, name, version, tuple(sorted(shared, key=version_tuple)) + ) + ) + return found + + +def fix(root: Path, workspace: str) -> list[Mismatch]: + """Re-lock the workspace starting from the root lockfile. + + Coupled crates such as the `futures-*` family pin each other, so they + cannot be moved one at a time. Seeding the workspace lockfile with the + root's lets Cargo keep every root version that still fits, add only the + crates the root does not have, and drop the rest. Returns what is still + misaligned afterwards. + """ + shutil.copyfile(root / "Cargo.lock", root / workspace / "Cargo.lock") + subprocess.run( + [ + "cargo", + "metadata", + "--format-version", + "1", + "--manifest-path", + str(root / workspace / "Cargo.toml"), + ], + check=True, + stdout=subprocess.DEVNULL, + ) + return mismatches(root, workspace) + + +def report(problems: list[Mismatch]) -> None: + for m in problems: + target = m.target() + hint = ( + f"; root has {target}" + if target + else f"; root has {', '.join(m.root_versions)}" + ) + print( + f"error: {m.workspace}: {m.name} {m.version} is not in the root Cargo.lock{hint}", + file=sys.stderr, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--fix", action="store_true", help="update aligned lockfiles to root versions" + ) + args = parser.parse_args(argv) + + root = Path( + subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ) + problems: list[Mismatch] = [] + for workspace in ALIGNED_WORKSPACES: + print(f"Checking {workspace}/Cargo.lock against Cargo.lock") + problems += fix(root, workspace) if args.fix else mismatches(root, workspace) + + if problems: + report(problems) + if not args.fix: + print( + "Run `uv run tasks/scripts/check_cargo_lockfile_alignment.py --fix` to align them.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tasks/scripts/check_cargo_lockfile_alignment_test.py b/tasks/scripts/check_cargo_lockfile_alignment_test.py new file mode 100644 index 0000000000..955f31db9e --- /dev/null +++ b/tasks/scripts/check_cargo_lockfile_alignment_test.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the Cargo lockfile alignment check.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +_SPEC = importlib.util.spec_from_file_location( + "check_cargo_lockfile_alignment", + Path(__file__).with_name("check_cargo_lockfile_alignment.py"), +) +assert _SPEC and _SPEC.loader +alignment = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = alignment +_SPEC.loader.exec_module(alignment) + +REGISTRY = 'source = "registry+https://github.com/rust-lang/crates.io-index"' + + +def lockfile(path: Path, packages: list[tuple[str, str, bool]]) -> None: + """Write a lockfile of (name, version, from_registry) packages.""" + entries = [] + for name, version, from_registry in packages: + entry = f'[[package]]\nname = "{name}"\nversion = "{version}"\n' + if from_registry: + entry += REGISTRY + "\n" + entries.append(entry) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("version = 4\n\n" + "\n".join(entries)) + + +def test_compat_key_follows_cargo_caret_rules() -> None: + assert alignment.compat_key("1.52.1") == (1,) + assert alignment.compat_key("0.44.0") == (0, 44) + assert alignment.compat_key("0.0.3") == (0, 0, 3) + assert alignment.compat_key("1.0.4+wasi-0.2.12") == (1,) + + +def test_reports_shared_registry_crates_at_other_versions(tmp_path: Path) -> None: + lockfile( + tmp_path / "Cargo.lock", + [ + ("tokio", "1.52.1", True), + ("rand", "0.8.6", True), + ("rand", "0.9.4", True), + ("openshell-sdk", "0.0.0", False), + ], + ) + lockfile( + tmp_path / "ws" / "Cargo.lock", + [ + ("tokio", "1.53.1", True), + ("rand", "0.9.5", True), + ("rand", "0.8.6", True), + ("only-here", "2.0.0", True), + ("openshell-sdk", "0.0.0", False), + ], + ) + + found = alignment.mismatches(tmp_path, "ws") + + assert [(m.name, m.version, m.target()) for m in found] == [ + ("rand", "0.9.5", "0.9.4"), + ("tokio", "1.53.1", "1.52.1"), + ] + + +def test_no_target_without_a_compatible_root_version(tmp_path: Path) -> None: + lockfile(tmp_path / "Cargo.lock", [("syn", "1.0.109", True)]) + lockfile(tmp_path / "ws" / "Cargo.lock", [("syn", "2.0.119", True)]) + + (found,) = alignment.mismatches(tmp_path, "ws") + + assert found.target() is None + + +def test_aligned_lockfiles_pass(tmp_path: Path) -> None: + lockfile(tmp_path / "Cargo.lock", [("tokio", "1.52.1", True)]) + lockfile(tmp_path / "ws" / "Cargo.lock", [("tokio", "1.52.1", True)]) + + assert alignment.mismatches(tmp_path, "ws") == [] From 55cc23064ebc3c82d60457d481b4b98e75557c67 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 21:19:01 -0700 Subject: [PATCH 04/16] test(conformance): add a sandbox smoke suite built on testkit Add the `sandbox` crate to the conformance workspace with one testkit test that creates a sandbox, runs `echo` in it with exec, deletes it and checks it is gone. It is deliberately small: it exists so a plan can run a real test through the runner on CI before the existing suites move to testkit. The workspace's other crate, the CLI conformance suite, keeps running as it does today. The conformance workspace joins the lockfile alignment check, so its lockfile is realigned with the root lockfile. The test is adapted from the `sandbox-lifecycle` suite in #4629. Co-authored-by: Evan Lezar Signed-off-by: Matthew Grossman --- .../scripts/check_cargo_lockfile_alignment.py | 1 + tests/suites/conformance/Cargo.lock | 4299 ++++++++++++++++- tests/suites/conformance/Cargo.toml | 2 +- tests/suites/conformance/sandbox/Cargo.toml | 15 + .../suites/conformance/sandbox/tests/smoke.rs | 40 + 5 files changed, 4140 insertions(+), 217 deletions(-) create mode 100644 tests/suites/conformance/sandbox/Cargo.toml create mode 100644 tests/suites/conformance/sandbox/tests/smoke.rs diff --git a/tasks/scripts/check_cargo_lockfile_alignment.py b/tasks/scripts/check_cargo_lockfile_alignment.py index 49282c6fce..29e82588af 100644 --- a/tasks/scripts/check_cargo_lockfile_alignment.py +++ b/tasks/scripts/check_cargo_lockfile_alignment.py @@ -37,6 +37,7 @@ # workspace here once its lockfile has been aligned with `--fix`. ALIGNED_WORKSPACES = ( "tests/testkit", + "tests/suites/conformance", ) diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index 89d8c0d392..f371505f97 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -2,11 +2,330 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" @@ -14,424 +333,3972 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1 0.10.6", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-http-proxy" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd1d471ea2f65ba45eddb1d6ab7d58ac2671d2d4ac14da5c6516ae1d97e1327a" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem 3.0.6", + "serde", + "serde_json", + "signature", + "simple_asn1", +] + +[[package]] +name = "k8s-openapi" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5" +dependencies = [ + "base64 0.22.1", + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "kube" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4eb20010536b48abe97fec37d23d43069bcbe9686adcf9932202327bc5ca6e" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-derive", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc2ed952042df20d15ac2fe9614d0ec14b6118eab89633985d4b36e688dccf1" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem 3.0.6", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff0d0793db58e70ca6d689489183816cb3aa481673e7433dc618cf7e8007c675" +dependencies = [ + "chrono", + "form_urlencoded", + "http", + "json-patch", + "k8s-openapi", + "schemars", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "kube-derive" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c562f58dc9f7ca5feac8a6ee5850ca221edd6f04ce0dd2ee873202a88cd494c9" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.117", +] + +[[package]] +name = "kube-runtime" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f34cfab9b4bd8633062e0e85edb81df23cb09f159f2e31c60b069ae826ffdc" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "async-trait", + "backon", + "educe", + "futures", + "hashbrown 0.15.5", + "hostname", + "json-patch", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.2", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noyalib" +version = "0.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" +dependencies = [ + "hashbrown 0.17.0", + "indexmap", + "libm", + "memchr", + "rustc-hash", + "serde_core", + "smallvec", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openshell-bootstrap" +version = "0.0.0" +dependencies = [ + "bytes", + "miette", + "openshell-core", + "openshell-crypto", + "rcgen", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "openshell-conformance" +version = "0.0.0" +dependencies = [ + "rand 0.9.4", + "serde", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "glob", + "ipnet", + "miette", + "nix", + "openshell-crypto", + "openshell-extension-core", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "reqwest", + "rustix", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tonic-types", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "openshell-crypto" +version = "0.0.0" +dependencies = [ + "aws-lc-rs", + "jsonwebtoken", + "kube", + "pem 3.0.6", + "rcgen", + "rustls", + "serde", + "sqlx", + "thiserror 2.0.20", + "tonic", + "x509-parser", +] + +[[package]] +name = "openshell-extension-core" +version = "0.0.0" +dependencies = [ + "hyper-util", + "serde", + "thiserror 2.0.20", + "tokio", + "tonic", + "tower", +] + +[[package]] +name = "openshell-policy-schema" +version = "0.0.0" +dependencies = [ + "miette", + "noyalib", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror 2.0.20", +] + +[[package]] +name = "openshell-sdk" +version = "0.0.0" +dependencies = [ + "async-stream", + "async-trait", + "futures", + "hyper", + "hyper-util", + "miette", + "oauth2", + "openshell-core", + "openshell-crypto", + "reqwest", + "rustls", + "serde", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "tonic", + "tower", + "tracing", +] + +[[package]] +name = "openshell-test-conformance-cli" +version = "0.0.0" +dependencies = [ + "openshell-conformance", + "tokio", +] + +[[package]] +name = "openshell-testkit" +version = "0.0.0" +dependencies = [ + "futures", + "openshell-bootstrap", + "openshell-core", + "openshell-sdk", + "openshell-testkit-macros", + "rand 0.9.4", + "serde", + "serde_json", + "tokio", + "tonic", +] + +[[package]] +name = "openshell-testkit-macros" +version = "0.0.0" +dependencies = [ + "openshell-testkit-spec", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "aws-lc-rs", + "pem 4.0.0", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "sandbox" +version = "0.0.0" +dependencies = [ + "openshell-testkit", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "thiserror 2.0.20", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "crc", + "digest 0.11.2", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.20", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.117", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "base64 0.22.1", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "mime", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1 0.10.6", + "thiserror 2.0.20", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "errno" -version = "0.3.14" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "libc", - "windows-sys", + "try-lock", ] [[package]] -name = "fastrand" -version = "2.5.0" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "getrandom" -version = "0.3.4" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", + "wit-bindgen 0.57.1", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] [[package]] -name = "libc" -version = "0.2.189" +name = "wasm-bindgen" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "wasm-bindgen-futures" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "wasm-bindgen-macro" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ - "scopeguard", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "memchr" -version = "2.8.3" +name = "wasm-bindgen-macro-support" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] [[package]] -name = "mio" -version = "1.2.3" +name = "wasm-bindgen-shared" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ - "libc", - "wasi", - "windows-sys", + "unicode-ident", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] [[package]] -name = "openshell-conformance" -version = "0.0.0" +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "rand", - "serde", - "serde_json", - "tempfile", - "tokio", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "openshell-test-conformance-cli" -version = "0.0.0" +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "openshell-conformance", - "tokio", + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "web-sys" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ - "lock_api", - "parking_lot_core", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "webpki-roots" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "rustls-pki-types", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "whoami" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "zerocopy", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "quote" -version = "1.0.47" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "rand" -version = "0.9.5" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "rand_chacha", - "rand_core", + "windows-link", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "ppv-lite86", - "rand_core", + "windows-link", ] [[package]] -name = "rand_core" -version = "0.9.5" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "getrandom", + "windows-targets", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "bitflags", + "windows-link", ] [[package]] -name = "rustix" -version = "1.1.5" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "serde" -version = "1.0.229" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "serde_core", - "serde_derive", + "wit-bindgen-rust-macro", ] [[package]] -name = "serde_core" -version = "1.0.229" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ - "serde_derive", + "anyhow", + "heck", + "wit-parser", ] [[package]] -name = "serde_derive" -version = "1.0.229" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] -name = "serde_json" -version = "1.0.151" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ - "itoa", - "memchr", + "anyhow", + "bitflags", + "indexmap", + "log", "serde", - "serde_core", - "zmij", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "errno", - "libc", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] -name = "smallvec" -version = "1.16.1" +name = "writeable" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] -name = "socket2" -version = "0.6.5" +name = "x509-parser" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "libc", - "windows-sys", + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", ] [[package]] -name = "syn" -version = "2.0.119" +name = "yasna" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "bit-vec", + "time", ] [[package]] -name = "syn" -version = "3.0.5" +name = "yoke" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", ] [[package]] -name = "tokio" -version = "1.53.1" +name = "zerocopy" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", + "zerocopy-derive", ] [[package]] -name = "tokio-macros" -version = "2.7.2" +name = "zerocopy-derive" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.117", ] [[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "zerofrom" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] [[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" +name = "zerofrom-derive" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ - "wit-bindgen", + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "zeroize" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "zerotrie" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ - "windows-link", + "displaydoc", + "yoke", + "zerofrom", ] [[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "zerocopy" -version = "0.8.57" +name = "zerovec" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ - "zerocopy-derive", + "yoke", + "zerofrom", + "zerovec-derive", ] [[package]] -name = "zerocopy-derive" -version = "0.8.57" +name = "zerovec-derive" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/suites/conformance/Cargo.toml b/tests/suites/conformance/Cargo.toml index 1c6b001096..8dad4ea1ff 100644 --- a/tests/suites/conformance/Cargo.toml +++ b/tests/suites/conformance/Cargo.toml @@ -3,4 +3,4 @@ [workspace] resolver = "2" -members = ["cli"] +members = ["cli", "sandbox"] diff --git a/tests/suites/conformance/sandbox/Cargo.toml b/tests/suites/conformance/sandbox/Cargo.toml new file mode 100644 index 0000000000..52ec79cbba --- /dev/null +++ b/tests/suites/conformance/sandbox/Cargo.toml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "sandbox" +description = "Conformance suite: sandbox behavior on every compute driver" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[dependencies] +openshell-testkit = { path = "../../../testkit/testkit" } diff --git a/tests/suites/conformance/sandbox/tests/smoke.rs b/tests/suites/conformance/sandbox/tests/smoke.rs new file mode 100644 index 0000000000..4134d9aa16 --- /dev/null +++ b/tests/suites/conformance/sandbox/tests/smoke.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox smoke suite: the smallest end-to-end check that a plan can run a +//! testkit test against a real gateway. +//! +//! Adapted from the `sandbox-lifecycle` suite in NVIDIA/OpenShell#4629 by +//! Evan Lezar. + +use testkit::Context; + +/// A sandbox becomes ready, runs a command with exec, and is gone from get and +/// list after delete. +#[testkit::test] +async fn create_exec_delete(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("smoke") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + + let marker = format!("openshell-e2e-{}", cx.run_id()); + let output = sandbox.exec(["echo", marker.as_str()]).await?; + testkit::ensure!( + output.exit_code == 0, + "exec exited with {}; stderr: {}", + output.exit_code, + output.stderr + ); + testkit::ensure!( + output.stdout == format!("{marker}\n"), + "exec stdout was {:?}; expected {:?}", + output.stdout, + format!("{marker}\n") + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} From 6b72502ae05d83fd34a593f8736ea9875d3dedbe Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 21:19:01 -0700 Subject: [PATCH 05/16] feat(runner): add the test runner, environments and the cpu-tests plan `openshell-test-runner` expands a plan (tests/plans/*.yaml) into one job per environment, builds the family's nextest archive for the guest, boots the environment through the tmachine Session API, runs nextest in it and brings back the JUnit report and logs. No per-suite Ansible playbook is involved. The runner points the tests at the system under test with environment variables: OPENSHELL_GATEWAY (the gateway the installer registered, from the environment's `gateway` field) and OPENSHELL_BIN (the installed CLI). Nextest runs with `--test-threads 1` and a slow-timeout that terminates a hung test. The JUnit report is checked against the catalog, so a missing or skipped expected test fails the job; `#[ignore]` tests are not expected and nextest's skipped case for them is accepted. `--strict` fails on an unsupported environment, a family with no tests, or an empty selection. `--emit-github-matrix` prints the GitHub Actions matrix for a plan, so the plan is the only list of what runs where. One environment is defined, amd64 Ubuntu with rootful Docker and the Debian package, which is the one CI can provision today; tests/plans/cpu-tests.yaml runs the conformance family on it. Further environments (Podman, k3s, arm64) are added with the suites that need them. There are no waivers or per-test requirements: the plan picks suites, and a broken test is `#[ignore]`d. Most of this comes from #4629, adapted to testkit finding the gateway the way the CLI does instead of reading a target file. Co-authored-by: Evan Lezar Signed-off-by: Matthew Grossman --- tests/environments/docker.yaml | 24 + tests/plans/cpu-tests.yaml | 18 + tests/runner/Cargo.lock | 815 ++++++++++++++++++ tests/runner/Cargo.toml | 30 + tests/runner/src/bundle.rs | 245 ++++++ tests/runner/src/catalog.rs | 423 +++++++++ tests/runner/src/environments.rs | 298 +++++++ tests/runner/src/execute.rs | 466 ++++++++++ tests/runner/src/github.rs | 170 ++++ tests/runner/src/jobs.rs | 460 ++++++++++ tests/runner/src/lib.rs | 15 + tests/runner/src/main.rs | 276 ++++++ tests/runner/src/plan.rs | 106 +++ tests/runner/src/report.rs | 467 ++++++++++ .../runner/testdata/nextest-0.9.143-junit.xml | 33 + tests/runner/tests/strict.rs | 49 ++ 16 files changed, 3895 insertions(+) create mode 100644 tests/environments/docker.yaml create mode 100644 tests/plans/cpu-tests.yaml create mode 100644 tests/runner/Cargo.lock create mode 100644 tests/runner/Cargo.toml create mode 100644 tests/runner/src/bundle.rs create mode 100644 tests/runner/src/catalog.rs create mode 100644 tests/runner/src/environments.rs create mode 100644 tests/runner/src/execute.rs create mode 100644 tests/runner/src/github.rs create mode 100644 tests/runner/src/jobs.rs create mode 100644 tests/runner/src/lib.rs create mode 100644 tests/runner/src/main.rs create mode 100644 tests/runner/src/plan.rs create mode 100644 tests/runner/src/report.rs create mode 100644 tests/runner/testdata/nextest-0.9.143-junit.xml create mode 100644 tests/runner/tests/strict.rs diff --git a/tests/environments/docker.yaml b/tests/environments/docker.yaml new file mode 100644 index 0000000000..dfa617b5d8 --- /dev/null +++ b/tests/environments/docker.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Docker-backed environments, by Evan Lezar (NVIDIA/OpenShell#4629). +# +# The ID must equal .-... +# `tmachine` names the environment and installer in tests/tmachine/config.nix +# that provision it. `gateway` is the name the installer registers the gateway +# under with the CLI (`openshell gateway add --name`); the packaged installers +# register `openshell`. +# +# Only amd64 is defined: CI has no arm64 runner with KVM for tmachine yet. To add +# arm64, copy the entry with `arch: arm64` and a `ubuntu2404-arm64` ID, then list +# it in a plan. The CI matrix picks the `ubuntu-24.04-arm` runner for it (see +# runner_label in tests/runner/src/github.rs); the calling workflow must also +# build arm64 inputs. +environments: + - id: tmachine.ubuntu2404-amd64.docker-rootful.deb + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: amd64 } + runtime: docker-rootful + installer: deb + tmachine: { environment: ubuntu-docker-rootful, installer: deb } + gateway: openshell diff --git a/tests/plans/cpu-tests.yaml b/tests/plans/cpu-tests.yaml new file mode 100644 index 0000000000..0ac90f2ff3 --- /dev/null +++ b/tests/plans/cpu-tests.yaml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Tests that need no GPU. `type` names a family under tests/suites/ (a Cargo +# workspace of testkit suites). Omit `suites` to run every suite in the family, +# or list suite names (a suite is a file under /tests/) to run only +# those. `environments` are IDs from tests/environments/. +# +# CI runs this plan from .github/workflows/e2e-suites.yml, one job per +# environment listed here. To add coverage, add an environment to an entry or an +# entry for another family; no workflow change is needed. +# +# Only amd64 is listed: Branch E2E has no arm64 runner with KVM for tmachine +# yet. See tests/environments/docker.yaml for how to add arm64. +matrix: + - type: conformance + environments: + - tmachine.ubuntu2404-amd64.docker-rootful.deb diff --git a/tests/runner/Cargo.lock b/tests/runner/Cargo.lock new file mode 100644 index 0000000000..ef57d0fe2e --- /dev/null +++ b/tests/runner/Cargo.lock @@ -0,0 +1,815 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "granit-parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0" +dependencies = [ + "arraydeque", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "noyalib" +version = "0.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962838abbd8dd2cd3844d2208dc502458bba6985f8bf1dc21761b30260fcbd7f" +dependencies = [ + "hashbrown", + "indexmap", + "libm", + "memchr", + "rustc-hash", + "serde_core", + "smallvec", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openshell-test-runner" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "noyalib", + "openshell-testkit-spec", + "serde", + "serde_json", + "tempfile", + "tmachine", + "tokio", + "toml", +] + +[[package]] +name = "openshell-testkit-spec" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-saphyr" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8" +dependencies = [ + "annotate-snippets", + "encoding_rs_io", + "granit-parser", + "num-traits", + "serde_core", + "smallvec", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tmachine" +version = "0.1.0" +dependencies = [ + "anyhow", + "blake3", + "clap", + "directories", + "libc", + "serde", + "serde-saphyr", + "tempfile", + "tokio", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/runner/Cargo.toml b/tests/runner/Cargo.toml new file mode 100644 index 0000000000..fac628a0e6 --- /dev/null +++ b/tests/runner/Cargo.toml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-test-runner" +description = "Runs OpenShell test plans against provisioned environments" +version = "0.0.0" +edition = "2024" +rust-version = "1.94" +license = "Apache-2.0" +repository = "https://github.com/NVIDIA/OpenShell" +publish = false + +[[bin]] +name = "openshell-test-runner" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive", "env"] } +openshell-testkit-spec = { path = "../testkit/testkit-spec" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yml = { package = "noyalib", version = "0.0.53", default-features = false, features = ["std", "compat-serde-yaml"] } +tempfile = "3" +tmachine = { path = "../tmachine" } +tokio = { version = "1", features = ["macros", "rt"] } +toml = "0.9" + +[workspace] diff --git a/tests/runner/src/bundle.rs b/tests/runner/src/bundle.rs new file mode 100644 index 0000000000..83114a65ce --- /dev/null +++ b/tests/runner/src/bundle.rs @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Building a family's test bundle for the guest. +//! +//! A bundle is a tar holding a nextest archive built for the guest target, +//! the family workspace's `Cargo.toml` files (for `--workspace-remap`), a +//! `nextest` config with the runner's `JUnit` profile, and `testkit-ready`, +//! which waits for the gateway before the tests run. The guest needs only the +//! `cargo-nextest` binary to run it. +//! +//! Adapted from the bundle builder in Evan Lezar's NVIDIA/OpenShell#4629. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail, ensure}; +use serde::Deserialize; +use tempfile::TempDir; + +use crate::catalog::Family; +use crate::environments::Arch; + +/// The nextest profile the runner uses in the guest. +pub const PROFILE: &str = "openshell"; + +/// The readiness check every bundle carries, at the bundle root. +pub const READY_BINARY: &str = "testkit-ready"; + +/// Nextest flags a test as slow after the period and terminates it after +/// `terminate-after` periods, so a hung test fails after two minutes instead +/// of holding the lane until the job times out. +const NEXTEST_CONFIG: &str = r#"# Written by the OpenShell test runner. +[profile.openshell] +fail-fast = false +slow-timeout = { period = "30s", terminate-after = 4 } + +[profile.openshell.junit] +path = "junit.xml" +"#; + +pub struct Bundle { + pub path: PathBuf, + _dir: TempDir, +} + +/// The Rust target for guests of `arch`. Static musl binaries don't depend +/// on the guest's libc. +pub const fn guest_target(arch: Arch) -> &'static str { + match arch { + Arch::Amd64 => "x86_64-unknown-linux-musl", + Arch::Arm64 => "aarch64-unknown-linux-musl", + } +} + +/// Build `testkit-ready` for the guest and return its path. +pub fn build_ready(root: &Path, target: &str) -> Result { + let output = Command::new("cargo") + .args(["build", "--locked", "--manifest-path"]) + .arg(root.join("tests/testkit/Cargo.toml")) + .args(["-p", "openshell-testkit", "--bin", READY_BINARY]) + .args([ + "--target", + target, + "--message-format", + "json-render-diagnostics", + ]) + .stderr(std::process::Stdio::inherit()) + .output() + .context("run cargo build")?; + ensure!( + output.status.success(), + "build {READY_BINARY} exited with {}", + output.status + ); + built_executable(&output.stdout, READY_BINARY) + .with_context(|| format!("cargo build did not report a {READY_BINARY} executable")) +} + +/// The executable cargo reports building for the binary target `name`. +fn built_executable(messages: &[u8], name: &str) -> Option { + #[derive(Deserialize)] + struct Message { + reason: String, + target: Option, + executable: Option, + } + #[derive(Deserialize)] + struct Target { + name: String, + } + + serde_json::Deserializer::from_slice(messages) + .into_iter::() + .filter_map(std::result::Result::ok) + .filter(|message| message.reason == "compiler-artifact") + .filter(|message| { + message + .target + .as_ref() + .is_some_and(|target| target.name == name) + }) + .find_map(|message| message.executable) +} + +/// Build the bundle for `packages`, and check that the archive contains a +/// binary for every suite in `binaries`. `ready` is the guest's +/// `testkit-ready`. +pub fn build( + family: &Family, + packages: &BTreeSet, + binaries: &BTreeSet, + target: &str, + ready: &Path, +) -> Result { + let dir = tempfile::tempdir().context("create bundle directory")?; + let stage = dir.path().join("stage"); + std::fs::create_dir(&stage)?; + let manifest = family.workspace.join("Cargo.toml"); + let archive = stage.join("tests.tar.zst"); + + let mut command = Command::new("cargo"); + command + .args(["nextest", "archive", "--locked", "--manifest-path"]) + .arg(&manifest) + .args(["--target", target, "--archive-file"]) + .arg(&archive); + for package in packages { + command.args(["-p", package]); + } + run(&mut command).with_context(|| format!("build {} test archive", family.name))?; + + let archived = archived_binaries(&manifest, &archive)?; + let missing: Vec<_> = binaries.difference(&archived).collect(); + ensure!( + missing.is_empty(), + "the {} catalog lists suites the archive has no test binary for: {missing:?}; archived: {archived:?}", + family.name + ); + + copy_manifests(&family.workspace, &stage)?; + std::fs::write(stage.join("nextest.toml"), NEXTEST_CONFIG)?; + std::fs::copy(ready, stage.join(READY_BINARY)) + .with_context(|| format!("copy {}", ready.display()))?; + + let path = dir.path().join(format!("{}-tests.tar", family.name)); + run(Command::new("tar") + .arg("-C") + .arg(&stage) + .arg("-cf") + .arg(&path) + .arg(".")) + .context("pack test bundle")?; + Ok(Bundle { path, _dir: dir }) +} + +#[derive(Deserialize)] +struct ListOutput { + #[serde(rename = "rust-binaries")] + rust_binaries: std::collections::BTreeMap, +} + +#[derive(Deserialize)] +struct ListSuite { + #[serde(rename = "binary-name")] + binary_name: String, + kind: String, +} + +/// Names of the test binaries in an archive, read without executing them. +fn archived_binaries(manifest: &Path, archive: &Path) -> Result> { + let output = Command::new("cargo") + .args(["nextest", "list", "--manifest-path"]) + .arg(manifest) + .arg("--archive-file") + .arg(archive) + .args(["--list-type", "binaries-only", "--message-format", "json"]) + .output() + .context("run cargo nextest list")?; + ensure!( + output.status.success(), + "cargo nextest list exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + let list: ListOutput = + serde_json::from_slice(&output.stdout).context("parse cargo nextest list output")?; + Ok(list + .rust_binaries + .into_values() + .filter(|suite| suite.kind == "test") + .map(|suite| suite.binary_name) + .collect()) +} + +/// Copy every `Cargo.toml` under `workspace` into `stage`, keeping relative +/// paths and skipping build output. +fn copy_manifests(workspace: &Path, stage: &Path) -> Result<()> { + fn walk(root: &Path, dir: &Path, stage: &Path) -> Result<()> { + for entry in std::fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? { + let path = entry?.path(); + let name = path.file_name().unwrap_or_default(); + if path.is_dir() { + if name != "target" && name != ".git" { + walk(root, &path, stage)?; + } + } else if name == "Cargo.toml" { + let dest = stage.join(path.strip_prefix(root)?); + std::fs::create_dir_all(dest.parent().unwrap_or(stage))?; + std::fs::copy(&path, &dest).with_context(|| format!("copy {}", path.display()))?; + } + } + Ok(()) + } + walk(workspace, workspace, stage) +} + +fn run(command: &mut Command) -> Result<()> { + let status = command + .status() + .with_context(|| format!("run {command:?}"))?; + if !status.success() { + bail!("{command:?} exited with {status}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn built_executable_finds_the_named_binary() { + let messages = br#"{"reason":"compiler-artifact","target":{"name":"testkit"},"executable":null} +{"reason":"compiler-artifact","target":{"name":"testkit-ready"},"executable":"/t/debug/testkit-ready"} +{"reason":"build-finished","success":true} +"#; + assert_eq!( + built_executable(messages, "testkit-ready"), + Some(PathBuf::from("/t/debug/testkit-ready")) + ); + assert_eq!(built_executable(messages, "other"), None); + } +} diff --git a/tests/runner/src/catalog.rs b/tests/runner/src/catalog.rs new file mode 100644 index 0000000000..2fceecc02d --- /dev/null +++ b/tests/runner/src/catalog.rs @@ -0,0 +1,423 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The test catalog: every family, suite, and test, read from source. +//! +//! A family is a Cargo workspace directly under `tests/suites/` that has at +//! least one testkit suite. A suite is one test binary +//! (`/tests/.rs`) containing `#[testkit::test]` functions; +//! the suite name is the binary name with `_` replaced by `-`. Tests are +//! parsed with `testkit-spec`, the same parser the macro uses, so the runner +//! never executes test binaries on the host to learn about them. Workspaces +//! whose tests are plain `#[test]` functions have no testkit suites and are +//! not families. A suite whose tests are all `#[ignore]`d is kept with no +//! tests, so selecting it reports that nothing would run. +//! +//! A test's identity is its optional stable ID, and its function name +//! otherwise. IDs are unique across every family, so a test can move between +//! suites and families without changing its ID. +//! +//! Adapted from the catalog in Evan Lezar's NVIDIA/OpenShell#4629. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +/// Where families live, relative to the repository root. +const SUITES_DIR: &str = "tests/suites"; + +#[derive(Debug, Serialize)] +pub struct Family { + pub name: String, + #[serde(skip)] + pub workspace: PathBuf, + pub suites: BTreeMap, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Suite { + pub suite: String, + pub binary: String, + pub package: String, + pub tests: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Test { + /// The stable test ID, if the test declares one. + pub id: Option, + /// The function name, which is also the nextest test name. + pub name: String, + pub description: String, +} + +impl Test { + /// How to name the test to a reader: the function name, with the ID + /// when there is one. + pub fn label(&self) -> String { + match &self.id { + Some(id) => format!("{} ({id})", self.name), + None => self.name.clone(), + } + } +} + +/// Every known family, keyed by name. +#[derive(Debug, Serialize)] +pub struct Catalog { + pub families: BTreeMap, +} + +impl Catalog { + pub fn load(root: &Path) -> Result { + let suites_dir = root.join(SUITES_DIR); + let mut workspaces: Vec<_> = std::fs::read_dir(&suites_dir) + .with_context(|| format!("read {}", suites_dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>() + .with_context(|| format!("read {}", suites_dir.display()))?; + workspaces.retain(|path| path.join("Cargo.toml").is_file()); + workspaces.sort(); + + let mut families = BTreeMap::new(); + for workspace in workspaces { + let name = workspace + .file_name() + .and_then(|name| name.to_str()) + .with_context(|| format!("{} is not a UTF-8 name", workspace.display()))? + .to_string(); + let family = Family::load(&name, &workspace) + .with_context(|| format!("load {name} test catalog"))?; + if !family.suites.is_empty() { + families.insert(name, family); + } + } + let catalog = Self { families }; + catalog.check_unique()?; + Ok(catalog) + } + + /// Every test, with its family and suite. + pub fn tests(&self) -> impl Iterator { + self.families.values().flat_map(|family| { + family + .suites + .values() + .flat_map(move |suite| suite.tests.iter().map(move |test| (family, suite, test))) + }) + } + + /// Fail if two tests anywhere in the catalog share an ID. + fn check_unique(&self) -> Result<()> { + let mut seen: BTreeMap<&str, String> = BTreeMap::new(); + for (family, suite, test) in self.tests() { + let Some(id) = &test.id else { continue }; + let location = format!("{}/{}/{}", family.name, suite.suite, test.name); + if let Some(first) = seen.insert(id, location.clone()) { + bail!("test ID {id} is used by both {first} and {location}"); + } + } + Ok(()) + } + + pub fn family(&self, name: &str) -> Result<&Family> { + self.families.get(name).with_context(|| { + format!( + "unknown test family {name:?}; known families: {}", + self.families.keys().cloned().collect::>().join(", ") + ) + }) + } +} + +#[derive(Deserialize)] +struct WorkspaceManifest { + workspace: Option, +} + +#[derive(Deserialize)] +struct WorkspaceSection { + #[serde(default)] + members: Vec, +} + +#[derive(Deserialize)] +struct PackageManifest { + package: PackageSection, +} + +#[derive(Deserialize)] +struct PackageSection { + name: String, +} + +impl Family { + fn load(name: &str, workspace: &Path) -> Result { + let manifest: WorkspaceManifest = read_toml(&workspace.join("Cargo.toml"))?; + let members = manifest.workspace.map(|w| w.members).unwrap_or_default(); + let mut suites = BTreeMap::new(); + for member in members { + // Cargo expands globs in `members`; the catalog doesn't, and would + // otherwise find no suites in them without saying so. + if member.contains(['*', '?', '[']) { + bail!( + "{}: list workspace members explicitly; the test catalog doesn't expand {member:?}", + workspace.join("Cargo.toml").display() + ); + } + let member_dir = workspace.join(&member); + for suite in member_suites(&member_dir)? { + if let Some(existing) = suites.insert(suite.suite.clone(), suite) { + bail!( + "suite {:?} is defined more than once (again in {member})", + existing.suite + ); + } + } + } + Ok(Self { + name: name.to_string(), + workspace: workspace.to_path_buf(), + suites, + }) + } + + pub fn suite(&self, name: &str) -> Result<&Suite> { + self.suites.get(name).with_context(|| { + format!( + "unknown {} suite {name:?}; known suites: {}", + self.name, + self.suites.keys().cloned().collect::>().join(", ") + ) + }) + } + + /// Packages containing the given suites. + pub fn packages<'a>(&self, suites: impl IntoIterator) -> BTreeSet { + suites + .into_iter() + .filter_map(|suite| self.suites.get(suite)) + .map(|suite| suite.package.clone()) + .collect() + } +} + +/// Suites in one workspace member: its `tests/*.rs` files that contain +/// `#[testkit::test]` functions. +fn member_suites(member_dir: &Path) -> Result> { + let tests_dir = member_dir.join("tests"); + if !tests_dir.is_dir() { + return Ok(Vec::new()); + } + let package: PackageManifest = read_toml(&member_dir.join("Cargo.toml"))?; + + let mut files: Vec<_> = std::fs::read_dir(&tests_dir) + .with_context(|| format!("read {}", tests_dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>()?; + files.retain(|path| path.extension().is_some_and(|ext| ext == "rs")); + files.sort(); + + let mut suites = Vec::new(); + for file in files { + let source = + std::fs::read_to_string(&file).with_context(|| format!("read {}", file.display()))?; + // Skip files that cannot contain a test before parsing them. + if !source.contains("testkit::test") { + continue; + } + let specs = testkit_spec::scan(&source) + .map_err(|error| anyhow::anyhow!("{}: {error}", file.display()))?; + if specs.is_empty() { + continue; + } + // An `#[ignore]` test only runs with `--run-ignored`, so it is not part + // of what a lane expects. A suite whose tests are all ignored stays in + // the catalog with no tests, so a plan that selects it is told that + // nothing would run rather than that the suite doesn't exist. + let specs: Vec<_> = specs.into_iter().filter(|spec| !spec.ignored).collect(); + let binary = file + .file_stem() + .and_then(|stem| stem.to_str()) + .with_context(|| format!("test file name {} is not UTF-8", file.display()))? + .to_string(); + suites.push(Suite { + suite: binary.replace('_', "-"), + binary, + package: package.package.name.clone(), + tests: specs + .into_iter() + .map(|spec| Test { + id: spec.id, + name: spec.name.to_string(), + description: spec.description, + }) + .collect(), + }); + } + Ok(suites) +} + +fn read_toml(path: &Path) -> Result { + let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + toml::from_str(&text).with_context(|| format!("parse {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") + } + + fn test(id: Option<&str>, name: &str) -> Test { + Test { + id: id.map(str::to_string), + name: name.to_string(), + description: "d".to_string(), + } + } + + fn catalog_of(tests: Vec<(&str, Test)>) -> Catalog { + let mut families: BTreeMap = BTreeMap::new(); + for (family, test) in tests { + let entry = families + .entry(family.to_string()) + .or_insert_with(|| Family { + name: family.to_string(), + workspace: PathBuf::new(), + suites: BTreeMap::from([( + "s".to_string(), + Suite { + suite: "s".to_string(), + binary: "s".to_string(), + package: "p".to_string(), + tests: Vec::new(), + }, + )]), + }); + entry.suites.get_mut("s").unwrap().tests.push(test); + } + Catalog { families } + } + + #[test] + fn repository_catalog_has_the_sandbox_smoke_suite() { + let catalog = Catalog::load(&repo_root()).unwrap(); + let family = catalog.family("conformance").unwrap(); + let suite = family.suite("smoke").unwrap(); + assert_eq!(suite.binary, "smoke"); + assert_eq!(suite.package, "sandbox"); + assert!( + suite + .tests + .iter() + .all(|test| !test.name.is_empty() && !test.description.is_empty()) + ); + assert!(!suite.tests.is_empty()); + } + + #[test] + fn glob_workspace_members_are_refused() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"suites/*\"]\n", + ) + .unwrap(); + let err = Family::load("conformance", dir.path()).unwrap_err(); + assert!( + err.to_string() + .contains("list workspace members explicitly"), + "{err}" + ); + } + + #[test] + fn ignored_tests_are_not_expected() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("tests")).unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"pkg\"\nversion = \"0.0.0\"\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("tests/s.rs"), + "/// Runs.\n#[testkit::test]\nasync fn runs(cx: &mut Context) -> testkit::Result { Ok(()) }\n\n/// Needs a flag.\n#[ignore]\n#[testkit::test]\nasync fn ignored(cx: &mut Context) -> testkit::Result { Ok(()) }\n", + ) + .unwrap(); + let suites = member_suites(dir.path()).unwrap(); + let names: Vec<_> = suites[0].tests.iter().map(|test| &test.name[..]).collect(); + assert_eq!(names, ["runs"]); + } + + #[test] + fn suites_with_every_test_ignored_have_no_tests() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("tests")).unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"pkg\"\nversion = \"0.0.0\"\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("tests/all_ignored.rs"), + "/// Broken.\n#[ignore = \"https://example.com/1\"]\n#[testkit::test]\nasync fn broken(cx: &mut Context) -> testkit::Result { Ok(()) }\n", + ) + .unwrap(); + let suites = member_suites(dir.path()).unwrap(); + assert_eq!(suites.len(), 1); + assert_eq!(suites[0].suite, "all-ignored"); + assert!(suites[0].tests.is_empty()); + } + + #[test] + fn workspaces_without_testkit_suites_are_not_families() { + let catalog = Catalog::load(&repo_root()).unwrap(); + for family in catalog.families.values() { + assert!(!family.suites.is_empty(), "{}", family.name); + } + } + + #[test] + fn duplicate_ids_are_rejected_across_families() { + let catalog = catalog_of(vec![ + ("a", test(Some("t-00000001"), "first")), + ("b", test(Some("t-00000001"), "second")), + ]); + let err = catalog.check_unique().unwrap_err().to_string(); + assert!( + err.contains("a/s/first") && err.contains("b/s/second"), + "{err}" + ); + } + + #[test] + fn tests_without_ids_may_share_a_name_across_families() { + let catalog = catalog_of(vec![("a", test(None, "same")), ("b", test(None, "same"))]); + catalog.check_unique().unwrap(); + } + + #[test] + fn labels_include_the_id_when_there_is_one() { + assert_eq!(test(None, "n").label(), "n"); + assert_eq!(test(Some("t-00000001"), "n").label(), "n (t-00000001)"); + } + + #[test] + fn unknown_names_list_known_ones() { + let catalog = Catalog::load(&repo_root()).unwrap(); + let err = catalog.family("no-such-family").unwrap_err(); + assert!(err.to_string().contains("conformance"), "{err}"); + let err = catalog + .family("conformance") + .unwrap() + .suite("smok") + .unwrap_err(); + assert!(err.to_string().contains("smoke"), "{err}"); + } +} diff --git a/tests/runner/src/environments.rs b/tests/runner/src/environments.rs new file mode 100644 index 0000000000..8813eb704e --- /dev/null +++ b/tests/runner/src/environments.rs @@ -0,0 +1,298 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Environment definitions: `tests/environments/*.yaml`. +//! +//! Each entry describes one environment a plan can name: the machine, the +//! container runtime, the installer, how the backend provisions it, and the +//! name the installer registers the gateway under. +//! +//! Adapted from the environment definitions in Evan Lezar's +//! NVIDIA/OpenShell#4629. + +use std::collections::BTreeMap; +use std::fmt; +use std::path::Path; + +use anyhow::{Context, Result, bail, ensure}; +use serde::Deserialize; + +/// All environments, keyed by ID. +#[derive(Debug, Default)] +pub struct Catalog { + environments: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + environments: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Environment { + pub id: String, + pub backend: Backend, + pub machine: Machine, + pub runtime: String, + pub installer: String, + /// Backend parameters when `backend` is `tmachine`. + #[serde(default)] + pub tmachine: Option, + /// The name the installer registers the gateway under with the CLI, which + /// the runner passes to tests as `OPENSHELL_GATEWAY`. Without one, tests + /// use the CLI's active gateway. + #[serde(default)] + pub gateway: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Backend { + Tmachine, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Machine { + pub os: String, + pub version: String, + pub arch: Arch, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Arch { + Amd64, + Arm64, +} + +/// The tmachine environment and installer that provision this environment. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Tmachine { + pub environment: String, + pub installer: String, +} + +impl fmt::Display for Backend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Tmachine => "tmachine", + }) + } +} + +impl fmt::Display for Arch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Amd64 => "amd64", + Self::Arm64 => "arm64", + }) + } +} + +impl Arch { + /// The architecture of the machine running this process. + pub const fn host() -> Option { + if cfg!(target_arch = "x86_64") { + Some(Self::Amd64) + } else if cfg!(target_arch = "aarch64") { + Some(Self::Arm64) + } else { + None + } + } +} + +impl Environment { + /// The ID implied by the environment's fields. + pub fn expected_id(&self) -> String { + format!( + "{}.{}{}-{}.{}.{}", + self.backend, + self.machine.os, + self.machine.version.replace('.', ""), + self.machine.arch, + self.runtime, + self.installer + ) + } + + fn validate(&self) -> Result<()> { + let expected = self.expected_id(); + ensure!( + self.id == expected, + "environment {:?} does not match its fields; expected ID {expected:?}", + self.id + ); + match self.backend { + Backend::Tmachine => ensure!( + self.tmachine.is_some(), + "environment {:?} uses the tmachine backend but has no `tmachine` section", + self.id + ), + } + Ok(()) + } + + /// Why the backend can't provision this environment on this host, if it + /// can't. tmachine boots guests of the host's architecture only. + pub fn unsupported_reason(&self, host: Option) -> Option { + match self.backend { + Backend::Tmachine if host != Some(self.machine.arch) => Some(format!( + "tmachine boots {} guests only on {} hosts", + self.machine.arch, self.machine.arch + )), + Backend::Tmachine => None, + } + } +} + +impl Catalog { + /// Load every `*.yaml` file in `dir`. + pub fn load_dir(dir: &Path) -> Result { + let mut paths: Vec<_> = std::fs::read_dir(dir) + .with_context(|| format!("read environments directory {}", dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>() + .with_context(|| format!("read environments directory {}", dir.display()))?; + paths.retain(|path| path.extension().is_some_and(|ext| ext == "yaml")); + paths.sort(); + + let mut catalog = Self::default(); + for path in paths { + let yaml = std::fs::read_to_string(&path) + .with_context(|| format!("read {}", path.display()))?; + catalog + .add_yaml(&yaml) + .with_context(|| format!("load environments from {}", path.display()))?; + } + Ok(catalog) + } + + fn add_yaml(&mut self, yaml: &str) -> Result<()> { + let file: File = serde_yml::from_str(yaml)?; + for environment in file.environments { + environment.validate()?; + if self.environments.contains_key(&environment.id) { + bail!("environment {:?} is defined more than once", environment.id); + } + self.environments + .insert(environment.id.clone(), environment); + } + Ok(()) + } + + pub fn get(&self, id: &str) -> Option<&Environment> { + self.environments.get(id) + } + + pub fn iter(&self) -> impl Iterator { + self.environments.values() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + const UBUNTU_DOCKER: &str = " +environments: + - id: tmachine.ubuntu2404-amd64.docker-rootful.deb + backend: tmachine + machine: { os: ubuntu, version: \"24.04\", arch: amd64 } + runtime: docker-rootful + installer: deb + tmachine: { environment: ubuntu-docker-rootful, installer: deb } +"; + + fn catalog(yaml: &str) -> Result { + let mut catalog = Catalog::default(); + catalog.add_yaml(yaml)?; + Ok(catalog) + } + + #[test] + fn loads_environment() { + let catalog = catalog(UBUNTU_DOCKER).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + assert_eq!(env.machine.arch, Arch::Amd64); + assert_eq!( + env.tmachine, + Some(Tmachine { + environment: "ubuntu-docker-rootful".to_string(), + installer: "deb".to_string(), + }) + ); + } + + #[test] + fn reads_the_registered_gateway_name() { + let yaml = UBUNTU_DOCKER.replace(" runtime:", " gateway: openshell\n runtime:"); + let catalog = catalog(&yaml).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + assert_eq!(env.gateway.as_deref(), Some("openshell")); + } + + #[test] + fn rejects_id_that_does_not_match_fields() { + let yaml = UBUNTU_DOCKER.replace("docker-rootful.deb", "docker-rootless.deb"); + let err = catalog(&yaml).unwrap_err(); + assert!( + err.to_string().contains("does not match its fields"), + "{err}" + ); + } + + #[test] + fn rejects_tmachine_environment_without_tmachine_section() { + let yaml = UBUNTU_DOCKER.replace( + " tmachine: { environment: ubuntu-docker-rootful, installer: deb }\n", + "", + ); + let err = catalog(&yaml).unwrap_err(); + assert!(err.to_string().contains("no `tmachine` section"), "{err}"); + } + + #[test] + fn rejects_unknown_fields() { + let yaml = UBUNTU_DOCKER.replace("runtime:", "runtme:"); + assert!(catalog(&yaml).is_err()); + } + + #[test] + fn rejects_duplicate_ids() { + let mut catalog = catalog(UBUNTU_DOCKER).unwrap(); + let err = catalog.add_yaml(UBUNTU_DOCKER).unwrap_err(); + assert!(err.to_string().contains("more than once"), "{err}"); + } + + #[test] + fn reports_arch_mismatch_as_unsupported() { + let catalog = catalog(UBUNTU_DOCKER).unwrap(); + let env = catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap(); + assert_eq!(env.unsupported_reason(Some(Arch::Amd64)), None); + assert!(env.unsupported_reason(Some(Arch::Arm64)).is_some()); + } + + #[test] + fn repository_environments_are_valid() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../environments"); + let catalog = Catalog::load_dir(&dir).unwrap(); + assert!( + catalog + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .is_some() + ); + } +} diff --git a/tests/runner/src/execute.rs b/tests/runner/src/execute.rs new file mode 100644 index 0000000000..9ce0511d2b --- /dev/null +++ b/tests/runner/src/execute.rs @@ -0,0 +1,466 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Running a job: provision the environment, run each family's bundle in it, +//! and bring the results back. +//! +//! Tests find the gateway the way the CLI does, so the runner points them at +//! the one the installer registered with `OPENSHELL_GATEWAY` and at the +//! installed CLI with `OPENSHELL_BIN`. There is no target file. +//! +//! Adapted from the executor in Evan Lezar's NVIDIA/OpenShell#4629. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::process::Output; +use std::time::Duration; + +use anyhow::{Context, Result, ensure}; +use tmachine::Session; +use tmachine::config::Config; + +use crate::bundle::{Bundle, PROFILE, READY_BINARY}; +use crate::jobs::{FamilyJob, Job}; +use crate::report::{Outcome, Status, annotate_junit, verify_junit}; + +const GUEST_ROOT: &str = "/var/tmp/openshell-tests"; +/// How long the gateway has to become healthy after the environment is up. +// Matches the installers' own wait: an HA k3s gateway booted from a cached +// disk can take minutes to report healthy. +const READY_TIMEOUT_SECS: u64 = 300; +/// How long one family's nextest run may take in total. Individual tests are +/// bounded by the nextest profile's slow-timeout; this catches a run that +/// stops making progress some other way. +const RUN_TIMEOUT: Duration = Duration::from_mins(45); +/// Slack beyond the readiness check's own timeout for SSH and process start. +const READY_SLACK: Duration = Duration::from_mins(1); + +pub struct Options<'a> { + pub tmachine: &'a Config, + pub bundles: &'a BTreeMap, + pub results: &'a Path, + pub keep: bool, +} + +/// Run one job. Every family gets an outcome, even when provisioning fails. +pub async fn run(job: &Job, options: &Options<'_>) -> Vec { + let env_id = &job.environment.id; + let outcome = |family: &FamilyJob, status| Outcome { + environment: env_id.clone(), + family: family.family.clone(), + status, + }; + + if let Some(reason) = &job.unsupported { + return job + .families + .iter() + .map(|family| outcome(family, Status::Unsupported(reason.clone()))) + .collect(); + } + if !job.families.iter().any(FamilyJob::has_tests) { + return job + .families + .iter() + .map(|family| outcome(family, Status::NothingToRun)) + .collect(); + } + + eprintln!("==> {env_id}: provisioning"); + let session = match provision(job, options.tmachine).await { + Ok(session) => session, + Err(error) => { + let message = format!("provision environment: {error:#}"); + eprintln!("==> {env_id}: {message}"); + return job + .families + .iter() + .map(|family| outcome(family, Status::Error(message.clone()))) + .collect(); + } + }; + + let mut outcomes = Vec::new(); + for family in &job.families { + if !family.has_tests() { + outcomes.push(outcome(family, Status::NothingToRun)); + continue; + } + let dir = options.results.join(env_id).join(&family.family); + eprintln!( + "==> {env_id}: running {} ({})", + family.family, + family.filterset() + ); + let status = match run_family(&session, job, family, options, &dir).await { + Ok(status) => status, + Err(error) => Status::Error(format!("{error:#}")), + }; + if status.is_failure() + && let Err(error) = session.collect_diagnostics(&dir.join("diagnostics")).await + { + eprintln!("==> {env_id}: collecting diagnostics failed: {error:#}"); + } + eprintln!("==> {env_id}: {} {status:?}", family.family); + outcomes.push(outcome(family, status)); + } + + if options.keep { + eprintln!( + "==> {env_id}: keeping the VM. Connect with `ssh -p {} tmachine@127.0.0.1` (password `tmachine`). Press Enter to tear it down.", + tmachine::ssh_port() + ); + let mut line = String::new(); + let _ = std::io::stdin().read_line(&mut line); + } + eprintln!("==> {env_id}: tearing down"); + session.down().await; + outcomes +} + +async fn provision(job: &Job, config: &Config) -> Result { + let params = job + .environment + .tmachine + .as_ref() + .context("environment has no tmachine section")?; + let environment = config + .environments + .iter() + .find(|candidate| candidate.name == params.environment) + .with_context(|| { + format!( + "tmachine environment {:?} is not defined", + params.environment + ) + })?; + let machine = config + .machines + .iter() + .find(|candidate| candidate.name == environment.machine) + .with_context(|| format!("tmachine machine {:?} is not defined", environment.machine))?; + let installer = config + .installers + .iter() + .find(|candidate| candidate.name == params.installer) + .with_context(|| format!("tmachine installer {:?} is not defined", params.installer))?; + Session::up(machine, environment, installer).await +} + +/// The environment variables that point the tests at the system under test: +/// the registered gateway (when the environment names one), the installed +/// CLI. +pub fn guest_env(job: &Job, openshell_bin: &str) -> Vec { + let mut vars = Vec::new(); + if let Some(gateway) = &job.environment.gateway { + vars.push(format!("OPENSHELL_GATEWAY={gateway}")); + } + vars.push(format!("OPENSHELL_BIN={openshell_bin}")); + vars +} + +/// Decide a family's status from nextest's exit status and its `JUnit` +/// report. The run passes only if nextest succeeded and the report has a +/// passing case for exactly the tests that were selected. +pub fn decide_status( + nextest_succeeded: bool, + junit: Option<&str>, + expected: &BTreeSet<(String, String)>, + log: &Path, +) -> Status { + let Some(xml) = junit else { + return Status::Error(format!( + "nextest produced no JUnit report (exit {}); see {}", + if nextest_succeeded { + "success" + } else { + "failure" + }, + log.display() + )); + }; + let verification = verify_junit(xml, expected); + if let Some(mismatch) = verification.mismatch() { + return Status::Error(format!( + "the JUnit report does not match the selected tests: {mismatch}; see {}", + log.display() + )); + } + let failures = u32::try_from(verification.failed.len()).unwrap_or(u32::MAX); + if failures > 0 { + Status::Failed { + tests: verification.tests, + failures, + } + } else if nextest_succeeded { + Status::Passed { + tests: verification.tests, + } + } else { + Status::Error(format!( + "nextest failed, but the JUnit report shows no failed test; see {}", + log.display() + )) + } +} + +async fn run_family( + session: &Session, + job: &Job, + family: &FamilyJob, + options: &Options<'_>, + dir: &Path, +) -> Result { + if dir.exists() { + std::fs::remove_dir_all(dir).with_context(|| format!("clear {}", dir.display()))?; + } + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + let bundle = options + .bundles + .get(&family.family) + .with_context(|| format!("no bundle was built for {}", family.family))?; + + let openshell_bin = checked( + "find the installed openshell CLI", + session.exec(&["sh", "-c", "command -v openshell"]).await?, + )?; + let openshell_bin = openshell_bin.trim(); + ensure!( + !openshell_bin.is_empty(), + "the installer did not put openshell on the guest's PATH" + ); + let env = guest_env(job, openshell_bin); + + let guest = format!("{GUEST_ROOT}/{}", family.family); + checked( + "prepare guest directory", + session + .exec(&[ + "sh", + "-c", + &format!("rm -rf {guest} && mkdir -p {guest}/extract"), + ]) + .await?, + )?; + session + .copy_in(&bundle.path, &format!("{guest}/bundle.tar")) + .await?; + checked( + "unpack bundle", + session + .exec(&["tar", "-C", &guest, "-xf", &format!("{guest}/bundle.tar")]) + .await?, + )?; + + // A booted guest restarts its gateway, so wait for it once here rather + // than in every test. Failing here is an environment error, not a test + // failure. + let ready_binary = format!("{guest}/{READY_BINARY}"); + let ready_timeout = READY_TIMEOUT_SECS.to_string(); + let mut ready = vec!["env"]; + ready.extend(env.iter().map(String::as_str)); + ready.extend([ready_binary.as_str(), ready_timeout.as_str()]); + checked( + "wait for the gateway", + session + .exec_timeout( + &ready, + Duration::from_secs(READY_TIMEOUT_SECS) + READY_SLACK, + ) + .await?, + )?; + + let filterset = family.filterset(); + let archive = format!("{guest}/tests.tar.zst"); + let extract = format!("{guest}/extract"); + let config = format!("{guest}/nextest.toml"); + let mut nextest = vec!["env"]; + nextest.extend(env.iter().map(String::as_str)); + nextest.extend([ + "cargo-nextest", + "nextest", + "run", + "--archive-file", + &archive, + "--workspace-remap", + &guest, + "--extract-to", + &extract, + "--extract-overwrite", + "--config-file", + &config, + "--profile", + PROFILE, + "--no-capture", + // Tests share one gateway and the guest has 4 GiB; run them serially + // whatever --no-capture implies. + "--test-threads", + "1", + "-E", + &filterset, + ]); + let output = session.exec_timeout(&nextest, RUN_TIMEOUT).await?; + let mut log = output.stdout.clone(); + log.extend_from_slice(&output.stderr); + let log_path = dir.join("nextest.log"); + std::fs::write(&log_path, &log)?; + + let junit = dir.join("junit.xml"); + let found = checked( + "find JUnit report", + session + .exec(&["find", &guest, "-name", "junit.xml"]) + .await?, + )?; + let xml = match found.lines().next() { + Some(path) => { + session.copy_out(path, &junit).await?; + let xml = annotate_junit(&std::fs::read_to_string(&junit)?, &family.test_ids()); + std::fs::write(&junit, &xml)?; + Some(xml) + } + None => None, + }; + + Ok(decide_status( + output.status.success(), + xml.as_deref(), + &family.expected_tests(), + &log_path, + )) +} + +fn checked(step: &str, output: Output) -> Result { + ensure!( + output.status.success(), + "{step}: exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + String::from_utf8(output.stdout).with_context(|| format!("{step}: output is not UTF-8")) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::environments::Catalog as Environments; + + fn expected(names: &[&str]) -> BTreeSet<(String, String)> { + names + .iter() + .map(|name| ("p::b".to_string(), (*name).to_string())) + .collect() + } + + fn report(cases: &[(&str, &str)]) -> String { + let body: String = cases + .iter() + .map(|(name, child)| { + format!( + "{child}" + ) + }) + .collect(); + format!("{body}") + } + + fn log() -> PathBuf { + PathBuf::from("nextest.log") + } + + #[test] + fn passes_only_when_every_selected_test_passed() { + let xml = report(&[("a", ""), ("b", "")]); + match decide_status(true, Some(&xml), &expected(&["a", "b"]), &log()) { + Status::Passed { tests } => assert_eq!(tests, 2), + other => panic!("{other:?}"), + } + } + + #[test] + fn a_missing_test_fails_the_lane_even_though_nextest_succeeded() { + let xml = report(&[("a", "")]); + match decide_status(true, Some(&xml), &expected(&["a", "b"]), &log()) { + Status::Error(message) => { + assert!(message.contains("p::b b"), "{message}"); + assert!(message.contains("does not match"), "{message}"); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn an_unselected_test_in_the_report_fails_the_lane() { + let xml = report(&[("a", ""), ("stray", "")]); + assert!(matches!( + decide_status(true, Some(&xml), &expected(&["a"]), &log()), + Status::Error(_) + )); + } + + #[test] + fn failures_are_reported_as_failed() { + let xml = report(&[("a", ""), ("b", "")]); + match decide_status(false, Some(&xml), &expected(&["a", "b"]), &log()) { + Status::Failed { tests, failures } => assert_eq!((tests, failures), (2, 1)), + other => panic!("{other:?}"), + } + } + + #[test] + fn a_missing_report_is_an_error() { + assert!(matches!( + decide_status(true, None, &expected(&["a"]), &log()), + Status::Error(_) + )); + assert!(matches!( + decide_status(false, None, &expected(&["a"]), &log()), + Status::Error(_) + )); + } + + #[test] + fn nextest_failing_without_a_failed_case_is_an_error() { + let xml = report(&[("a", "")]); + assert!(matches!( + decide_status(false, Some(&xml), &expected(&["a"]), &log()), + Status::Error(_) + )); + } + + #[test] + fn points_tests_at_the_gateway_and_the_cli() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("e.yaml"), + "environments:\n - id: tmachine.ubuntu2404-amd64.docker-rootful.deb\n backend: tmachine\n machine: { os: ubuntu, version: \"24.04\", arch: amd64 }\n runtime: docker-rootful\n installer: deb\n tmachine: { environment: ubuntu-docker-rootful, installer: deb }\n gateway: openshell\n", + ) + .unwrap(); + let environments = Environments::load_dir(dir.path()).unwrap(); + let job = Job { + environment: environments + .get("tmachine.ubuntu2404-amd64.docker-rootful.deb") + .unwrap() + .clone(), + unsupported: None, + families: Vec::new(), + }; + assert_eq!( + guest_env(&job, "/usr/bin/openshell"), + [ + "OPENSHELL_GATEWAY=openshell", + "OPENSHELL_BIN=/usr/bin/openshell", + ] + ); + + let mut job = job; + job.environment.gateway = None; + assert!( + !guest_env(&job, "/usr/bin/openshell") + .iter() + .any(|var| var.starts_with("OPENSHELL_GATEWAY=")) + ); + } +} diff --git a/tests/runner/src/github.rs b/tests/runner/src/github.rs new file mode 100644 index 0000000000..d3ad21f9de --- /dev/null +++ b/tests/runner/src/github.rs @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The CI matrix, generated from a plan. +//! +//! A workflow runs one job per entry of +//! `openshell-test-runner test --plan --emit-github-matrix`, so which +//! environments run is decided by the plan, not by a list copied into a +//! workflow. +//! Nothing checks the output in: the plan is the only copy. +//! +//! Adapted from the matrix generation in Evan Lezar's NVIDIA/OpenShell#4629. + +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; + +use crate::catalog::Catalog; +use crate::environments::{Arch, Catalog as Environments}; +use crate::jobs::{self, Narrowing}; +use crate::plan::Plan; + +/// One CI job: a plan run in one environment. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct GithubJob { + pub plan: String, + /// The environment ID, for `openshell-test-runner test --env`. + pub environment: String, + /// The GitHub runner label that can boot the environment. + pub runner: String, +} + +/// The strategy matrix a workflow reads with `fromJSON`. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct GithubMatrix { + pub include: Vec, +} + +/// The GitHub-hosted runner that can boot guests of `arch`: tmachine boots +/// guests of the host's architecture only. +pub const fn runner_label(arch: Arch) -> &'static str { + match arch { + Arch::Amd64 => "ubuntu-24.04", + Arch::Arm64 => "ubuntu-24.04-arm", + } +} + +/// Expand a plan the way a lane would and list one job per environment, +/// ignoring which host generates the matrix. +/// +/// # Errors +/// +/// Fails if the plan names an unknown family, suite or environment, or if the +/// plan selects an environment whose suites have no tests: a CI lane that +/// would pass without testing anything. +pub fn github_matrix( + plan_name: &str, + plan: &Plan, + catalog: &Catalog, + environments: &Environments, +) -> Result { + let jobs = jobs::expand(plan, catalog, environments, None, &Narrowing::default())?; + if jobs.is_empty() { + bail!("plan {plan_name} selects no tests"); + } + + let mut matrix = GithubMatrix::default(); + for job in &jobs { + let id = &job.environment.id; + if let Some(family) = job.families.iter().find(|family| !family.has_tests()) { + bail!( + "plan {plan_name} selects {id} / {}, but its suites have no tests", + family.family + ); + } + matrix.include.push(GithubJob { + plan: plan_name.to_string(), + environment: id.clone(), + runner: runner_label(job.environment.machine.arch).to_string(), + }); + } + matrix + .include + .sort_by(|a, b| a.environment.cmp(&b.environment)); + Ok(matrix) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::path::PathBuf; + + use super::*; + use crate::catalog::{Family, Suite, Test}; + + const AMD64: &str = "tmachine.ubuntu2404-amd64.docker-rootful.deb"; + const ARM64: &str = "tmachine.ubuntu2404-arm64.docker-rootful.deb"; + + fn environments() -> Environments { + let mut yaml = String::from("environments:\n"); + for arch in ["amd64", "arm64"] { + yaml.push_str(&format!( + " - id: tmachine.ubuntu2404-{arch}.docker-rootful.deb\n backend: tmachine\n machine: {{ os: ubuntu, version: \"24.04\", arch: {arch} }}\n runtime: docker-rootful\n installer: deb\n tmachine: {{ environment: ubuntu-docker-rootful, installer: deb }}\n" + )); + } + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("docker.yaml"), yaml).unwrap(); + Environments::load_dir(dir.path()).unwrap() + } + + fn catalog() -> Catalog { + let suite = Suite { + suite: "smoke".to_string(), + binary: "smoke".to_string(), + package: "pkg".to_string(), + tests: vec![Test { + id: None, + name: "only".to_string(), + description: "d".to_string(), + }], + }; + Catalog { + families: BTreeMap::from([( + "conformance".to_string(), + Family { + name: "conformance".to_string(), + workspace: PathBuf::new(), + suites: BTreeMap::from([("smoke".to_string(), suite)]), + }, + )]), + } + } + + fn plan() -> Plan { + Plan::parse(&format!( + "matrix:\n - type: conformance\n environments: [{ARM64}, {AMD64}]\n" + )) + .unwrap() + } + + fn generate() -> Result { + github_matrix("p", &plan(), &catalog(), &environments()) + } + + #[test] + fn generates_a_job_per_environment_with_its_runner() { + let matrix = generate().unwrap(); + assert_eq!( + matrix.include, + [ + GithubJob { + plan: "p".to_string(), + environment: AMD64.to_string(), + runner: "ubuntu-24.04".to_string(), + }, + GithubJob { + plan: "p".to_string(), + environment: ARM64.to_string(), + runner: "ubuntu-24.04-arm".to_string(), + }, + ] + ); + } + + #[test] + fn serializes_as_the_include_list_a_workflow_reads() { + let json = serde_json::to_value(generate().unwrap()).unwrap(); + assert_eq!(json["include"][0]["environment"], AMD64); + assert_eq!(json["include"][0]["runner"], "ubuntu-24.04"); + } +} diff --git a/tests/runner/src/jobs.rs b/tests/runner/src/jobs.rs new file mode 100644 index 0000000000..df95ce2034 --- /dev/null +++ b/tests/runner/src/jobs.rs @@ -0,0 +1,460 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Expanding a plan into jobs. +//! +//! Jobs are grouped by environment so each environment is provisioned once +//! and runs every family and suite the plan selects for it. A suite runs every +//! test in it; `#[ignore]`d tests are left out of the catalog. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Result, bail}; + +use crate::catalog::{Catalog, Suite}; +use crate::environments::{Arch, Catalog as Environments, Environment}; +use crate::plan::Plan; + +/// Everything the plan runs in one environment. +#[derive(Debug)] +pub struct Job { + pub environment: Environment, + /// Why the environment can't be provisioned on this host, if it can't. + pub unsupported: Option, + pub families: Vec, +} + +/// The suites of one family selected for an environment. +#[derive(Debug)] +pub struct FamilyJob { + pub family: String, + pub suites: Vec, +} + +#[derive(Debug)] +pub struct SuiteJob { + pub suite: Suite, + /// Names of the tests to run. + pub run: Vec, +} + +/// Filters applied on top of the plan, from the command line. +#[derive(Debug, Default)] +pub struct Narrowing { + pub environments: Vec, + pub suites: Vec, +} + +/// Validate the plan against the catalogs and expand it into jobs. +pub fn expand( + plan: &Plan, + catalog: &Catalog, + environments: &Environments, + host: Option, + narrowing: &Narrowing, +) -> Result> { + // environment ID -> family -> suite names, in plan order where it matters. + let mut selected: BTreeMap>> = BTreeMap::new(); + + for entry in &plan.matrix { + let family = catalog.family(&entry.family)?; + let suites: Vec = match &entry.suites { + Some(suites) => { + for suite in suites { + family.suite(suite)?; + } + suites.clone() + } + None => family.suites.keys().cloned().collect(), + }; + for id in &entry.environments { + if environments.get(id).is_none() { + bail!( + "unknown environment {id:?}; known environments: {}", + environments + .iter() + .map(|env| env.id.as_str()) + .collect::>() + .join(", ") + ); + } + let family_suites = selected + .entry(id.clone()) + .or_default() + .entry(entry.family.clone()) + .or_default(); + for suite in &suites { + if !family_suites.contains(suite) { + family_suites.push(suite.clone()); + } + } + } + } + + for id in &narrowing.environments { + if !selected.contains_key(id) { + bail!("--env {id:?} is not in the plan"); + } + } + for suite in &narrowing.suites { + let in_plan = selected + .values() + .flat_map(BTreeMap::values) + .any(|suites| suites.contains(suite)); + if !in_plan { + bail!("--suite {suite:?} is not in the plan"); + } + } + + let mut jobs = Vec::new(); + for (id, families) in selected { + if !narrowing.environments.is_empty() && !narrowing.environments.contains(&id) { + continue; + } + let environment = environments.get(&id).expect("validated above").clone(); + let mut family_jobs = Vec::new(); + for (family_name, suites) in families { + let family = catalog.family(&family_name)?; + let suites: Vec = suites + .iter() + .filter(|suite| narrowing.suites.is_empty() || narrowing.suites.contains(suite)) + .map(|suite| suite_job(family.suite(suite).expect("validated above"))) + .collect(); + if !suites.is_empty() { + family_jobs.push(FamilyJob { + family: family_name, + suites, + }); + } + } + if family_jobs.is_empty() { + continue; + } + jobs.push(Job { + unsupported: environment.unsupported_reason(host), + environment, + families: family_jobs, + }); + } + Ok(jobs) +} + +/// What strict mode (CI) refuses to run: nothing selected at all, an +/// environment this host can't provision, and a family with no tests. Locally these are reported and skipped, because a +/// laptop can't boot every environment; in CI each one means a lane that would +/// pass without testing what it claims to. +pub fn strict_problems(jobs: &[Job]) -> Vec { + if jobs.is_empty() { + return vec!["the plan selects no tests".to_string()]; + } + let mut problems = Vec::new(); + for job in jobs { + let id = &job.environment.id; + if let Some(reason) = &job.unsupported { + problems.push(format!("{id} is unsupported on this host: {reason}")); + continue; + } + for family in &job.families { + if !family.has_tests() { + problems.push(format!( + "{id} / {}: the selected suites have no tests, so nothing would run", + family.family + )); + } + } + } + problems +} + +fn suite_job(suite: &Suite) -> SuiteJob { + SuiteJob { + suite: suite.clone(), + run: suite.tests.iter().map(|test| test.name.clone()).collect(), + } +} + +/// The `classname` nextest gives a suite's tests in `JUnit` output, which is +/// the suite's nextest binary ID. +fn junit_classname(suite: &Suite) -> String { + format!("{}::{}", suite.package, suite.binary) +} + +impl FamilyJob { + /// Whether any test will run. + pub fn has_tests(&self) -> bool { + self.suites.iter().any(|suite| !suite.run.is_empty()) + } + + /// Stable test IDs keyed by nextest `JUnit` `(classname, name)`, for the + /// tests that have one. + pub fn test_ids(&self) -> BTreeMap<(String, String), String> { + self.suites + .iter() + .flat_map(|job| { + let classname = junit_classname(&job.suite); + job.suite.tests.iter().filter_map(move |test| { + test.id + .clone() + .map(|id| ((classname.clone(), test.name.clone()), id)) + }) + }) + .collect() + } + + /// The tests the report must contain, as nextest `JUnit` + /// `(classname, name)` pairs: every test selected to run. A missing one + /// fails the job. + pub fn expected_tests(&self) -> BTreeSet<(String, String)> { + self.suites + .iter() + .flat_map(|job| { + let classname = junit_classname(&job.suite); + job.run + .iter() + .map(move |name| (classname.clone(), name.clone())) + }) + .collect() + } + + /// The nextest filterset selecting exactly the tests to run. + pub fn filterset(&self) -> String { + self.suites + .iter() + .filter(|suite| !suite.run.is_empty()) + .map(|suite| { + // Name the package too: other packages in the family can have test + // binaries with the same name. + format!( + "binary_id(={}::{})", + suite.suite.package, suite.suite.binary + ) + }) + .collect::>() + .join(" | ") + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::catalog::{Family, Test}; + + fn catalog() -> Catalog { + let suite = |name: &str, tests: &[&str]| Suite { + suite: name.to_string(), + binary: name.replace('-', "_"), + package: format!("pkg-{name}"), + tests: tests + .iter() + .enumerate() + .map(|(index, name)| Test { + id: Some(format!("t-{index:08}")), + name: (*name).to_string(), + description: "d".to_string(), + }) + .collect(), + }; + let family = Family { + name: "conformance".to_string(), + workspace: PathBuf::new(), + suites: [ + suite("sandbox-lifecycle", &["long_running", "expose"]), + suite("providers", &["create_provider"]), + ] + .into_iter() + .map(|suite| (suite.suite.clone(), suite)) + .collect(), + }; + Catalog { + families: [("conformance".to_string(), family)].into(), + } + } + + fn environments() -> Environments { + use std::fmt::Write as _; + + let mut yaml = String::from("environments:\n"); + for arch in ["amd64", "arm64"] { + let _ = write!( + yaml, + " - id: tmachine.ubuntu2404-{arch}.docker-rootful.deb\n backend: tmachine\n machine: {{ os: ubuntu, version: \"24.04\", arch: {arch} }}\n runtime: docker-rootful\n installer: deb\n tmachine: {{ environment: ubuntu-docker-rootful, installer: deb }}\n" + ); + } + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("docker.yaml"), yaml).unwrap(); + Environments::load_dir(dir.path()).unwrap() + } + + const AMD64: &str = "tmachine.ubuntu2404-amd64.docker-rootful.deb"; + const ARM64: &str = "tmachine.ubuntu2404-arm64.docker-rootful.deb"; + + fn expand_plan(yaml: &str, narrowing: &Narrowing) -> Result> { + expand( + &Plan::parse(yaml).unwrap(), + &catalog(), + &environments(), + Some(Arch::Amd64), + narrowing, + ) + } + + #[test] + fn a_suite_runs_every_test_in_it() { + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n suites: [sandbox-lifecycle]\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap(); + assert_eq!(jobs.len(), 1); + let suite = &jobs[0].families[0].suites[0]; + assert_eq!(suite.run, ["long_running", "expose"]); + assert_eq!( + jobs[0].families[0].filterset(), + "binary_id(=pkg-sandbox-lifecycle::sandbox_lifecycle)" + ); + } + + #[test] + fn omitted_suites_select_the_whole_family() { + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap(); + let suites: Vec<_> = jobs[0].families[0] + .suites + .iter() + .map(|suite| suite.suite.suite.as_str()) + .collect(); + assert_eq!(suites, ["providers", "sandbox-lifecycle"]); + assert_eq!( + jobs[0].families[0].filterset(), + "binary_id(=pkg-providers::providers) | binary_id(=pkg-sandbox-lifecycle::sandbox_lifecycle)" + ); + } + + #[test] + fn groups_entries_by_environment() { + let jobs = expand_plan( + &format!( + "matrix:\n - type: conformance\n suites: [providers]\n environments: [{AMD64}, {ARM64}]\n - type: conformance\n suites: [providers, sandbox-lifecycle]\n environments: [{AMD64}]\n" + ), + &Narrowing::default(), + ) + .unwrap(); + assert_eq!(jobs.len(), 2); + let amd64 = jobs.iter().find(|job| job.environment.id == AMD64).unwrap(); + assert_eq!(amd64.families.len(), 1); + assert_eq!(amd64.families[0].suites.len(), 2); + assert!(amd64.unsupported.is_none()); + let arm64 = jobs.iter().find(|job| job.environment.id == ARM64).unwrap(); + assert!(arm64.unsupported.is_some()); + } + + #[test] + fn narrows_to_environment_and_suite() { + let narrowing = Narrowing { + environments: vec![AMD64.to_string()], + suites: vec!["providers".to_string()], + }; + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n environments: [{AMD64}, {ARM64}]\n"), + &narrowing, + ) + .unwrap(); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].families[0].suites.len(), 1); + assert_eq!( + jobs[0].families[0].filterset(), + "binary_id(=pkg-providers::providers)" + ); + } + + #[test] + fn rejects_unknown_names() { + let unknown_env = expand_plan( + "matrix:\n - type: conformance\n environments: [nope]\n", + &Narrowing::default(), + ) + .unwrap_err(); + assert!( + unknown_env.to_string().contains("unknown environment"), + "{unknown_env}" + ); + + let unknown_suite = expand_plan( + &format!("matrix:\n - type: conformance\n suites: [sandbox-lifecyle]\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap_err(); + assert!( + unknown_suite + .to_string() + .contains("unknown conformance suite"), + "{unknown_suite}" + ); + + let unknown_family = expand_plan( + &format!("matrix:\n - type: features\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap_err(); + assert!( + unknown_family.to_string().contains("unknown test family"), + "{unknown_family}" + ); + + let narrowing = Narrowing { + environments: vec![ARM64.to_string()], + suites: vec![], + }; + let not_in_plan = expand_plan( + &format!("matrix:\n - type: conformance\n environments: [{AMD64}]\n"), + &narrowing, + ) + .unwrap_err(); + assert!( + not_in_plan.to_string().contains("not in the plan"), + "{not_in_plan}" + ); + } + + #[test] + fn expected_tests_are_the_ones_in_the_suite() { + let jobs = expand_plan( + &format!("matrix:\n - type: conformance\n suites: [providers]\n environments: [{AMD64}]\n"), + &Narrowing::default(), + ) + .unwrap(); + let family = &jobs[0].families[0]; + assert_eq!( + family.expected_tests().into_iter().collect::>(), + [( + "pkg-providers::providers".to_string(), + "create_provider".to_string() + )] + ); + } + + #[test] + fn strict_mode_rejects_unsupported_environments_and_empty_selections() { + let plan = format!( + "matrix:\n - type: conformance\n suites: [sandbox-lifecycle]\n environments: [{AMD64}, {ARM64}]\n" + ); + let jobs = expand_plan(&plan, &Narrowing::default()).unwrap(); + let problems = strict_problems(&jobs); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains(ARM64), "{problems:?}"); + assert!( + problems[0].contains("unsupported on this host"), + "{problems:?}" + ); + + assert_eq!( + strict_problems(&[]), + ["the plan selects no tests".to_string()] + ); + } +} diff --git a/tests/runner/src/lib.rs b/tests/runner/src/lib.rs new file mode 100644 index 0000000000..25a3bfa9fa --- /dev/null +++ b/tests/runner/src/lib.rs @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Runs `OpenShell` test plans: expands a plan into jobs, provisions each +//! environment through its backend, runs the selected suites inside it, and +//! reports the results. + +pub mod bundle; +pub mod catalog; +pub mod environments; +pub mod execute; +pub mod github; +pub mod jobs; +pub mod plan; +pub mod report; diff --git a/tests/runner/src/main.rs b/tests/runner/src/main.rs new file mode 100644 index 0000000000..b7a75aeaa7 --- /dev/null +++ b/tests/runner/src/main.rs @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `openshell-test-runner`: run `OpenShell` test plans. +//! +//! Run it through `nix run .#openshell-test-runner -- `, which +//! provides the guest cross-compilation toolchain, QEMU, Ansible, and the +//! tmachine configuration. +//! +//! Adapted from the runner in Evan Lezar's NVIDIA/OpenShell#4629. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; +use openshell_test_runner::bundle::{self, Bundle}; +use openshell_test_runner::catalog::Catalog; +use openshell_test_runner::environments::{Arch, Catalog as Environments}; +use openshell_test_runner::execute::{self, Options}; +use openshell_test_runner::github; +use openshell_test_runner::jobs::{self, Job, Narrowing}; +use openshell_test_runner::plan::Plan; +use openshell_test_runner::report; + +#[derive(Parser)] +#[command(name = "openshell-test-runner", about = "Run OpenShell test plans")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run a test plan. + Test { + /// The plan to run, e.g. tests/plans/cpu-tests.yaml. + #[arg(long)] + plan: PathBuf, + /// Print the resolved jobs without building or provisioning anything. + #[arg(long)] + dry_run: bool, + /// Print the GitHub Actions matrix for the plan as JSON, one job per + /// environment, and exit. The matrix does not depend on this host. + #[arg(long)] + emit_github_matrix: bool, + /// CI mode: fail instead of skipping an environment this host can't + /// provision, an environment where no test is left to run, or a plan + /// that selects nothing. + #[arg( + long, + env = "OPENSHELL_TEST_STRICT", + value_parser = clap::builder::BoolishValueParser::new(), + default_value_t = false, + num_args = 0..=1, + default_missing_value = "true" + )] + strict: bool, + /// Keep each environment running after its tests until Enter is pressed. + #[arg(long)] + keep: bool, + /// Only run these environments from the plan. + #[arg(long = "env", value_name = "ID")] + environments: Vec, + /// Only run these suites from the plan. + #[arg(long = "suite", value_name = "NAME")] + suites: Vec, + /// Results directory [default: artifacts/test-results/]. + #[arg(long)] + results: Option, + }, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + // A SIGTERM from CI cancellation must not leave QEMU holding the SSH port. + tmachine::cleanup_on_signals(); + match run(Cli::parse()).await { + Ok(code) => code, + Err(error) => { + eprintln!("error: {error:#}"); + ExitCode::FAILURE + } + } +} + +async fn run(cli: Cli) -> Result { + let root = repo_root()?; + let catalog = Catalog::load(&root)?; + let environments_catalog = Environments::load_dir(&root.join("tests/environments"))?; + + match cli.command { + Command::Test { + plan, + dry_run, + emit_github_matrix, + strict, + keep, + environments, + suites, + results, + } => { + let plan_path = plan; + let plan = Plan::load(&plan_path)?; + + if emit_github_matrix { + let name = plan_path + .file_stem() + .and_then(|stem| stem.to_str()) + .context("plan path has no file name")?; + let matrix = github::github_matrix(name, &plan, &catalog, &environments_catalog)?; + println!("{}", serde_json::to_string(&matrix)?); + return Ok(ExitCode::SUCCESS); + } + + let host = Arch::host(); + let narrowing = Narrowing { + environments, + suites, + }; + let jobs = jobs::expand(&plan, &catalog, &environments_catalog, host, &narrowing)?; + print_jobs(&jobs); + if strict { + let problems = jobs::strict_problems(&jobs); + if !problems.is_empty() { + bail!( + "strict mode refuses this plan:\n {}", + problems.join("\n ") + ); + } + } + if dry_run { + return Ok(ExitCode::SUCCESS); + } + + // Absolute, because `load_tmachine` changes the working directory. + let results = match results { + Some(results) => std::path::absolute(&results) + .with_context(|| format!("resolve {}", results.display()))?, + None => root.join("artifacts/test-results").join( + plan_path + .file_stem() + .context("plan path has no file name")?, + ), + }; + std::fs::create_dir_all(&results) + .with_context(|| format!("create {}", results.display()))?; + std::fs::write( + results.join("suites.json"), + serde_json::to_string_pretty(&catalog)?, + )?; + + let bundles = build_bundles(&root, &jobs, &catalog, host)?; + let tmachine = load_tmachine(&root)?; + let options = Options { + tmachine: &tmachine, + bundles: &bundles, + results: &results, + keep, + }; + let mut outcomes = Vec::new(); + for job in &jobs { + outcomes.extend(execute::run(job, &options).await); + } + + eprintln!("{}", report::summary(&outcomes)); + eprintln!("Results: {}", results.display()); + Ok( + if outcomes.iter().any(|outcome| outcome.status.is_failure()) { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + }, + ) + } + } +} + +fn print_jobs(jobs: &[Job]) { + for job in jobs { + eprintln!("{}", job.environment.id); + if let Some(reason) = &job.unsupported { + eprintln!(" unsupported on this host: {reason}"); + } + for family in &job.families { + eprintln!(" {}: -E '{}'", family.family, family.filterset()); + for suite in &family.suites { + if suite.run.is_empty() { + eprintln!(" {}: no tests to run", suite.suite.suite); + } else { + eprintln!(" {}: {}", suite.suite.suite, suite.run.join(", ")); + } + } + } + } +} + +/// Build one bundle per family, covering every suite that will run. +fn build_bundles( + root: &Path, + jobs: &[Job], + catalog: &Catalog, + host: Option, +) -> Result> { + let mut suites: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + for job in jobs.iter().filter(|job| job.unsupported.is_none()) { + for family in &job.families { + for suite in family.suites.iter().filter(|suite| !suite.run.is_empty()) { + suites + .entry(family.family.as_str()) + .or_default() + .insert(suite.suite.suite.as_str()); + } + } + } + + let mut bundles = BTreeMap::new(); + if suites.is_empty() { + return Ok(bundles); + } + let target = bundle::guest_target(host.context("the host architecture is not supported")?); + eprintln!("==> building {} for {target}", bundle::READY_BINARY); + let ready = bundle::build_ready(root, target)?; + for (family_name, suite_names) in suites { + let family = catalog.family(family_name)?; + let binaries = suite_names + .iter() + .map(|name| family.suite(name).map(|suite| suite.binary.clone())) + .collect::>()?; + eprintln!("==> building {family_name} bundle for {target}"); + let bundle = bundle::build( + family, + &family.packages(suite_names.iter().copied()), + &binaries, + target, + &ready, + )?; + bundles.insert(family_name.to_string(), bundle); + } + Ok(bundles) +} + +/// Load the tmachine configuration and prepare the process to drive it. +/// +/// tmachine configs name playbooks and inputs relative to `tests/tmachine/`, +/// and its Ansible runs read `ANSIBLE_CONFIG`. +fn load_tmachine(root: &Path) -> Result { + let path = std::env::var_os("TMACHINE_CONFIG").context( + "TMACHINE_CONFIG is not set; run through `nix run .#openshell-test-runner`, which provides it", + )?; + let config = tmachine::config::Config::load(Path::new(&path))?; + let tmachine_dir = root.join("tests/tmachine"); + std::env::set_current_dir(&tmachine_dir) + .with_context(|| format!("enter {}", tmachine_dir.display()))?; + if std::env::var_os("ANSIBLE_CONFIG").is_none() { + // SAFETY: single-threaded at this point; no other thread reads the + // environment concurrently. + unsafe { + std::env::set_var("ANSIBLE_CONFIG", tmachine_dir.join("ansible/ansible.cfg")); + }; + } + Ok(config) +} + +fn repo_root() -> Result { + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("run git rev-parse")?; + anyhow::ensure!( + output.status.success(), + "not inside the OpenShell repository" + ); + Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim())) +} diff --git a/tests/runner/src/plan.rs b/tests/runner/src/plan.rs new file mode 100644 index 0000000000..1ae1d43056 --- /dev/null +++ b/tests/runner/src/plan.rs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Test plans: `tests/plans/*.yaml`. +//! +//! ```yaml +//! matrix: +//! - type: conformance +//! suites: [smoke] # omit for every suite in the family +//! environments: [tmachine.ubuntu2404-amd64.docker-rootful.deb] +//! ``` + +use std::path::Path; + +use anyhow::{Context, Result, ensure}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Plan { + pub matrix: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Entry { + /// The test family, e.g. `conformance`. + #[serde(rename = "type")] + pub family: String, + /// Suites to run. `None` selects every suite in the family. + #[serde(default)] + pub suites: Option>, + pub environments: Vec, +} + +impl Plan { + pub fn load(path: &Path) -> Result { + let yaml = std::fs::read_to_string(path) + .with_context(|| format!("read plan {}", path.display()))?; + Self::parse(&yaml).with_context(|| format!("load plan {}", path.display())) + } + + pub fn parse(yaml: &str) -> Result { + let plan: Self = serde_yml::from_str(yaml)?; + ensure!(!plan.matrix.is_empty(), "plan has an empty matrix"); + for (index, entry) in plan.matrix.iter().enumerate() { + ensure!( + !entry.environments.is_empty(), + "matrix entry {index} ({}) names no environments", + entry.family + ); + ensure!( + entry + .suites + .as_ref() + .is_none_or(|suites| !suites.is_empty()), + "matrix entry {index} ({}) has an empty suite list; omit `suites` to select every suite", + entry.family + ); + } + Ok(plan) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_plan() { + let plan = Plan::parse( + "matrix:\n - type: conformance\n suites: [smoke]\n environments: [e1, e2]\n - type: conformance\n environments: [e3]\n", + ) + .unwrap(); + assert_eq!(plan.matrix.len(), 2); + assert_eq!(plan.matrix[0].family, "conformance"); + assert_eq!( + plan.matrix[0].suites.as_deref(), + Some(&["smoke".to_string()][..]) + ); + assert_eq!(plan.matrix[1].suites, None); + } + + #[test] + fn rejects_unknown_fields() { + let err = + Plan::parse("matrix:\n - type: conformance\n suite: [x]\n environments: [e]\n") + .unwrap_err(); + assert!(format!("{err:#}").contains("suite"), "{err:#}"); + } + + #[test] + fn rejects_entry_without_environments() { + let err = + Plan::parse("matrix:\n - type: conformance\n environments: []\n").unwrap_err(); + assert!(err.to_string().contains("names no environments"), "{err}"); + } + + #[test] + fn rejects_empty_suite_list() { + let err = + Plan::parse("matrix:\n - type: conformance\n suites: []\n environments: [e]\n") + .unwrap_err(); + assert!(err.to_string().contains("empty suite list"), "{err}"); + } +} diff --git a/tests/runner/src/report.rs b/tests/runner/src/report.rs new file mode 100644 index 0000000000..54244401dc --- /dev/null +++ b/tests/runner/src/report.rs @@ -0,0 +1,467 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Job outcomes, `JUnit` result checking, and the run summary. +//! +//! The `JUnit` check is what keeps a lane honest: a job passes only if its +//! report has a passing case for every test the runner selected. A test that +//! is missing from the report, skipped, or not expected fails the job, so +//! dropping a test (a bad filter, a renamed function, a crashed binary) +//! cannot pass silently. +//! +//! Adapted from the report in Evan Lezar's NVIDIA/OpenShell#4629. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; + +/// The outcome of one family in one environment. +#[derive(Debug)] +pub struct Outcome { + pub environment: String, + pub family: String, + pub status: Status, +} + +#[derive(Debug)] +pub enum Status { + Passed { + tests: u32, + }, + Failed { + tests: u32, + failures: u32, + }, + /// The environment can't be provisioned on this host. + Unsupported(String), + /// The selected suites have no tests. + NothingToRun, + /// The runner could not complete the job, or the results did not match + /// what was expected. + Error(String), +} + +impl Status { + pub const fn is_failure(&self) -> bool { + matches!(self, Self::Failed { .. } | Self::Error(_)) + } +} + +/// How one test case ended, from a `JUnit` report. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CaseResult { + Passed, + Failed, + Skipped, +} + +/// One test case in a `JUnit` report. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Case { + pub classname: String, + pub name: String, + pub result: CaseResult, +} + +/// A `JUnit` report compared with the tests that were expected. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Verification { + /// Test cases in the report. + pub tests: u32, + /// Expected tests that failed. + pub failed: Vec, + /// Expected tests with no passing or failing case: absent from the + /// report, or skipped. + pub missing: Vec, + /// Cases in the report that nothing selected. + pub unexpected: Vec, +} + +impl Verification { + /// Whether the report has a passing case for exactly the expected tests. + pub fn is_clean(&self) -> bool { + self.failed.is_empty() && self.missing.is_empty() && self.unexpected.is_empty() + } + + /// The mismatch between the report and the selection, if any, as a + /// message. Failures are not a mismatch: they are the tests' own result. + pub fn mismatch(&self) -> Option { + let mut parts = Vec::new(); + if !self.missing.is_empty() { + parts.push(format!( + "expected tests missing from the report or skipped: {}", + self.missing.join(", ") + )); + } + if !self.unexpected.is_empty() { + parts.push(format!( + "tests in the report that were not selected: {}", + self.unexpected.join(", ") + )); + } + (!parts.is_empty()).then(|| parts.join("; ")) + } +} + +/// The test cases in a `JUnit` report written by nextest. +pub fn parse_cases(xml: &str) -> Vec { + let mut cases = Vec::new(); + let mut rest = xml; + while let Some(start) = rest.find("') else { + break; + }; + let tag_end = start + len; + let tag = &rest[start..tag_end]; + let (body, next) = if tag.ends_with('/') { + ("", tag_end + 1) + } else { + let close = rest[tag_end..] + .find("") + .map_or(rest.len(), |offset| tag_end + offset); + (&rest[tag_end + 1..close], close) + }; + if let (Some(classname), Some(name)) = (attr(tag, "classname"), attr(tag, "name")) { + let result = if body.contains(") -> Verification { + let cases = parse_cases(xml); + let mut verification = Verification { + tests: u32::try_from(cases.len()).unwrap_or(u32::MAX), + ..Verification::default() + }; + let label = |classname: &str, name: &str| format!("{classname} {name}"); + + let mut seen: BTreeSet<(&str, &str)> = BTreeSet::new(); + for case in &cases { + let key = (case.classname.as_str(), case.name.as_str()); + seen.insert(key); + let is_expected = expected.contains(&(case.classname.clone(), case.name.clone())); + if !is_expected { + // A skipped case that was never expected is an `#[ignore]` test: + // nextest lists it, and it did not run, so it is not a problem. + if case.result != CaseResult::Skipped { + verification + .unexpected + .push(label(&case.classname, &case.name)); + } + continue; + } + match case.result { + CaseResult::Passed => {} + CaseResult::Failed => verification.failed.push(label(&case.classname, &case.name)), + CaseResult::Skipped => verification + .missing + .push(label(&case.classname, &case.name)), + } + } + for (classname, name) in expected { + if !seen.contains(&(classname.as_str(), name.as_str())) { + verification.missing.push(label(classname, name)); + } + } + verification +} + +/// The `JUnit` property that carries a test's stable ID. +pub const TEST_ID_PROPERTY: &str = "test_id"; + +/// Add each test's stable ID to its `` as a property. +/// +/// `ids` maps a nextest `(classname, name)` pair (`::`, test +/// name) to the test's ID. Test cases not in `ids` are left unchanged. The +/// property is the test case's first child, where pytest's +/// `record_property` puts it. +pub fn annotate_junit(xml: &str, ids: &BTreeMap<(String, String), String>) -> String { + let mut out = String::with_capacity(xml.len()); + let mut rest = xml; + while let Some(start) = rest.find("') else { + break; + }; + let end = start + len; + let tag = &rest[start..end]; + out.push_str(&rest[..start]); + let key = attr(tag, "classname").zip(attr(tag, "name")); + match key.and_then(|(classname, name)| ids.get(&(classname.to_string(), name.to_string()))) + { + Some(id) => { + let properties = format!( + "" + ); + match tag.strip_suffix('/') { + Some(open) => { + let _ = write!(out, "{open}>{properties}"); + } + None => { + let _ = write!(out, "{tag}>{properties}"); + } + } + } + None => { + out.push_str(tag); + out.push('>'); + } + } + rest = &rest[end + 1..]; + } + out.push_str(rest); + out +} + +/// An attribute value from a start tag. nextest escapes `"` in values. +fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let key = format!(" {name}=\""); + let start = tag.find(&key)? + key.len(); + let end = start + tag[start..].find('"')?; + Some(&tag[start..end]) +} + +fn unescape(value: &str) -> String { + value + .replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +/// A human-readable summary of every outcome. +pub fn summary(outcomes: &[Outcome]) -> String { + let mut out = String::from("\nSummary\n"); + for outcome in outcomes { + let status = match &outcome.status { + Status::Passed { tests } => format!("PASS {tests} passed"), + Status::Failed { tests, failures } => { + format!("FAIL {failures} of {tests} failed") + } + Status::Unsupported(reason) => format!("UNSUPPORTED {reason}"), + Status::NothingToRun => "UNSUPPORTED the selected suites have no tests".to_string(), + Status::Error(error) => format!("ERROR {error}"), + }; + let _ = writeln!( + out, + " {} / {}: {status}", + outcome.environment, outcome.family + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const REPORT: &str = r#" + + + + + + ok +"#; + + fn expected(names: &[&str]) -> BTreeSet<(String, String)> { + names + .iter() + .map(|name| ("p::b".to_string(), (*name).to_string())) + .collect() + } + + #[test] + fn parses_cases_and_their_results() { + let cases = parse_cases(REPORT); + let results: Vec<_> = cases + .iter() + .map(|case| (case.name.as_str(), case.result)) + .collect(); + assert_eq!( + results, + [ + ("passed", CaseResult::Passed), + ("failed", CaseResult::Failed), + ("skipped", CaseResult::Skipped), + ("extra", CaseResult::Passed), + ] + ); + assert!(cases.iter().all(|case| case.classname == "p::b")); + } + + #[test] + fn a_report_with_exactly_the_expected_passing_tests_is_clean() { + let verification = verify_junit(REPORT, &expected(&["passed", "extra"])); + // `failed` was not expected, so it is unexpected. `skipped` was not + // expected either, which is what an `#[ignore]` test looks like, so it + // is not reported. + assert_eq!(verification.unexpected.len(), 1, "{verification:?}"); + assert!( + verification.unexpected[0].ends_with("failed"), + "{verification:?}" + ); + let clean = verify_junit( + r#""#, + &expected(&["a"]), + ); + assert!(clean.is_clean(), "{clean:?}"); + assert_eq!(clean.tests, 1); + assert_eq!(clean.mismatch(), None); + } + + #[test] + fn a_missing_test_is_a_mismatch() { + let verification = verify_junit(REPORT, &expected(&["passed", "extra", "gone"])); + assert_eq!(verification.missing, ["p::b gone"]); + let message = verification.mismatch().unwrap(); + assert!(message.contains("missing from the report or skipped: p::b gone")); + } + + #[test] + fn a_skipped_test_counts_as_missing() { + let verification = verify_junit(REPORT, &expected(&["passed", "skipped"])); + assert_eq!(verification.missing, ["p::b skipped"]); + } + + #[test] + fn a_failed_test_is_a_failure_not_a_mismatch() { + let verification = verify_junit(REPORT, &expected(&["passed", "failed"])); + assert_eq!(verification.failed, ["p::b failed"]); + assert!(verification.missing.is_empty()); + // `skipped` and `extra` were not expected. + assert!(verification.mismatch().unwrap().contains("not selected")); + } + + #[test] + fn an_empty_report_misses_every_expected_test() { + let verification = verify_junit("", &expected(&["a", "b"])); + assert_eq!(verification.tests, 0); + assert_eq!(verification.missing.len(), 2); + } + + #[test] + fn names_are_unescaped() { + let cases = parse_cases(r#""#); + assert_eq!(cases[0].name, "a&b"); + } + + #[test] + fn annotates_test_cases_with_ids() { + let xml = r#" + + + + "#; + let ids = BTreeMap::from([ + ( + ("p::b".to_string(), "passed".to_string()), + "t-00000001".to_string(), + ), + ( + ("p::b".to_string(), "failed".to_string()), + "t-00000002".to_string(), + ), + ]); + let out = annotate_junit(xml, &ids); + assert!(out.contains( + r#""# + ), "{out}"); + assert!(out.contains( + r#""# + ), "{out}"); + assert!( + out.contains(r#""#), + "{out}" + ); + assert!(out.ends_with(""), "{out}"); + } + + #[test] + fn annotated_reports_still_verify() { + let xml = r#""#; + let ids = BTreeMap::from([( + ("p::b".to_string(), "a".to_string()), + "t-00000001".to_string(), + )]); + let annotated = annotate_junit(xml, &ids); + assert!(verify_junit(&annotated, &expected(&["a"])).is_clean()); + } + + #[test] + fn summarizes_outcomes() { + let text = summary(&[ + Outcome { + environment: "env-a".to_string(), + family: "conformance".to_string(), + status: Status::Passed { tests: 2 }, + }, + Outcome { + environment: "env-b".to_string(), + family: "conformance".to_string(), + status: Status::Unsupported("arch".to_string()), + }, + ]); + assert!( + text.contains("env-a / conformance: PASS 2 passed"), + "{text}" + ); + assert!( + text.contains("env-b / conformance: UNSUPPORTED arch"), + "{text}" + ); + } + + /// A report written by the nextest version the guest installs + /// (`ansible/playbooks/nextest.yaml`) with the runner's profile, for a + /// suite with a passing, a failing and an `#[ignore]` test. nextest leaves + /// the ignored test out of the report entirely. + const NEXTEST_REPORT: &str = include_str!("../testdata/nextest-0.9.143-junit.xml"); + + #[test] + fn verifies_a_real_nextest_report() { + let expected: BTreeSet<_> = [ + ("sandbox::smoke", "create_exec_delete"), + ("sandbox::smoke", "fails"), + ] + .into_iter() + .map(|(classname, name)| (classname.to_string(), name.to_string())) + .collect(); + let verification = verify_junit(NEXTEST_REPORT, &expected); + assert_eq!(verification.tests, 2); + assert_eq!(verification.failed, ["sandbox::smoke fails"]); + assert!(verification.missing.is_empty(), "{verification:?}"); + assert!(verification.unexpected.is_empty(), "{verification:?}"); + } + + #[test] + fn annotates_a_real_nextest_report() { + let ids = BTreeMap::from([( + ("sandbox::smoke".to_string(), "fails".to_string()), + "t-0123abcd".to_string(), + )]); + let annotated = annotate_junit(NEXTEST_REPORT, &ids); + assert!( + annotated.contains( + r#"classname="sandbox::smoke" timestamp="2026-10-22T20:54:54.894-07:00" time="0.026">"# + ), + "{annotated}" + ); + // Annotating doesn't change what the report says. + assert_eq!(parse_cases(&annotated), parse_cases(NEXTEST_REPORT)); + } +} diff --git a/tests/runner/testdata/nextest-0.9.143-junit.xml b/tests/runner/testdata/nextest-0.9.143-junit.xml new file mode 100644 index 0000000000..f0542e08a2 --- /dev/null +++ b/tests/runner/testdata/nextest-0.9.143-junit.xml @@ -0,0 +1,33 @@ + + + + + + thread 'fails' (63231268) panicked at sandbox/tests/smoke.rs:7:5: +assertion `left == right` failed: sandbox phase + left: 1 + right: 2 +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + +running 1 test +some stdout +test fails ... FAILED + +failures: + +failures: + fails + +test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s + + + +thread 'fails' (63231268) panicked at sandbox/tests/smoke.rs:7:5: +assertion `left == right` failed: sandbox phase + left: 1 + right: 2 +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + + + diff --git a/tests/runner/tests/strict.rs b/tests/runner/tests/strict.rs new file mode 100644 index 0000000000..eec27fc151 --- /dev/null +++ b/tests/runner/tests/strict.rs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Strict mode reaches the plan check from both the flag and the environment +//! variable CI can set. The cpu-tests plan only has an amd64 environment, so +//! on any other host strict mode must refuse it and a plain run must not. + +use std::path::Path; +use std::process::{Command, Output}; + +fn dry_run(strict_flag: Option<&str>, strict_env: Option<&str>) -> Output { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut command = Command::new(env!("CARGO_BIN_EXE_openshell-test-runner")); + command + .current_dir(&root) + .args(["test", "--plan", "tests/plans/cpu-tests.yaml", "--dry-run"]) + .env_remove("OPENSHELL_TEST_STRICT"); + if let Some(flag) = strict_flag { + command.arg(flag); + } + if let Some(value) = strict_env { + command.env("OPENSHELL_TEST_STRICT", value); + } + command.output().expect("run openshell-test-runner") +} + +fn assert_strict(output: &Output, strict: bool) { + let stderr = String::from_utf8_lossy(&output.stderr); + let refused = strict && !cfg!(target_arch = "x86_64"); + assert_eq!(output.status.success(), !refused, "{stderr}"); + assert_eq!( + stderr.contains("strict mode refuses this plan"), + refused, + "{stderr}" + ); +} + +#[test] +fn strict_mode_is_off_by_default() { + assert_strict(&dry_run(None, None), false); + assert_strict(&dry_run(None, Some("false")), false); + assert_strict(&dry_run(Some("--strict=false"), None), false); +} + +#[test] +fn strict_mode_is_on_from_the_flag_or_the_environment() { + assert_strict(&dry_run(Some("--strict"), None), true); + assert_strict(&dry_run(None, Some("true")), true); +} From b06ac283d331f1d875a81b41b6958d658b34e7ee Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 21:20:38 -0700 Subject: [PATCH 06/16] ci(tests): lint and test the runner, tmachine and conformance suite Run fmt, clippy and nextest for tests/tmachine and tests/runner, and fmt and clippy for the new `sandbox` suite crate, wherever tests/testkit already runs them: the mise tasks and Branch Checks. The suite's tests need a gateway, so they run through `mise run e2e:testkit` against a local Docker gateway and, on CI, through the test runner rather than the unit-test job. The lockfile task and the Cargo lockfiles job also run the alignment check, and its own tests run with `mise run test`. Signed-off-by: Matthew Grossman --- .github/workflows/branch-checks.yml | 12 +++++++++++- tasks/rust.toml | 16 ++++++++++++++-- tasks/test.toml | 11 +++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 218d3d2657..f5af915600 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -222,6 +222,9 @@ jobs: cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all -- --check cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/tmachine/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/runner/Cargo.toml --all -- --check + cargo fmt --manifest-path tests/suites/conformance/Cargo.toml -p sandbox -- --check cargo-lockfiles: name: Cargo lockfiles @@ -239,7 +242,9 @@ jobs: - name: Verify Cargo lockfiles shell: nix develop -c bash -euo pipefail {0} - run: tasks/scripts/check-cargo-lockfiles.sh + run: | + tasks/scripts/check-cargo-lockfiles.sh + uv run tasks/scripts/check_cargo_lockfile_alignment.py rust-lint: name: Rust lint (${{ matrix.system }}) @@ -279,6 +284,9 @@ jobs: cargo clippy --locked --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path examples/isolation-backend-v8/Cargo.toml --all-targets -- -D warnings cargo clippy --locked --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/tmachine/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/runner/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path tests/suites/conformance/Cargo.toml -p sandbox --all-targets -- -D warnings rust-test: name: Rust test (${{ matrix.system }}) @@ -328,6 +336,8 @@ jobs: cargo nextest run --locked --profile ci --workspace --features openshell-server/test-support cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path tests/testkit/Cargo.toml + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path tests/tmachine/Cargo.toml + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path tests/runner/Cargo.toml rust-postgres-test: name: Rust PostgreSQL tests diff --git a/tasks/rust.toml b/tasks/rust.toml index 682e478fca..cc9e7f661f 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -18,8 +18,11 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts hide = true ["rust:lockfiles:check"] -description = "Verify all tracked Cargo lockfiles are current" -run = "tasks/scripts/check-cargo-lockfiles.sh" +description = "Verify all tracked Cargo lockfiles are current and aligned with the root" +run = [ + "tasks/scripts/check-cargo-lockfiles.sh", + "uv run tasks/scripts/check_cargo_lockfile_alignment.py", +] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/check-cargo-lockfiles.ps1" hide = true @@ -34,6 +37,9 @@ run = [ "cargo clippy --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path examples/isolation-backend-v8/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path tests/testkit/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/tmachine/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/runner/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path tests/suites/conformance/Cargo.toml -p sandbox --all-targets -- -D warnings", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -47,6 +53,9 @@ run = [ "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all", "cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all", "cargo fmt --manifest-path tests/testkit/Cargo.toml --all", + "cargo fmt --manifest-path tests/tmachine/Cargo.toml --all", + "cargo fmt --manifest-path tests/runner/Cargo.toml --all", + "cargo fmt --manifest-path tests/suites/conformance/Cargo.toml -p sandbox", ] hide = true @@ -59,6 +68,9 @@ run = [ "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/isolation-backend-v8/Cargo.toml --all -- --check", "cargo fmt --manifest-path tests/testkit/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/tmachine/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/runner/Cargo.toml --all -- --check", + "cargo fmt --manifest-path tests/suites/conformance/Cargo.toml -p sandbox -- --check", ] hide = true diff --git a/tasks/test.toml b/tasks/test.toml index a5a2a03928..c08578ff35 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -23,8 +23,13 @@ depends = [ "test:docs-website", "test:docs-nav", "test:maintainer-approval", + "test:cargo-lockfile-alignment", ] +["test:cargo-lockfile-alignment"] +description = "Test the Cargo lockfile alignment check" +run = "uv run --no-project --with pytest pytest tasks/scripts/check_cargo_lockfile_alignment_test.py" + ["test:docs-website"] description = "Test the docs-website sync script" # --no-project skips installing the OpenShell package; --with supplies the test @@ -120,6 +125,8 @@ run = [ "cargo test -p openshell-server --features test-support", "cargo nextest run --config-file .config/nextest.toml --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", "cargo nextest run --config-file .config/nextest.toml --manifest-path tests/testkit/Cargo.toml", + "cargo nextest run --config-file .config/nextest.toml --manifest-path tests/tmachine/Cargo.toml", + "cargo nextest run --config-file .config/nextest.toml --manifest-path tests/runner/Cargo.toml", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" hide = true @@ -160,6 +167,10 @@ run = "OPENSHELL_TEST_CONTAINER_ENGINE=docker e2e/with-docker-gateway.sh cargo t description = "Run the shared OCI image feature suite against a Podman-backed gateway" run = "OPENSHELL_TEST_CONTAINER_ENGINE=podman e2e/with-podman-gateway.sh cargo test --locked --manifest-path tests/suites/features/Cargo.toml -p openshell-test-feature-oci-image -- --test-threads 1 --nocapture" +["e2e:testkit"] +description = "Run the testkit suites in tests/suites/conformance against a Docker-backed gateway" +run = "e2e/with-docker-gateway.sh cargo nextest run --locked --manifest-path tests/suites/conformance/Cargo.toml --workspace --exclude openshell-test-conformance-cli --test-threads 1 --no-fail-fast" + ["e2e:rust:push"] description = "Run CLI conformance and policy e2e tests against a Docker gateway that pushes configuration" depends = ["e2e:conformance:build"] From 07d46fb5d9ecdabbef36442f6f6b12011ff53c6d Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 21:22:24 -0700 Subject: [PATCH 07/16] ci(tests): run test plans from a reusable E2E Suites workflow Add a reusable workflow that takes a plan. A planning job runs `openshell-test-runner test --plan --emit-github-matrix`, and one job per environment, on the runner label the matrix gives, runs `nix run .#test-runner -- test --plan --env --strict`. The jobs use the same integration inputs as the existing tmachine lanes, cache the tmachine disks keyed on the Ansible tree, upload the JUnit reports and logs whether or not the run passes, and an aggregator job reports the overall result. Branch E2E calls it for tests/plans/cpu-tests.yaml when the test:e2e label is set. The job is not in the Core E2E result's needs yet, so it starts out advisory. The existing conformance, feature and driver lanes are unchanged. Modeled on the E2E Suites workflow in #4629. Co-authored-by: Evan Lezar Signed-off-by: Matthew Grossman --- .agents/skills/watch-github-actions/SKILL.md | 2 + .github/workflows/branch-e2e.yml | 16 ++ .github/workflows/e2e-suites.yml | 153 +++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 .github/workflows/e2e-suites.yml diff --git a/.agents/skills/watch-github-actions/SKILL.md b/.agents/skills/watch-github-actions/SKILL.md index d1f00347ad..c86c8688c3 100644 --- a/.agents/skills/watch-github-actions/SKILL.md +++ b/.agents/skills/watch-github-actions/SKILL.md @@ -127,6 +127,8 @@ gh run list --json databaseId,status,headBranch,url --jq '.[] | {id: .databaseId For tmachine Kubernetes conformance failures, inspect the `tmachine-diagnostics-*` artifact. The K3s report includes Agent Sandbox controller state, leader leases, events, and current and previous controller logs. The conformance playbook checks reconciliation after guest boot with a temporary scheduling-gated Sandbox probe before running tests. Distinguish a failed controller probe from a failed conformance scenario; gateway connectivity and controller Deployment availability do not prove reconciliation works. +For test plan lanes (`plan-cpu-tests` and other `e2e-suites.yml` callers), start with the runner's `Summary` block in the job log, which gives PASS, FAIL or ERROR per environment and family. Then inspect the `e2e-results--` artifact: `suites.json` lists what the runner selected, and `//` holds `junit.xml`, `nextest.log` and, after a failure, the guest's `diagnostics/`. + `Pre-release` temporarily omits Codex Security from its `Security Scan` job. `Release` uses the passing prerelease qualification result. Other scanners and qualification gates still run. Use the standalone diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index db4c96b936..ca1a8f8b7f 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -303,6 +303,22 @@ jobs: {"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"driver-podman"} ] + # Run the tests in tests/plans/cpu-tests.yaml. The plan decides which suites + # run in which environments, so adding coverage means editing the plan, not + # this workflow. Not yet in core-e2e-result.needs: it starts out advisory. + plan-cpu-tests: + needs: [pr_metadata, prepare-integration] + if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' + permissions: + actions: read + contents: read + packages: read + uses: ./.github/workflows/e2e-suites.yml + with: + plan: cpu-tests + source-sha: ${{ needs.prepare-integration.outputs.source_sha }} + integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} + docker-e2e: needs: [pr_metadata, build-binaries, build-images] if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true' diff --git a/.github/workflows/e2e-suites.yml b/.github/workflows/e2e-suites.yml new file mode 100644 index 0000000000..670918ca6c --- /dev/null +++ b/.github/workflows/e2e-suites.yml @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs a test plan (tests/plans/.yaml) on CI. The plan decides which +# suites run in which environments; this workflow keeps no list of either. +# `openshell-test-runner test --emit-github-matrix` turns the plan into one job per +# environment, and each job runs the plan's tests in that environment with +# `openshell-test-runner test --env --strict`. +# +# Modeled on the E2E Suites workflow in NVIDIA/OpenShell#4629 by Evan Lezar. + +name: E2E Suites + +on: + workflow_call: + inputs: + plan: + description: Plan to run, the file stem of a plan in tests/plans/ + required: true + type: string + source-sha: + description: Source revision of the prepared candidate artifacts + required: true + type: string + integration-inputs-artifact-id: + description: Immutable ID of the prepared integration inputs artifact + required: true + type: string + +permissions: + actions: read + contents: read + packages: read + +# github.workflow is the caller's name here, so scope the group to this plan to +# avoid colliding with (and cancelling) the calling workflow's other jobs. +concurrency: + group: ${{ github.workflow }}-e2e-suites-${{ inputs.plan }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + plan: + name: Plan ${{ inputs.plan }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs['source-sha'] }} + persist-credentials: false + + - uses: ./.github/actions/setup-nix + with: + prepare-shell: "true" + + - name: Generate the job matrix from the plan + id: matrix + shell: nix develop -c bash -euo pipefail {0} + env: + PLAN: ${{ inputs.plan }} + run: | + matrix=$(cargo run --locked --quiet \ + --manifest-path tests/runner/Cargo.toml --bin openshell-test-runner -- \ + test --plan "tests/plans/${PLAN}.yaml" --emit-github-matrix) + echo "$matrix" + echo "matrix=${matrix}" >> "$GITHUB_OUTPUT" + + suites: + name: E2E suites (${{ matrix.plan }}, ${{ matrix.environment }}) + needs: plan + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs['source-sha'] }} + persist-credentials: false + + - name: Enable KVM access + run: | + set -euo pipefail + if [[ ! -c /dev/kvm ]]; then + echo "::error::The runner did not expose /dev/kvm" + exit 1 + fi + sudo chmod 0666 /dev/kvm + exec 3<>/dev/kvm + exec 3>&- + + - uses: ./.github/actions/setup-nix + + - name: Cache tmachine disks + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/.cache/tmachine + # tmachine keys its disk layers on a hash of the Ansible tree. + # actions/cache doesn't save after an exact key hit, so a fixed key + # would rebuild the changed layers on every run. Key on the playbooks + # and fall back to the newest entry for the same runner. + key: tmachine-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('tests/tmachine/ansible/**', 'tests/tmachine/images.nix') }} + restore-keys: | + tmachine-${{ runner.os }}-${{ runner.arch }}- + + - name: Download integration inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs['integration-inputs-artifact-id'] }} + path: artifacts + + - name: Run the plan in this environment + env: + PLAN: ${{ matrix.plan }} + ENVIRONMENT: ${{ matrix.environment }} + run: | + # Retry dependency preparation, then run the plan once. The runner + # builds the suites for the guest, so it needs no prepared archives. + # --strict fails the job instead of skipping an environment or a + # selection that would run no tests. + nix build --no-link .#openshell-test-runner || nix build --no-link .#openshell-test-runner + nix run .#openshell-test-runner -- test \ + --plan "tests/plans/${PLAN}.yaml" \ + --env "${ENVIRONMENT}" \ + --strict \ + --results artifacts/test-results + + # The results directory holds each family's JUnit report (junit.xml), + # the nextest log, and the guest's diagnostics when a run failed. + - name: Upload results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: e2e-results-${{ matrix.plan }}-${{ matrix.environment }} + path: artifacts/test-results + if-no-files-found: ignore + retention-days: 7 + + result: + name: E2E suites result (${{ inputs.plan }}) + needs: [plan, suites] + if: always() + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/check-job-results + with: + results: ${{ toJSON(needs) }} From 7828f353ce7fde9c895d6c44a4473994fc1dc448 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 21:23:12 -0700 Subject: [PATCH 08/16] docs(tests): document test plans, the runner and the conformance suite Describe how a plan runs on CI and locally in TESTING.md and a new tests/plans/README.md, add a README for the sandbox suite, point the testkit README at the runner, and add rows for tests/suites, tests/tmachine, tests/runner, tests/plans and tests/environments to the project-structure tables in AGENTS.md and CONTRIBUTING.md. Signed-off-by: Matthew Grossman --- AGENTS.md | 4 ++ CONTRIBUTING.md | 4 ++ TESTING.md | 84 ++++++++++++++++++++++ tests/plans/README.md | 73 +++++++++++++++++++ tests/suites/conformance/sandbox/README.md | 56 +++++++++++++++ tests/testkit/README.md | 6 ++ 6 files changed, 227 insertions(+) create mode 100644 tests/plans/README.md create mode 100644 tests/suites/conformance/sandbox/README.md diff --git a/AGENTS.md b/AGENTS.md index 26997d5c28..a84860b80d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,10 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `sdk/typescript/` | TypeScript SDK | Native Connect client, curated sandbox API, and generated protobuf types | | `proto/` | Protobuf definitions | gRPC service contracts | | `tests/testkit/` | E2E testkit | Support library, `#[testkit::test]` attribute, and `testkit-spec` parser for SDK-driven E2E suites | +| `tests/suites/` | E2E suites | One Cargo workspace per test family; `conformance/sandbox` is the first testkit suite | +| `tests/tmachine/` | Test machines | QEMU machines provisioned with Ansible, and the `Session` API that drives them | +| `tests/runner/` | Test runner | `openshell-test-runner`: expands plans into jobs, runs them in tmachine environments, checks JUnit results, emits the CI matrix | +| `tests/plans/`, `tests/environments/` | Test plans | Plans pair suite families with environments; environments say what each provides | | `deploy/` | Docker, Helm, K8s | Dockerfiles, Helm chart, manifests | | `docs/` | Published docs | MDX pages, navigation, and content assets | | `fern/` | Docs site config | Fern site config, components, and theme assets | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4dfea77b67..7d21c76ac1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -345,6 +345,10 @@ These are the primary `mise` tasks for day-to-day development: | `proto/` | Protocol buffer definitions and [public API conventions](proto/README.md) | | `tasks/` | `mise` task definitions and build scripts | | `tests/testkit/` | Support library for SDK-driven end-to-end suites | +| `tests/suites/` | End-to-end suites, one Cargo workspace per family (`conformance/sandbox` runs through a plan) | +| `tests/tmachine/` | QEMU test machines provisioned with Ansible, and the `Session` API that drives them | +| `tests/runner/` | `openshell-test-runner`: expands test plans into jobs, runs them in tmachine environments, and checks the results | +| `tests/plans/`, `tests/environments/` | Test plans, and the environments they run in | | `deploy/` | Dockerfiles, Helm chart, Kubernetes manifests | | `docs/` | Published Fern docs source, navigation, and content assets | | `fern/` | Fern site config, components, and theme assets | diff --git a/TESTING.md b/TESTING.md index e6754df317..1b6799a8d6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -17,6 +17,11 @@ python/openshell/ # Python unit tests (*_test.py suffix) e2e/python/ # Python E2E tests (test_*.py prefix) e2e/rust/ # Rust CLI E2E tests tests/testkit/ # Support library for SDK-driven E2E suites +tests/suites/conformance/ # Testkit suites (the `sandbox` crate) beside the older CLI conformance crate +tests/tmachine/ # QEMU test machines, Ansible playbooks, and the Session API +tests/runner/ # openshell-test-runner: runs test plans in environments +tests/plans/ # Test plans: which suites run in which environments +tests/environments/ # Environment definitions ``` ## Rust Tests @@ -140,6 +145,85 @@ by `OPENSHELL_BIN`. See [tests/testkit/README.md](tests/testkit/README.md) for how to write and run tests. +Suites live under `tests/suites//`. Each family is a Cargo workspace +with its own lockfile, and a family name is what a plan's `type` selects. +`tests/suites/conformance/sandbox` is the first testkit suite; it has one smoke +test. Run it against a local Docker gateway with: + +```shell +mise run e2e:testkit +``` + +That task runs `e2e/with-docker-gateway.sh`, which starts a gateway and exports +`OPENSHELL_GATEWAY` and `OPENSHELL_BIN`, so the suite runs the same way the +runner runs it in a guest. + +Workspaces that build root crates through path dependencies must lock shared +crates at the versions in the root `Cargo.lock`; otherwise CI can build against +dependencies the Nix toolchain doesn't support. `mise run rust:lockfiles:check` +verifies this, and `uv run tasks/scripts/check_cargo_lockfile_alignment.py --fix` +re-locks a workspace from the root lockfile. Add new testkit workspaces to +`ALIGNED_WORKSPACES` in that script. + +### Test plans and the runner (`tests/runner/`) + +`openshell-test-runner` runs testkit suites in provisioned environments. It is the +runner and plan layer from NVIDIA/OpenShell#4629 by Evan Lezar, on top of the +testkit and tmachine here. A plan selects which suites run in which environments. The plan is the only list of +what runs where: CI reads it, so no workflow or playbook repeats it. + +- **Plans** (`tests/plans/*.yaml`) pair a family (a workspace under + `tests/suites/`, such as `conformance`) and optionally some of its suites with + environment IDs. See [tests/plans/README.md](tests/plans/README.md). +- **Environments** (`tests/environments/*.yaml`) name the tmachine environment and + installer that provision them and the gateway name the installer registers. The + runner points the tests at that gateway and the installed CLI with + `OPENSHELL_GATEWAY` and `OPENSHELL_BIN`. +- **Test IDs** are optional: `#[testkit::test(id = "t-0123abcd")]` gives a test + an identity that survives renames. There is no per-environment skipping. A test + that cannot pass everywhere belongs in a different family, and a flaky or + broken one is marked `#[ignore = ""]`, which drops it everywhere. +- **Results** are checked, not trusted. A job passes only if nextest succeeds and + its JUnit report has a passing case for exactly the tests the runner selected. + A test missing from the report, or skipped, fails the job. An `#[ignore]` test + is left out of what a lane expects, so it can't fail the lane on its own. That + is the only way to take a broken or flaky test out of a lane, and it does so in every + environment: give the reason as a link to the tracking issue, + `#[ignore = "https://github.com/NVIDIA/OpenShell/issues/"]`. +- **Strict mode** (`--strict`, or `OPENSHELL_TEST_STRICT=true`) is for CI. It fails + instead of skipping an environment the host can't provision, a family + with no tests, or a plan that selects nothing. A family whose selected tests + are all ignored has no tests, so strict mode fails it: the plan pairs that + environment with suites that have nothing to run. Without it the + runner reports those and exits successfully, so a laptop can run the part of a + plan it can boot. + +```shell +nix run .#openshell-test-runner -- test --plan tests/plans/cpu-tests.yaml --dry-run +nix run .#openshell-test-runner -- test --plan tests/plans/cpu-tests.yaml --strict +``` + +`nix run .#openshell-test-runner` cross-builds the suites for the guest, boots +each environment through tmachine (this needs KVM), runs nextest in the guest +over SSH, and writes JUnit reports, logs and gateway diagnostics to +`artifacts/test-results//`. Every SSH and nextest step has a timeout, and +the runner kills its VMs if it receives SIGTERM. Two runs on one host need +different `TMACHINE_SSH_PORT` values (default 2222). The installer inputs, such +as the Debian package and the images, come from `artifacts/`, built by +`nix run .#build-artifacts` or downloaded by CI. + +**CI runs the plan.** `.github/workflows/e2e-suites.yml` takes a plan name. It +runs `openshell-test-runner test --plan tests/plans/.yaml --emit-github-matrix`, +which prints the `{"include": [...]}` a workflow reads with `fromJSON`: one job +per environment with the runner label that can boot it. Each job then runs +`nix run .#openshell-test-runner -- test --plan ... --env --strict` and +uploads its JUnit reports and logs. Branch E2E calls the workflow for `cpu-tests` when the +`test:e2e` label is set. To change what CI runs, edit the plan. + +The older conformance, feature and driver lanes in `.github/workflows/` are still +written by hand and run through the tmachine testsuites in +`tests/tmachine/config.nix`. They move to plans as their suites move to testkit. + ### Python E2E (`e2e/python/`) `mise run e2e:python` builds `openshell/e2e-python:dev` from diff --git a/tests/plans/README.md b/tests/plans/README.md new file mode 100644 index 0000000000..ba1fd6436e --- /dev/null +++ b/tests/plans/README.md @@ -0,0 +1,73 @@ +# Test plans + +A plan lists which test suites run in which environments. CI and local runs read +the same file, and nothing else repeats it: a workflow does not carry a copy of +the lane list, and a suite does not carry a playbook that says where it runs. + +```yaml +matrix: + - type: conformance # a family: a workspace under tests/suites/ + suites: [smoke] # optional; omit to run every suite in the family + environments: + - tmachine.ubuntu2404-amd64.docker-rootful.deb # IDs from tests/environments/ +``` + +A family is a Cargo workspace directly under `tests/suites/` with at least one +testkit suite. A suite is a test file in a crate's `tests/` directory containing +`#[testkit::test]` functions. Its name is the file name without `.rs`, with `_` +replaced by `-`: `tests/file_transfer.rs` is the suite `file-transfer`. + +| Plan | Runs | +|---|---| +| `cpu-tests.yaml` | The `conformance` family on Ubuntu 24.04 amd64 with rootful Docker and the Debian package | + +## How a plan runs on CI + +Branch E2E calls `.github/workflows/e2e-suites.yml` with the plan name when the +`test:e2e` label is set. + +1. A planning job runs + `openshell-test-runner test --plan tests/plans/.yaml --emit-github-matrix`. It + prints one job per environment, with the GitHub runner label that can boot it. +2. Each job downloads the integration inputs the tmachine lanes use (the Debian + and RPM packages and the images), then runs + `nix run .#openshell-test-runner -- test --plan tests/plans/.yaml --env --strict`. +3. The runner builds the family's tests for the guest, boots the environment + with tmachine, and runs nextest in it with `OPENSHELL_GATEWAY` set to the + gateway the installer registered and `OPENSHELL_BIN` set to the installed CLI. It checks the JUnit + report against the tests it selected: a missing or skipped test fails the job. +4. The job uploads the JUnit reports and logs, and an aggregator job reports the + result. + +Adding an environment or a suite to a plan changes what CI runs with no workflow +change. Arm64 is not defined yet: it needs a runner that can boot guests with KVM and +arm64 build inputs. `tests/environments/docker.yaml` says how to add it. + +## Running a plan locally + +Print what a plan would run, without building or booting anything: + +```shell +nix run .#openshell-test-runner -- test --plan tests/plans/cpu-tests.yaml --dry-run +``` + +Running it for real boots QEMU guests, so it needs KVM and the installer inputs in +`artifacts/` (`nix run .#build-artifacts`). The runner skips an environment the +host cannot boot unless you pass `--strict`, which is what CI does. + +When debugging, `--env ` and `--suite ` narrow the run to part of the +plan, and `--keep` leaves each VM running after its tests until you press +Enter, so you can inspect it: + +```shell +nix run .#openshell-test-runner -- test --plan tests/plans/cpu-tests.yaml \ + --env tmachine.ubuntu2404-amd64.docker-rootful.deb --suite smoke --keep +``` + +To run a suite against a gateway on your machine, without a guest, use the +Docker gateway wrapper. It exports `OPENSHELL_GATEWAY` and `OPENSHELL_BIN`, which +is how the runner points tests at the system under test: + +```shell +mise run e2e:testkit +``` diff --git a/tests/suites/conformance/sandbox/README.md b/tests/suites/conformance/sandbox/README.md new file mode 100644 index 0000000000..5e73fb2207 --- /dev/null +++ b/tests/suites/conformance/sandbox/README.md @@ -0,0 +1,56 @@ +# Sandbox conformance suite + +The `sandbox` crate holds testkit suites for sandbox behavior that every compute +driver must provide. It is a member of the `conformance` workspace, which a plan +selects with `type: conformance`. Each file in `tests/` is one suite, named after +the file with `_` replaced by `-` (`file_transfer.rs` is `file-transfer`). + +| Suite | Covers | +|---|---| +| `smoke` | Create a sandbox, run `echo` in it, delete it | + +The suite is small on purpose. It proves that a plan can run a real test through +the runner on CI. The older suites move to testkit by adding suites here (or a +new family under `tests/suites/`) and listing them in a plan. + +## Running it + +Against a local Docker gateway: + +```shell +mise run e2e:testkit +``` + +Against any gateway the CLI can reach, with the testkit environment variables +described in [tests/testkit/README.md](../../../testkit/README.md): + +```shell +OPENSHELL_GATEWAY= cargo nextest run --locked \ + --manifest-path tests/suites/conformance/Cargo.toml -p sandbox --test-threads 1 +``` + +On CI, `tests/plans/cpu-tests.yaml` runs it through +[the plan runner](../../../plans/README.md). + +## Adding a test + +Add a `#[testkit::test]` function to a suite, or a new file in `tests/` for a new +area. No playbook, test archive or workflow entry is needed: the runner discovers +tests from source and the plan selects them. + +## What a test may assert + +These tests run on every compute driver, so they check the public contract and +leave driver behavior alone. + +- Assert only what the public contract promises: the proto comments and the + published docs. +- Assert `phase` and `exit_code`. Don't assert condition reasons. +- Check that something did not happen by reading state, not by sleeping. +- Every environment must pass every test here. If a behavior is left to the + driver, the test belongs in a `features` or `drivers` suite instead. +- Use the CLI for upload, download, connect and forward. Rely on its exit code + and `--output json`. +- Add a testkit helper instead of calling raw gRPC from a test. + +Guidance adapted from NVIDIA/OpenShell#4629 by Evan Lezar. diff --git a/tests/testkit/README.md b/tests/testkit/README.md index ddd26b011e..0c1d7f2f9d 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -105,6 +105,12 @@ time or the CLI does not report it connected. ephemeral gateway and export `OPENSHELL_GATEWAY` and `OPENSHELL_BIN`, so a suite runs under them unchanged. +`mise run e2e:testkit` does this for the suites in `tests/suites/conformance`. +On CI the test runner (`tests/runner/`) does the same for a guest: it sets +`OPENSHELL_GATEWAY` to the gateway the installer registered, `OPENSHELL_BIN` to +the installed CLI, then runs the plan's suites with nextest. See [TESTING.md](../../TESTING.md) and +[tests/plans/README.md](../plans/README.md). + ## Checks ```shell From 217b54c283d141949f37d3c1645808bd7e265e02 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 22:00:08 -0700 Subject: [PATCH 09/16] test(conformance): add a mise task for the conformance suite on Docker `mise run e2e:testkit` runs the conformance sandbox suite against a Docker-backed gateway started by e2e/with-docker-gateway.sh, the same way the runner runs it in a guest. It is the quick way to run the suite locally. Signed-off-by: Matthew Grossman --- tasks/test.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tasks/test.toml b/tasks/test.toml index c08578ff35..7e7de8b13f 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -159,6 +159,10 @@ hide = true description = "Run Rust CLI e2e tests against a Docker-backed gateway" run = "e2e/rust/e2e-docker.sh" +["e2e:testkit"] +description = "Run the testkit suites in tests/suites/conformance against a Docker-backed gateway" +run = "e2e/with-docker-gateway.sh cargo test --locked --manifest-path tests/suites/conformance/Cargo.toml -p sandbox -- --test-threads 1 --nocapture" + ["e2e:oci-image"] description = "Run the shared OCI image feature suite against a Docker-backed gateway" run = "OPENSHELL_TEST_CONTAINER_ENGINE=docker e2e/with-docker-gateway.sh cargo test --locked --manifest-path tests/suites/features/Cargo.toml -p openshell-test-feature-oci-image -- --test-threads 1 --nocapture" @@ -167,10 +171,6 @@ run = "OPENSHELL_TEST_CONTAINER_ENGINE=docker e2e/with-docker-gateway.sh cargo t description = "Run the shared OCI image feature suite against a Podman-backed gateway" run = "OPENSHELL_TEST_CONTAINER_ENGINE=podman e2e/with-podman-gateway.sh cargo test --locked --manifest-path tests/suites/features/Cargo.toml -p openshell-test-feature-oci-image -- --test-threads 1 --nocapture" -["e2e:testkit"] -description = "Run the testkit suites in tests/suites/conformance against a Docker-backed gateway" -run = "e2e/with-docker-gateway.sh cargo nextest run --locked --manifest-path tests/suites/conformance/Cargo.toml --workspace --exclude openshell-test-conformance-cli --test-threads 1 --no-fail-fast" - ["e2e:rust:push"] description = "Run CLI conformance and policy e2e tests against a Docker gateway that pushes configuration" depends = ["e2e:conformance:build"] From be103d68035dc5deced62d5d722ae12f9cbc39df Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Thu, 22 Oct 2026 09:53:08 -0700 Subject: [PATCH 10/16] build(cargo): re-lock the conformance suite without testkit's tonic dependency Signed-off-by: Matthew Grossman --- tests/suites/conformance/Cargo.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index f371505f97..8e7d2d564d 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -2013,7 +2013,6 @@ dependencies = [ "serde", "serde_json", "tokio", - "tonic", ] [[package]] From 78813fc9dab328072646bad5658f05d3c3577886 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 22:07:03 -0700 Subject: [PATCH 11/16] feat(testkit): track sandboxes that tests create through the CLI Add cx.track and cx.adopt, so a test that creates a sandbox through the CLI still cleans it up after a failure: track the name before the command, adopt it afterwards for an SDK handle. Signed-off-by: Matthew Grossman --- tests/testkit/README.md | 4 +++ tests/testkit/testkit/src/context.rs | 25 +++++++++++++++++++ ...s-ready.yaml => k3s-controller-ready.yaml} | 0 3 files changed, 29 insertions(+) rename tests/tmachine/ansible/playbooks/{conformance/k3s-ready.yaml => k3s-controller-ready.yaml} (100%) diff --git a/tests/testkit/README.md b/tests/testkit/README.md index 0c1d7f2f9d..3dcfb54798 100644 --- a/tests/testkit/README.md +++ b/tests/testkit/README.md @@ -64,6 +64,10 @@ test creates, such as providers, in the test itself. - `cx.sandbox(suffix)` to build and create a sandbox with a run-unique name. The returned `Sandbox` can wait for ready or finished, stop, start, exec, read its config and draft policy, set a setting, and delete. +- `cx.track(name)` to register a sandbox created some other way, such as + through the CLI, for cleanup. Call it before the + create, so a create that fails after the gateway accepted it is still cleaned + up. `cx.adopt(name)` returns a `Sandbox` handle for one the CLI created. - `cx.expect_listed` and `cx.expect_absent` to check a sandbox in list and get. - `cx.cli([...])` to run the installed CLI against the same gateway, with `success()`, `failure()`, and `json()` on the output. diff --git a/tests/testkit/testkit/src/context.rs b/tests/testkit/testkit/src/context.rs index 24e3f0a37a..9d4b4a0ff5 100644 --- a/tests/testkit/testkit/src/context.rs +++ b/tests/testkit/testkit/src/context.rs @@ -101,6 +101,31 @@ impl Context { } } + /// Track a sandbox the test is about to create through the CLI, so cleanup + /// deletes it even if the test fails after the gateway accepted the create. + /// Call this before the CLI command; use [`Context::adopt`] afterwards. + pub fn track(&self, name: &str) { + lock(&self.sandboxes).insert(name.to_string()); + } + + /// A handle to a sandbox created outside the builder, e.g. by the CLI. + /// It is tracked for cleanup like a built one. + pub async fn adopt(&self, name: &str) -> Result { + self.track(name); + let found = self + .client + .get_sandbox(name) + .await + .context(format!("get sandbox {name}"))?; + Ok(Sandbox { + client: self.client.clone(), + grpc: self.grpc.clone(), + sandboxes: Arc::clone(&self.sandboxes), + name: name.to_string(), + id: found.id, + }) + } + /// Fail unless the sandbox is gone from both get and list. pub async fn expect_absent(&self, sandbox: &Sandbox) -> Result { let name = sandbox.name(); diff --git a/tests/tmachine/ansible/playbooks/conformance/k3s-ready.yaml b/tests/tmachine/ansible/playbooks/k3s-controller-ready.yaml similarity index 100% rename from tests/tmachine/ansible/playbooks/conformance/k3s-ready.yaml rename to tests/tmachine/ansible/playbooks/k3s-controller-ready.yaml From 46ce7097604c3d6aa867fd95360f90565f279d52 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 22:15:37 -0700 Subject: [PATCH 12/16] test(conformance): port the CLI, lifecycle, file-transfer and persistent-workspace suites to testkit Add the lifecycle, cli, persistent_workspace and file_transfer test files to the sandbox crate. The lifecycle, persistent-workspace and file-transfer tests are adapted from NVIDIA/OpenShell#4629. persistent_workspace_survives_stop_start stays in conformance: every conformant driver keeps the workspace across stop and start, and the one that can't yet (MXC) isn't conformant until it can. The policy-advisor suite needs no port: it is a filter over the existing conformance tests that no workflow runs. The OCI image, Keycloak and Podman userns suites are ported separately, in the features and drivers families. The old conformance/cli crate stays until the release lanes move to plans. Co-authored-by: Evan Lezar Signed-off-by: Matthew Grossman --- tests/suites/conformance/Cargo.lock | 3 + tests/suites/conformance/sandbox/Cargo.toml | 3 + tests/suites/conformance/sandbox/README.md | 18 +- tests/suites/conformance/sandbox/tests/cli.rs | 170 +++++++ .../sandbox/tests/file_transfer.rs | 420 ++++++++++++++++++ .../conformance/sandbox/tests/lifecycle.rs | 165 +++++++ .../sandbox/tests/persistent_workspace.rs | 63 +++ 7 files changed, 837 insertions(+), 5 deletions(-) create mode 100644 tests/suites/conformance/sandbox/tests/cli.rs create mode 100644 tests/suites/conformance/sandbox/tests/file_transfer.rs create mode 100644 tests/suites/conformance/sandbox/tests/lifecycle.rs create mode 100644 tests/suites/conformance/sandbox/tests/persistent_workspace.rs diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index 8e7d2d564d..b6f46bf9d4 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -2685,6 +2685,9 @@ name = "sandbox" version = "0.0.0" dependencies = [ "openshell-testkit", + "serde_json", + "tempfile", + "tokio", ] [[package]] diff --git a/tests/suites/conformance/sandbox/Cargo.toml b/tests/suites/conformance/sandbox/Cargo.toml index 52ec79cbba..d213c6b23a 100644 --- a/tests/suites/conformance/sandbox/Cargo.toml +++ b/tests/suites/conformance/sandbox/Cargo.toml @@ -13,3 +13,6 @@ publish = false [dependencies] openshell-testkit = { path = "../../../testkit/testkit" } +serde_json = "1" +tempfile = "3" +tokio = { version = "1", features = ["time"] } diff --git a/tests/suites/conformance/sandbox/README.md b/tests/suites/conformance/sandbox/README.md index 5e73fb2207..a39eb1f5f8 100644 --- a/tests/suites/conformance/sandbox/README.md +++ b/tests/suites/conformance/sandbox/README.md @@ -8,10 +8,17 @@ the file with `_` replaced by `-` (`file_transfer.rs` is `file-transfer`). | Suite | Covers | |---|---| | `smoke` | Create a sandbox, run `echo` in it, delete it | +| `lifecycle` | SDK: long-running and run-to-completion sandboxes, the default command, restart of a stopped sandbox, deleting a stopped sandbox | +| `cli` | The same lifecycle through the `openshell` CLI: status, create, get, list, exec, stop, start, delete | +| `persistent-workspace` | Files in `/sandbox` survive stop and start | +| `file-transfer` | `sandbox upload` and `download`: round trips, `.gitignore` filtering, workspace-escape and option-looking names | -The suite is small on purpose. It proves that a plan can run a real test through -the runner on CI. The older suites move to testkit by adding suites here (or a -new family under `tests/suites/`) and listing them in a plan. +`smoke` is the smallest end-to-end check that a plan can run a real test through +the runner on CI. The other suites are ports of the old `conformance/cli` +scenarios and of Evan Lezar's suites in NVIDIA/OpenShell#4629 (credited in each +file). `file-transfer` runs `git` on the machine that runs the tests, so a test +environment needs it installed (`tests/tmachine/ansible/playbooks/test-tools.yaml` +does that for the tmachine environments). ## Running it @@ -47,8 +54,9 @@ leave driver behavior alone. published docs. - Assert `phase` and `exit_code`. Don't assert condition reasons. - Check that something did not happen by reading state, not by sleeping. -- Every environment must pass every test here. If a behavior is left to the - driver, the test belongs in a `features` or `drivers` suite instead. +- Don't test behavior that only some drivers have. A test that can't pass on + every environment isn't conformance; put it in `tests/suites/features` or + `tests/suites/drivers`. - Use the CLI for upload, download, connect and forward. Rely on its exit code and `--output json`. - Add a testkit helper instead of calling raw gRPC from a test. diff --git a/tests/suites/conformance/sandbox/tests/cli.rs b/tests/suites/conformance/sandbox/tests/cli.rs new file mode 100644 index 0000000000..d26eed9bb5 --- /dev/null +++ b/tests/suites/conformance/sandbox/tests/cli.rs @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox lifecycle through the `openshell` CLI. +//! +//! Ported from the `smoke` and `sandbox-lifecycle` scenarios of the old +//! `conformance/cli` suite. The other lifecycle tests act through the SDK; these +//! check that the CLI commands for create, get, list, exec, stop, start, and +//! delete work against the same gateway. + +use serde_json::Value; +use testkit::Context; + +const PAGE_SIZE: &str = "1000"; +/// More pages than any test gateway should have; stops a repeated token from +/// looping forever. +const MAX_PAGES: usize = 1000; + +/// `openshell status` succeeds, and a sandbox created with the CLI is +/// retrievable and Ready, appears in `sandbox list`, runs `sandbox exec`, and is +/// gone from `sandbox list` after `sandbox delete`. +#[testkit::test] +async fn cli_create_exec_list_delete(cx: &mut Context) -> testkit::Result { + cx.cli(["status"]).await?.success()?; + + let name = cx.name("cs"); + cx.track(&name); + cx.cli(["sandbox", "create", "--name", &name, "--detach"]) + .await? + .success()?; + + let sandbox = cx.adopt(&name).await?; + let got: Value = cx + .cli(["sandbox", "get", &name, "--output", "json"]) + .await? + .success()? + .json()?; + testkit::ensure!( + got["name"] == name.as_str(), + "sandbox get returned {:?}; expected {name:?}", + got["name"] + ); + testkit::ensure!( + got["phase"] == "Ready", + "sandbox {name} is in phase {}; expected Ready", + got["phase"] + ); + + let listed = find_listed(cx, &name).await?; + testkit::ensure!(listed.is_some(), "sandbox {name} is not in sandbox list"); + + let marker = format!("openshell-e2e-{}", cx.run_id()); + let exec = cx + .cli([ + "sandbox", "exec", "--name", &name, "--no-tty", "--", "echo", &marker, + ]) + .await? + .success()?; + testkit::ensure!( + exec.stdout == format!("{marker}\n"), + "sandbox exec stdout was {:?}; expected {:?}", + exec.stdout, + format!("{marker}\n") + ); + + cx.cli(["sandbox", "delete", &name]).await?.success()?; + wait_unlisted(cx, &name).await?; + cx.expect_absent(&sandbox).await +} + +/// A sandbox created with the CLI can be stopped, rejects `sandbox exec` while +/// stopped, starts again, and can be stopped and deleted. +#[testkit::test] +async fn cli_stop_start_delete(cx: &mut Context) -> testkit::Result { + let name = cx.name("cl"); + cx.track(&name); + cx.cli([ + "sandbox", + "create", + "--name", + &name, + "--detach", + "--no-tty", + "--", + "sh", + "-c", + "exec sleep infinity", + ]) + .await? + .success()?; + let sandbox = cx.adopt(&name).await?; + sandbox.wait_ready().await?; + + cx.cli(["sandbox", "stop", &name]).await?.success()?; + sandbox + .wait_phase(testkit::sdk::SandboxPhase::Stopped, testkit::STOP_TIMEOUT) + .await?; + cx.cli(["sandbox", "exec", "--name", &name, "--no-tty", "--", "true"]) + .await? + .failure()?; + + cx.cli(["sandbox", "start", &name]).await?.success()?; + sandbox.wait_ready().await?; + cx.cli(["sandbox", "stop", &name]).await?.success()?; + sandbox + .wait_phase(testkit::sdk::SandboxPhase::Stopped, testkit::STOP_TIMEOUT) + .await?; + + cx.cli(["sandbox", "delete", &name]).await?.success()?; + wait_unlisted(cx, &name).await?; + cx.expect_absent(&sandbox).await +} + +/// Wait until `sandbox list` no longer shows the sandbox. +async fn wait_unlisted(cx: &Context, name: &str) -> testkit::Result { + testkit::poll_until( + &format!("sandbox {name} to leave sandbox list"), + testkit::DELETE_TIMEOUT, + std::time::Duration::from_secs(1), + async || { + Ok(match find_listed(cx, name).await? { + None => testkit::Poll::Ready(()), + Some(phase) => testkit::Poll::Pending(format!("still listed in phase {phase}")), + }) + }, + ) + .await +} + +/// The phase of the sandbox in `sandbox list`, if it is listed. Follows every +/// page. +async fn find_listed(cx: &Context, name: &str) -> testkit::Result> { + let mut token = String::new(); + for _ in 0..MAX_PAGES { + let page: Value = cx + .cli([ + "sandbox", + "list", + "--page-size", + PAGE_SIZE, + "--page-token", + &token, + "--output", + "json", + ]) + .await? + .success()? + .json()?; + if let Some(entry) = page["sandboxes"] + .as_array() + .into_iter() + .flatten() + .find(|entry| entry["name"] == name) + { + return Ok(Some( + entry["phase"].as_str().unwrap_or_default().to_string(), + )); + } + token = page["next_page_token"] + .as_str() + .unwrap_or_default() + .to_string(); + if token.is_empty() { + return Ok(None); + } + } + Err(testkit::Error::msg(format!( + "sandbox list has more than {MAX_PAGES} pages" + ))) +} diff --git a/tests/suites/conformance/sandbox/tests/file_transfer.rs b/tests/suites/conformance/sandbox/tests/file_transfer.rs new file mode 100644 index 0000000000..5bd24563c0 --- /dev/null +++ b/tests/suites/conformance/sandbox/tests/file_transfer.rs @@ -0,0 +1,420 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox file-transfer suite. +//! +//! Adapted from the `file-transfer` suite in NVIDIA/OpenShell#4629 by Evan Lezar. +//! +//! Upload and download exist only in the CLI, which moves files over the +//! sandbox's SSH session, so these tests act through `cx.cli`. They depend on +//! the supervisor, the sandbox image, and the driver's filesystem, which is +//! why they run in every environment. + +use std::fs; +use std::path::{Path, PathBuf}; + +use tempfile::TempDir; +use testkit::{CliOutput, Context, Error, Sandbox}; + +const LARGE_FILE_SIZE: usize = 512 * 1024; + +/// Files and directories round-trip through upload and download unchanged, +/// uploads into existing directories land inside them, and downloads of +/// single files and directories reproduce them locally. +#[testkit::test] +async fn upload_download_round_trip(cx: &mut Context) -> testkit::Result { + let ft = Transfer::new(cx, "fr").await?; + ft.upload_into_existing_directories().await?; + ft.round_trip().await?; + ft.download_file().await?; + ft.download_directory().await?; + ft.sandbox.delete().await +} + +/// Uploads from a Git work tree skip ignored files, a single file uploads +/// alone, and an upload whose selection is entirely ignored is rejected +/// unless `--no-git-ignore` is given. +#[testkit::test] +async fn upload_respects_gitignore(cx: &mut Context) -> testkit::Result { + let ft = Transfer::new(cx, "fg").await?; + ft.gitignore_filtering().await?; + ft.single_file_from_git_repo().await?; + ft.ignored_directory_requires_override().await?; + ft.sandbox.delete().await +} + +/// Downloads refuse sandbox paths that resolve outside the workspace, and +/// file names that look like command-line options download as plain files. +#[testkit::test] +async fn download_stays_inside_workspace(cx: &mut Context) -> testkit::Result { + let ft = Transfer::new(cx, "fs").await?; + ft.reject_workspace_escape().await?; + ft.download_dash_leading_name().await?; + ft.sandbox.delete().await +} + +/// A sandbox, a directory in its workspace, and a local scratch directory. +struct Transfer<'a> { + cx: &'a Context, + sandbox: Sandbox, + remote_root: String, + local: TempDir, +} + +impl<'a> Transfer<'a> { + async fn new(cx: &'a Context, suffix: &str) -> testkit::Result { + let sandbox = cx + .sandbox(suffix) + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + let remote_root = format!("/sandbox/file-transfer-{}-{suffix}", cx.run_id()); + let local = tempfile::tempdir() + .map_err(|error| Error::msg(format!("create a local scratch directory: {error}")))?; + let ft = Self { + cx, + sandbox, + remote_root, + local, + }; + ft.sh(&format!("mkdir -p '{}'", ft.remote_root)).await?; + Ok(ft) + } + + fn local(&self, name: &str) -> PathBuf { + self.local.path().join(name) + } + + /// Run a shell script in the sandbox and require it to succeed. + async fn sh(&self, script: &str) -> testkit::Result { + let output = self.sandbox.exec(["sh", "-c", script]).await?; + testkit::ensure!( + output.exit_code == 0, + "`{script}` exited with {}; stderr: {}", + output.exit_code, + output.stderr + ); + Ok(()) + } + + async fn upload( + &self, + source: &Path, + destination: &str, + no_git_ignore: bool, + ) -> testkit::Result { + let mut args = vec![ + "sandbox".to_string(), + "upload".to_string(), + self.sandbox.name().to_string(), + source.display().to_string(), + destination.to_string(), + ]; + if no_git_ignore { + args.push("--no-git-ignore".to_string()); + } + self.cx.cli(args).await + } + + async fn download(&self, source: &str, destination: &Path) -> testkit::Result { + self.cx + .cli([ + "sandbox", + "download", + self.sandbox.name(), + source, + &destination.display().to_string(), + ]) + .await + } + + /// Upload into existing directories, including a symlink to one and one + /// named with a trailing slash; the file must land inside each. Check the + /// destination's type, not only the exit status: tar can succeed after + /// replacing an empty directory with a file. + async fn upload_into_existing_directories(&self) -> testkit::Result { + let source = self.local("config.json"); + write(&source, "upload-destination-payload")?; + let root = format!("{}/upload destinations", self.remote_root); + self.sh(&format!( + "mkdir -p '{root}/empty' '{root}/populated' '{root}/target' '{root}/slash' && \ + printf keep > '{root}/populated/keep.txt' && \ + ln -s target '{root}/link'" + )) + .await?; + + for directory in ["empty", "populated", "link", "slash/"] { + let destination = format!("{root}/{directory}"); + self.upload(&source, &destination, true).await?.success()?; + self.sh(&format!( + "test -d '{destination}' && \ + test \"$(cat '{destination}/config.json')\" = upload-destination-payload && \ + test \"$(cat '{root}/populated/keep.txt')\" = keep && \ + test -L '{root}/link'" + )) + .await?; + } + + // A missing destination is a rename; uploading again must replace + // that file rather than treat it as a directory. + let renamed = format!("{root}/renamed.json"); + for _ in ["rename", "overwrite"] { + self.upload(&source, &renamed, true).await?.success()?; + self.sh(&format!( + "test -f '{renamed}' && test \"$(cat '{renamed}')\" = upload-destination-payload" + )) + .await?; + } + Ok(()) + } + + /// A directory with a nested file and a large binary file, and a single + /// file, survive upload then download. + async fn round_trip(&self) -> testkit::Result { + let source = self.local("roundtrip-upload"); + create_dir_all(&source.join("subdir"))?; + write(&source.join("greeting.txt"), "hello-from-local")?; + write(&source.join("subdir/nested.txt"), "nested-content")?; + let large: Vec = (0u8..=250).cycle().take(LARGE_FILE_SIZE).collect(); + fs::write(source.join("large.bin"), &large) + .map_err(|error| Error::msg(format!("write large.bin: {error}")))?; + + let remote = format!("{}/roundtrip", self.remote_root); + let uploaded = self.upload(&source, &remote, false).await?.success()?; + let warning = message(&uploaded); + testkit::ensure!( + warning.contains("outside a Git work tree") + && warning.contains(".gitignore rules are not applied"), + "upload outside a Git work tree did not warn that filtering is off: {warning}" + ); + + let destination = self.local("roundtrip-download"); + create_dir_all(&destination)?; + self.download(&format!("{remote}/roundtrip-upload"), &destination) + .await? + .success()?; + require_text(&destination.join("greeting.txt"), "hello-from-local")?; + require_text(&destination.join("subdir/nested.txt"), "nested-content")?; + let actual = fs::read(destination.join("large.bin")) + .map_err(|error| Error::msg(format!("read large.bin: {error}")))?; + testkit::ensure!( + actual == large, + "large file changed in the round trip: sent {} bytes, received {}", + large.len(), + actual.len() + ); + + let single = self.local("single.txt"); + write(&single, "single-file-payload")?; + let remote_single = format!("{}/single.txt", self.remote_root); + self.upload(&single, &remote_single, false) + .await? + .success()?; + let single_destination = self.local("single-download"); + create_dir_all(&single_destination)?; + self.download(&remote_single, &single_destination) + .await? + .success()?; + require_text( + &single_destination.join("single.txt"), + "single-file-payload", + ) + } + + async fn download_file(&self) -> testkit::Result { + let remote = format!("{}/download-file.txt", self.remote_root); + self.sh(&format!("printf greeting-payload > '{remote}'")) + .await?; + let destination = self.local("download-file"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("download-file.txt"), "greeting-payload") + } + + async fn download_directory(&self) -> testkit::Result { + let remote = format!("{}/tree", self.remote_root); + self.sh(&format!( + "mkdir -p '{remote}/sub' && printf top-level > '{remote}/root.txt' && \ + printf nested > '{remote}/sub/child.txt'" + )) + .await?; + let destination = self.local("download-directory"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("root.txt"), "top-level")?; + require_text(&destination.join("sub/child.txt"), "nested") + } + + async fn gitignore_filtering(&self) -> testkit::Result { + let repository = self.local("filter-repo"); + create_dir_all(&repository.join("build"))?; + git(&repository, &["init", "--quiet"])?; + write(&repository.join(".gitignore"), "*.log\nbuild/\n")?; + write(&repository.join("tracked.txt"), "i-am-tracked")?; + write(&repository.join("ignored.log"), "i-should-be-filtered")?; + write(&repository.join("build/output.bin"), "build-artifact")?; + git(&repository, &["add", "."])?; + + let remote = format!("{}/filtered", self.remote_root); + self.upload(&repository, &remote, false).await?.success()?; + let destination = self.local("filter-download"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + let uploaded = destination.join("filter-repo"); + require_text(&uploaded.join("tracked.txt"), "i-am-tracked")?; + require_exists(&uploaded.join(".gitignore"))?; + require_absent(&uploaded.join("ignored.log"))?; + require_absent(&uploaded.join("build")) + } + + async fn single_file_from_git_repo(&self) -> testkit::Result { + let repository = self.local("single-repo"); + create_dir_all(&repository.join("nested"))?; + git(&repository, &["init", "--quiet"])?; + write(&repository.join(".gitignore"), "*.log\n")?; + write( + &repository.join("nested/config.txt"), + "single-file-from-repo", + )?; + write(&repository.join("tracked.txt"), "should-not-upload")?; + write(&repository.join("ignored.log"), "ignored")?; + + let remote = format!("{}/single-from-repo", self.remote_root); + self.upload(&repository.join("nested/config.txt"), &remote, false) + .await? + .success()?; + let destination = self.local("single-repo-download"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("config.txt"), "single-file-from-repo")?; + require_absent(&destination.join("tracked.txt"))?; + require_absent(&destination.join("ignored.log")) + } + + async fn ignored_directory_requires_override(&self) -> testkit::Result { + let remote_seed = format!("{}/runs/test.json", self.remote_root); + self.sh(&format!( + "mkdir -p '{}/runs' && printf downloaded-payload > '{remote_seed}'", + self.remote_root + )) + .await?; + + let repository = self.local("override-repo"); + let runs = repository.join("runs"); + create_dir_all(&runs)?; + git(&repository, &["init", "--quiet"])?; + write(&repository.join(".gitignore"), "runs/\n")?; + self.download(&remote_seed, &runs).await?.success()?; + require_exists(&runs.join("test.json"))?; + + let remote = format!("{}/reuploaded", self.remote_root); + let rejected = self.upload(&runs, &remote, false).await?.failure()?; + let output = message(&rejected); + testkit::ensure!( + output.contains("filtering selected no files") && output.contains("--no-git-ignore"), + "the rejected upload did not explain the --no-git-ignore override: {output}" + ); + self.sh(&format!("test ! -e '{remote}'")).await?; + + self.upload(&runs, &remote, true).await?.success()?; + let destination = self.local("override-download"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text(&destination.join("runs/test.json"), "downloaded-payload") + } + + async fn reject_workspace_escape(&self) -> testkit::Result { + let etc_link = format!("{}/etc-link", self.remote_root); + let passwd_link = format!("{}/passwd-link", self.remote_root); + self.sh(&format!( + "ln -s /etc '{etc_link}' && ln -s /etc/passwd '{passwd_link}'" + )) + .await?; + let destination = self.local("workspace-escape"); + create_dir_all(&destination)?; + + for source in [etc_link.clone(), passwd_link, format!("{etc_link}/passwd")] { + let rejected = self.download(&source, &destination).await?.failure()?; + let output = message(&rejected); + testkit::ensure!( + output.contains("resolves to") + && output.contains("outside the") + && output.contains("sandbox workspace"), + "download of {source:?} was not rejected as outside the workspace: {output}" + ); + } + require_absent(&destination.join("passwd"))?; + require_absent(&destination.join("etc-link")) + } + + async fn download_dash_leading_name(&self) -> testkit::Result { + let remote = format!("{}/--checkpoint-action=evil", self.remote_root); + self.sh(&format!("printf dash-payload > '{remote}'")) + .await?; + let destination = self.local("dash-leading"); + create_dir_all(&destination)?; + self.download(&remote, &destination).await?.success()?; + require_text( + &destination.join("--checkpoint-action=evil"), + "dash-payload", + ) + } +} + +/// The CLI's stdout and stderr as one line of words. Error reports wrap to +/// the terminal width and draw a gutter, so match against this instead. +fn message(output: &CliOutput) -> String { + format!("{} {}", output.stdout, output.stderr) + .replace(['│', '×', '╰', '─', '╭', '├'], " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn git(repository: &Path, args: &[&str]) -> testkit::Result { + let output = std::process::Command::new("git") + .args(args) + .current_dir(repository) + .output() + .map_err(|error| Error::msg(format!("run git {}: {error}", args.join(" "))))?; + testkit::ensure!( + output.status.success(), + "git {} failed in {}: {}", + args.join(" "), + repository.display(), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + +fn write(path: &Path, contents: &str) -> testkit::Result { + fs::write(path, contents) + .map_err(|error| Error::msg(format!("write {}: {error}", path.display()))) +} + +fn create_dir_all(path: &Path) -> testkit::Result { + fs::create_dir_all(path) + .map_err(|error| Error::msg(format!("create {}: {error}", path.display()))) +} + +fn require_text(path: &Path, expected: &str) -> testkit::Result { + let actual = fs::read_to_string(path) + .map_err(|error| Error::msg(format!("read {}: {error}", path.display())))?; + testkit::ensure!( + actual == expected, + "{} contains {actual:?}; expected {expected:?}", + path.display() + ); + Ok(()) +} + +fn require_exists(path: &Path) -> testkit::Result { + testkit::ensure!(path.exists(), "{} does not exist", path.display()); + Ok(()) +} + +fn require_absent(path: &Path) -> testkit::Result { + testkit::ensure!(!path.exists(), "{} exists but should not", path.display()); + Ok(()) +} diff --git a/tests/suites/conformance/sandbox/tests/lifecycle.rs b/tests/suites/conformance/sandbox/tests/lifecycle.rs new file mode 100644 index 0000000000..02e4f6f117 --- /dev/null +++ b/tests/suites/conformance/sandbox/tests/lifecycle.rs @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox lifecycle suite. +//! +//! These tests are adapted from the `sandbox-lifecycle` suite in +//! NVIDIA/OpenShell#4629 by Evan Lezar. Tests that need a driver with a +//! persistent workspace live in `persistent_workspace.rs`. + +use testkit::Context; +use testkit::sdk::{SandboxPhase, SandboxRestartPolicy}; + +/// A long-running sandbox becomes ready, runs a command with exec, and is gone +/// from get and list after delete. +#[testkit::test] +async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("lr") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + + let marker = format!("openshell-e2e-{}", cx.run_id()); + let output = sandbox.exec(["echo", marker.as_str()]).await?; + testkit::ensure!( + output.exit_code == 0, + "exec exited with {}; stderr: {}", + output.exit_code, + output.stderr + ); + testkit::ensure!( + output.stdout == format!("{marker}\n"), + "exec stdout was {:?}; expected {:?}", + output.stdout, + format!("{marker}\n") + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} + +/// A sandbox whose main command exits 0, with restart policy Never, reaches +/// Completed with exit code 0 and is not restarted. It is gone from get and +/// list after delete. +#[testkit::test] +async fn run_to_completion_exit_delete(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("rtc") + .command(["sh", "-c", "exit 0"]) + .restart_policy(SandboxRestartPolicy::Never) + .create() + .await?; + + let finished = sandbox.wait_finished().await?; + testkit::ensure!( + finished.phase == SandboxPhase::Completed, + "sandbox finished in phase {:?} with exit code {:?}; expected Completed", + finished.phase, + finished.exit_code + ); + testkit::ensure!( + finished.exit_code == Some(0), + "exit code was {:?}; expected Some(0)", + finished.exit_code + ); + testkit::ensure!( + finished.restart_count == 0, + "restart count was {}; expected 0", + finished.restart_count + ); + + // Completed is final under Never: a later read must not show a restart. + let again = sandbox.get().await?; + testkit::ensure!( + again.phase == SandboxPhase::Completed && again.restart_count == 0, + "sandbox left Completed: phase {:?}, restart count {}", + again.phase, + again.restart_count + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} + +/// A sandbox created with the gateway's default command becomes Ready, is +/// listed as Ready, runs a command with exec, and is gone after delete. +#[testkit::test] +async fn default_command_sandbox_is_listed_ready(cx: &mut Context) -> testkit::Result { + let sandbox = cx.sandbox("dc").create().await?; + let ready = sandbox.wait_ready().await?; + testkit::ensure!( + ready.name == sandbox.name(), + "get returned sandbox {:?}; expected {:?}", + ready.name, + sandbox.name() + ); + cx.expect_listed(&sandbox, SandboxPhase::Ready).await?; + + let marker = format!("openshell-e2e-{}", cx.run_id()); + let output = sandbox.exec(["echo", marker.as_str()]).await?; + testkit::ensure!( + output.exit_code == 0 && output.stdout == format!("{marker}\n"), + "exec exited {} with stdout {:?}; stderr: {}", + output.exit_code, + output.stdout, + output.stderr + ); + + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} + +/// A stopped sandbox rejects exec, accepts a second stop, and returns to +/// Ready when started. +#[testkit::test] +async fn restart_stopped_sandbox(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("rs") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + + sandbox.stop().await?; + match sandbox.exec(["true"]).await { + Err(_) => {} + Ok(output) => testkit::ensure!( + output.exit_code != 0, + "exec succeeded while the sandbox was stopped" + ), + } + // Stop is idempotent. + let again = sandbox.stop().await?; + testkit::ensure!( + again.phase == SandboxPhase::Stopped, + "a second stop left the sandbox {:?}", + again.phase + ); + + sandbox.start().await?; + let output = sandbox.exec(["echo", "started"]).await?; + testkit::ensure!( + output.exit_code == 0 && output.stdout == "started\n", + "exec after start exited {} with stdout {:?}; stderr: {}", + output.exit_code, + output.stdout, + output.stderr + ); + Ok(()) +} + +/// A stopped sandbox can be deleted, and is then gone from get and list. +#[testkit::test] +async fn stopped_sandbox_can_be_deleted(cx: &mut Context) -> testkit::Result { + let sandbox = cx + .sandbox("sd") + .command(["sleep", "infinity"]) + .create() + .await?; + sandbox.wait_ready().await?; + sandbox.stop().await?; + sandbox.delete().await?; + cx.expect_absent(&sandbox).await +} diff --git a/tests/suites/conformance/sandbox/tests/persistent_workspace.rs b/tests/suites/conformance/sandbox/tests/persistent_workspace.rs new file mode 100644 index 0000000000..101ea6d6ff --- /dev/null +++ b/tests/suites/conformance/sandbox/tests/persistent_workspace.rs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Lifecycle tests for a compute driver whose workspace is persistent. +//! +//! Adapted from the `sandbox-lifecycle` suite in NVIDIA/OpenShell#4629 by Evan +//! Lezar. Docker, Podman, Kubernetes, and MicroVM keep `/sandbox` across stop +//! and start, and so must every conformant driver. The Microsoft MXC driver +//! cannot start a stopped sandbox yet, so it is not conformant until it can. + +use testkit::Context; + +/// Files in the workspace survive stop and start, and start runs the main +/// command again. +#[testkit::test] +async fn persistent_workspace_survives_stop_start(cx: &mut Context) -> testkit::Result { + const SENTINEL: &str = "/sandbox/.openshell-stop-start-sentinel"; + const RUN_COUNT: &str = "/sandbox/.openshell-main-run-count"; + let main = format!( + "count=0; test ! -f '{RUN_COUNT}' || count=$(cat '{RUN_COUNT}'); \ + count=$((count + 1)); printf '%s\\n' \"$count\" > '{RUN_COUNT}'; \ + exec sleep infinity" + ); + let sandbox = cx + .sandbox("kw") + .command(["sh", "-c", main.as_str()]) + .create() + .await?; + sandbox.wait_ready().await?; + + let sentinel = format!("openshell-stop-start-{}", cx.run_id()); + let write = sandbox + .exec([ + "sh", + "-c", + &format!("printf '%s\\n' '{sentinel}' > '{SENTINEL}' && sync"), + ]) + .await?; + testkit::ensure!( + write.exit_code == 0, + "writing the sentinel exited {}; stderr: {}", + write.exit_code, + write.stderr + ); + + sandbox.stop().await?; + sandbox.start().await?; + + let read = sandbox.exec(["cat", SENTINEL]).await?; + testkit::ensure!( + read.stdout == format!("{sentinel}\n"), + "sentinel after restart is {:?}; stderr: {}", + read.stdout, + read.stderr + ); + let runs = sandbox.exec(["cat", RUN_COUNT]).await?; + testkit::ensure!( + runs.stdout == "2\n", + "main command ran {:?} times; expected 2", + runs.stdout.trim() + ); + Ok(()) +} From 32c85f6b431a3acd4eb16d6a6a81d640c4875096 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Wed, 21 Oct 2026 22:15:42 -0700 Subject: [PATCH 13/16] test(tmachine): install Git in every test environment The file-transfer suite builds a Git repository on the machine that runs the tests, and the old conformance playbook installed Git itself. Signed-off-by: Matthew Grossman --- .../ansible/playbooks/test-tools.yaml | 19 +++++++++++++++++++ tests/tmachine/config.nix | 4 ++++ 2 files changed, 23 insertions(+) create mode 100644 tests/tmachine/ansible/playbooks/test-tools.yaml diff --git a/tests/tmachine/ansible/playbooks/test-tools.yaml b/tests/tmachine/ansible/playbooks/test-tools.yaml new file mode 100644 index 0000000000..394810f8f2 --- /dev/null +++ b/tests/tmachine/ansible/playbooks/test-tools.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +# Tools the testkit suites run on the machine itself, next to cargo-nextest. +# The file-transfer suite builds a Git repository there to check that upload +# honors .gitignore. +- name: Install test tools + hosts: all + become: true + gather_facts: false + tasks: + - name: Wait for SSH + ansible.builtin.wait_for_connection: + + - name: Install Git + ansible.builtin.package: + name: git + state: present diff --git a/tests/tmachine/config.nix b/tests/tmachine/config.nix index ee9f70943e..98d0c41519 100644 --- a/tests/tmachine/config.nix +++ b/tests/tmachine/config.nix @@ -48,6 +48,7 @@ let use_galaxy = true; playbooks = [ "ansible/playbooks/nextest.yaml" + "ansible/playbooks/test-tools.yaml" "ansible/playbooks/docker.yaml" ]; }; @@ -59,6 +60,7 @@ let use_galaxy = false; playbooks = [ "ansible/playbooks/nextest.yaml" + "ansible/playbooks/test-tools.yaml" "ansible/playbooks/k3s.yaml" ]; }; @@ -70,6 +72,7 @@ let use_galaxy = false; playbooks = [ "ansible/playbooks/nextest.yaml" + "ansible/playbooks/test-tools.yaml" "ansible/playbooks/selinux.yaml" "ansible/playbooks/podman-rootful.yaml" ]; @@ -82,6 +85,7 @@ let use_galaxy = false; playbooks = [ "ansible/playbooks/nextest.yaml" + "ansible/playbooks/test-tools.yaml" "ansible/playbooks/selinux.yaml" "ansible/playbooks/podman-rootless.yaml" ]; From 4eddc7ba01ab075e9f14dcc6aac1b2f613ab7e2f Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Thu, 22 Oct 2026 17:00:39 -0700 Subject: [PATCH 14/16] test(tmachine): check Agent Sandbox reconciliation in the k3s installer The k3s installer now waits until the Agent Sandbox controller reconciles a gated probe Sandbox, so a test that starts right after boot doesn't race it. The check moves out of the old conformance playbook into tests/tmachine/ansible/playbooks/k3s-controller-ready.yaml, which the installer and the old playbook both include. Tests themselves don't wait for the controller. Signed-off-by: Matthew Grossman --- tests/tmachine/ansible/playbooks/conformance/cli.yaml | 2 +- tests/tmachine/ansible/playbooks/openshell-k3s.yaml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/tmachine/ansible/playbooks/conformance/cli.yaml b/tests/tmachine/ansible/playbooks/conformance/cli.yaml index e841a11442..071672167d 100644 --- a/tests/tmachine/ansible/playbooks/conformance/cli.yaml +++ b/tests/tmachine/ansible/playbooks/conformance/cli.yaml @@ -83,7 +83,7 @@ when: gateway_status is succeeded and conformance_k3s.stat.exists block: - name: Verify Agent Sandbox reconciliation - ansible.builtin.include_tasks: k3s-ready.yaml + ansible.builtin.include_tasks: ../k3s-controller-ready.yaml rescue: - name: Record controller readiness failure ansible.builtin.set_fact: diff --git a/tests/tmachine/ansible/playbooks/openshell-k3s.yaml b/tests/tmachine/ansible/playbooks/openshell-k3s.yaml index e39a9f3523..aca8ec958f 100644 --- a/tests/tmachine/ansible/playbooks/openshell-k3s.yaml +++ b/tests/tmachine/ansible/playbooks/openshell-k3s.yaml @@ -167,6 +167,11 @@ - k3s_gateway_address.stdout is match('^[0-9.]+:[0-9]+$') fail_msg: "Unexpected gateway Service address: {{ k3s_gateway_address.stdout }}" + # Deployment readiness doesn't establish that the controller reconciles + # Sandboxes, so the first test would otherwise race it. Tests don't wait. + - name: Verify Agent Sandbox reconciliation + ansible.builtin.include_tasks: k3s-controller-ready.yaml + - name: Register OpenShell gateway for test client hosts: all gather_facts: false From a714c815679d3e09010e38db078effcf5630822c Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Thu, 22 Oct 2026 17:00:39 -0700 Subject: [PATCH 15/16] feat(runner): require conformance to select the same suites everywhere A plan can't run part of the conformance family in one environment and a different part in another, or conformance would mean different things in different places. The runner refuses such a plan and names the environments that differ. A test that can't pass everywhere belongs in another family. Signed-off-by: Matthew Grossman --- TESTING.md | 4 ++- tests/runner/src/jobs.rs | 71 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/TESTING.md b/TESTING.md index 1b6799a8d6..746d444dd5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -174,7 +174,9 @@ what runs where: CI reads it, so no workflow or playbook repeats it. - **Plans** (`tests/plans/*.yaml`) pair a family (a workspace under `tests/suites/`, such as `conformance`) and optionally some of its suites with - environment IDs. See [tests/plans/README.md](tests/plans/README.md). + environment IDs. See [tests/plans/README.md](tests/plans/README.md). A `conformance` entry must + select the same suites in every environment; the runner refuses a plan that + doesn't. - **Environments** (`tests/environments/*.yaml`) name the tmachine environment and installer that provision them and the gateway name the installer registers. The runner points the tests at that gateway and the installed CLI with diff --git a/tests/runner/src/jobs.rs b/tests/runner/src/jobs.rs index df95ce2034..a9f82d1f2c 100644 --- a/tests/runner/src/jobs.rs +++ b/tests/runner/src/jobs.rs @@ -91,6 +91,8 @@ pub fn expand( } } + check_conformance_is_uniform(&selected)?; + for id in &narrowing.environments { if !selected.contains_key(id) { bail!("--env {id:?} is not in the plan"); @@ -139,6 +141,44 @@ pub fn expand( Ok(jobs) } +/// The family every driver and install must pass. A plan can't run part of it +/// somewhere, or conformance would mean different things in different places. +const CONFORMANCE: &str = "conformance"; + +/// Every environment the plan runs conformance in must select the same suites. +fn check_conformance_is_uniform( + selected: &BTreeMap>>, +) -> Result<()> { + let mut by_suites: BTreeMap, Vec<&str>> = BTreeMap::new(); + for (id, families) in selected { + if let Some(suites) = families.get(CONFORMANCE) { + by_suites + .entry(suites.iter().map(String::as_str).collect()) + .or_default() + .push(id); + } + } + if by_suites.len() > 1 { + let groups: Vec = by_suites + .iter() + .map(|(suites, ids)| { + format!( + "{} select {}", + ids.join(", "), + suites.iter().copied().collect::>().join(", ") + ) + }) + .collect(); + bail!( + "the plan selects different {CONFORMANCE} suites in different environments, so \ + {CONFORMANCE} would not mean the same thing everywhere; use the same suites \ + everywhere, or move the suite that can't run everywhere to another family:\n {}", + groups.join("\n ") + ); + } + Ok(()) +} + /// What strict mode (CI) refuses to run: nothing selected at all, an /// environment this host can't provision, and a family with no tests. Locally these are reported and skipped, because a /// laptop can't boot every environment; in CI each one means a lane that would @@ -339,7 +379,7 @@ mod tests { fn groups_entries_by_environment() { let jobs = expand_plan( &format!( - "matrix:\n - type: conformance\n suites: [providers]\n environments: [{AMD64}, {ARM64}]\n - type: conformance\n suites: [providers, sandbox-lifecycle]\n environments: [{AMD64}]\n" + "matrix:\n - type: conformance\n suites: [providers]\n environments: [{AMD64}, {ARM64}]\n - type: conformance\n suites: [sandbox-lifecycle]\n environments: [{AMD64}, {ARM64}]\n" ), &Narrowing::default(), ) @@ -421,6 +461,35 @@ mod tests { ); } + #[test] + fn conformance_must_select_the_same_suites_in_every_environment() { + let err = expand_plan( + &format!( + "matrix:\n - type: conformance\n environments: [{AMD64}]\n - type: conformance\n suites: [providers]\n environments: [{ARM64}]\n" + ), + &Narrowing::default(), + ) + .unwrap_err(); + let message = err.to_string(); + assert!( + message.contains("different conformance suites"), + "{message}" + ); + assert!( + message.contains(AMD64) && message.contains(ARM64), + "{message}" + ); + + // The same suites listed differently are the same selection. + expand_plan( + &format!( + "matrix:\n - type: conformance\n suites: [providers, sandbox-lifecycle]\n environments: [{AMD64}]\n - type: conformance\n environments: [{ARM64}]\n" + ), + &Narrowing::default(), + ) + .unwrap(); + } + #[test] fn expected_tests_are_the_ones_in_the_suite() { let jobs = expand_plan( From 919f767b0915acd3040ea5898b25dacf1fb605ac Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Thu, 22 Oct 2026 17:00:39 -0700 Subject: [PATCH 16/16] test(plans): run the ported conformance suites on Docker, Podman and k3s Add the Podman (rootful and rootless, RPM) and k3s and HA k3s environments and put them, with Docker, in the cpu-tests plan, which now runs the whole conformance family (smoke, lifecycle, cli, persistent-workspace and file-transfer) on all five. The tmachine disk cache in e2e-suites.yml is keyed per environment; the k3s environments skip it as integration-runner.yml does. The old suites and lanes still run unchanged. arm64 is not defined. Signed-off-by: Matthew Grossman --- .github/workflows/e2e-suites.yml | 9 ++++++--- tests/environments/k3s.yaml | 21 +++++++++++++++++++++ tests/environments/podman.yaml | 20 ++++++++++++++++++++ tests/plans/README.md | 6 +++++- tests/plans/cpu-tests.yaml | 4 ++++ 5 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 tests/environments/k3s.yaml create mode 100644 tests/environments/podman.yaml diff --git a/.github/workflows/e2e-suites.yml b/.github/workflows/e2e-suites.yml index 670918ca6c..0826b5cc73 100644 --- a/.github/workflows/e2e-suites.yml +++ b/.github/workflows/e2e-suites.yml @@ -94,17 +94,20 @@ jobs: - uses: ./.github/actions/setup-nix + # integration-runner.yml skips the disk cache for ubuntu-k3s and doesn't say + # why, so the k3s environments skip it here too until that is understood. - name: Cache tmachine disks + if: ${{ !contains(matrix.environment, '.k3s.') }} uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: ~/.cache/tmachine # tmachine keys its disk layers on a hash of the Ansible tree. # actions/cache doesn't save after an exact key hit, so a fixed key # would rebuild the changed layers on every run. Key on the playbooks - # and fall back to the newest entry for the same runner. - key: tmachine-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('tests/tmachine/ansible/**', 'tests/tmachine/images.nix') }} + # and fall back to the newest entry for the same environment and runner. + key: tmachine-${{ matrix.environment }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('tests/tmachine/ansible/**', 'tests/tmachine/images.nix') }} restore-keys: | - tmachine-${{ runner.os }}-${{ runner.arch }}- + tmachine-${{ matrix.environment }}-${{ runner.os }}-${{ runner.arch }}- - name: Download integration inputs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/tests/environments/k3s.yaml b/tests/environments/k3s.yaml new file mode 100644 index 0000000000..87ea66462c --- /dev/null +++ b/tests/environments/k3s.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Kubernetes (k3s) environments, by Evan Lezar (NVIDIA/OpenShell#4629). See +# docker.yaml for the fields and for adding arm64. The k3s installers register the gateway as +# `tmachine`. +environments: + - id: tmachine.ubuntu2404-amd64.k3s.k3s + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: amd64 } + runtime: k3s + installer: k3s + tmachine: { environment: ubuntu-k3s, installer: k3s } + gateway: tmachine + - id: tmachine.ubuntu2404-amd64.k3s.k3s-ha + backend: tmachine + machine: { os: ubuntu, version: "24.04", arch: amd64 } + runtime: k3s + installer: k3s-ha + tmachine: { environment: ubuntu-k3s, installer: k3s-ha } + gateway: tmachine diff --git a/tests/environments/podman.yaml b/tests/environments/podman.yaml new file mode 100644 index 0000000000..458b9e3701 --- /dev/null +++ b/tests/environments/podman.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Podman-backed environments, by Evan Lezar (NVIDIA/OpenShell#4629). See +# docker.yaml for the fields and for adding arm64. +environments: + - id: tmachine.fedora44-amd64.podman-rootful.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: amd64 } + runtime: podman-rootful + installer: rpm + tmachine: { environment: fedora-podman-rootful, installer: rpm } + gateway: openshell + - id: tmachine.fedora44-amd64.podman-rootless.rpm + backend: tmachine + machine: { os: fedora, version: "44", arch: amd64 } + runtime: podman-rootless + installer: rpm + tmachine: { environment: fedora-podman-rootless, installer: rpm } + gateway: openshell diff --git a/tests/plans/README.md b/tests/plans/README.md index ba1fd6436e..f0326bd4eb 100644 --- a/tests/plans/README.md +++ b/tests/plans/README.md @@ -12,6 +12,10 @@ matrix: - tmachine.ubuntu2404-amd64.docker-rootful.deb # IDs from tests/environments/ ``` +A `conformance` entry must select the same suites in every environment: the +runner refuses a plan that doesn't, so conformance means the same thing +everywhere. A test that can't pass everywhere belongs in another family. + A family is a Cargo workspace directly under `tests/suites/` with at least one testkit suite. A suite is a test file in a crate's `tests/` directory containing `#[testkit::test]` functions. Its name is the file name without `.rs`, with `_` @@ -19,7 +23,7 @@ replaced by `-`: `tests/file_transfer.rs` is the suite `file-transfer`. | Plan | Runs | |---|---| -| `cpu-tests.yaml` | The `conformance` family on Ubuntu 24.04 amd64 with rootful Docker and the Debian package | +| `cpu-tests.yaml` | The `conformance` family on rootful Docker (Debian package), rootful and rootless Podman (RPM), and k3s and HA k3s, all amd64 | ## How a plan runs on CI diff --git a/tests/plans/cpu-tests.yaml b/tests/plans/cpu-tests.yaml index 0ac90f2ff3..ee2a67f6aa 100644 --- a/tests/plans/cpu-tests.yaml +++ b/tests/plans/cpu-tests.yaml @@ -16,3 +16,7 @@ matrix: - type: conformance environments: - tmachine.ubuntu2404-amd64.docker-rootful.deb + - tmachine.fedora44-amd64.podman-rootful.rpm + - tmachine.fedora44-amd64.podman-rootless.rpm + - tmachine.ubuntu2404-amd64.k3s.k3s + - tmachine.ubuntu2404-amd64.k3s.k3s-ha