mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-25 06:42:52 +08:00
chore: merge latest main into configuration snapshot branch
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
This commit is contained in:
@@ -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
@@ -4013,7 +4013,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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("-")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user