use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Mutex};
use nornir_build_thing::{ContainerBuild, VmBuild};
use crate::jobs::{kind, JobHandle, JobSink};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootMode {
Exe,
Container,
Vm,
}
impl BootMode {
pub fn as_str(&self) -> &'static str {
match self {
BootMode::Exe => "exe",
BootMode::Container => "container",
BootMode::Vm => "vm",
}
}
pub fn job_kind(&self) -> &'static str {
match self {
BootMode::Exe => kind::BOOT_EXE,
BootMode::Container => kind::BOOT_CONTAINER,
BootMode::Vm => kind::BOOT_VM,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BootStatus {
Booting,
Running,
BootedOk,
Failed(String),
Killed,
}
impl BootStatus {
pub fn is_terminal(&self) -> bool {
matches!(self, BootStatus::BootedOk | BootStatus::Failed(_) | BootStatus::Killed)
}
pub fn as_str(&self) -> &'static str {
match self {
BootStatus::Booting => "booting",
BootStatus::Running => "running",
BootStatus::BootedOk => "booted_ok",
BootStatus::Failed(_) => "failed",
BootStatus::Killed => "killed",
}
}
}
#[derive(Debug, Clone)]
pub enum BootTarget {
Exe {
name: String,
exe: PathBuf,
args: Vec<String>,
},
Container(ContainerBuild),
Vm(VmBuild),
}
impl BootTarget {
pub fn name(&self) -> &str {
match self {
BootTarget::Exe { name, .. } => name,
BootTarget::Container(c) => &c.name,
BootTarget::Vm(v) => &v.name,
}
}
pub fn mode(&self) -> BootMode {
match self {
BootTarget::Exe { .. } => BootMode::Exe,
BootTarget::Container(_) => BootMode::Container,
BootTarget::Vm(_) => BootMode::Vm,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BootSpec {
pub name: String,
pub kernel: String,
pub rootfs: String,
pub disk: String,
pub mem_mb: u32,
pub cores: u32,
pub cmdline: String,
pub format: String,
pub qemu_args: Vec<String>,
}
impl BootSpec {
pub fn from_vm(vm: &VmBuild) -> Self {
BootSpec {
name: vm.name.clone(),
kernel: vm.kernel.clone(),
rootfs: vm.rootfs.clone(),
disk: vm.disk.clone(),
mem_mb: vm.mem_mb,
cores: vm.cores,
cmdline: vm.cmdline(),
format: vm.format.as_str().to_string(),
qemu_args: vm.qemu_args(),
}
}
}
pub trait BootProc: Send {
fn poll(&mut self) -> BootStatus;
fn kill(&mut self);
fn drain_log(&mut self) -> Vec<String>;
fn send_stdin(&mut self, _line: &str) {}
}
pub trait BootRunner: Send + Sync {
fn spawn(&self, target: &BootTarget) -> Result<Box<dyn BootProc>, String>;
}
type LogBuf = Arc<Mutex<Vec<String>>>;
struct ExeProc {
child: Child,
stdin: Option<ChildStdin>,
pid: u32,
log: LogBuf,
killed: bool,
stop_cmd: Option<Vec<String>>,
terminal: Option<BootStatus>,
}
impl ExeProc {
fn compute(&mut self) -> BootStatus {
if let Some(t) = &self.terminal {
return t.clone();
}
match self.child.try_wait() {
Ok(Some(status)) => {
let t = if self.killed {
BootStatus::Killed
} else if status.success() {
BootStatus::BootedOk
} else {
BootStatus::Failed(format!("exited {status}"))
};
self.terminal = Some(t.clone());
t
}
Ok(None) => BootStatus::Running,
Err(e) => {
let t = BootStatus::Failed(format!("wait failed: {e}"));
self.terminal = Some(t.clone());
t
}
}
}
}
impl BootProc for ExeProc {
fn poll(&mut self) -> BootStatus {
self.compute()
}
fn kill(&mut self) {
if self.terminal.is_some() {
return;
}
if let Some(cmd) = self.stop_cmd.take() {
if let Some((prog, args)) = cmd.split_first() {
let _ = Command::new(prog)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
self.stdin = None;
unsafe {
libc::kill(-(self.pid as i32), libc::SIGKILL);
}
let _ = self.child.wait();
self.killed = true;
self.terminal = Some(BootStatus::Killed);
}
fn drain_log(&mut self) -> Vec<String> {
let mut g = self.log.lock().unwrap();
std::mem::take(&mut *g)
}
fn send_stdin(&mut self, line: &str) {
if let Some(stdin) = self.stdin.as_mut() {
let _ = stdin.write_all(line.as_bytes());
let _ = stdin.write_all(b"\n");
let _ = stdin.flush();
}
}
}
pub struct ExeBootRunner;
impl ExeBootRunner {
fn spawn_process(
program: &std::ffi::OsStr,
args: &[String],
stop_cmd: Option<Vec<String>>,
) -> Result<Box<dyn BootProc>, String> {
use std::os::unix::process::CommandExt;
let mut cmd = Command::new(program);
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd.process_group(0);
let mut child = cmd
.spawn()
.map_err(|e| format!("spawn `{}`: {e}", program.to_string_lossy()))?;
let pid = child.id();
let log: LogBuf = Arc::new(Mutex::new(Vec::new()));
if let Some(out) = child.stdout.take() {
spawn_reader(out, "", Arc::clone(&log));
}
if let Some(err) = child.stderr.take() {
spawn_reader(err, "err: ", Arc::clone(&log));
}
let stdin = child.stdin.take();
Ok(Box::new(ExeProc { child, stdin, pid, log, killed: false, stop_cmd, terminal: None }))
}
}
impl BootRunner for ExeBootRunner {
fn spawn(&self, target: &BootTarget) -> Result<Box<dyn BootProc>, String> {
match target {
BootTarget::Exe { exe, args, .. } => {
Self::spawn_process(exe.as_os_str(), args, None)
}
other => Err(format!(
"ExeBootRunner cannot boot a {} target",
other.mode().as_str()
)),
}
}
}
fn container_engine() -> String {
std::env::var("NORNIR_CONTAINER_ENGINE").unwrap_or_else(|_| "podman".to_string())
}
pub struct ContainerBootRunner;
impl ContainerBootRunner {
fn run_argv(image: &str, container_name: &str) -> Vec<String> {
vec![
"run".into(),
"--rm".into(),
"--name".into(),
container_name.into(),
image.into(),
]
}
}
impl BootRunner for ContainerBootRunner {
fn spawn(&self, target: &BootTarget) -> Result<Box<dyn BootProc>, String> {
let BootTarget::Container(c) = target else {
return Err(format!("ContainerBootRunner cannot boot a {} target", target.mode().as_str()));
};
let image = c.primary_tag().unwrap_or(c.name.as_str()).to_string();
let container_name = format!("nornir-boot-{}-{}", c.name, uuid::Uuid::new_v4());
let engine = container_engine();
let args = ContainerBootRunner::run_argv(&image, &container_name);
let stop_cmd = vec![engine.clone(), "kill".into(), container_name.clone()];
ExeBootRunner::spawn_process(std::ffi::OsStr::new(&engine), &args, Some(stop_cmd))
}
}
fn spawn_reader<R: std::io::Read + Send + 'static>(reader: R, prefix: &'static str, log: LogBuf) {
std::thread::spawn(move || {
let buf = BufReader::new(reader);
for line in buf.lines() {
match line {
Ok(l) => log.lock().unwrap().push(format!("{prefix}{l}")),
Err(_) => break,
}
}
});
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EngineState {
Running,
Exited(i64),
Gone,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ContainerRunSpec {
pub cmd: Vec<String>,
pub env: BTreeMap<String, String>,
pub ports: Vec<u16>,
}
impl ContainerRunSpec {
pub fn from_container(c: &ContainerBuild) -> Self {
ContainerRunSpec { cmd: c.cmd.clone(), env: c.env.clone(), ports: c.expose.clone() }
}
pub fn env_pairs(&self) -> Vec<String> {
self.env.iter().map(|(k, v)| format!("{k}={v}")).collect()
}
}
pub trait ContainerEngine: Send + Sync {
fn create_and_start(&self, image: &str, name: &str, spec: &ContainerRunSpec) -> Result<(), String>;
fn state(&self, name: &str) -> EngineState;
fn drain_logs(&self, name: &str) -> Vec<String>;
fn stop(&self, name: &str);
}
pub struct BollardProc {
engine: Arc<dyn ContainerEngine>,
name: String,
killed: bool,
terminal: Option<BootStatus>,
}
impl BootProc for BollardProc {
fn poll(&mut self) -> BootStatus {
if let Some(t) = &self.terminal {
return t.clone();
}
match self.engine.state(&self.name) {
EngineState::Running => BootStatus::Running,
EngineState::Exited(code) => {
let t = if self.killed {
BootStatus::Killed
} else if code == 0 {
BootStatus::BootedOk
} else {
BootStatus::Failed(format!("container exited {code}"))
};
self.terminal = Some(t.clone());
t
}
EngineState::Gone => {
let t = if self.killed { BootStatus::Killed } else { BootStatus::Failed("container gone".into()) };
self.terminal = Some(t.clone());
t
}
}
}
fn kill(&mut self) {
if self.terminal.is_some() {
return;
}
self.engine.stop(&self.name);
self.killed = true;
self.terminal = Some(BootStatus::Killed);
}
fn drain_log(&mut self) -> Vec<String> {
self.engine.drain_logs(&self.name)
}
}
pub struct BollardBootRunner {
engine: Arc<dyn ContainerEngine>,
}
impl BollardBootRunner {
pub fn with_engine(engine: Arc<dyn ContainerEngine>) -> Self {
BollardBootRunner { engine }
}
fn container_name(c: &ContainerBuild) -> String {
format!("nornir-boot-{}-{}", c.name, uuid::Uuid::new_v4())
}
#[cfg(feature = "test-boot-container-bollard")]
pub fn connect() -> Result<Self, String> {
Ok(Self::with_engine(Arc::new(BollardEngine::connect()?)))
}
}
impl BootRunner for BollardBootRunner {
fn spawn(&self, target: &BootTarget) -> Result<Box<dyn BootProc>, String> {
let BootTarget::Container(c) = target else {
return Err(format!("BollardBootRunner cannot boot a {} target", target.mode().as_str()));
};
let image = c.primary_tag().unwrap_or(c.name.as_str()).to_string();
let name = Self::container_name(c);
let spec = ContainerRunSpec::from_container(c);
self.engine.create_and_start(&image, &name, &spec)?;
Ok(Box::new(BollardProc { engine: Arc::clone(&self.engine), name, killed: false, terminal: None }))
}
}
#[cfg(feature = "test-boot-container-bollard")]
pub struct BollardEngine {
docker: bollard::Docker,
rt: tokio::runtime::Runtime,
logs: Mutex<std::collections::HashMap<String, LogBuf>>,
}
#[cfg(feature = "test-boot-container-bollard")]
impl BollardEngine {
fn socket_url() -> String {
if let Ok(h) = std::env::var("DOCKER_HOST") {
return h;
}
let xdg = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".into());
format!("unix://{xdg}/podman/podman.sock")
}
pub fn connect() -> Result<Self, String> {
let url = Self::socket_url();
let path = url.strip_prefix("unix://").unwrap_or(&url);
if !std::path::Path::new(path).exists() {
return Err(format!(
"podman API socket not found at {path} (enable with `systemctl --user enable --now podman.socket`, \
or point DOCKER_HOST at a running Docker/podman socket)"
));
}
let docker = bollard::Docker::connect_with_unix(&url, 120, bollard::API_DEFAULT_VERSION)
.map_err(|e| format!("connect podman socket {url}: {e}"))?;
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(|e| format!("build tokio runtime for bollard boot: {e}"))?;
Ok(BollardEngine { docker, rt, logs: Mutex::new(std::collections::HashMap::new()) })
}
}
#[cfg(feature = "test-boot-container-bollard")]
impl BollardEngine {
fn create_body(image: &str, spec: &ContainerRunSpec) -> bollard::models::ContainerCreateBody {
use bollard::models::{ContainerCreateBody, HostConfig, PortBinding};
use std::collections::HashMap;
let mut exposed: Vec<String> = Vec::new();
let mut bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
for p in &spec.ports {
let key = format!("{p}/tcp");
exposed.push(key.clone());
bindings.insert(
key,
Some(vec![PortBinding {
host_ip: Some("0.0.0.0".to_string()),
host_port: Some(p.to_string()),
}]),
);
}
let host_config = if bindings.is_empty() {
None
} else {
Some(HostConfig { port_bindings: Some(bindings), ..Default::default() })
};
ContainerCreateBody {
image: Some(image.to_string()),
cmd: if spec.cmd.is_empty() { None } else { Some(spec.cmd.clone()) },
env: if spec.env.is_empty() { None } else { Some(spec.env_pairs()) },
exposed_ports: if exposed.is_empty() { None } else { Some(exposed) },
host_config,
..Default::default()
}
}
}
#[cfg(feature = "test-boot-container-bollard")]
impl ContainerEngine for BollardEngine {
fn create_and_start(&self, image: &str, name: &str, spec: &ContainerRunSpec) -> Result<(), String> {
use bollard::query_parameters::{
CreateContainerOptionsBuilder, CreateImageOptionsBuilder, RemoveContainerOptionsBuilder,
};
use futures::StreamExt;
let docker = &self.docker;
self.rt.block_on(async {
let _ = docker
.remove_container(name, Some(RemoveContainerOptionsBuilder::new().force(true).build()))
.await;
if docker.inspect_image(image).await.is_err() {
let (repo, tag) = image.rsplit_once(':').unwrap_or((image, "latest"));
let opts = CreateImageOptionsBuilder::new().from_image(repo).tag(tag).build();
let mut pull = docker.create_image(Some(opts), None, None);
while let Some(item) = pull.next().await {
item.map_err(|e| format!("pull image {image}: {e}"))?;
}
}
let config = Self::create_body(image, spec);
docker
.create_container(Some(CreateContainerOptionsBuilder::new().name(name).build()), config)
.await
.map_err(|e| format!("create container {name}: {e}"))?;
docker
.start_container(name, None::<bollard::query_parameters::StartContainerOptions>)
.await
.map_err(|e| format!("start container {name}: {e}"))?;
Ok::<(), String>(())
})?;
let buf: LogBuf = Arc::new(Mutex::new(Vec::new()));
self.logs.lock().unwrap().insert(name.to_string(), Arc::clone(&buf));
let docker = self.docker.clone();
let name_owned = name.to_string();
self.rt.spawn(async move {
use bollard::container::LogOutput;
use bollard::query_parameters::LogsOptionsBuilder;
let mut stream = docker.logs(
&name_owned,
Some(LogsOptionsBuilder::new().follow(true).stdout(true).stderr(true).build()),
);
while let Some(item) = stream.next().await {
match item {
Ok(out) => {
let line = LogOutput::to_string(&out);
let line = line.trim_end_matches(['\n', '\r']).to_string();
if !line.is_empty() {
buf.lock().unwrap().push(line);
}
}
Err(_) => break,
}
}
});
Ok(())
}
fn state(&self, name: &str) -> EngineState {
let docker = &self.docker;
self.rt.block_on(async {
match docker
.inspect_container(name, None::<bollard::query_parameters::InspectContainerOptions>)
.await
{
Ok(info) => {
let state = info.state;
let running = state.as_ref().and_then(|s| s.running).unwrap_or(false);
if running {
EngineState::Running
} else {
let code = state.and_then(|s| s.exit_code).unwrap_or(0);
EngineState::Exited(code)
}
}
Err(_) => EngineState::Gone,
}
})
}
fn drain_logs(&self, name: &str) -> Vec<String> {
match self.logs.lock().unwrap().get(name) {
Some(buf) => std::mem::take(&mut *buf.lock().unwrap()),
None => Vec::new(),
}
}
fn stop(&self, name: &str) {
use bollard::query_parameters::RemoveContainerOptionsBuilder;
let docker = &self.docker;
self.rt.block_on(async {
let _ = docker
.stop_container(name, None::<bollard::query_parameters::StopContainerOptions>)
.await;
let _ = docker
.remove_container(name, Some(RemoveContainerOptionsBuilder::new().force(true).build()))
.await;
});
self.logs.lock().unwrap().remove(name);
}
}
fn default_container_runner() -> Box<dyn BootRunner> {
#[cfg(feature = "test-boot-container-bollard")]
{
match BollardBootRunner::connect() {
Ok(r) => return Box::new(r),
Err(reason) => {
eprintln!("test-boot: bollard API unavailable ({reason}); falling back to the podman subprocess runner");
}
}
}
Box::new(ContainerBootRunner)
}
pub trait VmBootRunner: Send + Sync {
fn boot(&self, spec: &BootSpec) -> Result<Box<dyn BootProc>, String>;
}
pub struct StubVmBootRunner;
impl VmBootRunner for StubVmBootRunner {
fn boot(&self, spec: &BootSpec) -> Result<Box<dyn BootProc>, String> {
Err(format!(
"vm boot '{}' not wired in this build: rebuild with `--features test-boot-vm` to \
drive tunnr_vm::boot_test(BootSpec) -> BootHandle (spec has {} qemu args)",
spec.name,
spec.qemu_args.len()
))
}
}
#[cfg(feature = "test-boot-vm")]
pub struct TunnrVmBootRunner;
#[cfg(feature = "test-boot-vm")]
impl VmBootRunner for TunnrVmBootRunner {
fn boot(&self, spec: &BootSpec) -> Result<Box<dyn BootProc>, String> {
use std::path::PathBuf;
use std::time::Duration;
let image = if !spec.rootfs.is_empty() { &spec.rootfs } else { &spec.disk };
let tunnr_spec = tunnr_vm::BootSpec {
kernel: PathBuf::from(&spec.kernel),
rootfs_or_disk: PathBuf::from(image),
mem_mb: spec.mem_mb,
cores: spec.cores,
timeout: Duration::from_secs(
std::env::var("NORNIR_TEST_BOOT_VM_TIMEOUT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(120),
),
headless: true,
extra_qemu_args: Vec::new(),
success_marker: tunnr_vm::DEFAULT_SUCCESS_MARKER.to_string(),
kernel_cmdline: if spec.cmdline.is_empty() { "quiet".into() } else { spec.cmdline.clone() },
};
let handle = tunnr_vm::boot_test(tunnr_spec).map_err(|e| format!("tunnr boot_test: {e}"))?;
Ok(Box::new(TunnrProc { handle, last_log_len: 0 }))
}
}
#[cfg(feature = "test-boot-vm")]
struct TunnrProc {
handle: tunnr_vm::BootHandle,
last_log_len: usize,
}
#[cfg(feature = "test-boot-vm")]
impl BootProc for TunnrProc {
fn poll(&mut self) -> BootStatus {
match self.handle.poll_status() {
tunnr_vm::BootStatus::Booting => BootStatus::Booting,
tunnr_vm::BootStatus::BootedOk => BootStatus::BootedOk,
tunnr_vm::BootStatus::Failed(r) => BootStatus::Failed(r),
tunnr_vm::BootStatus::Killed => BootStatus::Killed,
tunnr_vm::BootStatus::TimedOut => BootStatus::Failed("timed out".to_string()),
}
}
fn kill(&mut self) {
self.handle.kill();
}
fn drain_log(&mut self) -> Vec<String> {
let full = self.handle.boot_log();
if full.len() <= self.last_log_len {
return Vec::new();
}
let new = full[self.last_log_len..].to_string();
self.last_log_len = full.len();
new.lines().map(|l| l.to_string()).collect()
}
}
pub struct DispatchBootRunner {
exe: ExeBootRunner,
container: Box<dyn BootRunner>,
vm: Box<dyn VmBootRunner>,
}
impl Default for DispatchBootRunner {
fn default() -> Self {
#[cfg(feature = "test-boot-vm")]
let vm: Box<dyn VmBootRunner> = Box::new(TunnrVmBootRunner);
#[cfg(not(feature = "test-boot-vm"))]
let vm: Box<dyn VmBootRunner> = Box::new(StubVmBootRunner);
DispatchBootRunner { exe: ExeBootRunner, container: default_container_runner(), vm }
}
}
impl BootRunner for DispatchBootRunner {
fn spawn(&self, target: &BootTarget) -> Result<Box<dyn BootProc>, String> {
match target {
BootTarget::Exe { .. } => self.exe.spawn(target),
BootTarget::Container(_) => self.container.spawn(target),
BootTarget::Vm(vm) => self.vm.boot(&BootSpec::from_vm(vm)),
}
}
}
pub struct BootJob {
pub job_id: String,
pub target: String,
pub mode: BootMode,
pub status: BootStatus,
pub log: Vec<String>,
proc: Option<Box<dyn BootProc>>,
handle: Option<JobHandle>,
}
impl BootJob {
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"job_id": self.job_id,
"target": self.target,
"mode": self.mode.as_str(),
"status": self.status.as_str(),
"terminal": self.status.is_terminal(),
"log_lines": self.log.len(),
})
}
}
pub struct TestBootState {
runner: Arc<dyn BootRunner>,
sink: JobSink,
workspace: String,
jobs: Vec<BootJob>,
}
impl TestBootState {
pub fn new(sink: JobSink, workspace: impl Into<String>) -> Self {
Self::with_runner(Arc::new(DispatchBootRunner::default()), sink, workspace)
}
pub fn with_runner(
runner: Arc<dyn BootRunner>,
sink: JobSink,
workspace: impl Into<String>,
) -> Self {
TestBootState { runner, sink, workspace: workspace.into(), jobs: Vec::new() }
}
pub fn jobs(&self) -> &[BootJob] {
&self.jobs
}
pub fn runner_arc(&self) -> Arc<dyn BootRunner> {
Arc::clone(&self.runner)
}
pub fn any_running(&self) -> bool {
self.jobs.iter().any(|j| !j.status.is_terminal())
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"boot_jobs": self.jobs.iter().map(|j| j.state_json()).collect::<Vec<_>>(),
"running": self.jobs.iter().filter(|j| !j.status.is_terminal()).count(),
})
}
pub fn boot(&mut self, target: BootTarget) -> String {
let mode = target.mode();
let name = target.name().to_string();
let handle = JobHandle::start(
self.sink.clone(),
mode.job_kind(),
&name,
&self.workspace,
serde_json::json!({ "mode": mode.as_str() }),
);
let job_id = handle.job_id().to_string();
match self.runner.spawn(&target) {
Ok(proc) => {
nornir_testmatrix::functional_status(
"viz-test-boot",
&format!("boot_{}_{name}", mode.as_str()),
true,
&format!("test-boot '{name}' ({}) started", mode.as_str()),
);
self.jobs.push(BootJob {
job_id: job_id.clone(),
target: name,
mode,
status: BootStatus::Booting,
log: Vec::new(),
proc: Some(proc),
handle: Some(handle),
});
}
Err(reason) => {
nornir_testmatrix::functional_status(
"viz-test-boot",
&format!("boot_{}_{name}", mode.as_str()),
false,
&format!("test-boot '{name}' ({}) FAILED to start: {reason}", mode.as_str()),
);
handle.fail(&anyhow::anyhow!("{reason}"));
self.jobs.push(BootJob {
job_id: job_id.clone(),
target: name,
mode,
status: BootStatus::Failed(reason),
log: Vec::new(),
proc: None,
handle: None,
});
}
}
job_id
}
pub fn poll(&mut self) {
for job in &mut self.jobs {
if job.status.is_terminal() {
continue;
}
let Some(proc) = job.proc.as_mut() else { continue };
let new_lines = proc.drain_log();
if !new_lines.is_empty() {
job.log.extend(new_lines);
}
let status = proc.poll();
if status != job.status {
job.status = status.clone();
}
if status.is_terminal() {
Self::terminate_job(job, &status);
}
}
}
pub fn kill(&mut self, job_id: &str) -> bool {
let Some(job) = self.jobs.iter_mut().find(|j| j.job_id == job_id) else {
return false;
};
if job.status.is_terminal() {
return false;
}
if let Some(proc) = job.proc.as_mut() {
proc.kill();
let drained = proc.drain_log();
job.log.extend(drained);
}
job.status = BootStatus::Killed;
Self::terminate_job(job, &BootStatus::Killed);
true
}
pub fn send_stdin(&mut self, job_id: &str, line: &str) {
if let Some(job) = self.jobs.iter_mut().find(|j| j.job_id == job_id) {
if let Some(proc) = job.proc.as_mut() {
proc.send_stdin(line);
job.log.push(format!("> {line}"));
}
}
}
fn terminate_job(job: &mut BootJob, status: &BootStatus) {
let Some(handle) = job.handle.take() else { return };
let detail = serde_json::json!({
"mode": job.mode.as_str(),
"status": status.as_str(),
"log_tail": job.log.iter().rev().take(20).rev().cloned().collect::<Vec<_>>(),
});
match status {
BootStatus::BootedOk => handle.finish(detail, ""),
BootStatus::Killed => handle.fail_with_detail(detail),
BootStatus::Failed(reason) => {
let mut d = detail;
d["reason"] = serde_json::json!(reason);
handle.fail_with_detail(d);
}
_ => job.handle = Some(handle),
}
job.proc = None;
let ok = matches!(status, BootStatus::BootedOk);
nornir_testmatrix::functional_status(
"viz-test-boot",
&format!("boot_{}_{}_terminal", job.mode.as_str(), job.target),
ok,
&format!("test-boot '{}' → {}", job.target, status.as_str()),
);
}
}
#[derive(Clone)]
pub struct FakeProc {
pub status: Arc<Mutex<BootStatus>>,
pub killed: Arc<Mutex<bool>>,
pub pending_log: Arc<Mutex<Vec<String>>>,
}
impl FakeProc {
pub fn running() -> Self {
FakeProc {
status: Arc::new(Mutex::new(BootStatus::Running)),
killed: Arc::new(Mutex::new(false)),
pending_log: Arc::new(Mutex::new(Vec::new())),
}
}
}
impl BootProc for FakeProc {
fn poll(&mut self) -> BootStatus {
self.status.lock().unwrap().clone()
}
fn kill(&mut self) {
*self.killed.lock().unwrap() = true;
*self.status.lock().unwrap() = BootStatus::Killed;
}
fn drain_log(&mut self) -> Vec<String> {
std::mem::take(&mut *self.pending_log.lock().unwrap())
}
fn send_stdin(&mut self, line: &str) {
self.pending_log.lock().unwrap().push(format!("echo: {line}"));
}
}
pub struct FakeBootRunner {
pub next: Mutex<Option<FakeProc>>,
pub fail: Mutex<Option<String>>,
}
impl FakeBootRunner {
pub fn yielding(proc: FakeProc) -> Self {
FakeBootRunner { next: Mutex::new(Some(proc)), fail: Mutex::new(None) }
}
}
impl BootRunner for FakeBootRunner {
fn spawn(&self, _target: &BootTarget) -> Result<Box<dyn BootProc>, String> {
if let Some(reason) = self.fail.lock().unwrap().clone() {
return Err(reason);
}
let proc = self.next.lock().unwrap().clone().unwrap_or_else(FakeProc::running);
Ok(Box::new(proc))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jobs::{JobRecord, JobSink};
fn capturing_sink() -> (JobSink, Arc<Mutex<Vec<JobRecord>>>) {
let cap: Arc<Mutex<Vec<JobRecord>>> = Arc::new(Mutex::new(Vec::new()));
let c2 = Arc::clone(&cap);
let sink = JobSink::remote(move |rec: &JobRecord| c2.lock().unwrap().push(rec.clone()));
(sink, cap)
}
fn exe_target() -> BootTarget {
BootTarget::Exe { name: "full".into(), exe: PathBuf::from("/bin/true"), args: vec![] }
}
#[test]
fn mode_selection_maps_target_type_to_mode() {
assert_eq!(exe_target().mode(), BootMode::Exe);
assert_eq!(BootMode::Exe.job_kind(), kind::BOOT_EXE);
let c = BootTarget::Container(ContainerBuild { name: "img".into(), ..Default::default() });
assert_eq!(c.mode(), BootMode::Container);
assert_eq!(BootMode::Container.job_kind(), kind::BOOT_CONTAINER);
let v = BootTarget::Vm(VmBuild { name: "vm".into(), ..Default::default() });
assert_eq!(v.mode(), BootMode::Vm);
assert_eq!(BootMode::Vm.job_kind(), kind::BOOT_VM);
}
#[test]
fn vm_boot_creates_a_boot_vm_job_via_fake_runner() {
let (sink, cap) = capturing_sink();
let proc = FakeProc::running();
let runner = Arc::new(FakeBootRunner::yielding(proc.clone()));
let mut st = TestBootState::with_runner(runner, sink, "ws");
let target = BootTarget::Vm(VmBuild {
name: "holger-appliance".into(),
kernel: "/boot/vmlinuz".into(),
rootfs: "rootfs.cpio.gz".into(),
..Default::default()
});
assert_eq!(target.mode(), BootMode::Vm);
let id = st.boot(target);
st.poll();
assert_eq!(st.jobs()[0].mode, BootMode::Vm);
assert_eq!(st.jobs()[0].status, BootStatus::Running);
*proc.status.lock().unwrap() = BootStatus::Failed("qemu vanished".into());
st.poll();
assert!(matches!(st.jobs()[0].status, BootStatus::Failed(_)));
let recs = cap.lock().unwrap();
assert_eq!(recs[0].kind, kind::BOOT_VM);
let last = recs.iter().rev().find(|r| r.job_id == id).unwrap();
assert_eq!(last.status, crate::jobs::status::FAILED);
}
#[cfg(feature = "test-boot-vm")]
#[test]
#[ignore = "real KVM boot: needs QEMU+OVMF+KVM and a kernel/rootfs"]
fn real_vm_boot_via_tunnr_reaches_terminal() {
let kernel = std::env::var("NORNIR_VM_KERNEL").expect("set NORNIR_VM_KERNEL");
let rootfs = std::env::var("NORNIR_VM_ROOTFS").expect("set NORNIR_VM_ROOTFS");
let mut st = TestBootState::new(JobSink::noop(), "ws");
let id = st.boot(BootTarget::Vm(VmBuild {
name: "real".into(),
kernel,
rootfs,
mem_mb: 512,
cores: 2,
..Default::default()
}));
std::thread::sleep(std::time::Duration::from_secs(3));
st.poll();
assert!(st.kill(&id), "kill the real VM");
assert_eq!(st.jobs()[0].status, BootStatus::Killed);
}
#[test]
fn vmbuild_maps_to_bootspec_field_for_field() {
let vm = VmBuild {
name: "holger-appliance".into(),
kernel: "/boot/vmlinuz".into(),
rootfs: "rootfs.cpio.gz".into(),
mem_mb: 1024,
cores: 4,
boot_params: vec!["console=ttyS0".into()],
..Default::default()
};
let spec = BootSpec::from_vm(&vm);
assert_eq!(spec.name, "holger-appliance");
assert_eq!(spec.kernel, "/boot/vmlinuz");
assert_eq!(spec.rootfs, "rootfs.cpio.gz");
assert_eq!(spec.mem_mb, 1024);
assert_eq!(spec.cores, 4);
assert_eq!(spec.cmdline, "console=ttyS0");
assert_eq!(spec.format, "qemu");
assert_eq!(spec.qemu_args, vm.qemu_args());
}
#[test]
fn container_run_argv_brings_image_up_named() {
let argv = ContainerBootRunner::run_argv("holger:0.1", "nornir-boot-x");
assert_eq!(argv, vec!["run", "--rm", "--name", "nornir-boot-x", "holger:0.1"]);
}
#[test]
fn container_boot_creates_a_boot_container_job() {
let (sink, cap) = capturing_sink();
let proc = FakeProc::running();
let runner = Arc::new(FakeBootRunner::yielding(proc.clone()));
let mut st = TestBootState::with_runner(runner, sink, "ws");
let target = BootTarget::Container(ContainerBuild {
name: "holger-appliance".into(),
base: "debian".into(),
tags: vec!["holger-appliance:0.1".into()],
..Default::default()
});
assert_eq!(target.mode(), BootMode::Container);
let id = st.boot(target);
st.poll();
assert_eq!(st.jobs()[0].status, BootStatus::Running);
assert_eq!(st.jobs()[0].mode, BootMode::Container);
assert!(st.kill(&id));
assert!(*proc.killed.lock().unwrap());
let recs = cap.lock().unwrap();
assert_eq!(recs[0].kind, kind::BOOT_CONTAINER);
}
#[derive(Default)]
struct FakeEngineInner {
started: Vec<(String, String)>,
specs: Vec<ContainerRunSpec>,
state: EngineStateOpt,
logs: Vec<String>,
stopped: Vec<String>,
fail_start: Option<String>,
}
type EngineStateOpt = Option<EngineState>;
#[derive(Clone, Default)]
struct FakeContainerEngine {
inner: Arc<Mutex<FakeEngineInner>>,
}
impl FakeContainerEngine {
fn set_state(&self, s: EngineState) {
self.inner.lock().unwrap().state = Some(s);
}
fn set_logs(&self, lines: &[&str]) {
self.inner.lock().unwrap().logs = lines.iter().map(|s| s.to_string()).collect();
}
fn started(&self) -> Vec<(String, String)> {
self.inner.lock().unwrap().started.clone()
}
fn specs(&self) -> Vec<ContainerRunSpec> {
self.inner.lock().unwrap().specs.clone()
}
fn stopped(&self) -> Vec<String> {
self.inner.lock().unwrap().stopped.clone()
}
}
impl ContainerEngine for FakeContainerEngine {
fn create_and_start(&self, image: &str, name: &str, spec: &ContainerRunSpec) -> Result<(), String> {
let mut g = self.inner.lock().unwrap();
if let Some(reason) = g.fail_start.clone() {
return Err(reason);
}
g.started.push((image.to_string(), name.to_string()));
g.specs.push(spec.clone());
Ok(())
}
fn state(&self, _name: &str) -> EngineState {
self.inner.lock().unwrap().state.clone().unwrap_or(EngineState::Running)
}
fn drain_logs(&self, _name: &str) -> Vec<String> {
std::mem::take(&mut self.inner.lock().unwrap().logs)
}
fn stop(&self, name: &str) {
self.inner.lock().unwrap().stopped.push(name.to_string());
}
}
fn container_target() -> BootTarget {
BootTarget::Container(ContainerBuild {
name: "holger-appliance".into(),
base: "debian".into(),
tags: vec!["holger-appliance:0.1".into()],
..Default::default()
})
}
#[test]
fn bollard_runner_creates_starts_streams_logs_and_kills() {
let engine = FakeContainerEngine::default();
engine.set_logs(&["holger up", "listening on :8080"]);
let runner = BollardBootRunner::with_engine(Arc::new(engine.clone()));
let mut proc = runner.spawn(&container_target()).expect("bollard spawn");
let started = engine.started();
assert_eq!(started.len(), 1, "one create+start");
assert_eq!(started[0].0, "holger-appliance:0.1", "image = primary tag");
assert!(started[0].1.starts_with("nornir-boot-holger-appliance-"), "per-boot name: {}", started[0].1);
assert_eq!(proc.poll(), BootStatus::Running, "container is up");
assert_eq!(proc.drain_log(), vec!["holger up", "listening on :8080"], "streamed logs drained");
proc.kill();
assert_eq!(proc.poll(), BootStatus::Killed, "kill is terminal");
assert_eq!(engine.stopped(), started.iter().map(|(_, n)| n.clone()).collect::<Vec<_>>(), "stop hit the exact container");
proc.kill();
assert_eq!(engine.stopped().len(), 1, "kill is idempotent");
}
#[test]
fn bollard_runner_maps_engine_state_to_terminal_status() {
let engine = FakeContainerEngine::default();
let runner = BollardBootRunner::with_engine(Arc::new(engine.clone()));
engine.set_state(EngineState::Exited(0));
let mut p = runner.spawn(&container_target()).unwrap();
assert_eq!(p.poll(), BootStatus::BootedOk);
engine.set_state(EngineState::Exited(137));
let mut p = runner.spawn(&container_target()).unwrap();
assert!(matches!(p.poll(), BootStatus::Failed(r) if r.contains("137")));
engine.set_state(EngineState::Gone);
let mut p = runner.spawn(&container_target()).unwrap();
assert!(matches!(p.poll(), BootStatus::Failed(_)));
}
#[test]
fn bollard_runner_drives_the_boot_job_lifecycle_via_the_state_manager() {
let (sink, cap) = capturing_sink();
let engine = FakeContainerEngine::default();
let runner = Arc::new(BollardBootRunner::with_engine(Arc::new(engine.clone())));
let mut st = TestBootState::with_runner(runner, sink, "ws");
let id = st.boot(container_target());
st.poll();
assert_eq!(st.jobs()[0].mode, BootMode::Container);
assert_eq!(st.jobs()[0].status, BootStatus::Running);
engine.set_state(EngineState::Exited(0));
st.poll();
assert_eq!(st.jobs()[0].status, BootStatus::BootedOk);
let recs = cap.lock().unwrap();
assert_eq!(recs[0].kind, kind::BOOT_CONTAINER);
let last = recs.iter().rev().find(|r| r.job_id == id).unwrap();
assert_eq!(last.status, crate::jobs::status::DONE);
}
#[test]
fn bollard_runner_start_failure_is_reported() {
let engine = FakeContainerEngine::default();
engine.inner.lock().unwrap().fail_start = Some("no such image".into());
let runner = BollardBootRunner::with_engine(Arc::new(engine));
let err = runner.spawn(&container_target()).err().expect("start failure surfaces");
assert!(err.contains("no such image"), "reason carried: {err}");
}
#[test]
fn bollard_runner_rejects_non_container_target() {
let runner = BollardBootRunner::with_engine(Arc::new(FakeContainerEngine::default()));
let err = runner.spawn(&exe_target()).err().expect("exe is not a container");
assert!(err.contains("cannot boot a exe"), "{err}");
}
#[test]
fn bollard_runner_forwards_cmd_env_and_ports_from_the_descriptor() {
let engine = FakeContainerEngine::default();
let runner = BollardBootRunner::with_engine(Arc::new(engine.clone()));
let target = BootTarget::Container(ContainerBuild {
name: "holger-appliance".into(),
base: "debian".into(),
tags: vec!["holger-appliance:0.1".into()],
cmd: vec!["/usr/local/bin/holger".into(), "--served".into()],
env: BTreeMap::from([("RUST_LOG".to_string(), "info".to_string())]),
expose: vec![8080],
..Default::default()
});
let _ = runner.spawn(&target).expect("bollard spawn");
let specs = engine.specs();
assert_eq!(specs.len(), 1, "one create+start carried a spec");
let spec = &specs[0];
assert_eq!(spec.cmd, vec!["/usr/local/bin/holger", "--served"], "cmd override forwarded");
assert_eq!(spec.env_pairs(), vec!["RUST_LOG=info"], "env forwarded as KEY=VALUE");
assert_eq!(spec.ports, vec![8080], "published port forwarded");
}
#[test]
fn empty_descriptor_yields_an_empty_run_spec() {
let spec = ContainerRunSpec::from_container(&ContainerBuild {
name: "x".into(),
base: "scratch".into(),
..Default::default()
});
assert!(spec.cmd.is_empty() && spec.env.is_empty() && spec.ports.is_empty());
assert_eq!(spec, ContainerRunSpec::default());
}
#[cfg(feature = "test-boot-container-bollard")]
#[test]
fn create_body_carries_cmd_env_and_port_bindings() {
let spec = ContainerRunSpec {
cmd: vec!["/bin/app".into(), "--serve".into()],
env: BTreeMap::from([
("A".to_string(), "1".to_string()),
("B".to_string(), "2".to_string()),
]),
ports: vec![8080],
};
let body = BollardEngine::create_body("app:1", &spec);
assert_eq!(body.image.as_deref(), Some("app:1"));
assert_eq!(body.cmd, Some(vec!["/bin/app".into(), "--serve".into()]));
assert_eq!(body.env, Some(vec!["A=1".to_string(), "B=2".to_string()]));
let exposed = body.exposed_ports.expect("exposed ports set");
assert!(exposed.iter().any(|s| s == "8080/tcp"), "8080/tcp exposed: {exposed:?}");
let hc = body.host_config.expect("host config for port bindings");
let bindings = hc.port_bindings.expect("port bindings");
let b = bindings.get("8080/tcp").and_then(|v| v.clone()).expect("binding for 8080/tcp");
assert_eq!(b[0].host_port.as_deref(), Some("8080"));
}
#[cfg(feature = "test-boot-container-bollard")]
#[test]
fn create_body_empty_spec_is_all_defaults() {
let body = BollardEngine::create_body("app:1", &ContainerRunSpec::default());
assert_eq!(body.image.as_deref(), Some("app:1"));
assert!(body.cmd.is_none(), "no cmd override → image default entrypoint");
assert!(body.env.is_none());
assert!(body.exposed_ports.is_none());
assert!(body.host_config.is_none());
}
#[cfg(feature = "test-boot-container-bollard")]
#[test]
#[ignore = "real podman API: needs a running podman.socket + network pull"]
fn real_bollard_container_boot_reaches_terminal() {
let runner = BollardBootRunner::connect().expect("connect podman API socket");
let mut st = TestBootState::with_runner(Arc::new(runner), JobSink::noop(), "ws");
let image = std::env::var("NORNIR_TEST_BOOT_IMAGE")
.unwrap_or_else(|_| "docker.io/library/nginx:alpine".to_string());
let id = st.boot(BootTarget::Container(ContainerBuild {
name: "nornir-real-boot".into(),
base: image.clone(),
tags: vec![image],
..Default::default()
}));
std::thread::sleep(std::time::Duration::from_secs(3));
st.poll();
let _ = st.kill(&id);
st.poll();
assert!(st.jobs()[0].status.is_terminal(), "real container reaches a terminal state");
}
#[test]
fn stub_vm_runner_reports_honest_not_wired() {
let vm = VmBuild { name: "vm".into(), kernel: "/k".into(), rootfs: "r".into(), ..Default::default() };
let err = match StubVmBootRunner.boot(&BootSpec::from_vm(&vm)) {
Ok(_) => panic!("stub must not pretend to boot"),
Err(e) => e,
};
assert!(err.contains("tunnr_vm::boot_test"), "names the exact API: {err}");
}
#[test]
fn press_boot_creates_a_running_job() {
let (sink, cap) = capturing_sink();
let runner = Arc::new(FakeBootRunner::yielding(FakeProc::running()));
let mut st = TestBootState::with_runner(runner, sink, "ws");
let id = st.boot(exe_target());
assert_eq!(st.jobs().len(), 1);
assert_eq!(st.jobs()[0].job_id, id);
assert_eq!(st.jobs()[0].mode, BootMode::Exe);
assert_eq!(st.jobs()[0].status, BootStatus::Booting);
assert!(st.any_running());
let recs = cap.lock().unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].kind, kind::BOOT_EXE);
assert_eq!(recs[0].target, "full");
assert_eq!(recs[0].status, crate::jobs::status::RUNNING);
}
#[test]
fn poll_tracks_boot_status_running_then_death_terminates_job() {
let (sink, cap) = capturing_sink();
let proc = FakeProc::running();
let runner = Arc::new(FakeBootRunner::yielding(proc.clone()));
let mut st = TestBootState::with_runner(runner, sink, "ws");
let id = st.boot(exe_target());
st.poll();
assert_eq!(st.jobs()[0].status, BootStatus::Running, "job tracks Running");
*proc.status.lock().unwrap() = BootStatus::BootedOk;
st.poll();
assert_eq!(st.jobs()[0].status, BootStatus::BootedOk);
assert!(!st.any_running());
let recs = cap.lock().unwrap();
let last = recs.iter().rev().find(|r| r.job_id == id).unwrap();
assert_eq!(last.status, crate::jobs::status::DONE, "natural exit 0 → done");
}
#[test]
fn killing_the_job_kills_the_boot_and_marks_killed() {
let (sink, cap) = capturing_sink();
let proc = FakeProc::running();
let runner = Arc::new(FakeBootRunner::yielding(proc.clone()));
let mut st = TestBootState::with_runner(runner, sink, "ws");
let id = st.boot(exe_target());
st.poll();
assert_eq!(st.jobs()[0].status, BootStatus::Running);
assert!(st.kill(&id));
assert!(*proc.killed.lock().unwrap(), "kill reached the boot proc");
assert_eq!(st.jobs()[0].status, BootStatus::Killed);
assert!(!st.any_running());
let recs = cap.lock().unwrap();
let last = recs.iter().rev().find(|r| r.job_id == id).unwrap();
assert!(crate::jobs::status::is_terminal(&last.status));
assert!(last.detail_json.contains("killed"), "killed detail carried: {}", last.detail_json);
assert!(!st.kill(&id));
}
#[test]
fn spawn_failure_opens_and_immediately_fails_the_job() {
let (sink, cap) = capturing_sink();
let runner = FakeBootRunner::yielding(FakeProc::running());
*runner.fail.lock().unwrap() = Some("no such exe".into());
let mut st = TestBootState::with_runner(Arc::new(runner), sink, "ws");
let id = st.boot(exe_target());
assert!(matches!(st.jobs()[0].status, BootStatus::Failed(_)));
let recs = cap.lock().unwrap();
let last = recs.iter().rev().find(|r| r.job_id == id).unwrap();
assert_eq!(last.status, crate::jobs::status::FAILED);
}
#[test]
fn real_exe_boot_spawns_and_reaches_terminal() {
let runner = Arc::new(DispatchBootRunner::default());
let mut st = TestBootState::with_runner(runner, JobSink::noop(), "ws");
let target = BootTarget::Exe {
name: "sleeper".into(),
exe: PathBuf::from("/bin/sh"),
args: vec!["-c".into(), "sleep 30".into()],
};
let id = st.boot(target);
st.poll();
assert_eq!(st.jobs()[0].status, BootStatus::Running, "sleeper is running");
assert!(st.kill(&id), "kill the running sleeper");
assert_eq!(st.jobs()[0].status, BootStatus::Killed);
}
}