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