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(
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 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 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 pub explicit_volume_destinations: BTreeSet<String>,
211 pub inherits_volumes: bool,
212 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 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 #[serde(default)]
246 pub listen: Option<Listen>,
247}
248
249#[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 #[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 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
420pub 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 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
539pub 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
577pub 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 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
704pub 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 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
783pub 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}