Skip to main content

runner_manager_agent/
docker_cleanup.rs

1//! Attempt-scoped Docker accounting for native Linux runners.
2//!
3//! The journal is outside the disposable workspace. Only creations passing
4//! through the private proxy are attributed; absence of a container reference
5//! or an old creation date is never evidence that a volume is ours.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::fs;
9use std::io::{self, Read, Write};
10use std::path::{Path, PathBuf};
11
12use runner_manager_domain::model::{AttemptId, HostId};
13use runner_manager_platform::process::ProcessIdentity;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17pub mod http;
18pub mod policy;
19#[cfg(target_os = "linux")]
20mod proxy;
21#[cfg(test)]
22mod tests;
23
24pub const HOST_LABEL: &str = "runner-manager.host";
25pub const ATTEMPT_LABEL: &str = "runner-manager.attempt";
26pub const OPERATION_LABEL: &str = "runner-manager.operation";
27pub const RETAIN_LABEL: &str = "runner-manager.retain";
28const SCHEMA: u32 = 1;
29const PENDING_ENGINE: &str = "runner-manager-pending-engine";
30const MAX_JOURNAL_BYTES: u64 = 8 * 1024 * 1024;
31
32#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum Mode {
35    Off,
36    #[default]
37    Report,
38    Apply,
39}
40
41/// Docker accounting's settings, in the configuration directory.
42pub const CONFIG_FILE: &str = "docker-cleanup.json";
43
44#[derive(Debug, Default, Clone, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct Config {
47    pub mode: Mode,
48}
49
50impl Config {
51    pub fn load(path: &Path) -> io::Result<Self> {
52        runner_manager_platform::settings_file::load(path)
53    }
54}
55
56#[derive(Debug, Clone)]
57pub struct DockerCleanup {
58    pub host: HostId,
59    pub root: PathBuf,
60    pub config: PathBuf,
61    /// The Docker policy's settings. See [`policy`].
62    pub policy_config: PathBuf,
63    /// Where the Docker policy records violations.
64    pub policy_log: PathBuf,
65    /// The configuration directory, where the protected paths are listed.
66    pub config_dir: PathBuf,
67    /// Embedders may supply the manager binary that implements companion mode.
68    pub companion: Option<PathBuf>,
69}
70
71impl DockerCleanup {
72    pub fn new(host: HostId, config_dir: &Path, state_dir: &Path) -> Self {
73        Self {
74            host,
75            root: state_dir.join("docker-cleanup"),
76            config: config_dir.join(CONFIG_FILE),
77            policy_config: config_dir.join(policy::CONFIG_FILE),
78            policy_log: state_dir.join(policy::LOG_DIR).join(policy::LOG_FILE),
79            config_dir: config_dir.to_path_buf(),
80            companion: None,
81        }
82    }
83
84    pub fn supported() -> bool {
85        cfg!(target_os = "linux")
86    }
87
88    /// A remote endpoint or explicitly selected context stays under operator
89    /// control. Dependency-cache environment variables are never changed.
90    ///
91    /// `listen`, when given, is where the proxy's socket goes and the account
92    /// it is handed to: a runner running as another account cannot reach the
93    /// default under `state/`.
94    pub fn launch_environment(
95        &self,
96        attempt: AttemptId,
97        repository: &str,
98        host_env: &runner_manager_platform::runner_env::RunnerEnv,
99        listen: Option<(
100            PathBuf,
101            runner_manager_platform::runner_account::RunnerAccount,
102        )>,
103        own_directories: &[PathBuf],
104    ) -> io::Result<Option<std::ffi::OsString>> {
105        if !Self::supported() || Config::load(&self.config)?.mode == Mode::Off {
106            return Ok(None);
107        }
108        let inherited = |name: &str| {
109            host_env
110                .entries()
111                .find(|(key, _)| key.eq_ignore_ascii_case(name))
112                .map(|(_, value)| value.to_owned())
113                .or_else(|| std::env::var(name).ok())
114        };
115        if inherited("DOCKER_CONTEXT").is_some_and(|value| !value.is_empty()) {
116            tracing::info!(event="docker_accounting_bypassed",attempt_id=%attempt,reason="explicit_context");
117            return Ok(None);
118        }
119        let selected_host = inherited("DOCKER_HOST");
120        if selected_host.is_none() {
121            let docker_config = inherited("DOCKER_CONFIG")
122                .map(PathBuf::from)
123                .or_else(|| inherited("HOME").map(|home| PathBuf::from(home).join(".docker")));
124            if let Some(path) = docker_config.map(|directory| directory.join("config.json")) {
125                match fs::read(path) {
126                    Ok(bytes) => {
127                        let value: Value = serde_json::from_slice(&bytes).map_err(invalid)?;
128                        if value
129                            .get("currentContext")
130                            .and_then(Value::as_str)
131                            .is_some_and(|context| !context.is_empty() && context != "default")
132                        {
133                            tracing::info!(event="docker_accounting_bypassed",attempt_id=%attempt,reason="stored_context");
134                            return Ok(None);
135                        }
136                    }
137                    Err(error) if error.kind() == io::ErrorKind::NotFound => {}
138                    Err(error) => return Err(error),
139                }
140            }
141        }
142        let upstream = selected_host.unwrap_or_else(|| "unix:///var/run/docker.sock".into());
143        let Some(socket) = upstream.strip_prefix("unix://") else {
144            tracing::info!(event="docker_accounting_bypassed",attempt_id=%attempt,reason="remote_endpoint");
145            return Ok(None);
146        };
147        let socket = PathBuf::from(socket);
148        if !socket.is_absolute() {
149            return Err(invalid("Docker Unix endpoint must be absolute"));
150        }
151        #[cfg(target_os = "linux")]
152        {
153            let policy = self.policy_context(own_directories)?;
154            proxy::launch(self, attempt, repository, &socket, listen, policy)
155        }
156        #[cfg(not(target_os = "linux"))]
157        {
158            let _ = (attempt, repository, socket, listen, own_directories);
159            Ok(None)
160        }
161    }
162
163    /// What this launch's proxy checks: the configured mode, the job's own
164    /// directories and the operator's allowed ones, and the protected paths,
165    /// resolved as the host sees them. `None` when the policy is off and no
166    /// path is protected.
167    ///
168    /// # Errors
169    ///
170    /// When the policy's settings cannot be read: a launch then fails rather
171    /// than run with a policy nobody chose.
172    pub fn policy_context(
173        &self,
174        own_directories: &[PathBuf],
175    ) -> io::Result<Option<policy::Context>> {
176        let config = policy::Config::load(&self.policy_config)?;
177        let protected: Vec<_> =
178            runner_manager_platform::service::ProtectedPaths::load(&self.config_dir)?
179                .paths
180                .iter()
181                .filter_map(|path| policy::resolve(path))
182                .collect();
183        if config.mode == policy::Mode::Off && protected.is_empty() {
184            return Ok(None);
185        }
186        let allowed = own_directories
187            .iter()
188            .chain(&config.allow)
189            .filter_map(|path| policy::resolve(path))
190            .collect();
191        Ok(Some(policy::Context {
192            mode: config.mode,
193            allowed,
194            protected,
195            log: self.policy_log.clone(),
196        }))
197    }
198
199    /// Authoritative attempt states are supplied by Store, never inferred from
200    /// an absent workspace or a stale timestamp. Unknown attempts are retained.
201    pub fn sweep(
202        &self,
203        attempts: &[runner_manager_domain::attempt::RunnerAttempt],
204        dry_run: bool,
205    ) -> io::Result<SweepReport> {
206        self.sweep_with_lookup(
207            &|id| Ok(attempts.iter().find(|attempt| attempt.id == id).cloned()),
208            dry_run,
209        )
210    }
211
212    /// Query only journalled attempt IDs rather than loading years of host history.
213    pub fn sweep_with_lookup(
214        &self,
215        lookup: &dyn Fn(
216            AttemptId,
217        )
218            -> io::Result<Option<runner_manager_domain::attempt::RunnerAttempt>>,
219        dry_run: bool,
220    ) -> io::Result<SweepReport> {
221        #[cfg(target_os = "linux")]
222        {
223            proxy::sweep(self, lookup, dry_run)
224        }
225        #[cfg(not(target_os = "linux"))]
226        {
227            let _ = (lookup, dry_run);
228            Ok(SweepReport::default())
229        }
230    }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum Kind {
236    Container,
237    Volume,
238    Network,
239}
240
241impl Kind {
242    fn collection(self) -> &'static str {
243        match self {
244            Self::Container => "containers",
245            Self::Volume => "volumes",
246            Self::Network => "networks",
247        }
248    }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct Intent {
254    pub id: String,
255    pub kind: Kind,
256    /// Digests, not raw workflow paths: destinations can contain credentials.
257    pub explicit_volume_destinations: BTreeSet<String>,
258    pub inherits_volumes: bool,
259    /// True only after Docker acknowledged creation and every association was
260    /// durably captured. An ambiguous request must retain its recovery intent.
261    pub settled: bool,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct Resource {
267    pub kind: Kind,
268    pub id: String,
269    pub created: String,
270    pub operation: String,
271    /// Implicit image volumes do not inherit container labels. Their exact
272    /// name + creation fingerprint are recorded before create returns.
273    pub implicit_volume: bool,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct Journal {
279    pub schema: u32,
280    pub host: HostId,
281    pub attempt: AttemptId,
282    pub repository: String,
283    pub upstream: PathBuf,
284    pub engine: String,
285    pub proxy: Option<ProcessIdentity>,
286    pub intents: Vec<Intent>,
287    pub resources: Vec<Resource>,
288    #[serde(default)]
289    pub last_reported_secs: Option<u64>,
290    /// Where the proxy listens and who its socket is handed to, when the
291    /// runner runs as another account; otherwise `s` beside this journal.
292    #[serde(default)]
293    pub listen: Option<Listen>,
294    /// What the proxy checks each request against. `None` checks nothing.
295    #[serde(default)]
296    pub policy: Option<policy::Context>,
297}
298
299/// A proxy socket outside `state/`, for a runner running as another account.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301pub struct Listen {
302    pub socket: PathBuf,
303    pub uid: u32,
304    pub gid: u32,
305}
306
307impl Journal {
308    pub fn new(
309        host: HostId,
310        attempt: AttemptId,
311        repository: String,
312        upstream: PathBuf,
313        engine: String,
314    ) -> Self {
315        Self {
316            schema: SCHEMA,
317            host,
318            attempt,
319            repository,
320            upstream,
321            engine,
322            proxy: None,
323            intents: Vec::new(),
324            resources: Vec::new(),
325            last_reported_secs: None,
326            listen: None,
327            policy: None,
328        }
329    }
330
331    /// The socket the proxy for this journal, kept in `directory`, listens on.
332    #[must_use]
333    pub fn socket(&self, directory: &Path) -> PathBuf {
334        self.listen
335            .as_ref()
336            .map_or_else(|| directory.join("s"), |listen| listen.socket.clone())
337    }
338
339    pub fn load(path: &Path) -> io::Result<Self> {
340        let metadata = fs::symlink_metadata(path)?;
341        if !metadata.is_file() || metadata.len() > MAX_JOURNAL_BYTES {
342            return Err(invalid("invalid Docker journal file"));
343        }
344        let mut bytes = Vec::new();
345        fs::File::open(path)?
346            .take(MAX_JOURNAL_BYTES + 1)
347            .read_to_end(&mut bytes)?;
348        if bytes.len() as u64 > MAX_JOURNAL_BYTES {
349            return Err(invalid("Docker journal grew beyond its size limit"));
350        }
351        let value: Self = serde_json::from_slice(&bytes).map_err(invalid)?;
352        if value.schema != SCHEMA
353            || !value.upstream.is_absolute()
354            || value.engine.is_empty()
355            || (value.engine == PENDING_ENGINE
356                && (!value.intents.is_empty()
357                    || !value.resources.is_empty()
358                    || value.proxy.is_some()))
359            || value
360                .intents
361                .iter()
362                .any(|intent| !valid_operation(&intent.id))
363            || value
364                .resources
365                .iter()
366                .any(|resource| !valid_id(&resource.id) || !valid_operation(&resource.operation))
367        {
368            return Err(invalid("invalid Docker journal identity"));
369        }
370        let operations: BTreeSet<_> = value
371            .intents
372            .iter()
373            .map(|intent| intent.id.as_str())
374            .collect();
375        let resources: BTreeSet<_> = value
376            .resources
377            .iter()
378            .map(|resource| (resource.kind.collection(), resource.id.as_str()))
379            .collect();
380        if operations.len() != value.intents.len()
381            || resources.len() != value.resources.len()
382            || (!value.intents.is_empty() && value.proxy.is_none())
383            || value.intents.iter().any(|intent| {
384                intent
385                    .explicit_volume_destinations
386                    .iter()
387                    .any(|digest| !valid_id(digest))
388                    || (intent.kind != Kind::Container
389                        && (!intent.explicit_volume_destinations.is_empty()
390                            || intent.inherits_volumes))
391            })
392            || value.resources.iter().any(|resource| {
393                value
394                    .intents
395                    .iter()
396                    .find(|intent| intent.id == resource.operation)
397                    .is_none_or(|intent| {
398                        if resource.implicit_volume {
399                            resource.kind != Kind::Volume
400                                || intent.kind != Kind::Container
401                                || intent.inherits_volumes
402                        } else {
403                            resource.kind != intent.kind
404                        }
405                    })
406            })
407        {
408            return Err(invalid("invalid Docker journal resource associations"));
409        }
410        Ok(value)
411    }
412
413    fn labels(&self, operation: &str) -> BTreeMap<String, String> {
414        BTreeMap::from([
415            (HOST_LABEL.into(), self.host.to_string()),
416            (ATTEMPT_LABEL.into(), self.attempt.to_string()),
417            (OPERATION_LABEL.into(), operation.into()),
418        ])
419    }
420}
421
422pub fn write_json(path: &Path, value: &impl Serialize) -> io::Result<()> {
423    let parent = path
424        .parent()
425        .ok_or_else(|| invalid("missing journal parent"))?;
426    fs::create_dir_all(parent)?;
427    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
428    serde_json::to_writer(temporary.as_file_mut(), value).map_err(invalid)?;
429    temporary.flush()?;
430    // Keep the last readable journal if accumulated accounting metadata grows
431    // beyond the reader's bound. An intent that cannot be recorded is never
432    // forwarded, and an acknowledged creation still has its recovery intent.
433    if temporary.as_file().metadata()?.len() > MAX_JOURNAL_BYTES {
434        return Err(invalid("Docker journal exceeds its size limit"));
435    }
436    temporary.as_file().sync_all()?;
437    temporary.persist(path).map_err(|error| error.error)?;
438    #[cfg(unix)]
439    fs::File::open(parent)?.sync_all()?;
440    Ok(())
441}
442
443pub(crate) fn invalid(error: impl std::fmt::Display) -> io::Error {
444    io::Error::other(error.to_string())
445}
446
447fn valid_id(value: &str) -> bool {
448    value.len() == 64
449        && value
450            .bytes()
451            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
452}
453
454fn valid_operation(value: &str) -> bool {
455    uuid::Uuid::try_parse(value).is_ok_and(|id| id.to_string() == value)
456}
457
458fn labels_of(value: &Value, kind: Kind) -> Value {
459    match kind {
460        Kind::Container => value.pointer("/Config/Labels"),
461        _ => value.get("Labels"),
462    }
463    .cloned()
464    .unwrap_or(Value::Null)
465}
466
467fn retained(labels: &Value) -> bool {
468    labels.get(RETAIN_LABEL).and_then(Value::as_str) == Some("true")
469}
470
471/// Return an intent only for disposable creations. Explicit named volumes and
472/// builder state are persistent by default, even if no container uses them now.
473pub fn creation_intent(
474    journal: &Journal,
475    path: &str,
476    body: &mut Value,
477) -> io::Result<Option<Intent>> {
478    let endpoint = path.split('?').next().unwrap_or(path);
479    let kind = if endpoint.ends_with("/containers/create") {
480        Kind::Container
481    } else if endpoint.ends_with("/volumes/create") {
482        Kind::Volume
483    } else if endpoint.ends_with("/networks/create") {
484        Kind::Network
485    } else {
486        return Ok(None);
487    };
488    let object = body
489        .as_object_mut()
490        .ok_or_else(|| invalid("expected Docker creation object"))?;
491    let labels = object.get("Labels").cloned().unwrap_or(Value::Null);
492    if retained(&labels) {
493        return Ok(None);
494    }
495    if kind == Kind::Volume
496        && object
497            .get("Name")
498            .and_then(Value::as_str)
499            .is_some_and(|name| !name.is_empty())
500    {
501        return Ok(None);
502    }
503    let host_config = object.get("HostConfig").cloned().unwrap_or(Value::Null);
504    let mounts = host_config.get("Mounts").and_then(Value::as_array);
505    let name = path
506        .split_once('?')
507        .into_iter()
508        .flat_map(|(_, query)| query.split('&'))
509        .find_map(|field| field.strip_prefix("name="));
510    // Buildx's docker-container driver uses a named state volume mounted here.
511    // Preserve custom BuildKit images too, not only moby/buildkit tags.
512    if kind == Kind::Container
513        && (name.is_some_and(|name| name.starts_with("buildx_buildkit_"))
514            || mounts.is_some_and(|mounts| {
515                mounts.iter().any(|mount| {
516                    mount.get("Target").and_then(Value::as_str) == Some("/var/lib/buildkit")
517                })
518            })
519            || host_config
520                .get("Binds")
521                .and_then(Value::as_array)
522                .is_some_and(|binds| {
523                    binds.iter().filter_map(Value::as_str).any(|bind| {
524                        let mut parts = bind.split(':');
525                        let source = parts.next();
526                        parts.next().or(source) == Some("/var/lib/buildkit")
527                    })
528                }))
529    {
530        return Ok(None);
531    }
532    let id = uuid::Uuid::new_v4().to_string();
533    let mut owned = labels.as_object().cloned().unwrap_or_default();
534    if !labels.is_null() && !labels.is_object() {
535        return Err(invalid("invalid Docker labels"));
536    }
537    for (key, value) in journal.labels(&id) {
538        if owned.contains_key(&key) {
539            return Err(invalid("Docker ownership labels already supplied"));
540        }
541        owned.insert(key, Value::String(value));
542    }
543    object.insert("Labels".into(), Value::Object(owned));
544    let mut explicit_volume_destinations = BTreeSet::new();
545    if let Some(binds) = host_config.get("Binds").and_then(Value::as_array) {
546        for bind in binds {
547            let bind = bind
548                .as_str()
549                .ok_or_else(|| invalid("invalid Docker bind"))?;
550            let parts: Vec<_> = bind.split(':').collect();
551            if parts.len() >= 2 {
552                explicit_volume_destinations.insert(destination_key(parts[1]));
553            }
554        }
555    }
556    if let Some(mounts) = mounts {
557        for mount in mounts {
558            if mount
559                .get("Source")
560                .and_then(Value::as_str)
561                .is_some_and(|source| !source.is_empty())
562            {
563                let target = mount
564                    .get("Target")
565                    .and_then(Value::as_str)
566                    .ok_or_else(|| invalid("missing mount target"))?;
567                explicit_volume_destinations.insert(destination_key(target));
568            }
569        }
570    }
571    let inherits_volumes = host_config
572        .get("VolumesFrom")
573        .and_then(Value::as_array)
574        .is_some_and(|from| !from.is_empty());
575    Ok(Some(Intent {
576        id,
577        kind,
578        explicit_volume_destinations,
579        inherits_volumes,
580        settled: false,
581    }))
582}
583
584#[derive(Debug)]
585pub struct Reply {
586    pub status: u16,
587    pub body: Value,
588}
589
590/// Narrow Docker API port; cleanup never invokes global prune endpoints.
591pub trait Engine {
592    fn request(&self, method: &str, path: &str, body: Option<&Value>) -> io::Result<Reply>;
593}
594
595fn inspected(engine: &dyn Engine, kind: Kind, id: &str) -> io::Result<Option<Value>> {
596    if !valid_id(id) {
597        return Err(invalid("invalid Docker resource ID"));
598    }
599    let suffix = if kind == Kind::Container { "/json" } else { "" };
600    let reply = engine.request("GET", &format!("/{}/{id}{suffix}", kind.collection()), None)?;
601    match reply.status {
602        404 => Ok(None),
603        200 => Ok(Some(reply.body)),
604        _ => Err(invalid("Docker inspection failed")),
605    }
606}
607
608fn created(value: &Value) -> io::Result<String> {
609    value
610        .get("Created")
611        .or_else(|| value.get("CreatedAt"))
612        .and_then(Value::as_str)
613        .filter(|value| !value.is_empty())
614        .map(str::to_owned)
615        .ok_or_else(|| invalid("missing Docker creation fingerprint"))
616}
617
618fn matches_labels(journal: &Journal, intent: &Intent, labels: &Value) -> bool {
619    !retained(labels)
620        && journal
621            .labels(&intent.id)
622            .into_iter()
623            .all(|(key, expected)| {
624                labels.get(key).and_then(Value::as_str) == Some(expected.as_str())
625            })
626}
627
628/// Record only exact owned IDs and implicit anonymous mounts not supplied by
629/// the workflow. Called before releasing a successful create response.
630pub fn capture_created(
631    journal: &mut Journal,
632    intent: &Intent,
633    id: &str,
634    engine: &dyn Engine,
635) -> io::Result<()> {
636    let Some(value) = inspected(engine, intent.kind, id)? else {
637        return Ok(());
638    };
639    if !matches_labels(journal, intent, &labels_of(&value, intent.kind)) {
640        return Err(invalid("Docker creation ownership mismatch"));
641    }
642    let resource = Resource {
643        kind: intent.kind,
644        id: id.into(),
645        created: created(&value)?,
646        operation: intent.id.clone(),
647        implicit_volume: false,
648    };
649    add_resource(journal, resource);
650    if intent.kind != Kind::Container || intent.inherits_volumes {
651        return Ok(());
652    }
653    for mount in value
654        .get("Mounts")
655        .and_then(Value::as_array)
656        .into_iter()
657        .flatten()
658    {
659        if mount.get("Type").and_then(Value::as_str) != Some("volume") {
660            continue;
661        }
662        let destination = mount
663            .get("Destination")
664            .and_then(Value::as_str)
665            .ok_or_else(|| invalid("missing volume destination"))?;
666        if intent
667            .explicit_volume_destinations
668            .contains(&destination_key(destination))
669        {
670            continue;
671        }
672        let name = mount
673            .get("Name")
674            .and_then(Value::as_str)
675            .ok_or_else(|| invalid("missing volume name"))?;
676        // Named volumes are never inferred as disposable from a mount.
677        if !valid_id(name) {
678            continue;
679        }
680        let Some(volume) = inspected(engine, Kind::Volume, name)? else {
681            continue;
682        };
683        let labels = labels_of(&volume, Kind::Volume);
684        if retained(&labels) || labels.get("com.docker.volume.anonymous").is_none() {
685            continue;
686        }
687        add_resource(
688            journal,
689            Resource {
690                kind: Kind::Volume,
691                id: name.into(),
692                created: created(&volume)?,
693                operation: intent.id.clone(),
694                implicit_volume: true,
695            },
696        );
697    }
698    Ok(())
699}
700
701fn add_resource(journal: &mut Journal, resource: Resource) {
702    if !journal
703        .resources
704        .iter()
705        .any(|known| known.kind == resource.kind && known.id == resource.id)
706    {
707        journal.resources.push(resource);
708    }
709}
710
711fn destination_key(value: &str) -> String {
712    use sha2::Digest as _;
713    hex::encode(sha2::Sha256::digest(value.as_bytes()))
714}
715
716#[derive(Debug, Default, Serialize)]
717pub struct SweepReport {
718    pub candidates: usize,
719    pub removed: usize,
720    pub pending: usize,
721    pub protected: usize,
722    pub details: Vec<ResourceDetail>,
723    pub details_truncated: bool,
724}
725
726#[derive(Debug, Clone, Serialize)]
727pub struct ResourceDetail {
728    pub attempt: AttemptId,
729    pub kind: Kind,
730    pub id: String,
731    pub outcome: &'static str,
732}
733
734impl SweepReport {
735    fn detail(&mut self, journal: &Journal, resource: &Resource, outcome: &'static str) {
736        self.append_details([ResourceDetail {
737            attempt: journal.attempt,
738            kind: resource.kind,
739            id: resource.id.clone(),
740            outcome,
741        }]);
742    }
743
744    fn append_details(&mut self, details: impl IntoIterator<Item = ResourceDetail>) {
745        for detail in details {
746            if self.details.len() >= 1_000 {
747                self.details_truncated = true;
748                break;
749            }
750            self.details.push(detail);
751        }
752    }
753}
754
755/// Caller must prove the authoritative attempt is cleaned and the proxy gone.
756/// Docker's unforced volume/network delete is the final atomic in-use check.
757pub fn clean_resources(
758    journal: &mut Journal,
759    engine: &dyn Engine,
760    mode: Mode,
761) -> io::Result<SweepReport> {
762    let mut report = SweepReport::default();
763    if mode == Mode::Off {
764        return Ok(report);
765    }
766    let info = engine.request("GET", "/info", None)?;
767    if info.status != 200
768        || info.body.get("ID").and_then(Value::as_str) != Some(journal.engine.as_str())
769    {
770        return Err(invalid("Docker engine identity changed; cleanup refused"));
771    }
772    let mut kept = Vec::new();
773    // Containers precede volumes and networks, irrespective of creation order.
774    journal
775        .resources
776        .sort_by_key(|resource| match resource.kind {
777            Kind::Container => 0,
778            Kind::Volume => 1,
779            Kind::Network => 2,
780        });
781    for resource in &journal.resources {
782        let Some(value) = inspected(engine, resource.kind, &resource.id)? else {
783            continue;
784        };
785        let labels = labels_of(&value, resource.kind);
786        let intent = journal
787            .intents
788            .iter()
789            .find(|intent| intent.id == resource.operation)
790            .ok_or_else(|| invalid("resource without creation intent"))?;
791        let ownership = if resource.implicit_volume {
792            resource.kind == Kind::Volume
793                && !intent.inherits_volumes
794                && labels.get("com.docker.volume.anonymous").is_some()
795                && !retained(&labels)
796        } else {
797            matches_labels(journal, intent, &labels)
798        };
799        if !ownership || created(&value)? != resource.created {
800            report.protected += 1;
801            report.detail(journal, resource, "protected");
802            kept.push(resource.clone());
803            continue;
804        }
805        report.candidates += 1;
806        if mode == Mode::Report {
807            report.detail(journal, resource, "would_remove");
808            kept.push(resource.clone());
809            continue;
810        }
811        let suffix = if resource.kind == Kind::Container {
812            "?force=true"
813        } else {
814            ""
815        };
816        let reply = engine.request(
817            "DELETE",
818            &format!("/{}/{}{suffix}", resource.kind.collection(), resource.id),
819            None,
820        )?;
821        if matches!(reply.status, 200 | 204 | 404) {
822            report.removed += 1;
823            report.detail(journal, resource, "removed");
824        } else {
825            report.pending += 1;
826            report.detail(journal, resource, "pending");
827            kept.push(resource.clone());
828        }
829    }
830    journal.resources = kept;
831    Ok(report)
832}
833
834/// Handle the private companion invocation before the ordinary CLI. The only
835/// argument is a local journal path, never a request body or credential.
836pub fn internal_proxy_entry() -> Option<std::process::ExitCode> {
837    let mut args = std::env::args_os().skip(1);
838    if args.next().as_deref() != Some(std::ffi::OsStr::new("--runner-manager-docker-proxy")) {
839        return None;
840    }
841    let path = args.next().map(PathBuf::from);
842    if args.next().is_some() || path.is_none() {
843        return Some(std::process::ExitCode::FAILURE);
844    }
845    #[cfg(target_os = "linux")]
846    let result = proxy::serve(path.as_deref().expect("checked"));
847    #[cfg(not(target_os = "linux"))]
848    let result: io::Result<()> = Err(invalid("Docker proxy requires native Linux"));
849    Some(if result.is_ok() {
850        std::process::ExitCode::SUCCESS
851    } else {
852        std::process::ExitCode::FAILURE
853    })
854}