#![allow(dead_code)]
use std::{
fs::write,
path::{Path, PathBuf},
process::Command,
};
use serde::Serialize;
use tempfile::tempdir;
#[derive(Debug, Clone)]
pub struct LocalDataStore {
hostname: String,
network: bool,
bootcmd: Vec<String>,
runcmd: Vec<String>,
}
impl LocalDataStore {
fn meta_data(&self) -> Result<String, CloudInitError> {
#[derive(Debug, Serialize)]
struct Metadata<'a> {
hostname: &'a str,
}
serde_norway::to_string(&Metadata {
hostname: &self.hostname,
})
.map_err(CloudInitError::ToYaml)
}
fn user_data(&self) -> Result<String, CloudInitError> {
#[derive(Debug, Serialize)]
struct Userdata {
#[serde(skip_serializing_if = "Vec::is_empty")]
bootcmd: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
runcmd: Vec<String>,
}
let userdata = Userdata {
bootcmd: self.bootcmd.clone(),
runcmd: self.runcmd.clone(),
};
let userdata = serde_norway::to_string(&userdata).map_err(CloudInitError::ToYaml)?;
Ok(format!("#cloud-config\n{userdata}"))
}
fn no_network_config(&self) -> Result<String, CloudInitError> {
#[derive(Debug, Serialize)]
struct NetworkConfig {
network: NoEthernets,
}
#[derive(Debug, Serialize)]
struct NoEthernets {
version: usize,
ethernets: Vec<String>,
}
let network_config = NetworkConfig {
network: NoEthernets {
version: 2,
ethernets: vec![],
},
};
serde_norway::to_string(&network_config).map_err(CloudInitError::ToYaml)
}
pub fn iso(&self, filename: &Path) -> Result<(), CloudInitError> {
fn write_helper(filename: &Path, s: &str) -> Result<(), CloudInitError> {
write(filename, s.as_bytes())
.map_err(|err| CloudInitError::Write(filename.into(), err))?;
Ok(())
}
let tmp = tempdir().map_err(CloudInitError::TempDir)?;
write_helper(&tmp.path().join("meta-data"), &self.meta_data()?)?;
write_helper(&tmp.path().join("user-data"), &self.user_data()?)?;
if !self.network {
write_helper(
&tmp.path().join("network-config"),
r#"---
network:
version: 2
ethernets: {}
"#,
)?;
}
let r = Command::new("xorrisofs")
.arg("-quiet")
.arg("-volid")
.arg("CIDATA")
.arg("-joliet")
.arg("-rock")
.arg("-output")
.arg(filename)
.arg(tmp.path())
.output()
.map_err(|err| CloudInitError::Command("xorrisofs".into(), err))?;
if !r.status.success() {
let stderr = String::from_utf8_lossy(&r.stderr).to_string();
return Err(CloudInitError::IsoFailed(stderr));
}
Ok(())
}
}
#[derive(Debug, Default)]
pub struct LocalDataStoreBuilder {
hostname: Option<String>,
network: bool,
bootcmd: Vec<String>,
runcmd: Vec<String>,
}
impl LocalDataStoreBuilder {
pub fn build(self) -> Result<LocalDataStore, CloudInitError> {
if self.runcmd.is_empty() {
return Err(CloudInitError::NeedRunCmd);
}
Ok(LocalDataStore {
hostname: self.hostname.ok_or(CloudInitError::Missing("hostname"))?,
network: self.network,
bootcmd: self.bootcmd.clone(),
runcmd: self.runcmd.clone(),
})
}
pub fn with_hostname(mut self, hostname: &str) -> Self {
assert!(self.hostname.is_none());
self.hostname = Some(hostname.into());
self
}
pub fn with_network(mut self, network: bool) -> Self {
self.network = network;
self
}
pub fn with_bootcmd(mut self, cmd: &str) -> Self {
self.bootcmd.push(cmd.into());
self
}
pub fn with_runcmd(mut self, cmd: &str) -> Self {
self.runcmd.push(cmd.into());
self
}
}
#[derive(Debug, thiserror::Error)]
pub enum CloudInitError {
#[error("programming error: field LocalDataStoreBuilder::{0} has not been set")]
Missing(&'static str),
#[error("programming error: must add at least one command to run to LocalDataStore")]
NeedRunCmd,
#[error("failed to serialize data store data to YAML")]
ToYaml(#[from] serde_norway::Error),
#[error("failed to create a temporary directory")]
TempDir(#[source] std::io::Error),
#[error("failed to write data to {0}")]
Write(PathBuf, #[source] std::io::Error),
#[error("failed to execute command {0}")]
Command(String, #[source] std::io::Error),
#[error("failed to create ISO image with xorrisofs: {0}")]
IsoFailed(String),
}
#[cfg(test)]
mod test {
use super::*;
fn setup(runcmd: &[&str]) -> Result<LocalDataStore, Box<dyn std::error::Error>> {
let mut ds = LocalDataStoreBuilder::default().with_hostname("foo");
for cmd in runcmd.iter() {
ds = ds.with_runcmd(cmd);
}
Ok(ds.build()?)
}
#[test]
fn metadata() -> Result<(), Box<dyn std::error::Error>> {
let ds = setup(&["echo hello, world"])?;
assert_eq!(ds.meta_data()?, "hostname: foo\n");
Ok(())
}
#[test]
fn userdata_fails_without_runcmd() -> Result<(), Box<dyn std::error::Error>> {
let r = setup(&[]);
assert!(r.is_err());
Ok(())
}
#[test]
fn userdata() -> Result<(), Box<dyn std::error::Error>> {
let ds = setup(&["echo xyzzy"])?;
assert_eq!(
ds.user_data()?,
r#"#cloud-config
runcmd:
- echo xyzzy
"#
);
Ok(())
}
#[test]
fn iso() -> Result<(), Box<dyn std::error::Error>> {
let ds = setup(&["echo plugh"])?;
let tmp = tempdir()?;
let filename = tmp.path().join("cloud-init.iso");
ds.iso(&filename)?;
assert!(filename.exists());
Ok(())
}
}