use std::collections::BTreeSet;
use super::machines::Subject;
use super::volumes::safe_path;
use super::{Problems, kebab};
use crate::model::{Disk, Filesystem};
const MAX_DISK_GB: u32 = 16_384;
const MAX_DISK_NAME: usize = 12;
pub(super) fn of_machine(s: &Subject, volume_paths: &BTreeSet<&str>, p: &mut Problems) {
for (i, t) in s.m.tmpfs.iter().enumerate() {
let at = format!("{}.tmpfs[{i}]", s.at);
if !safe_path(t.path()) {
p.add(&at, format!("`{}` isn't a safe absolute path inside the machine, like /run", t.path()));
}
if let Some(size) = t.size().filter(|z| !size_ok(z)) {
p.add(
format!("{at}.size"),
format!("`{size}` isn't a size: bytes, or a number with k, m or g, like 64m"),
);
}
if let Some(mode) = t.mode().filter(|m| !mode_ok(m)) {
p.add(
format!("{at}.mode"),
format!("`{mode}` isn't a mode: three or four octal digits, like \"1777\""),
);
}
}
let mut mounts = BTreeSet::new();
for (name, d) in &s.m.disks {
let at = format!("{}.disks.{name}", s.at);
if let Some(why) = disk_name_problem(s, name) {
p.add(&at, why);
}
if d.size_gb == 0 || d.size_gb > MAX_DISK_GB {
p.add(format!("{at}.size_gb"), format!("a data disk is 1 to {MAX_DISK_GB} GB"));
}
let windows = s.m.vm.as_ref().is_some_and(|vm| crate::images::is_windows(&vm.os));
filesystem(d, windows, &at, p);
if let Some(m) = &d.mount {
mount(m, windows, &at, p);
if volume_paths.contains(m.trim_end_matches('/')) || !mounts.insert(m.trim_end_matches('/').to_ascii_uppercase()) {
p.add(format!("{at}.mount"), format!("`{m}` is already a volume or a disk of this machine"));
}
}
}
}
fn disk_name_problem(s: &Subject, name: &str) -> Option<String> {
if !kebab(name) || name.len() > MAX_DISK_NAME {
Some(format!(
"disk names are kebab-case, at most {MAX_DISK_NAME} characters: the filesystem's label is the name"
))
} else if s.m.volumes.contains_key(name) {
Some(format!(
"`{name}` is a volume of this machine too: a disk is stored as `<machine>-<disk>`, as a volume"
))
} else if s.name.len() + 1 + name.len() > 63 {
Some("machine name and disk name together are at most 62 characters: the Kubernetes claim is `<machine>-<disk>`".into())
} else {
None
}
}
fn filesystem(d: &Disk, windows: bool, at: &str, p: &mut Problems) {
match (d.fs, windows) {
(Some(Filesystem::Ntfs), false) => p.add(format!("{at}.fs"), "NTFS is a Windows machine's: use ext4 or xfs"),
(Some(Filesystem::Ext4 | Filesystem::Xfs), true) => p.add(format!("{at}.fs"), "a Windows machine formats NTFS: use ntfs, or leave it out"),
_ => {}
}
match (d.fs == Some(Filesystem::Raw), d.mount.is_some()) {
(true, true) => p.add(format!("{at}.mount"), "a raw disk has no filesystem to mount: leave `mount` out"),
(false, false) => p.add(format!("{at}.mount"), "say where the disk is mounted, like /data (or make it `fs: raw`)"),
_ => {}
}
}
fn mount(m: &str, windows: bool, at: &str, p: &mut Problems) {
if windows {
let drive_letter = matches!(m.as_bytes(), [letter, b':'] if (b'D'..=b'Z').contains(&letter.to_ascii_uppercase()));
if !drive_letter {
p.add(
format!("{at}.mount"),
format!("`{m}` isn't a drive letter: a Windows disk mounts at one, D: to Z:"),
);
}
} else if !safe_path(m) {
p.add(
format!("{at}.mount"),
format!("`{m}` isn't a safe absolute path inside the machine, like /data"),
);
}
}
fn size_ok(size: &str) -> bool {
let digits = size.strip_suffix(['k', 'm', 'g', 'K', 'M', 'G']).unwrap_or(size);
!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) && !digits.trim_start_matches('0').is_empty()
}
fn mode_ok(mode: &str) -> bool {
(3..=4).contains(&mode.len()) && mode.chars().all(|c| ('0'..='7').contains(&c))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{lab, problems};
fn named(name: &str, form: &str, storage: &str) -> Vec<String> {
problems(&lab(&format!("machines:\n {name}: {{ networks: {{ lan: 10 }}, {form}, {storage} }}\n")))
}
fn linux(disks: &str) -> Vec<String> {
named("web", "vm: { os: debian@12 }", &format!("disks: {disks}"))
}
fn windows(disks: &str) -> Vec<String> {
named("web", "vm: { os: windows-server@2022 }", &format!("disks: {disks}"))
}
#[test]
fn sizes_and_modes() {
assert!(size_ok("64m") && size_ok("1048576") && size_ok("2G"));
assert!(!size_ok("m") && !size_ok("0") && !size_ok("64mb") && !size_ok("-1"));
assert!(mode_ok("1777") && mode_ok("700") && mode_ok("0755"));
assert!(!mode_ok("8") && !mode_ok("17777") && !mode_ok("rwx"));
}
#[test]
fn a_tmpfs_mount_has_a_safe_path_a_size_and_a_mode() {
let tmpfs = |t: &str| named("web", "docker: { image: nginx }", &format!("tmpfs: [{t}]"));
assert_eq!(tmpfs("/run, { path: /cache, size: 64m, mode: '1777' }"), Vec::<String>::new());
assert_eq!(
tmpfs("run"),
["machines.web.tmpfs[0]: `run` isn't a safe absolute path inside the machine, like /run"]
);
assert_eq!(
tmpfs("{ path: /cache, size: 64mb }"),
["machines.web.tmpfs[0].size: `64mb` isn't a size: bytes, or a number with k, m or g, like 64m"]
);
assert_eq!(
tmpfs("{ path: /cache, mode: '888' }"),
["machines.web.tmpfs[0].mode: `888` isn't a mode: three or four octal digits, like \"1777\""]
);
}
#[test]
fn a_data_disk_is_1_gb_to_16_tib() {
let sized = |gb: u32| linux(&format!("{{ data: {{ size_gb: {gb}, fs: ext4, mount: /data }} }}"));
assert_eq!((sized(1), sized(16_384)), (Vec::<String>::new(), Vec::<String>::new()));
let wrong = "machines.web.disks.data.size_gb: a data disk is 1 to 16384 GB";
assert_eq!(sized(0), [wrong]);
assert_eq!(sized(16_385), [wrong]);
}
#[test]
fn a_disk_name_is_its_filesystems_label() {
let disk = |d: &str| linux(&format!("{{ {d}: {{ size_gb: 1, mount: /data }} }}"));
assert_eq!(disk("abcdefghij-l"), Vec::<String>::new());
for wrong in ["abcdefghij-lm", "Data"] {
assert_eq!(
disk(wrong),
[format!(
"machines.web.disks.{wrong}: disk names are kebab-case, at most 12 characters: the filesystem's label is the name"
)],
"{wrong}"
);
}
}
#[test]
fn a_machine_and_disk_name_fit_a_kubernetes_claim() {
let on = |machine: &str| named(machine, "vm: { os: debian@12 }", "disks: { abcdefghijkl: { size_gb: 1, mount: /data } }");
assert_eq!(on(&"m".repeat(50)), Vec::<String>::new());
let long = "m".repeat(51);
assert_eq!(
on(&long),
[format!(
"machines.{long}.disks.abcdefghijkl: machine name and disk name together are at most 62 characters: the Kubernetes claim is `<machine>-<disk>`"
)]
);
}
#[test]
fn a_disk_takes_the_filesystem_its_os_formats() {
assert_eq!(
linux("{ a: { size_gb: 1, fs: xfs, mount: /a }, b: { size_gb: 1, fs: raw } }"),
Vec::<String>::new()
);
assert_eq!(
windows("{ a: { size_gb: 1, fs: ntfs, mount: 'E:' }, b: { size_gb: 1, mount: 'f:' } }"),
Vec::<String>::new()
);
assert_eq!(
linux("{ a: { size_gb: 1, fs: ntfs, mount: /a } }"),
["machines.web.disks.a.fs: NTFS is a Windows machine's: use ext4 or xfs"]
);
let ntfs = "machines.web.disks.a.fs: a Windows machine formats NTFS: use ntfs, or leave it out";
assert_eq!(windows("{ a: { size_gb: 1, fs: ext4, mount: 'E:' } }"), [ntfs]);
assert_eq!(windows("{ a: { size_gb: 1, fs: xfs, mount: 'E:' } }"), [ntfs]);
}
#[test]
fn a_disk_with_a_filesystem_is_mounted_and_a_raw_one_is_not() {
assert_eq!(
linux("{ a: { size_gb: 1, fs: raw, mount: /a } }"),
["machines.web.disks.a.mount: a raw disk has no filesystem to mount: leave `mount` out"]
);
assert_eq!(
linux("{ a: { size_gb: 1 } }"),
["machines.web.disks.a.mount: say where the disk is mounted, like /data (or make it `fs: raw`)"]
);
}
#[test]
fn a_mount_is_a_safe_path_on_linux_and_a_data_drive_on_windows() {
assert_eq!(
linux("{ a: { size_gb: 1, mount: data } }"),
["machines.web.disks.a.mount: `data` isn't a safe absolute path inside the machine, like /data"]
);
for wrong in ["C:", "E:/", "/data", "EE"] {
assert_eq!(
windows(&format!("{{ a: {{ size_gb: 1, mount: '{wrong}' }} }}")),
[format!(
"machines.web.disks.a.mount: `{wrong}` isn't a drive letter: a Windows disk mounts at one, D: to Z:"
)],
"{wrong}"
);
}
assert_eq!(windows("{ a: { size_gb: 1, mount: 'Z:' } }"), Vec::<String>::new());
}
#[test]
fn a_mount_point_holds_one_volume_or_disk() {
let taken = |what: &str| [format!("machines.web.disks.b.mount: `{what}` is already a volume or a disk of this machine")];
assert_eq!(linux("{ a: { size_gb: 1, mount: /data }, b: { size_gb: 1, mount: /data/ } }"), taken("/data/"));
assert_eq!(windows("{ a: { size_gb: 1, mount: 'E:' }, b: { size_gb: 1, mount: 'e:' } }"), taken("e:"));
assert_eq!(
named(
"web",
"vm: { os: debian@12 }",
"volumes: { logs: /data }, disks: { b: { size_gb: 1, mount: /data } }"
),
taken("/data")
);
}
}