use isoloom_spec::shell::quote as sq;
use super::{HTTP_MAX_REDIRECTS, Host, HttpExpect, HttpRequest, Position, Probe, Resolved, TcpExchange, TcpExpect};
pub struct Render<'a> {
pub host: &'a dyn Fn(&Host, &Position) -> String,
pub script: &'a dyn Fn(&str) -> String,
pub playbook: Option<&'a dyn Fn(&str) -> String>,
}
fn dq(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"").replace('$', "\\$").replace('`', "\\`")
}
#[must_use]
pub fn script(position: &Position, checks: &[&Resolved], r: &Render) -> String {
let mut s = format!(
"#!/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, {}. `isoloom test` runs it and reads the PASS/FAIL lines.\n{RUNTIME}",
position.label()
);
for c in checks {
s.push('\n');
if c.derived {
s.push_str("if [ \"${ISOLOOM_DERIVED:-1}\" != 0 ]; then\n");
}
s.push_str(&with_directives(&check_lines(c, position, r)));
if c.derived {
s.push_str("fi\n");
}
}
s.push_str("\necho \"isoloom-check: END $passed passed, $failed failed\"\n[ \"$failed\" -eq 0 ]\n");
s
}
fn check_lines(c: &Resolved, position: &Position, r: &Render) -> String {
let n = sq(&c.name);
let host = |h: &Host| (r.host)(h, position);
match &c.probe {
Probe::Http { url, expect, request } if !request.is_plain() && *expect != HttpExpect::Blocked => {
http_request_lines(&n, c.wait, &url.render(&host(&url.host)), *expect, request)
}
Probe::Http { url, expect, request } => http_lines(&n, c.wait, &url.render(&host(&url.host)), *expect, request.follow),
Probe::Tcp {
host: h,
port,
expect: TcpExpect::Open,
exchange: TcpExchange { send, contains: Some(want) },
} => tcp_read_lines(&n, c.wait, &host(h), *port, (send.as_deref(), want)),
Probe::Tcp { host: h, port, expect, .. } => tcp_lines(&n, c.wait, &host(h), *port, *expect),
Probe::Exec { command, expect } => format!(
"if _retry {w} _exec {cmd} {want}; then pass {n}; else fail {n} \"$(printf '%s' \"$out\" | tail -n 1)\"; fi\n",
w = c.wait,
cmd = sq(command),
want = sq(expect.as_deref().unwrap_or("")),
),
Probe::Script { path } => project_file_lines(&n, c.wait, path, &(r.script)(path), "exited non-zero"),
Probe::Playbook { path } => match r.playbook {
Some(pb) => project_file_lines(&n, c.wait, path, &pb(path), "failed"),
None => format!("fail {n} \"Ansible checks don't run from this position\"\n"),
},
}
}
fn with_directives(lines: &str) -> String {
lines
.lines()
.map(|line| {
if single_quoted(line).into_iter().any(looks_expanded) {
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
format!("{indent}# shellcheck disable=SC2016 # the check's own text, quoted as written\n{line}\n")
} else {
format!("{line}\n")
}
})
.collect()
}
fn single_quoted(line: &str) -> Vec<&str> {
let mut parts = Vec::new();
let (mut rest, mut double) = (line, false);
while let Some((i, c)) = rest.char_indices().find(|&(_, c)| matches!(c, '\'' | '"' | '\\')) {
let after = rest.get(i + 1..).unwrap_or_default();
rest = match c {
'\\' => after.get(after.chars().next().map_or(0, char::len_utf8)..).unwrap_or_default(),
'"' => {
double = !double;
after
}
_ if double => after,
_ => {
let end = after.find('\'').unwrap_or(after.len());
parts.push(after.get(..end).unwrap_or_default());
after.get(end + 1..).unwrap_or_default()
}
};
}
parts
}
fn looks_expanded(text: &str) -> bool {
let dollar = text
.split('$')
.skip(1)
.any(|after| after.starts_with(|c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '{' | '(')));
let after: Vec<&str> = text.split('`').skip(1).collect();
dollar || after.split_last().is_some_and(|(_, between)| between.iter().any(|t| !t.is_empty()))
}
fn http_request_lines(n: &str, wait: u32, url: &str, expect: HttpExpect, request: &HttpRequest) -> String {
let status = match expect {
HttpExpect::Status(code) => code.to_string(),
HttpExpect::Any | HttpExpect::Blocked => "any".to_owned(),
};
format!(
"if _retry {wait} _req {status} {t}{args} {u}; then pass {n}; else fail {n} \"$out\"; fi\n",
t = sq(request.contains.as_deref().unwrap_or("")),
args = curl_args(request),
u = sq(url),
)
}
fn curl_args(request: &HttpRequest) -> String {
let mut args: Vec<String> = Vec::new();
if request.follow {
args.push(format!("-L --max-redirs {HTTP_MAX_REDIRECTS}"));
}
let method = request.method.as_deref().map(str::to_uppercase);
match method.as_deref() {
Some("POST") if request.follow => {
if request.body.is_none() {
args.push("-d ''".to_owned());
}
}
Some("HEAD") if request.follow => args.push("-I".to_owned()),
Some(m) => args.push(format!("-X {}", sq(m))),
None => {}
}
args.extend(request.headers.iter().map(|(k, v)| format!("-H {}", sq(&format!("{k}: {v}")))));
args.extend(request.body.iter().map(|b| format!("--data-binary {}", sq(b))));
args.iter().flat_map(|a| [" ", a.as_str()]).collect()
}
fn http_lines(n: &str, wait: u32, url: &str, expect: HttpExpect, follow: bool) -> String {
let (arg, text) = (sq(url), dq(url));
let f = if follow { " follow" } else { "" };
let after = if follow { " (after redirects)" } else { "" };
match expect {
HttpExpect::Status(code) => format!(
"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"
),
HttpExpect::Any => format!("if _retry {wait} _http_any {arg}{f}; then pass {n}; else fail {n} \"nothing answers at {text}\"; fi\n"),
HttpExpect::Blocked => {
format!("c=$(_http {arg}); if [ \"$c\" = 000 ]; then pass {n}; else fail {n} \"{text} answered (HTTP $c); it should be blocked\"; fi\n")
}
}
}
fn tcp_read_lines(n: &str, wait: u32, host: &str, port: u16, (send, expected): (Option<&str>, &str)) -> String {
format!(
"if _retry {wait} _tcp_read {h} {port} {snd} {t}; then pass {n}; else fail {n} \"$out\"; fi\n",
h = sq(host),
snd = sq(send.unwrap_or("")),
t = sq(expected),
)
}
fn tcp_lines(n: &str, wait: u32, host: &str, port: u16, expect: TcpExpect) -> String {
let (arg, text) = (sq(host), dq(host));
match expect {
TcpExpect::Open => {
format!("if _retry {wait} _tcp {arg} {port}; then pass {n}; else fail {n} \"nothing listens at {text}:{port}\"; fi\n")
}
TcpExpect::Blocked => {
format!("if _tcp {arg} {port}; then fail {n} \"{text}:{port} answered; it should be blocked\"; else pass {n}; fi\n")
}
}
}
fn project_file_lines(n: &str, wait: u32, path: &str, cmd: &str, failure: &str) -> String {
format!(
"echo {hdr}\nif _retry {wait} sh -c {cmd}; then pass {n}; else fail {n} \"{p} {failure}\"; fi\n",
hdr = sq(&format!("== {path}")),
cmd = sq(cmd),
p = dq(path)
)
}
const RUNTIME: &str = r#"passed=0; failed=0
pass() { passed=$((passed+1)); echo "isoloom-check: PASS $1"; }
fail() { failed=$((failed+1)); echo "isoloom-check: FAIL $1: $2"; }
# _http URL [follow] -> the status code, 000 when nothing answers (curl, else wget, else bash):
# the first answer's, or with `follow` the last one's after at most 5 redirects (bash: the first).
_http() {
if command -v curl >/dev/null 2>&1; then
if [ -n "$2" ]; then set -- "$1" -L --max-redirs 5; else set -- "$1"; fi
c=$(curl -sk -o /dev/null -m 5 -w '%{http_code}' "$@" 2>/dev/null)
elif command -v wget >/dev/null 2>&1; then
# wget follows redirects whatever it's told (busybox's can't be told): -S prints each answer.
if [ -n "$2" ]; then _n=6; else _n=1; fi
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)
else
u=${1#*://}; hp=${u%%/*}; p=/${u#*/}; [ "$u" = "$hp" ] && p=/
h=${hp%%:*}; port=${hp#*:}; [ "$hp" = "$h" ] && port=80
# shellcheck disable=SC2016 # the inner bash expands $H, $P and $U, for its /dev/tcp
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')
fi
echo "${c:-000}"
}
_http_is() { [ "$(_http "$1" "$3")" = "$2" ]; }
# _req STATUS|any TEXT CURL-ARGS... -> 0 when the request gets that status (any answer for `any`)
# and its body contains TEXT (if any); $out says why not. Needs curl.
_req() {
want=$1; text=$2; shift 2
command -v curl >/dev/null 2>&1 || { out="curl is needed for this check"; return 1; }
b=$(mktemp); c=$(curl -sk -m 10 -o "$b" -w '%{http_code}' "$@" 2>/dev/null); c=${c:-000}; r=0
if [ "$want" = any ]; then [ "$c" != 000 ] || r=1; else [ "$c" = "$want" ] || r=1; fi
if [ $r = 1 ]; then out="expected HTTP $want, got $c"
elif [ -n "$text" ] && ! grep -qF -- "$text" "$b"; then r=1; out="HTTP $c, but the response doesn't contain the expected text"
fi
rm -f "$b"; return $r
}
_http_any() { [ "$(_http "$1" "$2")" != 000 ]; }
# _tcp HOST PORT -> 0 when it connects (nc, else bash).
_tcp() {
if command -v nc >/dev/null 2>&1; then nc -z -w 5 "$1" "$2" >/dev/null 2>&1
else
# shellcheck disable=SC2016 # the inner bash expands $H and $P, for its /dev/tcp
H=$1 P=$2 timeout 5 bash -c 'exec 3<>/dev/tcp/$H/$P' >/dev/null 2>&1
fi
}
# _tcp_read HOST PORT LINE TEXT -> 0 when the bytes read within 5s of connecting (after writing
# LINE and a newline, if any) contain TEXT; $out says why not (nc, else bash). Stdin stays open
# while reading (some nc quit at its end), and nc is stopped after 5s whatever its flavor.
_tcp_read() {
f=$(mktemp); i=0; r=1
if command -v nc >/dev/null 2>&1; then
{ [ -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 &
else
# shellcheck disable=SC2016 # the inner bash expands $H, $P and $L, for its /dev/tcp
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 &
fi
p=$!
while :; do
if grep -qF -- "$4" "$f"; then r=0; break; fi
if [ $i -ge 5 ] || ! kill -0 $p 2>/dev/null; then break; fi
sleep 1; i=$((i+1))
done
[ $r = 0 ] || ! grep -qF -- "$4" "$f" || r=0
touch "$f.end"; kill $p 2>/dev/null
if [ $r = 1 ]; then
if [ -s "$f" ]; then out="$1:$2 answered \"$(head -c 100 "$f" | tr -d '\000\r' | tr '\n\t' ' ')\", without the expected text"
elif [ $i -lt 5 ]; then out="$1:$2 refused the connection or closed it without a word"
else out="nothing read from $1:$2 within 5s"; fi
fi
rm -f "$f" "$f.end"; return $r
}
# _exec COMMAND TEXT -> 0 when the command succeeds and its output contains TEXT (if any).
_exec() { out=$(sh -c "$1" 2>&1) && { [ -z "$2" ] || printf '%s\n' "$out" | grep -qF -- "$2"; }; }
# _retry SECONDS COMMAND... -> keeps trying every 2s until it passes or the time is up.
_retry() { _end=$(( $(date +%s) + $1 )); shift; while ! "$@"; do [ "$(date +%s)" -lt "$_end" ] || return 1; sleep 2; done; }
"#;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Line {
Pass(String),
Fail(String, String),
End(u32, u32),
}
#[must_use]
pub fn parse_line(line: &str) -> Option<Line> {
let (_, rest) = line.split_once("isoloom-check: ")?;
if let Some(n) = rest.strip_prefix("PASS ") {
return Some(Line::Pass(n.trim_end().to_owned()));
}
if let Some(r) = rest.strip_prefix("FAIL ") {
let (name, why) = r.split_once(": ").unwrap_or((r, ""));
return Some(Line::Fail(name.to_owned(), why.trim_end().to_owned()));
}
if let Some(r) = rest.strip_prefix("END ") {
let mut nums = r.split_whitespace().filter_map(|w| w.parse::<u32>().ok());
return Some(Line::End(nums.next()?, nums.next()?));
}
None
}
#[cfg(test)]
mod tests;