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