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