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,
}
}
});
}
fn container_build_to_draupnir(c: &ContainerBuild) -> draupnir::BootSpec {
let image = c.primary_tag().unwrap_or(c.name.as_str()).to_string();
let name = format!("nornir-boot-{}-{}", c.name, uuid::Uuid::new_v4());
let mut d = draupnir::BootSpec::container(name, image).with_cmd(c.cmd.clone());
d.ports = c.expose.clone();
d.env = c.env.clone();
d
}
#[derive(Default)]
pub struct DraupnirContainerBootRunner<B = draupnir::container::ContainerBoot> {
backend: B,
}
impl<B> DraupnirContainerBootRunner<B>
where
B: draupnir::Boot + draupnir::container::ContainerControl + Clone + Send + Sync + 'static,
{
pub fn with_backend(backend: B) -> Self {
DraupnirContainerBootRunner { backend }
}
}
impl<B> BootRunner for DraupnirContainerBootRunner<B>
where
B: draupnir::Boot + draupnir::container::ContainerControl + Clone + Send + Sync + 'static,
{
fn spawn(&self, target: &BootTarget) -> Result<Box<dyn BootProc>, String> {
let BootTarget::Container(c) = target else {
return Err(format!("DraupnirContainerBootRunner cannot boot a {} target", target.mode().as_str()));
};
let machine = self
.backend
.boot(&container_build_to_draupnir(c))
.map_err(|e| format!("draupnir container boot: {e}"))?;
Ok(Box::new(DraupnirContainerProc {
backend: self.backend.clone(),
machine,
killed: false,
terminal: None,
}))
}
}
struct DraupnirContainerProc<B: draupnir::container::ContainerControl> {
backend: B,
machine: draupnir::Machine,
killed: bool,
terminal: Option<BootStatus>,
}
impl<B: draupnir::container::ContainerControl + Send> BootProc for DraupnirContainerProc<B> {
fn poll(&mut self) -> BootStatus {
use draupnir::container::ContainerState;
if let Some(t) = &self.terminal {
return t.clone();
}
match self.backend.container_state(&self.machine) {
ContainerState::Running => BootStatus::Running,
ContainerState::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
}
ContainerState::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.backend.stop(&self.machine);
self.killed = true;
self.terminal = Some(BootStatus::Killed);
}
fn drain_log(&mut self) -> Vec<String> {
self.backend.drain_logs(&self.machine)
}
}
#[cfg(feature = "test-boot-container-bollard")]
fn podman_socket_present() -> bool {
let url = std::env::var("DOCKER_HOST").unwrap_or_else(|_| {
let xdg = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".into());
format!("unix://{xdg}/podman/podman.sock")
});
let path = url.strip_prefix("unix://").unwrap_or(&url);
std::path::Path::new(path).exists()
}
fn default_container_runner() -> Box<dyn BootRunner> {
#[cfg(feature = "test-boot-container-bollard")]
{
if podman_socket_present() {
return Box::new(DraupnirContainerBootRunner::<draupnir::container::ContainerBoot>::default());
}
eprintln!("test-boot: podman API socket unavailable; 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);
}
use std::collections::BTreeMap;
#[derive(Default)]
struct MockDraupnirInner {
booted: Vec<draupnir::BootSpec>,
stopped: Vec<String>,
state: Option<draupnir::container::ContainerState>,
logs: Vec<String>,
fail_boot: Option<String>,
}
#[derive(Clone, Default)]
struct MockDraupnirBackend {
inner: Arc<Mutex<MockDraupnirInner>>,
}
impl MockDraupnirBackend {
fn set_state(&self, s: draupnir::container::ContainerState) {
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 booted(&self) -> Vec<draupnir::BootSpec> {
self.inner.lock().unwrap().booted.clone()
}
fn stopped(&self) -> Vec<String> {
self.inner.lock().unwrap().stopped.clone()
}
}
impl draupnir::Boot for MockDraupnirBackend {
fn boot(&self, spec: &draupnir::BootSpec) -> draupnir::Result<draupnir::Machine> {
let mut g = self.inner.lock().unwrap();
if let Some(reason) = g.fail_boot.clone() {
return Err(draupnir::Error::Backend(reason));
}
g.booted.push(spec.clone());
Ok(draupnir::Machine::started(format!("draupnir-{}", spec.name), spec))
}
}
impl draupnir::container::ContainerControl for MockDraupnirBackend {
fn container_state(&self, _m: &draupnir::Machine) -> draupnir::container::ContainerState {
self.inner
.lock()
.unwrap()
.state
.clone()
.unwrap_or(draupnir::container::ContainerState::Running)
}
fn drain_logs(&self, _m: &draupnir::Machine) -> Vec<String> {
std::mem::take(&mut self.inner.lock().unwrap().logs)
}
fn stop(&self, m: &draupnir::Machine) {
self.inner.lock().unwrap().stopped.push(m.id.clone());
}
}
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 draupnir_runner_boots_streams_logs_and_kills() {
let backend = MockDraupnirBackend::default();
backend.set_logs(&["holger up", "listening on :8080"]);
let runner = DraupnirContainerBootRunner::with_backend(backend.clone());
let mut proc = runner.spawn(&container_target()).expect("draupnir spawn");
let booted = backend.booted();
assert_eq!(booted.len(), 1, "one boot routed through draupnir");
assert_eq!(booted[0].image, draupnir::ImageSource::OciImage("holger-appliance:0.1".into()));
assert!(booted[0].name.starts_with("nornir-boot-holger-appliance-"), "per-boot name: {}", booted[0].name);
assert_eq!(proc.poll(), BootStatus::Running, "container is up (draupnir state)");
assert_eq!(proc.drain_log(), vec!["holger up", "listening on :8080"], "draupnir-streamed logs drained");
proc.kill();
assert_eq!(proc.poll(), BootStatus::Killed, "kill is terminal");
assert_eq!(backend.stopped().len(), 1, "kill routed to draupnir stop");
proc.kill();
assert_eq!(backend.stopped().len(), 1, "kill is idempotent");
}
#[test]
fn draupnir_runner_maps_container_state_to_terminal_status() {
let make = || {
let backend = MockDraupnirBackend::default();
let runner = DraupnirContainerBootRunner::with_backend(backend.clone());
(backend, runner)
};
let (backend, runner) = make();
backend.set_state(draupnir::container::ContainerState::Exited(0));
let mut p = runner.spawn(&container_target()).unwrap();
assert_eq!(p.poll(), BootStatus::BootedOk);
let (backend, runner) = make();
backend.set_state(draupnir::container::ContainerState::Exited(137));
let mut p = runner.spawn(&container_target()).unwrap();
assert!(matches!(p.poll(), BootStatus::Failed(r) if r.contains("137")));
let (backend, runner) = make();
backend.set_state(draupnir::container::ContainerState::Gone);
let mut p = runner.spawn(&container_target()).unwrap();
assert!(matches!(p.poll(), BootStatus::Failed(_)));
}
#[test]
fn draupnir_runner_drives_the_boot_job_lifecycle_via_the_state_manager() {
let (sink, cap) = capturing_sink();
let backend = MockDraupnirBackend::default();
let runner = Arc::new(DraupnirContainerBootRunner::with_backend(backend.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);
backend.set_state(draupnir::container::ContainerState::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 draupnir_runner_boot_failure_is_reported() {
let backend = MockDraupnirBackend::default();
backend.inner.lock().unwrap().fail_boot = Some("no such image".into());
let runner = DraupnirContainerBootRunner::with_backend(backend);
let err = runner.spawn(&container_target()).err().expect("boot failure surfaces");
assert!(err.contains("no such image"), "reason carried: {err}");
}
#[test]
fn draupnir_runner_rejects_non_container_target() {
let runner = DraupnirContainerBootRunner::with_backend(MockDraupnirBackend::default());
let err = runner.spawn(&exe_target()).err().expect("exe is not a container");
assert!(err.contains("cannot boot a exe"), "{err}");
}
#[test]
fn draupnir_runner_forwards_cmd_env_and_ports_from_the_descriptor() {
let backend = MockDraupnirBackend::default();
let runner = DraupnirContainerBootRunner::with_backend(backend.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("draupnir spawn");
let booted = backend.booted();
assert_eq!(booted.len(), 1, "one boot carried the mapped spec");
let spec = &booted[0];
assert_eq!(spec.image, draupnir::ImageSource::OciImage("holger-appliance:0.1".into()));
assert_eq!(spec.cmd, vec!["/usr/local/bin/holger", "--served"], "cmd override forwarded");
assert_eq!(spec.env.get("RUST_LOG").map(String::as_str), Some("info"), "env forwarded");
assert_eq!(spec.ports, vec![8080], "published port forwarded");
}
#[test]
fn container_build_with_no_overrides_maps_to_a_bare_draupnir_spec() {
let d = container_build_to_draupnir(&ContainerBuild {
name: "x".into(),
base: "scratch".into(),
tags: vec!["x:1".into()],
..Default::default()
});
assert_eq!(d.backend, draupnir::Backend::Container);
assert_eq!(d.image, draupnir::ImageSource::OciImage("x:1".into()));
assert!(d.name.starts_with("nornir-boot-x-"), "per-boot name: {}", d.name);
assert!(d.cmd.is_empty() && d.env.is_empty() && d.ports.is_empty());
d.validate().expect("mapped container spec is valid");
}
#[cfg(feature = "test-boot-container-bollard")]
#[test]
#[ignore = "real podman API: needs a running podman.socket + network pull"]
fn real_draupnir_container_boot_reaches_terminal() {
let runner = DraupnirContainerBootRunner::<draupnir::container::ContainerBoot>::default();
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);
}
}