1use 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#[derive(Debug, Clone, PartialEq)]
20pub enum Reaped {
21 Kept,
24 Broken(Vec<Diagnostic>),
27 Done(Stopping),
29}
30
31#[derive(Debug, Clone, Default, PartialEq)]
33pub struct Stopping {
34 pub stopped: Vec<String>,
36 pub failed: Vec<(String, String)>,
38 pub unreachable: Vec<(EngineKind, String)>,
40 pub remaining: Registry,
44}
45
46#[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
77pub 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 ®istered {
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, ®istered, &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 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
143pub 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 if registry.is_clean() && registry.value.is_empty() {
164 return Ok(Reaped::Done(Stopping::default()));
165 }
166 let reaped = stop_orphans(®istry, on_close, engine_for, run);
167 if let Reaped::Done(stopping) = &reaped
168 && !same_containers(&stopping.remaining, ®istry.value)
169 {
170 Registry::replace(path, &stopping.remaining)?;
171 }
172 Ok(reaped)
173}
174
175fn 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
181fn 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 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 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 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 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 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 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 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, ®istered, &listing).is_empty());
484 }
485}