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