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::cgroup_memory::{
9 build_cgroup_memory_log_snippet, build_cgroup_sampler_start_snippet,
10 build_cgroup_sampler_stop_snippet,
11};
12use crate::detached_finalize::build_detached_finalize_snippet_with_attempt;
13use crate::docker_post_mortem::{
14 build_docker_post_mortem_snippet, build_docker_removal_note_snippet,
15 build_docker_state_snippet, build_docker_still_running_note_snippet,
16 build_docker_wait_for_exit_snippet, format_container_post_mortem,
17 format_container_removal_note, normalize_docker_timestamp, shell_vars, ContainerPostMortem,
18 DOCKER_STATE_INSPECT_FORMAT,
19};
20use crate::exit_reason::{is_oom_kill_of_command, resolve_memory_exhaustion};
21use crate::isolation::isolation_log::{
22 append_log_file, create_shell_log_footer_snippet, read_log_tail, shell_quote,
23 FATAL_MARKER_TAIL_BYTES,
24};
25use crate::isolation::IsolationOptions;
26
27const SELF_ABORT_EXIT_CODES: [i32; 2] = [134, 139];
32
33const 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";
37
38pub(crate) fn build_docker_kept_reason_snippet() -> String {
40 let codes: Vec<String> = SELF_ABORT_EXIT_CODES
41 .iter()
42 .map(|code| code.to_string())
43 .collect();
44 format!(
45 "__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",
46 codes.join("|"),
47 OOM_FLAG_BLIND_NOTE
48 )
49}
50
51pub fn build_docker_runtime_args(options: &IsolationOptions) -> Vec<&str> {
56 let mut args: Vec<&str> = Vec::new();
57 if options.privileged {
58 args.push("--privileged");
59 }
60 for env_var in &options.env {
61 args.push("-e");
62 args.push(env_var);
63 }
64 for label in &options.labels {
65 args.push("--label");
66 args.push(label);
67 }
68 for volume in &options.volumes {
69 args.push("-v");
70 args.push(volume);
71 }
72 for mount in &options.mounts {
73 args.push("--mount");
74 args.push(mount);
75 }
76 if let Some(network) = docker_networks(options).first() {
77 args.push("--network");
78 args.push(network);
79 }
80 for alias in &options.network_aliases {
81 args.push("--network-alias");
82 args.push(alias);
83 }
84 args.extend(options.resource_limits.iter().map(String::as_str));
86 args
87}
88
89pub(crate) fn docker_networks(options: &IsolationOptions) -> Vec<&str> {
90 if options.networks.is_empty() {
91 options.network.iter().map(String::as_str).collect()
92 } else {
93 options.networks.iter().map(String::as_str).collect()
94 }
95}
96
97#[derive(Clone, Copy, Debug, Eq, PartialEq)]
98pub enum DockerContainerCleanupPolicy {
99 Default,
100 Always,
101 Keep,
102 KeepOnFail,
103}
104
105pub fn docker_command() -> std::ffi::OsString {
106 std::env::var_os("START_DOCKER_BIN").unwrap_or_else(|| std::ffi::OsString::from("docker"))
107}
108
109pub(crate) fn get_docker_container_cleanup_policy(
110 options: &IsolationOptions,
111) -> DockerContainerCleanupPolicy {
112 if options.keep_container {
113 DockerContainerCleanupPolicy::Keep
114 } else if options.keep_container_on_fail {
115 DockerContainerCleanupPolicy::KeepOnFail
116 } else if options.always_cleanup_container || options.auto_remove_docker_container {
117 DockerContainerCleanupPolicy::Always
118 } else {
119 DockerContainerCleanupPolicy::Default
120 }
121}
122
123pub(crate) fn is_abnormal_docker_exit(exit_code: i32, oom_killed: bool) -> bool {
124 exit_code != 0 || oom_killed
125}
126
127pub(crate) fn should_cleanup_docker_container(
128 policy: DockerContainerCleanupPolicy,
129 exit_code: i32,
130 oom_killed: bool,
131) -> bool {
132 match policy {
133 DockerContainerCleanupPolicy::Default => !is_abnormal_docker_exit(exit_code, oom_killed),
134 DockerContainerCleanupPolicy::Always => true,
135 DockerContainerCleanupPolicy::Keep => false,
136 DockerContainerCleanupPolicy::KeepOnFail => !is_abnormal_docker_exit(exit_code, oom_killed),
137 }
138}
139
140pub(crate) fn docker_container_cleanup_instructions(container_name: &str) -> String {
141 format!(
142 "Container kept for investigation: {}\nRe-enter while running: $ --attach {}\nContinue the stored command: $ --resume {}\nRun another command (legacy containers snapshot the filesystem into a new container): $ --resume {} -- <command>\nRemove when done: docker rm -f {}",
143 container_name, container_name, container_name, container_name, container_name
144 )
145}
146
147pub(crate) fn append_docker_container_cleanup_policy_message(
148 message: &mut String,
149 container_name: &str,
150 policy: DockerContainerCleanupPolicy,
151) {
152 match policy {
153 DockerContainerCleanupPolicy::Always => {
154 message.push_str("\nContainer will be removed after command completes.");
155 }
156 DockerContainerCleanupPolicy::Default => {
157 message.push_str("\nContainer will be removed after successful completion.");
158 message.push_str(
159 "\nContainer will be kept if the command fails or Docker reports OOMKilled.",
160 );
161 message.push_str(&format!(
162 "\nRemove when done: docker rm -f {}",
163 container_name
164 ));
165 }
166 DockerContainerCleanupPolicy::Keep => {
167 message.push('\n');
168 message.push_str(&docker_container_cleanup_instructions(container_name));
169 }
170 DockerContainerCleanupPolicy::KeepOnFail => {
171 message.push_str("\nContainer will be removed after successful completion.");
172 message.push_str(
173 "\nContainer will be kept if the command fails or Docker reports OOMKilled.",
174 );
175 message.push_str(&format!(
176 "\nRemove when done: docker rm -f {}",
177 container_name
178 ));
179 }
180 }
181}
182
183pub(crate) fn read_docker_container_state(container_name: &str) -> Option<ContainerPostMortem> {
191 let inspect = |format: &str| -> Option<String> {
192 let output = Command::new(docker_command())
193 .args(["inspect", "-f", format, container_name])
194 .output()
195 .ok()?;
196 if !output.status.success() {
197 return None;
198 }
199 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
200 };
201 let state = inspect(DOCKER_STATE_INSPECT_FORMAT)?;
202 let error = inspect("{{.State.Error}}");
205 Some(parse_docker_container_state(
206 container_name,
207 &state,
208 error.as_deref(),
209 ))
210}
211
212pub(crate) fn parse_docker_container_state(
218 container_name: &str,
219 state: &str,
220 error: Option<&str>,
221) -> ContainerPostMortem {
222 let mut parts = state.split_whitespace();
223 let exit_code = parts.next().and_then(|value| value.parse::<i32>().ok());
224 let oom_killed = match parts.next() {
225 Some("true") => Some(true),
226 Some("false") => Some(false),
227 _ => None,
228 };
229 let started_at = normalize_docker_timestamp(parts.next());
230 let finished_at = normalize_docker_timestamp(parts.next());
231 ContainerPostMortem {
232 container_name: container_name.to_string(),
233 exit_code,
234 oom_killed,
235 started_at,
236 finished_at,
237 error: error
238 .map(str::trim)
239 .filter(|text| !text.is_empty())
240 .map(str::to_string),
241 }
242}
243
244pub(crate) fn record_attached_docker_post_mortem(
252 facts: Option<&ContainerPostMortem>,
253 log_path: Option<&PathBuf>,
254 removed: bool,
255) -> String {
256 let Some(facts) = facts else {
257 return String::new();
258 };
259 let text = if removed {
260 format_container_removal_note(facts)
261 } else {
262 format_container_post_mortem(facts)
263 };
264 if let Some(path) = log_path {
265 append_log_file(path, &format!("\n{}", text));
266 }
267 format!("\n{}", text.trim_end())
268}
269
270pub(crate) fn read_docker_container_status(container_name: &str) -> Option<String> {
271 let output = Command::new(docker_command())
272 .args(["inspect", "-f", "{{.State.Status}}", container_name])
273 .output()
274 .ok()?;
275 if !output.status.success() {
276 return None;
277 }
278 let status = String::from_utf8_lossy(&output.stdout).trim().to_string();
279 if status.is_empty() {
280 None
281 } else {
282 Some(status)
283 }
284}
285
286pub(crate) fn attached_docker_kept_reason(exit_code: i32, oom_killed: bool) -> &'static str {
290 if !oom_killed {
291 "\nContainer kept because the command failed."
292 } else if is_oom_kill_of_command(Some(exit_code), Some(true), None) {
293 "\nContainer kept because Docker reports it was OOM-killed."
294 } else {
295 "\nContainer kept because Docker reports a process in it was OOM-killed."
296 }
297}
298
299pub(crate) fn append_attached_docker_cleanup_message(
300 message: &mut String,
301 container_name: &str,
302 policy: DockerContainerCleanupPolicy,
303 exit_code: i32,
304 log_path: Option<&PathBuf>,
305 container_existed_before_launch: bool,
306) {
307 let state = read_docker_container_state(container_name);
308 let oom_killed = state
309 .as_ref()
310 .and_then(|facts| facts.oom_killed)
311 .unwrap_or(false);
312 let post_mortem =
313 |removed: bool| record_attached_docker_post_mortem(state.as_ref(), log_path, removed);
314 if !container_existed_before_launch
315 && read_docker_container_status(container_name).as_deref() == Some("created")
316 {
317 if remove_docker_container(container_name, log_path) {
318 message.push_str("\nContainer removed after launch failure.");
319 } else {
320 message.push_str("\nWarning: failed to remove container after launch failure.");
321 }
322 } else if should_cleanup_docker_container(policy, exit_code, oom_killed) {
323 if remove_docker_container(container_name, log_path) {
324 message.push_str("\nContainer removed after completion.");
325 message.push_str(&post_mortem(true));
326 } else {
327 message.push_str("\nWarning: failed to remove container automatically.");
328 message.push_str(&format!(
329 "\nRemove when done: docker rm -f {container_name}"
330 ));
331 }
332 } else if policy == DockerContainerCleanupPolicy::Keep {
333 message.push('\n');
334 message.push_str(&docker_container_cleanup_instructions(container_name));
335 message.push_str(&post_mortem(false));
336 } else {
337 let tail = log_path
338 .and_then(|path| read_log_tail(&path.to_string_lossy(), FATAL_MARKER_TAIL_BYTES));
339 if crate::exit_reason::resolve_exit_reason(
340 Some(exit_code),
341 tail.as_deref(),
342 Some(oom_killed),
343 None,
344 )
345 .as_deref()
346 == Some(crate::exit_reason::CGROUP_OOM_EXIT_REASON)
347 {
348 message.push_str("\nContainer kept because Docker reports it was OOM-killed.");
349 } else {
350 message.push_str(attached_docker_kept_reason(exit_code, oom_killed));
351 }
352 if let Some(memory) =
357 resolve_memory_exhaustion(Some(exit_code), tail.as_deref(), Some(oom_killed), None)
358 {
359 message.push_str(&format!(
360 "\nMemory exhaustion detected in the log: {}",
361 memory.memory_exhausted_reason
362 ));
363 }
364 message.push_str(&format!(
365 "\nRemove when done: docker rm -f {container_name}"
366 ));
367 message.push_str(&post_mortem(false));
368 }
369}
370
371pub(crate) fn remove_docker_container(container_name: &str, log_path: Option<&PathBuf>) -> bool {
372 let output = Command::new(docker_command())
373 .args(["rm", "-f", container_name])
374 .output();
375 match output {
376 Ok(output) => {
377 if let Some(path) = log_path {
378 let mut combined = String::new();
379 combined.push_str(&String::from_utf8_lossy(&output.stdout));
380 combined.push_str(&String::from_utf8_lossy(&output.stderr));
381 if !combined.is_empty() {
382 let content = if combined.ends_with('\n') {
383 combined
384 } else {
385 format!("{}\n", combined)
386 };
387 append_log_file(path, &content);
388 }
389 }
390 output.status.success()
391 }
392 Err(_) => false,
393 }
394}
395
396fn build_docker_kept_log_snippet(container_name: &str, quoted_log_path: &str) -> String {
397 let quoted_name = shell_quote(container_name);
398 format!(
399 "{}; printf '\\nContainer kept for investigation: %s\\nReason: %s\\nRe-enter while running: $ --attach %s\\nContinue the stored command: $ --resume %s\\nRun another command (legacy containers snapshot the filesystem into a new container): $ --resume %s -- <command>\\nRemove when done: docker rm -f %s\\n' {} \"$__start_command_reason\" {} {} {} {} >> {}",
400 build_docker_kept_reason_snippet(),
401 quoted_name, quoted_name, quoted_name, quoted_name, quoted_name, quoted_log_path
402 )
403}
404
405fn successful_non_oom_condition() -> &'static str {
406 "[ \"$__start_command_exit\" -eq 0 ] 2>/dev/null && [ \"$__start_command_oom\" != true ]"
407}
408
409pub fn build_detached_docker_completion_script(
422 container_name: &str,
423 policy: DockerContainerCleanupPolicy,
424 log_path: Option<&PathBuf>,
425 execution_id: Option<&str>,
426) -> String {
427 build_detached_docker_completion_script_with(
428 container_name,
429 policy,
430 log_path,
431 execution_id,
432 &DockerWatcherOptions::default(),
433 )
434}
435
436#[derive(Clone, Debug, Default, PartialEq, Eq)]
438pub struct DockerWatcherOptions {
439 pub since: Option<String>,
442 pub attempt_number: Option<u64>,
443 pub recover_on_kill: bool,
449}
450
451pub fn build_detached_docker_completion_script_with(
453 container_name: &str,
454 policy: DockerContainerCleanupPolicy,
455 log_path: Option<&PathBuf>,
456 execution_id: Option<&str>,
457 watcher: &DockerWatcherOptions,
458) -> String {
459 let quoted_name = shell_quote(container_name);
460 let mut parts = vec![build_cgroup_sampler_start_snippet(container_name)];
463 let cpu_start = crate::cpu_penalty_monitor::start_snippet(
464 execution_id,
465 container_name,
466 watcher.attempt_number,
467 );
468 if !cpu_start.is_empty() {
469 parts.push(cpu_start);
470 }
471 let mut exited = Vec::new();
474 let quoted_log_path = log_path.map(|path| shell_quote(&path.to_string_lossy()));
475
476 if let Some(quoted_log_path) = quoted_log_path.as_deref() {
477 let since = watcher
478 .since
479 .as_deref()
480 .map(|since| format!(" --since {}", shell_quote(since)))
481 .unwrap_or_default();
482 if let Some(number) = watcher.attempt_number {
483 let executable = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("start"));
484 parts.push(format!(
485 "docker logs -f --timestamps{} {} 2>&1 | {} {} {} {} {}",
486 since,
487 quoted_name,
488 shell_quote(&executable.to_string_lossy()),
489 crate::detached_output::INTERNAL_OUTPUT_FLAG,
490 quoted_log_path,
491 number,
492 shell_quote(watcher.since.as_deref().unwrap_or(""))
493 ));
494 } else {
495 parts.push(format!(
496 "docker logs -f{} {} >> {} 2>&1",
497 since, quoted_name, quoted_log_path
498 ));
499 }
500 parts.push(build_docker_wait_for_exit_snippet(
501 container_name,
502 Some(quoted_log_path),
503 ));
504 parts.push(crate::cpu_penalty_monitor::stop_snippet());
505 parts.push(build_cgroup_sampler_stop_snippet());
506 parts.push(build_docker_state_snippet(container_name));
507 parts.push(crate::exit_evidence::snippet(
508 container_name,
509 quoted_log_path,
510 ));
511
512 let memory = build_cgroup_memory_log_snippet(quoted_log_path);
513 let (capture_image, remove_image) =
514 crate::docker_snapshot_safety::snapshot_image_cleanup_snippets(
515 container_name,
516 &format!(">> {} 2>&1", quoted_log_path),
517 );
518 let remove = format!(
519 "{}; if docker rm -f {} >> {} 2>&1; then {}; fi; {}; {}",
520 capture_image,
521 quoted_name,
522 quoted_log_path,
523 remove_image,
524 build_docker_removal_note_snippet(container_name, quoted_log_path),
525 memory
526 );
527 let keep = format!(
530 "{}; {}; {}",
531 build_docker_post_mortem_snippet(container_name, quoted_log_path),
532 memory,
533 build_docker_kept_log_snippet(container_name, quoted_log_path)
534 );
535 match policy {
536 DockerContainerCleanupPolicy::Always => exited.push(remove),
537 DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
538 exited.push(format!(
539 "if {}; then {}; else {}; fi",
540 successful_non_oom_condition(),
541 remove,
542 keep
543 ))
544 }
545 DockerContainerCleanupPolicy::Keep => exited.push(keep),
548 }
549 exited.push(format!(
550 "{} >> {}",
551 create_shell_log_footer_snippet(),
552 quoted_log_path
553 ));
554 } else {
555 parts.push(format!("docker wait {} >/dev/null 2>&1", quoted_name));
556 parts.push(build_docker_wait_for_exit_snippet(container_name, None));
557 parts.push(crate::cpu_penalty_monitor::stop_snippet());
558 parts.push(build_cgroup_sampler_stop_snippet());
559 parts.push(build_docker_state_snippet(container_name));
560 let (capture_image, remove_image) =
561 crate::docker_snapshot_safety::snapshot_image_cleanup_snippets(
562 container_name,
563 ">/dev/null 2>&1",
564 );
565 match policy {
566 DockerContainerCleanupPolicy::Always => exited.push(format!(
567 "{}; if docker rm -f {} >/dev/null 2>&1; then {}; fi",
568 capture_image, quoted_name, remove_image
569 )),
570 DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
571 exited.push(format!(
572 "if {}; then {}; if docker rm -f {} >/dev/null 2>&1; then {}; fi; fi",
573 successful_non_oom_condition(),
574 capture_image,
575 quoted_name,
576 remove_image
577 ))
578 }
579 DockerContainerCleanupPolicy::Keep => {}
580 }
581 }
582
583 if let Some(execution_id) = execution_id {
584 exited.push(build_detached_finalize_snippet_with_attempt(
586 execution_id,
587 watcher.attempt_number,
588 ));
589 }
590
591 let still_running = quoted_log_path
592 .as_deref()
593 .map(|log| build_docker_still_running_note_snippet(container_name, log))
594 .unwrap_or_else(|| ":".to_string());
595 let exited = if exited.is_empty() {
596 ":".to_string()
597 } else {
598 exited.join("; ")
599 };
600 let recovery = match execution_id {
601 Some(execution_id) if watcher.recover_on_kill => format!(
602 "elif {}; then :; ",
603 crate::execution_recovery::build_recovery_snippet(execution_id)
604 ),
605 _ => String::new(),
606 };
607 parts.push(format!(
608 "if [ \"${}\" = true ]; then {}; {}else {}; fi",
609 shell_vars::RUNNING,
610 still_running,
611 recovery,
612 exited
613 ));
614
615 parts.join("; ")
616}
617
618pub(crate) fn start_detached_docker_completion_watcher_with(
619 container_name: &str,
620 policy: DockerContainerCleanupPolicy,
621 log_path: Option<&PathBuf>,
622 execution_id: Option<&str>,
623 watcher: &DockerWatcherOptions,
624) -> Result<(), String> {
625 let script = build_detached_docker_completion_script_with(
626 container_name,
627 policy,
628 log_path,
629 execution_id,
630 watcher,
631 );
632 Command::new("sh")
633 .args(["-c", &script])
634 .stdout(Stdio::null())
635 .stderr(Stdio::null())
636 .spawn()
637 .map(|_| ())
638 .map_err(|error| error.to_string())
639}
640
641pub(crate) struct AttachedDockerChild {
642 child: Child,
643 stdout_thread: Option<thread::JoinHandle<()>>,
644 stderr_thread: Option<thread::JoinHandle<()>>,
645}
646
647impl AttachedDockerChild {
648 pub(crate) fn wait(mut self) -> std::io::Result<ExitStatus> {
649 let status = self.child.wait();
650 if let Some(handle) = self.stdout_thread.take() {
651 let _ = handle.join();
652 }
653 if let Some(handle) = self.stderr_thread.take() {
654 let _ = handle.join();
655 }
656 status
657 }
658}
659
660pub(crate) fn spawn_attached_docker(
661 args: &[&str],
662 log_path: Option<&PathBuf>,
663) -> std::io::Result<AttachedDockerChild> {
664 if log_path.is_none() {
665 let child = Command::new(docker_command())
666 .args(args)
667 .stdin(Stdio::inherit())
668 .stdout(Stdio::inherit())
669 .stderr(Stdio::inherit())
670 .spawn()?;
671 return Ok(AttachedDockerChild {
672 child,
673 stdout_thread: None,
674 stderr_thread: None,
675 });
676 }
677
678 let mut child = Command::new(docker_command())
679 .args(args)
680 .stdin(Stdio::inherit())
681 .stdout(Stdio::piped())
682 .stderr(Stdio::piped())
683 .spawn()?;
684
685 let file = OpenOptions::new()
686 .create(true)
687 .append(true)
688 .open(log_path.unwrap())?;
689 let shared_log = Arc::new(Mutex::new(file));
690
691 let mut stdout_thread = None;
692 let mut stderr_thread = None;
693
694 if let Some(mut stdout) = child.stdout.take() {
695 let log = Arc::clone(&shared_log);
696 stdout_thread = Some(thread::spawn(move || {
697 let mut buffer = [0_u8; 8192];
698 let mut terminal = std::io::stdout();
699 while let Ok(size) = stdout.read(&mut buffer) {
700 if size == 0 {
701 break;
702 }
703 let chunk = &buffer[..size];
704 let _ = terminal.write_all(chunk);
705 let _ = terminal.flush();
706 if let Ok(mut file) = log.lock() {
707 let _ = file.write_all(chunk);
708 let _ = file.flush();
709 }
710 }
711 }));
712 }
713
714 if let Some(mut stderr) = child.stderr.take() {
715 let log = Arc::clone(&shared_log);
716 stderr_thread = Some(thread::spawn(move || {
717 let mut buffer = [0_u8; 8192];
718 let mut terminal = std::io::stderr();
719 while let Ok(size) = stderr.read(&mut buffer) {
720 if size == 0 {
721 break;
722 }
723 let chunk = &buffer[..size];
724 let _ = terminal.write_all(chunk);
725 let _ = terminal.flush();
726 if let Ok(mut file) = log.lock() {
727 let _ = file.write_all(chunk);
728 let _ = file.flush();
729 }
730 }
731 }));
732 }
733
734 Ok(AttachedDockerChild {
735 child,
736 stdout_thread,
737 stderr_thread,
738 })
739}
740
741#[cfg(test)]
742#[path = "docker_cleanup_cases.rs"]
743mod tests;