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("&"),
262 '<' => out.push_str("<"),
263 '>' => out.push_str(">"),
264 '"' => out.push_str("""),
265 '\'' => out.push_str("'"),
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}