1use isoloom_spec::shell::quote;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Trigger {
12 Boot,
14 BootAndLinkUp,
18}
19
20#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Kind {
56 Once,
59 Daemon,
61}
62
63#[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
97struct Unit<'a> {
99 name: &'a str,
100 what: &'a str,
101 script: &'a str,
103 hooks: &'a str,
105 after: &'a [&'a str],
107 requires: bool,
109 service: &'a str,
111 openrc: &'a str,
113 now: &'a str,
115}
116
117fn 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 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}