Skip to main content

start_command/
docker_cleanup.rs

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
27/// Exit codes a runtime produces when it aborts itself (SIGABRT / SIGSEGV).
28/// Node/V8 prints `FATAL ERROR: Reached heap limit ...` and aborts long before
29/// the container limit is reached, so the kernel never OOM-kills anything and
30/// `State.OOMKilled` stays `false` (issue #165).
31const SELF_ABORT_EXIT_CODES: [i32; 2] = [134, 139];
32
33/// Appended to the kept-container reason for a self-abort exit code, so the
34/// footer stops asserting the opposite of the `FATAL ERROR` printed a few lines
35/// above it. The footer is the string downstream tooling greps.
36const 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
38/// Shell fragment computing `$__start_command_reason` for the kept footer.
39pub(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
51/// Build the extra `docker run` arguments contributed by runtime options
52/// (--privileged, --env/-e, --volume/-v, --mount, --network,
53/// --network-alias, resource limits). Returned references borrow
54/// from `options`, which outlives the `docker run` invocation.
55pub 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    // Already in `--flag=value` form (issue #176).
85    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
183/// Read a finished container's post-mortem facts in a single `docker inspect`.
184///
185/// The attached path only ever inspected `State.OOMKilled`, which left its
186/// "Container kept for investigation" message unable to say *why* the container
187/// died — the same gap the detached watcher had (issue #171.1). Reading the
188/// facts once, before any `docker rm`, also makes them available to the removal
189/// path, where the container is about to stop existing (issue #171.3).
190pub(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    // `State.Error` is free-form text that may contain spaces, so it cannot
203    // ride along in the whitespace-separated template above.
204    let error = inspect("{{.State.Error}}");
205    Some(parse_docker_container_state(
206        container_name,
207        &state,
208        error.as_deref(),
209    ))
210}
211
212/// Turn the raw output of [`DOCKER_STATE_INSPECT_FORMAT`] into facts.
213///
214/// Split from the `docker inspect` call so the mapping — including the
215/// rejection of docker's zero time — is testable without a docker daemon or a
216/// process-wide `START_DOCKER_BIN` override.
217pub(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
244/// Write the post-mortem for an attached run into its log and return the same
245/// text for the console message.
246///
247/// Kept and removed containers both get their facts recorded — a kept container
248/// gets the full block, a removed one the single line — so the log of an
249/// attached run carries exactly what the detached watcher writes (issues
250/// #171.2 and #171.3).
251pub(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
286/// Why an attached container was kept. The OOM flag is container-wide (#180):
287/// a child OOM-killed under a command that exited on its own is not the
288/// command being OOM-killed.
289pub(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) -> bool {
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            return true;
320        } else {
321            message.push_str("\nWarning: failed to remove container after launch failure.");
322        }
323    } else if should_cleanup_docker_container(policy, exit_code, oom_killed) {
324        if remove_docker_container(container_name, log_path) {
325            message.push_str("\nContainer removed after completion.");
326            message.push_str(&post_mortem(true));
327            return true;
328        } else {
329            message.push_str("\nWarning: failed to remove container automatically.");
330            message.push_str(&format!(
331                "\nRemove when done: docker rm -f {container_name}"
332            ));
333        }
334    } else if policy == DockerContainerCleanupPolicy::Keep {
335        message.push('\n');
336        message.push_str(&docker_container_cleanup_instructions(container_name));
337        message.push_str(&post_mortem(false));
338    } else {
339        let tail = log_path
340            .and_then(|path| read_log_tail(&path.to_string_lossy(), FATAL_MARKER_TAIL_BYTES));
341        if crate::exit_reason::resolve_exit_reason(
342            Some(exit_code),
343            tail.as_deref(),
344            Some(oom_killed),
345            None,
346        )
347        .as_deref()
348            == Some(crate::exit_reason::CGROUP_OOM_EXIT_REASON)
349        {
350            message.push_str("\nContainer kept because Docker reports it was OOM-killed.");
351        } else {
352            message.push_str(attached_docker_kept_reason(exit_code, oom_killed));
353        }
354        // A runtime that aborts on its own memory limit never trips the
355        // container flag, so `oomKilled false` alone would contradict the
356        // `FATAL ERROR` the runtime just printed into this very log (issue
357        // #165). Best effort: the tail is read right after the child exits.
358        if let Some(memory) =
359            resolve_memory_exhaustion(Some(exit_code), tail.as_deref(), Some(oom_killed), None)
360        {
361            message.push_str(&format!(
362                "\nMemory exhaustion detected in the log: {}",
363                memory.memory_exhausted_reason
364            ));
365        }
366        message.push_str(&format!(
367            "\nRemove when done: docker rm -f {container_name}"
368        ));
369        message.push_str(&post_mortem(false));
370    }
371    false
372}
373
374pub(crate) fn remove_docker_container(container_name: &str, log_path: Option<&PathBuf>) -> bool {
375    let output = Command::new(docker_command())
376        .args(["rm", "-f", container_name])
377        .output();
378    match output {
379        Ok(output) => {
380            if let Some(path) = log_path {
381                let mut combined = String::new();
382                combined.push_str(&String::from_utf8_lossy(&output.stdout));
383                combined.push_str(&String::from_utf8_lossy(&output.stderr));
384                if !combined.is_empty() {
385                    let content = if combined.ends_with('\n') {
386                        combined
387                    } else {
388                        format!("{}\n", combined)
389                    };
390                    append_log_file(path, &content);
391                }
392            }
393            output.status.success()
394        }
395        Err(_) => false,
396    }
397}
398
399fn build_docker_kept_log_snippet(container_name: &str, quoted_log_path: &str) -> String {
400    let quoted_name = shell_quote(container_name);
401    format!(
402        "{}; 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\" {} {} {} {} >> {}",
403        build_docker_kept_reason_snippet(),
404        quoted_name, quoted_name, quoted_name, quoted_name, quoted_name, quoted_log_path
405    )
406}
407
408fn successful_non_oom_condition() -> &'static str {
409    "[ \"$__start_command_exit\" -eq 0 ] 2>/dev/null && [ \"$__start_command_oom\" != true ]"
410}
411
412/// Build the shell the detached completion watcher runs after the container is
413/// gone.
414///
415/// Order matters. The post-mortem block and the removal note go in *before* the
416/// footer, so the `Finished:`/`Exit Code:` pair stays the last thing in the log
417/// (issue #171.2); the store is finalized *after* the footer, so a record only
418/// becomes terminal once its log is complete (issue #170.1).
419///
420/// It only does any of that once the container has really exited: the return of
421/// `docker logs -f` / `docker wait` is not proof of exit (issue #174). A
422/// container that is somehow still running afterwards is never removed, gets no
423/// `Exit Code:` footer and is never finalized — its record stays `executing`.
424pub fn build_detached_docker_completion_script(
425    container_name: &str,
426    policy: DockerContainerCleanupPolicy,
427    log_path: Option<&PathBuf>,
428    execution_id: Option<&str>,
429) -> String {
430    build_detached_docker_completion_script_with(
431        container_name,
432        policy,
433        log_path,
434        execution_id,
435        &DockerWatcherOptions::default(),
436    )
437}
438
439/// Extra behaviour of a detached completion watcher (issue #176).
440#[derive(Clone, Debug, Default, PartialEq, Eq)]
441pub struct DockerWatcherOptions {
442    /// Limits `docker logs` to output after a restart, so a resumed run does
443    /// not copy the previous run's output into the log again.
444    pub since: Option<String>,
445    pub attempt_number: Option<u64>,
446    /// Hand a killed main process (exit 137, or `OOMKilled` without a usable
447    /// exit code, issue #178) to the recovery entry point first. When it
448    /// resumes the container, this watcher stops there — a new one follows
449    /// the resumed run — and cleanup, footer and finalization are left to
450    /// whichever watcher sees the last run end.
451    pub recover_on_kill: bool,
452}
453
454/// [`build_detached_docker_completion_script`] with [`DockerWatcherOptions`].
455pub fn build_detached_docker_completion_script_with(
456    container_name: &str,
457    policy: DockerContainerCleanupPolicy,
458    log_path: Option<&PathBuf>,
459    execution_id: Option<&str>,
460    watcher: &DockerWatcherOptions,
461) -> String {
462    let quoted_name = shell_quote(container_name);
463    // The container's cgroup disappears with it, so its memory counters are
464    // sampled while it runs (issue #182).
465    let mut parts = vec![build_cgroup_sampler_start_snippet(container_name)];
466    let cpu_start = crate::cpu_penalty_monitor::start_snippet(
467        execution_id,
468        container_name,
469        watcher.attempt_number,
470    );
471    if !cpu_start.is_empty() {
472        parts.push(cpu_start);
473    }
474    // Everything that assumes the container has exited: cleanup, footer and
475    // finalization. Guarded as a whole by `.State.Running` below.
476    // `removed` tells the finalizer whether the cleanup policy removed the
477    // container (issue #225).
478    let removed = crate::docker_post_mortem::shell_vars::REMOVED;
479    let mut exited = vec![format!("{}=false", removed)];
480    let quoted_log_path = log_path.map(|path| shell_quote(&path.to_string_lossy()));
481
482    if let Some(quoted_log_path) = quoted_log_path.as_deref() {
483        let since = watcher
484            .since
485            .as_deref()
486            .map(|since| format!(" --since {}", shell_quote(since)))
487            .unwrap_or_default();
488        if let Some(number) = watcher.attempt_number {
489            let executable = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("start"));
490            parts.push(format!(
491                "docker logs -f --timestamps{} {} 2>&1 | {} {} {} {} {}",
492                since,
493                quoted_name,
494                shell_quote(&executable.to_string_lossy()),
495                crate::detached_output::INTERNAL_OUTPUT_FLAG,
496                quoted_log_path,
497                number,
498                shell_quote(watcher.since.as_deref().unwrap_or(""))
499            ));
500        } else {
501            parts.push(format!(
502                "docker logs -f{} {} >> {} 2>&1",
503                since, quoted_name, quoted_log_path
504            ));
505        }
506        parts.push(build_docker_wait_for_exit_snippet(
507            container_name,
508            Some(quoted_log_path),
509        ));
510        parts.push(crate::cpu_penalty_monitor::stop_snippet());
511        parts.push(build_cgroup_sampler_stop_snippet());
512        parts.push(build_docker_state_snippet(container_name));
513        parts.push(crate::exit_evidence::snippet(
514            container_name,
515            quoted_log_path,
516        ));
517
518        let memory = build_cgroup_memory_log_snippet(quoted_log_path);
519        let (capture_image, remove_image) =
520            crate::docker_snapshot_safety::snapshot_image_cleanup_snippets(
521                container_name,
522                &format!(">> {} 2>&1", quoted_log_path),
523            );
524        let remove = format!(
525            "{}; if docker rm -f {} >> {} 2>&1; then {}=true; {}; fi; {}; {}",
526            capture_image,
527            quoted_name,
528            quoted_log_path,
529            removed,
530            remove_image,
531            build_docker_removal_note_snippet(container_name, quoted_log_path),
532            memory
533        );
534        // A kept container is exactly the case the user will investigate, so it
535        // gets the full post-mortem before the copy-paste instructions.
536        let keep = format!(
537            "{}; {}; {}",
538            build_docker_post_mortem_snippet(container_name, quoted_log_path),
539            memory,
540            build_docker_kept_log_snippet(container_name, quoted_log_path)
541        );
542        match policy {
543            DockerContainerCleanupPolicy::Always => exited.push(remove),
544            DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
545                exited.push(format!(
546                    "if {}; then {}; else {}; fi",
547                    successful_non_oom_condition(),
548                    remove,
549                    keep
550                ))
551            }
552            // Previously wrote nothing at all: the container is always kept, so
553            // the log said nothing about why it stopped (issue #171.2).
554            DockerContainerCleanupPolicy::Keep => exited.push(keep),
555        }
556        exited.push(format!(
557            "{} >> {}",
558            create_shell_log_footer_snippet(),
559            quoted_log_path
560        ));
561    } else {
562        parts.push(format!("docker wait {} >/dev/null 2>&1", quoted_name));
563        parts.push(build_docker_wait_for_exit_snippet(container_name, None));
564        parts.push(crate::cpu_penalty_monitor::stop_snippet());
565        parts.push(build_cgroup_sampler_stop_snippet());
566        parts.push(build_docker_state_snippet(container_name));
567        let (capture_image, remove_image) =
568            crate::docker_snapshot_safety::snapshot_image_cleanup_snippets(
569                container_name,
570                ">/dev/null 2>&1",
571            );
572        match policy {
573            DockerContainerCleanupPolicy::Always => exited.push(format!(
574                "{}; if docker rm -f {} >/dev/null 2>&1; then {}=true; {}; fi",
575                capture_image, quoted_name, removed, remove_image
576            )),
577            DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
578                exited.push(format!(
579                    "if {}; then {}; if docker rm -f {} >/dev/null 2>&1; then {}=true; {}; fi; fi",
580                    successful_non_oom_condition(),
581                    capture_image,
582                    quoted_name,
583                    removed,
584                    remove_image
585                ))
586            }
587            DockerContainerCleanupPolicy::Keep => {}
588        }
589    }
590
591    if let Some(execution_id) = execution_id {
592        // Last, so the record is only marked terminal once the log is complete.
593        exited.push(build_detached_finalize_snippet_with_attempt(
594            execution_id,
595            watcher.attempt_number,
596        ));
597    }
598
599    let still_running = quoted_log_path
600        .as_deref()
601        .map(|log| build_docker_still_running_note_snippet(container_name, log))
602        .unwrap_or_else(|| ":".to_string());
603    let exited = if exited.is_empty() {
604        ":".to_string()
605    } else {
606        exited.join("; ")
607    };
608    let recovery = match execution_id {
609        Some(execution_id) if watcher.recover_on_kill => format!(
610            "elif {}; then :; ",
611            crate::execution_recovery::build_recovery_snippet(execution_id)
612        ),
613        _ => String::new(),
614    };
615    parts.push(format!(
616        "if [ \"${}\" = true ]; then {}; {}else {}; fi",
617        shell_vars::RUNNING,
618        still_running,
619        recovery,
620        exited
621    ));
622
623    parts.join("; ")
624}
625
626pub(crate) fn start_detached_docker_completion_watcher_with(
627    container_name: &str,
628    policy: DockerContainerCleanupPolicy,
629    log_path: Option<&PathBuf>,
630    execution_id: Option<&str>,
631    watcher: &DockerWatcherOptions,
632) -> Result<(), String> {
633    let script = build_detached_docker_completion_script_with(
634        container_name,
635        policy,
636        log_path,
637        execution_id,
638        watcher,
639    );
640    Command::new("sh")
641        .args(["-c", &script])
642        .stdout(Stdio::null())
643        .stderr(Stdio::null())
644        .spawn()
645        .map(|_| ())
646        .map_err(|error| error.to_string())
647}
648
649pub(crate) struct AttachedDockerChild {
650    child: Child,
651    stdout_thread: Option<thread::JoinHandle<()>>,
652    stderr_thread: Option<thread::JoinHandle<()>>,
653}
654
655impl AttachedDockerChild {
656    pub(crate) fn wait(mut self) -> std::io::Result<ExitStatus> {
657        let status = self.child.wait();
658        if let Some(handle) = self.stdout_thread.take() {
659            let _ = handle.join();
660        }
661        if let Some(handle) = self.stderr_thread.take() {
662            let _ = handle.join();
663        }
664        status
665    }
666}
667
668pub(crate) fn spawn_attached_docker(
669    args: &[&str],
670    log_path: Option<&PathBuf>,
671) -> std::io::Result<AttachedDockerChild> {
672    if log_path.is_none() {
673        let child = Command::new(docker_command())
674            .args(args)
675            .stdin(Stdio::inherit())
676            .stdout(Stdio::inherit())
677            .stderr(Stdio::inherit())
678            .spawn()?;
679        return Ok(AttachedDockerChild {
680            child,
681            stdout_thread: None,
682            stderr_thread: None,
683        });
684    }
685
686    let mut child = Command::new(docker_command())
687        .args(args)
688        .stdin(Stdio::inherit())
689        .stdout(Stdio::piped())
690        .stderr(Stdio::piped())
691        .spawn()?;
692
693    let file = OpenOptions::new()
694        .create(true)
695        .append(true)
696        .open(log_path.unwrap())?;
697    let shared_log = Arc::new(Mutex::new(file));
698
699    let mut stdout_thread = None;
700    let mut stderr_thread = None;
701
702    if let Some(mut stdout) = child.stdout.take() {
703        let log = Arc::clone(&shared_log);
704        stdout_thread = Some(thread::spawn(move || {
705            let mut buffer = [0_u8; 8192];
706            let mut terminal = std::io::stdout();
707            while let Ok(size) = stdout.read(&mut buffer) {
708                if size == 0 {
709                    break;
710                }
711                let chunk = &buffer[..size];
712                let _ = terminal.write_all(chunk);
713                let _ = terminal.flush();
714                if let Ok(mut file) = log.lock() {
715                    let _ = file.write_all(chunk);
716                    let _ = file.flush();
717                }
718            }
719        }));
720    }
721
722    if let Some(mut stderr) = child.stderr.take() {
723        let log = Arc::clone(&shared_log);
724        stderr_thread = Some(thread::spawn(move || {
725            let mut buffer = [0_u8; 8192];
726            let mut terminal = std::io::stderr();
727            while let Ok(size) = stderr.read(&mut buffer) {
728                if size == 0 {
729                    break;
730                }
731                let chunk = &buffer[..size];
732                let _ = terminal.write_all(chunk);
733                let _ = terminal.flush();
734                if let Ok(mut file) = log.lock() {
735                    let _ = file.write_all(chunk);
736                    let _ = file.flush();
737                }
738            }
739        }));
740    }
741
742    Ok(AttachedDockerChild {
743        child,
744        stdout_thread,
745        stderr_thread,
746    })
747}
748
749#[cfg(test)]
750#[path = "docker_cleanup_cases.rs"]
751mod tests;