use std::collections::BTreeMap;
use std::fmt;
pub mod container;
pub mod kvm;
pub mod redfish;
pub type Result<T> = std::result::Result<T, Error>;
#[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(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, 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, PartialEq, Eq)]
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 env: BTreeMap<String, String>,
pub bmc: Option<BmcEndpoint>,
pub cloud_init: Option<CloudInit>,
}
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(),
env: BTreeMap::new(),
bmc: None,
cloud_init: 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(),
env: BTreeMap::new(),
bmc: None,
cloud_init: 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(),
env: BTreeMap::new(),
bmc: Some(bmc),
cloud_init: 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_cloud_init(mut self, ci: CloudInit) -> Self {
self.cloud_init = Some(ci);
self
}
pub fn validate(&self) -> Result<()> {
if !self.image.suits(self.backend) {
return Err(Error::Spec(format!(
"{:?} image is not bootable by the {:?} backend",
self.image, self.backend
)));
}
match (self.backend, &self.bmc) {
(Backend::Redfish, None) => {
Err(Error::Spec("Redfish boot needs a BMC endpoint".into()))
}
(Backend::Redfish, Some(_)) => 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()
}
#[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("/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();
}
#[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);
}
}