1mod configuration;
14pub(crate) mod service;
15
16use std::io::Read;
17use std::path::{Path, PathBuf};
18use std::time::{Duration, Instant};
19
20use anyhow::{Context, Result, bail, ensure};
21use sha2::{Digest, Sha256};
22
23use super::cache_host::CacheHost;
24use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec};
25use mj_core::config::{Config, TargetBuildCache, TargetTemplate};
26use mj_core::state::{
27 BuildCacheApplication, BuildCacheLimit, BuildCacheOff, BuildCachePreview, BuildCacheStats,
28 SessionBuildCache,
29};
30
31pub(crate) const MBX_VERSION: &str = "1.21.0";
34
35const MBX_X86_64_SHA256: &str = "5225a3b77f90e1cd3d1ef0d1054ccd4593ae19b2c98b815af1b82a4dfed0f97b";
36const MBX_AARCH64_SHA256: &str = "107f86955f8323ea96ca90ca2d06d49b9b1b99789dd8007f71f9c59bb17bb6d7";
37
38const MBX_BINARY_ENV: &str = "MJ_MBX_BINARY";
41
42const DEFAULT_CACHE_RELATIVE: &str = ".cache/mbx";
43const TALLY_RELATIVE: &str = "actions/savings/v1/tally.json";
45const DEFAULT_MAX_BYTES: u64 = 100_000_000_000;
47const RESOLUTION_LIFETIME: Duration = Duration::from_secs(600);
48const LABEL: &str = "hel-mbx";
49const UNSUPPORTED_HOST: &str = "Mjolnir's shared mbx cache requires a Linux host. Native mbx on macOS must be installed and configured separately.";
50
51fn host_supports_cache(host: &CacheHost, executor: &impl CommandExecutor) -> Result<bool> {
53 let command = host.command(
54 vec!["uname".into(), "-sm".into()],
55 "detect build cache host platform",
56 );
57 let output = checked(executor.execute(&command)?, &command)?;
58 let platform = targets::TargetPlatform::parse(
59 std::str::from_utf8(&output.stdout).context("decode build cache host platform")?,
60 )?;
61 Ok(platform.os == targets::TargetOs::Linux)
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub(super) struct ResolvedBuildCache {
67 pub directory: PathBuf,
69 pub target_root: Option<PathBuf>,
72 pub config_file: Option<String>,
74 pub config_directory: PathBuf,
75 pub previous_config: Option<String>,
76}
77
78type Resolutions = std::collections::BTreeMap<String, (Instant, Result<Inspection, String>)>;
83
84static RESOLUTIONS: std::sync::LazyLock<std::sync::Mutex<Resolutions>> =
85 std::sync::LazyLock::new(|| std::sync::Mutex::new(Resolutions::new()));
86
87type Applications = std::collections::BTreeMap<String, Result<(), String>>;
88static APPLICATIONS: std::sync::LazyLock<std::sync::Mutex<Applications>> =
89 std::sync::LazyLock::new(Default::default);
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94enum Freshness {
95 Memoized,
97 Fresh,
101}
102
103fn inspect(
107 host: &CacheHost,
108 settings: &TargetBuildCache,
109 freshness: Freshness,
110 executor: &impl CommandExecutor,
111) -> Result<Inspection> {
112 let key = format!("{}|{settings:?}", host.key());
113 if freshness == Freshness::Memoized
114 && let Some((recorded, inspection)) = RESOLUTIONS.lock().expect("mbx resolutions").get(&key)
115 && recorded.elapsed() < RESOLUTION_LIFETIME
116 {
117 return inspection.clone().map_err(|error| anyhow::anyhow!(error));
118 }
119 let inspection = inspect_host(host, settings, executor);
120 let recorded = match &inspection {
121 Ok(inspection) => Ok(inspection.clone()),
122 Err(error) => Err(format!("{error:#}")),
123 };
124 RESOLUTIONS
125 .lock()
126 .expect("mbx resolutions")
127 .insert(key, (Instant::now(), recorded));
128 inspection
129}
130
131pub(super) fn resolve(
134 target: &targets::TargetTemplate,
135 executor: &impl CommandExecutor,
136) -> Option<ResolvedBuildCache> {
137 let (host, settings) = supported_host(target)?;
138 let inspection = match inspect(&host, &settings, Freshness::Memoized, executor) {
139 Ok(inspection) => inspection,
140 Err(error) => {
141 tracing::warn!(host = host.key(), "build cache unavailable: {error:#}");
142 return None;
143 }
144 };
145 let Some(cache) = inspection.cache else {
146 if let Some(reason) = &inspection.preview.off_reason {
147 tracing::warn!(
148 directory = inspection
149 .preview
150 .directory
151 .as_ref()
152 .map(|directory| directory.display().to_string()),
153 "sessions on this target run without the build cache: {reason}"
154 );
155 }
156 return None;
157 };
158 if let Err(error) = create_directory(&host, &cache.directory, executor) {
163 tracing::warn!(
164 directory = %cache.directory.display(),
165 "the build cache directory could not be created: {error:#}"
166 );
167 return None;
168 }
169 match apply_cache(&host, &settings, cache, executor) {
170 Ok(cache) => Some(cache),
171 Err(error) => {
172 tracing::warn!(
173 host = host.key(),
174 "applying machine build cache configuration failed: {error:#}"
175 );
176 executor.notify_notice(&format!(
177 "Build cache configuration could not be applied: {error:#}"
178 ));
179 None
180 }
181 }
182}
183
184fn apply_cache(
185 host: &CacheHost,
186 settings: &TargetBuildCache,
187 mut cache: ResolvedBuildCache,
188 executor: &impl CommandExecutor,
189) -> Result<ResolvedBuildCache> {
190 let key = format!("{}|{settings:?}", host.key());
191 let result = (|| {
192 if !configuration::apply(host, &cache, executor)? {
193 cache = inspect(host, settings, Freshness::Fresh, executor)?
196 .cache
197 .context("build cache became unavailable during application")?;
198 ensure!(
199 cache.previous_config == cache.config_file,
200 "machine build cache policy changed during application; retry with current machine settings"
201 );
202 }
203 cache.previous_config = cache.config_file.clone();
204 if let Some((_, Ok(inspection))) =
205 RESOLUTIONS.lock().expect("mbx resolutions").get_mut(&key)
206 {
207 inspection.cache = Some(cache.clone());
208 inspection.preview.application = BuildCacheApplication::Applied;
209 }
210 Ok(cache)
211 })();
212 APPLICATIONS.lock().expect("mbx applications").insert(
213 key,
214 result
215 .as_ref()
216 .map(|_| ())
217 .map_err(|error| format!("{error:#}")),
218 );
219 result
220}
221
222fn supported_host(target: &targets::TargetTemplate) -> Option<(CacheHost, TargetBuildCache)> {
226 let settings = match target {
227 targets::TargetTemplate::LocalPodman(container)
228 | targets::TargetTemplate::LocalDocker(container)
229 | targets::TargetTemplate::SshPodman { container, .. }
230 | targets::TargetTemplate::SshDocker { container, .. } => {
231 container.build_cache.clone().unwrap_or_default()
232 }
233 targets::TargetTemplate::AppleContainer(_)
234 | targets::TargetTemplate::LocalBare
235 | targets::TargetTemplate::AwsEc2(_)
236 | targets::TargetTemplate::SshBare { .. } => return None,
237 };
238 Some((CacheHost::for_target(target)?, settings))
239}
240
241pub fn preview_build_cache(
245 machine: &mj_core::config::Machine,
246 executor: &impl CommandExecutor,
247) -> Result<Option<BuildCachePreview>> {
248 let Some(host) = CacheHost::for_machine(machine) else {
249 return Ok(None);
250 };
251 let settings = machine.build_cache().cloned().unwrap_or_default();
252 inspect(&host, &settings, Freshness::Fresh, executor).map(|inspection| Some(inspection.preview))
253}
254
255pub(crate) fn apply_machine_build_cache(
258 machine: &mj_core::config::Machine,
259 mounted_directories: &[PathBuf],
260 executor: &impl CommandExecutor,
261) -> Result<()> {
262 let Some(host) = CacheHost::for_machine(machine) else {
263 return Ok(());
264 };
265 let settings = machine.build_cache().cloned().unwrap_or_default();
266 let inspected = inspect(&host, &settings, Freshness::Fresh, executor)?;
267 if let Some(cache) = inspected.cache {
268 let cache = apply_cache(&host, &settings, cache, executor)?;
269 for directory in mounted_directories {
272 if directory != &cache.directory {
273 publish_at(&host, &cache, directory, executor)?;
274 }
275 }
276 }
277 Ok(())
278}
279
280fn publish_at(
281 host: &CacheHost,
282 cache: &ResolvedBuildCache,
283 directory: &Path,
284 executor: &impl CommandExecutor,
285) -> Result<PathBuf> {
286 let mut projected = cache.clone();
287 projected.config_directory = configuration::shared_directory(directory);
288 projected.previous_config = configuration::read_file(
289 host,
290 &projected.config_directory.join("config.toml"),
291 executor,
292 )?;
293 ensure!(
294 configuration::apply(host, &projected, executor)?,
295 "machine configuration changed during application; retry with current settings"
296 );
297 Ok(projected.config_directory)
298}
299
300pub(super) fn prepare_session_configuration(
303 config: &Config,
304 backend: &targets::TargetLocator,
305 recorded: &SessionBuildCache,
306 executor: &impl CommandExecutor,
307) -> Result<PathBuf> {
308 let host = host_for_locator(backend).context("build cache has no container host")?;
309 let mut settings = config
310 .machines
311 .values()
312 .filter(|machine| {
313 CacheHost::for_machine(machine).is_some_and(|candidate| candidate.key() == host.key())
314 })
315 .filter_map(|machine| machine.build_cache())
316 .next()
317 .cloned()
318 .unwrap_or_default();
319 settings.directory = Some(recorded.directory.clone());
320 settings.enabled = Some(true);
323 let inspected = inspect_host(&host, &settings, executor)?;
324 let cache = inspected.cache.with_context(|| {
325 format!(
326 "build cache configuration unavailable: {}",
327 inspected
328 .preview
329 .off_reason
330 .map(|reason| reason.to_string())
331 .unwrap_or_else(|| "host inspection returned no cache".into())
332 )
333 })?;
334 publish_at(&host, &cache, &recorded.directory, executor)
335}
336
337pub(crate) struct DoctorHostMbx {
339 pub host: String,
340 pub targets: Vec<String>,
341 pub status: DoctorHostMbxStatus,
342}
343
344pub(crate) enum DoctorHostMbxStatus {
345 Unsupported(String),
346 Absent,
347 Compatible(String),
348 TooOld(String),
349 Unknown(String),
350}
351
352pub(crate) fn doctor_host_mbx(
355 config: &Config,
356 executor: &impl CommandExecutor,
357) -> Vec<DoctorHostMbx> {
358 let mut hosts: std::collections::BTreeMap<String, (CacheHost, Vec<String>)> =
359 std::collections::BTreeMap::new();
360 let mut checks = Vec::new();
361 for (id, target) in &config.targets {
362 let container = match target {
363 TargetTemplate::LocalPodman { container }
364 | TargetTemplate::LocalDocker { container }
365 | TargetTemplate::SshPodman { container, .. }
366 | TargetTemplate::SshDocker { container, .. } => container,
367 _ => continue,
368 };
369 if container
370 .build_cache
371 .as_ref()
372 .and_then(|cache| cache.enabled)
373 == Some(false)
374 {
375 continue;
376 }
377 match CacheHost::for_path_target(target) {
378 Ok(host) => {
379 let key = host.key();
380 hosts
381 .entry(key)
382 .or_insert_with(|| (host, Vec::new()))
383 .1
384 .push(id.clone());
385 }
386 Err(error) => checks.push(DoctorHostMbx {
387 host: id.clone(),
388 targets: vec![id.clone()],
389 status: DoctorHostMbxStatus::Unknown(format!("{error:#}")),
390 }),
391 }
392 }
393 checks.extend(hosts.into_iter().map(|(key, (host, targets))| {
394 let status = (|| -> Result<DoctorHostMbxStatus> {
395 if !host_supports_cache(&host, executor)? {
396 return Ok(DoctorHostMbxStatus::Unsupported(UNSUPPORTED_HOST.into()));
397 }
398 Ok(match probe_native_version(&host, executor)? {
399 None => DoctorHostMbxStatus::Absent,
400 Some(native) if semver::Version::parse(&native.version).is_err() => {
401 DoctorHostMbxStatus::Unknown(format!(
402 "the host reported an unrecognized mbx version {:?}",
403 native.version
404 ))
405 }
406 Some(native) if version_at_least(&native.version, MBX_VERSION) => {
407 DoctorHostMbxStatus::Compatible(native.version)
408 }
409 Some(native) => DoctorHostMbxStatus::TooOld(native.version),
410 })
411 })()
412 .unwrap_or_else(|error| DoctorHostMbxStatus::Unknown(format!("{error:#}")));
413 DoctorHostMbx {
414 host: key,
415 targets,
416 status,
417 }
418 }));
419 checks
420}
421
422#[derive(Clone)]
425struct Inspection {
426 preview: BuildCachePreview,
427 cache: Option<ResolvedBuildCache>,
429}
430
431fn inspect_host(
432 host: &CacheHost,
433 settings: &TargetBuildCache,
434 executor: &impl CommandExecutor,
435) -> Result<Inspection> {
436 if !host_supports_cache(host, executor)? {
437 return Ok(Inspection {
438 preview: BuildCachePreview {
439 native_mbx: None,
440 directory: None,
441 max_total_size: None,
442 user_managed: false,
443 application: BuildCacheApplication::Pending,
444 budget_note: None,
445 stats: None,
446 off_reason: Some(BuildCacheOff::Unavailable(UNSUPPORTED_HOST.into())),
447 },
448 cache: None,
449 });
450 }
451 let native = probe_native_version(host, executor)?;
452 let native_version = native.as_ref().map(|native| native.version.clone());
453 let off = |preview: BuildCachePreview| Inspection {
454 preview,
455 cache: None,
456 };
457 if let Some(version) = &native_version
458 && !version_at_least(version, MBX_VERSION)
459 {
460 return Ok(off(BuildCachePreview {
461 native_mbx: native_version.clone(),
462 directory: None,
463 max_total_size: None,
464 user_managed: true,
465 application: BuildCacheApplication::Pending,
466 budget_note: None,
467 stats: None,
468 off_reason: Some(BuildCacheOff::Unavailable(format!(
469 "the host's mbx {version} is older than the {MBX_VERSION} Mjolnir installs, \
470 so they cannot share a store"
471 ))),
472 }));
473 }
474 let directory = match &native {
475 Some(native) => native_cache_directory(host, native, executor)?,
476 None => settings
477 .directory
478 .clone()
479 .unwrap_or(host.home(executor)?.join(DEFAULT_CACHE_RELATIVE)),
480 };
481 ensure!(
482 directory.is_absolute(),
483 "build cache directory {} is not absolute",
484 directory.display()
485 );
486
487 let user_managed = native.is_some();
488 let config_directory = configuration::shared_directory(&directory);
489 let previous_config =
490 configuration::read_file(host, &config_directory.join("config.toml"), executor)?;
491 let (config_file, limit) = if user_managed {
492 let text = host_config_file(host, executor)?;
493 let limit = match configuration::configured_limit(text.as_deref(), "gc", "max_total_size")?
494 {
495 Some(size) => BuildCacheLimit::HostConfiguration(Some(size)),
496 None => BuildCacheLimit::MbxDefault(None),
497 };
498 (Some(text.unwrap_or_default()), limit)
499 } else {
500 let automatic = match configuration::automatic_total(previous_config.as_deref()) {
501 Some(size) => size,
502 None => default_max_size(host, &directory, executor)?,
503 };
504 let limit = match &settings.max_total_size {
505 Some(size) => BuildCacheLimit::Size(size.clone()),
506 None => BuildCacheLimit::MjDefault(automatic.clone()),
507 };
508 (
509 Some(configuration::managed_document(settings, &automatic)?),
510 limit,
511 )
512 };
513 let target_root = config_file
514 .as_deref()
515 .and_then(|text| relocated_target_root(text, &directory));
516 let mut application =
517 configuration::application(previous_config.as_deref(), config_file.as_deref());
518 if application == BuildCacheApplication::Pending
519 && let Some(Err(error)) = APPLICATIONS
520 .lock()
521 .expect("mbx applications")
522 .get(&format!("{}|{settings:?}", host.key()))
523 {
524 application = BuildCacheApplication::Failed(error.clone());
525 }
526 let stats = read_stats(host, &directory, executor);
529 let preview = |off_reason: Option<BuildCacheOff>| BuildCachePreview {
530 native_mbx: native_version.clone(),
531 directory: Some(directory.clone()),
532 max_total_size: Some(limit.clone()),
533 user_managed,
534 application: application.clone(),
535 budget_note: Some(
536 "One budget covers shared compiler outputs, managed worktrees, and incremental state."
537 .into(),
538 ),
539 stats: stats.clone(),
540 off_reason,
541 };
542
543 let volume = nearest_existing_ancestor(host, &directory, executor)?;
546 if !settings.enabled.unwrap_or(true) {
549 return Ok(off(preview(Some(BuildCacheOff::TurnedOff))));
550 }
551 if !reflinks_supported(host, &volume, executor)? {
552 return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
553 "the filesystem under {} does not support reflinks, so restoring cached \
554 outputs would copy every byte",
555 directory.display()
556 ))))));
557 }
558 if let Some(reason) = unusable_filesystem(host, &volume, executor)? {
559 return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
560 "{} is on a {reason}, where mbx's file locks are unreliable",
561 directory.display()
562 ))))));
563 }
564
565 if let Some(root) = &target_root {
571 let root_volume = nearest_existing_ancestor(host, root, executor)?;
572 if !cross_reflinks_supported(host, &volume, &root_volume, executor)? {
573 tracing::warn!(
574 cache = %directory.display(),
575 target_root = %root.display(),
576 "the host's mbx target root does not share a mount with the build cache, \
577 so restoring a cached output copies every byte instead of cloning it"
578 );
579 }
580 }
581
582 Ok(Inspection {
583 preview: preview(None),
584 cache: Some(ResolvedBuildCache {
585 directory,
586 target_root,
587 config_file,
588 config_directory,
589 previous_config,
590 }),
591 })
592}
593
594fn read_stats(
603 host: &CacheHost,
604 directory: &Path,
605 executor: &impl CommandExecutor,
606) -> Option<BuildCacheStats> {
607 #[derive(Default, serde::Deserialize)]
608 #[serde(default)]
609 struct Tally {
610 builds: u64,
611 cached_compilations: u64,
612 avoided_compiler_ns: u64,
613 reflinked_bytes: u64,
614 }
615
616 let path = directory.join(TALLY_RELATIVE);
617 let command = host.shell_command(
618 READ_CONFIG_SCRIPT,
619 LABEL,
620 [path.to_string_lossy().into_owned()],
621 "read the container host build cache totals",
622 );
623 let output = executor.execute(&command).ok()?;
624 if output.status != 0 {
625 return None;
626 }
627 let tally: Tally = serde_json::from_slice(&output.stdout)
630 .inspect_err(|error| {
631 tracing::debug!(
632 path = %path.display(),
633 "the build cache totals could not be read: {error}"
634 );
635 })
636 .ok()?;
637 Some(BuildCacheStats {
638 builds: tally.builds,
639 cached_compilations: tally.cached_compilations,
640 avoided_compiler_ns: tally.avoided_compiler_ns,
641 reflinked_bytes: tally.reflinked_bytes,
642 })
643}
644
645fn version_at_least(found: &str, required: &str) -> bool {
647 let parse = |text: &str| semver::Version::parse(text.trim()).ok();
648 match (parse(found), parse(required)) {
649 (Some(found), Some(required)) => found >= required,
650 _ => false,
652 }
653}
654
655#[derive(Debug, Clone, PartialEq, Eq)]
657struct NativeMbx {
658 program: String,
659 version: String,
660}
661
662const NATIVE_VERSION_SCRIPT: &str = r#"for m in mbx "$HOME/.cargo/bin/mbx"; do
665 if v=$("$m" --version 2>/dev/null); then
666 printf '%s
667%s' "$m" "$v"
668 exit 0
669 fi
670done
671exit 1"#;
672
673fn probe_native_version(
676 host: &CacheHost,
677 executor: &impl CommandExecutor,
678) -> Result<Option<NativeMbx>> {
679 let command = host.shell_command(
680 NATIVE_VERSION_SCRIPT,
681 LABEL,
682 [],
683 "read the container host mbx version",
684 );
685 let output = executor.execute(&command)?;
686 if output.status == 1 {
687 return Ok(None);
688 }
689 ensure!(
690 output.status == 0,
691 "mbx version probe exited with status {}",
692 output.status
693 );
694 let text = String::from_utf8_lossy(&output.stdout);
695 let (program, version) = text
696 .trim()
697 .split_once('\n')
698 .context("mbx version probe gave no version")?;
699 let version = version
700 .split_whitespace()
701 .next_back()
702 .context("mbx version probe gave an empty version")?;
703 Ok(Some(NativeMbx {
704 program: program.to_owned(),
705 version: version.to_owned(),
706 }))
707}
708
709fn native_cache_directory(
712 host: &CacheHost,
713 native: &NativeMbx,
714 executor: &impl CommandExecutor,
715) -> Result<PathBuf> {
716 let command = host.command(
717 vec![
718 native.program.clone(),
719 "cache".to_owned(),
720 "dir".to_owned(),
721 "--json".to_owned(),
722 ],
723 "read the container host mbx cache directory",
724 );
725 let output = checked(executor.execute(&command)?, &command)?;
726 let report: serde_json::Value =
727 serde_json::from_slice(&output.stdout).context("parse the mbx cache directory report")?;
728 let store = report
729 .get("store")
730 .and_then(serde_json::Value::as_str)
731 .context("the mbx cache directory report has no store path")?;
732 Path::new(store)
733 .parent()
734 .map(Path::to_path_buf)
735 .with_context(|| format!("mbx store path {store:?} has no parent"))
736}
737
738const READ_CONFIG_SCRIPT: &str = r#"[ -f "$1" ] || exit 3
739cat -- "$1""#;
740
741fn host_config_file(host: &CacheHost, executor: &impl CommandExecutor) -> Result<Option<String>> {
745 let directory = configuration::host_directory(host, executor)?;
746 configuration::read_file(host, &directory.join("config.toml"), executor)
747}
748
749fn relocated_target_root(config_file: &str, directory: &Path) -> Option<PathBuf> {
752 let document: toml::Value = toml::from_str(config_file)
753 .map_err(|error| tracing::warn!("the host mbx configuration is unreadable: {error}"))
754 .ok()?;
755 let root = document.get("target")?.get("root")?.as_str()?;
756 let root = directory.join(root);
757 (!root.starts_with(directory)).then_some(root)
758}
759
760const NEAREST_ANCESTOR_SCRIPT: &str = r#"d=$1
761while [ ! -d "$d" ]; do
762 parent=$(dirname -- "$d")
763 if [ "$parent" = "$d" ]; then
764 break
765 fi
766 d=$parent
767done
768printf '%s' "$d""#;
769
770fn nearest_existing_ancestor(
773 host: &CacheHost,
774 directory: &Path,
775 executor: &impl CommandExecutor,
776) -> Result<PathBuf> {
777 let command = host.shell_command(
778 NEAREST_ANCESTOR_SCRIPT,
779 LABEL,
780 [directory.to_string_lossy().into_owned()],
781 "locate the build cache volume",
782 );
783 let output = checked(executor.execute(&command)?, &command)?;
784 let path = PathBuf::from(String::from_utf8(output.stdout).context("decode cache ancestor")?);
785 ensure!(
786 path.is_absolute(),
787 "build cache volume {} is not absolute",
788 path.display()
789 );
790 Ok(path)
791}
792
793fn default_max_size(
799 host: &CacheHost,
800 directory: &Path,
801 executor: &impl CommandExecutor,
802) -> Result<String> {
803 let volume = nearest_existing_ancestor(host, directory, executor)?;
804 let command = host.command(
805 vec![
806 "df".to_owned(),
807 "-B1".to_owned(),
808 "-P".to_owned(),
809 "--".to_owned(),
810 volume.to_string_lossy().into_owned(),
811 ],
812 "measure the build cache volume",
813 );
814 let output = checked(executor.execute(&command)?, &command)?;
815 let available = available_bytes(&String::from_utf8_lossy(&output.stdout))
816 .context("read the free space on the build cache volume")?;
817 Ok(format!("{}B", DEFAULT_MAX_BYTES.min(available / 4)))
818}
819
820pub(super) fn available_bytes(report: &str) -> Option<u64> {
824 let row = report
825 .lines()
826 .filter(|line| !line.trim().is_empty())
827 .nth(1)?;
828 let fields = row.split_whitespace().collect::<Vec<_>>();
829 let available = fields.get(fields.len().checked_sub(3)?)?;
831 available.parse().ok()
832}
833
834const REFLINK_SCRIPT: &str = r#"dir=$1
835d=$(mktemp -d "$dir/.mj-reflink.XXXXXX") || exit 1
836printf x > "$d/a" && cp --reflink=always "$d/a" "$d/b"
837status=$?
838rm -rf -- "$d"
839exit $status"#;
840
841fn reflinks_supported(
845 host: &CacheHost,
846 volume: &Path,
847 executor: &impl CommandExecutor,
848) -> Result<bool> {
849 let command = host.shell_command(
850 REFLINK_SCRIPT,
851 LABEL,
852 [volume.to_string_lossy().into_owned()],
853 "probe the build cache volume for reflinks",
854 );
855 Ok(executor.execute(&command)?.status == 0)
856}
857
858const CROSS_REFLINK_SCRIPT: &str = r#"src=$1
859dst=$2
860s=$(mktemp -d "$src/.mj-reflink.XXXXXX") || exit 1
861d=$(mktemp -d "$dst/.mj-reflink.XXXXXX") || { rm -rf -- "$s"; exit 1; }
862printf x > "$s/a" && cp --reflink=always "$s/a" "$d/b"
863status=$?
864rm -rf -- "$s" "$d"
865exit $status"#;
866
867fn cross_reflinks_supported(
871 host: &CacheHost,
872 store: &Path,
873 target_root: &Path,
874 executor: &impl CommandExecutor,
875) -> Result<bool> {
876 let command = host.shell_command(
877 CROSS_REFLINK_SCRIPT,
878 LABEL,
879 [
880 store.to_string_lossy().into_owned(),
881 target_root.to_string_lossy().into_owned(),
882 ],
883 "probe the managed target root for reflinks from the build cache",
884 );
885 Ok(executor.execute(&command)?.status == 0)
886}
887
888fn create_directory(
889 host: &CacheHost,
890 directory: &Path,
891 executor: &impl CommandExecutor,
892) -> Result<()> {
893 let command = host.command(
894 vec![
895 "mkdir".to_owned(),
896 "-p".to_owned(),
897 "--".to_owned(),
898 directory.to_string_lossy().into_owned(),
899 ],
900 "create the build cache directory",
901 );
902 checked(executor.execute(&command)?, &command).map(|_| ())
903}
904
905fn unusable_filesystem(
908 host: &CacheHost,
909 directory: &Path,
910 executor: &impl CommandExecutor,
911) -> Result<Option<&'static str>> {
912 let filesystems =
913 targets::probe_filesystem_types(host.ssh(), &[directory.to_path_buf()], executor)?;
914 let filesystem = filesystems
915 .first()
916 .context("the filesystem probe named no filesystem")?;
917 Ok(targets::overlay_unsupported_filesystem(filesystem)
921 .filter(|reason| matches!(*reason, "network filesystem" | "FUSE filesystem")))
922}
923
924fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
925 if output.status == 0 {
926 return Ok(output);
927 }
928 bail!(
929 "{} failed with status {}: {}",
930 command.purpose,
931 output.status,
932 String::from_utf8_lossy(&output.stderr).trim()
933 )
934}
935
936pub(super) fn binary_for(
941 locator: &targets::TargetLocator,
942 executor: &impl CommandExecutor,
943) -> Result<PathBuf> {
944 let triple = super::worker_binary::target_architecture(locator, executor)?;
945 if let Some(path) = std::env::var_os(MBX_BINARY_ENV) {
946 let path = PathBuf::from(path);
947 ensure!(
948 path.is_file(),
949 "{MBX_BINARY_ENV} does not name a file: {}",
950 path.display()
951 );
952 if triple == host_architecture() {
953 return Ok(path);
954 }
955 tracing::warn!(
956 triple,
957 "{MBX_BINARY_ENV} is for this machine's architecture; downloading the pinned mbx \
958 for the target instead"
959 );
960 }
961 download(triple)
962}
963
964fn host_architecture() -> &'static str {
967 if cfg!(target_arch = "aarch64") {
968 "aarch64"
969 } else {
970 "x86_64"
971 }
972}
973
974fn release_url(triple: &str) -> String {
975 format!(
976 "https://github.com/jdx/mr-boxington/releases/download/v{MBX_VERSION}/mbx-{triple}-unknown-linux-musl.tar.gz"
977 )
978}
979
980fn expected_digest(triple: &str) -> Result<&'static str> {
981 match triple {
982 "x86_64" => Ok(MBX_X86_64_SHA256),
983 "aarch64" => Ok(MBX_AARCH64_SHA256),
984 _ => bail!("no pinned mbx release for {triple}"),
985 }
986}
987
988fn download(triple: &str) -> Result<PathBuf> {
991 let expected = expected_digest(triple)?;
992 let directory = mj_core::config::data_dir()
993 .join("mbx")
994 .join(MBX_VERSION)
995 .join(triple);
996 let destination = directory.join("mbx");
997 if destination.is_file() {
998 return Ok(destination);
999 }
1000 std::fs::create_dir_all(&directory)
1001 .with_context(|| format!("create the mbx cache {}", directory.display()))?;
1002 let url = release_url(triple);
1003 let archive = reqwest::blocking::Client::builder()
1004 .timeout(Duration::from_secs(120))
1005 .build()?
1006 .get(&url)
1007 .send()
1008 .with_context(|| format!("download {url}"))?
1009 .error_for_status()
1010 .with_context(|| format!("download {url}"))?
1011 .bytes()?;
1012 let actual = mj_core::hex::lower_hex(Sha256::digest(&archive));
1013 ensure!(
1014 actual.eq_ignore_ascii_case(expected),
1015 "downloaded mbx checksum mismatch: expected {expected}, got {actual}"
1016 );
1017 let binary = extract_binary(&archive)?;
1018 let mut temporary = tempfile::NamedTempFile::new_in(&directory)?;
1019 std::io::Write::write_all(&mut temporary, &binary)?;
1020 temporary.as_file_mut().sync_all()?;
1021 #[cfg(unix)]
1022 {
1023 use std::os::unix::fs::PermissionsExt;
1024 std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o700))?;
1025 }
1026 match temporary.persist_noclobber(&destination) {
1027 Ok(_) => Ok(destination),
1028 Err(error) if destination.is_file() => {
1029 drop(error);
1030 Ok(destination)
1031 }
1032 Err(error) => Err(error.error)
1033 .with_context(|| format!("publish the mbx binary {}", destination.display())),
1034 }
1035}
1036
1037fn extract_binary(archive: &[u8]) -> Result<Vec<u8>> {
1040 let mut reader = tar::Archive::new(flate2::read::GzDecoder::new(archive));
1041 for entry in reader.entries().context("read the mbx release archive")? {
1042 let mut entry = entry.context("read the mbx release archive")?;
1043 if entry.path().context("read an mbx archive path")?.as_ref() != Path::new("mbx") {
1044 continue;
1045 }
1046 let mut bytes = Vec::new();
1047 entry
1048 .read_to_end(&mut bytes)
1049 .context("read the mbx binary from its release archive")?;
1050 return Ok(bytes);
1051 }
1052 bail!("the mbx release archive contains no mbx binary")
1053}
1054
1055pub(super) fn primary_repository_is_rust(
1061 host: &CacheHost,
1062 mirror: &Path,
1063 executor: &impl CommandExecutor,
1064) -> bool {
1065 let command = host.command(
1066 vec![
1067 "git".to_owned(),
1068 "--git-dir".to_owned(),
1069 mirror.to_string_lossy().into_owned(),
1070 "cat-file".to_owned(),
1071 "-e".to_owned(),
1072 "HEAD:Cargo.toml".to_owned(),
1073 ],
1074 "detect a Cargo workspace in the session repository",
1075 );
1076 matches!(executor.execute(&command), Ok(output) if output.status == 0)
1077}
1078
1079pub(super) fn prepare(
1083 target: &targets::TargetTemplate,
1084 session: &mj_core::state::SessionRecord,
1085 bundle: Option<&targets::ProjectBundleSpec>,
1086 clone_cache: Option<&super::git_cache::PreparedCloneCache>,
1087 mounts: &mut Vec<targets::AdditionalMount>,
1088 executor: &impl CommandExecutor,
1089) -> Option<SessionBuildCache> {
1090 session.container_workspace.as_ref()?;
1095 let resolved = resolve(target, executor)?;
1096 let host = supported_host(target)?.0;
1097 if session.build_cache.is_none() {
1098 let mirror = clone_cache?.mirror_for(&bundle?.primary)?;
1099 if !primary_repository_is_rust(&host, mirror, executor) {
1100 return None;
1101 }
1102 }
1103 let build_cache = SessionBuildCache {
1104 host: host.key(),
1105 directory: resolved.directory,
1106 max_size: None,
1107 target_root: resolved.target_root,
1108 };
1109 attach_mounts(&build_cache, mounts).then_some(build_cache)
1110}
1111
1112fn attach_mounts(
1116 build_cache: &SessionBuildCache,
1117 mounts: &mut Vec<targets::AdditionalMount>,
1118) -> bool {
1119 let wanted = std::iter::once(&build_cache.directory)
1120 .chain(build_cache.target_root.iter())
1121 .collect::<Vec<_>>();
1122 for directory in &wanted {
1123 if mounts.iter().any(|mount| {
1124 mount.destination.starts_with(directory) || directory.starts_with(&mount.destination)
1125 }) {
1126 tracing::warn!(
1127 directory = %directory.display(),
1128 "an attached directory overlaps the build cache, so this session runs without it"
1129 );
1130 return false;
1131 }
1132 }
1133 for directory in wanted {
1134 mounts.push(targets::AdditionalMount {
1135 source: directory.clone(),
1136 destination: directory.clone(),
1137 access: targets::MountAccess::Rw,
1138 });
1139 }
1140 true
1141}
1142
1143#[cfg(test)]
1146pub(super) fn attach_mounts_for_tests(
1147 build_cache: &SessionBuildCache,
1148 mounts: &mut Vec<targets::AdditionalMount>,
1149) -> bool {
1150 attach_mounts(build_cache, mounts)
1151}
1152
1153fn host_for_locator(target: &targets::TargetLocator) -> Option<CacheHost> {
1156 match target {
1157 targets::TargetLocator::LocalPodman { .. } | targets::TargetLocator::LocalDocker { .. } => {
1158 Some(CacheHost::Local)
1159 }
1160 targets::TargetLocator::SshPodman { ssh, .. }
1161 | targets::TargetLocator::SshDocker { ssh, .. } => Some(CacheHost::Ssh(ssh.clone())),
1162 _ => None,
1163 }
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168 use super::*;
1169 use crate::targets::{ContainerTemplate, SshTarget, TargetTemplate};
1170 use mj_core::config::ImagePullPolicy;
1171 use std::sync::Mutex;
1172
1173 static ISOLATED: Mutex<()> = Mutex::new(());
1176
1177 fn isolated() -> std::sync::MutexGuard<'static, ()> {
1178 let guard = ISOLATED.lock().unwrap_or_else(|error| error.into_inner());
1179 RESOLUTIONS.lock().expect("mbx resolutions").clear();
1180 APPLICATIONS.lock().expect("mbx applications").clear();
1181 guard
1182 }
1183
1184 #[derive(Default)]
1186 struct ProbeExecutor {
1187 answers: Vec<(&'static str, i32, String)>,
1188 seen: Mutex<Vec<String>>,
1189 }
1190
1191 impl ProbeExecutor {
1192 fn new(answers: &[(&'static str, i32, &str)]) -> Self {
1193 Self {
1194 answers: answers
1195 .iter()
1196 .map(|(needle, status, stdout)| (*needle, *status, (*stdout).to_owned()))
1197 .chain(std::iter::once(("uname -sm", 0, "Linux x86_64\n".into())))
1198 .collect(),
1199 seen: Mutex::new(Vec::new()),
1200 }
1201 }
1202
1203 fn ran(&self) -> Vec<String> {
1204 self.seen.lock().unwrap().clone()
1205 }
1206 }
1207
1208 impl CommandExecutor for ProbeExecutor {
1209 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1210 let line = format!("{} {}", command.program, command.args.join(" "));
1211 self.seen.lock().unwrap().push(line.clone());
1212 let searchable = if command.program == "ssh" {
1214 line.replace("'\\''", "'").replace("' '", " ")
1215 } else {
1216 line.clone()
1217 };
1218 for (needle, status, stdout) in &self.answers {
1219 if searchable.contains(needle) {
1220 return Ok(CommandOutput {
1221 status: *status,
1222 stdout: stdout.clone().into_bytes(),
1223 stderr: Vec::new(),
1224 });
1225 }
1226 }
1227 Ok(CommandOutput {
1228 status: 127,
1229 stdout: Vec::new(),
1230 stderr: format!("no canned answer for {line}").into_bytes(),
1231 })
1232 }
1233 }
1234
1235 fn container(build_cache: Option<TargetBuildCache>) -> ContainerTemplate {
1236 ContainerTemplate {
1237 image: "example/image:latest".into(),
1238 pull_policy: ImagePullPolicy::Missing,
1239 extra_run_args: Vec::new(),
1240 workspace_storage: Default::default(),
1241 build_cache,
1242 }
1243 }
1244
1245 fn podman(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1246 TargetTemplate::LocalPodman(container(build_cache))
1247 }
1248
1249 fn docker(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1250 TargetTemplate::LocalDocker(container(build_cache))
1251 }
1252
1253 fn configured_local_machine() -> mj_core::config::Machine {
1256 serde_json::from_value(serde_json::json!({"kind": "local"})).unwrap()
1257 }
1258
1259 fn default_cache_directory() -> PathBuf {
1262 dirs::home_dir()
1263 .expect("a home directory")
1264 .join(DEFAULT_CACHE_RELATIVE)
1265 }
1266
1267 fn current_native_mbx() -> String {
1271 format!("mbx\nmbx {MBX_VERSION}")
1272 }
1273
1274 fn plain_host() -> Vec<(&'static str, i32, &'static str)> {
1275 vec![
1276 ("$m\" --version", 1, ""),
1277 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1278 ("[ -f \"$1\" ]", 3, ""),
1279 ("while [ ! -d", 0, "/home/dev"),
1280 (
1281 "df -B1 -P",
1282 0,
1283 "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 1000000000000 0 800000000000 20% /home\n",
1284 ),
1285 ("mj-reflink", 0, ""),
1286 ("mkdir -p", 0, ""),
1287 ("stat -f -c %T", 0, "xfs"),
1288 ]
1289 }
1290
1291 #[test]
1292 fn darwin_hosts_skip_cache_inspection_provisioning_and_reconciliation() {
1293 let _isolated = isolated();
1294 for remote in [false, true] {
1295 for enabled in [None, Some(true)] {
1296 let settings = TargetBuildCache {
1297 enabled,
1298 ..Default::default()
1299 };
1300 let machine: mj_core::config::Machine = if remote {
1301 serde_json::from_value(serde_json::json!({
1302 "kind": "ssh", "host": "mac.test", "user": "builder", "build_cache": settings,
1303 }))
1304 .unwrap()
1305 } else {
1306 mj_core::config::Machine::Local {
1307 build_cache: Some(settings.clone()),
1308 }
1309 };
1310 let target = if remote {
1311 TargetTemplate::SshPodman {
1312 ssh: SshTarget {
1313 destination: "builder@mac.test".into(),
1314 ssh_args: vec![],
1315 },
1316 container: container(Some(settings)),
1317 }
1318 } else {
1319 podman(Some(settings))
1320 };
1321 let executor = ProbeExecutor::new(&[
1323 ("uname -sm", 0, "Darwin arm64\n"),
1324 ("$m\" --version", 0, "mbx\nmbx 1.16.0"),
1325 ]);
1326 let preview = preview_build_cache(&machine, &executor).unwrap().unwrap();
1327 assert_eq!(
1328 preview.off_reason,
1329 Some(BuildCacheOff::Unavailable(UNSUPPORTED_HOST.into()))
1330 );
1331 assert!(preview.directory.is_none());
1332 assert!(resolve(&target, &executor).is_none());
1333 apply_machine_build_cache(&machine, &[PathBuf::from("/existing/cache")], &executor)
1334 .unwrap();
1335 let commands = executor.ran();
1336 assert!(!commands.is_empty());
1337 assert!(
1338 commands
1339 .iter()
1340 .all(|command| command.contains("uname") && command.contains("-sm")),
1341 "{commands:?}"
1342 );
1343 assert!(
1344 commands
1345 .iter()
1346 .all(|command| command.starts_with(if remote { "ssh " } else { "uname " }))
1347 );
1348 }
1349 }
1350 }
1351
1352 #[test]
1353 fn linux_ssh_cache_remains_available_on_any_controller_platform() {
1354 let _isolated = isolated();
1355 let executor = ProbeExecutor::new(&plain_host());
1356 let target = TargetTemplate::SshDocker {
1357 ssh: SshTarget {
1358 destination: "builder@linux.test".into(),
1359 ssh_args: vec![],
1360 },
1361 container: container(None),
1362 };
1363 let cache = resolve(&target, &executor).unwrap();
1364 assert_eq!(cache.directory, PathBuf::from("/home/dev/.cache/mbx"));
1365 assert_eq!(cache.previous_config, cache.config_file);
1366 assert!(
1367 executor.ran().iter().all(
1368 |command| command.starts_with("ssh ") && command.contains("builder@linux.test")
1369 )
1370 );
1371 }
1372
1373 #[test]
1374 fn recorded_darwin_cache_fails_explicitly_without_writing() {
1375 let executor = ProbeExecutor::new(&[("uname -sm", 0, "Darwin x86_64")]);
1376 let recorded = SessionBuildCache {
1377 host: "local".into(),
1378 directory: PathBuf::from("/existing/cache"),
1379 max_size: None,
1380 target_root: None,
1381 };
1382 let backend = targets::TargetLocator::LocalPodman {
1383 container_id: "saved-container".into(),
1384 workspace_storage: Default::default(),
1385 borrowed_from: None,
1386 };
1387 let error =
1388 prepare_session_configuration(&Config::default(), &backend, &recorded, &executor)
1389 .unwrap_err();
1390 assert!(format!("{error:#}").contains(UNSUPPORTED_HOST));
1391 assert_eq!(executor.ran(), ["uname -sm"]);
1392 }
1393
1394 #[test]
1395 fn failed_platform_probe_does_not_attempt_cache_operations() {
1396 let executor = ProbeExecutor::new(&[("uname -sm", 1, "")]);
1397 assert!(inspect_host(&CacheHost::Local, &TargetBuildCache::default(), &executor).is_err());
1398 assert_eq!(executor.ran(), ["uname -sm"]);
1399 }
1400
1401 #[test]
1402 fn installed_worker_reads_cache_configuration_from_its_recorded_host() {
1403 let executor = ProbeExecutor::new(&[
1404 ("XDG_CONFIG_HOME", 0, "/home/builder/.config/mbx"),
1405 ("$HOME", 0, "/home/builder"),
1406 ("[ -f \"$1\" ]", 0, "[gc]\nmax_total_size = '50GB'\n"),
1407 ]);
1408 let target = targets::TargetLocator::SshPodman {
1409 ssh: SshTarget {
1410 destination: "builder@recorded-cache.test".into(),
1411 ssh_args: vec![],
1412 },
1413 container_id: "saved-container".into(),
1414 workspace_storage: Default::default(),
1415 borrowed_from: None,
1416 };
1417 let config = host_config_file(&host_for_locator(&target).unwrap(), &executor)
1418 .unwrap()
1419 .unwrap();
1420 assert!(config.contains("50GB"));
1421 assert!(
1422 executor
1423 .seen
1424 .lock()
1425 .unwrap()
1426 .iter()
1427 .all(|command| command.contains("builder@recorded-cache.test"))
1428 );
1429 }
1430
1431 #[test]
1432 fn a_native_mbx_supplies_the_cache_directory_and_its_own_limits() {
1433 let _isolated = isolated();
1434 let executor = ProbeExecutor::new(&[
1435 ("$m\" --version", 0, current_native_mbx().as_str()),
1436 (
1437 "mbx cache dir --json",
1438 0,
1439 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1440 ),
1441 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1442 (
1443 "[ -f \"$1\" ]",
1444 0,
1445 "cache_dir = \"/mnt/fast/mbx-cache\"\n[gc]\nmax_size = \"500GiB\"\n",
1446 ),
1447 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1448 ("mj-reflink", 0, ""),
1449 ("mkdir -p", 0, ""),
1450 ("stat -f -c %T", 0, "xfs"),
1451 ]);
1452 let resolved = resolve(&podman(None), &executor).unwrap();
1453 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1454 assert_eq!(
1456 configuration::configured_limit(
1457 resolved.config_file.as_deref(),
1458 "gc",
1459 "max_total_size"
1460 )
1461 .unwrap(),
1462 None
1463 );
1464 assert_eq!(resolved.target_root, None);
1465 assert!(resolved.config_file.unwrap().contains("500GiB"));
1466 assert!(
1467 !executor
1468 .ran()
1469 .iter()
1470 .any(|line| line.contains("apply machine")),
1471 "a host configuration is never rewritten"
1472 );
1473 }
1474
1475 #[test]
1476 fn looking_at_the_settings_page_lets_the_next_session_see_a_repaired_host() {
1477 let _isolated = isolated();
1478 let broken = ProbeExecutor::new(
1479 &plain_host()
1480 .into_iter()
1481 .map(|(needle, status, stdout)| match needle {
1482 "mj-reflink" => (needle, 1, stdout),
1483 _ => (needle, status, stdout),
1484 })
1485 .collect::<Vec<_>>(),
1486 );
1487 assert!(
1488 resolve(&podman(None), &broken).is_none(),
1489 "a volume that cannot clone runs without the cache"
1490 );
1491
1492 let repaired = ProbeExecutor::new(&plain_host());
1495 let preview = preview_build_cache(&configured_local_machine(), &repaired)
1496 .expect("the host answers")
1497 .expect("a local machine can hold a cache");
1498 assert_eq!(preview.off_reason, None);
1499
1500 assert!(
1501 resolve(&podman(None), &repaired).is_some(),
1502 "the next session asks the repaired host again instead of reusing the old verdict"
1503 );
1504 }
1505
1506 #[test]
1507 fn a_relocated_target_root_is_reported_for_its_own_mount() {
1508 let _isolated = isolated();
1509 let executor = ProbeExecutor::new(&[
1510 ("$m\" --version", 0, current_native_mbx().as_str()),
1511 (
1512 "mbx cache dir --json",
1513 0,
1514 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1515 ),
1516 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1517 (
1518 "[ -f \"$1\" ]",
1519 0,
1520 "[target]\nroot = \"/mnt/fast/mbx-targets\"\n",
1521 ),
1522 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1523 ("mj-reflink", 0, ""),
1524 ("mkdir -p", 0, ""),
1525 ("stat -f -c %T", 0, "xfs"),
1526 ]);
1527 let resolved = resolve(&podman(None), &executor).unwrap();
1528 assert_eq!(
1529 resolved.target_root,
1530 Some(PathBuf::from("/mnt/fast/mbx-targets"))
1531 );
1532 }
1533
1534 #[test]
1535 fn a_target_root_that_cannot_be_cloned_into_still_gets_the_cache() {
1536 let _isolated = isolated();
1537 let executor = ProbeExecutor::new(&[
1538 ("$m\" --version", 0, current_native_mbx().as_str()),
1539 (
1540 "mbx cache dir --json",
1541 0,
1542 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1543 ),
1544 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1545 (
1546 "[ -f \"$1\" ]",
1547 0,
1548 "[target]\nroot = \"/mnt/slow/mbx-targets\"\n",
1549 ),
1550 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1551 ("src=$1", 1, ""),
1554 ("mj-reflink", 0, ""),
1555 ("mkdir -p", 0, ""),
1556 ("stat -f -c %T", 0, "xfs"),
1557 ]);
1558 let resolved = resolve(&podman(None), &executor)
1559 .expect("a target root that copies instead of cloning is slower, not unusable");
1560 assert_eq!(
1561 resolved.target_root,
1562 Some(PathBuf::from("/mnt/slow/mbx-targets"))
1563 );
1564 assert!(
1565 executor.ran().iter().any(|line| line.contains("src=$1")),
1566 "the store and the target root are probed as a pair: {:?}",
1567 executor.ran()
1568 );
1569 }
1570
1571 #[test]
1572 fn a_target_root_inside_the_cache_directory_needs_no_second_mount() {
1573 assert_eq!(
1574 relocated_target_root("[target]\nroot = \"targets\"\n", Path::new("/cache")),
1575 None
1576 );
1577 assert_eq!(
1578 relocated_target_root("[target]\nroot = \"/cache/targets\"\n", Path::new("/cache")),
1579 None
1580 );
1581 }
1582
1583 #[test]
1584 fn a_cargo_installed_mbx_off_the_path_is_queried_where_it_was_found() {
1585 let _isolated = isolated();
1586 let found = format!("/home/dev/.cargo/bin/mbx\nmbx {MBX_VERSION}");
1587 let mut answers: Vec<(&'static str, i32, &str)> = plain_host();
1588 answers.retain(|(needle, _, _)| *needle != "$m\" --version");
1589 answers.push(("$m\" --version", 0, found.as_str()));
1590 answers.push((
1591 "/home/dev/.cargo/bin/mbx cache dir --json",
1592 0,
1593 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1594 ));
1595 let executor = ProbeExecutor::new(&answers);
1596 let resolved = resolve(&podman(None), &executor).unwrap();
1597 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1598 }
1599
1600 #[test]
1601 fn an_older_native_mbx_must_not_share_the_store() {
1602 let _isolated = isolated();
1603 let executor = ProbeExecutor::new(&[("$m\" --version", 0, "mbx\nmbx 1.15.0")]);
1604 assert_eq!(resolve(&podman(None), &executor), None);
1605 }
1606
1607 #[test]
1608 fn a_host_without_mbx_falls_back_to_the_default_cache_directory() {
1609 let _isolated = isolated();
1610 let executor = ProbeExecutor::new(&plain_host());
1611 let resolved = resolve(&podman(None), &executor).unwrap();
1612 assert_eq!(resolved.directory, default_cache_directory());
1613 assert_eq!(
1615 configuration::configured_limit(
1616 resolved.config_file.as_deref(),
1617 "gc",
1618 "max_total_size"
1619 )
1620 .unwrap()
1621 .as_deref(),
1622 Some("100000000000B")
1623 );
1624 }
1625
1626 #[test]
1627 fn a_small_volume_takes_a_quarter_of_its_free_space() {
1628 let _isolated = isolated();
1629 let mut answers = plain_host();
1630 answers.retain(|(needle, _, _)| *needle != "df -B1 -P");
1631 answers.push((
1632 "df -B1 -P",
1633 0,
1634 "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 100000000 60000000 40000000 60% /home\n",
1635 ));
1636 let executor = ProbeExecutor::new(&answers);
1637 let resolved = resolve(&podman(None), &executor).unwrap();
1638 assert_eq!(
1639 configuration::configured_limit(
1640 resolved.config_file.as_deref(),
1641 "gc",
1642 "max_total_size"
1643 )
1644 .unwrap()
1645 .as_deref(),
1646 Some("10000000B")
1647 );
1648 }
1649
1650 #[test]
1651 fn target_overrides_win_over_every_default() {
1652 let _isolated = isolated();
1653 let mut answers = plain_host();
1654 answers.push(("mbx cache dir", 0, r#"{"store":"/other/actions"}"#));
1655 let executor = ProbeExecutor::new(&answers);
1656 let resolved = resolve(
1657 &podman(Some(TargetBuildCache {
1658 enabled: Some(true),
1659 directory: Some(PathBuf::from("/mnt/nvme/mbx")),
1660 max_total_size: Some("250GiB".into()),
1661 })),
1662 &executor,
1663 )
1664 .unwrap();
1665 assert_eq!(resolved.directory, PathBuf::from("/mnt/nvme/mbx"));
1666 assert_eq!(
1667 configuration::configured_limit(
1668 resolved.config_file.as_deref(),
1669 "gc",
1670 "max_total_size"
1671 )
1672 .unwrap()
1673 .as_deref(),
1674 Some("250GiB")
1675 );
1676 }
1677
1678 #[test]
1679 fn one_total_budget_resolves_without_component_caps() {
1680 let _isolated = isolated();
1681 let executor = ProbeExecutor::new(&plain_host());
1682 let settings = TargetBuildCache {
1683 max_total_size: Some("500GiB".into()),
1684 ..Default::default()
1685 };
1686 let inspection = inspect_host(&CacheHost::Local, &settings, &executor).unwrap();
1687 assert!(!inspection.preview.user_managed);
1688 assert_eq!(
1689 inspection.preview.max_total_size,
1690 Some(BuildCacheLimit::Size("500GiB".into()))
1691 );
1692 assert_eq!(
1693 inspection.preview.application,
1694 BuildCacheApplication::Pending
1695 );
1696 let cache = inspection.cache.unwrap();
1697 assert_eq!(
1698 configuration::configured_limit(cache.config_file.as_deref(), "target", "max_size")
1699 .unwrap()
1700 .as_deref(),
1701 None
1702 );
1703 assert!(
1704 !executor
1705 .ran()
1706 .iter()
1707 .any(|command| command.contains(".mj-apply.lock")),
1708 "preview never applies a setting"
1709 );
1710 }
1711
1712 #[test]
1713 fn a_native_installation_owns_the_budget_despite_saved_mj_overrides() {
1714 let _isolated = isolated();
1715 let native = current_native_mbx();
1716 let mut answers = vec![
1717 ("$m\" --version", 0, native.as_str()),
1718 (
1719 "mbx cache dir --json",
1720 0,
1721 r#"{"store":"/native/cache/actions"}"#,
1722 ),
1723 (
1724 "[ -f \"$1\" ]",
1725 0,
1726 "[gc]\nmax_total_size = '400GiB'\n[target]\nmax_size = 'none'\n",
1727 ),
1728 ];
1729 answers.extend(plain_host());
1730 let executor = ProbeExecutor::new(&answers);
1731 let settings = TargetBuildCache {
1732 directory: Some("/ignored".into()),
1733 max_total_size: Some("1GB".into()),
1734 ..Default::default()
1735 };
1736 let inspection = inspect_host(&CacheHost::Local, &settings, &executor).unwrap();
1737 assert!(inspection.preview.user_managed);
1738 assert_eq!(inspection.preview.directory, Some("/native/cache".into()));
1739 assert_eq!(
1740 inspection.preview.max_total_size,
1741 Some(BuildCacheLimit::HostConfiguration(Some("400GiB".into())))
1742 );
1743 assert!(
1744 !executor
1745 .ran()
1746 .iter()
1747 .any(|command| command.contains(".mj-apply.lock"))
1748 );
1749 }
1750
1751 #[test]
1752 fn the_automatic_total_is_reused_instead_of_following_free_space() {
1753 let _isolated = isolated();
1754 let saved = configuration::managed_document(&TargetBuildCache::default(), "17GB").unwrap();
1755 let mut answers = vec![(".mjolnir/config/mbx/config.toml", 0, saved.as_str())];
1756 answers.extend(plain_host());
1757 let executor = ProbeExecutor::new(&answers);
1758 let preview = inspect_host(&CacheHost::Local, &TargetBuildCache::default(), &executor)
1759 .unwrap()
1760 .preview;
1761 assert_eq!(
1762 preview.max_total_size,
1763 Some(BuildCacheLimit::MjDefault("17GB".into()))
1764 );
1765 assert_eq!(preview.application, BuildCacheApplication::Applied);
1766 }
1767
1768 #[test]
1769 fn legacy_managed_budgets_are_replaced_by_a_fresh_shared_default() {
1770 let _isolated = isolated();
1771 let saved = "# mj automatic total: 17GB\n[gc]\nmax_total_size = '500GiB'\n[target]\nmax_size = '250GiB'\n";
1772 let mut answers = vec![(".mjolnir/config/mbx/config.toml", 0, saved)];
1773 answers.extend(plain_host());
1774 let executor = ProbeExecutor::new(&answers);
1775 let inspection =
1776 inspect_host(&CacheHost::Local, &TargetBuildCache::default(), &executor).unwrap();
1777 assert_eq!(
1778 inspection.preview.max_total_size,
1779 Some(BuildCacheLimit::MjDefault("100000000000B".into()))
1780 );
1781 assert_eq!(
1782 inspection.preview.application,
1783 BuildCacheApplication::Pending
1784 );
1785 let cache = inspection.cache.unwrap();
1786 let policy: toml::Value = toml::from_str(cache.config_file.as_deref().unwrap()).unwrap();
1787 assert_eq!(
1788 policy["gc"]["max_total_size"].as_str(),
1789 Some("100000000000B")
1790 );
1791 assert!(policy.get("target").is_none());
1792 assert!(policy["gc"].get("max_size").is_none());
1793 }
1794
1795 #[test]
1798 fn an_enabled_setting_does_not_survive_a_volume_without_reflinks() {
1799 let _isolated = isolated();
1800 let mut answers = plain_host();
1801 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1802 answers.push(("mj-reflink", 1, ""));
1803 let executor = ProbeExecutor::new(&answers);
1804 assert_eq!(
1805 resolve(
1806 &podman(Some(TargetBuildCache {
1807 enabled: Some(true),
1808 directory: None,
1809 max_total_size: None,
1810 })),
1811 &executor,
1812 ),
1813 None
1814 );
1815 }
1816
1817 #[test]
1818 fn a_volume_without_reflinks_runs_without_the_cache() {
1819 let _isolated = isolated();
1820 let mut answers = plain_host();
1821 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1822 answers.push(("mj-reflink", 1, ""));
1823 let executor = ProbeExecutor::new(&answers);
1824 assert_eq!(resolve(&podman(None), &executor), None);
1825 }
1826
1827 #[test]
1828 fn the_preview_names_the_resolved_values_and_the_reason_the_cache_is_off() {
1829 let _isolated = isolated();
1830 let mut answers = plain_host();
1831 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1832 answers.push(("mj-reflink", 1, ""));
1833 let executor = ProbeExecutor::new(&answers);
1834 let preview = preview_build_cache(&configured_local_machine(), &executor)
1835 .unwrap()
1836 .unwrap();
1837 assert_eq!(preview.native_mbx, None);
1838 assert_eq!(preview.directory, Some(default_cache_directory()));
1839 assert_eq!(
1840 preview.max_total_size,
1841 Some(BuildCacheLimit::MjDefault("100000000000B".into()))
1842 );
1843 assert!(
1844 matches!(&preview.off_reason, Some(BuildCacheOff::Unavailable(reason)) if reason.contains("reflinks")),
1845 "{:?}",
1846 preview.off_reason
1847 );
1848 assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1850
1851 let executor = ProbeExecutor::new(&[
1852 ("$m\" --version", 0, current_native_mbx().as_str()),
1853 (
1854 "mbx cache dir --json",
1855 0,
1856 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1857 ),
1858 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1859 ("[ -f \"$1\" ]", 0, "[gc]\nmax_size = \"500GiB\"\n"),
1860 ("while [ ! -d", 0, "/mnt/fast"),
1861 ("mj-reflink", 0, ""),
1862 ("stat -f -c %T", 0, "xfs"),
1863 ]);
1864 let preview = preview_build_cache(&configured_local_machine(), &executor)
1865 .unwrap()
1866 .unwrap();
1867 assert_eq!(preview.native_mbx.as_deref(), Some(MBX_VERSION));
1868 assert_eq!(
1869 preview.directory,
1870 Some(PathBuf::from("/mnt/fast/mbx-cache"))
1871 );
1872 assert_eq!(
1873 preview.max_total_size,
1874 Some(BuildCacheLimit::MbxDefault(None))
1875 );
1876 assert_eq!(preview.off_reason, None);
1877 assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1878 }
1879
1880 #[test]
1881 fn a_network_filesystem_runs_without_the_cache() {
1882 let _isolated = isolated();
1883 let mut answers = plain_host();
1884 answers.retain(|(needle, _, _)| *needle != "stat -f -c %T");
1885 answers.push(("stat -f -c %T", 0, "nfs4"));
1886 let executor = ProbeExecutor::new(&answers);
1887 assert_eq!(resolve(&podman(None), &executor), None);
1888 }
1889
1890 #[test]
1891 fn a_machine_opt_out_does_not_disable_default_cache_policy() {
1892 let _isolated = isolated();
1893 let executor = ProbeExecutor::new(&plain_host());
1894 let disabled = podman(Some(TargetBuildCache {
1895 enabled: Some(false),
1896 ..Default::default()
1897 }));
1898 assert!(resolve(&disabled, &executor).is_none());
1899 assert!(
1900 !executor
1901 .ran()
1902 .iter()
1903 .any(|command| command.contains("mkdir -p") || command.contains(".mj-apply.lock"))
1904 );
1905 assert!(resolve(&podman(None), &executor).is_some());
1906 assert!(resolve(&disabled, &executor).is_none());
1907 }
1908
1909 #[test]
1910 fn local_podman_and_local_docker_inspect_one_machine_once() {
1911 let _isolated = isolated();
1912 let executor = ProbeExecutor::new(&plain_host());
1913 let first = resolve(&podman(None), &executor).unwrap();
1914 let ran = executor.ran().len();
1915 assert!(ran > 0, "the first resolve inspects the host");
1916 let second = resolve(&docker(None), &executor).unwrap();
1917 assert_eq!(
1918 first, second,
1919 "both engines on this machine share one cache"
1920 );
1921 let added = executor.ran()[ran..].to_vec();
1925 assert_eq!(
1926 added.len(),
1927 1,
1928 "the second runtime is answered from the machine's recorded inspection: {added:?}"
1929 );
1930 assert!(
1931 added[0].contains("mkdir -p"),
1932 "the one repeated command creates the directory: {added:?}"
1933 );
1934 }
1935
1936 #[test]
1937 fn the_preview_reports_what_the_cache_has_already_done() {
1938 let _isolated = isolated();
1939 let mut answers = vec![(
1941 "tally.json",
1942 0,
1943 r#"{"version":1,"since_secs":1789824719,"builds":155,"cached_compilations":12050,"avoided_compiler_ns":6004997818721,"reflinked_bytes":47612059386}"#,
1944 )];
1945 answers.extend(plain_host());
1946 let executor = ProbeExecutor::new(&answers);
1947 let preview = preview_build_cache(&configured_local_machine(), &executor)
1948 .expect("the host answers")
1949 .expect("a local machine can hold a cache");
1950 assert_eq!(
1951 preview.stats,
1952 Some(mj_core::state::BuildCacheStats {
1953 builds: 155,
1954 cached_compilations: 12050,
1955 avoided_compiler_ns: 6_004_997_818_721,
1956 reflinked_bytes: 47_612_059_386,
1957 })
1958 );
1959 }
1960
1961 #[test]
1962 fn a_cache_nothing_has_used_yet_reports_no_totals() {
1963 let _isolated = isolated();
1964 let executor = ProbeExecutor::new(&plain_host());
1967 let preview = preview_build_cache(&configured_local_machine(), &executor)
1968 .expect("the host answers")
1969 .expect("a local machine can hold a cache");
1970 assert_eq!(preview.stats, None);
1971 }
1972
1973 #[test]
1974 fn a_machine_without_a_standing_host_has_no_build_cache_preview() {
1975 let _isolated = isolated();
1976 let executor = ProbeExecutor::new(&plain_host());
1977 let fleet: mj_core::config::Machine = serde_json::from_value(serde_json::json!({
1978 "kind": "aws-ec2",
1979 "region": "us-east-1",
1980 "launch_template": "lt-1",
1981 "ssh_user": "ubuntu",
1982 }))
1983 .unwrap();
1984 assert_eq!(preview_build_cache(&fleet, &executor).unwrap(), None);
1985 assert!(executor.ran().is_empty());
1986 }
1987
1988 #[test]
1989 fn apple_and_bare_targets_have_no_shared_build_cache() {
1990 let _isolated = isolated();
1991 let executor = ProbeExecutor::new(&plain_host());
1992 for target in [
1993 TargetTemplate::AppleContainer(container(None)),
1994 TargetTemplate::LocalBare,
1995 TargetTemplate::SshBare {
1996 ssh: SshTarget {
1997 destination: "dev@example.test".into(),
1998 ssh_args: Vec::new(),
1999 },
2000 workspace_prefix: "workspaces".into(),
2001 },
2002 ] {
2003 assert_eq!(resolve(&target, &executor), None, "{target:?}");
2004 }
2005 assert!(executor.ran().is_empty());
2006 }
2007
2008 fn bundle() -> targets::ProjectBundleSpec {
2009 targets::ProjectBundleSpec {
2010 primary: "main".into(),
2011 repositories: vec![targets::RepositorySpec {
2012 url: Some("https://github.com/example/main.git".into()),
2013 push_urls: Vec::new(),
2014 destination: "main".into(),
2015 git_ref: None,
2016 reference: None,
2017 }],
2018 }
2019 }
2020
2021 fn clone_cache() -> super::super::git_cache::PreparedCloneCache {
2022 super::super::git_cache::PreparedCloneCache::from_mirrors(
2023 [(
2024 "main".to_owned(),
2025 PathBuf::from("/home/dev/mirror/repo.git"),
2026 )]
2027 .into_iter()
2028 .collect(),
2029 )
2030 }
2031
2032 fn session(container_workspace: Option<&str>) -> mj_core::state::SessionRecord {
2033 let mut record = crate::controller::test_support::checkpoint_test_session("session-1");
2034 record.container_workspace = container_workspace.map(PathBuf::from);
2035 record
2036 }
2037
2038 #[test]
2039 fn a_rust_session_mounts_the_cache_at_the_host_path() {
2040 let _isolated = isolated();
2041 let mut answers = plain_host();
2042 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2043 let executor = ProbeExecutor::new(&answers);
2044 let mut mounts = Vec::new();
2045 let build_cache = prepare(
2046 &podman(None),
2047 &session(Some("/workspace/session-1")),
2048 Some(&bundle()),
2049 Some(&clone_cache()),
2050 &mut mounts,
2051 &executor,
2052 )
2053 .expect("a Rust session uses the build cache");
2054 assert_eq!(build_cache.directory, default_cache_directory());
2055 assert_eq!(
2056 mounts,
2057 vec![targets::AdditionalMount {
2058 source: default_cache_directory(),
2059 destination: default_cache_directory(),
2060 access: targets::MountAccess::Rw,
2061 }]
2062 );
2063 }
2064
2065 #[test]
2066 fn a_repository_without_a_root_manifest_runs_without_the_cache() {
2067 let _isolated = isolated();
2068 let mut answers = plain_host();
2069 answers.push(("cat-file -e HEAD:Cargo.toml", 1, ""));
2070 let executor = ProbeExecutor::new(&answers);
2071 let mut mounts = Vec::new();
2072 assert_eq!(
2073 prepare(
2074 &podman(None),
2075 &session(Some("/workspace/session-1")),
2076 Some(&bundle()),
2077 Some(&clone_cache()),
2078 &mut mounts,
2079 &executor,
2080 ),
2081 None
2082 );
2083 assert!(mounts.is_empty());
2084 }
2085
2086 #[test]
2087 fn a_session_at_the_legacy_shared_workspace_runs_without_the_cache() {
2088 let _isolated = isolated();
2089 let mut answers = plain_host();
2090 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2091 let executor = ProbeExecutor::new(&answers);
2092 let mut mounts = Vec::new();
2093 assert_eq!(
2094 prepare(
2095 &podman(None),
2096 &session(None),
2097 Some(&bundle()),
2098 Some(&clone_cache()),
2099 &mut mounts,
2100 &executor,
2101 ),
2102 None
2103 );
2104 assert!(executor.ran().is_empty());
2105 }
2106
2107 #[test]
2108 fn a_session_without_a_prepared_clone_cache_runs_without_the_cache() {
2109 let _isolated = isolated();
2110 let mut answers = plain_host();
2111 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2112 let executor = ProbeExecutor::new(&answers);
2113 let mut mounts = Vec::new();
2114 assert_eq!(
2115 prepare(
2116 &podman(None),
2117 &session(Some("/workspace/session-1")),
2118 Some(&bundle()),
2119 None,
2120 &mut mounts,
2121 &executor,
2122 ),
2123 None
2124 );
2125 }
2126
2127 #[test]
2128 fn an_apple_target_never_shares_a_build_cache() {
2129 let _isolated = isolated();
2130 let mut answers = plain_host();
2131 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2132 let executor = ProbeExecutor::new(&answers);
2133 let mut mounts = Vec::new();
2134 assert_eq!(
2135 prepare(
2136 &TargetTemplate::AppleContainer(container(None)),
2137 &session(Some("/workspace/session-1")),
2138 Some(&bundle()),
2139 Some(&clone_cache()),
2140 &mut mounts,
2141 &executor,
2142 ),
2143 None
2144 );
2145 assert!(executor.ran().is_empty());
2146 }
2147
2148 #[test]
2149 fn a_resumed_session_uses_current_machine_policy_instead_of_its_saved_budget() {
2150 let _isolated = isolated();
2151 let executor = ProbeExecutor::new(&plain_host());
2152 let mut record = session(Some("/workspace/session-1"));
2153 record.build_cache = Some(SessionBuildCache {
2154 host: "local".into(),
2155 directory: default_cache_directory(),
2156 max_size: Some("1GB".into()),
2157 target_root: None,
2158 });
2159 let mut mounts = Vec::new();
2160 let build_cache =
2161 prepare(&podman(None), &record, None, None, &mut mounts, &executor).unwrap();
2162 assert_eq!(build_cache.max_size, None);
2163 assert_eq!(build_cache.directory, default_cache_directory());
2164 assert_eq!(mounts.len(), 1);
2165 assert!(
2166 executor
2167 .ran()
2168 .iter()
2169 .any(|line| line.contains(".mj-apply.lock"))
2170 );
2171 }
2172
2173 #[test]
2174 fn a_session_moved_to_another_host_resolves_its_build_cache_again() {
2175 let _isolated = isolated();
2176 let mut answers = plain_host();
2177 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2178 let executor = ProbeExecutor::new(&answers);
2179 let mut record = session(Some("/workspace/session-1"));
2180 record.build_cache = Some(SessionBuildCache {
2181 host: "ssh:dev@example.test".into(),
2184 directory: PathBuf::from("/mnt/fast/mbx-cache"),
2185 max_size: None,
2186 target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
2187 });
2188 let mut mounts = Vec::new();
2189
2190 let build_cache = prepare(
2191 &podman(None),
2192 &record,
2193 Some(&bundle()),
2194 Some(&clone_cache()),
2195 &mut mounts,
2196 &executor,
2197 )
2198 .expect("the destination host qualifies on its own");
2199
2200 assert_eq!(build_cache.host, "local");
2201 assert_eq!(build_cache.directory, default_cache_directory());
2202 assert_eq!(build_cache.target_root, None);
2203 assert_eq!(
2204 mounts
2205 .iter()
2206 .map(|mount| mount.destination.clone())
2207 .collect::<Vec<_>>(),
2208 vec![default_cache_directory()]
2209 );
2210 assert!(
2211 executor
2212 .ran()
2213 .iter()
2214 .any(|line| line.contains("mj-reflink"))
2215 );
2216 }
2217
2218 #[test]
2219 fn an_attached_directory_over_the_cache_wins() {
2220 let build_cache = SessionBuildCache {
2221 host: "local-podman".into(),
2222 directory: PathBuf::from("/mnt/fast/mbx-cache"),
2223 max_size: None,
2224 target_root: None,
2225 };
2226 let mut mounts = vec![targets::AdditionalMount {
2227 source: PathBuf::from("/elsewhere"),
2228 destination: PathBuf::from("/mnt/fast/mbx-cache/actions"),
2229 access: targets::MountAccess::Ro,
2230 }];
2231 assert!(!attach_mounts(&build_cache, &mut mounts));
2232 assert_eq!(mounts.len(), 1);
2233 }
2234
2235 #[test]
2236 fn versions_compare_by_release_order() {
2237 assert!(version_at_least("1.12.0", "1.12.0"));
2238 assert!(version_at_least("1.12.1", "1.12.0"));
2239 assert!(version_at_least("2.0.0", "1.12.0"));
2240 assert!(!version_at_least("1.11.9", "1.12.0"));
2241 assert!(!version_at_least("1.9.0", "1.12.0"));
2242 assert!(!version_at_least("not-a-version", "1.12.0"));
2243 }
2244
2245 #[test]
2246 fn free_space_is_read_from_the_available_column() {
2247 assert_eq!(
2248 available_bytes(
2249 "Filesystem 1B-blocks Used Available Capacity Mounted on\n\
2250 /dev/sda1 1000 400 600 40% /\n"
2251 ),
2252 Some(600)
2253 );
2254 assert_eq!(available_bytes("Filesystem 1B-blocks\n"), None);
2255 }
2256}