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.16.0";
34
35const MBX_X86_64_SHA256: &str = "be74eb96c62e774d90e8e036ed3d5541bde682802b11dfb1bf340d57276f5ab1";
36const MBX_AARCH64_SHA256: &str = "01cce632e7bacacd78935226e778c5199f6942a55789645eb3e56c662f448792";
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_size: None,
442 target_max_size: None,
443 user_managed: false,
444 application: BuildCacheApplication::Pending,
445 budget_note: None,
446 stats: None,
447 off_reason: Some(BuildCacheOff::Unavailable(UNSUPPORTED_HOST.into())),
448 },
449 cache: None,
450 });
451 }
452 let native = probe_native_version(host, executor)?;
453 let native_version = native.as_ref().map(|native| native.version.clone());
454 let off = |preview: BuildCachePreview| Inspection {
455 preview,
456 cache: None,
457 };
458 if let Some(version) = &native_version
459 && !version_at_least(version, MBX_VERSION)
460 {
461 return Ok(off(BuildCachePreview {
462 native_mbx: native_version.clone(),
463 directory: None,
464 max_size: None,
465 target_max_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_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 configured_target =
519 configuration::configured_limit(config_file.as_deref(), "target", "max_size")?;
520 let target_limit = match configured_target {
521 Some(size) if user_managed => Some(BuildCacheLimit::HostConfiguration(Some(size))),
522 Some(size) => Some(BuildCacheLimit::Size(size)),
523 None => {
524 let disk = configuration::disk_total(
525 host,
526 target_root.as_deref().unwrap_or(&directory),
527 executor,
528 );
529 match disk {
530 Ok(total) => Some(BuildCacheLimit::MbxDefault(Some(
531 configuration::scaled_budget(Some(total), 10, 10, 100, 30),
532 ))),
533 Err(error) => {
534 tracing::warn!(
535 host = host.key(),
536 "could not resolve the worktree default: {error:#}"
537 );
538 None
539 }
540 }
541 }
542 };
543 let mut application =
544 configuration::application(previous_config.as_deref(), config_file.as_deref());
545 if application == BuildCacheApplication::Pending
546 && let Some(Err(error)) = APPLICATIONS
547 .lock()
548 .expect("mbx applications")
549 .get(&format!("{}|{settings:?}", host.key()))
550 {
551 application = BuildCacheApplication::Failed(error.clone());
552 }
553 let stats = read_stats(host, &directory, executor);
556 let preview = |off_reason: Option<BuildCacheOff>| {
557 BuildCachePreview {
558 native_mbx: native_version.clone(),
559 directory: Some(directory.clone()),
560 max_size: Some(limit.clone()),
561 target_max_size: target_limit.clone(),
562 user_managed,
563 application: application.clone(),
564 budget_note: Some("The combined budget also reserves shared compiler outputs and incremental state; worktrees may be collected below their own limit.".into()),
565 stats: stats.clone(),
566 off_reason,
567 }
568 };
569
570 let volume = nearest_existing_ancestor(host, &directory, executor)?;
573 if !settings.enabled.unwrap_or(true) {
576 return Ok(off(preview(Some(BuildCacheOff::TurnedOff))));
577 }
578 if !reflinks_supported(host, &volume, executor)? {
579 return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
580 "the filesystem under {} does not support reflinks, so restoring cached \
581 outputs would copy every byte",
582 directory.display()
583 ))))));
584 }
585 if let Some(reason) = unusable_filesystem(host, &volume, executor)? {
586 return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
587 "{} is on a {reason}, where mbx's file locks are unreliable",
588 directory.display()
589 ))))));
590 }
591
592 if let Some(root) = &target_root {
598 let root_volume = nearest_existing_ancestor(host, root, executor)?;
599 if !cross_reflinks_supported(host, &volume, &root_volume, executor)? {
600 tracing::warn!(
601 cache = %directory.display(),
602 target_root = %root.display(),
603 "the host's mbx target root does not share a mount with the build cache, \
604 so restoring a cached output copies every byte instead of cloning it"
605 );
606 }
607 }
608
609 Ok(Inspection {
610 preview: preview(None),
611 cache: Some(ResolvedBuildCache {
612 directory,
613 target_root,
614 config_file,
615 config_directory,
616 previous_config,
617 }),
618 })
619}
620
621fn read_stats(
630 host: &CacheHost,
631 directory: &Path,
632 executor: &impl CommandExecutor,
633) -> Option<BuildCacheStats> {
634 #[derive(Default, serde::Deserialize)]
635 #[serde(default)]
636 struct Tally {
637 builds: u64,
638 cached_compilations: u64,
639 avoided_compiler_ns: u64,
640 reflinked_bytes: u64,
641 }
642
643 let path = directory.join(TALLY_RELATIVE);
644 let command = host.shell_command(
645 READ_CONFIG_SCRIPT,
646 LABEL,
647 [path.to_string_lossy().into_owned()],
648 "read the container host build cache totals",
649 );
650 let output = executor.execute(&command).ok()?;
651 if output.status != 0 {
652 return None;
653 }
654 let tally: Tally = serde_json::from_slice(&output.stdout)
657 .inspect_err(|error| {
658 tracing::debug!(
659 path = %path.display(),
660 "the build cache totals could not be read: {error}"
661 );
662 })
663 .ok()?;
664 Some(BuildCacheStats {
665 builds: tally.builds,
666 cached_compilations: tally.cached_compilations,
667 avoided_compiler_ns: tally.avoided_compiler_ns,
668 reflinked_bytes: tally.reflinked_bytes,
669 })
670}
671
672fn version_at_least(found: &str, required: &str) -> bool {
674 let parse = |text: &str| semver::Version::parse(text.trim()).ok();
675 match (parse(found), parse(required)) {
676 (Some(found), Some(required)) => found >= required,
677 _ => false,
679 }
680}
681
682#[derive(Debug, Clone, PartialEq, Eq)]
684struct NativeMbx {
685 program: String,
686 version: String,
687}
688
689const NATIVE_VERSION_SCRIPT: &str = r#"for m in mbx "$HOME/.cargo/bin/mbx"; do
692 if v=$("$m" --version 2>/dev/null); then
693 printf '%s
694%s' "$m" "$v"
695 exit 0
696 fi
697done
698exit 1"#;
699
700fn probe_native_version(
703 host: &CacheHost,
704 executor: &impl CommandExecutor,
705) -> Result<Option<NativeMbx>> {
706 let command = host.shell_command(
707 NATIVE_VERSION_SCRIPT,
708 LABEL,
709 [],
710 "read the container host mbx version",
711 );
712 let output = executor.execute(&command)?;
713 if output.status == 1 {
714 return Ok(None);
715 }
716 ensure!(
717 output.status == 0,
718 "mbx version probe exited with status {}",
719 output.status
720 );
721 let text = String::from_utf8_lossy(&output.stdout);
722 let (program, version) = text
723 .trim()
724 .split_once('\n')
725 .context("mbx version probe gave no version")?;
726 let version = version
727 .split_whitespace()
728 .next_back()
729 .context("mbx version probe gave an empty version")?;
730 Ok(Some(NativeMbx {
731 program: program.to_owned(),
732 version: version.to_owned(),
733 }))
734}
735
736fn native_cache_directory(
739 host: &CacheHost,
740 native: &NativeMbx,
741 executor: &impl CommandExecutor,
742) -> Result<PathBuf> {
743 let command = host.command(
744 vec![
745 native.program.clone(),
746 "cache".to_owned(),
747 "dir".to_owned(),
748 "--json".to_owned(),
749 ],
750 "read the container host mbx cache directory",
751 );
752 let output = checked(executor.execute(&command)?, &command)?;
753 let report: serde_json::Value =
754 serde_json::from_slice(&output.stdout).context("parse the mbx cache directory report")?;
755 let store = report
756 .get("store")
757 .and_then(serde_json::Value::as_str)
758 .context("the mbx cache directory report has no store path")?;
759 Path::new(store)
760 .parent()
761 .map(Path::to_path_buf)
762 .with_context(|| format!("mbx store path {store:?} has no parent"))
763}
764
765const READ_CONFIG_SCRIPT: &str = r#"[ -f "$1" ] || exit 3
766cat -- "$1""#;
767
768fn host_config_file(host: &CacheHost, executor: &impl CommandExecutor) -> Result<Option<String>> {
772 let directory = configuration::host_directory(host, executor)?;
773 configuration::read_file(host, &directory.join("config.toml"), executor)
774}
775
776fn relocated_target_root(config_file: &str, directory: &Path) -> Option<PathBuf> {
779 let document: toml::Value = toml::from_str(config_file)
780 .map_err(|error| tracing::warn!("the host mbx configuration is unreadable: {error}"))
781 .ok()?;
782 let root = document.get("target")?.get("root")?.as_str()?;
783 let root = directory.join(root);
784 (!root.starts_with(directory)).then_some(root)
785}
786
787const NEAREST_ANCESTOR_SCRIPT: &str = r#"d=$1
788while [ ! -d "$d" ]; do
789 parent=$(dirname -- "$d")
790 if [ "$parent" = "$d" ]; then
791 break
792 fi
793 d=$parent
794done
795printf '%s' "$d""#;
796
797fn nearest_existing_ancestor(
800 host: &CacheHost,
801 directory: &Path,
802 executor: &impl CommandExecutor,
803) -> Result<PathBuf> {
804 let command = host.shell_command(
805 NEAREST_ANCESTOR_SCRIPT,
806 LABEL,
807 [directory.to_string_lossy().into_owned()],
808 "locate the build cache volume",
809 );
810 let output = checked(executor.execute(&command)?, &command)?;
811 let path = PathBuf::from(String::from_utf8(output.stdout).context("decode cache ancestor")?);
812 ensure!(
813 path.is_absolute(),
814 "build cache volume {} is not absolute",
815 path.display()
816 );
817 Ok(path)
818}
819
820fn default_max_size(
828 host: &CacheHost,
829 directory: &Path,
830 executor: &impl CommandExecutor,
831) -> Result<String> {
832 let volume = nearest_existing_ancestor(host, directory, executor)?;
833 let command = host.command(
834 vec![
835 "df".to_owned(),
836 "-B1".to_owned(),
837 "-P".to_owned(),
838 "--".to_owned(),
839 volume.to_string_lossy().into_owned(),
840 ],
841 "measure the build cache volume",
842 );
843 let output = checked(executor.execute(&command)?, &command)?;
844 let available = available_bytes(&String::from_utf8_lossy(&output.stdout))
845 .context("read the free space on the build cache volume")?;
846 Ok(format!("{}B", DEFAULT_MAX_BYTES.min(available / 4)))
847}
848
849pub(super) fn available_bytes(report: &str) -> Option<u64> {
853 let row = report
854 .lines()
855 .filter(|line| !line.trim().is_empty())
856 .nth(1)?;
857 let fields = row.split_whitespace().collect::<Vec<_>>();
858 let available = fields.get(fields.len().checked_sub(3)?)?;
860 available.parse().ok()
861}
862
863const REFLINK_SCRIPT: &str = r#"dir=$1
864d=$(mktemp -d "$dir/.mj-reflink.XXXXXX") || exit 1
865printf x > "$d/a" && cp --reflink=always "$d/a" "$d/b"
866status=$?
867rm -rf -- "$d"
868exit $status"#;
869
870fn reflinks_supported(
874 host: &CacheHost,
875 volume: &Path,
876 executor: &impl CommandExecutor,
877) -> Result<bool> {
878 let command = host.shell_command(
879 REFLINK_SCRIPT,
880 LABEL,
881 [volume.to_string_lossy().into_owned()],
882 "probe the build cache volume for reflinks",
883 );
884 Ok(executor.execute(&command)?.status == 0)
885}
886
887const CROSS_REFLINK_SCRIPT: &str = r#"src=$1
888dst=$2
889s=$(mktemp -d "$src/.mj-reflink.XXXXXX") || exit 1
890d=$(mktemp -d "$dst/.mj-reflink.XXXXXX") || { rm -rf -- "$s"; exit 1; }
891printf x > "$s/a" && cp --reflink=always "$s/a" "$d/b"
892status=$?
893rm -rf -- "$s" "$d"
894exit $status"#;
895
896fn cross_reflinks_supported(
900 host: &CacheHost,
901 store: &Path,
902 target_root: &Path,
903 executor: &impl CommandExecutor,
904) -> Result<bool> {
905 let command = host.shell_command(
906 CROSS_REFLINK_SCRIPT,
907 LABEL,
908 [
909 store.to_string_lossy().into_owned(),
910 target_root.to_string_lossy().into_owned(),
911 ],
912 "probe the managed target root for reflinks from the build cache",
913 );
914 Ok(executor.execute(&command)?.status == 0)
915}
916
917fn create_directory(
918 host: &CacheHost,
919 directory: &Path,
920 executor: &impl CommandExecutor,
921) -> Result<()> {
922 let command = host.command(
923 vec![
924 "mkdir".to_owned(),
925 "-p".to_owned(),
926 "--".to_owned(),
927 directory.to_string_lossy().into_owned(),
928 ],
929 "create the build cache directory",
930 );
931 checked(executor.execute(&command)?, &command).map(|_| ())
932}
933
934fn unusable_filesystem(
937 host: &CacheHost,
938 directory: &Path,
939 executor: &impl CommandExecutor,
940) -> Result<Option<&'static str>> {
941 let filesystems =
942 targets::probe_filesystem_types(host.ssh(), &[directory.to_path_buf()], executor)?;
943 let filesystem = filesystems
944 .first()
945 .context("the filesystem probe named no filesystem")?;
946 Ok(targets::overlay_unsupported_filesystem(filesystem)
950 .filter(|reason| matches!(*reason, "network filesystem" | "FUSE filesystem")))
951}
952
953fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
954 if output.status == 0 {
955 return Ok(output);
956 }
957 bail!(
958 "{} failed with status {}: {}",
959 command.purpose,
960 output.status,
961 String::from_utf8_lossy(&output.stderr).trim()
962 )
963}
964
965pub(super) fn binary_for(
970 locator: &targets::TargetLocator,
971 executor: &impl CommandExecutor,
972) -> Result<PathBuf> {
973 let triple = super::worker_binary::target_architecture(locator, executor)?;
974 if let Some(path) = std::env::var_os(MBX_BINARY_ENV) {
975 let path = PathBuf::from(path);
976 ensure!(
977 path.is_file(),
978 "{MBX_BINARY_ENV} does not name a file: {}",
979 path.display()
980 );
981 if triple == host_architecture() {
982 return Ok(path);
983 }
984 tracing::warn!(
985 triple,
986 "{MBX_BINARY_ENV} is for this machine's architecture; downloading the pinned mbx \
987 for the target instead"
988 );
989 }
990 download(triple)
991}
992
993fn host_architecture() -> &'static str {
996 if cfg!(target_arch = "aarch64") {
997 "aarch64"
998 } else {
999 "x86_64"
1000 }
1001}
1002
1003fn release_url(triple: &str) -> String {
1004 format!(
1005 "https://github.com/jdx/mr-boxington/releases/download/v{MBX_VERSION}/mbx-{triple}-unknown-linux-musl.tar.gz"
1006 )
1007}
1008
1009fn expected_digest(triple: &str) -> Result<&'static str> {
1010 match triple {
1011 "x86_64" => Ok(MBX_X86_64_SHA256),
1012 "aarch64" => Ok(MBX_AARCH64_SHA256),
1013 _ => bail!("no pinned mbx release for {triple}"),
1014 }
1015}
1016
1017fn download(triple: &str) -> Result<PathBuf> {
1020 let expected = expected_digest(triple)?;
1021 let directory = mj_core::config::data_dir()
1022 .join("mbx")
1023 .join(MBX_VERSION)
1024 .join(triple);
1025 let destination = directory.join("mbx");
1026 if destination.is_file() {
1027 return Ok(destination);
1028 }
1029 std::fs::create_dir_all(&directory)
1030 .with_context(|| format!("create the mbx cache {}", directory.display()))?;
1031 let url = release_url(triple);
1032 let archive = reqwest::blocking::Client::builder()
1033 .timeout(Duration::from_secs(120))
1034 .build()?
1035 .get(&url)
1036 .send()
1037 .with_context(|| format!("download {url}"))?
1038 .error_for_status()
1039 .with_context(|| format!("download {url}"))?
1040 .bytes()?;
1041 let actual = mj_core::hex::lower_hex(Sha256::digest(&archive));
1042 ensure!(
1043 actual.eq_ignore_ascii_case(expected),
1044 "downloaded mbx checksum mismatch: expected {expected}, got {actual}"
1045 );
1046 let binary = extract_binary(&archive)?;
1047 let mut temporary = tempfile::NamedTempFile::new_in(&directory)?;
1048 std::io::Write::write_all(&mut temporary, &binary)?;
1049 temporary.as_file_mut().sync_all()?;
1050 #[cfg(unix)]
1051 {
1052 use std::os::unix::fs::PermissionsExt;
1053 std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o700))?;
1054 }
1055 match temporary.persist_noclobber(&destination) {
1056 Ok(_) => Ok(destination),
1057 Err(error) if destination.is_file() => {
1058 drop(error);
1059 Ok(destination)
1060 }
1061 Err(error) => Err(error.error)
1062 .with_context(|| format!("publish the mbx binary {}", destination.display())),
1063 }
1064}
1065
1066fn extract_binary(archive: &[u8]) -> Result<Vec<u8>> {
1069 let mut reader = tar::Archive::new(flate2::read::GzDecoder::new(archive));
1070 for entry in reader.entries().context("read the mbx release archive")? {
1071 let mut entry = entry.context("read the mbx release archive")?;
1072 if entry.path().context("read an mbx archive path")?.as_ref() != Path::new("mbx") {
1073 continue;
1074 }
1075 let mut bytes = Vec::new();
1076 entry
1077 .read_to_end(&mut bytes)
1078 .context("read the mbx binary from its release archive")?;
1079 return Ok(bytes);
1080 }
1081 bail!("the mbx release archive contains no mbx binary")
1082}
1083
1084pub(super) fn primary_repository_is_rust(
1090 host: &CacheHost,
1091 mirror: &Path,
1092 executor: &impl CommandExecutor,
1093) -> bool {
1094 let command = host.command(
1095 vec![
1096 "git".to_owned(),
1097 "--git-dir".to_owned(),
1098 mirror.to_string_lossy().into_owned(),
1099 "cat-file".to_owned(),
1100 "-e".to_owned(),
1101 "HEAD:Cargo.toml".to_owned(),
1102 ],
1103 "detect a Cargo workspace in the session repository",
1104 );
1105 matches!(executor.execute(&command), Ok(output) if output.status == 0)
1106}
1107
1108pub(super) fn prepare(
1112 target: &targets::TargetTemplate,
1113 session: &mj_core::state::SessionRecord,
1114 bundle: Option<&targets::ProjectBundleSpec>,
1115 clone_cache: Option<&super::git_cache::PreparedCloneCache>,
1116 mounts: &mut Vec<targets::AdditionalMount>,
1117 executor: &impl CommandExecutor,
1118) -> Option<SessionBuildCache> {
1119 session.container_workspace.as_ref()?;
1124 let resolved = resolve(target, executor)?;
1125 let host = supported_host(target)?.0;
1126 if session.build_cache.is_none() {
1127 let mirror = clone_cache?.mirror_for(&bundle?.primary)?;
1128 if !primary_repository_is_rust(&host, mirror, executor) {
1129 return None;
1130 }
1131 }
1132 let build_cache = SessionBuildCache {
1133 host: host.key(),
1134 directory: resolved.directory,
1135 max_size: None,
1136 target_root: resolved.target_root,
1137 };
1138 attach_mounts(&build_cache, mounts).then_some(build_cache)
1139}
1140
1141fn attach_mounts(
1145 build_cache: &SessionBuildCache,
1146 mounts: &mut Vec<targets::AdditionalMount>,
1147) -> bool {
1148 let wanted = std::iter::once(&build_cache.directory)
1149 .chain(build_cache.target_root.iter())
1150 .collect::<Vec<_>>();
1151 for directory in &wanted {
1152 if mounts.iter().any(|mount| {
1153 mount.destination.starts_with(directory) || directory.starts_with(&mount.destination)
1154 }) {
1155 tracing::warn!(
1156 directory = %directory.display(),
1157 "an attached directory overlaps the build cache, so this session runs without it"
1158 );
1159 return false;
1160 }
1161 }
1162 for directory in wanted {
1163 mounts.push(targets::AdditionalMount {
1164 source: directory.clone(),
1165 destination: directory.clone(),
1166 access: targets::MountAccess::Rw,
1167 });
1168 }
1169 true
1170}
1171
1172#[cfg(test)]
1175pub(super) fn attach_mounts_for_tests(
1176 build_cache: &SessionBuildCache,
1177 mounts: &mut Vec<targets::AdditionalMount>,
1178) -> bool {
1179 attach_mounts(build_cache, mounts)
1180}
1181
1182fn host_for_locator(target: &targets::TargetLocator) -> Option<CacheHost> {
1185 match target {
1186 targets::TargetLocator::LocalPodman { .. } | targets::TargetLocator::LocalDocker { .. } => {
1187 Some(CacheHost::Local)
1188 }
1189 targets::TargetLocator::SshPodman { ssh, .. }
1190 | targets::TargetLocator::SshDocker { ssh, .. } => Some(CacheHost::Ssh(ssh.clone())),
1191 _ => None,
1192 }
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197 use super::*;
1198 use crate::targets::{ContainerTemplate, SshTarget, TargetTemplate};
1199 use mj_core::config::ImagePullPolicy;
1200 use std::sync::Mutex;
1201
1202 static ISOLATED: Mutex<()> = Mutex::new(());
1205
1206 fn isolated() -> std::sync::MutexGuard<'static, ()> {
1207 let guard = ISOLATED.lock().unwrap_or_else(|error| error.into_inner());
1208 RESOLUTIONS.lock().expect("mbx resolutions").clear();
1209 APPLICATIONS.lock().expect("mbx applications").clear();
1210 guard
1211 }
1212
1213 #[derive(Default)]
1215 struct ProbeExecutor {
1216 answers: Vec<(&'static str, i32, String)>,
1217 seen: Mutex<Vec<String>>,
1218 }
1219
1220 impl ProbeExecutor {
1221 fn new(answers: &[(&'static str, i32, &str)]) -> Self {
1222 Self {
1223 answers: answers
1224 .iter()
1225 .map(|(needle, status, stdout)| (*needle, *status, (*stdout).to_owned()))
1226 .chain(std::iter::once(("uname -sm", 0, "Linux x86_64\n".into())))
1227 .collect(),
1228 seen: Mutex::new(Vec::new()),
1229 }
1230 }
1231
1232 fn ran(&self) -> Vec<String> {
1233 self.seen.lock().unwrap().clone()
1234 }
1235 }
1236
1237 impl CommandExecutor for ProbeExecutor {
1238 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1239 let line = format!("{} {}", command.program, command.args.join(" "));
1240 self.seen.lock().unwrap().push(line.clone());
1241 let searchable = if command.program == "ssh" {
1243 line.replace("'\\''", "'").replace("' '", " ")
1244 } else {
1245 line.clone()
1246 };
1247 for (needle, status, stdout) in &self.answers {
1248 if searchable.contains(needle) {
1249 return Ok(CommandOutput {
1250 status: *status,
1251 stdout: stdout.clone().into_bytes(),
1252 stderr: Vec::new(),
1253 });
1254 }
1255 }
1256 Ok(CommandOutput {
1257 status: 127,
1258 stdout: Vec::new(),
1259 stderr: format!("no canned answer for {line}").into_bytes(),
1260 })
1261 }
1262 }
1263
1264 fn container(build_cache: Option<TargetBuildCache>) -> ContainerTemplate {
1265 ContainerTemplate {
1266 image: "example/image:latest".into(),
1267 pull_policy: ImagePullPolicy::Missing,
1268 extra_run_args: Vec::new(),
1269 workspace_storage: Default::default(),
1270 build_cache,
1271 }
1272 }
1273
1274 fn podman(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1275 TargetTemplate::LocalPodman(container(build_cache))
1276 }
1277
1278 fn docker(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1279 TargetTemplate::LocalDocker(container(build_cache))
1280 }
1281
1282 fn configured_local_machine() -> mj_core::config::Machine {
1285 serde_json::from_value(serde_json::json!({"kind": "local"})).unwrap()
1286 }
1287
1288 fn default_cache_directory() -> PathBuf {
1291 dirs::home_dir()
1292 .expect("a home directory")
1293 .join(DEFAULT_CACHE_RELATIVE)
1294 }
1295
1296 fn current_native_mbx() -> String {
1300 format!("mbx\nmbx {MBX_VERSION}")
1301 }
1302
1303 fn plain_host() -> Vec<(&'static str, i32, &'static str)> {
1304 vec![
1305 ("$m\" --version", 1, ""),
1306 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1307 ("[ -f \"$1\" ]", 3, ""),
1308 ("while [ ! -d", 0, "/home/dev"),
1309 (
1310 "df -B1 -P",
1311 0,
1312 "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 1000000000000 0 800000000000 20% /home\n",
1313 ),
1314 ("mj-reflink", 0, ""),
1315 ("mkdir -p", 0, ""),
1316 ("stat -f -c %T", 0, "xfs"),
1317 ]
1318 }
1319
1320 #[test]
1321 fn darwin_hosts_skip_cache_inspection_provisioning_and_reconciliation() {
1322 let _isolated = isolated();
1323 for remote in [false, true] {
1324 for enabled in [None, Some(true)] {
1325 let settings = TargetBuildCache {
1326 enabled,
1327 ..Default::default()
1328 };
1329 let machine: mj_core::config::Machine = if remote {
1330 serde_json::from_value(serde_json::json!({
1331 "kind": "ssh", "host": "mac.test", "user": "builder", "build_cache": settings,
1332 }))
1333 .unwrap()
1334 } else {
1335 mj_core::config::Machine::Local {
1336 build_cache: Some(settings.clone()),
1337 }
1338 };
1339 let target = if remote {
1340 TargetTemplate::SshPodman {
1341 ssh: SshTarget {
1342 destination: "builder@mac.test".into(),
1343 ssh_args: vec![],
1344 },
1345 container: container(Some(settings)),
1346 }
1347 } else {
1348 podman(Some(settings))
1349 };
1350 let executor = ProbeExecutor::new(&[
1352 ("uname -sm", 0, "Darwin arm64\n"),
1353 ("$m\" --version", 0, "mbx\nmbx 1.16.0"),
1354 ]);
1355 let preview = preview_build_cache(&machine, &executor).unwrap().unwrap();
1356 assert_eq!(
1357 preview.off_reason,
1358 Some(BuildCacheOff::Unavailable(UNSUPPORTED_HOST.into()))
1359 );
1360 assert!(preview.directory.is_none());
1361 assert!(resolve(&target, &executor).is_none());
1362 apply_machine_build_cache(&machine, &[PathBuf::from("/existing/cache")], &executor)
1363 .unwrap();
1364 let commands = executor.ran();
1365 assert!(!commands.is_empty());
1366 assert!(
1367 commands
1368 .iter()
1369 .all(|command| command.contains("uname") && command.contains("-sm")),
1370 "{commands:?}"
1371 );
1372 assert!(
1373 commands
1374 .iter()
1375 .all(|command| command.starts_with(if remote { "ssh " } else { "uname " }))
1376 );
1377 }
1378 }
1379 }
1380
1381 #[test]
1382 fn linux_ssh_cache_remains_available_on_any_controller_platform() {
1383 let _isolated = isolated();
1384 let executor = ProbeExecutor::new(&plain_host());
1385 let target = TargetTemplate::SshDocker {
1386 ssh: SshTarget {
1387 destination: "builder@linux.test".into(),
1388 ssh_args: vec![],
1389 },
1390 container: container(None),
1391 };
1392 let cache = resolve(&target, &executor).unwrap();
1393 assert_eq!(cache.directory, PathBuf::from("/home/dev/.cache/mbx"));
1394 assert_eq!(cache.previous_config, cache.config_file);
1395 assert!(
1396 executor.ran().iter().all(
1397 |command| command.starts_with("ssh ") && command.contains("builder@linux.test")
1398 )
1399 );
1400 }
1401
1402 #[test]
1403 fn recorded_darwin_cache_fails_explicitly_without_writing() {
1404 let executor = ProbeExecutor::new(&[("uname -sm", 0, "Darwin x86_64")]);
1405 let recorded = SessionBuildCache {
1406 host: "local".into(),
1407 directory: PathBuf::from("/existing/cache"),
1408 max_size: None,
1409 target_root: None,
1410 };
1411 let backend = targets::TargetLocator::LocalPodman {
1412 container_id: "saved-container".into(),
1413 workspace_storage: Default::default(),
1414 borrowed_from: None,
1415 };
1416 let error =
1417 prepare_session_configuration(&Config::default(), &backend, &recorded, &executor)
1418 .unwrap_err();
1419 assert!(format!("{error:#}").contains(UNSUPPORTED_HOST));
1420 assert_eq!(executor.ran(), ["uname -sm"]);
1421 }
1422
1423 #[test]
1424 fn failed_platform_probe_does_not_attempt_cache_operations() {
1425 let executor = ProbeExecutor::new(&[("uname -sm", 1, "")]);
1426 assert!(inspect_host(&CacheHost::Local, &TargetBuildCache::default(), &executor).is_err());
1427 assert_eq!(executor.ran(), ["uname -sm"]);
1428 }
1429
1430 #[test]
1431 fn installed_worker_reads_cache_configuration_from_its_recorded_host() {
1432 let executor = ProbeExecutor::new(&[
1433 ("XDG_CONFIG_HOME", 0, "/home/builder/.config/mbx"),
1434 ("$HOME", 0, "/home/builder"),
1435 ("[ -f \"$1\" ]", 0, "[gc]\nmax_total_size = '50GB'\n"),
1436 ]);
1437 let target = targets::TargetLocator::SshPodman {
1438 ssh: SshTarget {
1439 destination: "builder@recorded-cache.test".into(),
1440 ssh_args: vec![],
1441 },
1442 container_id: "saved-container".into(),
1443 workspace_storage: Default::default(),
1444 borrowed_from: None,
1445 };
1446 let config = host_config_file(&host_for_locator(&target).unwrap(), &executor)
1447 .unwrap()
1448 .unwrap();
1449 assert!(config.contains("50GB"));
1450 assert!(
1451 executor
1452 .seen
1453 .lock()
1454 .unwrap()
1455 .iter()
1456 .all(|command| command.contains("builder@recorded-cache.test"))
1457 );
1458 }
1459
1460 #[test]
1461 fn a_native_mbx_supplies_the_cache_directory_and_its_own_limits() {
1462 let _isolated = isolated();
1463 let executor = ProbeExecutor::new(&[
1464 ("$m\" --version", 0, current_native_mbx().as_str()),
1465 (
1466 "mbx cache dir --json",
1467 0,
1468 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1469 ),
1470 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1471 (
1472 "[ -f \"$1\" ]",
1473 0,
1474 "cache_dir = \"/mnt/fast/mbx-cache\"\n[gc]\nmax_size = \"500GiB\"\n",
1475 ),
1476 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1477 ("mj-reflink", 0, ""),
1478 ("mkdir -p", 0, ""),
1479 ("stat -f -c %T", 0, "xfs"),
1480 ]);
1481 let resolved = resolve(&podman(None), &executor).unwrap();
1482 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1483 assert_eq!(
1485 configuration::configured_limit(
1486 resolved.config_file.as_deref(),
1487 "gc",
1488 "max_total_size"
1489 )
1490 .unwrap(),
1491 None
1492 );
1493 assert_eq!(resolved.target_root, None);
1494 assert!(resolved.config_file.unwrap().contains("500GiB"));
1495 assert!(
1496 !executor
1497 .ran()
1498 .iter()
1499 .any(|line| line.contains("apply machine")),
1500 "a host configuration is never rewritten"
1501 );
1502 }
1503
1504 #[test]
1505 fn looking_at_the_settings_page_lets_the_next_session_see_a_repaired_host() {
1506 let _isolated = isolated();
1507 let broken = ProbeExecutor::new(
1508 &plain_host()
1509 .into_iter()
1510 .map(|(needle, status, stdout)| match needle {
1511 "mj-reflink" => (needle, 1, stdout),
1512 _ => (needle, status, stdout),
1513 })
1514 .collect::<Vec<_>>(),
1515 );
1516 assert!(
1517 resolve(&podman(None), &broken).is_none(),
1518 "a volume that cannot clone runs without the cache"
1519 );
1520
1521 let repaired = ProbeExecutor::new(&plain_host());
1524 let preview = preview_build_cache(&configured_local_machine(), &repaired)
1525 .expect("the host answers")
1526 .expect("a local machine can hold a cache");
1527 assert_eq!(preview.off_reason, None);
1528
1529 assert!(
1530 resolve(&podman(None), &repaired).is_some(),
1531 "the next session asks the repaired host again instead of reusing the old verdict"
1532 );
1533 }
1534
1535 #[test]
1536 fn a_relocated_target_root_is_reported_for_its_own_mount() {
1537 let _isolated = isolated();
1538 let executor = ProbeExecutor::new(&[
1539 ("$m\" --version", 0, current_native_mbx().as_str()),
1540 (
1541 "mbx cache dir --json",
1542 0,
1543 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1544 ),
1545 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1546 (
1547 "[ -f \"$1\" ]",
1548 0,
1549 "[target]\nroot = \"/mnt/fast/mbx-targets\"\n",
1550 ),
1551 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1552 ("mj-reflink", 0, ""),
1553 ("mkdir -p", 0, ""),
1554 ("stat -f -c %T", 0, "xfs"),
1555 ]);
1556 let resolved = resolve(&podman(None), &executor).unwrap();
1557 assert_eq!(
1558 resolved.target_root,
1559 Some(PathBuf::from("/mnt/fast/mbx-targets"))
1560 );
1561 }
1562
1563 #[test]
1564 fn a_target_root_that_cannot_be_cloned_into_still_gets_the_cache() {
1565 let _isolated = isolated();
1566 let executor = ProbeExecutor::new(&[
1567 ("$m\" --version", 0, current_native_mbx().as_str()),
1568 (
1569 "mbx cache dir --json",
1570 0,
1571 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1572 ),
1573 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1574 (
1575 "[ -f \"$1\" ]",
1576 0,
1577 "[target]\nroot = \"/mnt/slow/mbx-targets\"\n",
1578 ),
1579 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1580 ("src=$1", 1, ""),
1583 ("mj-reflink", 0, ""),
1584 ("mkdir -p", 0, ""),
1585 ("stat -f -c %T", 0, "xfs"),
1586 ]);
1587 let resolved = resolve(&podman(None), &executor)
1588 .expect("a target root that copies instead of cloning is slower, not unusable");
1589 assert_eq!(
1590 resolved.target_root,
1591 Some(PathBuf::from("/mnt/slow/mbx-targets"))
1592 );
1593 assert!(
1594 executor.ran().iter().any(|line| line.contains("src=$1")),
1595 "the store and the target root are probed as a pair: {:?}",
1596 executor.ran()
1597 );
1598 }
1599
1600 #[test]
1601 fn a_target_root_inside_the_cache_directory_needs_no_second_mount() {
1602 assert_eq!(
1603 relocated_target_root("[target]\nroot = \"targets\"\n", Path::new("/cache")),
1604 None
1605 );
1606 assert_eq!(
1607 relocated_target_root("[target]\nroot = \"/cache/targets\"\n", Path::new("/cache")),
1608 None
1609 );
1610 }
1611
1612 #[test]
1613 fn a_cargo_installed_mbx_off_the_path_is_queried_where_it_was_found() {
1614 let _isolated = isolated();
1615 let found = format!("/home/dev/.cargo/bin/mbx\nmbx {MBX_VERSION}");
1616 let mut answers: Vec<(&'static str, i32, &str)> = plain_host();
1617 answers.retain(|(needle, _, _)| *needle != "$m\" --version");
1618 answers.push(("$m\" --version", 0, found.as_str()));
1619 answers.push((
1620 "/home/dev/.cargo/bin/mbx cache dir --json",
1621 0,
1622 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1623 ));
1624 let executor = ProbeExecutor::new(&answers);
1625 let resolved = resolve(&podman(None), &executor).unwrap();
1626 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1627 }
1628
1629 #[test]
1630 fn an_older_native_mbx_must_not_share_the_store() {
1631 let _isolated = isolated();
1632 let executor = ProbeExecutor::new(&[("$m\" --version", 0, "mbx\nmbx 1.15.0")]);
1633 assert_eq!(resolve(&podman(None), &executor), None);
1634 }
1635
1636 #[test]
1637 fn a_host_without_mbx_falls_back_to_the_default_cache_directory() {
1638 let _isolated = isolated();
1639 let executor = ProbeExecutor::new(&plain_host());
1640 let resolved = resolve(&podman(None), &executor).unwrap();
1641 assert_eq!(resolved.directory, default_cache_directory());
1642 assert_eq!(
1644 configuration::configured_limit(
1645 resolved.config_file.as_deref(),
1646 "gc",
1647 "max_total_size"
1648 )
1649 .unwrap()
1650 .as_deref(),
1651 Some("100000000000B")
1652 );
1653 }
1654
1655 #[test]
1656 fn a_small_volume_takes_a_quarter_of_its_free_space() {
1657 let _isolated = isolated();
1658 let mut answers = plain_host();
1659 answers.retain(|(needle, _, _)| *needle != "df -B1 -P");
1660 answers.push((
1661 "df -B1 -P",
1662 0,
1663 "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 100000000 60000000 40000000 60% /home\n",
1664 ));
1665 let executor = ProbeExecutor::new(&answers);
1666 let resolved = resolve(&podman(None), &executor).unwrap();
1667 assert_eq!(
1668 configuration::configured_limit(
1669 resolved.config_file.as_deref(),
1670 "gc",
1671 "max_total_size"
1672 )
1673 .unwrap()
1674 .as_deref(),
1675 Some("10000000B")
1676 );
1677 }
1678
1679 #[test]
1680 fn target_overrides_win_over_every_default() {
1681 let _isolated = isolated();
1682 let mut answers = plain_host();
1683 answers.push(("mbx cache dir", 0, r#"{"store":"/other/actions"}"#));
1684 let executor = ProbeExecutor::new(&answers);
1685 let resolved = resolve(
1686 &podman(Some(TargetBuildCache {
1687 enabled: Some(true),
1688 directory: Some(PathBuf::from("/mnt/nvme/mbx")),
1689 max_size: Some("250GiB".into()),
1690 target_max_size: None,
1691 })),
1692 &executor,
1693 )
1694 .unwrap();
1695 assert_eq!(resolved.directory, PathBuf::from("/mnt/nvme/mbx"));
1696 assert_eq!(
1697 configuration::configured_limit(
1698 resolved.config_file.as_deref(),
1699 "gc",
1700 "max_total_size"
1701 )
1702 .unwrap()
1703 .as_deref(),
1704 Some("250GiB")
1705 );
1706 }
1707
1708 #[test]
1709 fn total_and_worktree_budgets_resolve_into_one_machine_document() {
1710 let _isolated = isolated();
1711 let executor = ProbeExecutor::new(&plain_host());
1712 let settings = TargetBuildCache {
1713 max_size: Some("500GiB".into()),
1714 target_max_size: Some("250GiB".into()),
1715 ..Default::default()
1716 };
1717 let inspection = inspect_host(&CacheHost::Local, &settings, &executor).unwrap();
1718 assert!(!inspection.preview.user_managed);
1719 assert_eq!(
1720 inspection.preview.max_size,
1721 Some(BuildCacheLimit::Size("500GiB".into()))
1722 );
1723 assert_eq!(
1724 inspection.preview.target_max_size,
1725 Some(BuildCacheLimit::Size("250GiB".into()))
1726 );
1727 assert_eq!(
1728 inspection.preview.application,
1729 BuildCacheApplication::Pending
1730 );
1731 let cache = inspection.cache.unwrap();
1732 assert_eq!(
1733 configuration::configured_limit(cache.config_file.as_deref(), "target", "max_size")
1734 .unwrap()
1735 .as_deref(),
1736 Some("250GiB")
1737 );
1738 assert!(
1739 !executor
1740 .ran()
1741 .iter()
1742 .any(|command| command.contains(".mj-apply.lock")),
1743 "preview never applies a setting"
1744 );
1745 }
1746
1747 #[test]
1748 fn a_native_installation_owns_both_budgets_despite_saved_mj_overrides() {
1749 let _isolated = isolated();
1750 let native = current_native_mbx();
1751 let mut answers = vec![
1752 ("$m\" --version", 0, native.as_str()),
1753 (
1754 "mbx cache dir --json",
1755 0,
1756 r#"{"store":"/native/cache/actions"}"#,
1757 ),
1758 (
1759 "[ -f \"$1\" ]",
1760 0,
1761 "[gc]\nmax_total_size = '400GiB'\n[target]\nmax_size = 'none'\n",
1762 ),
1763 ];
1764 answers.extend(plain_host());
1765 let executor = ProbeExecutor::new(&answers);
1766 let settings = TargetBuildCache {
1767 directory: Some("/ignored".into()),
1768 max_size: Some("1GB".into()),
1769 target_max_size: Some("2GB".into()),
1770 ..Default::default()
1771 };
1772 let inspection = inspect_host(&CacheHost::Local, &settings, &executor).unwrap();
1773 assert!(inspection.preview.user_managed);
1774 assert_eq!(inspection.preview.directory, Some("/native/cache".into()));
1775 assert_eq!(
1776 inspection.preview.max_size,
1777 Some(BuildCacheLimit::HostConfiguration(Some("400GiB".into())))
1778 );
1779 assert_eq!(
1780 inspection.preview.target_max_size,
1781 Some(BuildCacheLimit::HostConfiguration(Some("none".into())))
1782 );
1783 assert!(
1784 !executor
1785 .ran()
1786 .iter()
1787 .any(|command| command.contains(".mj-apply.lock"))
1788 );
1789 }
1790
1791 #[test]
1792 fn the_automatic_total_is_reused_instead_of_following_free_space() {
1793 let _isolated = isolated();
1794 let saved = configuration::managed_document(&TargetBuildCache::default(), "17GB").unwrap();
1795 let mut answers = vec![(".mjolnir/config/mbx/config.toml", 0, saved.as_str())];
1796 answers.extend(plain_host());
1797 let executor = ProbeExecutor::new(&answers);
1798 let preview = inspect_host(&CacheHost::Local, &TargetBuildCache::default(), &executor)
1799 .unwrap()
1800 .preview;
1801 assert_eq!(
1802 preview.max_size,
1803 Some(BuildCacheLimit::MjDefault("17GB".into()))
1804 );
1805 assert_eq!(preview.application, BuildCacheApplication::Applied);
1806 }
1807
1808 #[test]
1811 fn an_enabled_setting_does_not_survive_a_volume_without_reflinks() {
1812 let _isolated = isolated();
1813 let mut answers = plain_host();
1814 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1815 answers.push(("mj-reflink", 1, ""));
1816 let executor = ProbeExecutor::new(&answers);
1817 assert_eq!(
1818 resolve(
1819 &podman(Some(TargetBuildCache {
1820 enabled: Some(true),
1821 directory: None,
1822 max_size: None,
1823 target_max_size: None,
1824 })),
1825 &executor,
1826 ),
1827 None
1828 );
1829 }
1830
1831 #[test]
1832 fn a_volume_without_reflinks_runs_without_the_cache() {
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 assert_eq!(resolve(&podman(None), &executor), None);
1839 }
1840
1841 #[test]
1842 fn the_preview_names_the_resolved_values_and_the_reason_the_cache_is_off() {
1843 let _isolated = isolated();
1844 let mut answers = plain_host();
1845 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1846 answers.push(("mj-reflink", 1, ""));
1847 let executor = ProbeExecutor::new(&answers);
1848 let preview = preview_build_cache(&configured_local_machine(), &executor)
1849 .unwrap()
1850 .unwrap();
1851 assert_eq!(preview.native_mbx, None);
1852 assert_eq!(preview.directory, Some(default_cache_directory()));
1853 assert_eq!(
1854 preview.max_size,
1855 Some(BuildCacheLimit::MjDefault("100000000000B".into()))
1856 );
1857 assert!(
1858 matches!(&preview.off_reason, Some(BuildCacheOff::Unavailable(reason)) if reason.contains("reflinks")),
1859 "{:?}",
1860 preview.off_reason
1861 );
1862 assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1864
1865 let executor = ProbeExecutor::new(&[
1866 ("$m\" --version", 0, current_native_mbx().as_str()),
1867 (
1868 "mbx cache dir --json",
1869 0,
1870 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1871 ),
1872 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1873 ("[ -f \"$1\" ]", 0, "[gc]\nmax_size = \"500GiB\"\n"),
1874 ("while [ ! -d", 0, "/mnt/fast"),
1875 ("mj-reflink", 0, ""),
1876 ("stat -f -c %T", 0, "xfs"),
1877 ]);
1878 let preview = preview_build_cache(&configured_local_machine(), &executor)
1879 .unwrap()
1880 .unwrap();
1881 assert_eq!(preview.native_mbx.as_deref(), Some(MBX_VERSION));
1882 assert_eq!(
1883 preview.directory,
1884 Some(PathBuf::from("/mnt/fast/mbx-cache"))
1885 );
1886 assert_eq!(preview.max_size, Some(BuildCacheLimit::MbxDefault(None)));
1887 assert_eq!(preview.off_reason, None);
1888 assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1889 }
1890
1891 #[test]
1892 fn a_network_filesystem_runs_without_the_cache() {
1893 let _isolated = isolated();
1894 let mut answers = plain_host();
1895 answers.retain(|(needle, _, _)| *needle != "stat -f -c %T");
1896 answers.push(("stat -f -c %T", 0, "nfs4"));
1897 let executor = ProbeExecutor::new(&answers);
1898 assert_eq!(resolve(&podman(None), &executor), None);
1899 }
1900
1901 #[test]
1902 fn a_machine_opt_out_does_not_disable_default_cache_policy() {
1903 let _isolated = isolated();
1904 let executor = ProbeExecutor::new(&plain_host());
1905 let disabled = podman(Some(TargetBuildCache {
1906 enabled: Some(false),
1907 ..Default::default()
1908 }));
1909 assert!(resolve(&disabled, &executor).is_none());
1910 assert!(
1911 !executor
1912 .ran()
1913 .iter()
1914 .any(|command| command.contains("mkdir -p") || command.contains(".mj-apply.lock"))
1915 );
1916 assert!(resolve(&podman(None), &executor).is_some());
1917 assert!(resolve(&disabled, &executor).is_none());
1918 }
1919
1920 #[test]
1921 fn local_podman_and_local_docker_inspect_one_machine_once() {
1922 let _isolated = isolated();
1923 let executor = ProbeExecutor::new(&plain_host());
1924 let first = resolve(&podman(None), &executor).unwrap();
1925 let ran = executor.ran().len();
1926 assert!(ran > 0, "the first resolve inspects the host");
1927 let second = resolve(&docker(None), &executor).unwrap();
1928 assert_eq!(
1929 first, second,
1930 "both engines on this machine share one cache"
1931 );
1932 let added = executor.ran()[ran..].to_vec();
1936 assert_eq!(
1937 added.len(),
1938 1,
1939 "the second runtime is answered from the machine's recorded inspection: {added:?}"
1940 );
1941 assert!(
1942 added[0].contains("mkdir -p"),
1943 "the one repeated command creates the directory: {added:?}"
1944 );
1945 }
1946
1947 #[test]
1948 fn the_preview_reports_what_the_cache_has_already_done() {
1949 let _isolated = isolated();
1950 let mut answers = vec![(
1952 "tally.json",
1953 0,
1954 r#"{"version":1,"since_secs":1789824719,"builds":155,"cached_compilations":12050,"avoided_compiler_ns":6004997818721,"reflinked_bytes":47612059386}"#,
1955 )];
1956 answers.extend(plain_host());
1957 let executor = ProbeExecutor::new(&answers);
1958 let preview = preview_build_cache(&configured_local_machine(), &executor)
1959 .expect("the host answers")
1960 .expect("a local machine can hold a cache");
1961 assert_eq!(
1962 preview.stats,
1963 Some(mj_core::state::BuildCacheStats {
1964 builds: 155,
1965 cached_compilations: 12050,
1966 avoided_compiler_ns: 6_004_997_818_721,
1967 reflinked_bytes: 47_612_059_386,
1968 })
1969 );
1970 }
1971
1972 #[test]
1973 fn a_cache_nothing_has_used_yet_reports_no_totals() {
1974 let _isolated = isolated();
1975 let executor = ProbeExecutor::new(&plain_host());
1978 let preview = preview_build_cache(&configured_local_machine(), &executor)
1979 .expect("the host answers")
1980 .expect("a local machine can hold a cache");
1981 assert_eq!(preview.stats, None);
1982 }
1983
1984 #[test]
1985 fn a_machine_without_a_standing_host_has_no_build_cache_preview() {
1986 let _isolated = isolated();
1987 let executor = ProbeExecutor::new(&plain_host());
1988 let fleet: mj_core::config::Machine = serde_json::from_value(serde_json::json!({
1989 "kind": "aws-ec2",
1990 "region": "us-east-1",
1991 "launch_template": "lt-1",
1992 "ssh_user": "ubuntu",
1993 }))
1994 .unwrap();
1995 assert_eq!(preview_build_cache(&fleet, &executor).unwrap(), None);
1996 assert!(executor.ran().is_empty());
1997 }
1998
1999 #[test]
2000 fn apple_and_bare_targets_have_no_shared_build_cache() {
2001 let _isolated = isolated();
2002 let executor = ProbeExecutor::new(&plain_host());
2003 for target in [
2004 TargetTemplate::AppleContainer(container(None)),
2005 TargetTemplate::LocalBare,
2006 TargetTemplate::SshBare {
2007 ssh: SshTarget {
2008 destination: "dev@example.test".into(),
2009 ssh_args: Vec::new(),
2010 },
2011 workspace_prefix: "workspaces".into(),
2012 },
2013 ] {
2014 assert_eq!(resolve(&target, &executor), None, "{target:?}");
2015 }
2016 assert!(executor.ran().is_empty());
2017 }
2018
2019 fn bundle() -> targets::ProjectBundleSpec {
2020 targets::ProjectBundleSpec {
2021 primary: "main".into(),
2022 repositories: vec![targets::RepositorySpec {
2023 url: Some("https://github.com/example/main.git".into()),
2024 push_urls: Vec::new(),
2025 destination: "main".into(),
2026 git_ref: None,
2027 reference: None,
2028 }],
2029 }
2030 }
2031
2032 fn clone_cache() -> super::super::git_cache::PreparedCloneCache {
2033 super::super::git_cache::PreparedCloneCache::from_mirrors(
2034 [(
2035 "main".to_owned(),
2036 PathBuf::from("/home/dev/mirror/repo.git"),
2037 )]
2038 .into_iter()
2039 .collect(),
2040 )
2041 }
2042
2043 fn session(container_workspace: Option<&str>) -> mj_core::state::SessionRecord {
2044 let mut record = crate::controller::test_support::checkpoint_test_session("session-1");
2045 record.container_workspace = container_workspace.map(PathBuf::from);
2046 record
2047 }
2048
2049 #[test]
2050 fn a_rust_session_mounts_the_cache_at_the_host_path() {
2051 let _isolated = isolated();
2052 let mut answers = plain_host();
2053 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2054 let executor = ProbeExecutor::new(&answers);
2055 let mut mounts = Vec::new();
2056 let build_cache = prepare(
2057 &podman(None),
2058 &session(Some("/workspace/session-1")),
2059 Some(&bundle()),
2060 Some(&clone_cache()),
2061 &mut mounts,
2062 &executor,
2063 )
2064 .expect("a Rust session uses the build cache");
2065 assert_eq!(build_cache.directory, default_cache_directory());
2066 assert_eq!(
2067 mounts,
2068 vec![targets::AdditionalMount {
2069 source: default_cache_directory(),
2070 destination: default_cache_directory(),
2071 access: targets::MountAccess::Rw,
2072 }]
2073 );
2074 }
2075
2076 #[test]
2077 fn a_repository_without_a_root_manifest_runs_without_the_cache() {
2078 let _isolated = isolated();
2079 let mut answers = plain_host();
2080 answers.push(("cat-file -e HEAD:Cargo.toml", 1, ""));
2081 let executor = ProbeExecutor::new(&answers);
2082 let mut mounts = Vec::new();
2083 assert_eq!(
2084 prepare(
2085 &podman(None),
2086 &session(Some("/workspace/session-1")),
2087 Some(&bundle()),
2088 Some(&clone_cache()),
2089 &mut mounts,
2090 &executor,
2091 ),
2092 None
2093 );
2094 assert!(mounts.is_empty());
2095 }
2096
2097 #[test]
2098 fn a_session_at_the_legacy_shared_workspace_runs_without_the_cache() {
2099 let _isolated = isolated();
2100 let mut answers = plain_host();
2101 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2102 let executor = ProbeExecutor::new(&answers);
2103 let mut mounts = Vec::new();
2104 assert_eq!(
2105 prepare(
2106 &podman(None),
2107 &session(None),
2108 Some(&bundle()),
2109 Some(&clone_cache()),
2110 &mut mounts,
2111 &executor,
2112 ),
2113 None
2114 );
2115 assert!(executor.ran().is_empty());
2116 }
2117
2118 #[test]
2119 fn a_session_without_a_prepared_clone_cache_runs_without_the_cache() {
2120 let _isolated = isolated();
2121 let mut answers = plain_host();
2122 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2123 let executor = ProbeExecutor::new(&answers);
2124 let mut mounts = Vec::new();
2125 assert_eq!(
2126 prepare(
2127 &podman(None),
2128 &session(Some("/workspace/session-1")),
2129 Some(&bundle()),
2130 None,
2131 &mut mounts,
2132 &executor,
2133 ),
2134 None
2135 );
2136 }
2137
2138 #[test]
2139 fn an_apple_target_never_shares_a_build_cache() {
2140 let _isolated = isolated();
2141 let mut answers = plain_host();
2142 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2143 let executor = ProbeExecutor::new(&answers);
2144 let mut mounts = Vec::new();
2145 assert_eq!(
2146 prepare(
2147 &TargetTemplate::AppleContainer(container(None)),
2148 &session(Some("/workspace/session-1")),
2149 Some(&bundle()),
2150 Some(&clone_cache()),
2151 &mut mounts,
2152 &executor,
2153 ),
2154 None
2155 );
2156 assert!(executor.ran().is_empty());
2157 }
2158
2159 #[test]
2160 fn a_resumed_session_uses_current_machine_policy_instead_of_its_saved_budget() {
2161 let _isolated = isolated();
2162 let executor = ProbeExecutor::new(&plain_host());
2163 let mut record = session(Some("/workspace/session-1"));
2164 record.build_cache = Some(SessionBuildCache {
2165 host: "local".into(),
2166 directory: default_cache_directory(),
2167 max_size: Some("1GB".into()),
2168 target_root: None,
2169 });
2170 let mut mounts = Vec::new();
2171 let build_cache =
2172 prepare(&podman(None), &record, None, None, &mut mounts, &executor).unwrap();
2173 assert_eq!(build_cache.max_size, None);
2174 assert_eq!(build_cache.directory, default_cache_directory());
2175 assert_eq!(mounts.len(), 1);
2176 assert!(
2177 executor
2178 .ran()
2179 .iter()
2180 .any(|line| line.contains(".mj-apply.lock"))
2181 );
2182 }
2183
2184 #[test]
2185 fn a_session_moved_to_another_host_resolves_its_build_cache_again() {
2186 let _isolated = isolated();
2187 let mut answers = plain_host();
2188 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2189 let executor = ProbeExecutor::new(&answers);
2190 let mut record = session(Some("/workspace/session-1"));
2191 record.build_cache = Some(SessionBuildCache {
2192 host: "ssh:dev@example.test".into(),
2195 directory: PathBuf::from("/mnt/fast/mbx-cache"),
2196 max_size: None,
2197 target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
2198 });
2199 let mut mounts = Vec::new();
2200
2201 let build_cache = prepare(
2202 &podman(None),
2203 &record,
2204 Some(&bundle()),
2205 Some(&clone_cache()),
2206 &mut mounts,
2207 &executor,
2208 )
2209 .expect("the destination host qualifies on its own");
2210
2211 assert_eq!(build_cache.host, "local");
2212 assert_eq!(build_cache.directory, default_cache_directory());
2213 assert_eq!(build_cache.target_root, None);
2214 assert_eq!(
2215 mounts
2216 .iter()
2217 .map(|mount| mount.destination.clone())
2218 .collect::<Vec<_>>(),
2219 vec![default_cache_directory()]
2220 );
2221 assert!(
2222 executor
2223 .ran()
2224 .iter()
2225 .any(|line| line.contains("mj-reflink"))
2226 );
2227 }
2228
2229 #[test]
2230 fn an_attached_directory_over_the_cache_wins() {
2231 let build_cache = SessionBuildCache {
2232 host: "local-podman".into(),
2233 directory: PathBuf::from("/mnt/fast/mbx-cache"),
2234 max_size: None,
2235 target_root: None,
2236 };
2237 let mut mounts = vec![targets::AdditionalMount {
2238 source: PathBuf::from("/elsewhere"),
2239 destination: PathBuf::from("/mnt/fast/mbx-cache/actions"),
2240 access: targets::MountAccess::Ro,
2241 }];
2242 assert!(!attach_mounts(&build_cache, &mut mounts));
2243 assert_eq!(mounts.len(), 1);
2244 }
2245
2246 #[test]
2247 fn versions_compare_by_release_order() {
2248 assert!(version_at_least("1.12.0", "1.12.0"));
2249 assert!(version_at_least("1.12.1", "1.12.0"));
2250 assert!(version_at_least("2.0.0", "1.12.0"));
2251 assert!(!version_at_least("1.11.9", "1.12.0"));
2252 assert!(!version_at_least("1.9.0", "1.12.0"));
2253 assert!(!version_at_least("not-a-version", "1.12.0"));
2254 }
2255
2256 #[test]
2257 fn free_space_is_read_from_the_available_column() {
2258 assert_eq!(
2259 available_bytes(
2260 "Filesystem 1B-blocks Used Available Capacity Mounted on\n\
2261 /dev/sda1 1000 400 600 40% /\n"
2262 ),
2263 Some(600)
2264 );
2265 assert_eq!(available_bytes("Filesystem 1B-blocks\n"), None);
2266 }
2267}