Skip to main content

isoloom_plan/
checks.rs

1//! Checks: what proves an environment behaves as its spec says, on every target.
2//!
3//! A spec's `checks:` hold scripts and declared probes (`http`, `tcp`, `exec`, `script`), each
4//! run from a position: a machine of the environment (its networks, its routes, its view), or
5//! the environment's networks at once when the spec has no access machine. Isoloom adds
6//! **derived checks** from the spec itself: every service must answer from every machine that
7//! `reach` lets through, and must not answer from the machines it doesn't; a machine whose
8//! networks are offline must not reach the internet.
9//!
10//! The planner here is target-independent: [`plan`] resolves every check to a position and a
11//! probe with symbolic hosts, and [`script`] turns one position's checks into a POSIX sh
12//! runner. Each generator decides how a position is realized (a container in a machine's
13//! network namespace, a provisioner on a VM, a Job with a machine's labels) and how a symbolic
14//! host is written (an address, or a name on Kubernetes). The runners print one line per
15//! check, `isoloom-check: PASS <name>` or `isoloom-check: FAIL <name>: <why>`, which
16//! `isoloom test` reads back.
17
18pub use isoloom_spec::probe::{Host, Url, can_run_checks};
19
20mod derived;
21mod runner;
22
23pub use derived::{allowed, derived, from_networks};
24pub use runner::{Line, Render, parse_line, script};
25
26use isoloom_spec::model::{Check, Declared, Expect, Spec};
27
28/// Where a check runs from.
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub enum Position {
31    /// A machine of the environment (by name).
32    Machine(String),
33    /// The environment's networks at once: a spec without an access machine.
34    Networks,
35}
36
37impl Position {
38    /// A file-safe id: the machine's name, or `networks`.
39    #[must_use]
40    pub fn id(&self) -> &str {
41        match self {
42            Position::Machine(m) => m,
43            Position::Networks => "networks",
44        }
45    }
46
47    /// How results name the position.
48    #[must_use]
49    pub fn label(&self) -> String {
50        match self {
51            Position::Machine(m) => format!("from {m}"),
52            Position::Networks => "from the environment's networks".to_owned(),
53        }
54    }
55}
56
57/// What an HTTP probe expects.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum HttpExpect {
60    /// This exact status.
61    Status(u16),
62    /// An answer of any status.
63    Any,
64    /// Nothing answers (the connection fails).
65    Blocked,
66}
67
68/// What a TCP probe expects.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum TcpExpect {
71    /// The connection is accepted.
72    Open,
73    /// Nothing accepts the connection.
74    Blocked,
75}
76
77/// What an `http` check sends and looks for beyond a GET and its status (all optional).
78#[derive(Debug, Clone, PartialEq, Eq, Default)]
79pub struct HttpRequest {
80    /// The method, when not a GET.
81    pub method: Option<String>,
82    /// Headers sent as written, in order.
83    pub headers: Vec<(String, String)>,
84    /// The request body, sent as it is.
85    pub body: Option<String>,
86    /// Text the response body must contain.
87    pub contains: Option<String>,
88    /// Follow redirects (up to [`HTTP_MAX_REDIRECTS`]) and compare the final status.
89    pub follow: bool,
90}
91
92impl HttpRequest {
93    /// Nothing beyond a GET and its status (following redirects or not).
94    fn is_plain(&self) -> bool {
95        self.method.is_none() && self.headers.is_empty() && self.body.is_none() && self.contains.is_none()
96    }
97}
98
99/// How many redirects an `http` check with `follow` goes through before giving up (the status
100/// compared is then that of the last answer, a redirect).
101pub const HTTP_MAX_REDIRECTS: u32 = 5;
102
103/// What a `tcp` check writes and reads beyond connecting (both optional; `send` needs `contains`).
104#[derive(Debug, Clone, PartialEq, Eq, Default)]
105pub struct TcpExchange {
106    /// A line written once connected (a newline is added).
107    pub send: Option<String>,
108    /// Text the bytes read in the first 5 seconds of the connection must contain.
109    pub contains: Option<String>,
110}
111
112/// One resolved probe.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum Probe {
115    /// An HTTP request to a URL.
116    Http {
117        /// Where the request goes; its host is resolved per position.
118        url: Url,
119        /// The answer that passes.
120        expect: HttpExpect,
121        /// A method, headers, a body, text to find: empty for a plain GET.
122        request: HttpRequest,
123    },
124    /// A TCP connection to a host's port.
125    Tcp {
126        /// The host connected to, resolved per position.
127        host: Host,
128        /// The port connected to.
129        port: u16,
130        /// Whether the connection should open.
131        expect: TcpExpect,
132        /// A line to write and text to read back: empty for a bare connection.
133        exchange: TcpExchange,
134    },
135    /// A command inside the position's machine.
136    Exec {
137        /// The command, run with sh.
138        command: String,
139        /// Text the output must contain (`None`: exiting 0 passes).
140        expect: Option<String>,
141    },
142    /// A script in the project, run with sh from the position.
143    Script {
144        /// The script's path in the project.
145        path: String,
146    },
147    /// An Ansible playbook in the project, run from the controller.
148    Playbook {
149        /// The playbook's path in the project.
150        path: String,
151    },
152}
153
154/// A check, resolved: where it runs, what it probes, how long it may take to pass.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct Resolved {
157    /// The name results report it by.
158    pub name: String,
159    /// Where it runs from.
160    pub position: Position,
161    /// What it probes and expects.
162    pub probe: Probe,
163    /// Seconds to keep retrying (0: one attempt).
164    pub wait: u32,
165    /// Derived from the spec by Isoloom (not written by the author).
166    pub derived: bool,
167}
168
169/// The position a check without `from` runs from: the access machine, else the networks.
170#[must_use]
171pub fn default_position(spec: &Spec) -> Position {
172    match spec.machines.iter().find(|(_, m)| m.access) {
173        Some((name, _)) => Position::Machine(name.clone()),
174        None => Position::Networks,
175    }
176}
177
178/// Every check of the spec, resolved: the author's in order, then the derived ones.
179/// Validated specs only (a declared check that validation rejects isn't resolved).
180#[must_use]
181pub fn plan(spec: &Spec) -> Vec<Resolved> {
182    let default = default_position(spec);
183    let mut out: Vec<Resolved> = spec
184        .checks
185        .iter()
186        .filter_map(|c| match c {
187            Check::Script(p) => Some(Resolved {
188                name: p.clone(),
189                position: default.clone(),
190                probe: if c.is_playbook() {
191                    Probe::Playbook { path: p.clone() }
192                } else {
193                    Probe::Script { path: p.clone() }
194                },
195                wait: 0,
196                derived: false,
197            }),
198            Check::Declared(d) => declared(d, &default),
199        })
200        .collect();
201    out.extend(derived(spec));
202    out
203}
204
205fn declared(d: &Declared, default: &Position) -> Option<Resolved> {
206    let position = d.from.as_ref().map_or_else(|| default.clone(), |f| Position::Machine(f.clone()));
207    let (probe, default_wait, what) = if let Some(u) = &d.http {
208        declared_http(d, u)?
209    } else if let Some(t) = &d.tcp {
210        declared_tcp(d, t)?
211    } else if let Some(c) = &d.exec {
212        let expect = match &d.expect {
213            None => None,
214            Some(Expect::Text(t)) => Some(t.clone()),
215            Some(Expect::Status(_)) => return None,
216        };
217        (Probe::Exec { command: c.clone(), expect }, 0, c.clone())
218    } else {
219        let p = d.script.as_ref()?;
220        (Probe::Script { path: p.clone() }, 0, p.clone())
221    };
222    Some(Resolved {
223        name: d.name.clone().unwrap_or_else(|| match &position {
224            Position::Machine(m) => format!("{what} from {m}"),
225            Position::Networks => what,
226        }),
227        position,
228        probe,
229        wait: d.wait.unwrap_or(default_wait),
230        derived: false,
231    })
232}
233
234/// A declared probe, the seconds it waits by default, and what its name says it checks.
235type DeclaredProbe = (Probe, u32, String);
236
237/// A declared `http` check (to URL `u`).
238fn declared_http(d: &Declared, u: &str) -> Option<DeclaredProbe> {
239    let url = Url::parse(u)?;
240    let expect = match &d.expect {
241        None => HttpExpect::Status(200),
242        Some(Expect::Status(s)) => HttpExpect::Status(*s),
243        Some(Expect::Text(t)) if t == "any" => HttpExpect::Any,
244        Some(Expect::Text(t)) if t == "blocked" => HttpExpect::Blocked,
245        Some(Expect::Text(_)) => return None,
246    };
247    let wait = if expect == HttpExpect::Blocked { 0 } else { 30 };
248    let request = HttpRequest {
249        method: d.method.clone(),
250        headers: d.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
251        body: d.body.clone(),
252        contains: d.contains.clone(),
253        follow: d.follow,
254    };
255    let what = match (&expect, &request.method) {
256        (HttpExpect::Blocked, _) => format!("{u} blocked"),
257        (_, Some(m)) => format!("{} {u}", m.to_uppercase()),
258        _ => u.to_owned(),
259    };
260    Some((Probe::Http { url, expect, request }, wait, what))
261}
262
263/// A declared `tcp` check (to `host:port` `target`).
264fn declared_tcp(d: &Declared, target: &str) -> Option<DeclaredProbe> {
265    let (host, port) = target.rsplit_once(':')?;
266    let port: u16 = port.parse().ok().filter(|p| *p > 0)?;
267    let expect = match d.expect.as_ref() {
268        None => TcpExpect::Open,
269        Some(Expect::Text(t)) if t == "open" => TcpExpect::Open,
270        Some(Expect::Text(t)) if t == "blocked" => TcpExpect::Blocked,
271        _ => return None,
272    };
273    let wait = if expect == TcpExpect::Blocked { 0 } else { 30 };
274    let what = match expect {
275        TcpExpect::Blocked => format!("{target} blocked"),
276        TcpExpect::Open => target.to_owned(),
277    };
278    let exchange = TcpExchange {
279        send: d.send.clone(),
280        contains: d.contains.clone(),
281    };
282    let probe = Probe::Tcp {
283        host: Host::Literal(host.to_owned()),
284        port,
285        expect,
286        exchange,
287    };
288    Some((probe, wait, what))
289}
290
291/// The checks grouped by position: the default position first, then machines in spec order.
292#[must_use]
293pub fn by_position<'a>(spec: &Spec, checks: &'a [Resolved]) -> Vec<(Position, Vec<&'a Resolved>)> {
294    let mut order: Vec<Position> = vec![default_position(spec)];
295    for m in spec.machines.keys() {
296        let p = Position::Machine(m.clone());
297        if !order.contains(&p) {
298            order.push(p);
299        }
300    }
301    if !order.contains(&Position::Networks) {
302        order.push(Position::Networks);
303    }
304    order
305        .into_iter()
306        .filter_map(|p| {
307            let group: Vec<&Resolved> = checks.iter().filter(|c| c.position == p).collect();
308            (!group.is_empty()).then_some((p, group))
309        })
310        .collect()
311}
312
313#[cfg(test)]
314mod tests;