Skip to main content

choir_cli/
supervise.rs

1//! `choir node install` — hand the node to whatever supervises services
2//! on this machine, so it survives a logout, a crash and a reboot.
3//!
4//! # The unit names a command, not a configuration
5//!
6//! The rendered unit runs `choir node serve`, and nothing else. Every
7//! path and flag the daemon needs is derived at start time from the
8//! state directory, which means the supervision file never has to be
9//! re-rendered because a flag changed — the failure mode of the shell
10//! scripts this replaces, where a plist that lints clean and restarts
11//! cleanly can still launch yesterday's arguments.
12//!
13//! # Examples
14//!
15//! ```
16//! use choir_cli::supervise::Supervisor;
17//!
18//! // Whichever this machine has; the rendering is the same shape either way.
19//! let unit = Supervisor::Launchd.render(
20//!     std::path::Path::new("/usr/local/bin/choir"),
21//!     std::path::Path::new("/home/example/.choir"),
22//!     8417,
23//!     &[],
24//! );
25//! assert!(unit.contains("choir"));
26//! ```
27
28use std::path::{Path, PathBuf};
29
30/// The service manager this machine runs.
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum Supervisor {
33    /// macOS, per-user LaunchAgent.
34    Launchd,
35    /// Linux, `systemd --user`.
36    Systemd,
37}
38
39/// The reverse-DNS label and unit stem. One name, used by both, so the
40/// thing to grep for is the same wherever it is running.
41pub const LABEL: &str = "com.choir.node";
42
43impl Supervisor {
44    /// What this machine has, if it has one.
45    ///
46    /// By operating system rather than by probing for the binary: a mac
47    /// without `launchctl` is a broken mac, and guessing `systemd` there
48    /// would produce a unit file nothing will ever read.
49    #[must_use]
50    pub fn detect() -> Option<Supervisor> {
51        match std::env::consts::OS {
52            "macos" => Some(Supervisor::Launchd),
53            "linux" => Some(Supervisor::Systemd),
54            _ => None,
55        }
56    }
57
58    /// Where the unit file belongs under `home`.
59    #[must_use]
60    pub fn unit_path(self, home: &Path) -> PathBuf {
61        match self {
62            Supervisor::Launchd => home.join(format!("Library/LaunchAgents/{LABEL}.plist")),
63            Supervisor::Systemd => home.join(".config/systemd/user/choir-node.service"),
64        }
65    }
66
67    /// The unit file's contents.
68    ///
69    /// `extra` is forwarded to the daemon after `--`, the same spelling
70    /// a person would type, so a supervised node and a hand-started one
71    /// are the same command with the same arguments.
72    #[must_use]
73    pub fn render(self, exe: &Path, state: &Path, port: u16, extra: &[String]) -> String {
74        let log = state.join("node.log");
75        match self {
76            Supervisor::Launchd => {
77                let mut argv = String::new();
78                for arg in self.argv(exe, state, port, extra) {
79                    argv.push_str(&format!("    <string>{}</string>\n", xml_escape(&arg)));
80                }
81                format!(
82                    "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
83                     <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
84                     \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
85                     <plist version=\"1.0\">\n\
86                     <dict>\n  \
87                       <key>Label</key><string>{LABEL}</string>\n  \
88                       <key>ProgramArguments</key>\n  <array>\n{argv}  </array>\n  \
89                       <key>RunAtLoad</key><true/>\n  \
90                       <key>KeepAlive</key><true/>\n  \
91                       <key>StandardOutPath</key><string>{log}</string>\n  \
92                       <key>StandardErrorPath</key><string>{log}</string>\n\
93                     </dict>\n\
94                     </plist>\n",
95                    log = xml_escape(&log.display().to_string()),
96                )
97            }
98            Supervisor::Systemd => {
99                let argv: Vec<String> = self
100                    .argv(exe, state, port, extra)
101                    .into_iter()
102                    .map(|a| shell_quote(&a))
103                    .collect();
104                format!(
105                    "[Unit]\n\
106                     Description=choir node\n\
107                     After=network.target\n\
108                     \n\
109                     [Service]\n\
110                     ExecStart={exec}\n\
111                     Restart=always\n\
112                     RestartSec=2\n\
113                     StandardOutput=append:{log}\n\
114                     StandardError=append:{log}\n\
115                     \n\
116                     [Install]\n\
117                     WantedBy=default.target\n",
118                    exec = argv.join(" "),
119                    log = log.display(),
120                )
121            }
122        }
123    }
124
125    /// The command the unit runs, as argv.
126    ///
127    /// Shared by both renderings so the two supervisors cannot come to
128    /// disagree about what a supervised node is.
129    #[must_use]
130    pub fn argv(self, exe: &Path, state: &Path, port: u16, extra: &[String]) -> Vec<String> {
131        let mut argv = vec![
132            exe.display().to_string(),
133            "node".to_string(),
134            "serve".to_string(),
135            "--state".to_string(),
136            state.display().to_string(),
137            "--port".to_string(),
138            port.to_string(),
139        ];
140        if !extra.is_empty() {
141            argv.push("--".to_string());
142            argv.extend_from_slice(extra);
143        }
144        argv
145    }
146
147    /// The commands that load, stop and remove the unit, in order.
148    ///
149    /// Returned as data rather than run here so a test can assert on
150    /// what would be run without a service manager being involved, and
151    /// so the caller can print them when it refuses.
152    #[must_use]
153    pub fn commands(self, action: Action, home: &Path) -> Vec<Vec<String>> {
154        let unit = self.unit_path(home).display().to_string();
155        let uid = users_id();
156        match (self, action) {
157            // `bootout` then `bootstrap`, never `kickstart -k`: a restart
158            // reloads launchd's *cached* job definition, so a unit whose
159            // arguments changed can restart cleanly and still run the old
160            // ones.
161            (Supervisor::Launchd, Action::Install) => vec![
162                vec![
163                    "launchctl".into(),
164                    "bootout".into(),
165                    format!("gui/{uid}/{LABEL}"),
166                ],
167                vec![
168                    "launchctl".into(),
169                    "bootstrap".into(),
170                    format!("gui/{uid}"),
171                    unit,
172                ],
173            ],
174            (Supervisor::Launchd, Action::Stop) => vec![vec![
175                "launchctl".into(),
176                "bootout".into(),
177                format!("gui/{uid}/{LABEL}"),
178            ]],
179            (Supervisor::Launchd, Action::Uninstall) => vec![vec![
180                "launchctl".into(),
181                "bootout".into(),
182                format!("gui/{uid}/{LABEL}"),
183            ]],
184            (Supervisor::Systemd, Action::Install) => vec![
185                vec!["systemctl".into(), "--user".into(), "daemon-reload".into()],
186                vec![
187                    "systemctl".into(),
188                    "--user".into(),
189                    "enable".into(),
190                    "--now".into(),
191                    "choir-node.service".into(),
192                ],
193            ],
194            (Supervisor::Systemd, Action::Stop) => vec![vec![
195                "systemctl".into(),
196                "--user".into(),
197                "stop".into(),
198                "choir-node.service".into(),
199            ]],
200            (Supervisor::Systemd, Action::Uninstall) => vec![vec![
201                "systemctl".into(),
202                "--user".into(),
203                "disable".into(),
204                "--now".into(),
205                "choir-node.service".into(),
206            ]],
207        }
208    }
209}
210
211/// What `commands` should produce.
212#[derive(Clone, Copy, Debug, Eq, PartialEq)]
213pub enum Action {
214    /// Load the unit and start it now.
215    Install,
216    /// Stop it for this boot, leaving the unit in place.
217    Stop,
218    /// Stop it and stop it coming back.
219    Uninstall,
220}
221
222/// Whether this executable is sitting in a cargo build directory.
223///
224/// A unit pointing into one breaks at the next `cargo clean`, and it
225/// breaks at reboot — the moment nobody is watching. Detected by the
226/// profile directory cargo actually writes into rather than by looking
227/// for a component called `target`: `CARGO_TARGET_DIR` renames that one,
228/// and the first version of this check was defeated by a target
229/// directory called anything else.
230#[must_use]
231pub fn in_build_directory(exe: &Path) -> bool {
232    let Some(parent) = exe.parent().and_then(Path::file_name) else {
233        return false;
234    };
235    parent == "debug" || parent == "release"
236}
237
238/// This process's user id, for the `gui/<uid>` domain launchctl wants.
239fn users_id() -> String {
240    // `id -u` rather than a libc call: this workspace adds dependencies
241    // reluctantly, and the answer is a number a subprocess already
242    // prints. It is asked once per command, not per request.
243    std::process::Command::new("id")
244        .arg("-u")
245        .output()
246        .ok()
247        .filter(|out| out.status.success())
248        .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
249        .unwrap_or_default()
250}
251
252/// The five characters XML cannot carry raw.
253///
254/// A path with `&` in it is legal on every filesystem this runs on, and
255/// a plist that contains one raw is a plist `launchctl` refuses to
256/// parse — with an error naming the file rather than the character.
257fn xml_escape(text: &str) -> String {
258    let mut out = String::with_capacity(text.len());
259    for c in text.chars() {
260        match c {
261            '&' => out.push_str("&amp;"),
262            '<' => out.push_str("&lt;"),
263            '>' => out.push_str("&gt;"),
264            '"' => out.push_str("&quot;"),
265            '\'' => out.push_str("&apos;"),
266            _ => out.push(c),
267        }
268    }
269    out
270}
271
272/// Quotes one `ExecStart` word.
273///
274/// systemd splits `ExecStart` on whitespace, so a state directory with a
275/// space in it becomes two arguments and the node starts against a root
276/// that does not exist.
277fn shell_quote(word: &str) -> String {
278    if !word.is_empty()
279        && word
280            .chars()
281            .all(|c| c.is_ascii_alphanumeric() || "-_./:=".contains(c))
282    {
283        return word.to_string();
284    }
285    format!("\"{}\"", word.replace('\\', "\\\\").replace('"', "\\\""))
286}