Skip to main content

qcode/service/
stop.rs

1//! Stopping the containers no QCode owns any more.
2//!
3//! This is the whole work of the background service, and the one a closing QCode does itself when
4//! the service is not installed. It runs once nothing can be using the containers: the caller
5//! makes sure of that before calling it. What it does is spelled out as engine commands and run
6//! through a runner it is handed, so every decision — what is listed, what is stopped, what stays
7//! listed — is checked in tests with no engine anywhere near them.
8
9use std::io;
10use std::path::Path;
11
12use qframe::diagnostics::Diagnostic;
13
14use crate::engine::run::EngineError;
15use crate::engine::{Container, ContainerState, Engine, EngineCommand, EngineKind};
16use crate::store::{Loaded, OnClose, PREFIX, Registered, Registry};
17
18/// What came of looking at the list of QCode's containers.
19#[derive(Debug, Clone, PartialEq)]
20pub enum Reaped {
21    /// The person asked for the containers to be left running, so nothing was touched and the
22    /// list was kept.
23    Kept,
24    /// The list could not be read cleanly, so nothing was stopped and the list was left as it is
25    /// for the person to see. These are its problems.
26    Broken(Vec<Diagnostic>),
27    /// The containers were looked at, and those that ran were stopped.
28    Done(Stopping),
29}
30
31/// What stopping did, container by container.
32#[derive(Debug, Clone, Default, PartialEq)]
33pub struct Stopping {
34    /// The containers that were running and are stopped now.
35    pub stopped: Vec<String>,
36    /// The containers the engine would not stop, with its own words.
37    pub failed: Vec<(String, String)>,
38    /// The engines that could not be asked which of their containers run, with their words.
39    pub unreachable: Vec<(EngineKind, String)>,
40    /// What stays listed: every container that could not be stopped or whose engine could not be
41    /// asked, so the next run tries again. A container that was not running, or is gone, leaves
42    /// the list: there is nothing of it left to stop.
43    pub remaining: Registry,
44}
45
46/// The commands that stop the containers of `registered` that `listing` shows as up in `engine`,
47/// one entry per container with its name and what is run for it, in that order.
48///
49/// Only names with QCode's own prefix are ever considered, whatever the list says: the list is a
50/// file on disk and could hold anything by the time it is read.
51///
52/// A container the engine lists as paused is woken and then stopped. A stop of a paused container is
53/// refused by the engine, so a QCode that crashed with one frozen would leave it up for ever: it
54/// stays in the list, and every run after this one refuses it in the same way.
55#[must_use]
56pub fn stop_commands(
57    engine: &Engine,
58    registered: &[Registered],
59    listing: &[Container],
60) -> Vec<(String, Vec<EngineCommand>)> {
61    let state = |name: &str| listing.iter().find(|container| container.name == name).map(|container| &container.state);
62    registered
63        .iter()
64        .filter(|known| known.engine == engine.kind() && known.name.starts_with(PREFIX))
65        .filter(|known| state(&known.name).is_some_and(|state| state.is_running() || *state == ContainerState::Paused))
66        .map(|known| {
67            let mut commands = Vec::new();
68            if state(&known.name) == Some(&ContainerState::Paused) {
69                commands.push(engine.unpause_container(&known.name));
70            }
71            commands.push(engine.stop_container(&known.name));
72            (known.name.clone(), commands)
73        })
74        .collect()
75}
76
77/// Stops every container of `registry` that is running, when `on_close` says to, and answers
78/// what came of it.
79///
80/// `engine_for` finds the engine of a kind, or `None` when it is not there; `run` runs one
81/// command and answers what it printed. The real caller passes [`crate::engine::detect`] and
82/// [`crate::engine::run::capture`]; a test passes its own. Stopping is `stop`, never `rm`: the
83/// next QCode starts the same container again with everything in it.
84pub fn stop_orphans(
85    registry: &Loaded<Registry>,
86    on_close: OnClose,
87    engine_for: &dyn Fn(EngineKind) -> Option<Engine>,
88    run: &mut dyn FnMut(&EngineCommand) -> Result<String, EngineError>,
89) -> Reaped {
90    if on_close == OnClose::Keep {
91        return Reaped::Kept;
92    }
93    if !registry.is_clean() {
94        return Reaped::Broken(registry.diagnostics.clone());
95    }
96    let mut stopping = Stopping::default();
97    for kind in [EngineKind::Podman, EngineKind::Docker] {
98        let registered: Vec<Registered> =
99            registry.value.containers.iter().filter(|known| known.engine == kind).cloned().collect();
100        if registered.is_empty() {
101            continue;
102        }
103        let keep_all = |stopping: &mut Stopping| {
104            for known in &registered {
105                stopping.remaining.add(&known.name, known.engine);
106            }
107        };
108        let Some(engine) = engine_for(kind) else {
109            stopping.unreachable.push((kind, String::new()));
110            keep_all(&mut stopping);
111            continue;
112        };
113        let listing = match run(&engine.list_containers()) {
114            Ok(output) => Container::parse_list(&output),
115            Err(error) => {
116                stopping.unreachable.push((kind, said(&error)));
117                keep_all(&mut stopping);
118                continue;
119            }
120        };
121        for (name, commands) in stop_commands(&engine, &registered, &listing) {
122            let mut refused = None;
123            for command in &commands {
124                if let Err(error) = run(command) {
125                    refused = Some(said(&error));
126                    break;
127                }
128            }
129            match refused {
130                // Whatever could not be stopped stays listed for the next time, and a container the
131                // engine would not even wake is one of those: it is still up, and still QCode's.
132                Some(reason) => {
133                    stopping.remaining.add(&name, kind);
134                    stopping.failed.push((name, reason));
135                }
136                None => stopping.stopped.push(name),
137            }
138        }
139    }
140    Reaped::Done(stopping)
141}
142
143/// Reads the list at `path`, stops what [`stop_orphans`] says to, and writes back what stays
144/// listed.
145///
146/// A list that was kept or could not be read is not written at all: the first because the person
147/// asked for the containers to stay, the second because it is the person's to see as it is. Nor
148/// is a list that comes out holding what it held: a write to this file is what starts the
149/// service, so a run that wrote it back unchanged would start the next one, and that the next.
150///
151/// # Errors
152///
153/// Returns the I/O error when what stays listed cannot be written.
154pub fn reap(
155    path: &Path,
156    on_close: OnClose,
157    engine_for: &dyn Fn(EngineKind) -> Option<Engine>,
158    run: &mut dyn FnMut(&EngineCommand) -> Result<String, EngineError>,
159) -> io::Result<Reaped> {
160    let registry = Registry::load(path);
161    // Nothing was ever started, or everything was stopped the last time: there is nothing to ask
162    // any engine about and nothing to write.
163    if registry.is_clean() && registry.value.is_empty() {
164        return Ok(Reaped::Done(Stopping::default()));
165    }
166    let reaped = stop_orphans(&registry, on_close, engine_for, run);
167    if let Reaped::Done(stopping) = &reaped
168        && !same_containers(&stopping.remaining, &registry.value)
169    {
170        Registry::replace(path, &stopping.remaining)?;
171    }
172    Ok(reaped)
173}
174
175/// Whether `a` and `b` list the same containers, in whatever order: what stays listed is
176/// gathered engine by engine, so its order can differ from the file's without anything changing.
177fn same_containers(a: &Registry, b: &Registry) -> bool {
178    a.containers.len() == b.containers.len() && a.containers.iter().all(|known| b.containers.contains(known))
179}
180
181/// What the engine said, in one line of words.
182fn said(error: &EngineError) -> String {
183    match error {
184        EngineError::NotRunnable { command, error } => format!("{}: {error}", command.program.display()),
185        EngineError::Failed(failure) => failure.output.trim().to_owned(),
186        EngineError::Cancelled { command } => command.program.display().to_string(),
187        EngineError::TimedOut { command, after } => crate::engine::run::timed_out(command, *after),
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use std::path::PathBuf;
194
195    use super::*;
196    use crate::engine::run::Failure;
197
198    /// A binary that cannot be run: the tests hand their own runner, so it is only a name.
199    const NO_ENGINE: &str = "/nonexistent/qcode-test-engine";
200
201    fn engine(kind: EngineKind) -> Option<Engine> {
202        Some(Engine::new(kind, NO_ENGINE))
203    }
204
205    fn args(command: &EngineCommand) -> Vec<String> {
206        command.args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect()
207    }
208
209    fn registry(names: &[(&str, EngineKind)]) -> Loaded<Registry> {
210        let mut registry = Registry::default();
211        for (name, kind) in names {
212            registry.add(name, *kind);
213        }
214        Loaded { value: registry, diagnostics: Vec::new() }
215    }
216
217    /// A runner that answers `ps` with `listing` and every other command with success, and keeps
218    /// every command it was given.
219    fn runner<'a>(
220        listing: &'a str,
221        seen: &'a mut Vec<Vec<String>>,
222    ) -> impl FnMut(&EngineCommand) -> Result<String, EngineError> + 'a {
223        move |command| {
224            let args = args(command);
225            let answer = if args[0] == "ps" { listing.to_owned() } else { String::new() };
226            seen.push(args);
227            Ok(answer)
228        }
229    }
230
231    #[test]
232    fn only_registered_running_qcode_containers_are_stopped() {
233        let podman = Engine::new(EngineKind::Podman, NO_ENGINE);
234        let registered = registry(&[
235            ("qcode-a-base", EngineKind::Podman),
236            ("qcode-a-codex", EngineKind::Podman),
237            ("qcode-b-base", EngineKind::Docker),
238        ])
239        .value
240        .containers;
241        let mut foreign = registered.clone();
242        // A name that slipped into the list without the prefix is still never acted on.
243        foreign.push(Registered { name: "postgres".to_owned(), engine: EngineKind::Podman });
244        let listing = Container::parse_list(
245            "qcode-a-base\trunning\nqcode-a-codex\texited\npostgres\trunning\nqcode-c-base\trunning\n",
246        );
247        let commands = stop_commands(&podman, &foreign, &listing);
248        let spelled: Vec<(String, Vec<Vec<String>>)> = commands
249            .iter()
250            .map(|(name, commands)| (name.clone(), commands.iter().map(args).collect::<Vec<_>>()))
251            .collect();
252        assert_eq!(spelled, [("qcode-a-base".to_owned(), vec![vec!["stop".to_owned(), "qcode-a-base".to_owned()]])]);
253        assert_eq!(commands[0].1[0].program, PathBuf::from(NO_ENGINE));
254    }
255
256    #[test]
257    fn a_paused_qcode_container_is_woken_and_then_stopped() {
258        // A QCode that crashed with a container frozen leaves it paused, and the engine refuses to
259        // stop a paused container: it would stay up, and stay in the list, for ever.
260        let podman = Engine::new(EngineKind::Podman, NO_ENGINE);
261        let registered =
262            registry(&[("qcode-a-base", EngineKind::Podman), ("qcode-a-codex", EngineKind::Podman)]).value.containers;
263        let mut foreign = registered.clone();
264        foreign.push(Registered { name: "postgres".to_owned(), engine: EngineKind::Podman });
265        let listing = Container::parse_list("qcode-a-base\tpaused\nqcode-a-codex\trunning\npostgres\tpaused\n");
266        let spelled: Vec<(String, Vec<Vec<String>>)> = stop_commands(&podman, &foreign, &listing)
267            .iter()
268            .map(|(name, commands)| (name.clone(), commands.iter().map(args).collect::<Vec<_>>()))
269            .collect();
270        assert_eq!(
271            spelled,
272            [
273                (
274                    "qcode-a-base".to_owned(),
275                    vec![
276                        vec!["unpause".to_owned(), "qcode-a-base".to_owned()],
277                        vec!["stop".to_owned(), "qcode-a-base".to_owned()]
278                    ]
279                ),
280                ("qcode-a-codex".to_owned(), vec![vec!["stop".to_owned(), "qcode-a-codex".to_owned()]]),
281            ]
282        );
283    }
284
285    #[test]
286    fn a_paused_container_that_will_not_wake_stays_listed_for_the_next_time() {
287        let list = registry(&[("qcode-a-base", EngineKind::Podman)]);
288        let mut seen = Vec::new();
289        let mut run = |command: &EngineCommand| {
290            let args = args(command);
291            seen.push(args.clone());
292            if args[0] == "ps" {
293                return Ok("qcode-a-base\tpaused\n".to_owned());
294            }
295            Err(EngineError::Failed(Failure {
296                command: command.clone(),
297                code: Some(125),
298                output: "no such container\n".to_owned(),
299            }))
300        };
301        let reaped = stop_orphans(&list, OnClose::Stop, &engine, &mut run);
302        let Reaped::Done(stopping) = reaped else { panic!("stopping was asked for") };
303        assert_eq!(seen, [vec!["ps", "--all", "--format", "{{.Names}}\t{{.State}}"], vec!["unpause", "qcode-a-base"],]);
304        assert!(stopping.stopped.is_empty(), "it is still frozen: {:?}", stopping.stopped);
305        assert_eq!(stopping.failed, [("qcode-a-base".to_owned(), "no such container".to_owned())]);
306        assert_eq!(stopping.remaining.containers.len(), 1, "and it stays listed for the next time");
307    }
308
309    #[test]
310    fn stopping_stops_what_runs_and_empties_the_list() {
311        let list = registry(&[("qcode-a-base", EngineKind::Podman), ("qcode-a-codex", EngineKind::Podman)]);
312        let mut seen = Vec::new();
313        let reaped = stop_orphans(
314            &list,
315            OnClose::Stop,
316            &engine,
317            &mut runner("qcode-a-base\trunning\nqcode-a-codex\texited\n", &mut seen),
318        );
319        assert_eq!(seen, [vec!["ps", "--all", "--format", "{{.Names}}\t{{.State}}"], vec!["stop", "qcode-a-base"],]);
320        let Reaped::Done(stopping) = reaped else { panic!("the list was clean and stopping was asked for") };
321        assert_eq!(stopping.stopped, ["qcode-a-base"]);
322        assert!(stopping.remaining.is_empty(), "the stopped one and the one that was not running both leave the list");
323    }
324
325    #[test]
326    fn keeping_touches_nothing() {
327        let list = registry(&[("qcode-a-base", EngineKind::Podman)]);
328        let mut seen = Vec::new();
329        let reaped = stop_orphans(&list, OnClose::Keep, &engine, &mut runner("qcode-a-base\trunning\n", &mut seen));
330        assert_eq!(reaped, Reaped::Kept);
331        assert!(seen.is_empty(), "not even a question is asked: {seen:?}");
332    }
333
334    #[test]
335    fn a_broken_list_stops_nothing() {
336        let mut list = registry(&[("qcode-a-base", EngineKind::Podman)]);
337        list.diagnostics.push(Diagnostic::error(None, "containers.toml:3:1 unexpected"));
338        let mut seen = Vec::new();
339        let reaped = stop_orphans(&list, OnClose::Stop, &engine, &mut runner("qcode-a-base\trunning\n", &mut seen));
340        assert!(matches!(reaped, Reaped::Broken(ref problems) if problems.len() == 1), "{reaped:?}");
341        assert!(seen.is_empty(), "{seen:?}");
342    }
343
344    #[test]
345    fn each_engine_is_asked_about_its_own_containers() {
346        let list = registry(&[("qcode-a-base", EngineKind::Podman), ("qcode-b-base", EngineKind::Docker)]);
347        let mut seen = Vec::new();
348        let mut run = |command: &EngineCommand| {
349            seen.push((command.program.clone(), args(command)));
350            Ok(if args(command)[0] == "ps" {
351                "qcode-a-base\trunning\nqcode-b-base\trunning\n".to_owned()
352            } else {
353                String::new()
354            })
355        };
356        let engines = |kind: EngineKind| {
357            Some(Engine::new(kind, if kind == EngineKind::Podman { "/bin/podman-x" } else { "/bin/docker-x" }))
358        };
359        let Reaped::Done(stopping) = stop_orphans(&list, OnClose::Stop, &engines, &mut run) else {
360            panic!("stopping was asked for")
361        };
362        assert_eq!(stopping.stopped, ["qcode-a-base", "qcode-b-base"]);
363        let stops: Vec<(PathBuf, String)> = seen
364            .iter()
365            .filter(|(_, args)| args[0] == "stop")
366            .map(|(program, args)| (program.clone(), args[1].clone()))
367            .collect();
368        assert_eq!(
369            stops,
370            [
371                (PathBuf::from("/bin/podman-x"), "qcode-a-base".to_owned()),
372                (PathBuf::from("/bin/docker-x"), "qcode-b-base".to_owned()),
373            ]
374        );
375    }
376
377    #[test]
378    fn what_could_not_be_stopped_stays_listed_for_the_next_time() {
379        let list = registry(&[
380            ("qcode-a-base", EngineKind::Podman),
381            ("qcode-a-codex", EngineKind::Podman),
382            ("qcode-b-base", EngineKind::Docker),
383        ]);
384        let mut run = |command: &EngineCommand| {
385            let args = args(command);
386            match (args[0].as_str(), args.get(1).map(String::as_str)) {
387                ("ps", _) => Ok("qcode-a-base\trunning\nqcode-a-codex\trunning\n".to_owned()),
388                ("stop", Some("qcode-a-codex")) => Err(EngineError::Failed(Failure {
389                    command: command.clone(),
390                    code: Some(125),
391                    output: "container is paused\n".to_owned(),
392                })),
393                _ => Ok(String::new()),
394            }
395        };
396        // Docker is not there at all.
397        let engines = |kind: EngineKind| (kind == EngineKind::Podman).then(|| Engine::new(kind, NO_ENGINE));
398        let Reaped::Done(stopping) = stop_orphans(&list, OnClose::Stop, &engines, &mut run) else {
399            panic!("stopping was asked for")
400        };
401        assert_eq!(stopping.stopped, ["qcode-a-base"]);
402        assert_eq!(stopping.failed, [("qcode-a-codex".to_owned(), "container is paused".to_owned())]);
403        assert_eq!(stopping.unreachable, [(EngineKind::Docker, String::new())]);
404        let left: Vec<&str> = stopping.remaining.containers.iter().map(|known| known.name.as_str()).collect();
405        assert_eq!(left, ["qcode-a-codex", "qcode-b-base"]);
406    }
407
408    #[test]
409    fn an_engine_that_cannot_list_keeps_all_of_its_containers_listed() {
410        let list = registry(&[("qcode-a-base", EngineKind::Podman)]);
411        let mut run = |command: &EngineCommand| {
412            Err(EngineError::NotRunnable { command: command.clone(), error: io::Error::from(io::ErrorKind::NotFound) })
413        };
414        let Reaped::Done(stopping) = stop_orphans(&list, OnClose::Stop, &engine, &mut run) else {
415            panic!("stopping was asked for")
416        };
417        assert!(stopping.stopped.is_empty());
418        assert_eq!(stopping.unreachable.len(), 1);
419        assert_eq!(stopping.remaining.containers.len(), 1);
420    }
421
422    #[test]
423    fn reaping_writes_back_only_what_stays_and_leaves_a_kept_or_broken_list_alone() {
424        let dir = std::env::temp_dir().join(format!("qcode-reap-{}", std::process::id()));
425        let _ = std::fs::remove_dir_all(&dir);
426        let path = dir.join("containers.toml");
427
428        let mut seen = Vec::new();
429        let nothing = reap(&path, OnClose::Stop, &engine, &mut runner("", &mut seen)).expect("nothing to write");
430        assert_eq!(nothing, Reaped::Done(Stopping::default()));
431        assert!(!path.exists() && seen.is_empty(), "no list, no question and no file");
432
433        Registry::record(&path, "qcode-a-base", EngineKind::Podman).expect("written");
434        Registry::record(&path, "qcode-a-codex", EngineKind::Podman).expect("written");
435        let listing = "qcode-a-base\trunning\nqcode-a-codex\texited\n";
436        assert_eq!(reap(&path, OnClose::Keep, &engine, &mut runner(listing, &mut seen)).expect("read"), Reaped::Kept);
437        assert_eq!(Registry::load(&path).value.containers.len(), 2, "a kept list is not emptied");
438
439        reap(&path, OnClose::Stop, &engine, &mut runner(listing, &mut seen)).expect("written");
440        let after = Registry::load(&path);
441        assert!(after.is_clean() && after.value.is_empty(), "{after:?}");
442        assert_eq!(std::fs::read_to_string(&path).expect("the file stays"), "");
443
444        std::fs::write(&path, "[[container]]\nname = \n").expect("a broken list");
445        let broken = reap(&path, OnClose::Stop, &engine, &mut runner(listing, &mut seen)).expect("read");
446        assert!(matches!(broken, Reaped::Broken(_)), "{broken:?}");
447        assert_eq!(std::fs::read_to_string(&path).expect("untouched"), "[[container]]\nname = \n");
448        let _ = std::fs::remove_dir_all(&dir);
449    }
450
451    #[cfg(unix)]
452    #[test]
453    fn a_list_that_comes_out_the_same_is_not_written_again() {
454        // The service is started by a write to this file; a run that writes the file it was
455        // started by, with nothing changed, would start the next run, and that one the next.
456        use std::os::unix::fs::MetadataExt as _;
457        let dir = std::env::temp_dir().join(format!("qcode-reap-same-{}", std::process::id()));
458        let _ = std::fs::remove_dir_all(&dir);
459        let path = dir.join("containers.toml");
460        let inode = || std::fs::metadata(&path).expect("the list is there").ino();
461
462        Registry::record(&path, "qcode-a-base", EngineKind::Podman).expect("written");
463        let before = inode();
464        // The engine is not there, so the container stays listed as it was.
465        let reaped = reap(&path, OnClose::Stop, &|_| None, &mut |_| Ok(String::new())).expect("read");
466        assert!(matches!(reaped, Reaped::Done(ref stopping) if stopping.remaining.containers.len() == 1));
467        assert_eq!(inode(), before, "the same list is not written again");
468
469        Registry::replace(&path, &Registry::default()).expect("emptied");
470        let before = inode();
471        let mut seen = Vec::new();
472        reap(&path, OnClose::Stop, &engine, &mut runner("", &mut seen)).expect("read");
473        assert_eq!(inode(), before, "an empty list is not written again");
474        assert!(seen.is_empty(), "and no engine is asked about nothing: {seen:?}");
475        let _ = std::fs::remove_dir_all(&dir);
476    }
477
478    #[test]
479    fn a_state_the_engine_calls_by_another_word_is_not_running() {
480        let podman = Engine::new(EngineKind::Podman, NO_ENGINE);
481        let registered = registry(&[("qcode-a-base", EngineKind::Podman)]).value.containers;
482        let listing = [Container { name: "qcode-a-base".to_owned(), state: ContainerState::Exited }];
483        assert!(stop_commands(&podman, &registered, &listing).is_empty());
484    }
485}