1use std::fs::OpenOptions;
2use std::io::{Read, Write};
3use std::path::PathBuf;
4use std::process::{Child, Command, ExitStatus, Stdio};
5use std::sync::{Arc, Mutex};
6use std::thread;
7
8use crate::detached_finalize::build_detached_finalize_snippet;
9use crate::docker_post_mortem::{
10 build_docker_post_mortem_snippet, build_docker_removal_note_snippet,
11 build_docker_state_snippet, build_docker_still_running_note_snippet,
12 build_docker_wait_for_exit_snippet, format_container_post_mortem,
13 format_container_removal_note, normalize_docker_timestamp, shell_vars, ContainerPostMortem,
14 DOCKER_STATE_INSPECT_FORMAT,
15};
16use crate::exit_reason::resolve_memory_exhaustion;
17use crate::isolation::isolation_log::{
18 append_log_file, create_shell_log_footer_snippet, read_log_tail, shell_quote,
19 FATAL_MARKER_TAIL_BYTES,
20};
21use crate::isolation::IsolationOptions;
22
23const SELF_ABORT_EXIT_CODES: [i32; 2] = [134, 139];
28
29const OOM_FLAG_BLIND_NOTE: &str = "a runtime self-abort on its own memory limit is invisible to this flag - check the log above for a fatal memory marker";
33
34pub(crate) fn build_docker_kept_reason_snippet() -> String {
36 let codes: Vec<String> = SELF_ABORT_EXIT_CODES
37 .iter()
38 .map(|code| code.to_string())
39 .collect();
40 format!(
41 "__start_command_reason=\"exitCode=$__start_command_exit oomKilled=$__start_command_oom\"; case \"$__start_command_exit\" in {}) [ \"$__start_command_oom\" = true ] || __start_command_reason=\"$__start_command_reason ({})\";; esac",
42 codes.join("|"),
43 OOM_FLAG_BLIND_NOTE
44 )
45}
46
47pub(crate) fn build_docker_runtime_args(options: &IsolationOptions) -> Vec<&str> {
52 let mut args: Vec<&str> = Vec::new();
53 if options.privileged {
54 args.push("--privileged");
55 }
56 for env_var in &options.env {
57 args.push("-e");
58 args.push(env_var);
59 }
60 for volume in &options.volumes {
61 args.push("-v");
62 args.push(volume);
63 }
64 for mount in &options.mounts {
65 args.push("--mount");
66 args.push(mount);
67 }
68 if let Some(network) = docker_networks(options).first() {
69 args.push("--network");
70 args.push(network);
71 }
72 for alias in &options.network_aliases {
73 args.push("--network-alias");
74 args.push(alias);
75 }
76 args
77}
78
79pub(crate) fn docker_networks(options: &IsolationOptions) -> Vec<&str> {
80 if options.networks.is_empty() {
81 options.network.iter().map(String::as_str).collect()
82 } else {
83 options.networks.iter().map(String::as_str).collect()
84 }
85}
86
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum DockerContainerCleanupPolicy {
89 Default,
90 Always,
91 Keep,
92 KeepOnFail,
93}
94
95pub(crate) fn docker_command() -> std::ffi::OsString {
96 std::env::var_os("START_DOCKER_BIN").unwrap_or_else(|| std::ffi::OsString::from("docker"))
97}
98
99pub(crate) fn get_docker_container_cleanup_policy(
100 options: &IsolationOptions,
101) -> DockerContainerCleanupPolicy {
102 if options.keep_container {
103 DockerContainerCleanupPolicy::Keep
104 } else if options.keep_container_on_fail {
105 DockerContainerCleanupPolicy::KeepOnFail
106 } else if options.always_cleanup_container || options.auto_remove_docker_container {
107 DockerContainerCleanupPolicy::Always
108 } else {
109 DockerContainerCleanupPolicy::Default
110 }
111}
112
113pub(crate) fn is_abnormal_docker_exit(exit_code: i32, oom_killed: bool) -> bool {
114 exit_code != 0 || oom_killed
115}
116
117pub(crate) fn should_cleanup_docker_container(
118 policy: DockerContainerCleanupPolicy,
119 exit_code: i32,
120 oom_killed: bool,
121) -> bool {
122 match policy {
123 DockerContainerCleanupPolicy::Default => !is_abnormal_docker_exit(exit_code, oom_killed),
124 DockerContainerCleanupPolicy::Always => true,
125 DockerContainerCleanupPolicy::Keep => false,
126 DockerContainerCleanupPolicy::KeepOnFail => !is_abnormal_docker_exit(exit_code, oom_killed),
127 }
128}
129
130pub(crate) fn docker_container_cleanup_instructions(container_name: &str) -> String {
131 format!(
132 "Container kept for investigation: {}\nRe-enter while running: $ --attach {}\nContinue the stored command: $ --resume {}\nRun another command in the same container: $ --resume {} -- <command>\nRemove when done: docker rm -f {}",
133 container_name, container_name, container_name, container_name, container_name
134 )
135}
136
137pub(crate) fn append_docker_container_cleanup_policy_message(
138 message: &mut String,
139 container_name: &str,
140 policy: DockerContainerCleanupPolicy,
141) {
142 match policy {
143 DockerContainerCleanupPolicy::Always => {
144 message.push_str("\nContainer will be removed after command completes.");
145 }
146 DockerContainerCleanupPolicy::Default => {
147 message.push_str("\nContainer will be removed after successful completion.");
148 message.push_str(
149 "\nContainer will be kept if the command fails or Docker reports OOMKilled.",
150 );
151 message.push_str(&format!(
152 "\nRemove when done: docker rm -f {}",
153 container_name
154 ));
155 }
156 DockerContainerCleanupPolicy::Keep => {
157 message.push('\n');
158 message.push_str(&docker_container_cleanup_instructions(container_name));
159 }
160 DockerContainerCleanupPolicy::KeepOnFail => {
161 message.push_str("\nContainer will be removed after successful completion.");
162 message.push_str(
163 "\nContainer will be kept if the command fails or Docker reports OOMKilled.",
164 );
165 message.push_str(&format!(
166 "\nRemove when done: docker rm -f {}",
167 container_name
168 ));
169 }
170 }
171}
172
173pub(crate) fn read_docker_container_state(container_name: &str) -> Option<ContainerPostMortem> {
181 let inspect = |format: &str| -> Option<String> {
182 let output = Command::new(docker_command())
183 .args(["inspect", "-f", format, container_name])
184 .output()
185 .ok()?;
186 if !output.status.success() {
187 return None;
188 }
189 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
190 };
191 let state = inspect(DOCKER_STATE_INSPECT_FORMAT)?;
192 let error = inspect("{{.State.Error}}");
195 Some(parse_docker_container_state(
196 container_name,
197 &state,
198 error.as_deref(),
199 ))
200}
201
202pub(crate) fn parse_docker_container_state(
208 container_name: &str,
209 state: &str,
210 error: Option<&str>,
211) -> ContainerPostMortem {
212 let mut parts = state.split_whitespace();
213 let exit_code = parts.next().and_then(|value| value.parse::<i32>().ok());
214 let oom_killed = match parts.next() {
215 Some("true") => Some(true),
216 Some("false") => Some(false),
217 _ => None,
218 };
219 let started_at = normalize_docker_timestamp(parts.next());
220 let finished_at = normalize_docker_timestamp(parts.next());
221 ContainerPostMortem {
222 container_name: container_name.to_string(),
223 exit_code,
224 oom_killed,
225 started_at,
226 finished_at,
227 error: error
228 .map(str::trim)
229 .filter(|text| !text.is_empty())
230 .map(str::to_string),
231 }
232}
233
234pub(crate) fn record_attached_docker_post_mortem(
242 facts: Option<&ContainerPostMortem>,
243 log_path: Option<&PathBuf>,
244 removed: bool,
245) -> String {
246 let Some(facts) = facts else {
247 return String::new();
248 };
249 let text = if removed {
250 format_container_removal_note(facts)
251 } else {
252 format_container_post_mortem(facts)
253 };
254 if let Some(path) = log_path {
255 append_log_file(path, &format!("\n{}", text));
256 }
257 format!("\n{}", text.trim_end())
258}
259
260pub(crate) fn read_docker_container_status(container_name: &str) -> Option<String> {
261 let output = Command::new(docker_command())
262 .args(["inspect", "-f", "{{.State.Status}}", container_name])
263 .output()
264 .ok()?;
265 if !output.status.success() {
266 return None;
267 }
268 let status = String::from_utf8_lossy(&output.stdout).trim().to_string();
269 if status.is_empty() {
270 None
271 } else {
272 Some(status)
273 }
274}
275
276pub(crate) fn append_attached_docker_cleanup_message(
277 message: &mut String,
278 container_name: &str,
279 policy: DockerContainerCleanupPolicy,
280 exit_code: i32,
281 log_path: Option<&PathBuf>,
282 container_existed_before_launch: bool,
283) {
284 let state = read_docker_container_state(container_name);
285 let oom_killed = state
286 .as_ref()
287 .and_then(|facts| facts.oom_killed)
288 .unwrap_or(false);
289 let post_mortem =
290 |removed: bool| record_attached_docker_post_mortem(state.as_ref(), log_path, removed);
291 if !container_existed_before_launch
292 && read_docker_container_status(container_name).as_deref() == Some("created")
293 {
294 if remove_docker_container(container_name, log_path) {
295 message.push_str("\nContainer removed after launch failure.");
296 } else {
297 message.push_str("\nWarning: failed to remove container after launch failure.");
298 }
299 } else if should_cleanup_docker_container(policy, exit_code, oom_killed) {
300 if remove_docker_container(container_name, log_path) {
301 message.push_str("\nContainer removed after completion.");
302 message.push_str(&post_mortem(true));
303 } else {
304 message.push_str("\nWarning: failed to remove container automatically.");
305 message.push_str(&format!(
306 "\nRemove when done: docker rm -f {container_name}"
307 ));
308 }
309 } else if policy == DockerContainerCleanupPolicy::Keep {
310 message.push('\n');
311 message.push_str(&docker_container_cleanup_instructions(container_name));
312 message.push_str(&post_mortem(false));
313 } else {
314 if oom_killed {
315 message.push_str("\nContainer kept because Docker reports it was OOM-killed.");
316 } else {
317 message.push_str("\nContainer kept because the command failed.");
318 }
319 let tail = log_path
324 .and_then(|path| read_log_tail(&path.to_string_lossy(), FATAL_MARKER_TAIL_BYTES));
325 if let Some(memory) =
326 resolve_memory_exhaustion(Some(exit_code), tail.as_deref(), Some(oom_killed))
327 {
328 message.push_str(&format!(
329 "\nMemory exhaustion detected in the log: {}",
330 memory.memory_exhausted_reason
331 ));
332 }
333 message.push_str(&format!(
334 "\nRemove when done: docker rm -f {container_name}"
335 ));
336 message.push_str(&post_mortem(false));
337 }
338}
339
340pub(crate) fn remove_docker_container(container_name: &str, log_path: Option<&PathBuf>) -> bool {
341 let output = Command::new(docker_command())
342 .args(["rm", "-f", container_name])
343 .output();
344 match output {
345 Ok(output) => {
346 if let Some(path) = log_path {
347 let mut combined = String::new();
348 combined.push_str(&String::from_utf8_lossy(&output.stdout));
349 combined.push_str(&String::from_utf8_lossy(&output.stderr));
350 if !combined.is_empty() {
351 let content = if combined.ends_with('\n') {
352 combined
353 } else {
354 format!("{}\n", combined)
355 };
356 append_log_file(path, &content);
357 }
358 }
359 output.status.success()
360 }
361 Err(_) => false,
362 }
363}
364
365fn build_docker_kept_log_snippet(container_name: &str, quoted_log_path: &str) -> String {
366 let quoted_name = shell_quote(container_name);
367 format!(
368 "{}; printf '\\nContainer kept for investigation: %s\\nReason: %s\\nRe-enter while running: $ --attach %s\\nContinue the stored command: $ --resume %s\\nRun another command in the same container: $ --resume %s -- <command>\\nRemove when done: docker rm -f %s\\n' {} \"$__start_command_reason\" {} {} {} {} >> {}",
369 build_docker_kept_reason_snippet(),
370 quoted_name, quoted_name, quoted_name, quoted_name, quoted_name, quoted_log_path
371 )
372}
373
374fn successful_non_oom_condition() -> &'static str {
375 "[ \"$__start_command_exit\" -eq 0 ] 2>/dev/null && [ \"$__start_command_oom\" != true ]"
376}
377
378pub fn build_detached_docker_completion_script(
391 container_name: &str,
392 policy: DockerContainerCleanupPolicy,
393 log_path: Option<&PathBuf>,
394 execution_id: Option<&str>,
395) -> String {
396 let quoted_name = shell_quote(container_name);
397 let mut parts = Vec::new();
398 let mut exited = Vec::new();
401 let quoted_log_path = log_path.map(|path| shell_quote(&path.to_string_lossy()));
402
403 if let Some(quoted_log_path) = quoted_log_path.as_deref() {
404 parts.push(format!(
405 "docker logs -f {} >> {} 2>&1",
406 quoted_name, quoted_log_path
407 ));
408 parts.push(build_docker_wait_for_exit_snippet(
409 container_name,
410 Some(quoted_log_path),
411 ));
412 parts.push(build_docker_state_snippet(container_name));
413
414 let remove = format!(
415 "docker rm -f {} >> {} 2>&1 || true; {}",
416 quoted_name,
417 quoted_log_path,
418 build_docker_removal_note_snippet(container_name, quoted_log_path)
419 );
420 let keep = format!(
423 "{}; {}",
424 build_docker_post_mortem_snippet(container_name, quoted_log_path),
425 build_docker_kept_log_snippet(container_name, quoted_log_path)
426 );
427 match policy {
428 DockerContainerCleanupPolicy::Always => exited.push(remove),
429 DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
430 exited.push(format!(
431 "if {}; then {}; else {}; fi",
432 successful_non_oom_condition(),
433 remove,
434 keep
435 ))
436 }
437 DockerContainerCleanupPolicy::Keep => exited.push(keep),
440 }
441 exited.push(format!(
442 "{} >> {}",
443 create_shell_log_footer_snippet(),
444 quoted_log_path
445 ));
446 } else {
447 parts.push(format!("docker wait {} >/dev/null 2>&1", quoted_name));
448 parts.push(build_docker_wait_for_exit_snippet(container_name, None));
449 parts.push(build_docker_state_snippet(container_name));
450 match policy {
451 DockerContainerCleanupPolicy::Always => exited.push(format!(
452 "docker rm -f {} >/dev/null 2>&1 || true",
453 quoted_name
454 )),
455 DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
456 exited.push(format!(
457 "if {}; then docker rm -f {} >/dev/null 2>&1 || true; fi",
458 successful_non_oom_condition(),
459 quoted_name
460 ))
461 }
462 DockerContainerCleanupPolicy::Keep => {}
463 }
464 }
465
466 if let Some(execution_id) = execution_id {
467 exited.push(build_detached_finalize_snippet(execution_id));
469 }
470
471 let still_running = quoted_log_path
472 .as_deref()
473 .map(|log| build_docker_still_running_note_snippet(container_name, log))
474 .unwrap_or_else(|| ":".to_string());
475 let exited = if exited.is_empty() {
476 ":".to_string()
477 } else {
478 exited.join("; ")
479 };
480 parts.push(format!(
481 "if [ \"${}\" = true ]; then {}; else {}; fi",
482 shell_vars::RUNNING,
483 still_running,
484 exited
485 ));
486
487 parts.join("; ")
488}
489
490pub(crate) fn start_detached_docker_completion_watcher(
491 container_name: &str,
492 policy: DockerContainerCleanupPolicy,
493 log_path: Option<&PathBuf>,
494 execution_id: Option<&str>,
495) {
496 let script =
497 build_detached_docker_completion_script(container_name, policy, log_path, execution_id);
498 let _ = Command::new("sh")
499 .args(["-c", &script])
500 .stdout(Stdio::null())
501 .stderr(Stdio::null())
502 .spawn();
503}
504
505pub(crate) struct AttachedDockerChild {
506 child: Child,
507 stdout_thread: Option<thread::JoinHandle<()>>,
508 stderr_thread: Option<thread::JoinHandle<()>>,
509}
510
511impl AttachedDockerChild {
512 pub(crate) fn wait(mut self) -> std::io::Result<ExitStatus> {
513 let status = self.child.wait();
514 if let Some(handle) = self.stdout_thread.take() {
515 let _ = handle.join();
516 }
517 if let Some(handle) = self.stderr_thread.take() {
518 let _ = handle.join();
519 }
520 status
521 }
522}
523
524pub(crate) fn spawn_attached_docker(
525 args: &[&str],
526 log_path: Option<&PathBuf>,
527) -> std::io::Result<AttachedDockerChild> {
528 if log_path.is_none() {
529 let child = Command::new(docker_command())
530 .args(args)
531 .stdin(Stdio::inherit())
532 .stdout(Stdio::inherit())
533 .stderr(Stdio::inherit())
534 .spawn()?;
535 return Ok(AttachedDockerChild {
536 child,
537 stdout_thread: None,
538 stderr_thread: None,
539 });
540 }
541
542 let mut child = Command::new(docker_command())
543 .args(args)
544 .stdin(Stdio::inherit())
545 .stdout(Stdio::piped())
546 .stderr(Stdio::piped())
547 .spawn()?;
548
549 let file = OpenOptions::new()
550 .create(true)
551 .append(true)
552 .open(log_path.unwrap())?;
553 let shared_log = Arc::new(Mutex::new(file));
554
555 let mut stdout_thread = None;
556 let mut stderr_thread = None;
557
558 if let Some(mut stdout) = child.stdout.take() {
559 let log = Arc::clone(&shared_log);
560 stdout_thread = Some(thread::spawn(move || {
561 let mut buffer = [0_u8; 8192];
562 let mut terminal = std::io::stdout();
563 while let Ok(size) = stdout.read(&mut buffer) {
564 if size == 0 {
565 break;
566 }
567 let chunk = &buffer[..size];
568 let _ = terminal.write_all(chunk);
569 let _ = terminal.flush();
570 if let Ok(mut file) = log.lock() {
571 let _ = file.write_all(chunk);
572 let _ = file.flush();
573 }
574 }
575 }));
576 }
577
578 if let Some(mut stderr) = child.stderr.take() {
579 let log = Arc::clone(&shared_log);
580 stderr_thread = Some(thread::spawn(move || {
581 let mut buffer = [0_u8; 8192];
582 let mut terminal = std::io::stderr();
583 while let Ok(size) = stderr.read(&mut buffer) {
584 if size == 0 {
585 break;
586 }
587 let chunk = &buffer[..size];
588 let _ = terminal.write_all(chunk);
589 let _ = terminal.flush();
590 if let Ok(mut file) = log.lock() {
591 let _ = file.write_all(chunk);
592 let _ = file.flush();
593 }
594 }
595 }));
596 }
597
598 Ok(AttachedDockerChild {
599 child,
600 stdout_thread,
601 stderr_thread,
602 })
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use crate::docker_post_mortem::POST_MORTEM_HEADER;
609
610 #[test]
611 fn default_policy_keeps_abnormal_containers() {
612 let options = IsolationOptions::default();
613 let policy = get_docker_container_cleanup_policy(&options);
614 assert_eq!(policy, DockerContainerCleanupPolicy::Default);
615 assert!(should_cleanup_docker_container(policy, 0, false));
616 assert!(!should_cleanup_docker_container(policy, 7, false));
617 assert!(!should_cleanup_docker_container(policy, 0, true));
618 }
619
620 #[test]
621 fn keep_on_fail_policy_keeps_oom_killed_containers() {
622 let options = IsolationOptions {
623 keep_container_on_fail: true,
624 ..IsolationOptions::default()
625 };
626 let policy = get_docker_container_cleanup_policy(&options);
627 assert_eq!(policy, DockerContainerCleanupPolicy::KeepOnFail);
628 assert!(should_cleanup_docker_container(policy, 0, false));
629 assert!(!should_cleanup_docker_container(policy, 0, true));
630 }
631
632 #[test]
633 fn explicit_always_policy_cleans_abnormal_containers() {
634 let options = IsolationOptions {
635 always_cleanup_container: true,
636 ..IsolationOptions::default()
637 };
638 let policy = get_docker_container_cleanup_policy(&options);
639 assert_eq!(policy, DockerContainerCleanupPolicy::Always);
640 assert!(should_cleanup_docker_container(policy, 7, false));
641 assert!(should_cleanup_docker_container(policy, 0, true));
642 }
643
644 #[test]
645 fn detached_watcher_inspects_oom_killed_before_default_cleanup() {
646 let log_path = PathBuf::from("/tmp/issue144.log");
647 let script = build_detached_docker_completion_script(
648 "issue144-container",
649 DockerContainerCleanupPolicy::Default,
650 Some(&log_path),
651 None,
652 );
653 assert!(script.contains(".State.ExitCode"));
654 assert!(script.contains(".State.OOMKilled"));
655 assert!(script.contains("__start_command_oom"));
656 assert!(script.contains("Container kept for investigation"));
657 assert!(script.contains("docker rm -f"));
658 assert!(script.contains("issue144-container"));
659 }
660
661 #[cfg(unix)]
663 fn evaluate_reason(exit_code: &str, oom_killed: &str) -> String {
664 let output = Command::new("sh")
665 .arg("-c")
666 .arg(format!(
667 "__start_command_exit={}; __start_command_oom={}; {}; printf '%s' \"$__start_command_reason\"",
668 exit_code,
669 oom_killed,
670 build_docker_kept_reason_snippet()
671 ))
672 .output()
673 .expect("sh");
674 assert!(output.status.success());
675 String::from_utf8_lossy(&output.stdout).into_owned()
676 }
677
678 #[test]
679 #[cfg(unix)]
680 fn kept_footer_does_not_assert_a_bare_oom_false_for_self_aborts() {
681 for exit_code in SELF_ABORT_EXIT_CODES {
684 let reason = evaluate_reason(&exit_code.to_string(), "false");
685 assert!(reason.contains(&format!("exitCode={} oomKilled=false", exit_code)));
686 assert!(reason.contains("invisible to this flag"));
687 }
688 }
689
690 #[test]
691 #[cfg(unix)]
692 fn kept_footer_stays_plain_for_other_exits_and_real_oom_kills() {
693 assert_eq!(evaluate_reason("1", "false"), "exitCode=1 oomKilled=false");
694 assert_eq!(
695 evaluate_reason("137", "true"),
696 "exitCode=137 oomKilled=true"
697 );
698 }
699
700 #[test]
701 fn detached_watcher_computes_the_kept_reason() {
702 let log_path = PathBuf::from("/tmp/issue165.log");
703 let script = build_detached_docker_completion_script(
704 "issue165-container",
705 DockerContainerCleanupPolicy::Default,
706 Some(&log_path),
707 None,
708 );
709 assert!(script.contains("__start_command_reason="));
710 assert!(script.contains("Reason: %s"));
711 }
712
713 #[test]
716 fn detached_watcher_invokes_the_finalizer_with_the_inspected_facts() {
717 let log_path = PathBuf::from("/tmp/issue170.log");
718 let script = build_detached_docker_completion_script(
719 "issue170-container",
720 DockerContainerCleanupPolicy::Default,
721 Some(&log_path),
722 Some("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
723 );
724 assert!(script.contains(crate::detached_finalize::INTERNAL_FINALIZE_FLAG));
725 assert!(script.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
726 assert!(script.contains("$__start_command_exit"));
727 assert!(script.contains("$__start_command_finished"));
728 let finalize_at = script
731 .find(crate::detached_finalize::INTERNAL_FINALIZE_FLAG)
732 .unwrap();
733 let footer_at = script.find("Exit Code: %s").unwrap();
734 assert!(finalize_at > footer_at);
735 }
736
737 #[test]
739 fn detached_watcher_stays_backward_compatible_without_an_execution_id() {
740 let log_path = PathBuf::from("/tmp/issue170.log");
741 let script = build_detached_docker_completion_script(
742 "issue170-container",
743 DockerContainerCleanupPolicy::Default,
744 Some(&log_path),
745 None,
746 );
747 assert!(!script.contains(crate::detached_finalize::INTERNAL_FINALIZE_FLAG));
748 }
749
750 #[test]
752 fn detached_watcher_writes_the_post_mortem_on_both_paths() {
753 let log_path = PathBuf::from("/tmp/issue171.log");
754 let script = build_detached_docker_completion_script(
755 "issue171-container",
756 DockerContainerCleanupPolicy::KeepOnFail,
757 Some(&log_path),
758 None,
759 );
760 assert!(script.contains("{{.State.StartedAt}}"));
761 assert!(script.contains("{{.State.FinishedAt}}"));
762 assert!(script.contains("{{.State.Error}}"));
763 assert!(script.contains("=== Container post-mortem ==="));
764 assert!(script.contains("Container removed:"));
765 assert!(script.contains("Container kept for investigation"));
766 }
767
768 #[test]
771 fn always_kept_containers_still_get_a_post_mortem() {
772 let log_path = PathBuf::from("/tmp/issue171.log");
773 let script = build_detached_docker_completion_script(
774 "issue171-container",
775 DockerContainerCleanupPolicy::Keep,
776 Some(&log_path),
777 None,
778 );
779 assert!(script.contains("=== Container post-mortem ==="));
780 assert!(!script.contains("docker rm -f 'issue171-container' >>"));
782 }
783
784 #[test]
786 fn always_removed_containers_get_the_removal_note() {
787 let log_path = PathBuf::from("/tmp/issue171.log");
788 let script = build_detached_docker_completion_script(
789 "issue171-container",
790 DockerContainerCleanupPolicy::Always,
791 Some(&log_path),
792 None,
793 );
794 assert!(script.contains("Container removed:"));
795 assert!(script.contains("docker rm -f 'issue171-container' >>"));
796 }
797
798 #[test]
799 fn attached_runs_read_every_documented_fact_in_one_inspect() {
800 let facts = parse_docker_container_state(
801 "demo",
802 "137 false 2026-09-15T22:21:40.942007645Z 2026-09-15T22:21:46.740817278Z",
803 Some(""),
804 );
805
806 assert_eq!(facts.container_name, "demo");
807 assert_eq!(facts.exit_code, Some(137));
808 assert_eq!(facts.oom_killed, Some(false));
809 assert_eq!(
810 facts.started_at.as_deref(),
811 Some("2026-09-15T22:21:40.942007645Z")
812 );
813 assert_eq!(
814 facts.finished_at.as_deref(),
815 Some("2026-09-15T22:21:46.740817278Z")
816 );
817 assert_eq!(facts.error, None);
818 }
819
820 #[test]
821 fn attached_runs_reject_the_zero_time_of_a_container_that_never_started() {
822 let facts = parse_docker_container_state(
823 "demo",
824 "125 false 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z",
825 Some("no such file or directory"),
826 );
827
828 assert_eq!(facts.started_at, None);
829 assert_eq!(facts.finished_at, None);
830 assert_eq!(facts.error.as_deref(), Some("no such file or directory"));
831 }
832
833 #[test]
834 fn attached_runs_survive_an_inspect_that_answered_nothing_useful() {
835 let facts = parse_docker_container_state("demo", "", None);
836
837 assert_eq!(facts.exit_code, None);
838 assert_eq!(facts.oom_killed, None);
839 assert_eq!(facts.started_at, None);
840 assert_eq!(facts.error, None);
841 }
842
843 #[test]
844 fn attached_kept_containers_get_the_post_mortem_block_in_their_log() {
845 let dir =
846 std::env::temp_dir().join(format!("start-attached-kept-171-{}", std::process::id()));
847 std::fs::create_dir_all(&dir).unwrap();
848 let log_path = dir.join("run.log");
849 std::fs::write(&log_path, "output\n").unwrap();
850
851 let facts = ContainerPostMortem {
852 container_name: "demo".to_string(),
853 exit_code: Some(137),
854 oom_killed: Some(false),
855 started_at: Some("2026-09-15T22:21:40.942007645Z".to_string()),
856 finished_at: Some("2026-09-15T22:21:46.740817278Z".to_string()),
857 error: None,
858 };
859 let message = record_attached_docker_post_mortem(Some(&facts), Some(&log_path), false);
860
861 let log = std::fs::read_to_string(&log_path).unwrap();
862 assert!(log.contains(POST_MORTEM_HEADER));
863 assert!(log.contains("Exit Code: 137 (SIGKILL - 128+9)"));
864 assert!(log.contains("Lifetime: 5.798s"));
865 assert!(message.contains(POST_MORTEM_HEADER));
866 let _ = std::fs::remove_dir_all(&dir);
867 }
868
869 #[test]
870 fn attached_removed_containers_get_the_one_line_note() {
871 let dir =
872 std::env::temp_dir().join(format!("start-attached-removed-171-{}", std::process::id()));
873 std::fs::create_dir_all(&dir).unwrap();
874 let log_path = dir.join("run.log");
875 std::fs::write(&log_path, "output\n").unwrap();
876
877 let facts = ContainerPostMortem {
878 container_name: "demo".to_string(),
879 exit_code: Some(0),
880 oom_killed: Some(false),
881 started_at: Some("2026-09-15T22:21:40.942007645Z".to_string()),
882 finished_at: Some("2026-09-15T22:21:46.740817278Z".to_string()),
883 error: None,
884 };
885 let message = record_attached_docker_post_mortem(Some(&facts), Some(&log_path), true);
886
887 assert!(std::fs::read_to_string(&log_path)
888 .unwrap()
889 .contains("Container removed: demo (exit 0, lifetime 5.798s, oomKilled=false)"));
890 assert_eq!(
891 message,
892 "\nContainer removed: demo (exit 0, lifetime 5.798s, oomKilled=false)"
893 );
894 let _ = std::fs::remove_dir_all(&dir);
895 }
896
897 #[test]
898 fn attached_runs_stay_silent_when_docker_could_not_be_inspected() {
899 let dir =
900 std::env::temp_dir().join(format!("start-attached-silent-171-{}", std::process::id()));
901 std::fs::create_dir_all(&dir).unwrap();
902 let log_path = dir.join("run.log");
903 std::fs::write(&log_path, "output\n").unwrap();
904
905 assert_eq!(
906 record_attached_docker_post_mortem(None, Some(&log_path), false),
907 ""
908 );
909 assert_eq!(std::fs::read_to_string(&log_path).unwrap(), "output\n");
910 let _ = std::fs::remove_dir_all(&dir);
911 }
912}