Skip to main content

isoloom_spec/syntax/
check.rs

1//! The checks a parsed spec needs beyond its types: what a machine is (its `os`, its recipe, a
2//! catalogue service's fields the spec alone rules on), where its values come from (inputs, secrets, sensitive values), and what
3//! its names refer to (ports, overrides, the message's placeholders). The checks on the parts
4//! this format keeps from the earlier one (networks, addresses, reach, volumes, checks) run on
5//! the lowered spec ([`crate::validate`]).
6
7mod messages;
8mod overrides;
9mod steps;
10mod values;
11
12pub use messages::{Unresolved, placeholder_problems};
13
14use super::Spec;
15use super::app::Recipe;
16use super::machine::{Kind, Machine};
17use super::problem::{Code, FieldPath, Problem, Problems};
18use super::process::{HealthProbe, PortRef};
19use super::step::has_extension;
20
21/// The spec's problems, placed, in file order.
22#[must_use]
23pub fn check(spec: &Spec) -> Problems {
24    let mut out = Problems::new();
25    for (name, m) in &spec.machines {
26        let at = FieldPath::new("machines").key(name);
27        what_it_is(&at, m, &mut out);
28        process(&at, m, &mut out);
29        values::machine(spec, &at, m, &mut out);
30    }
31    overrides::check(spec, &mut out);
32    messages::check(spec, &mut out);
33    let mut placed: Problems = out.into_iter().map(|p| spec.spans.place(p)).collect();
34    placed.sort_by_key(|p| p.span.map(|s| (s.line, s.column)));
35    placed
36}
37
38/// The machine's `os`, its recipe and its catalogue service.
39fn what_it_is(at: &FieldPath, m: &Machine, out: &mut Problems) {
40    let container_only = matches!(&m.kind, Kind::App(a) if a.container.is_some() && a.steps.is_empty());
41    let supplied = m.access && matches!(m.kind, Kind::Bare);
42    match &m.kind {
43        Kind::App(a) if !a.steps.is_empty() && m.os.is_none() => {
44            out.push(Problem::new(Code::MissingField, at.key("os"), "steps install onto an OS").fix("`os: debian@12`"));
45        }
46        _ if m.os.is_none() && !container_only && !supplied => {
47            out.push(Problem::new(Code::MissingField, at.key("os"), "a machine says what it is: its OS, plus a service or an app").fix("`os: debian@12`"));
48        }
49        _ => {}
50    }
51    // The catalogue's entries, releases and settings are the plan's to resolve (it has the
52    // catalogue): here, what the spec alone says.
53    if matches!(m.kind, Kind::Service(_)) && m.command.is_some() {
54        out.push(Problem::new(Code::Conflict, at.key("command"), "a catalogue service runs its own process").fix("leave `command` out, or use `app:`"));
55    }
56    if let Some(a) = m.app() {
57        for (i, step) in a.steps.iter().enumerate() {
58            steps::check(&at.key("app").key("steps").index(i), m, step, out);
59        }
60        if let Some(Recipe::Appliance(_)) = &a.container
61            && !a.steps.is_empty()
62        {
63            out.push(Problem::new(Code::Conflict, at.key("app").key("steps"), "an appliance has no VM form for steps to set up").fix("leave `steps` out"));
64        }
65    }
66}
67
68/// Its `setup` scripts, its ports and its healthcheck.
69fn process(at: &FieldPath, m: &Machine, out: &mut Problems) {
70    let windows = m.os.is_some_and(super::os::OsRef::is_windows);
71    for (i, script) in m.setup.iter().enumerate() {
72        let ok = if windows { has_extension(script, "ps1") } else { has_extension(script, "sh") };
73        if !ok {
74            let want = if windows { "`.ps1` on Windows" } else { "a `.sh` script" };
75            out.push(Problem::new(
76                Code::InvalidValue,
77                at.key("setup").index(i),
78                format!("`{script}`: a setup script is {want}"),
79            ));
80        }
81    }
82    let service = matches!(m.kind, Kind::Service(_));
83    for (name, p) in &m.ports {
84        if p.port.is_some() && service {
85            out.push(
86                Problem::new(
87                    Code::Conflict,
88                    at.key("ports").key(name.as_str()).key("port"),
89                    "a catalogue service's entry gives its ports' numbers",
90                )
91                .fix("leave `port` out (publish it with `publish`), and change the number through `with:`"),
92            );
93        }
94        if p.port.is_none() && !service {
95            out.push(
96                Problem::new(Code::MissingField, at.key("ports").key(name.as_str()).key("port"), "a port says its number")
97                    .fix(format!("`{name}: {{ port: 8080 }}`")),
98            );
99        }
100    }
101    if let Some(HealthProbe::Http(page)) = m.healthcheck.as_ref().map(|h| &h.probe)
102        && let PortRef::Name(n) = &page.port
103        && !m.ports.contains_key(n)
104    {
105        out.push(port_reference(&at.key("healthcheck").key("http").key("port"), n.as_str(), m));
106    }
107}
108
109/// A port name that names none of the machine's ports.
110fn port_reference(at: &FieldPath, name: &str, m: &Machine) -> Problem {
111    let known: Vec<String> = m.ports.keys().map(|k| format!("`{k}`")).collect();
112    let p = Problem::new(Code::UnknownReference, at.clone(), format!("the machine has no port named `{name}`"));
113    if known.is_empty() {
114        p.fix("declare it under `ports:`")
115    } else {
116        p.fix(format!("its ports: {}", known.join(", ")))
117    }
118}
119
120#[cfg(test)]
121mod tests;