chore: merge latest main into configuration snapshot branch

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
This commit is contained in:
Piotr Mlocek
2026-09-15 09:16:35 -07:00
21 changed files with 29 additions and 1093 deletions
+5 -7
View File
@@ -150,7 +150,7 @@ jobs:
name: openshell-conformance-x86_64-unknown-linux-musl
path: conformance-input
- name: Run RPM gateway continuity conformance
- name: Run RPM smoke conformance
shell: bash
run: |
set -euo pipefail
@@ -169,11 +169,9 @@ jobs:
--distro fedora \
--with podman-rootless \
--with selinux \
--copy "${candidate_cli_package[0]}:/var/lib/openshell-conformance/candidate/openshell.rpm" \
--copy "${candidate_gateway_package[0]}:/var/lib/openshell-conformance/candidate/openshell-gateway.rpm" \
--install "${candidate_cli_package[0]}" \
--install "${candidate_gateway_package[0]}" \
--copy conformance-input/openshell-conformance:/tmp/openshell-conformance \
--copy nix/test-guest/conformance-plans/gateway-upgrade-restart.toml:/tmp/conformance-plan.toml \
--provision openshell-rpm-latest-release \
--provision openshell-rpm \
--provision gateway-podman \
--provision openshell-rpm-gateway-upgrade \
-- /tmp/openshell-conformance run --plan /tmp/conformance-plan.toml
-- /tmp/openshell-conformance run smoke
Generated
-1
View File
@@ -4013,7 +4013,6 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"toml",
]
[[package]]
+3 -6
View File
@@ -292,12 +292,9 @@ pulls and explicit publication. OCI pulls require a trusted manifest digest
and retain that provenance with the local entry; mutable tags are used only
for explicit publication.
CLI conformance runs after target provisioning. Action-free scenarios operate
only through the configured OpenShell CLI. A versioned conformance plan may add
an ordered sequence of target-supplied host-side actions, such as a gateway
restart, while the scenario remains responsible for black-box sandbox
continuity checks. The plan exposes opaque executable paths and timeouts rather
than driver or package-manager configuration; target setup owns those details.
CLI conformance runs after target provisioning and operates only through the
configured OpenShell CLI. The smoke scenario verifies the black-box sandbox
lifecycle by creating, inspecting, executing in, and deleting a sandbox.
## Python Wheel Packaging
+10 -165
View File
@@ -3,19 +3,11 @@
//! Standalone runner for `OpenShell` CLI conformance scenarios.
use std::fmt::Write;
use std::future::Future;
use std::io::Read;
use std::path::PathBuf;
use std::pin::Pin;
use std::process::ExitCode;
use std::sync::Arc;
use clap::{Parser, Subcommand, ValueEnum};
use openshell_conformance::{
ConformancePlan, HostAction, HostActionExecutor, OpenShellRunner, PlanRun, Scenario,
default_scenarios, scenario, scenarios,
};
use openshell_conformance::{OpenShellRunner, Scenario, scenario, scenarios};
use serde::Serialize;
#[derive(Debug, Parser)]
@@ -36,16 +28,13 @@ enum Command {
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
output: OutputFormat,
},
/// Run action-free scenarios, named scenarios, or an explicit plan.
/// Run all registered scenarios, or named scenarios.
Run {
/// Action-free scenario names. Omit to run every action-free scenario.
/// Scenario names. Omit to run every registered scenario.
scenarios: Vec<String>,
/// Explicit path to the `OpenShell` CLI. Defaults to `openshell` on PATH.
#[arg(long)]
openshell_bin: Option<PathBuf>,
/// Versioned TOML conformance plan. Use '-' to read the plan from stdin.
#[arg(long, conflicts_with = "scenarios")]
plan: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
output: OutputFormat,
},
@@ -93,9 +82,8 @@ async fn execute(cli: Cli) -> Result<(), String> {
Command::Run {
scenarios: requested,
openshell_bin,
plan,
output,
} => run(&requested, openshell_bin, plan, output).await,
} => run(&requested, openshell_bin, output).await,
}
}
@@ -126,63 +114,20 @@ fn list(output: OutputFormat) -> Result<(), String> {
async fn run(
requested: &[String],
binary: Option<PathBuf>,
plan_path: Option<PathBuf>,
output: OutputFormat,
) -> Result<(), String> {
if let Some(plan_path) = plan_path {
let plan = read_plan(&plan_path)?;
return run_plan(&plan, binary, output).await;
}
let selected = select_scenarios(requested)?;
let mut results = Vec::with_capacity(selected.len());
for candidate in selected {
let plan_run = default_plan_run(candidate.name);
results.push(run_scenario(candidate, &plan_run, binary.as_ref(), None).await);
results.push(run_scenario(candidate, binary.as_ref()).await);
}
render_results(results, output)
}
async fn run_plan(
plan: &ConformancePlan,
binary: Option<PathBuf>,
output: OutputFormat,
) -> Result<(), String> {
let executor: Arc<dyn HostActionExecutor> = Arc::new(ProcessHostAction);
let mut results = Vec::with_capacity(plan.runs.len());
for plan_run in &plan.runs {
let candidate = scenario(&plan_run.scenario)
.expect("validated conformance plan references a registered scenario");
let result =
run_scenario(candidate, plan_run, binary.as_ref(), Some(executor.clone())).await;
let result = match (result.passed, &plan.diagnostics) {
(false, Some(diagnostics)) => append_diagnostics(result, &executor, diagnostics).await,
_ => result,
};
let passed = result.passed;
results.push(result);
if !passed {
break;
}
}
render_results(results, output)
}
fn default_plan_run(scenario: &str) -> PlanRun {
PlanRun {
scenario: scenario.to_string(),
workload_expectation: None,
actions: Vec::new(),
}
}
async fn run_scenario(
candidate: &'static Scenario,
plan_run: &PlanRun,
binary: Option<&PathBuf>,
host_action_executor: Option<Arc<dyn HostActionExecutor>>,
) -> ScenarioResult<'static> {
let runner = binary.map_or_else(
|| OpenShellRunner::new(candidate.name),
@@ -198,12 +143,9 @@ async fn run_scenario(
};
}
};
if let Some(host_action_executor) = host_action_executor {
runner = runner.with_host_action_executor(host_action_executor);
}
eprintln!("CLI conformance run ID: {}", runner.id());
let scenario_result = match runner.check_gateway_status().await {
Ok(()) => candidate.run(&mut runner, plan_run).await,
Ok(()) => candidate.run(&mut runner).await,
Err(error) => Err(error),
};
let outcome = runner.finish(scenario_result).await;
@@ -214,19 +156,6 @@ async fn run_scenario(
}
}
async fn append_diagnostics(
mut result: ScenarioResult<'static>,
executor: &Arc<dyn HostActionExecutor>,
diagnostics: &openshell_conformance::PlanDiagnostics,
) -> ScenarioResult<'static> {
let action = diagnostics.as_action();
if let Err(error) = executor.execute(&action).await {
let diagnostic = result.diagnostic.get_or_insert_default();
let _ = write!(diagnostic, "\n\nsecondary diagnostics failure:\n{error}");
}
result
}
fn render_results(
results: Vec<ScenarioResult<'static>>,
output: OutputFormat,
@@ -262,86 +191,20 @@ fn render_results(
}
}
fn read_plan(path: &PathBuf) -> Result<ConformancePlan, String> {
let contents = if path.as_os_str() == "-" {
let mut input = String::new();
std::io::stdin()
.read_to_string(&mut input)
.map_err(|error| format!("read conformance plan from stdin: {error}"))?;
input
} else {
std::fs::read_to_string(path)
.map_err(|error| format!("read conformance plan {}: {error}", path.display()))?
};
ConformancePlan::parse(&contents).map_err(|error| format!("invalid conformance plan: {error}"))
}
fn select_scenarios(requested: &[String]) -> Result<Vec<&'static Scenario>, String> {
if requested.is_empty() {
return Ok(default_scenarios().collect());
return Ok(scenarios().iter().collect());
}
requested
.iter()
.map(|name| {
let candidate = scenario(name).ok_or_else(|| {
scenario(name).ok_or_else(|| {
format!("unknown scenario '{name}'; run `openshell-conformance list`")
})?;
if candidate.requires_plan() {
return Err(format!(
"scenario '{name}' requires an explicit --plan; run `openshell-conformance list`"
));
}
Ok(candidate)
})
})
.collect()
}
struct ProcessHostAction;
impl HostActionExecutor for ProcessHostAction {
fn execute(
&self,
action: &HostAction,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + '_>> {
let name = action.name.clone();
let command = action.command.clone();
let timeout = action.timeout();
let timeout_secs = action.timeout_secs;
Box::pin(async move {
let mut process = tokio::process::Command::new(&command);
process.kill_on_drop(true);
let output = tokio::time::timeout(timeout, process.output())
.await
.map_err(|_| {
format!(
"host action {:?} command '{}' timed out after {}s",
name,
command.display(),
timeout_secs,
)
})?
.map_err(|error| {
format!(
"start host action {:?} command '{}': {error}",
name,
command.display(),
)
})?;
if output.status.success() {
return Ok(());
}
Err(format!(
"host action {:?} command '{}' exited {:?}:\nstdout:\n{}\nstderr:\n{}",
name,
command.display(),
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
))
})
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
@@ -352,7 +215,7 @@ mod tests {
fn selects_all_scenarios_by_default() {
assert_eq!(
select_scenarios(&[]).expect("select all").len(),
default_scenarios().count()
scenarios().len()
);
}
@@ -368,14 +231,6 @@ mod tests {
assert!(error.contains("openshell-conformance list"));
}
#[test]
fn action_scenario_requires_an_explicit_plan() {
let error = select_scenarios(&["sandbox-continuity".to_string()])
.expect_err("action scenario requires a plan");
assert!(error.contains("requires an explicit --plan"));
}
#[test]
fn parses_binary_override_and_json_output() {
let cli = Cli::try_parse_from([
@@ -399,14 +254,4 @@ mod tests {
assert_eq!(openshell_bin, Some(PathBuf::from("/opt/openshell")));
assert_eq!(output, OutputFormat::Json);
}
#[test]
fn parses_plan_from_stdin() {
let cli = Cli::try_parse_from(["openshell-conformance", "run", "--plan", "-"])
.expect("parse plan from stdin");
let Command::Run { plan, .. } = cli.command else {
panic!("expected run")
};
assert_eq!(plan, Some(PathBuf::from("-")));
}
}
-1
View File
@@ -15,7 +15,6 @@ rand.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
toml.workspace = true
[dev-dependencies]
tempfile = "3"
+5 -89
View File
@@ -4,7 +4,6 @@
//! Reusable support for portable `OpenShell` CLI conformance scenarios.
pub mod executor;
pub mod plan;
mod scenarios;
use std::collections::BTreeSet;
@@ -24,56 +23,25 @@ use tokio::time::sleep;
use self::executor::{CliExecutionError, CliExecutor, ProcessCli};
pub use plan::{ConformancePlan, HostAction, PlanDiagnostics, PlanRun, WorkloadExpectation};
pub use scenarios::{SANDBOX_CONTINUITY_SCENARIO, SMOKE_SCENARIO};
pub use scenarios::SMOKE_SCENARIO;
/// An installed conformance scenario.
#[derive(Debug)]
pub struct Scenario {
pub name: &'static str,
pub description: &'static str,
requires_plan: bool,
run: for<'a> fn(&'a mut OpenShellRunner, &'a PlanRun) -> ScenarioFuture<'a>,
validate_plan_run: Option<PlanRunValidator>,
run: for<'a> fn(&'a mut OpenShellRunner) -> ScenarioFuture<'a>,
}
pub type ScenarioFuture<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
type PlanRunValidator = fn(&PlanRun) -> Result<(), String>;
impl Scenario {
pub async fn run(
&self,
runner: &mut OpenShellRunner,
plan_run: &PlanRun,
) -> Result<(), String> {
self.validate_plan_run(plan_run)?;
(self.run)(runner, plan_run).await
}
pub fn validate_plan_run(&self, plan_run: &PlanRun) -> Result<(), String> {
self.validate_plan_run.map_or_else(
|| default_validate_plan_run(plan_run),
|validate| validate(plan_run),
)
}
/// Whether this scenario may run only through an explicit target plan.
pub fn requires_plan(&self) -> bool {
self.requires_plan
pub async fn run(&self, runner: &mut OpenShellRunner) -> Result<(), String> {
(self.run)(runner).await
}
}
fn default_validate_plan_run(plan_run: &PlanRun) -> Result<(), String> {
if plan_run.workload_expectation.is_some() || !plan_run.actions.is_empty() {
return Err(format!(
"scenario {:?} does not accept workload_expectation or actions",
plan_run.scenario
));
}
Ok(())
}
const SCENARIOS: &[Scenario] = &[SMOKE_SCENARIO, SANDBOX_CONTINUITY_SCENARIO];
const SCENARIOS: &[Scenario] = &[SMOKE_SCENARIO];
/// Returns every scenario compiled into this distribution.
pub fn scenarios() -> &'static [Scenario] {
@@ -85,13 +53,6 @@ pub fn scenario(name: &str) -> Option<&'static Scenario> {
scenarios().iter().find(|candidate| candidate.name == name)
}
/// Returns scenarios that need no host-level disruption capability.
pub fn default_scenarios() -> impl Iterator<Item = &'static Scenario> {
scenarios()
.iter()
.filter(|scenario| !scenario.requires_plan)
}
const CLEANUP_TIMEOUT: Duration = Duration::from_mins(2);
pub const STATUS_TIMEOUT: Duration = Duration::from_secs(30);
const GATEWAY_STATUS_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -284,26 +245,12 @@ struct AuthenticationOutput {
/// Runs `OpenShell` commands for one conformance scenario and owns its cleanup.
pub struct OpenShellRunner {
cli: Arc<dyn CliExecutor>,
host_action_executor: Option<Arc<dyn HostActionExecutor>>,
run_id: String,
scenario: String,
known_sandboxes: BTreeSet<String>,
finished: bool,
}
/// Executes one target-supplied host-side action from an explicit plan.
///
/// This intentionally differs from [`CliExecutor`]: that executor models
/// `OpenShell` CLI invocations through the runner's configured binary and emits
/// structured command results, while an action is a plan-owned executable with
/// no caller-provided arguments.
pub trait HostActionExecutor: Send + Sync {
fn execute(
&self,
action: &HostAction,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + '_>>;
}
/// A runner command with diagnostic context but no timeout yet.
pub struct CommandStep<'a> {
runner: &'a OpenShellRunner,
@@ -345,7 +292,6 @@ impl OpenShellRunner {
pub fn with_executor(cli: Arc<dyn CliExecutor>, scenario: &str) -> Self {
Self {
cli,
host_action_executor: None,
run_id: generate_run_id(),
scenario: scenario.to_string(),
known_sandboxes: BTreeSet::new(),
@@ -353,36 +299,6 @@ impl OpenShellRunner {
}
}
/// Attaches the target-side executor used only by explicit plan actions.
#[must_use]
pub fn with_host_action_executor(
mut self,
host_action_executor: Arc<dyn HostActionExecutor>,
) -> Self {
self.host_action_executor = Some(host_action_executor);
self
}
/// Execute a target-supplied action declared by the active plan.
pub async fn execute_host_action(&self, action: &HostAction) -> Result<(), String> {
let Some(host_action_executor) = &self.host_action_executor else {
return Err(format!(
"{} requires a host action executor; run this scenario through an explicit plan",
self.context(&format!("action/{}", action.name))
));
};
eprintln!(
"{} applying target host action",
self.context(&format!("action/{}", action.name))
);
host_action_executor.execute(action).await.map_err(|error| {
format!(
"{} failed: {error}",
self.context(&format!("action/{}", action.name))
)
})
}
pub fn id(&self) -> &str {
&self.run_id
}
-243
View File
@@ -1,243 +0,0 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Versioned, target-supplied execution plans for conformance scenarios.
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::Deserialize;
use crate::scenario;
pub const PLAN_VERSION: u32 = 1;
#[derive(Debug, Deserialize)]
pub struct ConformancePlan {
pub version: u32,
#[serde(default)]
pub runs: Vec<PlanRun>,
pub diagnostics: Option<PlanDiagnostics>,
}
#[derive(Debug, Deserialize)]
pub struct PlanRun {
pub scenario: String,
pub workload_expectation: Option<WorkloadExpectation>,
#[serde(default)]
pub actions: Vec<HostAction>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum WorkloadExpectation {
Reconciled,
}
#[derive(Clone, Debug, Deserialize)]
pub struct HostAction {
pub name: String,
pub command: PathBuf,
pub timeout_secs: u64,
}
impl HostAction {
pub fn timeout(&self) -> Duration {
Duration::from_secs(self.timeout_secs)
}
}
#[derive(Debug, Deserialize)]
pub struct PlanDiagnostics {
pub command: PathBuf,
pub timeout_secs: u64,
}
impl PlanDiagnostics {
pub fn as_action(&self) -> HostAction {
HostAction {
name: "diagnostics".to_string(),
command: self.command.clone(),
timeout_secs: self.timeout_secs,
}
}
}
impl ConformancePlan {
pub fn parse(input: &str) -> Result<Self, String> {
let plan = toml::from_str::<Self>(input).map_err(|error| error.to_string())?;
plan.validate()?;
Ok(plan)
}
fn validate(&self) -> Result<(), String> {
if self.version != PLAN_VERSION {
return Err(format!(
"unsupported conformance plan version {}; expected {PLAN_VERSION}",
self.version
));
}
if self.runs.is_empty() {
return Err("conformance plan must contain at least one run".to_string());
}
if let Some(diagnostics) = &self.diagnostics {
validate_command(
"diagnostics",
&diagnostics.command,
diagnostics.timeout_secs,
)?;
}
for run in &self.runs {
let Some(scenario) = scenario(&run.scenario) else {
return Err(format!(
"unknown scenario {:?}; run `openshell-conformance list`",
run.scenario
));
};
scenario.validate_plan_run(run)?;
for action in &run.actions {
validate_command(
&format!("action {:?}", action.name),
&action.command,
action.timeout_secs,
)?;
}
}
Ok(())
}
}
fn validate_command(label: &str, command: &Path, timeout_secs: u64) -> Result<(), String> {
if !command.is_absolute() {
return Err(format!(
"{label} command must be an absolute path: {}",
command.display()
));
}
if timeout_secs == 0 {
return Err(format!("{label} timeout_secs must be greater than zero"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(windows))]
const RESTART_GATEWAY_COMMAND: &str = "/usr/local/libexec/restart-gateway";
#[cfg(windows)]
const RESTART_GATEWAY_COMMAND: &str = r"C:\usr\local\libexec\restart-gateway";
#[cfg(not(windows))]
const DIAGNOSTICS_COMMAND: &str = "/usr/local/libexec/diagnostics";
#[cfg(windows)]
const DIAGNOSTICS_COMMAND: &str = r"C:\usr\local\libexec\diagnostics";
#[test]
fn parses_a_smoke_and_continuity_plan() {
let plan = ConformancePlan::parse(&format!(
r#"
version = 1
[[runs]]
scenario = "smoke"
[[runs]]
scenario = "sandbox-continuity"
workload_expectation = "reconciled"
[[runs.actions]]
name = "gateway-upgrade"
command = '{RESTART_GATEWAY_COMMAND}'
timeout_secs = 120
"#,
))
.expect("valid plan");
assert_eq!(plan.runs.len(), 2);
assert_eq!(plan.runs[1].actions[0].name, "gateway-upgrade");
}
#[test]
fn parses_plan_diagnostics() {
let plan = ConformancePlan::parse(&format!(
r#"
version = 1
[diagnostics]
command = '{DIAGNOSTICS_COMMAND}'
timeout_secs = 60
[[runs]]
scenario = "smoke"
"#,
))
.expect("valid plan with diagnostics");
assert_eq!(
plan.diagnostics
.expect("diagnostics configured")
.as_action()
.name,
"diagnostics"
);
}
#[test]
fn rejects_a_relative_action_command() {
let error = ConformancePlan::parse(
r#"
version = 1
[[runs]]
scenario = "sandbox-continuity"
workload_expectation = "reconciled"
[[runs.actions]]
name = "gateway-restart"
command = "restart-gateway"
timeout_secs = 120
"#,
)
.expect_err("relative command must fail");
assert!(error.contains("absolute path"));
}
#[test]
fn rejects_an_actionless_continuity_run() {
let error = ConformancePlan::parse(
r#"
version = 1
[[runs]]
scenario = "sandbox-continuity"
workload_expectation = "reconciled"
"#,
)
.expect_err("continuity requires an action");
assert!(error.contains("requires at least one action"));
}
#[test]
fn default_validation_rejects_actions_for_smoke() {
let error = ConformancePlan::parse(
r#"
version = 1
[[runs]]
scenario = "smoke"
[[runs.actions]]
name = "gateway-restart"
command = "/usr/local/libexec/restart-gateway"
timeout_secs = 120
"#,
)
.expect_err("smoke does not accept actions");
assert!(error.contains("does not accept workload_expectation or actions"));
}
}
@@ -3,8 +3,6 @@
//! Registered, portable conformance scenarios.
mod sandbox_continuity;
mod smoke;
pub use sandbox_continuity::SANDBOX_CONTINUITY_SCENARIO;
pub use smoke::SMOKE_SCENARIO;
@@ -1,207 +0,0 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Product-visible sandbox continuity across host-side actions.
use std::time::Duration;
use serde::Deserialize;
use crate::{
HostAction, OpenShellRunner, PlanRun, Poll, Scenario, ScenarioFuture, WorkloadExpectation,
};
const CREATE_TIMEOUT: Duration = Duration::from_mins(10);
const COMMAND_TIMEOUT: Duration = Duration::from_mins(2);
const RECOVERY_TIMEOUT: Duration = Duration::from_mins(4);
const RECOVERY_INTERVAL: Duration = Duration::from_secs(2);
#[derive(Debug, Deserialize)]
struct SandboxState {
name: String,
phase: String,
}
/// Certify sandbox state and workspace continuity across host-side actions.
pub const SANDBOX_CONTINUITY_SCENARIO: Scenario = Scenario {
name: "sandbox-continuity",
description: "Verify sandbox state and workspace continuity across planned host actions.",
requires_plan: true,
run: run_sandbox_continuity,
validate_plan_run: Some(validate_plan_run),
};
fn run_sandbox_continuity<'a>(
runner: &'a mut OpenShellRunner,
plan_run: &'a PlanRun,
) -> ScenarioFuture<'a> {
Box::pin(async move { run_sandbox_continuity_inner(runner, plan_run).await })
}
fn validate_plan_run(plan_run: &PlanRun) -> Result<(), String> {
if plan_run.workload_expectation != Some(WorkloadExpectation::Reconciled) {
return Err(
"scenario 'sandbox-continuity' requires workload_expectation = \"reconciled\""
.to_string(),
);
}
if plan_run.actions.is_empty() {
return Err("scenario 'sandbox-continuity' requires at least one action".to_string());
}
Ok(())
}
async fn run_sandbox_continuity_inner(
runner: &mut OpenShellRunner,
plan_run: &PlanRun,
) -> Result<(), String> {
// The VM driver permits at most 19 characters in a sandbox name.
let running_name = format!("ct-{}-r", runner.id());
let stopped_name = format!("ct-{}-s", runner.id());
let marker = format!("openshell-sandbox-continuity-{}", runner.id());
let marker_path = "/sandbox/.openshell-sandbox-continuity";
let running_script =
format!("printf '%s\\n' '{marker}' > {marker_path}; while true; do sleep 1; done");
create_retained_sandbox(runner, &running_name, &running_script, "running").await?;
assert_marker(runner, &running_name, marker_path, &marker, "pre-action").await?;
create_retained_sandbox(
runner,
&stopped_name,
"while true; do sleep 1; done",
"stopped",
)
.await?;
let stop = runner
.step("stop")
.description(format!("sandbox '{stopped_name}' stops"))
.with_timeout(COMMAND_TIMEOUT)
.run(&["sandbox", "stop", &stopped_name])
.await
.map_err(|error| error.to_string())?;
stop.require_success()?;
wait_for_phase(runner, &stopped_name, "Stopped", "stopped-before-actions").await?;
for action in &plan_run.actions {
apply_action_and_assert(
runner,
action,
&running_name,
&stopped_name,
marker_path,
&marker,
)
.await?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn apply_action_and_assert(
runner: &mut OpenShellRunner,
action: &HostAction,
running_name: &str,
stopped_name: &str,
marker_path: &str,
marker: &str,
) -> Result<(), String> {
let step = format!("after-{}", action.name);
runner.execute_host_action(action).await?;
runner.check_gateway_status().await?;
wait_for_phase(runner, running_name, "Ready", &format!("running-{step}")).await?;
wait_for_phase(runner, stopped_name, "Stopped", &format!("stopped-{step}")).await?;
assert_marker(runner, running_name, marker_path, marker, &step).await
}
async fn create_retained_sandbox(
runner: &mut OpenShellRunner,
name: &str,
script: &str,
step: &str,
) -> Result<(), String> {
runner.track_sandbox(name);
let create = runner
.step(format!("create-{step}"))
.description(format!("retained sandbox '{name}' is created"))
.with_timeout(CREATE_TIMEOUT)
.run(&[
"sandbox", "create", "--name", name, "--from", "base", "--detach", "--no-tty", "--",
"sh", "-lc", script,
])
.await
.map_err(|error| error.to_string())?;
create.require_success()
}
async fn assert_marker(
runner: &OpenShellRunner,
name: &str,
path: &str,
marker: &str,
step: &str,
) -> Result<(), String> {
let result = runner
.step(format!("marker-{step}"))
.description(format!("sandbox '{name}' retains its workspace marker"))
.with_timeout(COMMAND_TIMEOUT)
.run(&[
"sandbox", "exec", "--name", name, "--no-tty", "--", "cat", path,
])
.await
.map_err(|error| error.to_string())?;
result.require_success()?;
if result.stdout().lines().any(|line| line.trim() == marker) {
Ok(())
} else {
Err(result.failure_diagnostic(&format!(
"sandbox '{name}' stdout contains marker {marker:?}"
)))
}
}
async fn wait_for_phase(
runner: &mut OpenShellRunner,
name: &str,
expected_phase: &str,
step: &str,
) -> Result<(), String> {
let name = name.to_string();
let expected_phase = expected_phase.to_string();
let step = step.to_string();
let poll_step = step.clone();
runner
.poll_until(
&poll_step,
RECOVERY_TIMEOUT,
RECOVERY_INTERVAL,
async move |runner| {
let result = runner
.step(format!("{step}/get"))
.description(format!("sandbox '{name}' reaches phase {expected_phase}"))
.with_timeout(COMMAND_TIMEOUT)
.run(&["sandbox", "get", &name, "--output", "json"])
.await;
match result {
Ok(result) if !result.success() => Poll::Pending(
result.failure_diagnostic(&format!("sandbox '{name}' can be retrieved")),
),
Ok(result) => match result.json::<SandboxState>() {
Ok(state) if state.name != name => Poll::Failed(format!(
"sandbox get returned {:?}; expected '{name}'",
state.name
)),
Ok(state) if state.phase == expected_phase => Poll::Ready(()),
Ok(state) => Poll::Pending(format!(
"sandbox '{name}' phase is {:?}; expected {expected_phase:?}",
state.phase
)),
Err(error) => Poll::Failed(error.to_string()),
},
Err(error) => Poll::Pending(error.to_string()),
}
},
)
.await
.map_err(|error| error.to_string())
}
@@ -5,7 +5,7 @@
use std::time::{Duration, Instant};
use crate::{OpenShellRunner, PlanRun, STATUS_TIMEOUT, Scenario, ScenarioFuture};
use crate::{OpenShellRunner, STATUS_TIMEOUT, Scenario, ScenarioFuture};
use serde::Deserialize;
use tokio::time::sleep;
@@ -32,12 +32,10 @@ struct SandboxListPage {
pub const SMOKE_SCENARIO: Scenario = Scenario {
name: "smoke",
description: "Create, inspect, execute in, and delete a base sandbox.",
requires_plan: false,
run: run_smoke,
validate_plan_run: None,
};
fn run_smoke<'a>(runner: &'a mut OpenShellRunner, _plan_run: &'a PlanRun) -> ScenarioFuture<'a> {
fn run_smoke(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> {
Box::pin(async move { run_smoke_inner(runner).await })
}
+4 -52
View File
@@ -53,8 +53,7 @@ nix/test-guest/
└── roles/
├── gateway-podman/
├── openshell-development/
├── openshell-rpm/
└── openshell-rpm-gateway-upgrade/
└── openshell-rpm/
```
- `default.nix` assembles the guest and cache flake apps. It selects host architecture and acceleration, supplies the runtime tools, and exposes distro profiles and configuration playbooks as Nix-store catalogs.
@@ -167,31 +166,6 @@ copied artifacts are present. Unlike `--with`, provisioners are not cached.
They can therefore install and start an OpenShell system without coupling the
prepared guest image to a particular build or driver configuration.
Provisioners that support gateway continuity install a target-control command:
```text
/home/openshell/.local/bin/openshell-test-guest-gateway-restart
```
It restarts an already-provisioned gateway and exits only after CLI health
succeeds. An explicit conformance plan consumes that stable test-guest contract
without knowing how the provisioner implements it:
```shell
openshell-conformance run --plan - <<'EOF'
version = 1
[[runs]]
scenario = "sandbox-continuity"
workload_expectation = "reconciled"
[[runs.actions]]
name = "gateway-restart"
command = "/home/openshell/.local/bin/openshell-test-guest-gateway-restart"
timeout_secs = 120
EOF
```
`openshell-development` expects these copied guest paths:
- `/usr/local/bin/openshell`
@@ -213,36 +187,14 @@ nix run .#test-guest -- \
--copy ./openshell-sandbox.tar:/usr/local/lib/openshell-sandbox.tar \
--provision openshell-development \
--provision gateway-podman \
-- /usr/local/bin/openshell-conformance run --plan - <<'EOF'
version = 1
[[runs]]
scenario = "sandbox-continuity"
workload_expectation = "reconciled"
[[runs.actions]]
name = "gateway-restart"
command = "/home/openshell/.local/bin/openshell-test-guest-gateway-restart"
timeout_secs = 120
EOF
-- /usr/local/bin/openshell-conformance run smoke
```
`openshell-rpm` expects OpenShell to have been installed with `--install`. It
uses the RPM-owned `/usr/bin` binaries and `openshell-gateway` user service,
without copied development artifacts or a supervisor archive. Compose it with
`gateway-podman` to start the installed version. The existing upgrade flow uses
the same role with the rootless configuration before
`openshell-rpm-gateway-upgrade`.
`openshell-rpm-latest-release` downloads and installs the latest stable
OpenShell GitHub release for the guest architecture, then publishes the same
RPM installation contract. Compose it with `gateway-podman` and an RPM gateway
action when testing an upgrade from the current release.
Versioned plans under `nix/test-guest/conformance-plans/` bind conformance
scenarios to the stable action-command contracts installed by provisioners.
Copy the applicable plan to the guest and pass it to `openshell-conformance run
--plan`.
`gateway-podman` to start the installed version before running smoke
conformance.
## Prepared VM cache
@@ -1,25 +0,0 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
version = 1
[diagnostics]
command = "/home/openshell/.local/bin/openshell-test-guest-diagnostics"
timeout_secs = 60
[[runs]]
scenario = "smoke"
[[runs]]
scenario = "sandbox-continuity"
workload_expectation = "reconciled"
[[runs.actions]]
name = "gateway-upgrade"
command = "/home/openshell/.local/bin/openshell-test-guest-gateway-upgrade"
timeout_secs = 120
[[runs.actions]]
name = "gateway-restart"
command = "/home/openshell/.local/bin/openshell-test-guest-gateway-restart"
timeout_secs = 120
-3
View File
@@ -43,10 +43,7 @@ let
provisionerRoles = [
"openshell-development"
"openshell-rpm"
"openshell-rpm-latest-release"
"gateway-podman"
"openshell-rpm-gateway-reinstall"
"openshell-rpm-gateway-upgrade"
];
mkDistroProfile =
@@ -1,7 +0,0 @@
---
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
openshell_gateway_service_command: /home/openshell/.local/bin/openshell-test-guest-as-gateway-user
openshell_gateway_restart_command: /home/openshell/.local/bin/openshell-test-guest-gateway-restart
openshell_gateway_diagnostics_command: /home/openshell/.local/bin/openshell-test-guest-diagnostics
@@ -80,56 +80,3 @@
ansible.builtin.assert:
that: gateway_health is not failed
fail_msg: The provisioned gateway did not become healthy.
- name: Create the target gateway-control command directory
ansible.builtin.file:
path: "{{ openshell_gateway_restart_command | dirname }}"
state: directory
mode: "0700"
- name: Install the gateway service-account command wrapper
ansible.builtin.copy:
dest: "{{ openshell_gateway_service_command }}"
mode: "0700"
content: |
#!/usr/bin/env bash
set -Eeuo pipefail
exec sudo -H -u {{ openshell_gateway_service_user }} env \
HOME={{ openshell_gateway_service_home }} \
XDG_RUNTIME_DIR=/run/user/{{ openshell_gateway_service_uid }} \
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ openshell_gateway_service_uid }}/bus \
"$@"
- name: Install the target gateway restart command
ansible.builtin.copy:
dest: "{{ openshell_gateway_restart_command }}"
mode: "0700"
content: |
#!/usr/bin/env bash
set -Eeuo pipefail
gateway_user_command={{ openshell_gateway_service_command | quote }}
"${gateway_user_command}" systemctl --user restart {{ openshell_gateway_service }}
for _ in $(seq 1 60); do
if "${gateway_user_command}" {{ openshell_cli_bin }} status >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
"${gateway_user_command}" systemctl --user status {{ openshell_gateway_service }} --no-pager >&2 || true
exit 1
- name: Install the target gateway diagnostics command
ansible.builtin.copy:
dest: "{{ openshell_gateway_diagnostics_command }}"
mode: "0700"
content: |
#!/usr/bin/env bash
set -u
gateway_user_command={{ openshell_gateway_service_command | quote }}
"${gateway_user_command}" systemctl --user status {{ openshell_gateway_service }} --no-pager || true
"${gateway_user_command}" journalctl --user -u {{ openshell_gateway_service }} --no-pager -n 200 || true
"${gateway_user_command}" podman info || true
"${gateway_user_command}" podman ps --all || true
if command -v getenforce >/dev/null 2>&1; then
getenforce || true
fi
@@ -1,7 +0,0 @@
---
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
openshell_rpm_reinstall_cli: /var/lib/openshell-conformance/candidate/openshell.rpm
openshell_rpm_reinstall_gateway: /var/lib/openshell-conformance/candidate/openshell-gateway.rpm
openshell_rpm_reinstall_command: /home/openshell/.local/bin/openshell-test-guest-gateway-reinstall
@@ -1,70 +0,0 @@
---
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
- name: Require RPM gateway provisioner dependencies
ansible.builtin.assert:
that:
- openshell_install_source | default('') == 'rpm'
- openshell_cli_bin is defined
- openshell_gateway_service is defined
- openshell_gateway_runtime | default('') == 'rootless-podman'
fail_msg: >-
openshell-rpm-gateway-reinstall requires openshell-rpm and a configured
gateway runtime.
- name: Verify candidate RPMs for reinstall
ansible.builtin.stat:
path: "{{ item }}"
loop:
- "{{ openshell_rpm_reinstall_cli }}"
- "{{ openshell_rpm_reinstall_gateway }}"
register: openshell_rpm_reinstall_candidates
- name: Require copied candidate RPMs
ansible.builtin.assert:
that: item.stat.exists
fail_msg: "missing candidate RPM: {{ item.item }}"
loop: "{{ openshell_rpm_reinstall_candidates.results }}"
loop_control:
label: "{{ item.item }}"
- name: Create the target reinstall command directory
ansible.builtin.file:
path: "{{ openshell_rpm_reinstall_command | dirname }}"
state: directory
mode: "0700"
- name: Install the target RPM reinstall command
ansible.builtin.copy:
dest: "{{ openshell_rpm_reinstall_command }}"
mode: "0700"
content: |
#!/usr/bin/env bash
set -Eeuo pipefail
latest_transaction() {
sudo dnf history list --reverse --quiet | awk 'NR == 1 { print $1 }'
}
before_transaction="$(latest_transaction)"
sudo dnf reinstall -y --nogpgcheck {{ openshell_rpm_reinstall_cli }} {{ openshell_rpm_reinstall_gateway }}
after_transaction="$(latest_transaction)"
if [ -z "${after_transaction}" ] || [ "${after_transaction}" = "${before_transaction}" ]; then
echo "RPM reinstall did not create a DNF transaction" >&2
exit 1
fi
transaction_info="$(sudo dnf history info "${after_transaction}")"
grep -Eq 'Reinstall[[:space:]]+openshell-' <<<"${transaction_info}"
grep -Eq 'Reinstall[[:space:]]+openshell-gateway-' <<<"${transaction_info}"
rpm -V openshell openshell-gateway
systemctl --user daemon-reload
systemctl --user restart {{ openshell_gateway_service }}
for _ in $(seq 1 60); do
if {{ openshell_cli_bin }} status >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
systemctl --user status {{ openshell_gateway_service }} --no-pager >&2 || true
exit 1
@@ -1,7 +0,0 @@
---
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
openshell_rpm_upgrade_cli: /var/lib/openshell-conformance/candidate/openshell.rpm
openshell_rpm_upgrade_gateway: /var/lib/openshell-conformance/candidate/openshell-gateway.rpm
openshell_rpm_upgrade_command: /home/openshell/.local/bin/openshell-test-guest-gateway-upgrade
@@ -1,59 +0,0 @@
---
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
- name: Require RPM gateway provisioner dependencies
ansible.builtin.assert:
that:
- openshell_install_source | default('') == 'rpm'
- openshell_cli_bin is defined
- openshell_gateway_service is defined
- openshell_gateway_runtime | default('') == 'rootless-podman'
fail_msg: >-
openshell-rpm-gateway-upgrade requires openshell-rpm and a configured
gateway runtime.
- name: Verify candidate RPMs for upgrade
ansible.builtin.stat:
path: "{{ item }}"
loop:
- "{{ openshell_rpm_upgrade_cli }}"
- "{{ openshell_rpm_upgrade_gateway }}"
register: openshell_rpm_upgrade_candidates
- name: Require copied candidate RPMs
ansible.builtin.assert:
that: item.stat.exists
fail_msg: "missing required RPM upgrade candidate: {{ item.item }}"
loop: "{{ openshell_rpm_upgrade_candidates.results }}"
- name: Install the target RPM upgrade command
ansible.builtin.copy:
dest: "{{ openshell_rpm_upgrade_command }}"
mode: "0700"
content: |
#!/usr/bin/env bash
set -Eeuo pipefail
baseline_cli="$(rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' openshell)"
baseline_gateway="$(rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' openshell-gateway)"
candidate_cli="$(rpm -qp --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' {{ openshell_rpm_upgrade_cli }})"
candidate_gateway="$(rpm -qp --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' {{ openshell_rpm_upgrade_gateway }})"
if [[ "$candidate_cli" == "$baseline_cli" || "$candidate_gateway" == "$baseline_gateway" ]]; then
echo "candidate RPMs must differ from the installed baseline" >&2
exit 1
fi
sudo dnf install -y --allowerasing --nogpgcheck {{ openshell_rpm_upgrade_cli }} {{ openshell_rpm_upgrade_gateway }}
installed_cli="$(rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' openshell)"
installed_gateway="$(rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' openshell-gateway)"
if [[ "$installed_cli" != "$candidate_cli" || "$installed_gateway" != "$candidate_gateway" ]]; then
echo "installed RPMs do not match the candidate RPMs" >&2
exit 1
fi
rpm -V openshell openshell-gateway
systemctl --user daemon-reload
systemctl --user restart {{ openshell_gateway_service }}
for _ in $(seq 1 60); do
{{ openshell_cli_bin }} status >/dev/null 2>&1 && exit 0
sleep 1
done
exit 1
@@ -1,6 +0,0 @@
---
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
openshell_rpm_latest_release_api_url: https://api.github.com/repos/NVIDIA/OpenShell/releases/latest
openshell_rpm_latest_release_download_dir: /var/lib/openshell-conformance/baseline
@@ -1,79 +0,0 @@
---
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
- name: Resolve the guest RPM architecture
ansible.builtin.command:
cmd: rpm --eval '%{_arch}'
register: openshell_rpm_latest_release_arch
changed_when: false
- name: Query the latest OpenShell release
ansible.builtin.uri:
url: "{{ openshell_rpm_latest_release_api_url }}"
return_content: true
headers:
Accept: application/vnd.github+json
X-GitHub-Api-Version: 2022-11-28
register: openshell_rpm_latest_release
changed_when: false
- name: Select latest-release RPM assets for the guest architecture
ansible.builtin.set_fact:
openshell_rpm_latest_release_cli_assets: >-
{{ openshell_rpm_latest_release.json.assets
| selectattr('name', 'match', '^openshell-[0-9].*[.]'
~ openshell_rpm_latest_release_arch.stdout ~ '[.]rpm$')
| list }}
openshell_rpm_latest_release_gateway_assets: >-
{{ openshell_rpm_latest_release.json.assets
| selectattr('name', 'match', '^openshell-gateway-[0-9].*[.]'
~ openshell_rpm_latest_release_arch.stdout ~ '[.]rpm$')
| list }}
- name: Require one latest-release RPM per OpenShell package
ansible.builtin.assert:
that:
- openshell_rpm_latest_release_cli_assets | length == 1
- openshell_rpm_latest_release_gateway_assets | length == 1
fail_msg: >-
expected one OpenShell CLI and gateway RPM for
{{ openshell_rpm_latest_release_arch.stdout }} in release
{{ openshell_rpm_latest_release.json.tag_name }}
- name: Record the selected latest-release RPM assets
ansible.builtin.set_fact:
openshell_rpm_latest_release_cli_asset: "{{ openshell_rpm_latest_release_cli_assets | first }}"
openshell_rpm_latest_release_gateway_asset: "{{ openshell_rpm_latest_release_gateway_assets | first }}"
- name: Create the latest-release RPM staging directory
ansible.builtin.file:
path: "{{ openshell_rpm_latest_release_download_dir }}"
state: directory
mode: "0755"
become: true
- name: Download latest-release OpenShell RPMs
ansible.builtin.get_url:
url: "{{ item.browser_download_url }}"
dest: "{{ openshell_rpm_latest_release_download_dir }}/{{ item.name }}"
mode: "0644"
loop:
- "{{ openshell_rpm_latest_release_cli_asset }}"
- "{{ openshell_rpm_latest_release_gateway_asset }}"
loop_control:
label: "{{ item.name }}"
become: true
- name: Install latest-release OpenShell RPMs
ansible.builtin.dnf:
name:
- "{{ openshell_rpm_latest_release_download_dir }}/{{ openshell_rpm_latest_release_cli_asset.name }}"
- "{{ openshell_rpm_latest_release_download_dir }}/{{ openshell_rpm_latest_release_gateway_asset.name }}"
state: present
disable_gpg_check: true
become: true
- name: Publish latest-release RPM OpenShell installation
ansible.builtin.include_role:
name: openshell-rpm