Skip to main content

start_command/
execution_cleanup.rs

1//! `$ --cleanup <uuid-or-session-name>` (issue #224): remove the Docker
2//! containers of one execution and the snapshot images they were derived from.
3//!
4//! For callers that remove containers themselves: start-command's completion
5//! watcher removes a snapshot image only when it removes the container. Nothing
6//! here is forced, so Docker keeps any container that is still running and any
7//! image that something else still uses; those are reported as retained.
8//!
9//! The containers are the current one and every earlier container of a
10//! `--resume` chain (`sessionNameHistory`). The images are the
11//! `start-command-resume/*` images named by the `start-command.snapshot-image`
12//! label of those containers, by the record, and by the `-resume-N` names of
13//! the chain, removed newest first.
14
15use crate::docker_cleanup::docker_command;
16use crate::docker_snapshot_safety::SNAPSHOT_IMAGE_LABEL;
17use crate::execution_control::{CommandRunOutput, CommandRunner};
18use crate::execution_resume::build_snapshot_image_name;
19use crate::execution_store::{ExecutionRecord, ExecutionStore};
20use crate::query_commands::QueryOutcome;
21use serde_json::Value;
22
23/// Whether `image` is a wrapper-owned `start-command-resume/<name>:<N>` image.
24pub fn is_snapshot_image(image: &str) -> bool {
25    let Some((name, attempt)) = image
26        .strip_prefix("start-command-resume/")
27        .and_then(|rest| rest.split_once(':'))
28    else {
29        return false;
30    };
31    !name.is_empty()
32        && name
33            .chars()
34            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c))
35        && !attempt.is_empty()
36        && attempt.chars().all(|c| c.is_ascii_digit())
37}
38
39/// Snapshot images implied by a `<name>-resume-<N>` chain, newest first.
40pub fn chain_images(container_name: &str) -> Vec<String> {
41    let mut images = Vec::new();
42    let mut current = container_name;
43    while let Some((base, attempt)) = current.rsplit_once("-resume-") {
44        let Ok(number) = attempt.parse::<u64>() else {
45            break;
46        };
47        if base.is_empty() || !attempt.chars().all(|c| c.is_ascii_digit()) {
48            break;
49        }
50        images.push(build_snapshot_image_name(base, number));
51        current = base;
52    }
53    images
54}
55
56fn attempt_of(image: &str) -> u64 {
57    image
58        .rsplit_once(':')
59        .and_then(|(_, attempt)| attempt.parse().ok())
60        .unwrap_or(0)
61}
62
63/// The containers and images `--cleanup <execution>` would remove.
64pub fn plan_execution_cleanup<R: CommandRunner + ?Sized>(
65    record: &ExecutionRecord,
66    runner: &R,
67) -> (Vec<String>, Vec<String>) {
68    let option = |key: &str| {
69        record
70            .options
71            .get(key)
72            .and_then(Value::as_str)
73            .filter(|value| !value.is_empty())
74    };
75    let history = record
76        .options
77        .get("sessionNameHistory")
78        .and_then(Value::as_array)
79        .map(|names| names.iter().filter_map(Value::as_str).collect::<Vec<_>>())
80        .unwrap_or_default();
81    let mut containers: Vec<String> = Vec::new();
82    for name in option("sessionName")
83        .or_else(|| option("containerId"))
84        .into_iter()
85        .chain(history.into_iter().rev())
86    {
87        if !name.is_empty() && !containers.iter().any(|known| known == name) {
88            containers.push(name.to_string());
89        }
90    }
91    let docker = docker_command().to_string_lossy().to_string();
92    let mut images: Vec<String> = option("snapshotImage")
93        .map(String::from)
94        .into_iter()
95        .collect();
96    for container in &containers {
97        let labelled = runner.run(
98            &docker,
99            &[
100                "inspect".into(),
101                "-f".into(),
102                format!("{{{{index .Config.Labels \"{}\"}}}}", SNAPSHOT_IMAGE_LABEL),
103                container.clone(),
104            ],
105        );
106        if labelled.success {
107            images.push(labelled.stdout.trim().to_string());
108        }
109        images.extend(chain_images(container));
110    }
111    let mut unique: Vec<String> = Vec::new();
112    for image in images {
113        if is_snapshot_image(&image) && !unique.contains(&image) {
114            unique.push(image);
115        }
116    }
117    // Stable, like Array.prototype.sort: equal attempts keep their order.
118    unique.sort_by_key(|image| std::cmp::Reverse(attempt_of(image)));
119    (containers, unique)
120}
121
122/// What happened to one Docker object.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum CleanupState {
125    Removed,
126    Absent,
127    Retained,
128    /// Dry run: the object exists and would be removed.
129    Present,
130}
131
132pub struct CleanupOutcome {
133    pub kind: &'static str,
134    pub name: String,
135    pub state: CleanupState,
136    pub detail: Option<String>,
137}
138
139fn error_text(result: &CommandRunOutput) -> String {
140    let stderr = result.stderr.trim();
141    if !stderr.is_empty() {
142        return stderr.to_string();
143    }
144    result.error.clone().unwrap_or_else(|| {
145        format!(
146            "exit code {}",
147            result
148                .status
149                .map(|code| code.to_string())
150                .unwrap_or_else(|| "null".into())
151        )
152    })
153}
154
155fn is_not_found(detail: &str) -> bool {
156    let lower = detail.to_lowercase();
157    ["no such container", "no such image", "no such object"]
158        .iter()
159        .any(|pattern| lower.contains(pattern))
160}
161
162/// Remove (or, with `dry_run`, look up) one object without force.
163fn remove_object<R: CommandRunner + ?Sized>(
164    kind: &'static str,
165    name: &str,
166    runner: &R,
167    dry_run: bool,
168) -> CleanupOutcome {
169    let args: Vec<String> = match (dry_run, kind) {
170        (true, _) => vec![
171            if kind == "image" {
172                "image"
173            } else {
174                "container"
175            }
176            .into(),
177            "inspect".into(),
178            "--format".into(),
179            "{{.Id}}".into(),
180            name.into(),
181        ],
182        (false, "image") => vec!["rmi".into(), name.into()],
183        (false, _) => vec!["rm".into(), name.into()],
184    };
185    let result = runner.run(&docker_command().to_string_lossy(), &args);
186    let outcome = |state, detail| CleanupOutcome {
187        kind,
188        name: name.to_string(),
189        state,
190        detail,
191    };
192    if result.success {
193        return outcome(
194            if dry_run {
195                CleanupState::Present
196            } else {
197                CleanupState::Removed
198            },
199            None,
200        );
201    }
202    let detail = error_text(&result);
203    if result.error.is_none() && is_not_found(&detail) {
204        return outcome(CleanupState::Absent, None);
205    }
206    outcome(CleanupState::Retained, Some(detail))
207}
208
209fn format_outcome(outcome: &CleanupOutcome) -> String {
210    let label = match outcome.state {
211        CleanupState::Removed => "Removed",
212        CleanupState::Absent => "Already gone:",
213        CleanupState::Retained => "Retained",
214        CleanupState::Present => "Would remove",
215    };
216    let line = format!("{} {} {}", label, outcome.kind, outcome.name);
217    match &outcome.detail {
218        Some(detail) => format!("{} ({})", line, detail),
219        None => line,
220    }
221}
222
223fn failure(error: String) -> QueryOutcome {
224    QueryOutcome {
225        success: false,
226        output: None,
227        error: Some(error),
228        exit_code: None,
229    }
230}
231
232/// Remove one execution's containers, then its snapshot images.
233pub fn cleanup_execution<R: CommandRunner + ?Sized>(
234    store: Option<&ExecutionStore>,
235    identifier: &str,
236    dry_run: bool,
237    runner: &R,
238) -> QueryOutcome {
239    let Some(store) = store else {
240        return failure("Execution tracking is disabled.".into());
241    };
242    let Some(record) = store.get(identifier) else {
243        return failure(format!(
244            "No execution found with UUID or session name: {}",
245            identifier
246        ));
247    };
248    if record.options.get("isolated").and_then(Value::as_str) != Some("docker") {
249        return failure(format!(
250            "--cleanup <execution> only removes Docker containers and images; {} is not a Docker execution.",
251            identifier
252        ));
253    }
254    let (containers, images) = plan_execution_cleanup(&record, runner);
255    let mut outcomes: Vec<CleanupOutcome> = containers
256        .iter()
257        .map(|name| remove_object("container", name, runner, dry_run))
258        .collect();
259    // Images go only after their containers; Docker refuses a used image.
260    for image in &images {
261        outcomes.push(remove_object("image", image, runner, dry_run));
262    }
263    if !dry_run
264        && outcomes.first().is_some_and(|current| {
265            matches!(current.state, CleanupState::Removed | CleanupState::Absent)
266        })
267    {
268        let _ = store.mark_container_removed(&record.uuid, &chrono::Utc::now().to_rfc3339());
269    }
270    let retained = outcomes
271        .iter()
272        .filter(|outcome| outcome.state == CleanupState::Retained)
273        .count();
274    let output = if outcomes.is_empty() {
275        "No containers or snapshot images recorded for this execution.".to_string()
276    } else {
277        outcomes
278            .iter()
279            .map(format_outcome)
280            .collect::<Vec<_>>()
281            .join("\n")
282    };
283    QueryOutcome {
284        success: retained == 0,
285        output: Some(output),
286        error: (retained > 0).then(|| {
287            format!(
288                "{} Docker object(s) retained; nothing was forced.",
289                retained
290            )
291        }),
292        exit_code: None,
293    }
294}