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();
let outcome = write_seed_dir_inner(dir, ci);
crate::functional_status(
"draupnir/seed",
"write_seed_dir",
outcome.is_ok(),
&match &outcome {
Ok(()) => format!("NoCloud seed dir written to {}", dir.display()),
Err(e) => format!("NoCloud seed dir {} failed: {e}", dir.display()),
},
);
outcome.map(|()| dir.to_path_buf())
}
fn write_seed_dir_inner(dir: &Path, ci: &CloudInit) -> Result<()> {
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(())
}
#[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();
let outcome = build_fat_image(out, NOCLOUD_LABEL, &blobs);
crate::functional_status(
"draupnir/seed",
"build_seed_image",
outcome.is_ok(),
&match &outcome {
Ok(()) => format!("NoCloud `cidata` seed image authored at {}", out.display()),
Err(e) => format!("NoCloud seed image {} failed: {e}", out.display()),
},
);
outcome.map(|()| out.to_path_buf())
}
#[cfg(feature = "seed")]
pub fn build_seed_image_with(
out: impl AsRef<Path>,
ci: &CloudInit,
extra: &[(String, Vec<u8>)],
) -> Result<PathBuf> {
let out = out.as_ref();
let mut blobs: Vec<(String, Vec<u8>)> = seed_files(ci)
.into_iter()
.map(|(n, c)| (n.to_string(), c.into_bytes()))
.collect();
blobs.extend(extra.iter().cloned());
let outcome = build_fat_image(out, NOCLOUD_LABEL, &blobs);
crate::functional_status(
"draupnir/seed",
"build_seed_image_with",
outcome.is_ok(),
&match &outcome {
Ok(()) => format!(
"NoCloud `cidata` seed image (+{} extra file(s)) authored at {}",
extra.len(),
out.display()
),
Err(e) => format!("NoCloud seed image {} failed: {e}", out.display()),
},
);
outcome.map(|()| out.to_path_buf())
}
#[cfg(feature = "seed")]
pub fn read_seed_files(image: &[u8]) -> Result<Vec<(String, Vec<u8>)>> {
use std::io::Read;
let cursor = std::io::Cursor::new(image.to_vec());
let fs = fatfs::FileSystem::new(cursor, fatfs::FsOptions::new())
.map_err(|e| Error::Backend(format!("open FAT seed image: {e}")))?;
let mut out = Vec::new();
for entry in fs.root_dir().iter() {
let entry = entry.map_err(|e| Error::Backend(format!("read FAT dir: {e}")))?;
if entry.is_dir() {
continue;
}
let name = entry.file_name();
let mut buf = Vec::new();
entry
.to_file()
.read_to_end(&mut buf)
.map_err(|e| Error::Backend(format!("read {name} from FAT seed: {e}")))?;
out.push((name, buf));
}
Ok(out)
}
#[cfg(feature = "seed")]
pub fn seed_volume_label(image: &[u8]) -> Result<String> {
let cursor = std::io::Cursor::new(image.to_vec());
let fs = fatfs::FileSystem::new(cursor, fatfs::FsOptions::new())
.map_err(|e| Error::Backend(format!("open FAT seed image: {e}")))?;
Ok(fs.volume_label())
}
#[cfg(feature = "seed")]
pub fn is_nocloud_seed(image: &[u8]) -> bool {
matches!(seed_volume_label(image), Ok(l) if l.trim().eq_ignore_ascii_case(NOCLOUD_LABEL))
}
#[cfg(feature = "seed")]
pub fn read_seed_image(image: &[u8]) -> Result<CloudInit> {
let files = read_seed_files(image)?;
let pick = |want: &str| {
files
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(want))
.map(|(_, b)| String::from_utf8_lossy(b).into_owned())
};
let user_data =
pick("user-data").ok_or_else(|| Error::Backend("seed image has no `user-data`".into()))?;
Ok(CloudInit {
user_data,
meta_data: pick("meta-data"),
network_config: pick("network-config"),
})
}
pub fn meta_data_value(meta_data: &str, key: &str) -> Option<String> {
for line in meta_data.lines() {
let line = line.split('#').next().unwrap_or("").trim();
let Some((k, v)) = line.split_once(':') else {
continue;
};
if !k.trim().eq_ignore_ascii_case(key) {
continue;
}
let v = v.trim().trim_matches(|c| c == '"' || c == '\'').trim();
if v.is_empty() {
return None;
}
return Some(v.to_string());
}
None
}
#[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);
}
#[cfg(feature = "seed")]
#[test]
fn seed_image_roundtrips_through_the_guest_side_reader() {
let out = scratch("guestread").with_extension("img");
let _ = std::fs::remove_file(&out);
let ci = CloudInit {
user_data: "#cloud-config\nhostname: node-7\n".into(),
meta_data: Some("instance-id: iid-7\nlocal-hostname: node-7\n".into()),
network_config: Some("version: 2\n".into()),
};
build_seed_image(&out, &ci).unwrap();
let bytes = std::fs::read(&out).unwrap();
assert!(
is_nocloud_seed(&bytes),
"the authored image IS a `cidata` NoCloud volume"
);
assert_eq!(
seed_volume_label(&bytes).unwrap().to_ascii_lowercase(),
NOCLOUD_LABEL
);
let back = read_seed_image(&bytes).unwrap();
assert_eq!(back.user_data, ci.user_data);
assert_eq!(back.meta_data, ci.meta_data);
assert_eq!(back.network_config, ci.network_config);
let _ = std::fs::remove_file(&out);
}
#[cfg(feature = "seed")]
#[test]
fn a_non_cidata_image_is_not_mistaken_for_a_seed() {
assert!(
!is_nocloud_seed(&[0u8; 4096]),
"zeroes are not a NoCloud seed"
);
assert!(
!is_nocloud_seed(b"not a filesystem at all"),
"garbage is not a seed"
);
let other = scratch("otherlabel").with_extension("img");
let _ = std::fs::remove_file(&other);
build_fat_image(&other, "SOMEDISK", &[("a.txt".to_string(), b"x".to_vec())]).unwrap();
let bytes = std::fs::read(&other).unwrap();
assert!(
!is_nocloud_seed(&bytes),
"a FAT volume labelled SOMEDISK is not the `cidata` seed"
);
let _ = std::fs::remove_file(&other);
}
#[cfg(feature = "seed")]
#[test]
fn extra_files_ride_alongside_the_standard_nocloud_set() {
let out = scratch("extra").with_extension("img");
let _ = std::fs::remove_file(&out);
let ci = CloudInit::user_data("#cloud-config\n");
let appliance = "[Interface]\nAddress = 10.9.0.1/24\n";
build_seed_image_with(
&out,
&ci,
&[(
"tunnr-appliance.conf".to_string(),
appliance.as_bytes().to_vec(),
)],
)
.unwrap();
let bytes = std::fs::read(&out).unwrap();
let files = read_seed_files(&bytes).unwrap();
let name_of = |want: &str| files.iter().find(|(n, _)| n.eq_ignore_ascii_case(want));
assert!(
name_of("user-data").is_some(),
"cloud-init still finds user-data"
);
assert!(
name_of("meta-data").is_some(),
"cloud-init still finds meta-data"
);
let extra = name_of("tunnr-appliance.conf").expect("the consumer-native file is present");
assert_eq!(String::from_utf8_lossy(&extra.1), appliance);
assert_eq!(
read_seed_image(&bytes).unwrap().user_data,
"#cloud-config\n"
);
let _ = std::fs::remove_file(&out);
}
#[test]
fn meta_data_value_reads_the_flat_nocloud_scalars() {
let meta = "instance-id: iid-demo-server\nlocal-hostname: tunnr-server # the name\n";
assert_eq!(
meta_data_value(meta, "instance-id").as_deref(),
Some("iid-demo-server")
);
assert_eq!(
meta_data_value(meta, "local-hostname").as_deref(),
Some("tunnr-server")
);
assert_eq!(
meta_data_value(meta, "Local-Hostname").as_deref(),
Some("tunnr-server")
);
assert_eq!(meta_data_value(meta, "public-keys"), None);
assert_eq!(meta_data_value("local-hostname:\n", "local-hostname"), None);
assert_eq!(
meta_data_value("local-hostname: \"quoted-host\"\n", "local-hostname").as_deref(),
Some("quoted-host")
);
}
}