Skip to main content

corundum_framework_package/
services.rs

1//! The app's background services across an update: the running ones are stopped before it
2//! is applied, noted in the data directory, and started again at the next start (or at
3//! once, when the apply fails).
4
5/// An app's background services, for [`PackageSpec::services`](crate::PackageSpec::services):
6/// [`Services::NONE`], or with the `service` feature a list given to `Services::new`.
7/// Functions, as a service's spec holds owned values a static cannot.
8#[derive(Clone, Copy, Debug)]
9pub struct Services {
10    #[cfg(feature = "service")]
11    list: &'static [fn() -> corundum_framework_service::ServiceSpec],
12}
13
14impl Services {
15    /// No background services.
16    pub const NONE: Services = Services {
17        #[cfg(feature = "service")]
18        list: &[],
19    };
20
21    /// These services: each function builds one's spec.
22    #[cfg(feature = "service")]
23    pub const fn new(
24        services: &'static [fn() -> corundum_framework_service::ServiceSpec],
25    ) -> Services {
26        Services { list: services }
27    }
28
29    /// Whether there are none.
30    #[cfg(feature = "service")]
31    pub(crate) fn is_empty(&self) -> bool {
32        self.list.is_empty()
33    }
34
35    /// Each service's spec, built.
36    #[cfg(feature = "service")]
37    pub(crate) fn specs(&self) -> impl Iterator<Item = corundum_framework_service::ServiceSpec> {
38        self.list.iter().map(|service| service())
39    }
40}
41
42/// What the updater asks of the app's services around an apply. The real one drives the
43/// OS's service manager; without the `service` feature, and in an updater's tests, `()`
44/// stands in and does nothing.
45pub(crate) trait ServiceDriver: Send + Sync {
46    /// Stops the running services and notes them, before an update is applied. An error
47    /// stops the update.
48    fn stop_running(&self) -> Result<(), String>;
49    /// Starts the services noted by [`stop_running`](Self::stop_running) again, as an
50    /// apply failed and nothing will start them otherwise.
51    fn restart_stopped(&self);
52}
53
54/// No services: nothing to stop or start.
55impl ServiceDriver for () {
56    fn stop_running(&self) -> Result<(), String> {
57        Ok(())
58    }
59
60    fn restart_stopped(&self) {}
61}
62
63#[cfg(feature = "service")]
64pub(crate) use with_services::*;
65
66#[cfg(not(feature = "service"))]
67/// The driver for `spec`'s services: none without the `service` feature.
68pub(crate) fn driver(
69    _spec: &'static crate::start::PackageSpec,
70) -> std::sync::Arc<dyn ServiceDriver> {
71    std::sync::Arc::new(())
72}
73
74#[cfg(feature = "service")]
75mod with_services {
76    use std::fs;
77    use std::path::{Path, PathBuf};
78    use std::sync::Arc;
79
80    use corundum_framework_platform::dirs::AppDirs;
81    use corundum_framework_service::{
82        Environment, ServiceManager, ServiceState, SystemManager, start, state, stop,
83    };
84
85    use super::ServiceDriver;
86    use crate::start::PackageSpec;
87
88    /// The file, in the app's data directory, naming the services an update stopped, one
89    /// per line.
90    pub(crate) const RESTART_MARKER: &str = "services-to-restart";
91
92    /// The driver for `spec`'s services, through the OS's service manager.
93    pub(crate) fn driver(spec: &'static PackageSpec) -> Arc<dyn ServiceDriver> {
94        Arc::new(System {
95            spec,
96            data: AppDirs::new(spec.app_name).map(|dirs| dirs.data().to_owned()),
97        })
98    }
99
100    /// The services of an app, driven through the OS's service manager.
101    struct System {
102        spec: &'static PackageSpec,
103        /// Where the marker goes: `None` when the OS gives no data directory.
104        data: Option<PathBuf>,
105    }
106
107    impl ServiceDriver for System {
108        fn stop_running(&self) -> Result<(), String> {
109            if self.spec.services.is_empty() {
110                return Ok(());
111            }
112            // Without the marker an update would leave the services stopped: better not to
113            // update.
114            let data = self
115                .data
116                .as_deref()
117                .ok_or("no data directory to note the services to restart in")?;
118            stop_running(self.spec, data, &Environment::detect(), &SystemManager).map(|_| ())
119        }
120
121        fn restart_stopped(&self) {
122            if let Some(data) = &self.data {
123                restart_stopped(self.spec, data, &Environment::detect(), &SystemManager);
124            }
125        }
126    }
127
128    /// Stops each of `spec`'s services that is running, after noting their names in
129    /// `data`'s [`RESTART_MARKER`]; returns them.
130    ///
131    /// The marker is written before anything is stopped, so a service is never left
132    /// stopped without a note to start it again. A service whose state cannot be read may
133    /// be running: it is stopped and noted all the same, rather than refusing every update
134    /// until the cause goes away. `stop` cannot fail ("not running" is stopped), so only a
135    /// marker that cannot be written refuses the update.
136    pub(crate) fn stop_running(
137        spec: &PackageSpec,
138        data: &Path,
139        env: &Environment,
140        manager: &dyn ServiceManager,
141    ) -> Result<Vec<&'static str>, String> {
142        let mut running = Vec::new();
143        for service in spec.services.specs() {
144            match state(&service, env, manager) {
145                Ok(ServiceState::Running) => running.push(service),
146                Ok(_) => {}
147                Err(e) => {
148                    tracing::warn!(
149                        "could not tell whether {} runs, stopping it anyway: {e}",
150                        service.app_name
151                    );
152                    running.push(service);
153                }
154            }
155        }
156        if running.is_empty() {
157            return Ok(Vec::new());
158        }
159        let names: String = running
160            .iter()
161            .map(|service| format!("{}\n", service.app_name))
162            .collect();
163        let marker = data.join(RESTART_MARKER);
164        fs::create_dir_all(data)
165            .and_then(|()| fs::write(&marker, names))
166            .map_err(|e| format!("could not write {}: {e}", marker.display()))?;
167        for service in &running {
168            // Never an error: see above.
169            let _ = stop(service, env, manager);
170        }
171        Ok(running.iter().map(|service| service.app_name).collect())
172    }
173
174    /// Starts each service named in `data`'s [`RESTART_MARKER`] that is still one of
175    /// `spec`'s, then removes the file.
176    ///
177    /// Never fails: a start that fails, a name the app no longer lists, or a file that is
178    /// garbage is logged, and the app goes on starting.
179    pub(crate) fn restart_stopped(
180        spec: &PackageSpec,
181        data: &Path,
182        env: &Environment,
183        manager: &dyn ServiceManager,
184    ) {
185        let marker = data.join(RESTART_MARKER);
186        let names = match fs::read(&marker) {
187            Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
188            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
189            Err(e) => {
190                tracing::warn!("could not read {}: {e}", marker.display());
191                String::new()
192            }
193        };
194        for name in names.lines().map(str::trim).filter(|name| !name.is_empty()) {
195            let Some(service) = spec
196                .services
197                .specs()
198                .find(|service| service.app_name == name)
199            else {
200                tracing::warn!(
201                    "{name:?} was stopped for an update but is no service of the app now"
202                );
203                continue;
204            };
205            if let Err(e) = start(&service, env, manager) {
206                tracing::warn!("could not start {name} again after the update: {e}");
207            }
208        }
209        if let Err(e) = fs::remove_file(&marker) {
210            tracing::warn!("could not remove {}: {e}", marker.display());
211        }
212    }
213
214    /// Uninstalls each of `spec`'s services, logging what fails: from the Windows uninstall
215    /// hook, which has 30 seconds and no window.
216    #[cfg(any(windows, test))]
217    pub(crate) fn uninstall_all(
218        spec: &PackageSpec,
219        env: &Environment,
220        manager: &dyn ServiceManager,
221    ) {
222        for service in spec.services.specs() {
223            if let Err(e) = corundum_framework_service::uninstall(&service, env, manager) {
224                tracing::warn!("could not uninstall {}: {e}", service.app_name);
225            }
226        }
227    }
228
229    #[cfg(test)]
230    mod tests {
231        use std::fs;
232        use std::path::PathBuf;
233
234        use corundum_framework_service::{
235            Environment, Os, RecordingManager, ServiceSpec, unit_path,
236        };
237
238        use super::*;
239        use crate::services::Services;
240
241        fn demo() -> ServiceSpec {
242            ServiceSpec {
243                app_name: "demo",
244                description: "Demo daemon".into(),
245                args: vec!["daemon".into()],
246                legacy_labels: vec![],
247                post_install: None,
248            }
249        }
250
251        fn idle() -> ServiceSpec {
252            ServiceSpec {
253                app_name: "idle",
254                ..demo()
255            }
256        }
257
258        fn package(services: &'static [fn() -> ServiceSpec]) -> PackageSpec {
259            PackageSpec {
260                app_name: "demo",
261                on_installed: None,
262                on_uninstalling: None,
263                before_apply: None,
264                cli: None,
265                services: Services::new(services),
266            }
267        }
268
269        fn home() -> PathBuf {
270            PathBuf::from("/home/user")
271        }
272
273        fn linux() -> Environment {
274            Environment {
275                euid: 1000,
276                os: Os::Linux,
277                home: Some(home()),
278            }
279        }
280
281        fn command(parts: &[&str]) -> Vec<String> {
282            parts.iter().map(|part| (*part).to_owned()).collect()
283        }
284
285        /// A manager on which both services are installed, `demo` running and `idle`
286        /// stopped.
287        fn installed() -> RecordingManager {
288            RecordingManager::new()
289                .with_unit(
290                    unit_path("demo", &home()),
291                    "[Service]\nExecStart=/usr/bin/demo daemon\n",
292                )
293                .with_unit(
294                    unit_path("idle", &home()),
295                    "[Service]\nExecStart=/usr/bin/idle daemon\n",
296                )
297                .with_file("/usr/bin/demo")
298                .with_file("/usr/bin/idle")
299                // The first matching answer wins: `idle`'s `is-active` is inactive.
300                .answering("idle", "inactive")
301                .answering("is-active", "active")
302        }
303
304        fn marker(data: &Path) -> PathBuf {
305            data.join(RESTART_MARKER)
306        }
307
308        #[test]
309        fn a_running_service_is_stopped_and_marked() {
310            let data = tempfile::tempdir().unwrap();
311            let manager = installed();
312            let stopped = stop_running(&package(&[demo]), data.path(), &linux(), &manager).unwrap();
313            assert_eq!(stopped, ["demo"]);
314            assert_eq!(fs::read_to_string(marker(data.path())).unwrap(), "demo\n");
315            assert!(manager.calls().commands.contains(&command(&[
316                "systemctl",
317                "--user",
318                "stop",
319                "demo"
320            ])));
321        }
322
323        #[test]
324        fn only_the_spec_s_running_services_are_stopped() {
325            let data = tempfile::tempdir().unwrap();
326            let manager = installed();
327            let stopped =
328                stop_running(&package(&[demo, idle]), data.path(), &linux(), &manager).unwrap();
329            assert_eq!(stopped, ["demo"]);
330            assert_eq!(fs::read_to_string(marker(data.path())).unwrap(), "demo\n");
331            let commands = manager.calls().commands;
332            assert!(!commands.contains(&command(&["systemctl", "--user", "stop", "idle"])));
333
334            // A service the app no longer lists (installed by an earlier version) is neither
335            // asked about nor stopped, and with no services no marker is written.
336            let data = tempfile::tempdir().unwrap();
337            let manager = installed();
338            let stopped = stop_running(&package(&[]), data.path(), &linux(), &manager).unwrap();
339            assert!(stopped.is_empty());
340            assert!(manager.calls().commands.is_empty());
341            assert!(!marker(data.path()).exists());
342        }
343
344        #[test]
345        fn the_marked_services_start_and_the_marker_goes() {
346            let data = tempfile::tempdir().unwrap();
347            fs::write(marker(data.path()), "demo\n").unwrap();
348            let manager = RecordingManager::new();
349            restart_stopped(&package(&[demo, idle]), data.path(), &linux(), &manager);
350            assert_eq!(
351                manager.calls().commands,
352                [command(&["systemctl", "--user", "start", "demo"])]
353            );
354            assert!(!marker(data.path()).exists());
355        }
356
357        #[test]
358        fn a_marker_naming_an_unknown_service_is_dropped() {
359            let data = tempfile::tempdir().unwrap();
360            fs::write(marker(data.path()), b"gone\n\xff\n").unwrap();
361            let manager = RecordingManager::new();
362            restart_stopped(&package(&[demo]), data.path(), &linux(), &manager);
363            assert!(manager.calls().commands.is_empty());
364            assert!(!marker(data.path()).exists());
365
366            // No marker at all: nothing to do, and nothing goes wrong.
367            restart_stopped(&package(&[demo]), data.path(), &linux(), &manager);
368            assert!(manager.calls().commands.is_empty());
369        }
370
371        #[test]
372        fn a_failed_start_is_logged_and_the_marker_still_goes() {
373            let data = tempfile::tempdir().unwrap();
374            fs::write(marker(data.path()), "demo\nidle\n").unwrap();
375            let manager = RecordingManager::failing_on("start");
376            restart_stopped(&package(&[demo, idle]), data.path(), &linux(), &manager);
377            // A failure starting one does not keep the next from starting.
378            assert_eq!(manager.calls().commands.len(), 2);
379            assert!(!marker(data.path()).exists());
380        }
381
382        #[test]
383        fn a_service_whose_state_cannot_be_read_is_stopped_and_marked() {
384            // Without a home directory the unit cannot be found: the service may be
385            // running, so it is stopped and marked, and the update goes on.
386            let data = tempfile::tempdir().unwrap();
387            let env = Environment {
388                home: None,
389                ..linux()
390            };
391            let manager = installed();
392            let stopped = stop_running(&package(&[demo]), data.path(), &env, &manager).unwrap();
393            assert_eq!(stopped, ["demo"]);
394            assert_eq!(fs::read_to_string(marker(data.path())).unwrap(), "demo\n");
395            assert!(manager.calls().commands.contains(&command(&[
396                "systemctl",
397                "--user",
398                "stop",
399                "demo"
400            ])));
401        }
402
403        #[test]
404        fn a_marker_that_cannot_be_written_stops_nothing() {
405            // The marker is written first: one that cannot be written fails the stop
406            // before anything is stopped, so no service is left stopped and unnoted.
407            let data = tempfile::tempdir().unwrap();
408            let unwritable = data.path().join("file");
409            fs::write(&unwritable, "").unwrap();
410            let manager = installed();
411            let err = stop_running(&package(&[demo]), &unwritable, &linux(), &manager).unwrap_err();
412            assert!(err.contains(RESTART_MARKER), "{err}");
413            assert!(!manager.calls().commands.contains(&command(&[
414                "systemctl",
415                "--user",
416                "stop",
417                "demo"
418            ])));
419        }
420
421        #[test]
422        fn uninstall_all_uninstalls_each_service() {
423            let manager = RecordingManager::new();
424            uninstall_all(&package(&[demo, idle]), &linux(), &manager);
425            assert_eq!(
426                manager.calls().removed,
427                [unit_path("demo", &home()), unit_path("idle", &home())]
428            );
429        }
430    }
431}