Skip to main content

isoloom_render/
boot.rs

1//! Set-up that must survive a restart. A step's script (routes, trunks, link impairment, the
2//! egress rules) changes kernel state a reboot forgets; a VM backend whose machines restart
3//! (a `reboot` step, `vagrant reload`, a stopped cloud VM) wraps it with [`at_boot`] so it is
4//! applied now and again at every boot. A machine's own command and `before` steps run the same
5//! way, as a [`service`].
6
7use isoloom_spec::shell::quote;
8
9/// When a script kept with [`at_boot`] runs again.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Trigger {
12    /// At every boot.
13    Boot,
14    /// At every boot, and whenever an interface comes up (ifupdown, networkd-dispatcher):
15    /// bringing an interface down drops its routes, and some hypervisors (Vagrant) reconfigure
16    /// interfaces after every boot.
17    BootAndLinkUp,
18}
19
20/// `script` saved as `/etc/isoloom/<name>.sh` and run now and again as `trigger` says, by a
21/// systemd service (`isoloom-<name>`), `what` saying what it does.
22///
23/// `after`: the other such scripts it runs after at boot (routes through a trunk's interface
24/// after the trunks).
25///
26/// Without systemd (OpenRC: Alpine), it runs from `/etc/local.d` at boot instead.
27///
28/// The script always succeeds (a route whose link isn't up yet, a VLAN interface already
29/// there): an interface hook failing would fail the interface's bring-up. It runs again at the
30/// next boot or link change.
31#[must_use]
32pub fn at_boot(name: &str, what: &str, script: &str, trigger: Trigger, after: &[&str]) -> String {
33    let path = format!("/etc/isoloom/{name}.sh");
34    let hooks = match trigger {
35        Trigger::BootAndLinkUp => format!(
36            "for d in /etc/network/if-up.d /etc/networkd-dispatcher/routable.d; do\n  if [ -d \"$d\" ]; then ln -sf {path} \"$d/zz-isoloom-{name}\"; fi\ndone\n"
37        ),
38        Trigger::Boot => String::new(),
39    };
40    install(&Unit {
41        name,
42        what,
43        script: &format!("{script}exit 0\n"),
44        hooks: &hooks,
45        after,
46        requires: false,
47        service: &format!("Type=oneshot\nRemainAfterExit=yes\nExecStart=-{path}"),
48        openrc: &format!("mkdir -p /etc/local.d && ln -sf {path} /etc/local.d/isoloom-{name}.start\n"),
49        now: &format!("{path} || true"),
50    })
51}
52
53/// How a [`service`] runs.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Kind {
56    /// Runs to its end, now and at every boot; a failure fails the step that installs it
57    /// (a machine's `before` scripts).
58    Once,
59    /// Keeps running, restarted when it fails (a machine's own command).
60    Daemon,
61}
62
63/// `script` saved as `/etc/isoloom/<name>.sh` and run as the systemd service `isoloom-<name>`,
64/// started now and at every boot after (and requiring) `after`'s: [`at_boot`]'s script and
65/// unit, for a step whose failure matters (`Kind::Once`) or a process that keeps running
66/// (`Kind::Daemon`).
67///
68/// Without systemd (OpenRC: Alpine), it runs from `/etc/local.d` at boot (in the background
69/// for a daemon, its output in `/var/log/isoloom-<name>.log`), and now.
70#[must_use]
71pub fn service(name: &str, what: &str, script: &str, kind: Kind, after: &[&str]) -> String {
72    let path = format!("/etc/isoloom/{name}.sh");
73    let (service, now) = match kind {
74        Kind::Once => (format!("Type=oneshot\nRemainAfterExit=yes\nExecStart={path}"), path.clone()),
75        Kind::Daemon => (
76            format!("Type=simple\nExecStart={path}\nRestart=on-failure\nRestartSec=2"),
77            format!("nohup {path} >/var/log/isoloom-{name}.log 2>&1 &"),
78        ),
79    };
80    let start = format!("/etc/local.d/isoloom-{name}.start");
81    install(&Unit {
82        name,
83        what,
84        script,
85        hooks: "",
86        after,
87        requires: true,
88        service: &service,
89        openrc: &format!(
90            "mkdir -p /etc/local.d && printf '%s\\n' '#!/bin/sh' {} > {start} && chmod +x {start}\n",
91            quote(&now)
92        ),
93        now: &now,
94    })
95}
96
97/// A script kept on the machine and the service running it (see [`at_boot`] and [`service`]).
98struct Unit<'a> {
99    name: &'a str,
100    what: &'a str,
101    /// The script's lines, after its `#!/bin/sh` and its description.
102    script: &'a str,
103    /// Run once the script is written (interface hooks).
104    hooks: &'a str,
105    /// The other such services it starts after.
106    after: &'a [&'a str],
107    /// Whether it also requires them (it can't run without them).
108    requires: bool,
109    /// The unit's `[Service]` section.
110    service: &'a str,
111    /// How OpenRC runs it at boot.
112    openrc: &'a str,
113    /// How it runs now, without systemd.
114    now: &'a str,
115}
116
117/// The script written, then its systemd unit enabled and started, or OpenRC's way without
118/// systemd.
119fn install(u: &Unit) -> String {
120    let (name, what, path) = (u.name, u.what, format!("/etc/isoloom/{}.sh", u.name));
121    let unit = format!("isoloom-{name}.service");
122    let marker = name.to_uppercase();
123    let after: String = u.after.iter().map(|a| format!(" isoloom-{a}.service")).collect::<Vec<_>>().concat();
124    let requires = if u.requires && !after.is_empty() {
125        format!("\nRequires={}", after.trim_start())
126    } else {
127        String::new()
128    };
129    format!(
130        "mkdir -p /etc/isoloom\ncat > {path} <<'{marker}'\n#!/bin/sh\n# {what}\n{script}{marker}\nchmod +x {path}\n{hooks}\
131         if command -v systemctl >/dev/null; then\n\
132         cat > /etc/systemd/system/{unit} <<'UNIT'\n[Unit]\nDescription={what}\nAfter=network-online.target{after}\nWants=network-online.target{requires}\n\n\
133         [Service]\n{service}\n\n[Install]\nWantedBy=multi-user.target\nUNIT\n\
134         systemctl daemon-reload\nsystemctl enable {unit}\nsystemctl restart {unit}\n\
135         else\n\
136         {openrc}rc-update add local default >/dev/null 2>&1 || true\n{now}\n\
137         fi\n",
138        script = u.script,
139        hooks = u.hooks,
140        service = u.service,
141        openrc = u.openrc,
142        now = u.now,
143    )
144}
145
146#[cfg(test)]
147mod tests {
148    use super::{Kind, Trigger, at_boot, service};
149
150    #[test]
151    fn a_daemon_restarts_and_a_step_fails_its_setup() {
152        let sh = service("command", "The machine's command", "exec app\n", Kind::Daemon, &["before"]);
153        assert!(
154            sh.contains("cat > /etc/isoloom/command.sh <<'COMMAND'\n#!/bin/sh\n# The machine's command\nexec app\nCOMMAND\n"),
155            "{sh}"
156        );
157        assert!(sh.contains("Type=simple\nExecStart=/etc/isoloom/command.sh\nRestart=on-failure"), "{sh}");
158        assert!(
159            sh.contains("After=network-online.target isoloom-before.service\nWants=network-online.target\nRequires=isoloom-before.service\n"),
160            "{sh}"
161        );
162        assert!(sh.contains("nohup /etc/isoloom/command.sh >/var/log/isoloom-command.log 2>&1 &"), "{sh}");
163        let once = service("before", "Before", "sh x.sh\n", Kind::Once, &[]);
164        assert!(once.contains("Type=oneshot\nRemainAfterExit=yes\nExecStart=/etc/isoloom/before.sh\n"), "{once}");
165        assert!(!once.contains("ExecStart=-") && !once.contains("Requires="), "a failure is reported: {once}");
166    }
167
168    #[test]
169    fn the_script_is_kept_and_run_at_every_boot() {
170        let sh = at_boot(
171            "routes",
172            "Routes to the other networks",
173            "ip route replace 10.1.0.0/24 via 10.0.0.1\n",
174            Trigger::BootAndLinkUp,
175            &["trunks"],
176        );
177        assert!(
178            sh.contains("cat > /etc/isoloom/routes.sh <<'ROUTES'\n#!/bin/sh\n# Routes to the other networks\nip route replace"),
179            "{sh}"
180        );
181        assert!(
182            sh.contains("ExecStart=-/etc/isoloom/routes.sh") && sh.contains("systemctl enable isoloom-routes.service"),
183            "{sh}"
184        );
185        assert!(
186            sh.contains("zz-isoloom-routes") && sh.contains("After=network-online.target isoloom-trunks.service\n"),
187            "{sh}"
188        );
189        assert!(!sh.contains("Requires="), "a kept script runs after the others without needing them: {sh}");
190        let tc = at_boot("tc", "Link impairment", "tc qdisc\n", Trigger::Boot, &[]);
191        assert!(!tc.contains("if-up.d") && !tc.contains("Requires="), "{tc}");
192        assert!(tc.contains("tc qdisc\nexit 0\nTC\n"), "a kept script always succeeds: {tc}");
193    }
194
195    #[test]
196    fn it_runs_under_sh() {
197        let dir = std::env::temp_dir().join(format!("isoloom-boot-{}", std::process::id()));
198        // Only the file part: a test can't enable a systemd unit.
199        let sh = at_boot("t", "test", "echo ok\n", Trigger::Boot, &[]).replace("/etc/isoloom", &dir.display().to_string());
200        let file_part = sh.split("if command -v systemctl").next().unwrap_or_default().to_owned();
201        let out = std::process::Command::new("sh")
202            .arg("-c")
203            .arg(format!("{file_part}{}/t.sh", dir.display()))
204            .output();
205        std::fs::remove_dir_all(&dir).ok();
206        let out = out.expect("sh runs");
207        assert_eq!(String::from_utf8_lossy(&out.stdout), "ok\n");
208        assert!(out.status.success());
209    }
210}