diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 757609a456..b4a24f84e3 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -173,11 +173,10 @@ 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" \ + --copy "${candidate_cli_package[0]}:/var/lib/openshell-test-guest/artifacts/openshell.rpm" \ + --copy "${candidate_gateway_package[0]}:/var/lib/openshell-test-guest/artifacts/openshell-gateway.rpm" \ --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 \ + --copy nix/test-guest/conformance-plans/gateway-restart.toml:/tmp/conformance-plan.toml \ + --provision openshell-candidate-rpm-source \ --provision gateway-podman \ - --provision openshell-rpm-gateway-upgrade \ -- /tmp/openshell-conformance run --plan /tmp/conformance-plan.toml diff --git a/architecture/build.md b/architecture/build.md index 972f847bb9..aa33e4e702 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -257,11 +257,14 @@ 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. +only through the configured OpenShell CLI. A scenario may be a named +collection of internal leaf scenarios. Collections are selected and reported +as one scenario while their runner owns cleanup for every child. 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 diff --git a/crates/openshell-conformance-cli/src/main.rs b/crates/openshell-conformance-cli/src/main.rs index c7f35bc13f..b1958841f0 100644 --- a/crates/openshell-conformance-cli/src/main.rs +++ b/crates/openshell-conformance-cli/src/main.rs @@ -103,15 +103,15 @@ fn list(output: OutputFormat) -> Result<(), String> { match output { OutputFormat::Text => { for candidate in scenarios() { - println!("{:<16} {}", candidate.name, candidate.description); + println!("{:<16} {}", candidate.name(), candidate.description()); } } OutputFormat::Json => { let result = scenarios() .iter() .map(|candidate| ScenarioDescription { - name: candidate.name, - description: candidate.description, + name: candidate.name(), + description: candidate.description(), }) .collect::>(); println!( @@ -137,7 +137,7 @@ async fn run( 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); + let plan_run = default_plan_run(candidate.name()); results.push(run_scenario(candidate, &plan_run, binary.as_ref(), None).await); } @@ -179,20 +179,20 @@ fn default_plan_run(scenario: &str) -> PlanRun { } async fn run_scenario( - candidate: &'static Scenario, + candidate: &'static dyn 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), + || 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, + name: candidate.name(), passed: false, diagnostic: Some(error.to_string()), }; @@ -208,7 +208,7 @@ async fn run_scenario( }; let outcome = runner.finish(scenario_result).await; ScenarioResult { - name: candidate.name, + name: candidate.name(), passed: outcome.is_ok(), diagnostic: outcome.err(), } @@ -276,7 +276,7 @@ fn read_plan(path: &PathBuf) -> Result { ConformancePlan::parse(&contents).map_err(|error| format!("invalid conformance plan: {error}")) } -fn select_scenarios(requested: &[String]) -> Result, String> { +fn select_scenarios(requested: &[String]) -> Result, String> { if requested.is_empty() { return Ok(default_scenarios().collect()); } @@ -359,19 +359,22 @@ mod tests { #[test] fn selects_named_scenario() { let selected = select_scenarios(&["smoke".to_string()]).expect("select smoke"); - assert_eq!(selected[0].name, "smoke"); + assert_eq!(selected[0].name(), "smoke"); } #[test] fn unknown_scenario_has_actionable_diagnostic() { - let error = select_scenarios(&["missing".to_string()]).expect_err("unknown scenario"); + let error = select_scenarios(&["missing".to_string()]) + .err() + .expect("unknown scenario"); 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"); + .err() + .expect("action scenario requires a plan"); assert!(error.contains("requires an explicit --plan")); } diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs index 63cd1677e4..de458ff2af 100644 --- a/crates/openshell-conformance/src/lib.rs +++ b/crates/openshell-conformance/src/lib.rs @@ -25,41 +25,83 @@ 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::{SANDBOX_CONTINUITY_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO, 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, +pub type ScenarioFuture<'a> = Pin> + Send + 'a>>; + +/// A reusable `OpenShell` conformance contract. +pub trait Scenario: Send + Sync { + /// Stable command-line name for this scenario. + fn name(&self) -> &'static str; + + /// Human-readable summary for scenario discovery. + fn description(&self) -> &'static str; + + /// Whether this scenario may run only through an explicit target plan. + fn requires_plan(&self) -> bool { + false + } + + /// Whether this scenario is selected when no scenario names are supplied. + fn runs_by_default(&self) -> bool { + !self.requires_plan() + } + + /// Validates target-supplied inputs before the scenario starts. + fn validate_plan_run(&self, plan_run: &PlanRun) -> Result<(), String> { + default_validate_plan_run(plan_run) + } + + /// Execute this scenario with a suite-owned runner and target-supplied plan input. + fn run<'a>(&self, runner: &'a mut OpenShellRunner, plan_run: &'a PlanRun) + -> ScenarioFuture<'a>; } -pub type ScenarioFuture<'a> = Pin> + Send + 'a>>; -type PlanRunValidator = fn(&PlanRun) -> Result<(), String>; +/// A scenario that executes a fixed sequence of child scenarios. +pub struct ScenarioCollection { + name: &'static str, + description: &'static str, + scenarios: &'static [&'static dyn Scenario], +} -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 +impl ScenarioCollection { + #[must_use] + pub const fn new( + name: &'static str, + description: &'static str, + scenarios: &'static [&'static dyn Scenario], + ) -> Self { + Self { + name, + description, + scenarios, + } } +} - 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), - ) +impl Scenario for ScenarioCollection { + fn name(&self) -> &'static str { + self.name } - /// Whether this scenario may run only through an explicit target plan. - pub fn requires_plan(&self) -> bool { - self.requires_plan + fn description(&self) -> &'static str { + self.description + } + + fn run<'a>( + &self, + runner: &'a mut OpenShellRunner, + plan_run: &'a PlanRun, + ) -> ScenarioFuture<'a> { + let validation = self.validate_plan_run(plan_run); + let scenarios = self.scenarios; + Box::pin(async move { + validation?; + for scenario in scenarios { + scenario.run(runner, plan_run).await?; + } + Ok(()) + }) } } @@ -73,23 +115,31 @@ fn default_validate_plan_run(plan_run: &PlanRun) -> Result<(), String> { Ok(()) } -const SCENARIOS: &[Scenario] = &[SMOKE_SCENARIO, SANDBOX_CONTINUITY_SCENARIO]; +const SCENARIOS: &[&dyn Scenario] = &[ + SMOKE_SCENARIO, + SANDBOX_LIFECYCLE_SCENARIO, + SANDBOX_CONTINUITY_SCENARIO, +]; /// Returns every scenario compiled into this distribution. -pub fn scenarios() -> &'static [Scenario] { +pub fn scenarios() -> &'static [&'static dyn Scenario] { SCENARIOS } -/// Finds a scenario by its stable command-line name. -pub fn scenario(name: &str) -> Option<&'static Scenario> { - scenarios().iter().find(|candidate| candidate.name == name) +/// Finds a publicly selectable scenario by its stable command-line name. +pub fn scenario(name: &str) -> Option<&'static dyn Scenario> { + scenarios() + .iter() + .copied() + .find(|candidate| candidate.name() == name) } /// Returns scenarios that need no host-level disruption capability. -pub fn default_scenarios() -> impl Iterator { +pub fn default_scenarios() -> impl Iterator { scenarios() .iter() - .filter(|scenario| !scenario.requires_plan) + .copied() + .filter(|scenario| scenario.runs_by_default()) } const CLEANUP_TIMEOUT: Duration = Duration::from_secs(120); diff --git a/crates/openshell-conformance/src/plan.rs b/crates/openshell-conformance/src/plan.rs index ad955ea5f6..fd17e532ce 100644 --- a/crates/openshell-conformance/src/plan.rs +++ b/crates/openshell-conformance/src/plan.rs @@ -125,7 +125,7 @@ mod tests { use super::*; #[test] - fn parses_a_smoke_and_continuity_plan() { + fn parses_a_smoke_lifecycle_and_continuity_plan() { let plan = ConformancePlan::parse( r#" version = 1 @@ -133,6 +133,9 @@ mod tests { [[runs]] scenario = "smoke" + [[runs]] + scenario = "sandbox-lifecycle" + [[runs]] scenario = "sandbox-continuity" workload_expectation = "reconciled" @@ -145,8 +148,8 @@ mod tests { ) .expect("valid plan"); - assert_eq!(plan.runs.len(), 2); - assert_eq!(plan.runs[1].actions[0].name, "gateway-upgrade"); + assert_eq!(plan.runs.len(), 3); + assert_eq!(plan.runs[2].actions[0].name, "gateway-upgrade"); } #[test] diff --git a/crates/openshell-conformance/src/scenarios/mod.rs b/crates/openshell-conformance/src/scenarios/mod.rs index c5211b3690..d430f63def 100644 --- a/crates/openshell-conformance/src/scenarios/mod.rs +++ b/crates/openshell-conformance/src/scenarios/mod.rs @@ -4,7 +4,9 @@ //! Registered, portable conformance scenarios. mod sandbox_continuity; +mod sandbox_lifecycle; mod smoke; pub use sandbox_continuity::SANDBOX_CONTINUITY_SCENARIO; +pub use sandbox_lifecycle::SANDBOX_LIFECYCLE_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 index 7fe1f0d06b..b54e590a24 100644 --- a/crates/openshell-conformance/src/scenarios/sandbox_continuity.rs +++ b/crates/openshell-conformance/src/scenarios/sandbox_continuity.rs @@ -22,20 +22,39 @@ struct SandboxState { phase: String, } +struct SandboxContinuityScenario; + /// 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), -}; +pub static SANDBOX_CONTINUITY_SCENARIO: &dyn Scenario = &SandboxContinuityScenario; + +impl Scenario for SandboxContinuityScenario { + fn name(&self) -> &'static str { + "sandbox-continuity" + } + + fn description(&self) -> &'static str { + "Verify sandbox state and workspace continuity across planned host actions." + } -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 requires_plan(&self) -> bool { + true + } + + fn validate_plan_run(&self, plan_run: &PlanRun) -> Result<(), String> { + validate_plan_run(plan_run) + } + + fn run<'a>( + &self, + runner: &'a mut OpenShellRunner, + plan_run: &'a PlanRun, + ) -> ScenarioFuture<'a> { + let validation = self.validate_plan_run(plan_run); + Box::pin(async move { + validation?; + run_sandbox_continuity_inner(runner, plan_run).await + }) + } } fn validate_plan_run(plan_run: &PlanRun) -> Result<(), String> { diff --git a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs new file mode 100644 index 0000000000..3d2a38df5d --- /dev/null +++ b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs @@ -0,0 +1,331 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Portable sandbox lifecycle conformance scenarios. + +use std::time::Duration; + +use serde::Deserialize; + +use crate::{OpenShellRunner, PlanRun, Poll, Scenario, ScenarioCollection, ScenarioFuture}; + +const CREATE_TIMEOUT: Duration = Duration::from_secs(600); +const COMMAND_TIMEOUT: Duration = Duration::from_secs(120); +const TRANSITION_TIMEOUT: Duration = Duration::from_secs(240); +const TRANSITION_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Debug, Deserialize)] +struct SandboxState { + name: String, + phase: String, +} + +struct StopStartPreservesWorkspaceScenario; +struct StoppedCanBeDeletedScenario; + +static STOP_START_PRESERVES_WORKSPACE_SCENARIO: StopStartPreservesWorkspaceScenario = + StopStartPreservesWorkspaceScenario; +static STOPPED_CAN_BE_DELETED_SCENARIO: StoppedCanBeDeletedScenario = StoppedCanBeDeletedScenario; + +static SANDBOX_LIFECYCLE_CHILDREN: &[&dyn Scenario] = &[ + &STOP_START_PRESERVES_WORKSPACE_SCENARIO, + &STOPPED_CAN_BE_DELETED_SCENARIO, +]; + +static SANDBOX_LIFECYCLE_COLLECTION: ScenarioCollection = ScenarioCollection::new( + "sandbox-lifecycle", + "Verify sandbox stop, start, and deletion lifecycle behavior.", + SANDBOX_LIFECYCLE_CHILDREN, +); + +/// Certify portable sandbox lifecycle behavior as one conformance scenario. +pub static SANDBOX_LIFECYCLE_SCENARIO: &dyn Scenario = &SANDBOX_LIFECYCLE_COLLECTION; + +impl Scenario for StopStartPreservesWorkspaceScenario { + fn name(&self) -> &'static str { + "stop-start-preserves-workspace" + } + + fn description(&self) -> &'static str { + "Stopping and starting a sandbox preserves its workspace and restarts its main process." + } + + fn run<'a>( + &self, + runner: &'a mut OpenShellRunner, + plan_run: &'a PlanRun, + ) -> ScenarioFuture<'a> { + let validation = self.validate_plan_run(plan_run); + Box::pin(async move { + validation?; + stop_start_preserves_workspace(runner).await + }) + } +} + +impl Scenario for StoppedCanBeDeletedScenario { + fn name(&self) -> &'static str { + "stopped-can-be-deleted" + } + + fn description(&self) -> &'static str { + "A stopped sandbox can be deleted without being started again." + } + + fn run<'a>( + &self, + runner: &'a mut OpenShellRunner, + plan_run: &'a PlanRun, + ) -> ScenarioFuture<'a> { + let validation = self.validate_plan_run(plan_run); + Box::pin(async move { + validation?; + stopped_can_be_deleted(runner).await + }) + } +} + +async fn stop_start_preserves_workspace(runner: &mut OpenShellRunner) -> Result<(), String> { + let sandbox_name = format!("ct-{}-ss", runner.id()); + let sentinel = format!("openshell-stop-start-{}", runner.id()); + let sentinel_path = "/sandbox/.openshell-stop-start-sentinel"; + let run_count_path = "/sandbox/.openshell-main-run-count"; + let main = format!( + "count=0; test ! -f '{run_count_path}' || count=$(cat '{run_count_path}'); \ + count=$((count + 1)); printf '%s\\n' \"$count\" > '{run_count_path}'; \ + exec sleep infinity" + ); + + create_running_sandbox(runner, &sandbox_name, &main, "stop-start").await?; + exec_expect_exact( + runner, + &sandbox_name, + "write-sentinel", + &[ + "sh", + "-lc", + &format!("printf '%s\\n' '{sentinel}' > '{sentinel_path}' && sync"), + ], + "", + ) + .await?; + + run_lifecycle_command(runner, "stop", &sandbox_name, "stop").await?; + wait_for_phase(runner, &sandbox_name, "Stopped", "stop-start/stopped").await?; + + let stopped_exec = runner + .step("stop-start/exec-while-stopped") + .description(format!( + "sandbox '{sandbox_name}' rejects exec while stopped" + )) + .with_timeout(COMMAND_TIMEOUT) + .run(&[ + "sandbox", + "exec", + "--name", + &sandbox_name, + "--no-tty", + "--", + "cat", + sentinel_path, + ]) + .await + .map_err(|error| error.to_string())?; + if stopped_exec.success() { + return Err( + stopped_exec.failure_diagnostic("sandbox exec fails while the sandbox is stopped") + ); + } + + run_lifecycle_command(runner, "start", &sandbox_name, "start").await?; + wait_for_phase(runner, &sandbox_name, "Ready", "stop-start/restarted").await?; + + exec_expect_exact( + runner, + &sandbox_name, + "read-sentinel", + &["cat", sentinel_path], + &format!("{sentinel}\n"), + ) + .await?; + exec_expect_exact( + runner, + &sandbox_name, + "read-main-run-count", + &["cat", run_count_path], + "2\n", + ) + .await +} + +async fn stopped_can_be_deleted(runner: &mut OpenShellRunner) -> Result<(), String> { + let sandbox_name = format!("ct-{}-sd", runner.id()); + create_running_sandbox( + runner, + &sandbox_name, + "exec sleep infinity", + "stopped-delete", + ) + .await?; + + run_lifecycle_command(runner, "stop", &sandbox_name, "stopped-delete/stop").await?; + wait_for_phase(runner, &sandbox_name, "Stopped", "stopped-delete/stopped").await?; + run_lifecycle_command(runner, "delete", &sandbox_name, "stopped-delete/delete").await?; + wait_for_absence(runner, &sandbox_name, "stopped-delete/deleted").await?; + runner.forget_sandbox(&sandbox_name); + Ok(()) +} + +async fn create_running_sandbox( + runner: &mut OpenShellRunner, + sandbox_name: &str, + main: &str, + step: &str, +) -> Result<(), String> { + runner.track_sandbox(sandbox_name); + let create = runner + .step(format!("{step}/create")) + .description(format!("sandbox '{sandbox_name}' is created")) + .with_timeout(CREATE_TIMEOUT) + .run(&[ + "sandbox", + "create", + "--name", + sandbox_name, + "--from", + "base", + "--detach", + "--no-tty", + "--", + "sh", + "-lc", + main, + ]) + .await + .map_err(|error| error.to_string())?; + create.require_success()?; + wait_for_phase(runner, sandbox_name, "Ready", &format!("{step}/ready")).await +} + +async fn run_lifecycle_command( + runner: &OpenShellRunner, + operation: &str, + sandbox_name: &str, + step: &str, +) -> Result<(), String> { + let result = runner + .step(step) + .description(format!("sandbox '{sandbox_name}' {operation} succeeds")) + .with_timeout(COMMAND_TIMEOUT) + .run(&["sandbox", operation, sandbox_name]) + .await + .map_err(|error| error.to_string())?; + result.require_success() +} + +async fn exec_expect_exact( + runner: &OpenShellRunner, + sandbox_name: &str, + step: &str, + command: &[&str], + expected_stdout: &str, +) -> Result<(), String> { + let mut args = vec!["sandbox", "exec", "--name", sandbox_name, "--no-tty", "--"]; + args.extend_from_slice(command); + let result = runner + .step(format!("stop-start/{step}")) + .description(format!("sandbox '{sandbox_name}' exec {step} succeeds")) + .with_timeout(COMMAND_TIMEOUT) + .run(&args) + .await + .map_err(|error| error.to_string())?; + result.require_success()?; + if result.stdout() == expected_stdout { + Ok(()) + } else { + Err(result.failure_diagnostic(&format!("stdout is exactly {expected_stdout:?}"))) + } +} + +async fn wait_for_phase( + runner: &mut OpenShellRunner, + sandbox_name: &str, + expected_phase: &str, + step: &str, +) -> Result<(), String> { + let sandbox_name = sandbox_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, + TRANSITION_TIMEOUT, + TRANSITION_INTERVAL, + async move |runner| { + let result = runner + .step(format!("{step}/get")) + .description(format!( + "sandbox '{sandbox_name}' reaches phase {expected_phase}" + )) + .with_timeout(COMMAND_TIMEOUT) + .run(&["sandbox", "get", &sandbox_name, "--output", "json"]) + .await; + match result { + Ok(result) if !result.success() => { + Poll::Pending(result.failure_diagnostic(&format!( + "sandbox '{sandbox_name}' can be retrieved" + ))) + } + Ok(result) => match result.json::() { + Ok(state) if state.name != sandbox_name => Poll::Failed(format!( + "sandbox get returned {:?}; expected '{sandbox_name}'", + state.name + )), + Ok(state) if state.phase == expected_phase => Poll::Ready(()), + Ok(state) => Poll::Pending(format!( + "sandbox '{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()) +} + +async fn wait_for_absence( + runner: &mut OpenShellRunner, + sandbox_name: &str, + step: &str, +) -> Result<(), String> { + let sandbox_name = sandbox_name.to_string(); + let step = step.to_string(); + let poll_step = step.clone(); + runner + .poll_until( + &poll_step, + TRANSITION_TIMEOUT, + TRANSITION_INTERVAL, + async move |runner| { + let result = runner + .step(format!("{step}/get")) + .description(format!("sandbox '{sandbox_name}' is no longer retrievable")) + .with_timeout(COMMAND_TIMEOUT) + .run(&["sandbox", "get", &sandbox_name, "--output", "json"]) + .await; + match result { + Ok(result) if !result.success() => Poll::Ready(()), + Ok(_) => { + Poll::Pending(format!("sandbox '{sandbox_name}' is still retrievable")) + } + 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 cb5ca40122..18bf8f30ef 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -22,17 +22,31 @@ struct SandboxListEntry { phase: String, } +struct SmokeScenario; + /// Certify status -> create -> list Ready -> exec -> delete -> list empty. -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> { - Box::pin(async move { run_smoke_inner(runner).await }) +pub static SMOKE_SCENARIO: &dyn Scenario = &SmokeScenario; + +impl Scenario for SmokeScenario { + fn name(&self) -> &'static str { + "smoke" + } + + fn description(&self) -> &'static str { + "Create, inspect, execute in, and delete a base sandbox." + } + + fn run<'a>( + &self, + runner: &'a mut OpenShellRunner, + plan_run: &'a PlanRun, + ) -> ScenarioFuture<'a> { + let validation = self.validate_plan_run(plan_run); + Box::pin(async move { + validation?; + run_smoke_inner(runner).await + }) + } } async fn run_smoke_inner(runner: &mut OpenShellRunner) -> Result<(), String> { diff --git a/nix/test-guest/README.md b/nix/test-guest/README.md index 58cf771679..db495dd7e8 100644 --- a/nix/test-guest/README.md +++ b/nix/test-guest/README.md @@ -52,9 +52,12 @@ nix/test-guest/ └── provisioners/ └── roles/ ├── gateway-podman/ - ├── openshell-development/ - ├── openshell-rpm/ - └── openshell-rpm-gateway-upgrade/ + ├── openshell-candidate-binaries-source/ + ├── openshell-binaries-contract/ + ├── openshell-candidate-rpm-source/ + ├── openshell-rpm-contract/ + ├── openshell-latest-release-rpm-source/ + └── openshell-rpm-source/ ``` - `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. @@ -153,19 +156,25 @@ nix run .#test-guest -- \ Configurations are Ansible playbooks stored under `nix/test-guest/configuration/`. Ansible runs on the host using the VM's ephemeral SSH key and loopback port. The guest does not install Ansible. -Configurations run in the order provided on the command line. OpenShell packages and copied files are installed after all configurations succeed. - -`--install` packages and `--copy` files are applied by a dedicated per-run -transfer step. `--copy` preserves each source file's ordinary permission bits -unless an octal mode is supplied. They are not stored in prepared VM cache -entries. +Configurations run in the order provided on the command line. Package and file +artifacts are applied after all configurations succeed. `--install` installs a +generic Debian or RPM package directly; `--copy` stages a file at a guest path +and preserves its ordinary permission bits. Neither is stored in prepared VM +cache entries. ## System provisioners -`--provision NAME` applies a target-specific system setup after packages and +`--provision NAME` applies 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. +Stage OpenShell artifacts with `--copy`, then use an ordered source installer +provisioner to make OpenShell available without coupling the prepared guest +image to a particular build or driver configuration. + +OpenShell source provisioners run in command-line order. The first source +installs and publishes the initial OpenShell state; later sources only make +their packages and target-side apply commands available. This lets a scenario +prepare guest state before initial installation and gives lifecycle actions the +exact source artifacts they must install later. Provisioners that support gateway continuity install a target-control command: @@ -192,26 +201,28 @@ timeout_secs = 120 EOF ``` -`openshell-development` expects these copied guest paths: +`openshell-candidate-binaries-source` makes staged raw candidate artifacts available. +When it is the first OpenShell source provisioner, it installs them into the +candidate binary OpenShell state. Stage these guest paths with `--copy`: -- `/usr/local/bin/openshell` -- `/usr/local/bin/openshell-gateway` -- `/usr/local/lib/openshell-sandbox.tar` +- `/var/lib/openshell-test-guest/artifacts/openshell` +- `/var/lib/openshell-test-guest/artifacts/openshell-gateway` +- `/var/lib/openshell-test-guest/artifacts/openshell-sandbox.tar` Compose it with `gateway-podman` after either Podman configuration. The role uses the recorded mode to select the corresponding service account. It -generates configuration only for development artifacts; RPM installations +generates configuration only for candidate binaries; RPM installations retain their packaged service and first-start configuration. For example, run conformance after the rootless provisioners complete: ```shell nix run .#test-guest -- \ --distro fedora --with podman-rootless --with selinux \ - --copy ./openshell:/usr/local/bin/openshell \ + --copy ./openshell:/var/lib/openshell-test-guest/artifacts/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 \ + --copy ./openshell-gateway:/var/lib/openshell-test-guest/artifacts/openshell-gateway \ + --copy ./openshell-sandbox.tar:/var/lib/openshell-test-guest/artifacts/openshell-sandbox.tar \ + --provision openshell-candidate-binaries-source \ --provision gateway-podman \ -- /usr/local/bin/openshell-conformance run --plan - <<'EOF' version = 1 @@ -227,17 +238,31 @@ 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-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. +`openshell-candidate-rpm-source` makes staged candidate RPMs available and publishes a +target-side candidate apply command. Stage the CLI and gateway packages as +`/var/lib/openshell-test-guest/artifacts/openshell.rpm` and +`/var/lib/openshell-test-guest/artifacts/openshell-gateway.rpm`. + +`openshell-latest-release-rpm-source` downloads the latest stable OpenShell GitHub +release for the guest architecture, stores its versioned RPMs under +`/var/lib/openshell-conformance/baseline`, and publishes a target-side +latest-release apply command. + +For gateway restart continuity, provision the candidate source first so it +initializes the guest: + +```shell +--provision openshell-candidate-rpm-source \ +--provision gateway-podman +``` + +The gateway-restart plan runs one `gateway-restart` action to verify continuity +across the restart. + +`openshell-binaries-contract`, `openshell-rpm-contract`, and +`openshell-rpm-source` are internal composition roles used by the public source +provisioners. They are listed for the runner's role resolution but are not +normal `--provision` entry points. Versioned plans under `nix/test-guest/conformance-plans/` bind conformance scenarios to the stable action-command contracts installed by provisioners. diff --git a/nix/test-guest/conformance-plans/gateway-upgrade-restart.toml b/nix/test-guest/conformance-plans/gateway-restart.toml similarity index 75% rename from nix/test-guest/conformance-plans/gateway-upgrade-restart.toml rename to nix/test-guest/conformance-plans/gateway-restart.toml index fde903b778..40789f2e80 100644 --- a/nix/test-guest/conformance-plans/gateway-upgrade-restart.toml +++ b/nix/test-guest/conformance-plans/gateway-restart.toml @@ -8,17 +8,12 @@ command = "/home/openshell/.local/bin/openshell-test-guest-diagnostics" timeout_secs = 60 [[runs]] -scenario = "smoke" +scenario = "sandbox-lifecycle" [[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" diff --git a/nix/test-guest/default.nix b/nix/test-guest/default.nix index 8129198d7f..3ba90520e9 100644 --- a/nix/test-guest/default.nix +++ b/nix/test-guest/default.nix @@ -41,12 +41,13 @@ let ]; provisionerRoles = [ - "openshell-development" - "openshell-rpm" - "openshell-rpm-latest-release" + "openshell-candidate-binaries-source" + "openshell-binaries-contract" + "openshell-candidate-rpm-source" + "openshell-rpm-contract" + "openshell-latest-release-rpm-source" + "openshell-rpm-source" "gateway-podman" - "openshell-rpm-gateway-reinstall" - "openshell-rpm-gateway-upgrade" ]; mkDistroProfile = diff --git a/nix/test-guest/provisioners/roles/gateway-podman/tasks/main.yml b/nix/test-guest/provisioners/roles/gateway-podman/tasks/main.yml index 6c04e08733..73eaea2f44 100644 --- a/nix/test-guest/provisioners/roles/gateway-podman/tasks/main.yml +++ b/nix/test-guest/provisioners/roles/gateway-podman/tasks/main.yml @@ -22,11 +22,13 @@ - openshell_gateway_bin is defined - openshell_gateway_service is defined - openshell_gateway_endpoint is defined - - openshell_install_source in ['development', 'rpm'] + - openshell_install_source in ['binaries', 'rpm'] - openshell_podman_mode in ['rootless', 'rootful'] fail_msg: >- - gateway-podman requires an earlier OpenShell installation role and a - podman-rootless or podman-rootful test-guest configuration. + gateway-podman requires an earlier OpenShell source provisioner, such as + openshell-candidate-binaries-source, openshell-candidate-rpm-source, or + openshell-latest-release-rpm-source, and a podman-rootless or + podman-rootful test-guest configuration. - name: Select the Podman service account ansible.builtin.set_fact: @@ -44,9 +46,9 @@ openshell_gateway_service_uid: "{{ ansible_facts.getent_passwd[openshell_podman_service_user][1] }}" openshell_gateway_service_home: "{{ ansible_facts.getent_passwd[openshell_podman_service_user][4] }}" -- name: Configure a development gateway +- name: Configure a candidate binary gateway ansible.builtin.include_tasks: development-gateway.yml - when: openshell_install_source == 'development' + when: openshell_install_source == 'binaries' - name: Enable the gateway service account user manager ansible.builtin.command: diff --git a/nix/test-guest/provisioners/roles/openshell-binaries-contract/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-binaries-contract/defaults/main.yml new file mode 100644 index 0000000000..6dd2f200c7 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-binaries-contract/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_binaries_contract_cli_bin: /usr/local/bin/openshell +openshell_binaries_contract_gateway_bin: /usr/local/bin/openshell-gateway +openshell_binaries_contract_sandbox_archive: /usr/local/lib/openshell-sandbox.tar +openshell_binaries_contract_gateway_service: openshell-test-guest-gateway.service +openshell_binaries_contract_gateway_endpoint: http://127.0.0.1:8080 +openshell_binaries_contract_state_root: /home/openshell/.local/share/openshell-test-guest +openshell_binaries_contract_supervisor_image: localhost/openshell/supervisor:test-guest diff --git a/nix/test-guest/provisioners/roles/openshell-binaries-contract/tasks/main.yml b/nix/test-guest/provisioners/roles/openshell-binaries-contract/tasks/main.yml new file mode 100644 index 0000000000..698e548932 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-binaries-contract/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 installed candidate binaries + ansible.builtin.stat: + path: "{{ item }}" + loop: + - "{{ openshell_binaries_contract_cli_bin }}" + - "{{ openshell_binaries_contract_gateway_bin }}" + - "{{ openshell_binaries_contract_sandbox_archive }}" + register: openshell_binaries_contract_artifacts + +- name: Require installed candidate binaries + ansible.builtin.assert: + that: item.stat.exists + fail_msg: "missing required candidate binary: {{ item.item }}" + loop: "{{ openshell_binaries_contract_artifacts.results }}" + loop_control: + label: "{{ item.item }}" + +- name: Publish candidate binary OpenShell installation + ansible.builtin.set_fact: + openshell_install_source: binaries + openshell_cli_bin: "{{ openshell_binaries_contract_cli_bin }}" + openshell_gateway_bin: "{{ openshell_binaries_contract_gateway_bin }}" + openshell_gateway_service: "{{ openshell_binaries_contract_gateway_service }}" + openshell_gateway_endpoint: "{{ openshell_binaries_contract_gateway_endpoint }}" + openshell_gateway_state_root: "{{ openshell_binaries_contract_state_root }}" + openshell_supervisor_source: archive + openshell_supervisor_archive: "{{ openshell_binaries_contract_sandbox_archive }}" + openshell_supervisor_image: "{{ openshell_binaries_contract_supervisor_image }}" diff --git a/nix/test-guest/provisioners/roles/openshell-candidate-binaries-source/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-candidate-binaries-source/defaults/main.yml new file mode 100644 index 0000000000..da9fe4d175 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-candidate-binaries-source/defaults/main.yml @@ -0,0 +1,22 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +openshell_binaries_candidate_staging_dir: /var/lib/openshell-test-guest/artifacts +openshell_binaries_candidate_cli_artifact: "{{ openshell_binaries_candidate_staging_dir }}/openshell" +openshell_binaries_candidate_gateway_artifact: "{{ openshell_binaries_candidate_staging_dir }}/openshell-gateway" +openshell_binaries_candidate_sandbox_artifact: "{{ openshell_binaries_candidate_staging_dir }}/openshell-sandbox.tar" +openshell_binaries_candidate_cli_bin: /usr/local/bin/openshell +openshell_binaries_candidate_gateway_bin: /usr/local/bin/openshell-gateway +openshell_binaries_candidate_sandbox_archive: /usr/local/lib/openshell-sandbox.tar +openshell_binaries_candidate_apply_command: /home/openshell/.local/bin/openshell-test-guest-binaries-apply-candidate +openshell_binaries_candidate_artifacts: + - source: "{{ openshell_binaries_candidate_cli_artifact }}" + destination: "{{ openshell_binaries_candidate_cli_bin }}" + mode: "0755" + - source: "{{ openshell_binaries_candidate_gateway_artifact }}" + destination: "{{ openshell_binaries_candidate_gateway_bin }}" + mode: "0755" + - source: "{{ openshell_binaries_candidate_sandbox_artifact }}" + destination: "{{ openshell_binaries_candidate_sandbox_archive }}" + mode: "0644" diff --git a/nix/test-guest/provisioners/roles/openshell-candidate-binaries-source/tasks/main.yml b/nix/test-guest/provisioners/roles/openshell-candidate-binaries-source/tasks/main.yml new file mode 100644 index 0000000000..9a591d9304 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-candidate-binaries-source/tasks/main.yml @@ -0,0 +1,52 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Verify candidate OpenShell binaries + ansible.builtin.stat: + path: "{{ item.source }}" + loop: "{{ openshell_binaries_candidate_artifacts }}" + register: openshell_binaries_candidate_artifact_stats + +- name: Require candidate OpenShell binaries + ansible.builtin.assert: + that: item.stat.exists + fail_msg: "missing candidate OpenShell binary: {{ item.item.source }}" + loop: "{{ openshell_binaries_candidate_artifact_stats.results }}" + loop_control: + label: "{{ item.item.source }}" + +- name: Create the candidate binary apply command directory + ansible.builtin.file: + path: "{{ openshell_binaries_candidate_apply_command | dirname }}" + state: directory + mode: "0700" + +- name: Install the candidate binary apply command + ansible.builtin.copy: + dest: "{{ openshell_binaries_candidate_apply_command }}" + mode: "0700" + content: | + #!/usr/bin/env bash + set -Eeuo pipefail + {% for artifact in openshell_binaries_candidate_artifacts %} + sudo install -D -m {{ artifact.mode }} -- {{ artifact.source | quote }} {{ artifact.destination | quote }} + {% endfor %} + +- name: Determine whether the candidate binaries are the initial OpenShell source + ansible.builtin.set_fact: + openshell_binaries_candidate_is_initial: "{{ openshell_install_source is not defined }}" + +- name: Install candidate binaries as the initial OpenShell state + ansible.builtin.command: + cmd: "{{ openshell_binaries_candidate_apply_command }}" + when: openshell_binaries_candidate_is_initial + +- name: Publish the initial candidate binary OpenShell contract + ansible.builtin.include_role: + name: openshell-binaries-contract + vars: + openshell_binaries_contract_cli_bin: "{{ openshell_binaries_candidate_cli_bin }}" + openshell_binaries_contract_gateway_bin: "{{ openshell_binaries_candidate_gateway_bin }}" + openshell_binaries_contract_sandbox_archive: "{{ openshell_binaries_candidate_sandbox_archive }}" + when: openshell_binaries_candidate_is_initial diff --git a/nix/test-guest/provisioners/roles/openshell-candidate-rpm-source/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-candidate-rpm-source/defaults/main.yml new file mode 100644 index 0000000000..4bcbb0d15e --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-candidate-rpm-source/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_candidate_staging_dir: /var/lib/openshell-test-guest/artifacts +openshell_rpm_candidate_cli_package: "{{ openshell_rpm_candidate_staging_dir }}/openshell.rpm" +openshell_rpm_candidate_gateway_package: "{{ openshell_rpm_candidate_staging_dir }}/openshell-gateway.rpm" diff --git a/nix/test-guest/provisioners/roles/openshell-candidate-rpm-source/tasks/main.yml b/nix/test-guest/provisioners/roles/openshell-candidate-rpm-source/tasks/main.yml new file mode 100644 index 0000000000..b2408e1782 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-candidate-rpm-source/tasks/main.yml @@ -0,0 +1,11 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Prepare the candidate RPM source + ansible.builtin.include_role: + name: openshell-rpm-source + vars: + openshell_rpm_source_name: candidate + openshell_rpm_source_cli_package: "{{ openshell_rpm_candidate_cli_package }}" + openshell_rpm_source_gateway_package: "{{ openshell_rpm_candidate_gateway_package }}" diff --git a/nix/test-guest/provisioners/roles/openshell-development/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-development/defaults/main.yml deleted file mode 100644 index 2aae28bbe8..0000000000 --- a/nix/test-guest/provisioners/roles/openshell-development/defaults/main.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -# 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_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 deleted file mode 100644 index dee9747279..0000000000 --- a/nix/test-guest/provisioners/roles/openshell-development/tasks/main.yml +++ /dev/null @@ -1,31 +0,0 @@ ---- -# 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_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-latest-release/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-latest-release-rpm-source/defaults/main.yml similarity index 100% rename from nix/test-guest/provisioners/roles/openshell-rpm-latest-release/defaults/main.yml rename to nix/test-guest/provisioners/roles/openshell-latest-release-rpm-source/defaults/main.yml diff --git a/nix/test-guest/provisioners/roles/openshell-rpm-latest-release/tasks/main.yml b/nix/test-guest/provisioners/roles/openshell-latest-release-rpm-source/tasks/main.yml similarity index 85% rename from nix/test-guest/provisioners/roles/openshell-rpm-latest-release/tasks/main.yml rename to nix/test-guest/provisioners/roles/openshell-latest-release-rpm-source/tasks/main.yml index f6063bde0c..fcf0c10852 100644 --- a/nix/test-guest/provisioners/roles/openshell-rpm-latest-release/tasks/main.yml +++ b/nix/test-guest/provisioners/roles/openshell-latest-release-rpm-source/tasks/main.yml @@ -65,15 +65,12 @@ 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 +- name: Prepare the latest-release RPM source ansible.builtin.include_role: - name: openshell-rpm + name: openshell-rpm-source + vars: + openshell_rpm_source_name: latest-release + openshell_rpm_source_cli_package: >- + {{ openshell_rpm_latest_release_download_dir }}/{{ openshell_rpm_latest_release_cli_asset.name }} + openshell_rpm_source_gateway_package: >- + {{ openshell_rpm_latest_release_download_dir }}/{{ openshell_rpm_latest_release_gateway_asset.name }} diff --git a/nix/test-guest/provisioners/roles/openshell-rpm/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-rpm-contract/defaults/main.yml similarity index 100% rename from nix/test-guest/provisioners/roles/openshell-rpm/defaults/main.yml rename to nix/test-guest/provisioners/roles/openshell-rpm-contract/defaults/main.yml diff --git a/nix/test-guest/provisioners/roles/openshell-rpm/tasks/main.yml b/nix/test-guest/provisioners/roles/openshell-rpm-contract/tasks/main.yml similarity index 100% rename from nix/test-guest/provisioners/roles/openshell-rpm/tasks/main.yml rename to nix/test-guest/provisioners/roles/openshell-rpm-contract/tasks/main.yml 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 deleted file mode 100644 index 95c448d300..0000000000 --- a/nix/test-guest/provisioners/roles/openshell-rpm-gateway-reinstall/defaults/main.yml +++ /dev/null @@ -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 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 deleted file mode 100644 index 36f1c6dce2..0000000000 --- a/nix/test-guest/provisioners/roles/openshell-rpm-gateway-reinstall/tasks/main.yml +++ /dev/null @@ -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 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 deleted file mode 100644 index 1538cce109..0000000000 --- a/nix/test-guest/provisioners/roles/openshell-rpm-gateway-upgrade/defaults/main.yml +++ /dev/null @@ -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 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 deleted file mode 100644 index 1d51168714..0000000000 --- a/nix/test-guest/provisioners/roles/openshell-rpm-gateway-upgrade/tasks/main.yml +++ /dev/null @@ -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 diff --git a/nix/test-guest/provisioners/roles/openshell-rpm-source/defaults/main.yml b/nix/test-guest/provisioners/roles/openshell-rpm-source/defaults/main.yml new file mode 100644 index 0000000000..68c620e7a2 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-rpm-source/defaults/main.yml @@ -0,0 +1,15 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +openshell_rpm_source_name: "" +openshell_rpm_source_cli_package: "" +openshell_rpm_source_gateway_package: "" +openshell_rpm_source_command_dir: /home/openshell/.local/bin +openshell_rpm_source_apply_command: >- + {{ openshell_rpm_source_command_dir }}/openshell-test-guest-rpm-apply-{{ openshell_rpm_source_name }} +openshell_rpm_source_packages: + - name: openshell + path: "{{ openshell_rpm_source_cli_package }}" + - name: openshell-gateway + path: "{{ openshell_rpm_source_gateway_package }}" diff --git a/nix/test-guest/provisioners/roles/openshell-rpm-source/tasks/main.yml b/nix/test-guest/provisioners/roles/openshell-rpm-source/tasks/main.yml new file mode 100644 index 0000000000..8e63f650d8 --- /dev/null +++ b/nix/test-guest/provisioners/roles/openshell-rpm-source/tasks/main.yml @@ -0,0 +1,64 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +- name: Require an RPM source definition + ansible.builtin.assert: + that: + - openshell_rpm_source_name | length > 0 + - openshell_rpm_source_cli_package | length > 0 + - openshell_rpm_source_gateway_package | length > 0 + +- name: Verify RPM source packages + ansible.builtin.stat: + path: "{{ item.path }}" + loop: "{{ openshell_rpm_source_packages }}" + register: openshell_rpm_source_package_stats + +- name: Require RPM source packages + ansible.builtin.assert: + that: item.stat.exists + fail_msg: "missing {{ openshell_rpm_source_name }} RPM: {{ item.item.path }}" + loop: "{{ openshell_rpm_source_package_stats.results }}" + loop_control: + label: "{{ item.item.path }}" + +- name: Create the RPM source command directory + ansible.builtin.file: + path: "{{ openshell_rpm_source_command_dir }}" + state: directory + mode: "0700" + +- name: Install the RPM source apply command + ansible.builtin.copy: + dest: "{{ openshell_rpm_source_apply_command }}" + mode: "0700" + content: | + #!/usr/bin/env bash + set -Eeuo pipefail + case "${1:-}" in + "") + sudo dnf install -y --allowerasing --nogpgcheck {{ openshell_rpm_source_cli_package | quote }} {{ openshell_rpm_source_gateway_package | quote }} + ;; + --force) + sudo dnf reinstall -y --nogpgcheck {{ openshell_rpm_source_cli_package | quote }} {{ openshell_rpm_source_gateway_package | quote }} + ;; + *) + echo "usage: $0 [--force]" >&2 + exit 2 + ;; + esac + +- name: Determine whether this is the initial OpenShell source + ansible.builtin.set_fact: + openshell_rpm_source_is_initial: "{{ openshell_install_source is not defined }}" + +- name: Install the first RPM source as the initial OpenShell state + ansible.builtin.command: + cmd: "{{ openshell_rpm_source_apply_command }}" + when: openshell_rpm_source_is_initial + +- name: Publish the initial RPM OpenShell contract + ansible.builtin.include_role: + name: openshell-rpm-contract + when: openshell_rpm_source_is_initial