test(conformance): add SDK-driven testkit and sandbox-lifecycle suite

Add a testkit for black-box conformance tests driven through the Rust SDK,
and a first sandbox-lifecycle suite built on it.

- testkit loads the system under test from a target file named by
  OPENSHELL_TEST_TARGET (gateway endpoint, TLS or mTLS material, bearer
  token, capabilities). mTLS targets get a tonic channel built by testkit
  and passed to OpenShellClient::from_parts, since the SDK does not speak
  mTLS.
- Context tracks every sandbox a test creates and deletes them after the
  test, including on failure or panic; cleanup failures are reported
  separately.
- #[testkit::test] wraps an async fn in a same-named #[tokio::test]. The
  doc comment is the required description, the function name is the test
  ID, and requires = [...] names target capabilities. A test whose
  requirement the target lacks fails rather than passes.
- sandbox-lifecycle adds long_running_exec_delete: create, wait for Ready,
  exec, delete, and confirm the sandbox is gone from get and list.

Pin aws-lc-rs 1.18.0 and aws-lc-sys 0.44.0 to the root workspace versions
so the musl test archive links against the Nix-provided AWS-LC.

Signed-off-by: Evan Lezar <elezar@nvidia.com>
This commit is contained in:
Evan Lezar
2026-10-15 23:05:09 +02:00
parent 6546691d5e
commit 0b8cd5f222
13 changed files with 3634 additions and 14 deletions
+2374 -13
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,4 +3,4 @@
[workspace]
resolver = "2"
members = ["cli"]
members = ["cli", "sandbox-lifecycle", "testkit", "testkit-macros"]
@@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
[package]
name = "openshell-conformance-sandbox-lifecycle"
description = "Conformance suite: sandbox-lifecycle"
version = "0.0.0"
edition = "2024"
rust-version = "1.94"
license = "Apache-2.0"
repository = "https://github.com/NVIDIA/OpenShell"
publish = false
[dependencies]
openshell-testkit = { path = "../testkit" }
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Conformance suite: sandbox-lifecycle.
use testkit::Context;
/// A long-running sandbox becomes ready, runs a command with exec, and is gone
/// from get and list after delete.
#[testkit::test]
async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result {
let sandbox = cx
.sandbox("lr")
.command(["sleep", "infinity"])
.create()
.await?;
sandbox.wait_ready().await?;
let marker = format!("openshell-conformance-{}", cx.run_id());
let output = sandbox.exec(["echo", marker.as_str()]).await?;
testkit::ensure!(
output.exit_code == 0,
"exec exited with {}; stderr: {}",
output.exit_code,
output.stderr
);
testkit::ensure!(
output.stdout == format!("{marker}\n"),
"exec stdout was {:?}; expected {:?}",
output.stdout,
format!("{marker}\n")
);
sandbox.delete().await?;
cx.expect_absent(&sandbox).await
}
@@ -0,0 +1,21 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
[package]
name = "openshell-testkit-macros"
description = "The #[testkit::test] attribute for OpenShell conformance tests"
version = "0.0.0"
edition = "2024"
rust-version = "1.94"
license = "Apache-2.0"
repository = "https://github.com/NVIDIA/OpenShell"
publish = false
[lib]
name = "testkit_macros"
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
@@ -0,0 +1,240 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! The `#[testkit::test]` attribute.
//!
//! A conformance test is an `async fn` taking `&mut testkit::Context` and
//! returning `testkit::Result`. Its doc comment is the test's description and
//! is required. The function name is the test ID. Requirements are named
//! after target capabilities:
//!
//! ```ignore
//! /// An exposed loopback HTTP service is reachable and goes away after delete.
//! #[testkit::test(requires = [service_exposure])]
//! async fn expose_service(cx: &mut Context) -> testkit::Result { /* ... */ }
//! ```
//!
//! The attribute expands to a `#[tokio::test]` wrapper with the same name, so
//! the nextest test name is the test ID. The original function is nested in
//! the wrapper and passed to `testkit::run`.
use proc_macro::TokenStream;
use proc_macro2::{Span, TokenStream as TokenStream2};
use quote::{format_ident, quote};
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{Expr, ExprLit, Ident, ItemFn, Lit, LitStr, Meta, Token, bracketed};
#[proc_macro_attribute]
pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream {
expand(attr.into(), item.into())
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}
/// Arguments accepted by `#[testkit::test(...)]`.
#[derive(Default)]
struct Args {
requires: Vec<Ident>,
}
impl Parse for Args {
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
let mut args = Self::default();
while !input.is_empty() {
let key: Ident = input.parse()?;
if key != "requires" {
return Err(syn::Error::new(
key.span(),
format!("unknown argument `{key}`; expected `requires = [...]`"),
));
}
input.parse::<Token![=]>()?;
let content;
bracketed!(content in input);
args.requires
.extend(Punctuated::<Ident, Token![,]>::parse_terminated(&content)?);
if !input.is_empty() {
input.parse::<Token![,]>()?;
}
}
Ok(args)
}
}
fn expand(attr: TokenStream2, item: TokenStream2) -> syn::Result<TokenStream2> {
let args: Args = syn::parse2(attr)?;
let mut body: ItemFn = syn::parse2(item)?;
if body.sig.asyncness.is_none() {
return Err(syn::Error::new_spanned(
body.sig.fn_token,
"conformance tests must be `async fn`",
));
}
if body.sig.inputs.len() != 1 {
return Err(syn::Error::new_spanned(
&body.sig.inputs,
"conformance tests take exactly one argument: `cx: &mut testkit::Context`",
));
}
let id = body.sig.ident.clone();
let description = description(&body)?;
let requires = args.requires.iter().map(|capability| {
let variant = Ident::new(&to_camel_case(&capability.to_string()), capability.span());
quote!(::testkit::Capability::#variant)
});
let vis = body.vis.clone();
let wrapper_attrs: Vec<_> = body
.attrs
.iter()
.filter(|attr| attr.path().is_ident("doc"))
.cloned()
.collect();
body.attrs.retain(|attr| !attr.path().is_ident("doc"));
body.vis = syn::Visibility::Inherited;
body.sig.ident = format_ident!("body");
let id_str = LitStr::new(&id.to_string(), id.span());
Ok(quote! {
#(#wrapper_attrs)*
#[::testkit::__private::tokio::test(crate = "::testkit::__private::tokio")]
#vis async fn #id() {
#body
::testkit::run(
::testkit::TestInfo {
id: #id_str,
description: #description,
requires: &[#(#requires),*],
},
body,
)
.await;
}
})
}
/// The test description: the doc comment, joined into one line.
fn description(item: &ItemFn) -> syn::Result<LitStr> {
let lines: Vec<String> = item
.attrs
.iter()
.filter_map(|attr| match &attr.meta {
Meta::NameValue(meta) if meta.path.is_ident("doc") => match &meta.value {
Expr::Lit(ExprLit {
lit: Lit::Str(line),
..
}) => Some(line.value().trim().to_string()),
_ => None,
},
_ => None,
})
.filter(|line| !line.is_empty())
.collect();
if lines.is_empty() {
return Err(syn::Error::new(
item.sig.ident.span(),
"conformance tests need a doc comment describing the behavior they verify",
));
}
Ok(LitStr::new(&lines.join(" "), Span::call_site()))
}
fn to_camel_case(snake: &str) -> String {
snake
.split('_')
.map(|word| {
let mut chars = word.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().chain(chars).collect()
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::expand;
fn expand_str(attr: &str, item: &str) -> syn::Result<String> {
expand(attr.parse().unwrap(), item.parse().unwrap()).map(|tokens| tokens.to_string())
}
#[test]
fn expands_to_wrapper_with_same_name() {
let out = expand_str(
"",
"/// Does a thing.\nasync fn does_a_thing(cx: &mut Context) -> testkit::Result { Ok(()) }",
)
.unwrap();
assert!(out.contains("async fn does_a_thing ()"), "{out}");
assert!(out.contains("async fn body (cx : & mut Context)"), "{out}");
assert!(out.contains("id : \"does_a_thing\""), "{out}");
assert!(out.contains("description : \"Does a thing.\""), "{out}");
}
#[test]
fn joins_multi_line_doc_comments() {
let out = expand_str(
"",
"/// First line\n/// second line.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }",
)
.unwrap();
assert!(out.contains("\"First line second line.\""), "{out}");
}
#[test]
fn maps_requirements_to_capabilities() {
let out = expand_str(
"requires = [service_exposure, gpu]",
"/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }",
)
.unwrap();
assert!(
out.contains(
"requires : & [:: testkit :: Capability :: ServiceExposure , :: testkit :: Capability :: Gpu]"
),
"{out}"
);
}
#[test]
fn rejects_missing_doc_comment() {
let err = expand_str(
"",
"async fn t(cx: &mut Context) -> testkit::Result { Ok(()) }",
)
.unwrap_err();
assert!(err.to_string().contains("doc comment"), "{err}");
}
#[test]
fn rejects_sync_fn() {
let err = expand_str(
"",
"/// Doc.\nfn t(cx: &mut Context) -> testkit::Result { Ok(()) }",
)
.unwrap_err();
assert!(err.to_string().contains("async fn"), "{err}");
}
#[test]
fn rejects_wrong_arity() {
let err =
expand_str("", "/// Doc.\nasync fn t() -> testkit::Result { Ok(()) }").unwrap_err();
assert!(err.to_string().contains("exactly one argument"), "{err}");
}
#[test]
fn rejects_unknown_argument() {
let err = expand_str(
"timeout = [x]",
"/// Doc.\nasync fn t(cx: &mut Context) -> testkit::Result { Ok(()) }",
)
.unwrap_err();
assert!(err.to_string().contains("unknown argument"), "{err}");
}
}
@@ -0,0 +1,25 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
[package]
name = "openshell-testkit"
description = "Shared support for OpenShell conformance tests"
version = "0.0.0"
edition = "2024"
rust-version = "1.94"
license = "Apache-2.0"
repository = "https://github.com/NVIDIA/OpenShell"
publish = false
[lib]
name = "testkit"
[dependencies]
openshell-sdk = { path = "../../../../crates/openshell-sdk" }
openshell-testkit-macros = { path = "../testkit-macros" }
futures = "0.3"
rand = "0.9"
serde = { version = "1", features = ["derive"] }
serde_yml = { package = "noyalib", version = "0.0.53", default-features = false, features = ["std", "compat-serde-yaml"] }
tokio = { version = "1.43", features = ["macros", "rt", "time"] }
tonic = { version = "0.14", features = ["tls-native-roots"] }
@@ -0,0 +1,285 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Per-test context: the SDK client, the run ID, and the resources to clean up.
use std::collections::BTreeSet;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use openshell_sdk::{
DeleteOptions, ExecOptions, ListOptions, SandboxPhase, SandboxRef, SandboxRestartPolicy,
SandboxSpec, SdkError, WorkspaceScopedClient,
};
use rand::Rng;
use crate::error::{Error, Result, ResultExt};
/// Sandbox creation includes image pulls on a cold environment.
pub const READY_TIMEOUT: Duration = Duration::from_mins(10);
pub const DELETE_TIMEOUT: Duration = Duration::from_mins(2);
pub const EXEC_TIMEOUT: Duration = Duration::from_mins(2);
const CLEANUP_TIMEOUT: Duration = Duration::from_mins(2);
/// Names of sandboxes created by a test and not yet confirmed deleted.
type Registry = Arc<Mutex<BTreeSet<String>>>;
/// What a test uses to act on the system under test.
pub struct Context {
client: WorkspaceScopedClient,
run_id: String,
sandboxes: Registry,
}
impl Context {
pub(crate) fn new(client: WorkspaceScopedClient) -> Self {
Self {
client,
run_id: generate_run_id(),
sandboxes: Registry::default(),
}
}
/// The ID unique to this test run, used in resource names.
pub fn run_id(&self) -> &str {
&self.run_id
}
/// The SDK client, for operations testkit does not wrap.
pub fn client(&self) -> &WorkspaceScopedClient {
&self.client
}
/// A resource name unique to this run: `ct-<run-id>-<suffix>`.
pub fn name(&self, suffix: &str) -> String {
format!("ct-{}-{suffix}", self.run_id)
}
/// Start describing a sandbox named `cx.name(suffix)`.
pub fn sandbox(&self, suffix: &str) -> SandboxBuilder {
SandboxBuilder {
client: self.client.clone(),
sandboxes: Arc::clone(&self.sandboxes),
spec: SandboxSpec {
name: Some(self.name(suffix)),
..SandboxSpec::default()
},
}
}
/// Fail unless the sandbox is gone from both get and list.
pub async fn expect_absent(&self, sandbox: &Sandbox) -> Result {
let name = sandbox.name();
match self.client.get_sandbox(name).await {
Err(SdkError::NotFound { .. }) => {}
Err(error) => return Err(Error::from(error).context(format!("get sandbox {name}"))),
Ok(found) => {
return Err(Error::msg(format!(
"sandbox {name} still exists (id {}, phase {:?})",
found.id, found.phase
)));
}
}
let listed = self
.client
.list_all_sandboxes(ListOptions::default())
.await
.context("list sandboxes")?;
crate::ensure!(
!listed.iter().any(|entry| entry.name == name),
"sandbox {name} is absent from get but still listed"
);
Ok(())
}
/// Delete every sandbox the test created and has not deleted. Returns the
/// cleanup failures; they are reported separately from the test result.
pub(crate) async fn cleanup(&self) -> Vec<String> {
let started = Instant::now();
let names: Vec<String> = lock(&self.sandboxes).iter().cloned().collect();
let mut failures = Vec::new();
for name in names {
let remaining = CLEANUP_TIMEOUT.saturating_sub(started.elapsed());
let result = async {
self.client
.delete_sandbox(
&name,
DeleteOptions {
allow_missing: true,
},
)
.await?;
self.client.wait_deleted(&name, remaining, None).await
}
.await;
match result {
Ok(()) => {
lock(&self.sandboxes).remove(&name);
}
Err(error) => failures.push(format!("delete sandbox {name}: {error}")),
}
}
failures
}
}
/// A sandbox to create.
pub struct SandboxBuilder {
client: WorkspaceScopedClient,
sandboxes: Registry,
spec: SandboxSpec,
}
impl SandboxBuilder {
/// The sandbox's main command. Empty selects the gateway's default.
#[must_use]
pub fn command<I, S>(mut self, command: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.spec.command = command.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn restart_policy(mut self, policy: SandboxRestartPolicy) -> Self {
self.spec.restart_policy = policy;
self
}
/// Full access to the spec for fields without a builder method.
#[must_use]
pub fn spec(mut self, update: impl FnOnce(&mut SandboxSpec)) -> Self {
update(&mut self.spec);
self
}
/// Create the sandbox. It is tracked for cleanup before the request is
/// sent, so a create that fails after the gateway accepted it is still
/// cleaned up.
pub async fn create(self) -> Result<Sandbox> {
let name = self.spec.name.clone().unwrap_or_default();
lock(&self.sandboxes).insert(name.clone());
let created = self
.client
.create_sandbox(self.spec)
.await
.context(format!("create sandbox {name}"))?;
Ok(Sandbox {
client: self.client,
sandboxes: self.sandboxes,
name,
id: created.id,
})
}
}
/// A sandbox created by the test.
pub struct Sandbox {
client: WorkspaceScopedClient,
sandboxes: Registry,
name: String,
id: String,
}
/// The buffered output of an exec.
#[derive(Debug)]
pub struct ExecOutput {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
impl Sandbox {
pub fn name(&self) -> &str {
&self.name
}
pub fn id(&self) -> &str {
&self.id
}
/// Wait until the sandbox is Ready. A sandbox that completes or fails
/// instead is an error.
pub async fn wait_ready(&self) -> Result<SandboxRef> {
let snapshot = self
.client
.wait_ready(&self.name, READY_TIMEOUT)
.await
.context(format!("wait for sandbox {} to be ready", self.name))?;
crate::ensure!(
snapshot.phase == SandboxPhase::Ready,
"sandbox {} reached phase {:?}; expected Ready",
self.name,
snapshot.phase
);
Ok(snapshot)
}
/// Run a command in the sandbox and buffer its output.
pub async fn exec<I, S>(&self, command: I) -> Result<ExecOutput>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let command: Vec<String> = command.into_iter().map(Into::into).collect();
let options = ExecOptions {
timeout: Some(EXEC_TIMEOUT),
..ExecOptions::default()
};
let result = self
.client
.exec(&self.name, &command, options)
.await
.context(format!("exec {command:?} in sandbox {}", self.name))?;
Ok(ExecOutput {
exit_code: result.exit_code,
stdout: String::from_utf8_lossy(&result.stdout).into_owned(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
})
}
/// Delete the sandbox and wait until it is gone.
pub async fn delete(&self) -> Result {
self.client
.delete_sandbox(&self.name, DeleteOptions::default())
.await
.context(format!("delete sandbox {}", self.name))?;
self.client
.wait_deleted(&self.name, DELETE_TIMEOUT, Some(&self.id))
.await
.context(format!("wait for sandbox {} to be deleted", self.name))?;
lock(&self.sandboxes).remove(&self.name);
Ok(())
}
}
fn lock(registry: &Registry) -> std::sync::MutexGuard<'_, BTreeSet<String>> {
registry.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Short, lowercase, and DNS-label safe, so names work on every driver.
fn generate_run_id() -> String {
const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
let mut rng = rand::rng();
(0..8)
.map(|_| char::from(ALPHABET[rng.random_range(0..ALPHABET.len())]))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_ids_are_dns_label_safe() {
let id = generate_run_id();
assert_eq!(id.len(), 8);
assert!(
id.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()),
"{id}"
);
}
}
@@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Test failures.
use std::fmt;
use openshell_sdk::SdkError;
/// A test failure: a message with the context it happened in.
#[derive(Debug)]
pub struct Error {
message: String,
}
/// The result of a test or test step.
pub type Result<T = ()> = std::result::Result<T, Error>;
impl Error {
pub fn msg(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn from_display(error: impl fmt::Display) -> Self {
Self::msg(error.to_string())
}
#[must_use]
pub fn context(self, context: impl fmt::Display) -> Self {
Self::msg(format!("{context}: {}", self.message))
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for Error {}
impl From<SdkError> for Error {
fn from(error: SdkError) -> Self {
Self::from_display(error)
}
}
/// Add context to a failing step.
pub trait ResultExt<T> {
fn context(self, context: impl fmt::Display) -> Result<T>;
}
impl<T, E: Into<Error>> ResultExt<T> for std::result::Result<T, E> {
fn context(self, context: impl fmt::Display) -> Result<T> {
self.map_err(|error| error.into().context(context))
}
}
/// Fail the test unless `cond` holds.
///
/// ```ignore
/// testkit::ensure!(output.exit_code == 0, "exec exited with {}", output.exit_code);
/// ```
#[macro_export]
macro_rules! ensure {
($cond:expr $(,)?) => {
if !$cond {
return Err($crate::Error::msg(concat!("condition failed: ", stringify!($cond))));
}
};
($cond:expr, $($arg:tt)+) => {
if !$cond {
return Err($crate::Error::msg(format!($($arg)+)));
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_prefixes_message() {
let result: Result = Err(Error::msg("boom"));
let err = result.context("step two").context("test").unwrap_err();
assert_eq!(err.to_string(), "test: step two: boom");
}
#[test]
fn ensure_returns_error_with_message() {
fn check(value: i32) -> Result {
ensure!(value == 1, "value was {value}");
Ok(())
}
assert!(check(1).is_ok());
assert_eq!(check(2).unwrap_err().to_string(), "value was 2");
}
#[test]
fn ensure_without_message_names_condition() {
fn check(value: i32) -> Result {
ensure!(value == 1);
Ok(())
}
assert_eq!(
check(2).unwrap_err().to_string(),
"condition failed: value == 1"
);
}
}
@@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Shared support for `OpenShell` conformance tests.
//!
//! Conformance tests are black-box tests of a running `OpenShell` system,
//! driven through the Rust SDK. Each test is an `async fn` marked
//! `#[testkit::test]`:
//!
//! ```ignore
//! use testkit::Context;
//!
//! /// A long-running sandbox becomes ready, runs exec, and is gone after delete.
//! #[testkit::test]
//! async fn long_running_exec_delete(cx: &mut Context) -> testkit::Result {
//! let sandbox = cx.sandbox("lr").command(["sleep", "infinity"]).create().await?;
//! sandbox.wait_ready().await?;
//! // ...
//! Ok(())
//! }
//! ```
//!
//! Tests read the system under test from the target file named by
//! `OPENSHELL_TEST_TARGET` (see [`target`]).
mod context;
mod error;
mod poll;
mod run;
pub mod target;
pub use context::{
Context, DELETE_TIMEOUT, EXEC_TIMEOUT, ExecOutput, READY_TIMEOUT, Sandbox, SandboxBuilder,
};
pub use error::{Error, Result, ResultExt};
pub use openshell_sdk as sdk;
pub use poll::{Poll, poll_until};
pub use run::{TestInfo, run};
pub use target::{Capability, Target};
pub use testkit_macros::test;
#[doc(hidden)]
pub mod __private {
pub use tokio;
}
@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Waiting for observable state.
use std::time::{Duration, Instant};
use crate::error::{Error, Result};
/// The outcome of one observation.
pub enum Poll<T> {
/// The condition holds.
Ready(T),
/// Not yet; the string describes what was observed, for the timeout report.
Pending(String),
}
/// Observe until the condition holds or `timeout` elapses.
///
/// An error from `observe` fails immediately. On timeout, the error reports
/// the last observation.
pub async fn poll_until<T>(
what: &str,
timeout: Duration,
interval: Duration,
mut observe: impl AsyncFnMut() -> Result<Poll<T>>,
) -> Result<T> {
let started = Instant::now();
loop {
match observe().await? {
Poll::Ready(value) => return Ok(value),
Poll::Pending(observation) if started.elapsed() >= timeout => {
return Err(Error::msg(format!(
"timed out after {timeout:?} waiting for {what}; last observation: {observation}"
)));
}
Poll::Pending(_) => tokio::time::sleep(interval).await,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn returns_when_ready() {
let mut calls = 0;
let value = poll_until(
"three calls",
Duration::from_secs(5),
Duration::ZERO,
async || {
calls += 1;
Ok(if calls == 3 {
Poll::Ready(calls)
} else {
Poll::Pending(format!("call {calls}"))
})
},
)
.await
.unwrap();
assert_eq!(value, 3);
}
#[tokio::test]
async fn reports_last_observation_on_timeout() {
let err = poll_until::<()>("never", Duration::ZERO, Duration::ZERO, async || {
Ok(Poll::Pending("still waiting".to_string()))
})
.await
.unwrap_err();
assert!(err.to_string().contains("waiting for never"), "{err}");
assert!(err.to_string().contains("still waiting"), "{err}");
}
#[tokio::test]
async fn fails_fast_on_error() {
let err = poll_until::<()>(
"error",
Duration::from_mins(1),
Duration::ZERO,
async || Err(Error::msg("broken")),
)
.await
.unwrap_err();
assert_eq!(err.to_string(), "broken");
}
}
@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Running one test: load the target, check requirements, run, clean up.
use std::panic::AssertUnwindSafe;
use futures::FutureExt;
use crate::context::Context;
use crate::error::Result;
use crate::target::{Capability, Target};
/// Identity of a test, generated by `#[testkit::test]`.
#[derive(Clone, Copy, Debug)]
pub struct TestInfo {
pub id: &'static str,
pub description: &'static str,
pub requires: &'static [Capability],
}
/// Run a test body against the target named by `OPENSHELL_TEST_TARGET`.
///
/// Cleanup runs whether the body passes, fails, or panics. The test fails if
/// the body fails, or if cleanup fails after the body passed.
///
/// # Panics
///
/// Panics to fail the test.
pub async fn run<F>(info: TestInfo, body: F)
where
F: AsyncFnOnce(&mut Context) -> Result,
{
let id = info.id;
eprintln!("[{id}] {}", info.description);
let target = Target::from_env().unwrap_or_else(|error| panic!("[{id}] {error}"));
let missing = missing_requirements(&info, &target);
// The runner excludes tests the target can't support. Reaching here means
// it didn't, so fail rather than report a pass for an untested behavior.
assert!(
missing.is_empty(),
"[{id}] requires {}, which the target does not provide",
missing.join(", ")
);
let client = target
.connect()
.await
.unwrap_or_else(|error| panic!("[{id}] {error}"));
let mut cx = Context::new(client.workspace(&target.workspace));
eprintln!("[{id}] run id {}", cx.run_id());
let outcome = AssertUnwindSafe(body(&mut cx)).catch_unwind().await;
let cleanup_failures = cx.cleanup().await;
for failure in &cleanup_failures {
eprintln!("[{id}] cleanup failed: {failure}");
}
match outcome {
Err(panic) => std::panic::resume_unwind(panic),
Ok(Err(error)) => panic!("[{id}] {error}"),
Ok(Ok(())) => assert!(
cleanup_failures.is_empty(),
"[{id}] passed, but cleanup failed: {}",
cleanup_failures.join("; ")
),
}
}
fn missing_requirements(info: &TestInfo, target: &Target) -> Vec<&'static str> {
info.requires
.iter()
.filter(|capability| !target.capabilities.provides(**capability))
.map(|capability| capability.name())
.collect()
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn reports_missing_requirements() {
let target = Target::parse(
"gateway:\n endpoint: http://x\ncapabilities:\n gpu: true\n",
PathBuf::new(),
)
.unwrap();
let info = TestInfo {
id: "t",
description: "d",
requires: &[Capability::Gpu, Capability::ServiceExposure],
};
assert_eq!(missing_requirements(&info, &target), ["service_exposure"]);
}
}
@@ -0,0 +1,291 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! The target: how tests reach the system under test, and what it provides.
//!
//! An environment writes a target file; tests find it through
//! [`TARGET_ENV`]. Relative paths in the file resolve against its directory.
//!
//! ```yaml
//! gateway:
//! endpoint: https://127.0.0.1:17670
//! tls:
//! ca: ca.crt
//! client_cert: client.crt
//! client_key: client.key
//! workspace: default
//! capabilities:
//! compute_driver: docker
//! service_exposure: true
//! ```
use std::path::{Path, PathBuf};
use std::time::Duration;
use openshell_sdk::{AuthConfig, ClientConfig, EdgeAuthInterceptor, OpenShellClient};
use serde::Deserialize;
use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity};
use crate::error::{Error, Result, ResultExt};
/// Environment variable naming the target file.
pub const TARGET_ENV: &str = "OPENSHELL_TEST_TARGET";
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Target {
pub gateway: Gateway,
#[serde(default = "default_workspace")]
pub workspace: String,
#[serde(default)]
pub capabilities: Capabilities,
#[serde(skip)]
base_dir: PathBuf,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Gateway {
/// Gateway URL; the scheme selects plaintext (`http`) or TLS (`https`).
pub endpoint: String,
#[serde(default)]
pub tls: Option<Tls>,
#[serde(default)]
pub auth: Option<Auth>,
}
/// TLS material. A client certificate and key together select mTLS.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Tls {
pub ca: Option<PathBuf>,
pub client_cert: Option<PathBuf>,
pub client_key: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Auth {
pub bearer_token_file: PathBuf,
}
/// Facts about the environment, declared by the environment definition.
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Capabilities {
pub arch: Option<String>,
pub compute_driver: Option<String>,
#[serde(default)]
pub rootless: bool,
#[serde(default)]
pub gpu: bool,
#[serde(default)]
pub service_exposure: bool,
}
/// A capability a test can require with `#[testkit::test(requires = [...])]`.
///
/// Variants are the snake-case requirement names in CamelCase, so an unknown
/// requirement fails to compile.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Capability {
Gpu,
ServiceExposure,
}
impl Capability {
pub const fn name(self) -> &'static str {
match self {
Self::Gpu => "gpu",
Self::ServiceExposure => "service_exposure",
}
}
}
impl Capabilities {
pub const fn provides(&self, capability: Capability) -> bool {
match capability {
Capability::Gpu => self.gpu,
Capability::ServiceExposure => self.service_exposure,
}
}
}
fn default_workspace() -> String {
"default".to_string()
}
// Mirrors the SDK's endpoint settings for channels testkit builds itself.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
impl Target {
/// Load the target named by [`TARGET_ENV`].
pub fn from_env() -> Result<Self> {
let path = std::env::var_os(TARGET_ENV).ok_or_else(|| {
Error::msg(format!(
"{TARGET_ENV} is not set; it must name the target file for the system under test"
))
})?;
Self::from_path(Path::new(&path))
}
pub fn from_path(path: &Path) -> Result<Self> {
let yaml = std::fs::read_to_string(path)
.map_err(Error::from_display)
.context(format!("read target file {}", path.display()))?;
let base_dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
Self::parse(&yaml, base_dir).context(format!("load target file {}", path.display()))
}
/// Parse a target, resolving relative paths against `base_dir`.
pub fn parse(yaml: &str, base_dir: PathBuf) -> Result<Self> {
let mut target: Self = serde_yml::from_str(yaml).map_err(Error::from_display)?;
target.base_dir = base_dir;
target.validate()?;
Ok(target)
}
fn validate(&self) -> Result {
let gateway = &self.gateway;
let Some(tls) = &gateway.tls else {
return Ok(());
};
if tls.client_cert.is_some() != tls.client_key.is_some() {
return Err(Error::msg(
"gateway.tls.client_cert and gateway.tls.client_key must be set together",
));
}
if !gateway.endpoint.starts_with("https://") {
return Err(Error::msg(format!(
"gateway.tls is set but gateway.endpoint {:?} is not https",
gateway.endpoint
)));
}
Ok(())
}
fn resolve(&self, path: &Path) -> PathBuf {
self.base_dir.join(path)
}
fn read(&self, path: &Path) -> Result<Vec<u8>> {
let path = self.resolve(path);
std::fs::read(&path)
.map_err(Error::from_display)
.context(format!("read {}", path.display()))
}
/// Connect to the target's gateway.
pub async fn connect(&self) -> Result<OpenShellClient> {
let gateway = &self.gateway;
let tls = gateway.tls.as_ref();
let ca = tls
.and_then(|tls| tls.ca.as_deref())
.map(|path| self.read(path))
.transpose()?;
let token = gateway
.auth
.as_ref()
.map(|auth| {
self.read(&auth.bearer_token_file)
.map(|bytes| String::from_utf8_lossy(&bytes).trim().to_string())
})
.transpose()?;
let identity = match tls.map(|tls| (&tls.client_cert, &tls.client_key)) {
Some((Some(cert), Some(key))) => {
Some(Identity::from_pem(self.read(cert)?, self.read(key)?))
}
_ => None,
};
let Some(identity) = identity else {
let mut config = ClientConfig::new(gateway.endpoint.clone());
config.ca_cert = ca;
config.auth = token.map(AuthConfig::oidc);
return OpenShellClient::connect(config)
.await
.context(format!("connect to {}", gateway.endpoint));
};
// The SDK does not speak mTLS, so build the channel here and hand it
// to the SDK. Every SDK call, including exec, uses this channel.
let tls_config = ca.map_or_else(
|| ClientTlsConfig::new().with_enabled_roots(),
|ca| ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca)),
);
let channel = Endpoint::from_shared(gateway.endpoint.clone())
.map_err(Error::from_display)?
.connect_timeout(CONNECT_TIMEOUT)
.http2_adaptive_window(true)
.http2_keep_alive_interval(KEEP_ALIVE_INTERVAL)
.keep_alive_while_idle(true)
.tls_config(tls_config.identity(identity))
.map_err(Error::from_display)?
.connect()
.await
.map_err(Error::from_display)
.context(format!("connect to {} with mTLS", gateway.endpoint))?;
let interceptor = EdgeAuthInterceptor::new(token.as_deref(), None)?;
Ok(OpenShellClient::from_parts(channel, interceptor))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(yaml: &str) -> Result<Target> {
Target::parse(yaml, PathBuf::from("/targets/vm"))
}
#[test]
fn parses_mtls_target_and_resolves_relative_paths() {
let target = parse(
"gateway:\n endpoint: https://127.0.0.1:17670\n tls:\n ca: ca.crt\n client_cert: client.crt\n client_key: /abs/client.key\ncapabilities:\n compute_driver: docker\n service_exposure: true\n",
)
.unwrap();
let tls = target.gateway.tls.as_ref().unwrap();
assert_eq!(target.workspace, "default");
assert_eq!(
target.resolve(tls.ca.as_deref().unwrap()),
PathBuf::from("/targets/vm/ca.crt")
);
assert_eq!(
target.resolve(tls.client_key.as_deref().unwrap()),
PathBuf::from("/abs/client.key")
);
assert!(target.capabilities.provides(Capability::ServiceExposure));
assert!(!target.capabilities.provides(Capability::Gpu));
}
#[test]
fn parses_plaintext_target() {
let target = parse("gateway:\n endpoint: http://127.0.0.1:17670\n").unwrap();
assert!(target.gateway.tls.is_none());
assert!(!target.capabilities.provides(Capability::ServiceExposure));
}
#[test]
fn rejects_unknown_fields() {
let err = parse("gateway:\n endpoint: http://x\n endpiont: http://y\n").unwrap_err();
assert!(err.to_string().contains("endpiont"), "{err}");
}
#[test]
fn rejects_half_configured_client_identity() {
let err = parse("gateway:\n endpoint: https://x\n tls:\n client_cert: client.crt\n")
.unwrap_err();
assert!(err.to_string().contains("set together"), "{err}");
}
#[test]
fn rejects_tls_on_plaintext_endpoint() {
let err = parse("gateway:\n endpoint: http://x\n tls:\n ca: ca.crt\n").unwrap_err();
assert!(err.to_string().contains("not https"), "{err}");
}
}