Skip to main content

isoloom_render/
controller.rs

1//! The controller: the VM that runs the environment's playbooks (`provision:`) on every target
2//! that runs playbooks (Vagrant, Proxmox, the clouds, `isoloom provision`), with one script, so
3//! they all retry the same way.
4//!
5//! After a failed run, when no task failed on its own, the hosts that failed only because of the
6//! connection run the playbook again, on those hosts only (`--limit @<playbook>.retry`), after a
7//! pause, up to four runs in all:
8//! - unreachable hosts (a Windows host dropping WinRM for a minute after a domain join or an
9//!   update reboot);
10//! - hosts whose task was cut by a dropped connection: a failure whose message is in
11//!   the known list of dropped connections (WinRM's shell crashing mid-task, an SSH session
12//!   reset).
13//!
14//! A task that failed on its own is real: the setup stops at once, nothing is retried. So does a
15//! run with no recap (a playbook that doesn't parse). Every wait and run is logged, so the output
16//! explains what happened.
17
18use isoloom_spec::model::{Provision, Spec};
19use isoloom_spec::shell;
20
21/// Runs of a playbook in all: the first and up to three again.
22const ATTEMPTS: u32 = 4;
23
24/// Where Ansible writes its retry files (`<playbook>.retry`), and where `run_play` writes the
25/// hosts a playbook runs again on, in the same file and format.
26const RETRY_DIR: &str = "/tmp/isoloom-retry";
27
28/// The controller's script running the environment's playbooks, in order, with the inventory
29/// at `/etc/isoloom/inventory.ini` plus each step's own inventories, as it runs when the
30/// environment comes up; `isoloom provision` runs it again on a running environment.
31///
32/// `limit`: only these machines (`isoloom provision web`); every machine when empty.
33#[must_use]
34pub fn provision_script(spec: &Spec, limit: &[String]) -> String {
35    let limit = if limit.is_empty() {
36        String::new()
37    } else {
38        format!(" --limit {}", shell::quote(&limit.join(",")))
39    };
40    let head = format!(
41        "set -e\nmkdir -p /tmp/isoloom-facts\n{ENVIRONMENT}\nmkdir -p {RETRY_DIR}\nexport ANSIBLE_RETRY_FILES_ENABLED=True ANSIBLE_RETRY_FILES_SAVE_PATH={RETRY_DIR}\n{}",
42        helpers()
43    );
44    let plays: String = spec.provision.iter().map(|step| play(step, &limit)).collect();
45    head + &plays
46}
47
48/// One `provision:` step's lines: into its playbook's folder, its roles installed, the playbook
49/// run through `run_play`.
50fn play(step: &Provision, limit: &str) -> String {
51    let (dir, file) = step.ansible.rsplit_once('/').unwrap_or((".", &step.ansible));
52    let extra: String = step.inventory.iter().map(|i| format!(" -i /opt/isoloom/{i}")).collect::<Vec<_>>().concat();
53    let requirements = match &step.requirements {
54        Some(r) => format!("retry_download ansible-galaxy install -r /opt/isoloom/{r}\n"),
55        None => "[ ! -f requirements.yml ] || retry_download ansible-galaxy install -r requirements.yml\n".into(),
56    };
57    format!(
58        "cd /opt/isoloom/{dir}\n{requirements}run_play ansible-playbook -i /etc/isoloom/inventory.ini{extra}{}{limit} {file}\n",
59        vars(step)
60    )
61}
62
63/// A step's variables as one JSON `-e` (`-e k=v` splits values on spaces).
64fn vars(step: &Provision) -> String {
65    if step.vars.is_empty() {
66        return String::new();
67    }
68    let json = serde_json::Value::Object(step.vars.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
69    format!(" -e {}", shell::quote(&json.to_string()))
70}
71
72/// Ansible's settings. Performance: facts gathered once and cached (the WinRM `setup` module is
73/// slow and the environment's playbooks re-run across many imported plays), a fan-out across
74/// hosts (the default 5 forks are too few for a multi-DC range), SSH steps pipelined (a no-op
75/// over WinRM). `ANSIBLE_HOST_KEY_CHECKING=False` is a deliberate trade-off (reviewed, security
76/// finding S5): the lab's hosts are freshly created and ephemeral, with no stable `known_hosts`
77/// for the controller to trust, so host-key checking inside the lab network is off.
78const ENVIRONMENT: &str = "export PATH=\"/opt/ansible/bin:$PATH\" ANSIBLE_HOST_KEY_CHECKING=False \
79ANSIBLE_GATHERING=smart ANSIBLE_FORKS=20 ANSIBLE_PIPELINING=True ANSIBLE_CACHE_PLUGIN=jsonfile \
80ANSIBLE_CACHE_PLUGIN_CONNECTION=/tmp/isoloom-facts ANSIBLE_CACHE_PLUGIN_TIMEOUT=7200";
81
82/// Failure messages that are the connection dropping, not the task failing, as extended
83/// regular expressions matched against a lowercased `fatal:` / `failed:` line. Ansible reports
84/// most of these as a failed task rather than an unreachable host: WinRM's shell crashing
85/// mid-task, a Windows host rebooting during a play (a domain join), an SSH session reset.
86/// Lowercase; none may hold a `'` (the list is single-quoted in the script).
87const TRANSIENT: &[&str] = &[
88    // WinRM: the shell or its HTTP transport went away under a task.
89    "send_input failed",
90    "the pipe has been ended",
91    "winrmtransporterror",
92    "winrmoperationtimeouterror",
93    "read timeout",
94    "bad http response returned from server",
95    "(kerberos|ntlm|credssp).*500",
96    "remote end closed connection",
97    // TCP and SSH.
98    "connection reset by peer",
99    "connection refused",
100    "connection aborted",
101    "broken pipe",
102    "timed out",
103    "failed to connect to the host via ssh",
104    "shared connection to .* closed",
105];
106
107/// The script's shell helpers (POSIX sh, safe under `set -e`):
108/// - `retry_download`: role downloads (Galaxy, from GitHub) fail now and then on the network;
109///   tried 5 times, with a growing pause (`ISOLOOM_RETRY_PAUSE` seconds, 15 by default).
110/// - `play_hosts <log>`: the hosts of a run's PLAY RECAP that failed, one line each, sorted:
111///   `unreachable <host>`; `cut <host>` when every task that failed there was cut by the
112///   connection (its `fatal:` line matches [`TRANSIENT`]); `real <host>` otherwise. A failure
113///   Ansible ignored (`...ignoring`) doesn't count.
114/// - `run_play <command...> <playbook>`: the command (the playbook its last argument), then,
115///   while no host is `real` and some are `unreachable` or `cut`, again on those only
116///   (`--limit @<playbook>.retry`, in `ANSIBLE_RETRY_FILES_SAVE_PATH`, [`RETRY_DIR`] by default)
117///   after a pause of 4 × `ISOLOOM_RETRY_PAUSE` (60 s by default; 2 × when no host was
118///   unreachable, only cut), up to [`ATTEMPTS`] runs in all. Its exit code is the last run's.
119fn helpers() -> String {
120    format!(
121        "_isoloom_pause=${{ISOLOOM_RETRY_PAUSE:-15}}\n_isoloom_attempts={ATTEMPTS}\n_isoloom_transient='{}'\n{HELPER_FUNCTIONS}",
122        TRANSIENT.join("|")
123    )
124}
125
126/// The functions of [`helpers`].
127const HELPER_FUNCTIONS: &str = r#"retry_download() {
128  _isoloom_n=1
129  until "$@"; do
130    [ "$_isoloom_n" -lt 5 ] || return 1
131    echo "isoloom: download failed; trying again in $((_isoloom_n * _isoloom_pause)) s ($((_isoloom_n + 1))/5)"
132    sleep $((_isoloom_n * _isoloom_pause))
133    _isoloom_n=$((_isoloom_n + 1))
134  done
135}
136play_hosts() {
137  awk -v transient="$_isoloom_transient" '
138    function commit() {
139      if (pending != "") { if (pending_real) real[pending] = 1; else cut[pending] = 1 }
140      pending = ""
141    }
142    { gsub(/\033\[[0-9;]*m/, "") }
143    /^\.\.\.ignoring/ { pending = ""; next }
144    { commit() }
145    /^(fatal|failed): \[/ && !/UNREACHABLE!/ {
146      pending = $0
147      sub(/^(fatal|failed): \[/, "", pending)
148      sub(/\].*/, "", pending)
149      sub(/ -> .*/, "", pending)
150      pending_real = (tolower($0) !~ transient)
151      next
152    }
153    /^PLAY RECAP/ { recap = 1; next }
154    recap && / : ok=/ {
155      for (i = 2; i <= NF; i++) {
156        split($i, kv, "=")
157        if (kv[1] == "failed" && kv[2] > 0) failed[$1] = 1
158        if (kv[1] == "unreachable" && kv[2] > 0) unreachable[$1] = 1
159      }
160    }
161    END {
162      commit()
163      for (h in failed) print ((h in real || !(h in cut)) ? "real " : "cut ") h
164      for (h in unreachable) if (!(h in failed)) print "unreachable " h
165    }
166  ' "$1" | sort
167}
168_isoloom_kind() {
169  echo "$1" | awk -v k="$2" '$1 == k { printf "%s%s", s, $2; s = " " }'
170}
171run_play() {
172  # The playbook is the last argument; Ansible names its retry file after it.
173  for _isoloom_book; do :; done
174  _isoloom_name=$(basename "$_isoloom_book")
175  _isoloom_retry=${ANSIBLE_RETRY_FILES_SAVE_PATH:-/tmp/isoloom-retry}/${_isoloom_name%.*}.retry
176  _isoloom_limit=
177  _isoloom_log=$(mktemp)
178  _isoloom_rcf=$(mktemp)
179  _isoloom_n=1
180  rm -f "$_isoloom_retry"
181  while :; do
182    { "$@" && echo 0 > "$_isoloom_rcf" || echo $? > "$_isoloom_rcf"; } 2>&1 | tee "$_isoloom_log"
183    _isoloom_rc=$(cat "$_isoloom_rcf")
184    [ "$_isoloom_rc" -ne 0 ] || break
185    _isoloom_hosts=$(play_hosts "$_isoloom_log")
186    _isoloom_real=$(_isoloom_kind "$_isoloom_hosts" real)
187    _isoloom_gone=$(_isoloom_kind "$_isoloom_hosts" unreachable)
188    _isoloom_cut=$(_isoloom_kind "$_isoloom_hosts" cut)
189    _isoloom_again="$_isoloom_gone${_isoloom_gone:+${_isoloom_cut:+ }}$_isoloom_cut"
190    if [ -n "$_isoloom_real" ]; then
191      echo "isoloom: $_isoloom_name: a task failed on $_isoloom_real: not retried"
192      break
193    fi
194    if [ -z "$_isoloom_again" ]; then
195      echo "isoloom: $_isoloom_name failed (exit $_isoloom_rc) with no unreachable host or dropped connection: not retried"
196      break
197    fi
198    if [ "$_isoloom_n" -ge "$_isoloom_attempts" ]; then
199      echo "isoloom: $_isoloom_name: still unreachable after $_isoloom_n runs: $_isoloom_again; giving up"
200      break
201    fi
202    echo "$_isoloom_again" | tr ' ' '\n' > "$_isoloom_retry"
203    if [ -z "$_isoloom_limit" ]; then set -- "$@" --limit "@$_isoloom_retry"; _isoloom_limit=1; fi
204    _isoloom_n=$((_isoloom_n + 1))
205    if [ -n "$_isoloom_gone" ]; then
206      _isoloom_wait=$((4 * _isoloom_pause))
207      echo "isoloom: $_isoloom_name: unreachable: $_isoloom_again; waiting $_isoloom_wait s, then running it again on them ($_isoloom_n/$_isoloom_attempts)"
208    else
209      _isoloom_wait=$((2 * _isoloom_pause))
210      echo "isoloom: $_isoloom_name: the play was cut by a dropped connection on $_isoloom_cut; waiting $_isoloom_wait s, then running it again on them ($_isoloom_n/$_isoloom_attempts)"
211    fi
212    sleep "$_isoloom_wait"
213  done
214  rm -f "$_isoloom_log" "$_isoloom_rcf"
215  [ "$_isoloom_rc" -ne 0 ] || rm -f "$_isoloom_retry"
216  return "$_isoloom_rc"
217}
218"#;
219
220#[cfg(test)]
221mod tests;