Skip to main content

isoloom_render/configure/
units.rs

1//! A service Isoloom defines (a `service` step with `run`) on a Linux VM: a systemd unit, or an
2//! OpenRC script on Alpine. Each runs the step's program in the foreground (OpenRC backgrounds
3//! it itself), as its user, in its folder, restarted when it fails.
4
5use isoloom_plan::configure::Family;
6use isoloom_spec::shell::quote;
7use isoloom_spec::syntax::{AbsPath, Argv, UnitName, UserName};
8
9/// A Linux service unit's file for `family`: the same text the shell renderer writes and the
10/// Ansible backend copies, so a defined service runs the same under either carrier. An OpenRC
11/// script on Alpine, else a systemd unit.
12#[must_use]
13pub fn linux_unit(family: Family, name: &UnitName, run: &Argv, user: Option<&UserName>, cwd: Option<&AbsPath>) -> String {
14    linux(family, name, run, user, cwd)
15}
16
17/// The unit's file for `family`: an OpenRC script on Alpine, else a systemd unit.
18#[must_use]
19pub(super) fn linux(family: Family, name: &UnitName, run: &Argv, user: Option<&UserName>, cwd: Option<&AbsPath>) -> String {
20    if family == Family::Alpine {
21        openrc(name, run, user, cwd)
22    } else {
23        systemd(name, run, user, cwd)
24    }
25}
26
27/// A systemd unit.
28fn systemd(name: &UnitName, run: &Argv, user: Option<&UserName>, cwd: Option<&AbsPath>) -> String {
29    let exec: Vec<String> = run.args().iter().map(|a| systemd_word(a)).collect();
30    let user = user.map(|u| format!("User={u}\n")).unwrap_or_default();
31    // A bare path: systemd doesn't unquote `WorkingDirectory=`, but expands its `%` specifiers.
32    let cwd = cwd.map(|d| format!("WorkingDirectory={}\n", d.as_str().replace('%', "%%"))).unwrap_or_default();
33    format!(
34        "[Unit]\nDescription={name}, defined by Isoloom\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nExecStart={}\n{user}{cwd}Restart=on-failure\n\n[Install]\nWantedBy=multi-user.target\n",
35        exec.join(" ")
36    )
37}
38
39/// One word of a systemd command line, quoted: systemd unescapes C-style in double quotes and
40/// expands `%` specifiers and `$` variables, each doubled to stay literal.
41#[must_use]
42pub fn systemd_word(s: &str) -> String {
43    let mut out = String::with_capacity(s.len() + 2);
44    out.push('"');
45    for c in s.chars() {
46        match c {
47            '\\' => out.push_str("\\\\"),
48            '"' => out.push_str("\\\""),
49            '\n' => out.push_str("\\n"),
50            '%' => out.push_str("%%"),
51            '$' => out.push_str("$$"),
52            _ => out.push(c),
53        }
54    }
55    out.push('"');
56    out
57}
58
59/// An OpenRC script: OpenRC runs `command` with `command_args` (read by `sh`, so each word is
60/// quoted for it) in the background.
61fn openrc(name: &UnitName, run: &Argv, user: Option<&UserName>, cwd: Option<&AbsPath>) -> String {
62    let args: Vec<String> = run.args().iter().skip(1).map(|a| quote(a)).collect();
63    let user = user.map(|u| format!("command_user={}\n", quote(u.as_str()))).unwrap_or_default();
64    let cwd = cwd.map(|d| format!("directory={}\n", quote(d.as_str()))).unwrap_or_default();
65    format!(
66        "#!/sbin/openrc-run\ndescription={}\ncommand={}\ncommand_args={}\ncommand_background=true\npidfile=\"/run/${{RC_SVCNAME}}.pid\"\n{user}{cwd}\ndepend() {{\n\tneed net\n}}\n",
67        quote(&format!("{name}, defined by Isoloom")),
68        quote(run.program()),
69        quote(&args.join(" "))
70    )
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    fn argv(args: &[&str]) -> Argv {
78        Argv::try_from(args.iter().map(|a| (*a).to_owned()).collect::<Vec<_>>()).unwrap()
79    }
80
81    #[test]
82    fn a_systemd_unit_keeps_every_word_literal() {
83        let unit = linux(
84            Family::Debian,
85            &"api".parse().unwrap(),
86            &argv(&["/srv/api", "--name", "a \"b\" $HOME 100%"]),
87            "api".parse().ok().as_ref(),
88            "/srv".parse().ok().as_ref(),
89        );
90        assert!(
91            unit.contains("ExecStart=\"/srv/api\" \"--name\" \"a \\\"b\\\" $$HOME 100%%\"\nUser=api\nWorkingDirectory=/srv\nRestart=on-failure\n"),
92            "{unit}"
93        );
94        assert!(unit.ends_with("[Install]\nWantedBy=multi-user.target\n"));
95    }
96
97    /// systemd takes `WorkingDirectory=` as a bare path (quoted, it isn't absolute: a real
98    /// VirtualBox run refused the unit), with `%` its only specifier.
99    #[test]
100    fn a_working_directory_is_a_bare_path_its_percent_signs_doubled() {
101        let unit = linux(
102            Family::Debian,
103            &"api".parse().unwrap(),
104            &argv(&["/srv/api"]),
105            None,
106            "/srv/my app/100%".parse().ok().as_ref(),
107        );
108        assert!(unit.contains("\nWorkingDirectory=/srv/my app/100%%\n"), "{unit}");
109    }
110
111    #[test]
112    fn alpine_gets_an_openrc_script_its_shell_reads_back() {
113        let unit = linux(Family::Alpine, &"api".parse().unwrap(), &argv(&["/srv/api", "it's"]), None, None);
114        assert!(unit.starts_with("#!/sbin/openrc-run\n"), "{unit}");
115        assert!(
116            unit.contains("command='/srv/api'\ncommand_args=''\\''it'\\''\\'\\'''\\''s'\\'''\ncommand_background=true\n"),
117            "{unit}"
118        );
119    }
120}