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;
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 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 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 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 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 pub explicit_volume_destinations: BTreeSet<String>,
203 pub inherits_volumes: bool,
204 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 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 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
391pub 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 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
510pub 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
548pub 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 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
675pub 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 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
754pub 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}