Skip to main content

isoloom_plan/checks/
runner.rs

1//! The POSIX sh runner a position's checks become, and the lines it prints that `isoloom test`
2//! reads back.
3
4use isoloom_spec::shell::quote as sq;
5
6use super::{HTTP_MAX_REDIRECTS, Host, HttpExpect, HttpRequest, Position, Probe, Resolved, TcpExchange, TcpExpect};
7
8/// How a target writes the runner for one position.
9pub struct Render<'a> {
10    /// A symbolic host as this target reaches it from the position (an address, or a name).
11    pub host: &'a dyn Fn(&Host, &Position) -> String,
12    /// The shell command that runs a project script (`cd /opt/isoloom && sh checks/x.sh`).
13    pub script: &'a dyn Fn(&str) -> String,
14    /// The shell command that runs a playbook, when this target can.
15    pub playbook: Option<&'a dyn Fn(&str) -> String>,
16}
17
18/// Escaped for the inside of a double-quoted sh string.
19fn dq(s: &str) -> String {
20    s.replace('\\', "\\\\").replace('"', "\\\"").replace('$', "\\$").replace('`', "\\`")
21}
22
23/// The sh runner for one position's checks. It needs `sh` and either `curl`, `wget` or `bash`
24/// (plus `nc` or `bash` for TCP), prints a PASS or FAIL line per check, and exits 1 when any
25/// failed. Derived checks are skipped when `ISOLOOM_DERIVED=0`.
26#[must_use]
27pub fn script(position: &Position, checks: &[&Resolved], r: &Render) -> String {
28    let mut s = format!(
29        "#!/bin/sh\n# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run\n# `isoloom generate`. `isoloom check` fails when this file is out of date.\n# The environment's checks, {}. `isothrum check` runs it and reads the PASS/FAIL lines.\n{RUNTIME}",
30        position.label()
31    );
32    for c in checks {
33        s.push('\n');
34        if c.derived {
35            s.push_str("if [ \"${ISOLOOM_DERIVED:-1}\" != 0 ]; then\n");
36        }
37        s.push_str(&with_directives(&check_lines(c, position, r)));
38        if c.derived {
39            s.push_str("fi\n");
40        }
41    }
42    s.push_str("\necho \"isoloom-check: END $passed passed, $failed failed\"\n[ \"$failed\" -eq 0 ]\n");
43    s
44}
45
46/// The sh lines that run one check and print its PASS or FAIL line.
47fn check_lines(c: &Resolved, position: &Position, r: &Render) -> String {
48    let n = sq(&c.name);
49    let host = |h: &Host| (r.host)(h, position);
50    match &c.probe {
51        Probe::Http { url, expect, request } if !request.is_plain() && *expect != HttpExpect::Blocked => {
52            http_request_lines(&n, c.wait, &url.render(&host(&url.host)), *expect, request)
53        }
54        Probe::Http { url, expect, request } => http_lines(&n, c.wait, &url.render(&host(&url.host)), *expect, request.follow),
55        Probe::Tcp {
56            host: h,
57            port,
58            expect: TcpExpect::Open,
59            exchange: TcpExchange { send, contains: Some(want) },
60        } => tcp_read_lines(&n, c.wait, &host(h), *port, (send.as_deref(), want)),
61        Probe::Tcp { host: h, port, expect, .. } => tcp_lines(&n, c.wait, &host(h), *port, *expect),
62        Probe::Exec { command, expect } => format!(
63            "if _retry {w} _exec {cmd} {want}; then pass {n}; else fail {n} \"$(printf '%s' \"$out\" | tail -n 1)\"; fi\n",
64            w = c.wait,
65            cmd = sq(command),
66            want = sq(expect.as_deref().unwrap_or("")),
67        ),
68        Probe::Script { path } => project_file_lines(&n, c.wait, path, &(r.script)(path), "exited non-zero"),
69        Probe::Playbook { path } => match r.playbook {
70            Some(pb) => project_file_lines(&n, c.wait, path, &pb(path), "failed"),
71            None => format!("fail {n} \"Ansible checks don't run from this position\"\n"),
72        },
73    }
74}
75
76/// The directive shellcheck needs on the line before each of `lines` that single-quotes what
77/// looks like an expansion (a `$name`, `$(`, `${` or a backticked text): the spec's own text (an
78/// `exec` command, a header, the text to find), quoted on purpose so this script never expands
79/// it; only an `exec` command's own shell does.
80fn with_directives(lines: &str) -> String {
81    lines
82        .lines()
83        .map(|line| {
84            if single_quoted(line).into_iter().any(looks_expanded) {
85                let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
86                format!("{indent}# shellcheck disable=SC2016 # the check's own text, quoted as written\n{line}\n")
87            } else {
88                format!("{line}\n")
89            }
90        })
91        .collect()
92}
93
94/// The single-quoted parts of an sh line, outside double quotes (`'a'\\''b'` is `a`, then `b`).
95fn single_quoted(line: &str) -> Vec<&str> {
96    let mut parts = Vec::new();
97    let (mut rest, mut double) = (line, false);
98    while let Some((i, c)) = rest.char_indices().find(|&(_, c)| matches!(c, '\'' | '"' | '\\')) {
99        let after = rest.get(i + 1..).unwrap_or_default();
100        rest = match c {
101            '\\' => after.get(after.chars().next().map_or(0, char::len_utf8)..).unwrap_or_default(),
102            '"' => {
103                double = !double;
104                after
105            }
106            _ if double => after,
107            _ => {
108                let end = after.find('\'').unwrap_or(after.len());
109                parts.push(after.get(..end).unwrap_or_default());
110                after.get(end + 1..).unwrap_or_default()
111            }
112        };
113    }
114    parts
115}
116
117/// Whether shellcheck reads `text`, single-quoted, as an expansion meant to happen (SC2016):
118/// `$` before a name, a digit, `{` or `(`, or a backticked text.
119fn looks_expanded(text: &str) -> bool {
120    let dollar = text
121        .split('$')
122        .skip(1)
123        .any(|after| after.starts_with(|c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '{' | '(')));
124    // What follows each backtick; all but the last part are between two of them.
125    let after: Vec<&str> = text.split('`').skip(1).collect();
126    dollar || after.split_last().is_some_and(|(_, between)| between.iter().any(|t| !t.is_empty()))
127}
128
129/// An HTTP check beyond a GET and its status (a method, headers, a body, text to find), with
130/// curl: `_req`.
131fn http_request_lines(n: &str, wait: u32, url: &str, expect: HttpExpect, request: &HttpRequest) -> String {
132    // A request that must be blocked is a plain one (`http_lines`).
133    let status = match expect {
134        HttpExpect::Status(code) => code.to_string(),
135        HttpExpect::Any | HttpExpect::Blocked => "any".to_owned(),
136    };
137    format!(
138        "if _retry {wait} _req {status} {t}{args} {u}; then pass {n}; else fail {n} \"$out\"; fi\n",
139        t = sq(request.contains.as_deref().unwrap_or("")),
140        args = curl_args(request),
141        u = sq(url),
142    )
143}
144
145/// The curl arguments for a request's redirects, method, headers and body, each after a space.
146fn curl_args(request: &HttpRequest) -> String {
147    let mut args: Vec<String> = Vec::new();
148    if request.follow {
149        args.push(format!("-L --max-redirs {HTTP_MAX_REDIRECTS}"));
150    }
151    let method = request.method.as_deref().map(str::to_uppercase);
152    match method.as_deref() {
153        // Following redirects, a POST is sent as curl sends a form (`--data-binary`,
154        // or an empty `-d ''`), not forced with `-X`: a 301/302/303 is then followed
155        // with a GET, as browsers do. `-X POST` would POST to every hop (a login
156        // page's redirect to a GET-only page answering 405).
157        Some("POST") if request.follow => {
158            if request.body.is_none() {
159                args.push("-d ''".to_owned());
160            }
161        }
162        Some("HEAD") if request.follow => args.push("-I".to_owned()),
163        Some(m) => args.push(format!("-X {}", sq(m))),
164        None => {}
165    }
166    args.extend(request.headers.iter().map(|(k, v)| format!("-H {}", sq(&format!("{k}: {v}")))));
167    args.extend(request.body.iter().map(|b| format!("--data-binary {}", sq(b))));
168    args.iter().flat_map(|a| [" ", a.as_str()]).collect()
169}
170
171/// A plain HTTP check: a status, any answer, or none.
172fn http_lines(n: &str, wait: u32, url: &str, expect: HttpExpect, follow: bool) -> String {
173    // The URL as an argument, and as text inside a double-quoted message.
174    let (arg, text) = (sq(url), dq(url));
175    // `_http`'s second argument: follow redirects.
176    let f = if follow { " follow" } else { "" };
177    let after = if follow { " (after redirects)" } else { "" };
178    match expect {
179        HttpExpect::Status(code) => format!(
180            "if _retry {wait} _http_is {arg} {code}{f}; then pass {n}; else fail {n} \"expected HTTP {code} from {text}{after}, got $(_http {arg}{f})\"; fi\n"
181        ),
182        HttpExpect::Any => format!("if _retry {wait} _http_any {arg}{f}; then pass {n}; else fail {n} \"nothing answers at {text}\"; fi\n"),
183        HttpExpect::Blocked => {
184            format!("c=$(_http {arg}); if [ \"$c\" = 000 ]; then pass {n}; else fail {n} \"{text} answered (HTTP $c); it should be blocked\"; fi\n")
185        }
186    }
187}
188
189/// A TCP check that writes a line (if any) and looks for text in what comes back: `_tcp_read`.
190fn tcp_read_lines(n: &str, wait: u32, host: &str, port: u16, (send, expected): (Option<&str>, &str)) -> String {
191    format!(
192        "if _retry {wait} _tcp_read {h} {port} {snd} {t}; then pass {n}; else fail {n} \"$out\"; fi\n",
193        h = sq(host),
194        snd = sq(send.unwrap_or("")),
195        t = sq(expected),
196    )
197}
198
199/// A bare TCP check: something listens, or nothing does.
200fn tcp_lines(n: &str, wait: u32, host: &str, port: u16, expect: TcpExpect) -> String {
201    // The host as an argument, and as text inside a double-quoted message.
202    let (arg, text) = (sq(host), dq(host));
203    match expect {
204        TcpExpect::Open => {
205            format!("if _retry {wait} _tcp {arg} {port}; then pass {n}; else fail {n} \"nothing listens at {text}:{port}\"; fi\n")
206        }
207        TcpExpect::Blocked => {
208            format!("if _tcp {arg} {port}; then fail {n} \"{text}:{port} answered; it should be blocked\"; else pass {n}; fi\n")
209        }
210    }
211}
212
213/// A project file (a script, a playbook) run by `cmd`, its path echoed first; `failure` says
214/// what went wrong after the path.
215fn project_file_lines(n: &str, wait: u32, path: &str, cmd: &str, failure: &str) -> String {
216    format!(
217        "echo {hdr}\nif _retry {wait} sh -c {cmd}; then pass {n}; else fail {n} \"{p} {failure}\"; fi\n",
218        hdr = sq(&format!("== {path}")),
219        cmd = sq(cmd),
220        p = dq(path)
221    )
222}
223
224/// The helpers every runner starts with.
225const RUNTIME: &str = r#"passed=0; failed=0
226pass() { passed=$((passed+1)); echo "isoloom-check: PASS $1"; }
227fail() { failed=$((failed+1)); echo "isoloom-check: FAIL $1: $2"; }
228# _http URL [follow] -> the status code, 000 when nothing answers (curl, else wget, else bash):
229# the first answer's, or with `follow` the last one's after at most 5 redirects (bash: the first).
230_http() {
231  if command -v curl >/dev/null 2>&1; then
232    if [ -n "$2" ]; then set -- "$1" -L --max-redirs 5; else set -- "$1"; fi
233    c=$(curl -sk -o /dev/null -m 5 -w '%{http_code}' "$@" 2>/dev/null)
234  elif command -v wget >/dev/null 2>&1; then
235    # wget follows redirects whatever it's told (busybox's can't be told): -S prints each answer.
236    if [ -n "$2" ]; then _n=6; else _n=1; fi
237    c=$(wget -q -S -O /dev/null -T 5 --no-check-certificate "$1" 2>&1 | sed -n 's/^ *HTTP\/[0-9.]* \([0-9][0-9][0-9]\).*/\1/p' | head -n $_n | tail -n 1)
238  else
239    u=${1#*://}; hp=${u%%/*}; p=/${u#*/}; [ "$u" = "$hp" ] && p=/
240    h=${hp%%:*}; port=${hp#*:}; [ "$hp" = "$h" ] && port=80
241    # shellcheck disable=SC2016 # the inner bash expands $H, $P and $U, for its /dev/tcp
242    c=$(H=$h P=$port U=$p timeout 5 bash -c 'exec 3<>/dev/tcp/$H/$P && printf "GET %s HTTP/1.0\r\nHost: %s\r\n\r\n" "$U" "$H" >&3 && read -r l <&3 && echo "$l"' 2>/dev/null | sed -n 's/^HTTP\/[0-9.]* \([0-9][0-9][0-9]\).*/\1/p')
243  fi
244  echo "${c:-000}"
245}
246_http_is() { [ "$(_http "$1" "$3")" = "$2" ]; }
247# _req STATUS|any TEXT CURL-ARGS... -> 0 when the request gets that status (any answer for `any`)
248# and its body contains TEXT (if any); $out says why not. Needs curl.
249_req() {
250  want=$1; text=$2; shift 2
251  command -v curl >/dev/null 2>&1 || { out="curl is needed for this check"; return 1; }
252  b=$(mktemp); c=$(curl -sk -m 10 -o "$b" -w '%{http_code}' "$@" 2>/dev/null); c=${c:-000}; r=0
253  if [ "$want" = any ]; then [ "$c" != 000 ] || r=1; else [ "$c" = "$want" ] || r=1; fi
254  if [ $r = 1 ]; then out="expected HTTP $want, got $c"
255  elif [ -n "$text" ] && ! grep -qF -- "$text" "$b"; then r=1; out="HTTP $c, but the response doesn't contain the expected text"
256  fi
257  rm -f "$b"; return $r
258}
259_http_any() { [ "$(_http "$1" "$2")" != 000 ]; }
260# _tcp HOST PORT -> 0 when it connects (nc, else bash).
261_tcp() {
262  if command -v nc >/dev/null 2>&1; then nc -z -w 5 "$1" "$2" >/dev/null 2>&1
263  else
264    # shellcheck disable=SC2016 # the inner bash expands $H and $P, for its /dev/tcp
265    H=$1 P=$2 timeout 5 bash -c 'exec 3<>/dev/tcp/$H/$P' >/dev/null 2>&1
266  fi
267}
268# _tcp_read HOST PORT LINE TEXT -> 0 when the bytes read within 5s of connecting (after writing
269# LINE and a newline, if any) contain TEXT; $out says why not (nc, else bash). Stdin stays open
270# while reading (some nc quit at its end), and nc is stopped after 5s whatever its flavor.
271_tcp_read() {
272  f=$(mktemp); i=0; r=1
273  if command -v nc >/dev/null 2>&1; then
274    { [ -z "$3" ] || printf '%s\n' "$3"; j=0; while [ ! -e "$f.end" ] && [ $j -lt 6 ]; do sleep 1; j=$((j+1)); done; } | nc "$1" "$2" >"$f" 2>/dev/null &
275  else
276    # shellcheck disable=SC2016 # the inner bash expands $H, $P and $L, for its /dev/tcp
277    H=$1 P=$2 L=$3 timeout 6 bash -c 'exec 3<>/dev/tcp/$H/$P || exit 1; [ -z "$L" ] || printf "%s\n" "$L" >&3; cat <&3' >"$f" 2>/dev/null &
278  fi
279  p=$!
280  while :; do
281    if grep -qF -- "$4" "$f"; then r=0; break; fi
282    if [ $i -ge 5 ] || ! kill -0 $p 2>/dev/null; then break; fi
283    sleep 1; i=$((i+1))
284  done
285  [ $r = 0 ] || ! grep -qF -- "$4" "$f" || r=0
286  touch "$f.end"; kill $p 2>/dev/null
287  if [ $r = 1 ]; then
288    if [ -s "$f" ]; then out="$1:$2 answered \"$(head -c 100 "$f" | tr -d '\000\r' | tr '\n\t' '  ')\", without the expected text"
289    elif [ $i -lt 5 ]; then out="$1:$2 refused the connection or closed it without a word"
290    else out="nothing read from $1:$2 within 5s"; fi
291  fi
292  rm -f "$f" "$f.end"; return $r
293}
294# _exec COMMAND TEXT -> 0 when the command succeeds and its output contains TEXT (if any).
295_exec() { out=$(sh -c "$1" 2>&1) && { [ -z "$2" ] || printf '%s\n' "$out" | grep -qF -- "$2"; }; }
296# _retry SECONDS COMMAND... -> keeps trying every 2s until it passes or the time is up.
297_retry() { _end=$(( $(date +%s) + $1 )); shift; while ! "$@"; do [ "$(date +%s)" -lt "$_end" ] || return 1; sleep 2; done; }
298"#;
299
300/// One line of a runner's output, as `isoloom test` reads it.
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub enum Line {
303    /// The check of this name passed.
304    Pass(String),
305    /// The check of this name failed, and why.
306    Fail(String, String),
307    /// The runner finished: how many passed and failed.
308    End(u32, u32),
309}
310
311/// Reads a runner's output line (anywhere in the line: tools prefix their output).
312#[must_use]
313pub fn parse_line(line: &str) -> Option<Line> {
314    let (_, rest) = line.split_once("isoloom-check: ")?;
315    if let Some(n) = rest.strip_prefix("PASS ") {
316        return Some(Line::Pass(n.trim_end().to_owned()));
317    }
318    if let Some(r) = rest.strip_prefix("FAIL ") {
319        let (name, why) = r.split_once(": ").unwrap_or((r, ""));
320        return Some(Line::Fail(name.to_owned(), why.trim_end().to_owned()));
321    }
322    if let Some(r) = rest.strip_prefix("END ") {
323        let mut nums = r.split_whitespace().filter_map(|w| w.parse::<u32>().ok());
324        return Some(Line::End(nums.next()?, nums.next()?));
325    }
326    None
327}
328
329#[cfg(test)]
330mod tests;