use super::Problems;
use crate::model::{Healthcheck, Machine, Spec, seconds};
pub(super) fn check(spec: &Spec, p: &mut Problems) {
for (name, m) in &spec.machines {
let at = format!("machines.{name}");
command(&at, m, p);
if let Some(dir) = &m.working_dir
&& !dir.starts_with('/')
{
p.add(format!("{at}.working_dir"), format!("`{dir}` must be an absolute path, like /srv/app"));
}
if let Some(h) = &m.healthcheck {
healthcheck(&format!("{at}.healthcheck"), h, p);
}
}
}
fn command(at: &str, m: &Machine, p: &mut Problems) {
if m.command.as_ref().is_some_and(crate::model::CommandLine::is_empty) {
p.add(format!("{at}.command"), "give the program to run (a text, or a list starting with the program)");
}
let set = m.command.is_some() || m.working_dir.is_some();
let Some(d) = m.docker.as_ref().filter(|_| set) else { return };
if d.appliance.is_some() {
p.add(at, "a network appliance runs its own OS: no `command` or `working_dir`");
} else if d.idle {
p.add(
at,
"an `idle` machine runs nothing of its own: leave `idle` out to give it a `command` or `working_dir`",
);
}
}
fn healthcheck(at: &str, h: &Healthcheck, p: &mut Problems) {
match (&h.test, &h.http) {
(Some(_), Some(_)) => p.add(at, "use `test` or `http`, not both"),
(None, None) => p.add(at, "say what to check: `test` (a command) or `http` (a page, with its `port`)"),
(Some(t), None) if t.is_empty() => p.add(format!("{at}.test"), "give the command to run"),
_ => {}
}
if let Some(http) = &h.http {
if http.port == 0 {
p.add(format!("{at}.http.port"), "1 to 65535");
}
if !http.path.starts_with('/') {
p.add(format!("{at}.http.path"), format!("`{}` must start with /, like /healthz", http.path));
}
}
let paces = [("interval", &h.interval, 1), ("timeout", &h.timeout, 1), ("start_period", &h.start_period, 0)];
for (field, value, least) in paces {
let Some(v) = value else { continue };
if seconds(v).is_none_or(|s| s < least) {
p.add(
format!("{at}.{field}"),
format!("`{v}` isn't a duration of at least {least}s, like 5s, 1m or 1m30s"),
);
}
}
if h.retries == Some(0) {
p.add(format!("{at}.retries"), "at least 1 try");
}
}
#[cfg(test)]
mod tests {
use crate::testing::model as parse;
use crate::validate;
fn problems(machine: &str) -> Vec<String> {
let yaml = format!("version: 1\nname: t\nnetworks: {{ lan: {{ cidr: 10.0.0.0/24 }} }}\nmachines:\n a: {{ networks: {{ lan: 10 }}, {machine} }}\n");
let spec = parse(&yaml).expect("the spec parses");
validate(&spec).into_iter().map(|p| p.to_string()).collect()
}
#[test]
fn a_machine_with_its_own_process_is_valid() {
let ok = problems(
"docker: { image: postgres }, command: [docker-entrypoint.sh, postgres], working_dir: /srv, healthcheck: { test: pg_isready, interval: 2s, retries: 30, start_period: 1m }",
);
assert!(ok.is_empty(), "{ok:?}");
let vm = problems("vm: { os: debian@12 }, command: 'python3 -m http.server 80', healthcheck: { http: { port: 80 } }");
assert!(vm.is_empty(), "{vm:?}");
}
#[test]
fn the_working_dir_is_absolute() {
let p = problems("docker: { image: postgres }, working_dir: srv");
assert!(p.iter().any(|m| m.contains("working_dir: `srv` must be an absolute path")), "{p:?}");
}
#[test]
fn a_healthcheck_says_what_to_check_and_how_often() {
let p = problems("docker: { image: nginx }, healthcheck: { interval: '5' }");
assert!(p.iter().any(|m| m.contains("say what to check")), "{p:?}");
assert!(p.iter().any(|m| m.contains("interval: `5` isn't a duration")), "{p:?}");
}
#[test]
fn a_healthcheck_is_a_test_or_a_page_from_its_root() {
let p = problems("docker: { image: nginx }, healthcheck: { test: x, http: { port: 80, path: health } }");
assert!(
p.iter().any(|m| m.contains("not both")) && p.iter().any(|m| m.contains("must start with /")),
"{p:?}"
);
}
#[test]
fn an_idle_machine_runs_no_command() {
let p = problems("docker: { image: alpine, idle: true }, command: [sleep, '1']");
assert!(p.iter().any(|m| m.contains("an `idle` machine")), "{p:?}");
}
#[test]
fn an_appliance_runs_no_process_of_its_own() {
assert_eq!(
problems("docker: { image: x, appliance: cisco-iol }, working_dir: /srv"),
["machines.a: a network appliance runs its own OS: no `command` or `working_dir`"]
);
}
#[test]
fn a_healthcheck_test_is_a_command() {
assert_eq!(
problems("docker: { image: nginx }, healthcheck: { test: '' }"),
["machines.a.healthcheck.test: give the command to run"]
);
}
#[test]
fn a_healthchecks_pace_has_a_floor() {
let floor = "docker: { image: nginx }, healthcheck: { test: x, interval: 1s, timeout: 1s, start_period: 0s }";
assert_eq!(problems(floor), Vec::<String>::new());
assert_eq!(
problems("docker: { image: nginx }, healthcheck: { test: x, interval: 0s }"),
["machines.a.healthcheck.interval: `0s` isn't a duration of at least 1s, like 5s, 1m or 1m30s"]
);
}
}