1use isoloom_spec::shell::quote as sq;
5
6use super::{HTTP_MAX_REDIRECTS, Host, HttpExpect, HttpRequest, Position, Probe, Resolved, TcpExchange, TcpExpect};
7
8pub struct Render<'a> {
10 pub host: &'a dyn Fn(&Host, &Position) -> String,
12 pub script: &'a dyn Fn(&str) -> String,
14 pub playbook: Option<&'a dyn Fn(&str) -> String>,
16}
17
18fn dq(s: &str) -> String {
20 s.replace('\\', "\\\\").replace('"', "\\\"").replace('$', "\\$").replace('`', "\\`")
21}
22
23#[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
46fn 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
76fn 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
94fn 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
117fn 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 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
129fn http_request_lines(n: &str, wait: u32, url: &str, expect: HttpExpect, request: &HttpRequest) -> String {
132 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
145fn 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 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
171fn http_lines(n: &str, wait: u32, url: &str, expect: HttpExpect, follow: bool) -> String {
173 let (arg, text) = (sq(url), dq(url));
175 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
189fn 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
199fn tcp_lines(n: &str, wait: u32, host: &str, port: u16, expect: TcpExpect) -> String {
201 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
213fn 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
224const 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#[derive(Debug, Clone, PartialEq, Eq)]
302pub enum Line {
303 Pass(String),
305 Fail(String, String),
307 End(u32, u32),
309}
310
311#[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;