Skip to main content

isoloom_spec/model/
checks.rs

1//! The checks that say an environment works: a script, or a declared probe and what it expects.
2
3use indexmap::IndexMap;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// One check: a script in the project (a string), or a declared probe (a map).
8#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
9#[serde(untagged)]
10pub enum Check {
11    /// A path in the project: a `.sh` script run with sh from where a user stands (exits 0
12    /// when the behavior holds), or an Ansible playbook (`.yml`) run from the controller.
13    Script(String),
14    /// A probe Isoloom runs itself, from a machine of the environment.
15    Declared(Box<Declared>),
16}
17
18impl Check {
19    /// The project path this check runs, when it is a script or a playbook.
20    #[must_use]
21    pub fn path(&self) -> Option<&str> {
22        match self {
23            Check::Script(p) => Some(p),
24            Check::Declared(d) => d.script.as_deref(),
25        }
26    }
27
28    /// Whether this check is an Ansible playbook (`.yml` / `.yaml`).
29    pub fn is_playbook(&self) -> bool {
30        self.path().is_some_and(is_yaml_file)
31    }
32}
33
34/// Whether `path` names a YAML file (an Ansible playbook): `.yml` or `.yaml`, as written.
35pub(crate) fn is_yaml_file(path: &str) -> bool {
36    std::path::Path::new(path).extension().is_some_and(|e| e == "yml" || e == "yaml")
37}
38
39// A string is a script; a map is a declared check. Written by hand (not `untagged`) so a typo in
40// a declared check is reported as such, with its field, instead of "no variant matched".
41impl<'de> Deserialize<'de> for Check {
42    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
43        struct V;
44        impl<'de> serde::de::Visitor<'de> for V {
45            type Value = Check;
46            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
47                f.write_str("a script path, or a check with `http`, `tcp`, `exec` or `script`")
48            }
49            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Check, E> {
50                Ok(Check::Script(v.to_owned()))
51            }
52            fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> Result<Check, A::Error> {
53                Declared::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(|d| Check::Declared(Box::new(d)))
54            }
55        }
56        d.deserialize_any(V)
57    }
58}
59
60/// A check Isoloom runs itself. Exactly one of `http`, `tcp`, `exec` and `script` says what to
61/// check; `from` says where from; `expect` what counts as passing.
62#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
63#[serde(deny_unknown_fields)]
64pub struct Declared {
65    /// Shown in results. Default: what the check does (`http://web/ from user`).
66    #[serde(default)]
67    pub name: Option<String>,
68    /// The machine the check runs from (its networks, its routes, its view). Default: the
69    /// access machine when the spec has one, else the environment's networks at once.
70    #[serde(default)]
71    pub from: Option<String>,
72    /// An HTTP(S) request to this URL. `expect`: a status code (default 200), `any` (an answer
73    /// of any status), or `blocked` (nothing answers).
74    #[serde(default)]
75    pub http: Option<String>,
76    /// With `http`: the request method (default GET).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub method: Option<String>,
79    /// With `http`: request headers.
80    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
81    pub headers: IndexMap<String, String>,
82    /// With `http`: the request body, sent as is.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub body: Option<String>,
85    /// With `http`: text the response body must contain. With `tcp`: text the bytes read in
86    /// the first 5 seconds of the connection (after `send`) must contain, like a banner.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub contains: Option<String>,
89    /// With `http`: follow redirects (up to 5 hops) and compare the final answer's status, for a
90    /// page that redirects (a login page, an app's index). Default: the first answer counts. A
91    /// POST followed by a 301, 302 or 303 goes on as a GET, as in a browser; other methods are
92    /// sent again to each hop.
93    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
94    pub follow: bool,
95    /// With `tcp` and `contains`: a line written once connected (followed by a newline; end it
96    /// with `\r` for CRLF), before reading.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub send: Option<String>,
99    /// A TCP connection to `host:port`. `expect`: `open` (default) or `blocked`.
100    #[serde(default)]
101    pub tcp: Option<String>,
102    /// A command run inside the `from` machine (`from` is required). `expect`: text its output
103    /// must contain (default: the command succeeds).
104    #[serde(default)]
105    pub exec: Option<String>,
106    /// A script in the project, run with sh from the `from` machine's position; it exits 0 when
107    /// the behavior holds.
108    #[serde(default)]
109    pub script: Option<String>,
110    /// What counts as passing, in the check's own terms: a status code, `any` or `blocked`
111    /// for `http`; `open` or `blocked` for `tcp`; text the output contains for `exec`. Each
112    /// kind's field says its default.
113    #[serde(default)]
114    pub expect: Option<Expect>,
115    /// How long to keep retrying until the check passes, as seconds (`90`) or a duration (`90s`,
116    /// `3m`, `1m30s`): default 30 seconds for `http` and `tcp` (except `blocked`, tried once), 0
117    /// for `exec` and `script`. Held in seconds.
118    #[serde(default, deserialize_with = "duration")]
119    #[schemars(with = "Option<WaitSchema>")]
120    pub wait: Option<u32>,
121}
122
123// What the schema shows and the reader returns.
124/// A wait as written, for the schema.
125#[derive(JsonSchema)]
126#[serde(untagged)]
127enum WaitSchema {
128    /// Seconds.
129    Seconds(u32),
130    /// A duration: `90s`, `3m`, `1m30s`.
131    Duration(String),
132}
133
134impl<'de> Deserialize<'de> for WaitSchema {
135    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<WaitSchema, D::Error> {
136        let not_a_wait = || serde::de::Error::custom("a wait is a number of seconds or a duration (`90s`, `3m`)");
137        match serde_json::Value::deserialize(d)? {
138            serde_json::Value::Number(n) => n.as_u64().and_then(|n| u32::try_from(n).ok()).map(WaitSchema::Seconds).ok_or_else(not_a_wait),
139            serde_json::Value::String(s) => Ok(WaitSchema::Duration(s)),
140            _ => Err(not_a_wait()),
141        }
142    }
143}
144
145/// A wait written as seconds (`90`) or a duration (`90s`, `3m`, `1m30s`), in seconds.
146fn duration<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u32>, D::Error> {
147    match Option::<WaitSchema>::deserialize(d)? {
148        None => Ok(None),
149        Some(WaitSchema::Seconds(n)) => Ok(Some(n)),
150        Some(WaitSchema::Duration(s)) => super::seconds(&s)
151            .map(Some)
152            .ok_or_else(|| serde::de::Error::custom(format!("`{s}` isn't a duration: write `90s`, `3m` or `1m30s`"))),
153    }
154}
155
156/// What a declared check expects: an HTTP status code, or a word (`any`, `blocked`, `open`),
157/// or for `exec` the text the output must contain.
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
159#[serde(untagged)]
160pub enum Expect {
161    /// An HTTP status code (`200`, `404`).
162    Status(u16),
163    /// A word (`any`, `blocked`, `open`), or the text an `exec` output must contain.
164    Text(String),
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    /// The check `yaml` describes.
172    fn check(yaml: &str) -> Result<Check, serde_yaml_ng::Error> {
173        serde_yaml_ng::from_str(yaml)
174    }
175
176    #[test]
177    fn a_script_or_a_declared_script_is_a_path_and_yaml_ones_are_playbooks() -> Result<(), serde_yaml_ng::Error> {
178        let read = |yaml| check(yaml).map(|c| (c.path().map(str::to_owned), c.is_playbook()));
179        assert_eq!(read("checks/login.sh")?, (Some("checks/login.sh".into()), false));
180        assert_eq!(read("checks/site.yml")?, (Some("checks/site.yml".into()), true));
181        assert_eq!(read("{ script: checks/site.yaml, from: web }")?, (Some("checks/site.yaml".into()), true));
182        assert_eq!(read("{ http: \"http://web/\" }")?, (None, false));
183        Ok(())
184    }
185
186    #[test]
187    fn a_check_of_another_shape_says_what_it_takes() {
188        let refusal = check("3").map(|_| ()).map_err(|e| e.to_string());
189        assert!(refusal.is_err_and(|e| e.ends_with("expected a script path, or a check with `http`, `tcp`, `exec` or `script`")));
190    }
191
192    #[test]
193    fn a_wait_is_seconds_or_a_duration_held_in_seconds() -> Result<(), serde_yaml_ng::Error> {
194        let wait = |yaml| {
195            check(yaml).map(|c| match c {
196                Check::Declared(d) => d.wait,
197                Check::Script(_) => None,
198            })
199        };
200        assert_eq!(wait("{ tcp: \"web:22\", wait: 90 }")?, Some(90));
201        assert_eq!(wait("{ tcp: \"web:22\", wait: 1m30s }")?, Some(90));
202        assert_eq!(wait("{ tcp: \"web:22\" }")?, None);
203        Ok(())
204    }
205}