use std::collections::BTreeMap;
use std::fmt;
pub mod container;
pub mod kvm;
pub mod redfish;
pub mod seed;
pub type Result<T> = std::result::Result<T, Error>;
#[inline]
pub fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(component, check, ok, detail);
#[cfg(not(feature = "testmatrix"))]
{
let _ = (component, check, ok, detail);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
Unsupported(String),
Backend(String),
Spec(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Unsupported(m) => write!(f, "draupnir: unsupported: {m}"),
Error::Backend(m) => write!(f, "draupnir: backend error: {m}"),
Error::Spec(m) => write!(f, "draupnir: invalid boot spec: {m}"),
}
}
}
impl std::error::Error for Error {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
Kvm,
Container,
Redfish,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImageSource {
KernelRootfs {
kernel: String,
rootfs: String,
},
Disk {
kernel: String,
disk: String,
},
OciImage(String),
Iso(String),
}
impl ImageSource {
pub fn suits(&self, backend: Backend) -> bool {
matches!(
(self, backend),
(ImageSource::KernelRootfs { .. }, Backend::Kvm)
| (ImageSource::Disk { .. }, Backend::Kvm)
| (ImageSource::OciImage(_), Backend::Container)
| (ImageSource::Iso(_), Backend::Redfish)
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BmcEndpoint {
pub host: String,
pub username: String,
pub system_id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootTarget {
Cd,
Pxe,
Hdd,
BiosSetup,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PowerState {
On,
Off,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NetMode {
#[default]
Default,
None,
Host,
Bridge,
}
impl NetMode {
pub fn oci_value(self) -> Option<&'static str> {
match self {
NetMode::Default => Option::None,
NetMode::None => Some("none"),
NetMode::Host => Some("host"),
NetMode::Bridge => Some("bridge"),
}
}
pub fn is_default(self) -> bool {
matches!(self, NetMode::Default)
}
pub fn from_oci_value(value: Option<&str>) -> Self {
match value {
Some("none") => NetMode::None,
Some("host") => NetMode::Host,
Some("bridge") => NetMode::Bridge,
_ => NetMode::Default,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CloudInit {
pub user_data: String,
pub meta_data: Option<String>,
pub network_config: Option<String>,
}
impl CloudInit {
pub fn user_data(user_data: impl Into<String>) -> Self {
Self { user_data: user_data.into(), meta_data: None, network_config: None }
}
pub fn with_network_config(mut self, network_config: impl Into<String>) -> Self {
self.network_config = Some(network_config.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct PortMap {
pub host: u16,
pub container: u16,
}
impl PortMap {
pub fn new(host: u16, container: u16) -> Self {
Self { host, container }
}
pub fn same(port: u16) -> Self {
Self { host: port, container: port }
}
}
impl From<u16> for PortMap {
fn from(port: u16) -> Self {
Self::same(port)
}
}
impl From<(u16, u16)> for PortMap {
fn from((host, container): (u16, u16)) -> Self {
Self::new(host, container)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BootSpec {
pub name: String,
pub backend: Backend,
pub image: ImageSource,
pub mem_mb: u32,
pub cores: u32,
pub cmdline: String,
pub cmd: Vec<String>,
pub ports: Vec<u16>,
pub port_maps: Vec<PortMap>,
pub env: BTreeMap<String, String>,
pub bmc: Option<BmcEndpoint>,
pub cloud_init: Option<CloudInit>,
pub net: NetMode,
pub cpus: Option<f64>,
pub mem_limit_mb: Option<u32>,
}
impl BootSpec {
pub fn kvm_kernel_rootfs(
name: impl Into<String>,
kernel: impl Into<String>,
rootfs: impl Into<String>,
) -> Self {
Self {
name: name.into(),
backend: Backend::Kvm,
image: ImageSource::KernelRootfs { kernel: kernel.into(), rootfs: rootfs.into() },
mem_mb: 512,
cores: 2,
cmdline: String::new(),
cmd: Vec::new(),
ports: Vec::new(),
port_maps: Vec::new(),
env: BTreeMap::new(),
bmc: None,
cloud_init: None,
net: NetMode::Default,
cpus: None,
mem_limit_mb: None,
}
}
pub fn kvm_disk(
name: impl Into<String>,
kernel: impl Into<String>,
disk: impl Into<String>,
) -> Self {
Self {
name: name.into(),
backend: Backend::Kvm,
image: ImageSource::Disk { kernel: kernel.into(), disk: disk.into() },
mem_mb: 512,
cores: 2,
cmdline: String::new(),
cmd: Vec::new(),
ports: Vec::new(),
port_maps: Vec::new(),
env: BTreeMap::new(),
bmc: None,
cloud_init: None,
net: NetMode::Default,
cpus: None,
mem_limit_mb: None,
}
}
pub fn container(name: impl Into<String>, oci_image: impl Into<String>) -> Self {
Self {
name: name.into(),
backend: Backend::Container,
image: ImageSource::OciImage(oci_image.into()),
mem_mb: 0,
cores: 0,
cmdline: String::new(),
cmd: Vec::new(),
ports: Vec::new(),
port_maps: Vec::new(),
env: BTreeMap::new(),
bmc: None,
cloud_init: None,
net: NetMode::Default,
cpus: None,
mem_limit_mb: None,
}
}
pub fn redfish_iso(name: impl Into<String>, iso: impl Into<String>, bmc: BmcEndpoint) -> Self {
Self {
name: name.into(),
backend: Backend::Redfish,
image: ImageSource::Iso(iso.into()),
mem_mb: 0,
cores: 0,
cmdline: String::new(),
cmd: Vec::new(),
ports: Vec::new(),
port_maps: Vec::new(),
env: BTreeMap::new(),
bmc: Some(bmc),
cloud_init: None,
net: NetMode::Default,
cpus: None,
mem_limit_mb: None,
}
}
pub fn with_env(mut self, key: impl Into<String>, val: impl Into<String>) -> Self {
self.env.insert(key.into(), val.into());
self
}
pub fn with_cmd<I, S>(mut self, cmd: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.cmd = cmd.into_iter().map(Into::into).collect();
self
}
pub fn with_port(mut self, port: u16) -> Self {
self.ports.push(port);
self
}
pub fn with_port_map(mut self, host: u16, container: u16) -> Self {
self.port_maps.push(PortMap::new(host, container));
self
}
pub fn with_cloud_init(mut self, ci: CloudInit) -> Self {
self.cloud_init = Some(ci);
self
}
pub fn with_net(mut self, net: NetMode) -> Self {
self.net = net;
self
}
pub fn with_cpus(mut self, cpus: f64) -> Self {
self.cpus = Some(cpus);
self
}
pub fn with_mem_limit_mb(mut self, mem_mb: u32) -> Self {
self.mem_limit_mb = Some(mem_mb);
self
}
pub fn validate(&self) -> Result<()> {
let require = |what: &str, val: &str| -> Result<()> {
if val.trim().is_empty() {
Err(Error::Spec(format!("a {:?} boot needs a non-empty {what}", self.backend)))
} else {
Ok(())
}
};
require("instance name", &self.name)?;
if !self.image.suits(self.backend) {
return Err(Error::Spec(format!(
"{:?} image is not bootable by the {:?} backend",
self.image, self.backend
)));
}
match &self.image {
ImageSource::KernelRootfs { kernel, rootfs } => {
require("kernel path", kernel)?;
require("rootfs path", rootfs)?;
}
ImageSource::Disk { kernel, disk } => {
require("kernel path", kernel)?;
require("disk path", disk)?;
}
ImageSource::OciImage(image) => require("OCI image reference", image)?,
ImageSource::Iso(iso) => require("ISO path", iso)?,
}
if self.backend == Backend::Kvm {
if self.mem_mb == 0 {
return Err(Error::Spec("a Kvm boot needs mem_mb > 0 (VM RAM)".into()));
}
if self.cores == 0 {
return Err(Error::Spec("a Kvm boot needs cores > 0 (vCPUs)".into()));
}
}
if self.backend != Backend::Container {
if !self.cmd.is_empty() {
return Err(Error::Spec(format!(
"a {:?} boot takes no container cmd (cmd is container-only)",
self.backend
)));
}
if !self.ports.is_empty() || !self.port_maps.is_empty() {
return Err(Error::Spec(format!(
"a {:?} boot publishes no ports (ports are container-only)",
self.backend
)));
}
if !self.net.is_default() {
return Err(Error::Spec(format!(
"a {:?} boot takes no container network mode (net is container-only)",
self.backend
)));
}
if self.cpus.is_some() {
return Err(Error::Spec(format!(
"a {:?} boot takes no container cpus quota (cpus is container-only)",
self.backend
)));
}
if self.mem_limit_mb.is_some() {
return Err(Error::Spec(format!(
"a {:?} boot takes no container memory limit (mem_limit_mb is container-only)",
self.backend
)));
}
}
if let Some(c) = self.cpus {
if !c.is_finite() || c <= 0.0 {
return Err(Error::Spec(
"a container cpus quota must be a finite value > 0 (use None for all host cores)".into(),
));
}
}
if self.mem_limit_mb == Some(0) {
return Err(Error::Spec(
"a container memory limit must be > 0 MiB (use None for unconstrained)".into(),
));
}
if !self.ports.is_empty() || !self.port_maps.is_empty() {
let mut seen_host = std::collections::BTreeSet::new();
for &p in &self.ports {
if p == 0 {
return Err(Error::Spec(
"a published container port must be > 0".into(),
));
}
if !seen_host.insert(p) {
return Err(Error::Spec(format!(
"published container port {p} is listed twice"
)));
}
}
for pm in &self.port_maps {
if pm.host == 0 || pm.container == 0 {
return Err(Error::Spec(
"a published container port map needs host > 0 and container > 0".into(),
));
}
if !seen_host.insert(pm.host) {
return Err(Error::Spec(format!(
"published container host port {} is bound twice",
pm.host
)));
}
}
}
match (self.backend, &self.bmc) {
(Backend::Redfish, None) => {
Err(Error::Spec("Redfish boot needs a BMC endpoint".into()))
}
(Backend::Redfish, Some(bmc)) => {
require("BMC host", &bmc.host)?;
require("BMC username", &bmc.username)?;
require("BMC system id", &bmc.system_id)?;
Ok(())
}
(_, Some(_)) => Err(Error::Spec(
"only the Redfish backend takes a BMC endpoint".into(),
)),
(_, None) => Ok(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Machine {
pub id: String,
pub spec_name: String,
pub backend: Backend,
pub power: PowerState,
}
impl Machine {
pub fn started(id: impl Into<String>, spec: &BootSpec) -> Self {
Self {
id: id.into(),
spec_name: spec.name.clone(),
backend: spec.backend,
power: PowerState::On,
}
}
}
pub trait Boot {
fn boot(&self, spec: &BootSpec) -> Result<Machine>;
}
pub trait Lifecycle {
fn power_on(&self, machine: &Machine) -> Result<()>;
fn power_off(&self, machine: &Machine) -> Result<()>;
fn status(&self, machine: &Machine) -> Result<PowerState>;
}
pub trait VirtualMedia {
fn insert_media(&self, node: &BmcEndpoint, iso: &str) -> Result<()>;
fn eject_media(&self, node: &BmcEndpoint) -> Result<()>;
fn set_boot_override(&self, node: &BmcEndpoint, target: BootTarget) -> Result<()>;
}
pub fn boot(spec: &BootSpec, backend: &dyn Boot) -> Result<Machine> {
spec.validate()?;
backend.boot(spec)
}
pub fn plan_fleet(spec: &BootSpec, n: usize) -> Vec<BootSpec> {
(1..=n)
.map(|i| {
let mut member = spec.clone();
member.name = format!("{}-{i}", spec.name);
member
})
.collect()
}
pub fn boot_fleet(spec: &BootSpec, n: usize, backend: &dyn Boot) -> Vec<Result<Machine>> {
plan_fleet(spec, n)
.iter()
.map(|member| boot(member, backend))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WaitOptions {
pub timeout: Option<std::time::Duration>,
pub poll_interval: std::time::Duration,
}
impl Default for WaitOptions {
fn default() -> Self {
Self { timeout: None, poll_interval: std::time::Duration::from_millis(200) }
}
}
impl WaitOptions {
pub fn poll_every(poll_interval: std::time::Duration) -> Self {
Self { timeout: None, poll_interval }
}
pub fn bounded(timeout: std::time::Duration, poll_interval: std::time::Duration) -> Self {
Self { timeout: Some(timeout), poll_interval }
}
}
pub fn await_power_state<L: Lifecycle>(
lifecycle: &L,
machine: &Machine,
want: PowerState,
opts: &WaitOptions,
) -> Result<()> {
if want == PowerState::Unknown {
return Err(Error::Spec(
"cannot await PowerState::Unknown (it means \"not observed\")".into(),
));
}
let deadline = opts.timeout.map(|t| std::time::Instant::now() + t);
loop {
let observed = lifecycle.status(machine)?;
if observed == want {
functional_status(
"draupnir/lifecycle",
"await_power_state",
true,
&format!("instance {} reached {want:?}", machine.id),
);
return Ok(());
}
if let Some(dl) = deadline {
if std::time::Instant::now() >= dl {
functional_status(
"draupnir/lifecycle",
"await_power_state",
false,
&format!("instance {} never reached {want:?}", machine.id),
);
return Err(Error::Backend(format!(
"instance `{}` did not reach {want:?} within {:?} (last observed {observed:?})",
machine.id,
opts.timeout.unwrap()
)));
}
}
std::thread::sleep(opts.poll_interval);
}
}
pub fn boot_and_await<B>(backend: &B, spec: &BootSpec, opts: &WaitOptions) -> Result<Machine>
where
B: Boot + Lifecycle,
{
let machine = boot(spec, backend)?;
await_power_state(backend, &machine, PowerState::On, opts)?;
Ok(machine)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MemberOutcome {
Up(Machine),
Timeout(String),
Error(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberReadback {
pub name: String,
pub outcome: MemberOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FleetReadback {
pub members: Vec<MemberReadback>,
}
impl FleetReadback {
pub fn ready(&self) -> usize {
self.members.iter().filter(|m| matches!(m.outcome, MemberOutcome::Up(_))).count()
}
pub fn failed(&self) -> usize {
self.members.len() - self.ready()
}
pub fn all_ready(&self) -> bool {
!self.members.is_empty() && self.failed() == 0
}
}
pub fn boot_fleet_and_await<B>(
spec: &BootSpec,
n: usize,
backend: &B,
opts: &WaitOptions,
) -> FleetReadback
where
B: Boot + Lifecycle,
{
let members =
plan_fleet(spec, n).iter().map(|member| classify_member(backend, member, opts)).collect();
let rollup = FleetReadback { members };
functional_status(
"draupnir/lifecycle",
"boot_fleet_and_await",
rollup.all_ready(),
&format!("fleet of {n}: {} ready, {} failed", rollup.ready(), rollup.failed()),
);
rollup
}
fn classify_member<B>(backend: &B, member: &BootSpec, opts: &WaitOptions) -> MemberReadback
where
B: Boot + Lifecycle,
{
let outcome = match boot_and_await(backend, member, opts) {
Ok(machine) => MemberOutcome::Up(machine),
Err(Error::Backend(msg)) => MemberOutcome::Timeout(msg),
Err(Error::Spec(msg)) | Err(Error::Unsupported(msg)) => MemberOutcome::Error(msg),
};
MemberReadback { name: member.name.clone(), outcome }
}
pub fn boot_fleet_and_await_parallel<B>(
spec: &BootSpec,
n: usize,
backend: &B,
opts: &WaitOptions,
) -> FleetReadback
where
B: Boot + Lifecycle + Sync,
{
let plan = plan_fleet(spec, n);
let members: Vec<MemberReadback> = std::thread::scope(|scope| {
let handles: Vec<_> = plan
.iter()
.map(|member| scope.spawn(move || classify_member(backend, member, opts)))
.collect();
handles.into_iter().map(|h| h.join().expect("fleet member thread panicked")).collect()
});
let rollup = FleetReadback { members };
functional_status(
"draupnir/lifecycle",
"boot_fleet_and_await_parallel",
rollup.all_ready(),
&format!("fleet of {n} (parallel): {} ready, {} failed", rollup.ready(), rollup.failed()),
);
rollup
}
#[cfg(test)]
mod tests {
use super::*;
fn bmc() -> BmcEndpoint {
BmcEndpoint {
host: "https://bmc-42.dc.example".into(),
username: "admin".into(),
system_id: "System.Embedded.1".into(),
}
}
#[test]
fn image_source_suits_the_right_backend() {
assert!(ImageSource::Disk { kernel: "/bzImage".into(), disk: "/d.qcow2".into() }
.suits(Backend::Kvm));
assert!(ImageSource::OciImage("redis:7".into()).suits(Backend::Container));
assert!(ImageSource::Iso("/boot.iso".into()).suits(Backend::Redfish));
assert!(!ImageSource::Iso("/boot.iso".into()).suits(Backend::Kvm));
assert!(!ImageSource::OciImage("redis:7".into()).suits(Backend::Redfish));
}
#[test]
fn valid_specs_pass_validation() {
BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz")
.validate()
.unwrap();
BootSpec::container("cache", "docker.io/library/redis:7")
.validate()
.unwrap();
BootSpec::redfish_iso("node-42", "/images/installer.iso", bmc())
.validate()
.unwrap();
BootSpec::kvm_disk("disky", "/bzImage", "/disk.qcow2")
.validate()
.unwrap();
}
#[test]
fn disk_boot_without_a_kernel_is_rejected() {
let mut spec = BootSpec::kvm_disk("disky", "", "/disk.qcow2");
assert!(matches!(spec.validate(), Err(Error::Spec(_))));
spec = BootSpec::kvm_disk("disky", "/bzImage", "");
assert!(matches!(spec.validate(), Err(Error::Spec(_))));
}
#[test]
fn empty_required_payload_paths_are_rejected_on_every_image_source() {
let mut kr = BootSpec::kvm_kernel_rootfs("kr", "", "/rootfs.cpio.gz");
assert!(matches!(kr.validate(), Err(Error::Spec(_))), "empty kernel rejected");
kr = BootSpec::kvm_kernel_rootfs("kr", "/bzImage", " ");
assert!(matches!(kr.validate(), Err(Error::Spec(_))), "whitespace rootfs rejected");
let oci = BootSpec::container("cache", "");
assert!(matches!(oci.validate(), Err(Error::Spec(_))), "empty OCI ref rejected");
let iso = BootSpec::redfish_iso("node", " ", bmc());
assert!(matches!(iso.validate(), Err(Error::Spec(_))), "empty ISO path rejected");
BootSpec::kvm_kernel_rootfs("kr", "/bzImage", "/rootfs.cpio.gz").validate().unwrap();
BootSpec::container("cache", "redis:7").validate().unwrap();
BootSpec::redfish_iso("node", "/boot.iso", bmc()).validate().unwrap();
}
#[test]
fn blank_instance_name_is_rejected_on_every_backend() {
let mut vm = BootSpec::kvm_kernel_rootfs("", "/bzImage", "/rootfs.cpio.gz");
assert!(matches!(vm.validate(), Err(Error::Spec(_))), "blank KVM name rejected");
vm = BootSpec::kvm_kernel_rootfs(" ", "/bzImage", "/rootfs.cpio.gz");
assert!(matches!(vm.validate(), Err(Error::Spec(_))), "whitespace KVM name rejected");
let ctr = BootSpec::container("", "redis:7");
assert!(matches!(ctr.validate(), Err(Error::Spec(_))), "blank container name rejected");
let node = BootSpec::redfish_iso("", "/boot.iso", bmc());
assert!(matches!(node.validate(), Err(Error::Spec(_))), "blank Redfish name rejected");
}
#[test]
fn kvm_spec_without_ram_or_cpus_is_rejected() {
let mut vm = BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz");
vm.mem_mb = 0;
assert!(matches!(vm.validate(), Err(Error::Spec(_))), "0 MiB RAM rejected");
vm = BootSpec::kvm_disk("disky", "/bzImage", "/disk.qcow2");
vm.cores = 0;
assert!(matches!(vm.validate(), Err(Error::Spec(_))), "0 vCPUs rejected");
BootSpec::container("cache", "redis:7").validate().unwrap();
BootSpec::redfish_iso("node", "/boot.iso", bmc()).validate().unwrap();
}
#[test]
fn redfish_spec_with_a_blank_bmc_field_is_rejected() {
let mut spec = BootSpec::redfish_iso("node", "/boot.iso", bmc());
spec.bmc = Some(BmcEndpoint { host: " ".into(), ..bmc() });
assert!(matches!(spec.validate(), Err(Error::Spec(_))), "blank BMC host rejected");
spec = BootSpec::redfish_iso("node", "/boot.iso", bmc());
spec.bmc = Some(BmcEndpoint { username: String::new(), ..bmc() });
assert!(matches!(spec.validate(), Err(Error::Spec(_))), "blank BMC username rejected");
spec = BootSpec::redfish_iso("node", "/boot.iso", bmc());
spec.bmc = Some(BmcEndpoint { system_id: String::new(), ..bmc() });
assert!(matches!(spec.validate(), Err(Error::Spec(_))), "blank BMC system id rejected");
BootSpec::redfish_iso("node", "/boot.iso", bmc()).validate().unwrap();
}
#[test]
fn container_spec_with_a_zero_or_duplicate_port_is_rejected() {
let zero = BootSpec::container("cache", "redis:7").with_port(0);
assert!(matches!(zero.validate(), Err(Error::Spec(_))), "0 published port rejected");
let dup = BootSpec::container("cache", "redis:7").with_port(8080).with_port(8080);
assert!(matches!(dup.validate(), Err(Error::Spec(_))), "duplicate published port rejected");
let mixed = BootSpec::container("cache", "redis:7").with_port(8080).with_port(0);
assert!(matches!(mixed.validate(), Err(Error::Spec(_))), "0 among valid ports rejected");
BootSpec::container("cache", "redis:7").with_port(8080).with_port(8443).validate().unwrap();
BootSpec::container("cache", "redis:7").validate().unwrap();
}
#[test]
fn container_port_map_validates_distinct_host_container_and_host_collisions() {
BootSpec::container("falkor", "docker.io/falkordb/falkordb:v4.20.0")
.with_port_map(6380, 6379)
.validate()
.unwrap();
let host0 = BootSpec::container("f", "img:1").with_port_map(0, 6379);
assert!(matches!(host0.validate(), Err(Error::Spec(_))), "host 0 rejected");
let cont0 = BootSpec::container("f", "img:1").with_port_map(6380, 0);
assert!(matches!(cont0.validate(), Err(Error::Spec(_))), "container 0 rejected");
let dup_map = BootSpec::container("f", "img:1").with_port_map(6380, 6379).with_port_map(6380, 15002);
assert!(matches!(dup_map.validate(), Err(Error::Spec(_))), "duplicate host port across maps rejected");
let dup_mix = BootSpec::container("f", "img:1").with_port(8080).with_port_map(8080, 80);
assert!(matches!(dup_mix.validate(), Err(Error::Spec(_))), "host port shared by ports+port_maps rejected");
BootSpec::container("f", "img:1").with_port_map(6380, 6379).with_port_map(6381, 6379).validate().unwrap();
BootSpec::container("f", "img:1").with_port(6379).with_port_map(15003, 15002).validate().unwrap();
}
#[test]
fn container_only_cmd_or_ports_on_a_non_container_backend_is_rejected() {
let kvm_cmd = BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz")
.with_cmd(["/bin/init"]);
assert!(matches!(kvm_cmd.validate(), Err(Error::Spec(_))), "cmd on KVM rejected");
let kvm_port =
BootSpec::kvm_disk("disky", "/bzImage", "/disk.qcow2").with_port(8080);
assert!(matches!(kvm_port.validate(), Err(Error::Spec(_))), "port on KVM rejected");
let redfish_cmd =
BootSpec::redfish_iso("node", "/boot.iso", bmc()).with_cmd(["/bin/init"]);
assert!(matches!(redfish_cmd.validate(), Err(Error::Spec(_))), "cmd on Redfish rejected");
let redfish_port = BootSpec::redfish_iso("node", "/boot.iso", bmc()).with_port(443);
assert!(matches!(redfish_port.validate(), Err(Error::Spec(_))), "port on Redfish rejected");
BootSpec::container("web", "nginx:latest")
.with_cmd(["nginx", "-g", "daemon off;"])
.with_port(8080)
.with_port(8443)
.validate()
.unwrap();
}
#[test]
fn container_resource_knobs_are_container_only_and_must_be_positive() {
let kvm_cpus = BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz")
.with_cpus(8.0);
assert!(matches!(kvm_cpus.validate(), Err(Error::Spec(_))), "cpus on KVM rejected");
let kvm_mem = BootSpec::kvm_disk("disky", "/bzImage", "/disk.qcow2").with_mem_limit_mb(4096);
assert!(matches!(kvm_mem.validate(), Err(Error::Spec(_))), "mem limit on KVM rejected");
let redfish_cpus = BootSpec::redfish_iso("node", "/boot.iso", bmc()).with_cpus(4.0);
assert!(matches!(redfish_cpus.validate(), Err(Error::Spec(_))), "cpus on Redfish rejected");
let zero_cpus = BootSpec::container("cache", "redis:7").with_cpus(0.0);
assert!(matches!(zero_cpus.validate(), Err(Error::Spec(_))), "0 cpus rejected");
let mut neg = BootSpec::container("cache", "redis:7");
neg.cpus = Some(-1.0);
assert!(matches!(neg.validate(), Err(Error::Spec(_))), "negative cpus rejected");
let zero_mem = BootSpec::container("cache", "redis:7").with_mem_limit_mb(0);
assert!(matches!(zero_mem.validate(), Err(Error::Spec(_))), "0 MiB memory rejected");
BootSpec::container("falkordb", "docker.io/falkordb/falkordb:v4.20.0")
.with_port(6379)
.with_cpus(12.0)
.with_mem_limit_mb(16384)
.validate()
.unwrap();
BootSpec::container("cache", "redis:7").validate().unwrap();
assert_eq!(BootSpec::container("cache", "redis:7").cpus, None, "default = all host cores");
}
#[test]
fn image_backend_mismatch_is_rejected() {
let mut spec = BootSpec::container("bad", "redis:7");
spec.image = ImageSource::Iso("/boot.iso".into());
assert!(matches!(spec.validate(), Err(Error::Spec(_))));
}
#[test]
fn redfish_without_bmc_is_rejected() {
let mut spec = BootSpec::redfish_iso("node", "/boot.iso", bmc());
spec.bmc = None;
assert!(matches!(spec.validate(), Err(Error::Spec(_))));
}
#[test]
fn non_redfish_with_bmc_is_rejected() {
let mut spec = BootSpec::container("cache", "redis:7");
spec.bmc = Some(bmc());
assert!(matches!(spec.validate(), Err(Error::Spec(_))));
}
#[test]
fn started_machine_records_the_spec() {
let spec = BootSpec::container("cache", "redis:7").with_env("PORT", "6379");
let m = Machine::started("ctr-abc123", &spec);
assert_eq!(m.spec_name, "cache");
assert_eq!(m.backend, Backend::Container);
assert_eq!(m.power, PowerState::On);
assert_eq!(spec.env.get("PORT").map(String::as_str), Some("6379"));
}
#[derive(Default)]
struct RecordingBoot {
seen: std::cell::RefCell<Vec<String>>,
}
impl Boot for RecordingBoot {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
self.seen.borrow_mut().push(spec.name.clone());
Ok(Machine::started(format!("id-{}", spec.name), spec))
}
}
#[test]
fn boot_validates_then_delegates_to_the_backend() {
let backend = RecordingBoot::default();
let spec = BootSpec::container("cache", "redis:7");
let m = boot(&spec, &backend).unwrap();
assert_eq!(m.id, "id-cache");
assert_eq!(m.backend, Backend::Container);
assert_eq!(backend.seen.borrow().as_slice(), &["cache".to_string()]);
}
#[test]
fn boot_rejects_an_invalid_spec_before_touching_the_backend() {
let backend = RecordingBoot::default();
let mut spec = BootSpec::container("bad", "redis:7");
spec.image = ImageSource::Iso("/boot.iso".into()); assert!(matches!(boot(&spec, &backend), Err(Error::Spec(_))));
assert!(backend.seen.borrow().is_empty(), "backend never touched on an invalid spec");
}
#[test]
fn boot_fleet_drips_and_boots_every_member_through_one_backend() {
let backend = RecordingBoot::default();
let one = BootSpec::redfish_iso("node", "/images/installer.iso", bmc());
let results = boot_fleet(&one, 3, &backend);
assert_eq!(results.len(), 3);
let ids: Vec<_> = results.into_iter().map(|r| r.unwrap().id).collect();
assert_eq!(ids, vec!["id-node-1", "id-node-2", "id-node-3"]);
assert_eq!(backend.seen.borrow().as_slice(), &["node-1", "node-2", "node-3"]);
}
struct FlakyBoot {
ok_before: usize,
booted: std::cell::RefCell<usize>,
}
impl Boot for FlakyBoot {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
let mut n = self.booted.borrow_mut();
if *n >= self.ok_before {
return Err(Error::Backend(format!("backend went away booting {}", spec.name)));
}
*n += 1;
Ok(Machine::started(format!("id-{}", spec.name), spec))
}
}
#[test]
fn boot_fleet_reports_a_partial_fleet_when_a_later_member_fails() {
let backend = FlakyBoot { ok_before: 2, booted: std::cell::RefCell::new(0) };
let one = BootSpec::redfish_iso("node", "/images/installer.iso", bmc());
let results = boot_fleet(&one, 4, &backend);
assert_eq!(results.len(), 4);
assert_eq!(results[0].as_ref().unwrap().id, "id-node-1");
assert_eq!(results[1].as_ref().unwrap().id, "id-node-2");
assert!(matches!(results[2], Err(Error::Backend(_))), "3rd member fails");
assert!(matches!(results[3], Err(Error::Backend(_))), "4th member fails too");
let ok = results.iter().filter(|r| r.is_ok()).count();
assert_eq!(ok, 2, "exactly the first two members booted");
}
#[test]
fn plan_fleet_of_zero_is_empty_and_of_one_keeps_a_suffix() {
assert!(plan_fleet(&BootSpec::container("c", "redis:7"), 0).is_empty());
let one = plan_fleet(&BootSpec::container("c", "redis:7"), 1);
assert_eq!(one.len(), 1);
assert_eq!(one[0].name, "c-1");
}
#[test]
fn cloud_init_user_data_constructor_defaults_meta_data_to_none() {
let ci = CloudInit::user_data("#cloud-config\n");
assert_eq!(ci.user_data, "#cloud-config\n");
assert_eq!(ci.meta_data, None);
assert_eq!(ci.network_config, None);
}
#[test]
fn container_spec_defaults_are_lean() {
let spec = BootSpec::container("cache", "docker.io/library/redis:7");
assert_eq!(spec.mem_mb, 0);
assert_eq!(spec.cores, 0);
assert!(spec.cmd.is_empty());
assert!(spec.ports.is_empty());
assert!(spec.env.is_empty());
assert!(spec.bmc.is_none());
assert!(spec.cloud_init.is_none());
}
#[test]
fn cloud_init_on_a_container_spec_still_validates_and_is_backend_ignored() {
let spec = BootSpec::container("cache", "redis:7")
.with_cloud_init(CloudInit::user_data("#cloud-config\n"));
spec.validate().unwrap();
assert!(spec.cloud_init.is_some());
}
#[test]
fn plan_fleet_drips_n_identical_but_distinctly_named_members() {
let one = BootSpec::redfish_iso("node", "/images/installer.iso", bmc());
let fleet = plan_fleet(&one, 8);
assert_eq!(fleet.len(), 8);
assert_eq!(fleet[0].name, "node-1");
assert_eq!(fleet[7].name, "node-8");
assert!(fleet.iter().all(|m| m.image == one.image && m.backend == one.backend));
let mut names: Vec<_> = fleet.iter().map(|m| m.name.clone()).collect();
names.sort();
names.dedup();
assert_eq!(names.len(), 8);
}
struct ScriptedNode {
states: std::cell::RefCell<std::collections::VecDeque<PowerState>>,
fallback: PowerState,
status_calls: std::cell::Cell<usize>,
}
impl ScriptedNode {
fn new(seq: impl IntoIterator<Item = PowerState>, fallback: PowerState) -> Self {
Self {
states: std::cell::RefCell::new(seq.into_iter().collect()),
fallback,
status_calls: std::cell::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))
}
}
fn tiny_bounded() -> WaitOptions {
WaitOptions::bounded(
std::time::Duration::from_millis(200),
std::time::Duration::from_millis(1),
)
}
#[test]
fn await_power_state_returns_ok_once_the_state_is_reached() {
let node =
ScriptedNode::new([PowerState::Unknown, PowerState::Unknown, PowerState::On], PowerState::On);
let m = Machine::started("node-1", &BootSpec::container("c", "redis:7"));
await_power_state(&node, &m, PowerState::On, &tiny_bounded()).unwrap();
assert!(node.status_calls.get() >= 3, "polled past the two Unknowns to On");
}
#[test]
fn await_power_state_times_out_when_the_state_is_never_reached() {
let node = ScriptedNode::new(std::iter::empty(), PowerState::Off);
let m = Machine::started("node-2", &BootSpec::container("c", "redis:7"));
let r = await_power_state(
&node,
&m,
PowerState::On,
&WaitOptions::bounded(
std::time::Duration::from_millis(20),
std::time::Duration::from_millis(1),
),
);
assert!(matches!(r, Err(Error::Backend(_))), "never-up node times out");
}
#[test]
fn await_power_state_rejects_awaiting_unknown() {
let node = ScriptedNode::new([PowerState::On], PowerState::On);
let m = Machine::started("node-3", &BootSpec::container("c", "redis:7"));
let r = await_power_state(&node, &m, PowerState::Unknown, &tiny_bounded());
assert!(matches!(r, Err(Error::Spec(_))), "await Unknown rejected");
assert_eq!(node.status_calls.get(), 0, "rejected before polling");
}
#[test]
fn await_power_state_propagates_a_backend_status_error() {
struct ErrLifecycle;
impl Lifecycle for ErrLifecycle {
fn power_on(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn status(&self, _m: &Machine) -> Result<PowerState> {
Err(Error::Backend("BMC unreachable".into()))
}
}
let m = Machine::started("node-4", &BootSpec::container("c", "redis:7"));
let r = await_power_state(&ErrLifecycle, &m, PowerState::On, &tiny_bounded());
assert!(matches!(r, Err(Error::Backend(_))), "status error propagates");
}
#[test]
fn boot_and_await_boots_then_confirms_power_on() {
let node = ScriptedNode::new([PowerState::Unknown, PowerState::On], PowerState::On);
let spec = BootSpec::container("cache", "redis:7");
let m = boot_and_await(&node, &spec, &tiny_bounded()).unwrap();
assert_eq!(m.id, "id-cache");
assert_eq!(m.power, PowerState::On);
assert!(node.status_calls.get() >= 2, "awaited past Unknown to On");
}
#[test]
fn boot_and_await_times_out_when_the_instance_never_comes_up() {
let node = ScriptedNode::new(std::iter::empty(), PowerState::Off);
let spec = BootSpec::container("cache", "redis:7");
let r = boot_and_await(
&node,
&spec,
&WaitOptions::bounded(
std::time::Duration::from_millis(20),
std::time::Duration::from_millis(1),
),
);
assert!(matches!(r, Err(Error::Backend(_))), "never-up boot times out");
}
#[test]
fn boot_and_await_rejects_an_invalid_spec_before_booting() {
let node = ScriptedNode::new([PowerState::On], PowerState::On);
let mut spec = BootSpec::container("bad", "redis:7");
spec.image = ImageSource::Iso("/boot.iso".into()); let r = boot_and_await(&node, &spec, &tiny_bounded());
assert!(matches!(r, Err(Error::Spec(_))), "invalid spec rejected pre-boot");
assert_eq!(node.status_calls.get(), 0, "never awaited an unbooted instance");
}
struct FleetNode {
dead: std::collections::HashSet<String>,
}
impl FleetNode {
fn with_dead<'a>(dead: impl IntoIterator<Item = &'a str>) -> Self {
Self { dead: dead.into_iter().map(String::from).collect() }
}
}
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)
}
}
}
#[test]
fn boot_fleet_and_await_rolls_up_one_dead_member_as_timeout_others_up() {
let backend = FleetNode::with_dead(["node-2"]);
let one = BootSpec::redfish_iso("node", "/images/installer.iso", bmc());
let rollup = boot_fleet_and_await(&one, 3, &backend, &tiny_bounded());
assert_eq!(rollup.members.len(), 3, "one verdict per member, in fleet order");
assert_eq!(rollup.members[0].name, "node-1");
assert_eq!(rollup.members[1].name, "node-2");
assert_eq!(rollup.members[2].name, "node-3");
match &rollup.members[0].outcome {
MemberOutcome::Up(m) => assert_eq!(m.id, "id-node-1"),
other => panic!("node-1 should be Up, was {other:?}"),
}
assert!(matches!(rollup.members[2].outcome, MemberOutcome::Up(_)), "node-3 up");
assert!(
matches!(rollup.members[1].outcome, MemberOutcome::Timeout(_)),
"node-2 never powered on → Timeout, was {:?}",
rollup.members[1].outcome
);
assert_eq!(rollup.ready(), 2, "exactly the two healthy members are ready");
assert_eq!(rollup.failed(), 1, "exactly the one dead member failed");
assert!(!rollup.all_ready(), "a partial fleet is not all-ready");
}
#[test]
fn boot_fleet_and_await_reports_a_fully_ready_fleet() {
let backend = FleetNode::with_dead(std::iter::empty());
let one = BootSpec::container("cache", "redis:7");
let rollup = boot_fleet_and_await(&one, 4, &backend, &tiny_bounded());
assert_eq!(rollup.members.len(), 4);
assert!(rollup.members.iter().all(|m| matches!(m.outcome, MemberOutcome::Up(_))));
assert_eq!(rollup.ready(), 4);
assert_eq!(rollup.failed(), 0);
assert!(rollup.all_ready(), "a fully-up fleet is all-ready");
}
#[test]
fn boot_fleet_and_await_rolls_up_an_invalid_spec_as_error_not_timeout() {
let backend = FleetNode::with_dead(std::iter::empty());
let mut bad = BootSpec::container("bad", "redis:7");
bad.image = ImageSource::Iso("/boot.iso".into()); let rollup = boot_fleet_and_await(&bad, 2, &backend, &tiny_bounded());
assert_eq!(rollup.members.len(), 2);
assert!(
rollup.members.iter().all(|m| matches!(m.outcome, MemberOutcome::Error(_))),
"an invalid spec rolls up as Error on every member, not Timeout"
);
assert_eq!(rollup.ready(), 0);
assert_eq!(rollup.failed(), 2);
}
#[test]
fn boot_fleet_and_await_of_zero_is_an_empty_not_ready_rollup() {
let backend = FleetNode::with_dead(std::iter::empty());
let rollup =
boot_fleet_and_await(&BootSpec::container("c", "redis:7"), 0, &backend, &tiny_bounded());
assert!(rollup.members.is_empty());
assert_eq!(rollup.ready(), 0);
assert_eq!(rollup.failed(), 0);
assert!(!rollup.all_ready(), "an empty fleet is not all-ready");
}
struct MixedNode {
dead: std::collections::HashSet<String>,
unbootable: std::collections::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 backend 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)
}
}
}
#[test]
fn parallel_fleet_boot_is_identical_to_serial_for_a_mixed_fleet() {
let backend = MixedNode {
dead: std::collections::HashSet::from(["node-3".to_string()]),
unbootable: std::collections::HashSet::from(["node-5".to_string()]),
};
let spec = BootSpec::container("node", "redis:7");
let serial = boot_fleet_and_await(&spec, 5, &backend, &tiny_bounded());
let parallel = boot_fleet_and_await_parallel(&spec, 5, &backend, &tiny_bounded());
assert_eq!(parallel, serial, "parallel rollup must equal the serial rollup");
assert_eq!(parallel.members.len(), 5);
assert!(matches!(parallel.members[0].outcome, MemberOutcome::Up(_)), "node-1 up");
assert!(matches!(parallel.members[2].outcome, MemberOutcome::Timeout(_)), "node-3 timeout");
assert!(matches!(parallel.members[4].outcome, MemberOutcome::Error(_)), "node-5 error");
assert_eq!(parallel.ready(), 3);
assert_eq!(parallel.failed(), 2);
}
struct BarrierNode {
n: usize,
in_flight: std::sync::atomic::AtomicUsize,
peak: std::sync::atomic::AtomicUsize,
}
impl BarrierNode {
fn new(n: usize) -> Self {
Self {
n,
in_flight: std::sync::atomic::AtomicUsize::new(0),
peak: std::sync::atomic::AtomicUsize::new(0),
}
}
}
impl Boot for BarrierNode {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
Ok(Machine::started(format!("id-{}", spec.name), spec))
}
}
impl Lifecycle for BarrierNode {
fn power_on(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn status(&self, _m: &Machine) -> Result<PowerState> {
use std::sync::atomic::Ordering::SeqCst;
let now = self.in_flight.fetch_add(1, SeqCst) + 1;
self.peak.fetch_max(now, SeqCst);
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
while self.in_flight.load(SeqCst) < self.n && std::time::Instant::now() < deadline {
std::thread::yield_now();
}
self.in_flight.fetch_sub(1, SeqCst);
Ok(PowerState::On)
}
}
#[test]
fn parallel_fleet_boot_actually_overlaps_the_members() {
use std::sync::atomic::Ordering::SeqCst;
let n = 6;
let backend = BarrierNode::new(n);
let spec = BootSpec::container("node", "redis:7");
let rollup = boot_fleet_and_await_parallel(&spec, n, &backend, &tiny_bounded());
assert!(rollup.all_ready(), "every member comes up");
assert_eq!(
backend.peak.load(SeqCst),
n,
"all {n} members were awaited concurrently (peak in-flight == n)"
);
}
#[test]
fn parallel_fleet_boot_of_zero_is_an_empty_rollup() {
let backend = MixedNode {
dead: std::collections::HashSet::new(),
unbootable: std::collections::HashSet::new(),
};
let rollup = boot_fleet_and_await_parallel(
&BootSpec::container("c", "redis:7"),
0,
&backend,
&tiny_bounded(),
);
assert!(rollup.members.is_empty());
assert!(!rollup.all_ready());
}
}