Skip to main content

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}