mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-02 08:04:37 +08:00
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:
Generated
+2374
-13
File diff suppressed because it is too large
Load Diff
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user