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