diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index bae9e3d326..775eb9f83e 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -154,42 +154,30 @@ jobs: name: openshell-conformance-x86_64-unknown-linux-musl path: conformance-input - - name: Install RPMs and check status + - name: Run RPM gateway continuity conformance shell: bash run: | set -euo pipefail chmod +x conformance-input/openshell-conformance - guest_script="$RUNNER_TEMP/conformance.sh" - cat > "$guest_script" <<'EOF' - set -euo pipefail - - on_exit() { - rc=$? - trap - EXIT - if [ "$rc" -ne 0 ]; then - systemctl --user status openshell-gateway --no-pager || true - journalctl --user -u openshell-gateway --no-pager -n 200 || true - podman info || true - podman ps --all || true - getenforce || true - fi - exit "$rc" - } - trap on_exit EXIT - - mkdir -p "$HOME/.config/openshell" - echo 'OPENSHELL_TELEMETRY_ENABLED=false' > "$HOME/.config/openshell/gateway.env" - systemctl --user enable --now openshell-gateway - openshell gateway add --local https://127.0.0.1:17670 --name openshell - /tmp/openshell-conformance run smoke - EOF + shopt -s nullglob + candidate_cli_package=(rpm-input/openshell-[0-9]*.rpm) + candidate_gateway_package=(rpm-input/openshell-gateway-[0-9]*.rpm) + if [[ ${#candidate_cli_package[@]} -ne 1 || ${#candidate_gateway_package[@]} -ne 1 ]]; then + echo "expected one candidate CLI and gateway RPM" >&2 + printf 'RPM artifacts:\n' >&2 + printf ' %s\n' rpm-input/* >&2 + exit 1 + fi OPENSHELL_TEST_GUEST_CACHE_DISABLE=1 nix run .#test-guest -- \ --distro fedora \ --with podman-rootless \ --with selinux \ - --install rpm-input/openshell-[0-9]*.rpm \ - --install rpm-input/openshell-gateway-[0-9]*.rpm \ - --copy "$guest_script:/tmp/conformance.sh" \ + --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" \ --copy conformance-input/openshell-conformance:/tmp/openshell-conformance \ - -- bash /tmp/conformance.sh + --copy nix/test-guest/conformance-plans/gateway-upgrade-restart.toml:/tmp/conformance-plan.toml \ + --provision openshell-rpm-latest-release \ + --provision gateway-rootless-podman \ + --provision openshell-rpm-gateway-upgrade \ + -- /tmp/openshell-conformance run --plan /tmp/conformance-plan.toml diff --git a/Cargo.lock b/Cargo.lock index 90240d6b3a..05d9af8832 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3833,6 +3833,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "toml", ] [[package]] diff --git a/architecture/build.md b/architecture/build.md index 393eb9e468..3ec240a759 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -256,6 +256,13 @@ 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. + ## Python Wheel Packaging The generated protobuf/gRPC stubs under `python/openshell/_proto/` are gitignored diff --git a/crates/openshell-conformance-cli/src/main.rs b/crates/openshell-conformance-cli/src/main.rs index c877cf4fc1..c7f35bc13f 100644 --- a/crates/openshell-conformance-cli/src/main.rs +++ b/crates/openshell-conformance-cli/src/main.rs @@ -3,11 +3,19 @@ //! 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::{OpenShellRunner, Scenario, scenario, scenarios}; +use openshell_conformance::{ + ConformancePlan, HostAction, HostActionExecutor, OpenShellRunner, PlanRun, Scenario, + default_scenarios, scenario, scenarios, +}; use serde::Serialize; #[derive(Debug, Parser)] @@ -28,13 +36,16 @@ enum Command { #[arg(long, value_enum, default_value_t = OutputFormat::Text)] output: OutputFormat, }, - /// Run all registered scenarios, or named scenarios. + /// Run action-free scenarios, named scenarios, or an explicit plan. Run { - /// Scenario names. Omit to run every registered scenario. + /// Action-free scenario names. Omit to run every action-free scenario. scenarios: Vec, /// Explicit path to the `OpenShell` CLI. Defaults to `openshell` on PATH. #[arg(long)] openshell_bin: Option, + /// Versioned TOML conformance plan. Use '-' to read the plan from stdin. + #[arg(long, conflicts_with = "scenarios")] + plan: Option, #[arg(long, value_enum, default_value_t = OutputFormat::Text)] output: OutputFormat, }, @@ -82,8 +93,9 @@ async fn execute(cli: Cli) -> Result<(), String> { Command::Run { scenarios: requested, openshell_bin, + plan, output, - } => run(&requested, openshell_bin, output).await, + } => run(&requested, openshell_bin, plan, output).await, } } @@ -114,39 +126,111 @@ fn list(output: OutputFormat) -> Result<(), String> { async fn run( requested: &[String], binary: Option, + plan_path: Option, 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 runner = binary.as_ref().map_or_else( - || OpenShellRunner::new(candidate.name), - |path| OpenShellRunner::with_binary(path.clone(), candidate.name), - ); - let mut runner = match runner { - Ok(runner) => runner, - Err(error) => { - results.push(ScenarioResult { - name: candidate.name, - passed: false, - diagnostic: Some(error.to_string()), - }); - continue; - } - }; - eprintln!("CLI conformance run ID: {}", runner.id()); - let scenario_result = match runner.check_gateway_status().await { - Ok(()) => candidate.run(&mut runner).await, - Err(error) => Err(error), + let plan_run = default_plan_run(candidate.name); + results.push(run_scenario(candidate, &plan_run, binary.as_ref(), None).await); + } + + render_results(results, output) +} + +async fn run_plan( + plan: &ConformancePlan, + binary: Option, + output: OutputFormat, +) -> Result<(), String> { + let executor: Arc = 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 outcome = runner.finish(scenario_result).await; - results.push(ScenarioResult { - name: candidate.name, - passed: outcome.is_ok(), - diagnostic: outcome.err(), - }); + 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>, +) -> ScenarioResult<'static> { + let runner = binary.map_or_else( + || OpenShellRunner::new(candidate.name), + |path| OpenShellRunner::with_binary(path.clone(), candidate.name), + ); + let mut runner = match runner { + Ok(runner) => runner, + Err(error) => { + return ScenarioResult { + name: candidate.name, + passed: false, + diagnostic: Some(error.to_string()), + }; + } + }; + 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, + Err(error) => Err(error), + }; + let outcome = runner.finish(scenario_result).await; + ScenarioResult { + name: candidate.name, + passed: outcome.is_ok(), + diagnostic: outcome.err(), + } +} + +async fn append_diagnostics( + mut result: ScenarioResult<'static>, + executor: &Arc, + 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>, + output: OutputFormat, +) -> Result<(), String> { let passed = results.iter().all(|result| result.passed); match output { OutputFormat::Text => { @@ -178,20 +262,86 @@ async fn run( } } +fn read_plan(path: &PathBuf) -> Result { + 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, String> { if requested.is_empty() { - return Ok(scenarios().iter().collect()); + return Ok(default_scenarios().collect()); } requested .iter() .map(|name| { - scenario(name).ok_or_else(|| { + let candidate = 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> + 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; @@ -202,7 +352,7 @@ mod tests { fn selects_all_scenarios_by_default() { assert_eq!( select_scenarios(&[]).expect("select all").len(), - scenarios().len() + default_scenarios().count() ); } @@ -218,6 +368,14 @@ 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([ @@ -241,4 +399,14 @@ 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("-"))); + } } diff --git a/crates/openshell-conformance/Cargo.toml b/crates/openshell-conformance/Cargo.toml index 7b67258045..2c355a0052 100644 --- a/crates/openshell-conformance/Cargo.toml +++ b/crates/openshell-conformance/Cargo.toml @@ -15,6 +15,7 @@ rand.workspace = true serde.workspace = true serde_json.workspace = true tokio.workspace = true +toml.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs index aa6f69c387..63cd1677e4 100644 --- a/crates/openshell-conformance/src/lib.rs +++ b/crates/openshell-conformance/src/lib.rs @@ -4,6 +4,7 @@ //! Reusable support for portable `OpenShell` CLI conformance scenarios. pub mod executor; +pub mod plan; mod scenarios; use std::collections::BTreeSet; @@ -23,25 +24,56 @@ use tokio::time::sleep; use self::executor::{CliExecutionError, CliExecutor, ProcessCli}; -pub use scenarios::SMOKE_SCENARIO; +pub use plan::{ConformancePlan, HostAction, PlanDiagnostics, PlanRun, WorkloadExpectation}; +pub use scenarios::{SANDBOX_CONTINUITY_SCENARIO, SMOKE_SCENARIO}; /// An installed conformance scenario. #[derive(Debug)] pub struct Scenario { pub name: &'static str, pub description: &'static str, - run: for<'a> fn(&'a mut OpenShellRunner) -> ScenarioFuture<'a>, + requires_plan: bool, + run: for<'a> fn(&'a mut OpenShellRunner, &'a PlanRun) -> ScenarioFuture<'a>, + validate_plan_run: Option, } pub type ScenarioFuture<'a> = Pin> + Send + 'a>>; +type PlanRunValidator = fn(&PlanRun) -> Result<(), String>; impl Scenario { - pub async fn run(&self, runner: &mut OpenShellRunner) -> Result<(), String> { - (self.run)(runner).await + 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 } } -const SCENARIOS: &[Scenario] = &[SMOKE_SCENARIO]; +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]; /// Returns every scenario compiled into this distribution. pub fn scenarios() -> &'static [Scenario] { @@ -53,6 +85,13 @@ 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 { + scenarios() + .iter() + .filter(|scenario| !scenario.requires_plan) +} + const CLEANUP_TIMEOUT: Duration = Duration::from_secs(120); pub const STATUS_TIMEOUT: Duration = Duration::from_secs(30); const GATEWAY_STATUS_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); @@ -245,12 +284,26 @@ struct AuthenticationOutput { /// Runs `OpenShell` commands for one conformance scenario and owns its cleanup. pub struct OpenShellRunner { cli: Arc, + host_action_executor: Option>, run_id: String, scenario: String, known_sandboxes: BTreeSet, 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> + Send + '_>>; +} + /// A runner command with diagnostic context but no timeout yet. pub struct CommandStep<'a> { runner: &'a OpenShellRunner, @@ -292,6 +345,7 @@ impl OpenShellRunner { pub fn with_executor(cli: Arc, scenario: &str) -> Self { Self { cli, + host_action_executor: None, run_id: generate_run_id(), scenario: scenario.to_string(), known_sandboxes: BTreeSet::new(), @@ -299,6 +353,36 @@ 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, + ) -> 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 } diff --git a/crates/openshell-conformance/src/plan.rs b/crates/openshell-conformance/src/plan.rs new file mode 100644 index 0000000000..ad955ea5f6 --- /dev/null +++ b/crates/openshell-conformance/src/plan.rs @@ -0,0 +1,233 @@ +// 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, + pub diagnostics: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PlanRun { + pub scenario: String, + pub workload_expectation: Option, + #[serde(default)] + pub actions: Vec, +} + +#[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 { + let plan = toml::from_str::(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::*; + + #[test] + fn parses_a_smoke_and_continuity_plan() { + let plan = ConformancePlan::parse( + r#" + version = 1 + + [[runs]] + scenario = "smoke" + + [[runs]] + scenario = "sandbox-continuity" + workload_expectation = "reconciled" + + [[runs.actions]] + name = "gateway-upgrade" + command = "/usr/local/libexec/restart-gateway" + 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( + r#" + version = 1 + + [diagnostics] + command = "/usr/local/libexec/diagnostics" + 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")); + } +} diff --git a/crates/openshell-conformance/src/scenarios/mod.rs b/crates/openshell-conformance/src/scenarios/mod.rs index bd6abdc7af..c5211b3690 100644 --- a/crates/openshell-conformance/src/scenarios/mod.rs +++ b/crates/openshell-conformance/src/scenarios/mod.rs @@ -3,6 +3,8 @@ //! Registered, portable conformance scenarios. +mod sandbox_continuity; mod smoke; +pub use sandbox_continuity::SANDBOX_CONTINUITY_SCENARIO; pub use smoke::SMOKE_SCENARIO; diff --git a/crates/openshell-conformance/src/scenarios/sandbox_continuity.rs b/crates/openshell-conformance/src/scenarios/sandbox_continuity.rs new file mode 100644 index 0000000000..7fe1f0d06b --- /dev/null +++ b/crates/openshell-conformance/src/scenarios/sandbox_continuity.rs @@ -0,0 +1,207 @@ +// 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_secs(600); +const COMMAND_TIMEOUT: Duration = Duration::from_secs(120); +const RECOVERY_TIMEOUT: Duration = Duration::from_secs(240); +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::() { + 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()) +} diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs index 7a71cb9099..cb5ca40122 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -5,7 +5,7 @@ use std::time::{Duration, Instant}; -use crate::{OpenShellRunner, STATUS_TIMEOUT, Scenario, ScenarioFuture}; +use crate::{OpenShellRunner, PlanRun, STATUS_TIMEOUT, Scenario, ScenarioFuture}; use serde::Deserialize; use tokio::time::sleep; @@ -26,10 +26,12 @@ struct SandboxListEntry { 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(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { +fn run_smoke<'a>(runner: &'a mut OpenShellRunner, _plan_run: &'a PlanRun) -> ScenarioFuture<'a> { Box::pin(async move { run_smoke_inner(runner).await }) } diff --git a/nix/test-guest/README.md b/nix/test-guest/README.md index 3bf1687205..0006a331fb 100644 --- a/nix/test-guest/README.md +++ b/nix/test-guest/README.md @@ -48,6 +48,12 @@ nix/test-guest/ │ ├── shared.yml │ └── ubuntu.yml └── selinux.yml +└── provisioners/ + └── roles/ + ├── gateway-rootless-podman/ + ├── openshell-development/ + ├── openshell-rpm/ + └── openshell-rpm-gateway-upgrade/ ``` - `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. @@ -57,6 +63,7 @@ nix/test-guest/ - `cache-seal.sh` removes per-instance state and zeroes free space inside a prepared guest before capture. - `distros/*.nix` define the immutable base-image catalog. Each record pins and exports the image URL and hash and declares the expected OS ID, version, and package family. - `configuration/*.yml` are host-executed Ansible playbooks that layer optional capabilities onto a base guest. Configurations remain independent and run in the order supplied with repeated `--with` arguments. +- `provisioners/roles/*` are per-run Ansible roles that assemble a system from copied or installed artifacts. They run after artifact transfer and are never part of the prepared VM cache. - `README.md` documents the supported combinations and developer interface. The root [`flake.nix`](../../flake.nix) exposes this directory as the `test-guest` and `test-guest-cache` apps. Debian artifact creation remains outside the guest harness in [`tasks/scripts/package-deb.sh`](../../tasks/scripts/package-deb.sh); the runner only installs or copies artifacts that already exist. @@ -143,6 +150,86 @@ Configurations run in the order provided on the command line. OpenShell packages Ansible playbook. `--copy` preserves each source file's ordinary permission bits. They are not stored in prepared VM cache entries. +## System provisioners + +`--provision NAME` applies a target-specific system setup after packages and +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` +- `/usr/local/bin/openshell-gateway` +- `/usr/local/lib/openshell-sandbox.tar` + +Compose it with `gateway-rootless-podman` to configure a rootless Podman +gateway. For example, run conformance after the provisioners complete: + +```shell +nix run .#test-guest -- \ + --distro fedora --with podman-rootless --with selinux \ + --copy ./openshell:/usr/local/bin/openshell \ + --copy ./openshell-conformance:/usr/local/bin/openshell-conformance \ + --copy ./openshell-gateway:/usr/local/bin/openshell-gateway \ + --copy ./openshell-sandbox.tar:/usr/local/lib/openshell-sandbox.tar \ + --provision openshell-development \ + --provision gateway-rootless-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 +``` + +`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-rootless-podman` before an RPM action such as +`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-rootless-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`. + ## Prepared VM cache The `test-guest-cache` app ensures a prepared disk exists for one exact distro, host architecture, and ordered configuration list. It checks the local cache first, optionally pulls a matching OCI artifact, or builds and validates a new local entry on a miss: @@ -279,7 +366,7 @@ The destination must be an absolute guest path. Copied files are installed with --forward-port HOST_PORT:GUEST_PORT Forward a loopback host port to a guest port; repeatable --keep Preserve the disk overlay and logs after shutdown ---list List distros and configurations +--list List distros, configurations, and provisioners ``` Each `--forward-port` binds only `127.0.0.1` on the host. Both ports must be unprivileged values from 1024 through 65535, and each host port may appear only once. diff --git a/nix/test-guest/conformance-plans/gateway-upgrade-restart.toml b/nix/test-guest/conformance-plans/gateway-upgrade-restart.toml new file mode 100644 index 0000000000..fde903b778 --- /dev/null +++ b/nix/test-guest/conformance-plans/gateway-upgrade-restart.toml @@ -0,0 +1,25 @@ +# 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 diff --git a/nix/test-guest/default.nix b/nix/test-guest/default.nix index 3b03c1fa95..e3b19a5d83 100644 --- a/nix/test-guest/default.nix +++ b/nix/test-guest/default.nix @@ -39,6 +39,15 @@ let "podman-rootless/ubuntu.yml" ]; + provisionerRoles = [ + "openshell-development" + "openshell-rpm" + "openshell-rpm-latest-release" + "gateway-rootless-podman" + "openshell-rpm-gateway-reinstall" + "openshell-rpm-gateway-upgrade" + ]; + mkDistroProfile = name: distro: pkgs.writeText "openshell-test-guest-${name}" '' @@ -67,6 +76,13 @@ let }) configurationTasks) ); + provisionerCatalog = pkgs.linkFarm "openshell-test-guest-provisioners" ( + map (name: { + inherit name; + path = ./provisioners/roles/${name}; + }) provisionerRoles + ); + runtimeInputs = [ qemu pkgs.python3Packages.ansible-core @@ -86,6 +102,7 @@ let export OPENSHELL_TEST_GUEST_RUNTIME=1 export OPENSHELL_TEST_GUEST_DISTROS=${distroCatalog} export OPENSHELL_TEST_GUEST_CONFIGURATIONS=${configurationCatalog} + export OPENSHELL_TEST_GUEST_PROVISIONERS=${provisionerCatalog} export OPENSHELL_TEST_GUEST_CACHE_LIB=${./cache-lib.sh} export OPENSHELL_TEST_GUEST_CACHE_RUNNER=${./cache.sh} export OPENSHELL_TEST_GUEST_CACHE_SEAL=${./cache-seal.sh} diff --git a/nix/test-guest/provisioners/roles/gateway-rootless-podman/defaults/main.yml b/nix/test-guest/provisioners/roles/gateway-rootless-podman/defaults/main.yml new file mode 100644 index 0000000000..3a50abfba1 --- /dev/null +++ b/nix/test-guest/provisioners/roles/gateway-rootless-podman/defaults/main.yml @@ -0,0 +1,6 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +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 diff --git a/nix/test-guest/provisioners/roles/gateway-rootless-podman/tasks/development-gateway.yml b/nix/test-guest/provisioners/roles/gateway-rootless-podman/tasks/development-gateway.yml new file mode 100644 index 0000000000..4c89402dda --- /dev/null +++ b/nix/test-guest/provisioners/roles/gateway-rootless-podman/tasks/development-gateway.yml @@ -0,0 +1,86 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Create development gateway state directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0700" + loop: + - "{{ openshell_gateway_state_root }}" + - "{{ openshell_gateway_state_root }}/xdg/config" + - "{{ openshell_gateway_state_root }}/xdg/cache" + - "{{ openshell_gateway_state_root }}/xdg/data" + - "{{ openshell_gateway_state_root }}/xdg/state" + - /home/openshell/.config/systemd/user + +- name: Generate development gateway certificates + ansible.builtin.command: + cmd: "{{ openshell_gateway_bin }} generate-certs --output-dir {{ openshell_gateway_state_root }}/pki" + creates: "{{ openshell_gateway_state_root }}/pki/jwt/signing.pem" + +- name: Write rootless Podman gateway configuration + ansible.builtin.copy: + dest: "{{ openshell_gateway_state_root }}/gateway.toml" + mode: "0600" + content: | + [openshell] + version = 1 + + [openshell.gateway] + bind_address = "127.0.0.1:8080" + log_level = "info" + compute_drivers = ["podman"] + disable_tls = true + + [openshell.gateway.auth] + allow_unauthenticated_users = true + + [openshell.gateway.gateway_jwt] + signing_key_path = "{{ openshell_gateway_state_root }}/pki/jwt/signing.pem" + public_key_path = "{{ openshell_gateway_state_root }}/pki/jwt/public.pem" + kid_path = "{{ openshell_gateway_state_root }}/pki/jwt/kid" + gateway_id = "openshell-test-guest" + ttl_secs = 0 + + [openshell.drivers.podman] + default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" + supervisor_image = "{{ openshell_supervisor_image }}" + image_pull_policy = "always" + network_name = "openshell-test-guest" + grpc_endpoint = "http://host.containers.internal:8080" + +- name: Check for the development supervisor image + ansible.builtin.command: + cmd: "podman image exists {{ openshell_supervisor_image }}" + register: openshell_supervisor_image_exists + changed_when: false + failed_when: false + +- name: Import the development supervisor image + ansible.builtin.command: + cmd: >- + podman import --change 'ENTRYPOINT ["/openshell-sandbox"]' + {{ openshell_supervisor_archive }} {{ openshell_supervisor_image }} + when: openshell_supervisor_image_exists.rc != 0 + +- name: Install the development gateway user service + ansible.builtin.copy: + dest: "/home/openshell/.config/systemd/user/{{ openshell_gateway_service }}" + mode: "0600" + content: | + [Unit] + Description=OpenShell development test guest gateway + After=podman.socket + + [Service] + Environment=XDG_CONFIG_HOME={{ openshell_gateway_state_root }}/xdg/config + Environment=XDG_CACHE_HOME={{ openshell_gateway_state_root }}/xdg/cache + Environment=XDG_DATA_HOME={{ openshell_gateway_state_root }}/xdg/data + Environment=XDG_STATE_HOME={{ openshell_gateway_state_root }}/xdg/state + ExecStart={{ openshell_gateway_bin }} --config {{ openshell_gateway_state_root }}/gateway.toml + Restart=on-failure + + [Install] + WantedBy=default.target diff --git a/nix/test-guest/provisioners/roles/gateway-rootless-podman/tasks/lifecycle.yml b/nix/test-guest/provisioners/roles/gateway-rootless-podman/tasks/lifecycle.yml new file mode 100644 index 0000000000..b90e66f26b --- /dev/null +++ b/nix/test-guest/provisioners/roles/gateway-rootless-podman/tasks/lifecycle.yml @@ -0,0 +1,66 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Reload gateway user services + ansible.builtin.systemd_service: + daemon_reload: true + scope: user + +- name: Start the gateway user service + ansible.builtin.systemd_service: + name: "{{ openshell_gateway_service }}" + enabled: true + state: started + scope: user + +- name: Register the provisioned gateway with the CLI + ansible.builtin.command: + cmd: "{{ openshell_cli_bin }} gateway add {{ openshell_gateway_endpoint }} --local --name test-guest" + +- name: Wait for gateway CLI health + ansible.builtin.command: + cmd: "{{ openshell_cli_bin }} status" + register: gateway_health + changed_when: false + retries: 60 + delay: 1 + until: gateway_health.rc == 0 + +- name: Create the target gateway-control command directory + ansible.builtin.file: + path: "{{ openshell_gateway_restart_command | dirname }}" + state: directory + mode: "0700" + +- 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 + 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 + +- name: Install the target gateway diagnostics command + ansible.builtin.copy: + dest: "{{ openshell_gateway_diagnostics_command }}" + mode: "0700" + content: | + #!/usr/bin/env bash + set -u + systemctl --user status {{ openshell_gateway_service }} --no-pager || true + journalctl --user -u {{ openshell_gateway_service }} --no-pager -n 200 || true + podman info || true + podman ps --all || true + if command -v getenforce >/dev/null 2>&1; then + getenforce || true + fi diff --git a/nix/test-guest/provisioners/roles/gateway-rootless-podman/tasks/main.yml b/nix/test-guest/provisioners/roles/gateway-rootless-podman/tasks/main.yml new file mode 100644 index 0000000000..5a23e28406 --- /dev/null +++ b/nix/test-guest/provisioners/roles/gateway-rootless-podman/tasks/main.yml @@ -0,0 +1,25 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Require an OpenShell installation + ansible.builtin.assert: + that: + - openshell_install_source is defined + - openshell_cli_bin is defined + - openshell_gateway_bin is defined + - openshell_gateway_service is defined + - openshell_gateway_endpoint is defined + fail_msg: >- + gateway-rootless-podman requires an earlier OpenShell source provisioner, + such as openshell-development or openshell-rpm. + +- name: Configure development gateway for rootless Podman + ansible.builtin.include_tasks: development-gateway.yml + when: openshell_install_source == 'development' + +- ansible.builtin.import_tasks: lifecycle.yml + +- name: Publish rootless Podman gateway runtime + ansible.builtin.set_fact: + openshell_gateway_runtime: rootless-podman diff --git a/nix/test-guest/provisioners/roles/openshell-development/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-development/defaults/main.yml new file mode 100644 index 0000000000..8c2eb312cf --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-development/defaults/main.yml @@ -0,0 +1,11 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +openshell_development_cli_bin: /usr/local/bin/openshell +openshell_development_gateway_bin: /usr/local/bin/openshell-gateway +openshell_development_sandbox_archive: /usr/local/lib/openshell-sandbox.tar +openshell_development_gateway_service: openshell-test-guest-gateway.service +openshell_development_gateway_endpoint: http://127.0.0.1:8080 +openshell_development_state_root: /home/openshell/.local/share/openshell-test-guest +openshell_development_supervisor_image: localhost/openshell/supervisor:test-guest diff --git a/nix/test-guest/provisioners/roles/openshell-development/tasks/main.yml b/nix/test-guest/provisioners/roles/openshell-development/tasks/main.yml new file mode 100644 index 0000000000..902646116a --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-development/tasks/main.yml @@ -0,0 +1,32 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Verify copied development artifacts + ansible.builtin.stat: + path: "{{ item }}" + loop: + - "{{ openshell_development_cli_bin }}" + - "{{ openshell_development_gateway_bin }}" + - "{{ openshell_development_sandbox_archive }}" + register: openshell_development_artifacts + +- name: Require copied development artifacts + ansible.builtin.assert: + that: item.stat.exists + fail_msg: "missing required development artifact: {{ item.item }}" + loop: "{{ openshell_development_artifacts.results }}" + loop_control: + label: "{{ item.item }}" + +- name: Publish development OpenShell installation + ansible.builtin.set_fact: + openshell_install_source: development + openshell_cli_bin: "{{ openshell_development_cli_bin }}" + openshell_gateway_bin: "{{ openshell_development_gateway_bin }}" + openshell_gateway_service: "{{ openshell_development_gateway_service }}" + openshell_gateway_endpoint: "{{ openshell_development_gateway_endpoint }}" + openshell_gateway_state_root: "{{ openshell_development_state_root }}" + openshell_supervisor_source: archive + openshell_supervisor_archive: "{{ openshell_development_sandbox_archive }}" + openshell_supervisor_image: "{{ openshell_development_supervisor_image }}" diff --git a/nix/test-guest/provisioners/roles/openshell-rpm-gateway-reinstall/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-rpm-gateway-reinstall/defaults/main.yml new file mode 100644 index 0000000000..95c448d300 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-rpm-gateway-reinstall/defaults/main.yml @@ -0,0 +1,7 @@ +--- +# 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 diff --git a/nix/test-guest/provisioners/roles/openshell-rpm-gateway-reinstall/tasks/main.yml b/nix/test-guest/provisioners/roles/openshell-rpm-gateway-reinstall/tasks/main.yml new file mode 100644 index 0000000000..36f1c6dce2 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-rpm-gateway-reinstall/tasks/main.yml @@ -0,0 +1,70 @@ +--- +# 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 diff --git a/nix/test-guest/provisioners/roles/openshell-rpm-gateway-upgrade/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-rpm-gateway-upgrade/defaults/main.yml new file mode 100644 index 0000000000..1538cce109 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-rpm-gateway-upgrade/defaults/main.yml @@ -0,0 +1,7 @@ +--- +# 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 diff --git a/nix/test-guest/provisioners/roles/openshell-rpm-gateway-upgrade/tasks/main.yml b/nix/test-guest/provisioners/roles/openshell-rpm-gateway-upgrade/tasks/main.yml new file mode 100644 index 0000000000..1d51168714 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-rpm-gateway-upgrade/tasks/main.yml @@ -0,0 +1,59 @@ +--- +# 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 diff --git a/nix/test-guest/provisioners/roles/openshell-rpm-latest-release/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-rpm-latest-release/defaults/main.yml new file mode 100644 index 0000000000..a41a59674b --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-rpm-latest-release/defaults/main.yml @@ -0,0 +1,6 @@ +--- +# 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 diff --git a/nix/test-guest/provisioners/roles/openshell-rpm-latest-release/tasks/main.yml b/nix/test-guest/provisioners/roles/openshell-rpm-latest-release/tasks/main.yml new file mode 100644 index 0000000000..f6063bde0c --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-rpm-latest-release/tasks/main.yml @@ -0,0 +1,79 @@ +--- +# 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 diff --git a/nix/test-guest/provisioners/roles/openshell-rpm/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-rpm/defaults/main.yml new file mode 100644 index 0000000000..bc9c52b7fa --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-rpm/defaults/main.yml @@ -0,0 +1,8 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +openshell_rpm_cli_bin: /usr/bin/openshell +openshell_rpm_gateway_bin: /usr/bin/openshell-gateway +openshell_rpm_gateway_service: openshell-gateway.service +openshell_rpm_gateway_endpoint: https://127.0.0.1:17670 diff --git a/nix/test-guest/provisioners/roles/openshell-rpm/tasks/main.yml b/nix/test-guest/provisioners/roles/openshell-rpm/tasks/main.yml new file mode 100644 index 0000000000..fb3a11a3ec --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-rpm/tasks/main.yml @@ -0,0 +1,29 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Verify RPM-provided OpenShell files + ansible.builtin.stat: + path: "{{ item }}" + loop: + - "{{ openshell_rpm_cli_bin }}" + - "{{ openshell_rpm_gateway_bin }}" + - /usr/lib/systemd/user/openshell-gateway.service + register: openshell_rpm_files + +- name: Require the installed OpenShell RPM + ansible.builtin.assert: + that: item.stat.exists + fail_msg: "missing required RPM-provided file: {{ item.item }}" + loop: "{{ openshell_rpm_files.results }}" + loop_control: + label: "{{ item.item }}" + +- name: Publish RPM OpenShell installation + ansible.builtin.set_fact: + openshell_install_source: rpm + openshell_cli_bin: "{{ openshell_rpm_cli_bin }}" + openshell_gateway_bin: "{{ openshell_rpm_gateway_bin }}" + openshell_gateway_service: "{{ openshell_rpm_gateway_service }}" + openshell_gateway_endpoint: "{{ openshell_rpm_gateway_endpoint }}" + openshell_supervisor_source: package diff --git a/nix/test-guest/run.sh b/nix/test-guest/run.sh index 8704633828..045f2f441f 100644 --- a/nix/test-guest/run.sh +++ b/nix/test-guest/run.sh @@ -14,6 +14,7 @@ Usage: Options: --distro NAME Base distro: ubuntu-24-04, ubuntu-26-04, centos, fedora, or rocky --with NAME Apply a configuration; repeatable (docker, podman-rootless, selinux, snapd) + --provision NAME Apply a post-artifact system provisioner; repeatable --install PATH Install a .deb or .rpm package; repeatable --copy SRC:DEST Copy a regular file to an absolute guest path, preserving its host mode; repeatable @@ -21,7 +22,7 @@ Options: --forward-port HOST_PORT:GUEST_PORT Forward a loopback host port to a guest port; repeatable --keep Keep the disposable disk and logs after shutdown - --list List distros and configurations + --list List distros, configurations, and provisioners -h, --help Show this help With no COMMAND, the runner opens an interactive SSH session. @@ -31,6 +32,7 @@ EOF if [ "${OPENSHELL_TEST_GUEST_RUNTIME:-}" != 1 ] || [ ! -d "${OPENSHELL_TEST_GUEST_DISTROS:-}" ] || [ ! -d "${OPENSHELL_TEST_GUEST_CONFIGURATIONS:-}" ] || + [ ! -d "${OPENSHELL_TEST_GUEST_PROVISIONERS:-}" ] || [ ! -r "${OPENSHELL_TEST_GUEST_CACHE_LIB:-}" ] || [ ! -r "${OPENSHELL_TEST_GUEST_CACHE_RUNNER:-}" ]; then echo "run this script through 'nix run .#test-guest -- ...'" >&2 @@ -70,6 +72,7 @@ requested_ssh_port= keep=0 list=0 configurations=() +provisions=() packages=() copies=() forward_ports=() @@ -87,6 +90,11 @@ while [ "$#" -gt 0 ]; do configurations+=("$2") shift 2 ;; + --provision) + require_value "$@" + provisions+=("$2") + shift 2 + ;; --install) require_value "$@" packages+=("$2") @@ -145,6 +153,12 @@ if [ "${list}" -eq 1 ]; then [ -f "${entry}" ] || continue printf ' %s\n' "${entry##*/}" done + echo "Provisions:" + for entry in "${OPENSHELL_TEST_GUEST_PROVISIONERS}"/*; do + if [ -d "${entry}" ]; then + printf ' %s\n' "${entry##*/}" + fi + done exit 0 fi @@ -169,6 +183,13 @@ fi exit 2 fi done +for item in "${provisions[@]}"; do + if [[ ! ${item} =~ ^[a-z0-9][a-z0-9-]*$ ]] || + [ ! -d "${OPENSHELL_TEST_GUEST_PROVISIONERS}/${item}" ]; then + echo "unknown provisioner: ${item:-}" >&2 + exit 2 + fi +done if [ -n "${requested_ssh_port}" ] && { [[ ! ${requested_ssh_port} =~ ^[0-9]+$ ]] || @@ -587,6 +608,7 @@ host_key_checking = False inventory = ${ansible_inventory} interpreter_python = /usr/bin/python3 retry_files_enabled = False +roles_path = ${OPENSHELL_TEST_GUEST_PROVISIONERS} [ssh_connection] ssh_args = -F /dev/null -o IdentitiesOnly=yes -o UserKnownHostsFile=/dev/null -o GlobalKnownHostsFile=/dev/null @@ -658,6 +680,25 @@ if [ "${#packages[@]}" -gt 0 ] || [ "${#copies[@]}" -gt 0 ]; then report_timing "artifact transfer" "${phase_started_at}" fi +if [ "${#provisions[@]}" -gt 0 ]; then + phase_started_at=${SECONDS} + provision_playbook=${run_dir}/provisioners.yml + { + echo "---" + echo "- name: Apply requested system provisioners" + echo " hosts: test_vm" + echo " gather_facts: false" + echo " roles:" + for item in "${provisions[@]}"; do + echo " - ${item}" + done + } >"${provision_playbook}" + echo "==> Applying provisioners: ${provisions[*]}" + ANSIBLE_CONFIG="${ansible_config}" ANSIBLE_NOCOLOR=1 \ + ansible-playbook "${provision_playbook}" + report_timing "system provisioning" "${phase_started_at}" +fi + # Configuration may change the test user's groups. Close the SSH control # connection established before provisioning so subsequent commands start with # the guest's current credentials.