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