use draupnir::seed;
use draupnir::CloudInit;
use draupnir::{
await_power_state, boot_and_await, boot_fleet_and_await, boot_fleet_and_await_parallel,
BmcEndpoint, Boot, BootSpec, Error, Lifecycle, Machine, MemberOutcome, NetMode, PowerState,
Result, WaitOptions,
};
use std::cell::{Cell, RefCell};
use std::collections::{HashSet, VecDeque};
use std::time::Duration;
struct ScriptedNode {
states: RefCell<VecDeque<PowerState>>,
fallback: PowerState,
status_calls: Cell<usize>,
}
impl ScriptedNode {
fn new(seq: impl IntoIterator<Item = PowerState>, fallback: PowerState) -> Self {
Self {
states: RefCell::new(seq.into_iter().collect()),
fallback,
status_calls: Cell::new(0),
}
}
}
impl Boot for ScriptedNode {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
Ok(Machine::started(format!("id-{}", spec.name), spec))
}
}
impl Lifecycle for ScriptedNode {
fn power_on(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn status(&self, _m: &Machine) -> Result<PowerState> {
self.status_calls.set(self.status_calls.get() + 1);
Ok(self
.states
.borrow_mut()
.pop_front()
.unwrap_or(self.fallback))
}
}
struct FleetNode {
dead: HashSet<String>,
}
impl Boot for FleetNode {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
Ok(Machine::started(format!("id-{}", spec.name), spec))
}
}
impl Lifecycle for FleetNode {
fn power_on(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn status(&self, m: &Machine) -> Result<PowerState> {
if self.dead.contains(&m.spec_name) {
Ok(PowerState::Off)
} else {
Ok(PowerState::On)
}
}
}
struct MixedNode {
dead: HashSet<String>,
unbootable: HashSet<String>,
}
impl Boot for MixedNode {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
if self.unbootable.contains(&spec.name) {
return Err(Error::Unsupported(format!("no slot for {}", spec.name)));
}
Ok(Machine::started(format!("id-{}", spec.name), spec))
}
}
impl Lifecycle for MixedNode {
fn power_on(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn status(&self, m: &Machine) -> Result<PowerState> {
if self.dead.contains(&m.spec_name) {
Ok(PowerState::Off)
} else {
Ok(PowerState::On)
}
}
}
macro_rules! assert_emit {
($surface:expr, $check:expr, $ok:expr, $($detail:tt)+) => {{
let ok: bool = $ok;
let detail = format!($($detail)+);
draupnir::functional_status(concat!("draupnir/", $surface), $check, ok, &detail);
assert!(ok, "{}::{} — {}", $surface, $check, detail);
}};
}
#[test]
fn validate_rejects_empty_required_payload_paths() {
let bmc = BmcEndpoint {
host: "https://bmc-42.dc.example".into(),
username: "admin".into(),
system_id: "System.Embedded.1".into(),
};
let empty_kernel = matches!(
BootSpec::kvm_kernel_rootfs("kr", "", "/rootfs.cpio.gz").validate(),
Err(Error::Spec(_))
);
let empty_oci = matches!(
BootSpec::container("cache", "").validate(),
Err(Error::Spec(_))
);
let empty_iso = matches!(
BootSpec::redfish_iso("node", " ", bmc).validate(),
Err(Error::Spec(_))
);
let good_ok = BootSpec::container("cache", "redis:7").validate().is_ok();
assert_emit!(
"spec",
"validate_rejects_empty_required_payload_paths",
empty_kernel && empty_oci && empty_iso && good_ok,
"empty kernel/OCI-ref/ISO rejected (kernel:{empty_kernel} oci:{empty_oci} iso:{empty_iso}), good spec ok:{good_ok}"
);
}
#[test]
fn validate_rejects_zero_or_duplicate_container_ports() {
let zero_port = matches!(
BootSpec::container("cache", "redis:7")
.with_port(0)
.validate(),
Err(Error::Spec(_))
);
let dup_port = matches!(
BootSpec::container("cache", "redis:7")
.with_port(8080)
.with_port(8080)
.validate(),
Err(Error::Spec(_))
);
let good_ok = BootSpec::container("cache", "redis:7")
.with_port(8080)
.with_port(8443)
.validate()
.is_ok();
assert_emit!(
"spec",
"validate_rejects_zero_or_duplicate_container_ports",
zero_port && dup_port && good_ok,
"0 port rejected:{zero_port}, duplicate port rejected:{dup_port}, distinct port set ok:{good_ok}"
);
}
#[test]
fn validate_rejects_container_only_cmd_or_ports_on_other_backends() {
let bmc = BmcEndpoint {
host: "https://bmc-42.dc.example".into(),
username: "admin".into(),
system_id: "System.Embedded.1".into(),
};
let kvm_cmd = matches!(
BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz")
.with_cmd(["/bin/init"])
.validate(),
Err(Error::Spec(_))
);
let kvm_port = matches!(
BootSpec::kvm_disk("disky", "/bzImage", "/disk.qcow2")
.with_port(8080)
.validate(),
Err(Error::Spec(_))
);
let redfish_cmd = matches!(
BootSpec::redfish_iso("node", "/boot.iso", bmc.clone())
.with_cmd(["/bin/init"])
.validate(),
Err(Error::Spec(_))
);
let redfish_port = matches!(
BootSpec::redfish_iso("node", "/boot.iso", bmc)
.with_port(443)
.validate(),
Err(Error::Spec(_))
);
let container_ok = BootSpec::container("web", "nginx:latest")
.with_cmd(["nginx", "-g", "daemon off;"])
.with_port(8080)
.validate()
.is_ok();
assert_emit!(
"spec",
"validate_rejects_container_only_cmd_or_ports_on_other_backends",
kvm_cmd && kvm_port && redfish_cmd && redfish_port && container_ok,
"kvm cmd:{kvm_cmd} kvm port:{kvm_port} redfish cmd:{redfish_cmd} redfish port:{redfish_port}, container ok:{container_ok}"
);
}
#[test]
fn net_mode_renders_oci_value_and_is_container_only() {
let none_val = NetMode::None.oci_value() == Some("none");
let default_none = NetMode::Default.oci_value().is_none() && NetMode::Default.is_default();
let host_val = NetMode::Host.oci_value() == Some("host");
let roundtrip = NetMode::from_oci_value(Some("none")) == NetMode::None
&& NetMode::from_oci_value(None) == NetMode::Default
&& NetMode::from_oci_value(Some("bogus")) == NetMode::Default;
let set_on_spec = BootSpec::container("job", "busybox:latest")
.with_net(NetMode::None)
.net
== NetMode::None;
let bmc = BmcEndpoint {
host: "https://bmc-42.dc.example".into(),
username: "admin".into(),
system_id: "System.Embedded.1".into(),
};
let kvm_net_rejected = matches!(
BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz")
.with_net(NetMode::Host)
.validate(),
Err(Error::Spec(_))
);
let kvm_airgap_ok = BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz")
.with_net(NetMode::None)
.validate()
.is_ok();
let redfish_net_rejected = matches!(
BootSpec::redfish_iso("node", "/boot.iso", bmc)
.with_net(NetMode::Host)
.validate(),
Err(Error::Spec(_))
);
let kvm_default_ok = BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz")
.with_net(NetMode::Default)
.validate()
.is_ok();
let container_airgap_ok = BootSpec::container("job", "busybox:latest")
.with_net(NetMode::None)
.validate()
.is_ok();
assert_emit!(
"spec",
"net_mode_renders_oci_value_and_is_container_only",
none_val
&& default_none
&& host_val
&& roundtrip
&& set_on_spec
&& kvm_net_rejected
&& kvm_airgap_ok
&& redfish_net_rejected
&& kvm_default_ok
&& container_airgap_ok,
"none:{none_val} default:{default_none} host:{host_val} roundtrip:{roundtrip} set:{set_on_spec} \
kvm-reject:{kvm_net_rejected} kvm-airgap-ok:{kvm_airgap_ok} redfish-reject:{redfish_net_rejected} \
kvm-default-ok:{kvm_default_ok} container-airgap-ok:{container_airgap_ok}"
);
}
#[test]
fn container_exec_builds_argv() {
use draupnir::container::{exec_argv, ContainerControl, ContainerState, ExecOutcome};
use std::cell::RefCell;
#[derive(Default)]
struct ExecRecorder {
seen: RefCell<Vec<Vec<String>>>,
}
impl ContainerControl for ExecRecorder {
fn container_state(&self, _m: &Machine) -> ContainerState {
ContainerState::Running
}
fn drain_logs(&self, _m: &Machine) -> Vec<String> {
Vec::new()
}
fn stop(&self, _m: &Machine) {}
fn exec_command(&self, command: &[String]) -> Result<ExecOutcome> {
self.seen.borrow_mut().push(command.to_vec());
Ok(ExecOutcome {
exit_code: Some(0),
stdout: vec!["PONG".into()],
stderr: vec![],
})
}
}
let argv_built = exec_argv("draupnir-cache", &["redis-cli", "ping"])
== vec!["exec", "draupnir-cache", "redis-cli", "ping"];
let recorder = ExecRecorder::default();
let m = Machine::started("draupnir-cache", &BootSpec::container("cache", "redis:7"));
let outcome = recorder.exec(&m, &["redis-cli", "ping"]).unwrap();
let exec_records_command = recorder.seen.borrow().as_slice()
== [vec![
"exec".to_string(),
"draupnir-cache".to_string(),
"redis-cli".to_string(),
"ping".to_string(),
]];
let exec_returns_outcome = outcome.exit_code == Some(0) && outcome.stdout == ["PONG"];
let guard = ExecRecorder::default();
let kvm = Machine::started(
"vm-1",
&BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz"),
);
let kvm_rejected = matches!(guard.exec(&kvm, &["ls"]), Err(Error::Spec(_)));
let empty_argv_rejected = matches!(guard.exec(&m, &[]), Err(Error::Spec(_)));
let guard_before_engine = guard.seen.borrow().is_empty();
assert_emit!(
"container",
"container_exec_builds_argv",
argv_built
&& exec_records_command
&& exec_returns_outcome
&& kvm_rejected
&& empty_argv_rejected
&& guard_before_engine,
"argv:{argv_built} records-cmd:{exec_records_command} returns-outcome:{exec_returns_outcome} \
kvm-reject:{kvm_rejected} empty-reject:{empty_argv_rejected} guard-first:{guard_before_engine}"
);
}
#[test]
fn await_power_state_confirms_a_boot_readback_across_backends() {
let bounded = WaitOptions::bounded(Duration::from_millis(200), Duration::from_millis(1));
let up = ScriptedNode::new(
[PowerState::Unknown, PowerState::Unknown, PowerState::On],
PowerState::On,
);
let m = Machine::started("node", &BootSpec::container("c", "redis:7"));
let reached_ok = await_power_state(&up, &m, PowerState::On, &bounded).is_ok();
let dead = ScriptedNode::new(std::iter::empty(), PowerState::Off);
let timed_out = matches!(
await_power_state(
&dead,
&m,
PowerState::On,
&WaitOptions::bounded(Duration::from_millis(20), Duration::from_millis(1))
),
Err(Error::Backend(_))
);
let idle = ScriptedNode::new([PowerState::On], PowerState::On);
let unknown_rejected = matches!(
await_power_state(&idle, &m, PowerState::Unknown, &bounded),
Err(Error::Spec(_))
) && idle.status_calls.get() == 0;
let node = ScriptedNode::new([PowerState::Unknown, PowerState::On], PowerState::On);
let confirmed = boot_and_await(&node, &BootSpec::container("cache", "redis:7"), &bounded)
.map(|m| m.id == "id-cache" && m.power == PowerState::On)
.unwrap_or(false);
assert_emit!(
"lifecycle",
"await_power_state_confirms_a_boot_readback",
reached_ok && timed_out && unknown_rejected && confirmed,
"reached On:{reached_ok} never-up times out:{timed_out} await-Unknown rejected:{unknown_rejected} boot_and_await confirms On:{confirmed}"
);
}
#[test]
fn boot_fleet_and_await_rolls_up_who_is_ready() {
let bounded = WaitOptions::bounded(Duration::from_millis(200), Duration::from_millis(1));
let backend = FleetNode {
dead: HashSet::from(["node-2".to_string()]),
};
let one = BootSpec::redfish_iso(
"node",
"/images/installer.iso",
BmcEndpoint {
host: "https://bmc-42.dc.example".into(),
username: "admin".into(),
system_id: "System.Embedded.1".into(),
},
);
let rollup = boot_fleet_and_await(&one, 3, &backend, &bounded);
let names_in_order = rollup
.members
.iter()
.map(|m| m.name.as_str())
.eq(["node-1", "node-2", "node-3"]);
let node2_timeout = matches!(rollup.members[1].outcome, MemberOutcome::Timeout(_));
let others_up = matches!(rollup.members[0].outcome, MemberOutcome::Up(_))
&& matches!(rollup.members[2].outcome, MemberOutcome::Up(_));
let partial_counts = rollup.ready() == 2 && rollup.failed() == 1 && !rollup.all_ready();
let healthy = FleetNode {
dead: HashSet::new(),
};
let up = boot_fleet_and_await(
&BootSpec::container("cache", "redis:7"),
4,
&healthy,
&bounded,
);
let all_up = up.ready() == 4 && up.failed() == 0 && up.all_ready();
assert_emit!(
"lifecycle",
"boot_fleet_and_await_rolls_up_who_is_ready",
names_in_order && node2_timeout && others_up && partial_counts && all_up,
"order:{names_in_order} node-2 timeout:{node2_timeout} others up:{others_up} partial(2/1):{partial_counts} full-fleet all-ready:{all_up}"
);
}
#[test]
fn boot_fleet_and_await_parallel_matches_the_serial_rollup() {
let bounded = WaitOptions::bounded(Duration::from_millis(200), Duration::from_millis(1));
let backend = MixedNode {
dead: HashSet::from(["node-3".to_string()]),
unbootable: HashSet::from(["node-5".to_string()]),
};
let spec = BootSpec::container("node", "redis:7");
let serial = boot_fleet_and_await(&spec, 5, &backend, &bounded);
let parallel = boot_fleet_and_await_parallel(&spec, 5, &backend, &bounded);
let identical = parallel == serial;
let mixed_shape = parallel.members.len() == 5
&& matches!(parallel.members[0].outcome, MemberOutcome::Up(_))
&& matches!(parallel.members[2].outcome, MemberOutcome::Timeout(_))
&& matches!(parallel.members[4].outcome, MemberOutcome::Error(_))
&& parallel.ready() == 3
&& parallel.failed() == 2;
assert_emit!(
"lifecycle",
"boot_fleet_and_await_parallel_matches_serial",
identical && mixed_shape,
"parallel==serial:{identical} mixed(3 up/1 timeout/1 error):{mixed_shape}"
);
}
#[test]
fn seed_files_render_exact_user_data_and_meta_data() {
let user_data = "#cloud-config\nhostname: web-01\nruncmd:\n - [systemctl, start, holger]\n";
let ci = CloudInit {
user_data: user_data.into(),
meta_data: Some("instance-id: iid-web-01\nlocal-hostname: web-01\n".into()),
network_config: None,
};
let files = seed::seed_files(&ci);
assert_emit!(
"seed",
"seed_files_render_exact_user_data_and_meta_data",
files.len() == 2
&& files[0] == ("user-data", user_data.to_string())
&& files[1]
== (
"meta-data",
"instance-id: iid-web-01\nlocal-hostname: web-01\n".to_string()
),
"{} files: user-data verbatim + meta-data instance-id/hostname exact",
files.len()
);
}
#[test]
fn seed_files_default_meta_data_carries_instance_id_and_hostname() {
let files = seed::seed_files(&CloudInit::user_data("#cloud-config\n"));
assert_emit!(
"seed",
"omitted_meta_data_gets_default_instance_id_and_hostname",
files[1].0 == "meta-data"
&& files[1].1 == seed::DEFAULT_META_DATA
&& files[1].1.contains("instance-id:")
&& files[1].1.contains("local-hostname:"),
"default meta-data = {:?}",
files[1].1
);
}
#[test]
fn seed_files_emit_optional_network_config() {
let net = CloudInit::user_data("#cloud-config\n")
.with_network_config("version: 2\nethernets:\n eth0:\n dhcp4: true\n");
let files = seed::seed_files(&net);
assert_emit!(
"seed",
"network_config_is_the_optional_third_file",
files.len() == 3
&& files[2]
== (
"network-config",
"version: 2\nethernets:\n eth0:\n dhcp4: true\n".to_string()
),
"{} files with a network-config",
files.len()
);
}
#[test]
fn write_seed_dir_lands_files_verbatim_on_disk() {
let dir = std::env::temp_dir().join(format!("draupnir-tm-seeddir-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let ci = CloudInit {
user_data: "#cloud-config\npackages: [curl]\n".into(),
meta_data: Some("instance-id: iid-42\nlocal-hostname: node-42\n".into()),
network_config: None,
};
seed::write_seed_dir(&dir, &ci).unwrap();
let ud = std::fs::read_to_string(dir.join("user-data")).unwrap_or_default();
let md = std::fs::read_to_string(dir.join("meta-data")).unwrap_or_default();
assert_emit!(
"seed",
"write_seed_dir_lands_files_verbatim",
ud == "#cloud-config\npackages: [curl]\n"
&& md == "instance-id: iid-42\nlocal-hostname: node-42\n",
"seed dir at {} carries user-data + meta-data verbatim",
dir.display()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn failed_boot_and_rollback_emit_red_rows() {
struct DeadBackend;
impl Boot for DeadBackend {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
Err(Error::Backend(format!("no host slot for {}", spec.name)))
}
}
impl Lifecycle for DeadBackend {
fn power_on(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
Err(Error::Backend("power-off refused".into()))
}
fn status(&self, _m: &Machine) -> Result<PowerState> {
Ok(PowerState::Off)
}
}
let dead = DeadBackend;
let spec = BootSpec::container("doomed", "redis:7");
let boot_err = matches!(draupnir::boot(&spec, &dead), Err(Error::Backend(_)));
let m = Machine::started("doomed", &spec);
let rollback_err = matches!(draupnir::rollback(&dead, &m), Err(Error::Backend(_)));
let healthy = ScriptedNode::new([PowerState::On], PowerState::On);
let boot_ok = draupnir::boot(&spec, &healthy).is_ok();
let rollback_ok = draupnir::rollback(&healthy, &m).is_ok();
assert!(
boot_err,
"a failed backend boot must propagate as Err(Backend)"
);
assert!(
rollback_err,
"a failed power-off must propagate as Err(Backend)"
);
assert!(
boot_ok && rollback_ok,
"a healthy backend boots and rolls back cleanly"
);
#[cfg(feature = "testmatrix")]
{
let rows = draupnir::drain_status_rows();
let has = |component: &str, check: &str, ok: bool| {
rows.iter()
.any(|(c, k, o)| c == component && k == check && *o == ok)
};
assert!(
has("draupnir/boot", "boot", false),
"failed boot => RED row"
);
assert!(
has("draupnir/lifecycle", "rollback", false),
"failed rollback => RED row"
);
assert!(
has("draupnir/boot", "boot", true),
"healthy boot => GREEN row"
);
assert!(
has("draupnir/lifecycle", "rollback", true),
"healthy rollback => GREEN row"
);
}
}
#[cfg(feature = "seed")]
#[test]
fn build_seed_image_roundtrips_cidata_label_and_contents() {
use std::io::Read;
let out = std::env::temp_dir().join(format!("draupnir-tm-seed-{}.img", std::process::id()));
let _ = std::fs::remove_file(&out);
let ci = CloudInit {
user_data: "#cloud-config\nruncmd:\n - [echo, hi]\n".into(),
meta_data: Some("instance-id: iid-img\nlocal-hostname: img-host\n".into()),
network_config: None,
};
seed::build_seed_image(&out, &ci).unwrap();
let img = std::fs::File::options()
.read(true)
.write(true)
.open(&out)
.unwrap();
let fs = fatfs::FileSystem::new(img, fatfs::FsOptions::new()).unwrap();
let label_ok = fs.volume_label().to_ascii_lowercase() == seed::NOCLOUD_LABEL;
let mut ud = String::new();
fs.root_dir()
.open_file("user-data")
.unwrap()
.read_to_string(&mut ud)
.unwrap();
assert_emit!(
"seed",
"build_seed_image_roundtrips_cidata_label_and_contents",
label_ok && ud == "#cloud-config\nruncmd:\n - [echo, hi]\n",
"vfat label=cidata:{label_ok}, user-data survives the round-trip"
);
drop(fs);
let _ = std::fs::remove_file(&out);
}