isoloom-spec 0.11.1

The Isoloom spec: model, parsing, normalization and validation.
Documentation
//! A machine's own process: `command`, `working_dir` and `healthcheck`. What each target needs
//! to run them the same way: a command to run, an absolute directory, and a healthcheck with one
//! test and a pace it can keep.

use super::Problems;
use crate::model::{Healthcheck, Machine, Spec, seconds};

/// Every machine's process fields.
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);
        }
    }
}

/// A command to run, on a machine that runs one of its own (an appliance or an idle machine
/// has Isoloom's).
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`",
        );
    }
}

/// One test, a pace in seconds, and at least one try.
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"]
        );
    }
}