use crate::{CloudInit, Error, Result};
use std::path::{Path, PathBuf};
pub const NOCLOUD_LABEL: &str = "cidata";
pub const DEFAULT_META_DATA: &str = "instance-id: draupnir\nlocal-hostname: draupnir\n";
pub fn seed_files(ci: &CloudInit) -> Vec<(&'static str, String)> {
let md = ci
.meta_data
.clone()
.unwrap_or_else(|| DEFAULT_META_DATA.to_string());
let mut files = vec![("user-data", ci.user_data.clone()), ("meta-data", md)];
if let Some(nc) = &ci.network_config {
files.push(("network-config", nc.clone()));
}
files
}
pub fn write_seed_dir(dir: impl AsRef<Path>, ci: &CloudInit) -> Result<PathBuf> {
let dir = dir.as_ref();
std::fs::create_dir_all(dir)
.map_err(|e| Error::Backend(format!("mkdir {}: {e}", dir.display())))?;
for (name, contents) in seed_files(ci) {
let path = dir.join(name);
std::fs::write(&path, contents.as_bytes())
.map_err(|e| Error::Backend(format!("write {}: {e}", path.display())))?;
}
Ok(dir.to_path_buf())
}
#[cfg(feature = "seed")]
pub fn build_seed_image(out: impl AsRef<Path>, ci: &CloudInit) -> Result<PathBuf> {
let out = out.as_ref();
let blobs: Vec<(String, Vec<u8>)> = seed_files(ci)
.into_iter()
.map(|(n, c)| (n.to_string(), c.into_bytes()))
.collect();
build_fat_image(out, NOCLOUD_LABEL, &blobs)?;
Ok(out.to_path_buf())
}
#[cfg(feature = "seed")]
pub fn build_fat_image(out: &Path, label: &str, files: &[(String, Vec<u8>)]) -> Result<()> {
use std::io::Write;
if let Some(dir) = out.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| Error::Backend(format!("mkdir {}: {e}", dir.display())))?;
}
let payload: u64 = files.iter().map(|(_, b)| b.len() as u64).sum();
let needed = (payload + 1024 * 1024).max(2 * 1024 * 1024);
let size = needed.div_ceil(512 * 1024) * 512 * 1024;
let mut cursor = std::io::Cursor::new(vec![0u8; size as usize]);
let mut lbl = [b' '; 11];
for (i, b) in label.bytes().take(11).enumerate() {
lbl[i] = b;
}
fatfs::format_volume(&mut cursor, fatfs::FormatVolumeOptions::new().volume_label(lbl))
.map_err(|e| Error::Backend(format!("format FAT {}: {e}", out.display())))?;
{
let fs = fatfs::FileSystem::new(&mut cursor, fatfs::FsOptions::new())
.map_err(|e| Error::Backend(format!("open FAT {}: {e}", out.display())))?;
let root = fs.root_dir();
for (name, bytes) in files {
let mut file = root
.create_file(name)
.map_err(|e| Error::Backend(format!("create {name} in FAT: {e}")))?;
file.truncate().ok();
file.write_all(bytes)
.map_err(|e| Error::Backend(format!("write {name} in FAT: {e}")))?;
file.flush().ok();
}
}
std::fs::write(out, cursor.into_inner())
.map_err(|e| Error::Backend(format!("write {}: {e}", out.display())))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("draupnir-seed-{tag}-{}", std::process::id()))
}
#[test]
fn known_input_renders_exact_seed_contents() {
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_files(&ci);
assert_eq!(files.len(), 2, "just user-data + meta-data when no network-config");
assert_eq!(files[0].0, "user-data");
assert_eq!(files[0].1, user_data);
assert_eq!(files[1].0, "meta-data");
assert_eq!(files[1].1, "instance-id: iid-web-01\nlocal-hostname: web-01\n");
}
#[test]
fn omitted_meta_data_gets_the_default_instance_id_and_hostname() {
let ci = CloudInit::user_data("#cloud-config\n");
let files = seed_files(&ci);
assert_eq!(files[1].0, "meta-data");
assert_eq!(files[1].1, DEFAULT_META_DATA);
assert!(files[1].1.contains("instance-id:"));
assert!(files[1].1.contains("local-hostname:"));
}
#[test]
fn network_config_is_the_optional_third_file() {
let plain = CloudInit::user_data("#cloud-config\n");
assert!(!seed_files(&plain).iter().any(|(n, _)| *n == "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_files(&net);
assert_eq!(files.len(), 3);
let nc = files
.iter()
.find(|(n, _)| *n == "network-config")
.expect("network-config present");
assert_eq!(nc.1, "version: 2\nethernets:\n eth0:\n dhcp4: true\n");
}
#[test]
fn write_seed_dir_writes_each_file_verbatim() {
let dir = scratch("dir");
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: Some("version: 2\n".into()),
};
let out = write_seed_dir(&dir, &ci).unwrap();
assert_eq!(out, dir);
let read = |name: &str| std::fs::read_to_string(dir.join(name)).unwrap();
assert_eq!(read("user-data"), "#cloud-config\npackages: [curl]\n");
assert_eq!(read("meta-data"), "instance-id: iid-42\nlocal-hostname: node-42\n");
assert_eq!(read("network-config"), "version: 2\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(feature = "seed")]
#[test]
fn build_seed_image_roundtrips_label_and_contents() {
use std::io::Read;
let out = scratch("img").with_extension("img");
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: Some("version: 2\nethernets:\n eth0:\n dhcp4: true\n".into()),
};
let path = build_seed_image(&out, &ci).unwrap();
assert_eq!(path, out);
let img = std::fs::File::options().read(true).write(true).open(&out).unwrap();
let fs = fatfs::FileSystem::new(img, fatfs::FsOptions::new()).unwrap();
assert_eq!(fs.volume_label().to_ascii_lowercase(), NOCLOUD_LABEL);
let read = |name: &str| {
let mut buf = String::new();
fs.root_dir().open_file(name).unwrap().read_to_string(&mut buf).unwrap();
buf
};
assert_eq!(read("user-data"), "#cloud-config\nruncmd:\n - [echo, hi]\n");
assert_eq!(read("meta-data"), "instance-id: iid-img\nlocal-hostname: img-host\n");
assert_eq!(read("network-config"), "version: 2\nethernets:\n eth0:\n dhcp4: true\n");
drop(fs);
let _ = std::fs::remove_file(&out);
}
}