1use 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
41pub 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 pub policy_config: PathBuf,
63 pub policy_log: PathBuf,
65 pub config_dir: PathBuf,
67 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 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 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 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 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 pub explicit_volume_destinations: BTreeSet<String>,
258 pub inherits_volumes: bool,
259 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 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 #[serde(default)]
293 pub listen: Option<Listen>,
294 #[serde(default)]
296 pub policy: Option<policy::Context>,
297}
298
299#[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 #[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 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
471pub 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 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
590pub 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
628pub 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 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
755pub 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 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
834pub 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}