Skip to main content

isoloom_render/configure/
runner.rs

1//! Isoloom's runner (design ยง3): the process of a container whose recipe runs services, generated
2//! per machine in POSIX `sh` and installed as the image's entrypoint. The engine's init (Compose's
3//! `init: true`, a pod sharing its process namespace) is PID 1 and reaps what the runner leaves.
4//!
5//! It runs the recipe's `start` steps, links each daemon's log files to the container's output,
6//! then:
7//! - **one daemon:** `exec`s it, dropping to its user, so the daemon is the container's process;
8//! - **several (or one and the machine's command):** starts each in recipe order, each line of
9//!   its output prefixed `<unit> | `, then waits. `SIGTERM` or `SIGINT` goes to every daemon.
10//!   When one exits, it stops the others and exits with that one's status, and the engine
11//!   restarts the container: a half-running machine never looks ready.
12//!
13//! The machine's command reaches it as its arguments (Compose's `command:`, a pod's `args:`).
14
15use isoloom_plan::configure::{ContainerProcess, Daemon, Resource, RunPlan, ServiceSource, Stream};
16use isoloom_spec::shell::quote;
17use isoloom_spec::syntax::{AbsPath, UserName};
18
19use super::{Configured, DIR, shell};
20use crate::{GeneratedFile, header};
21
22/// Where machine `machine`'s runner is generated, from the project folder.
23#[must_use]
24pub fn path(machine: &str) -> String {
25    format!("{DIR}/{machine}/run")
26}
27
28/// Machine `c`'s runner as a generated file, when its container runs `process` through it.
29#[must_use]
30pub fn file(c: &Configured<'_>, process: &ContainerProcess) -> Option<GeneratedFile> {
31    Some(GeneratedFile {
32        path: path(c.machine()),
33        contents: script(c, process)?,
34    })
35}
36
37/// Drops to a user (empty: stays root), in a folder (empty: stays), then `exec`s the command:
38/// `setpriv` where util-linux has it, else `su`.
39const AS: &str = r#"isoloom_as() {
40  _user=$1 _cwd=$2
41  shift 2
42  if [ -n "$_cwd" ]; then cd "$_cwd"; fi
43  if [ -z "$_user" ] || [ "$_user" = root ]; then exec "$@"; fi
44  if command -v setpriv >/dev/null 2>&1; then
45    exec setpriv --reuid="$_user" --regid="$(id -g "$_user")" --init-groups -- "$@"
46  fi
47  _line='exec'
48  for _arg do
49    _line="$_line '$(printf '%s' "$_arg" | sed "s/'/'\\\\''/g")'"
50  done
51  exec su -s /bin/sh -c "$_line" "$_user"
52}
53"#;
54
55/// Starts and watches several processes, each line of their output prefixed with its name.
56const SUPERVISE: &str = r#"isoloom_dir=$(mktemp -d)
57isoloom_pids=
58# Prints each line of its input as `NAME | line`.
59isoloom_prefix() {
60  while IFS= read -r _line || [ -n "$_line" ]; do printf '%s | %s\n' "$1" "$_line"; done
61}
62# Starts NAME as USER in CWD: ARGS..., its output through a FIFO, so its own PID is kept.
63isoloom_start() {
64  mkfifo "$isoloom_dir/$1"
65  isoloom_prefix "$1" <"$isoloom_dir/$1" &
66  _name=$1
67  shift
68  isoloom_as "$@" >"$isoloom_dir/$_name" 2>&1 &
69  isoloom_pids="$isoloom_pids $!"
70}
71# Whether process PID runs (not ended, nor a zombie waiting for this runner).
72isoloom_alive() {
73  [ -r "/proc/$1/stat" ] && [ "$(sed 's/.*) //' "/proc/$1/stat" | cut -c 1)" != Z ]
74}
75# Stops every process and waits for them.
76isoloom_stop() {
77  for _pid in $isoloom_pids; do kill -TERM "$_pid" 2>/dev/null || true; done
78  for _pid in $isoloom_pids; do wait "$_pid" 2>/dev/null || true; done
79}
80trap 'isoloom_stop; exit 143' TERM INT
81# Waits until one ends, then stops the others and exits with its status.
82isoloom_watch() {
83  while :; do
84    for _pid in $isoloom_pids; do
85      isoloom_alive "$_pid" && continue
86      _status=0
87      wait "$_pid" || _status=$?
88      echo "isoloom: process $_pid ended (status $_status): stopping the others" >&2
89      isoloom_stop
90      exit "$_status"
91    done
92    sleep 1
93  done
94}
95"#;
96
97/// Machine `c`'s runner, when its container runs `process` through it (one daemon, or several).
98#[must_use]
99pub fn script(c: &Configured<'_>, process: &ContainerProcess) -> Option<String> {
100    let (daemons, command) = match process {
101        ContainerProcess::One(d) => (std::slice::from_ref(d), false),
102        ContainerProcess::Supervised { daemons, command } => (daemons.as_slice(), command.is_some()),
103        ContainerProcess::Started { entrypoint, .. } => return Some(started(c, entrypoint)),
104        _ => return None,
105    };
106    let start = start(c);
107    let logs: String = daemons.iter().map(logs).collect();
108    let run = if let ([one], false) = (daemons, command) {
109        format!("{}\n", invocation(c, "isoloom_as", one))
110    } else {
111        let mut each = String::new();
112        for d in daemons {
113            each.push_str(&invocation(c, &format!("isoloom_start {}", quote(d.unit.as_str())), d));
114            each.push('\n');
115        }
116        let command = if command { "isoloom_start command '' '' \"$@\"\n" } else { "" };
117        format!("{SUPERVISE}{each}{command}isoloom_watch\n")
118    };
119    Some(format!(
120        "#!/bin/sh\n{}# The process of machine `{}`'s container: its services, run by Isoloom's runner.\nset -eu\n{start}{AS}{logs}{run}",
121        header("#"),
122        c.machine()
123    ))
124}
125
126/// The runner of an official image whose entry's `start` steps it runs: them, then the image's
127/// own `entrypoint`, with the container's command as its arguments (the image's `CMD`, or the
128/// machine's).
129fn started(c: &Configured<'_>, entrypoint: &[String]) -> String {
130    let exec: Vec<String> = entrypoint.iter().map(|a| quote(a)).collect();
131    let exec = if exec.is_empty() { String::new() } else { format!("{} ", exec.join(" ")) };
132    format!(
133        "#!/bin/sh\n{}# The process of machine `{}`'s container: its entry's start steps, run by Isoloom's runner, then the image's own.\nset -eu\n{}exec {exec}\"$@\"\n",
134        header("#"),
135        c.machine(),
136        start(c)
137    )
138}
139
140/// The recipe's `start` steps, run at each start before the services, from the project folder:
141/// scripts behind their `creates`, the other steps through the shell library (an entry's; an app
142/// has none in 1.0).
143fn start(c: &Configured<'_>) -> String {
144    let steps = &c.recipe().start;
145    if steps.is_empty() {
146        return String::new();
147    }
148    let calls: String = steps
149        .iter()
150        .filter_map(|r| match r {
151            Resource::Run(run) => Some(start_script(run)),
152            other => shell::call(c, other),
153        })
154        .map(|line| {
155            if shell::literal_dollar(&line) {
156                format!("# shellcheck disable=SC2016\n{line}")
157            } else {
158                line
159            }
160        })
161        .collect();
162    let library = if steps.iter().any(|r| !matches!(r, Resource::Run(_))) {
163        ". /usr/local/lib/isoloom/configure.sh\n"
164    } else {
165        ""
166    };
167    format!("{library}cd /opt/isoloom\n{calls}")
168}
169
170/// A project script of the `start` phase, behind its `creates` (the Dockerfile copies it in).
171fn start_script(run: &RunPlan) -> String {
172    let line = format!("sh {}", quote(&format!("/opt/isoloom/{}", run.script)));
173    match &run.creates {
174        Some(done) => format!("[ -e {} ] || {line}\n", quote(done.as_str())),
175        None => format!("{line}\n"),
176    }
177}
178
179/// Links each of the daemon's log files to the container's output (a fresh link at each start).
180fn logs(d: &Daemon) -> String {
181    d.logs
182        .iter()
183        .map(|l| {
184            let path = quote(l.path.as_str());
185            let stream = if l.stream == Stream::Stderr { "/dev/stderr" } else { "/dev/stdout" };
186            format!("mkdir -p \"$(dirname {path})\" && rm -f {path} && ln -s {stream} {path}\n")
187        })
188        .collect::<Vec<_>>()
189        .concat()
190}
191
192/// `function`, then the daemon's user, folder (a service Isoloom defines may name one) and
193/// foreground form, each quoted.
194fn invocation(c: &Configured<'_>, function: &str, d: &Daemon) -> String {
195    let user = quote(d.user.as_ref().map(UserName::as_str).unwrap_or_default());
196    let cwd = c.recipe().resources().find_map(|r| match r {
197        Resource::Service(s) if s.unit == d.unit => match &s.source {
198            ServiceSource::Defined { cwd, .. } => cwd.as_ref(),
199            _ => None,
200        },
201        _ => None,
202    });
203    let args: Vec<String> = d.foreground.args().iter().map(|a| quote(a)).collect();
204    format!("{function} {user} {} {}", quote(cwd.map(AbsPath::as_str).unwrap_or_default()), args.join(" "))
205}
206
207#[cfg(test)]
208mod tests;