running_process/boot_autostart/mod.rs
1//! Per-OS boot autostart for the `runpm` daemon (Phase 4 of #222 — #427).
2//!
3//! What this module decides is *enrolment*: that the thing being registered is
4//! the runpm daemon, what it is called on each host, and that it starts with
5//! `start` and stops with `stop`. How a host registers a program to run at
6//! login — systemd user unit, launchd agent, Task Scheduler ONLOGON task — is
7//! [`crate::platform::autostart`]'s business, and the names below are the only
8//! part of it that is ours.
9//!
10//! The trio is unchanged:
11//! - `install(daemon_binary)` — write the unit/plist/task and arm the init
12//! system. Returns the unit path that was written.
13//! - `uninstall()` — disarm the init system and remove the unit.
14//! - `render_unit(daemon_binary)` — render the unit text without touching
15//! the filesystem. Used by fixture tests and by `install`.
16//!
17//! Tests never call `install` — they assert against `render_unit` output to
18//! avoid mutating the runner's init system.
19
20use std::fmt;
21use std::path::{Path, PathBuf};
22
23use crate::platform::autostart::{AutostartError, AutostartProgram};
24
25/// Plain stem: the systemd unit name and the Task Scheduler task name.
26const IDENTIFIER: &str = "runpm-daemon";
27
28/// Reverse-DNS label, as launchd requires.
29const LABEL: &str = "com.zackees.runpm-daemon";
30
31/// What an operator reading their init system should see.
32const DESCRIPTION: &str = "runpm process supervisor (running-process daemon)";
33
34/// Typed wrapper around the path where the unit/plist/task was written.
35/// Wrapped so callers can't accidentally pass it as a generic `PathBuf`
36/// and lose the "this is the autostart artifact" intent.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct UnitPath(pub PathBuf);
39
40impl UnitPath {
41 pub fn as_path(&self) -> &Path {
42 &self.0
43 }
44
45 pub fn into_inner(self) -> PathBuf {
46 self.0
47 }
48}
49
50impl fmt::Display for UnitPath {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 write!(f, "{}", self.0.display())
53 }
54}
55
56/// Anything that can go wrong installing/uninstalling boot autostart.
57#[derive(Debug)]
58pub enum BootAutostartError {
59 /// Could not resolve where to write the unit file.
60 Resolve(String),
61 /// Filesystem write/remove failed.
62 Io(std::io::Error),
63 /// The init-system CLI (`systemctl`, `launchctl`, `schtasks`) failed.
64 InitSystem(String),
65 /// This OS has no autostart backend.
66 ///
67 /// Retained for compatibility. It is no longer produced: the platform
68 /// facade is built for exactly the hosts this crate compiles on, so a host
69 /// without a backend fails to build rather than failing at runtime.
70 Unsupported(String),
71}
72
73impl fmt::Display for BootAutostartError {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 match self {
76 Self::Resolve(detail) => write!(f, "could not resolve autostart location: {detail}"),
77 Self::Io(error) => write!(f, "autostart file operation failed: {error}"),
78 Self::InitSystem(detail) => write!(f, "init system rejected autostart: {detail}"),
79 Self::Unsupported(os) => write!(f, "boot autostart is not supported on {os}"),
80 }
81 }
82}
83
84impl std::error::Error for BootAutostartError {}
85
86impl From<std::io::Error> for BootAutostartError {
87 fn from(error: std::io::Error) -> Self {
88 Self::Io(error)
89 }
90}
91
92impl From<AutostartError> for BootAutostartError {
93 fn from(error: AutostartError) -> Self {
94 match error {
95 AutostartError::Resolve(detail) => Self::Resolve(detail),
96 AutostartError::Io(io) => Self::Io(io),
97 AutostartError::InitSystem(detail) => Self::InitSystem(detail),
98 }
99 }
100}
101
102/// The runpm daemon, as this host should know it.
103fn runpm_daemon(daemon_binary: &Path) -> AutostartProgram<'_> {
104 AutostartProgram {
105 identifier: IDENTIFIER,
106 label: LABEL,
107 description: DESCRIPTION,
108 program: daemon_binary,
109 start_argument: "start",
110 stop_argument: "stop",
111 }
112}
113
114/// Install boot autostart for the running-process daemon. Returns the
115/// path where the unit/plist/task was written.
116pub fn install(daemon_binary: &Path) -> Result<UnitPath, BootAutostartError> {
117 let written = crate::platform::autostart::register(&runpm_daemon(daemon_binary))?;
118 Ok(UnitPath(written))
119}
120
121/// Uninstall boot autostart for the running-process daemon.
122pub fn uninstall() -> Result<(), BootAutostartError> {
123 // The path is not consulted for removal, but the names are, so the same
124 // description is handed over to keep one definition of what runpm is.
125 Ok(crate::platform::autostart::unregister(&runpm_daemon(
126 Path::new(""),
127 ))?)
128}
129
130/// Render the unit/plist/task text for the current OS without touching
131/// the filesystem. Test seam used by `tests/runpm/runpm_boot_autostart_fixtures.rs`.
132pub fn render_unit(daemon_binary: &Path) -> String {
133 crate::platform::autostart::render_registration(&runpm_daemon(daemon_binary))
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 /// Whatever this host writes, it is identifiably runpm's, and it names the
141 /// binary it was handed.
142 ///
143 /// Which of the three names appears is the host's choice, not ours: the
144 /// systemd unit carries the identifier in its *filename* and only the
145 /// description in its body, launchd puts the reverse-DNS label in the
146 /// plist, and Task Scheduler puts the plain stem in `/TN`. So this asserts
147 /// what is true of all three -- an operator reading the registration can
148 /// tell whose it is -- rather than picking one host's spelling.
149 #[test]
150 fn the_rendered_registration_is_for_the_runpm_daemon() {
151 let rendered = render_unit(Path::new("/usr/local/bin/running-process-daemon"));
152 assert!(
153 rendered.contains("running-process-daemon"),
154 "must name the daemon binary: {rendered}"
155 );
156 assert!(
157 rendered.contains("runpm"),
158 "an operator must be able to tell whose registration this is: {rendered}"
159 );
160 }
161
162 /// Every facade failure maps onto the variant an operator acts on, so the
163 /// distinction survives the hop across the boundary.
164 #[test]
165 fn facade_errors_keep_their_kind() {
166 assert!(matches!(
167 BootAutostartError::from(AutostartError::Resolve("no HOME".into())),
168 BootAutostartError::Resolve(_)
169 ));
170 assert!(matches!(
171 BootAutostartError::from(AutostartError::InitSystem("schtasks".into())),
172 BootAutostartError::InitSystem(_)
173 ));
174 assert!(matches!(
175 BootAutostartError::from(AutostartError::Io(std::io::Error::other("disk"))),
176 BootAutostartError::Io(_)
177 ));
178 }
179}