1mod configuration;
15pub(crate) mod install;
16pub(crate) mod release;
17pub(crate) mod service;
18
19use std::path::{Path, PathBuf};
20use std::time::{Duration, Instant};
21
22use anyhow::{Context, Result, bail, ensure};
23
24use super::cache_host::CacheHost;
25use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec};
26use mj_core::config::{Config, TargetBuildCache, TargetTemplate};
27use mj_core::state::{
28 BuildCacheApplication, BuildCacheLimit, BuildCacheOff, BuildCachePreview, BuildCacheStats,
29 SessionBuildCache,
30};
31
32pub(crate) const MBX_VERSION: &str = "1.22.0";
34
35const MBX_COPY_RELATIVE: &str = ".mjolnir/bin/mbx";
36const TALLY_RELATIVE: &str = "actions/savings/v1/tally.json";
38const RESOLUTION_LIFETIME: Duration = Duration::from_secs(600);
39const LABEL: &str = "hel-mbx";
40const UNSUPPORTED_HOST: &str = "Mjolnir's shared mbx cache requires a Linux host. Native mbx on macOS must be installed and configured separately.";
41
42fn host_supports_cache(host: &CacheHost, executor: &impl CommandExecutor) -> Result<bool> {
44 if host.ssh().is_none() && !cfg!(unix) {
47 return Ok(false);
48 }
49 let command = host.command(
50 vec!["uname".into(), "-sm".into()],
51 "detect build cache host platform",
52 );
53 let output = checked(executor.execute(&command)?, &command)?;
54 let platform = targets::TargetPlatform::parse(
55 std::str::from_utf8(&output.stdout).context("decode build cache host platform")?,
56 )?;
57 Ok(platform.os == targets::TargetOs::Linux)
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub(super) struct ResolvedBuildCache {
63 pub directory: PathBuf,
65 pub native_mbx: NativeMbx,
67 pub target_root: Option<PathBuf>,
70 pub config_file: Option<String>,
72 pub config_directory: PathBuf,
73 pub previous_config: Option<String>,
74}
75
76type Resolutions = std::collections::BTreeMap<String, (Instant, Result<Inspection, String>)>;
81
82static RESOLUTIONS: std::sync::LazyLock<std::sync::Mutex<Resolutions>> =
83 std::sync::LazyLock::new(|| std::sync::Mutex::new(Resolutions::new()));
84
85type Applications = std::collections::BTreeMap<String, Result<(), String>>;
86static APPLICATIONS: std::sync::LazyLock<std::sync::Mutex<Applications>> =
87 std::sync::LazyLock::new(Default::default);
88
89type MbxSyncLocks = std::collections::BTreeMap<String, std::sync::Arc<std::sync::Mutex<()>>>;
90static MBX_SYNC_LOCKS: std::sync::LazyLock<std::sync::Mutex<MbxSyncLocks>> =
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 let mut preview = inspect(&host, &settings, Freshness::Fresh, executor)?.preview;
255 if install::install_kind(&preview).is_some() {
256 let profile = install::login_profile_details(&host, executor)?;
257 preview.mbx_profile_file = Some(profile.file);
258 preview.mbx_profile_warning = profile.warning;
259 preview.mbx_manual_path_line = profile.manual_path_line;
260 }
261 Ok(Some(preview))
262}
263
264pub(crate) fn apply_machine_build_cache(
267 machine: &mj_core::config::Machine,
268 mounted_directories: &[PathBuf],
269 executor: &impl CommandExecutor,
270) -> Result<()> {
271 let Some(host) = CacheHost::for_machine(machine) else {
272 return Ok(());
273 };
274 let settings = machine.build_cache().cloned().unwrap_or_default();
275 let inspected = inspect(&host, &settings, Freshness::Fresh, executor)?;
276 if matches!(
277 &inspected.preview.off_reason,
278 Some(BuildCacheOff::Unavailable(reason)) if reason == UNSUPPORTED_HOST
279 ) {
280 return Ok(());
281 }
282 let cache = inspected
283 .cache
284 .map(|cache| apply_cache(&host, &settings, cache, executor))
285 .transpose()?;
286
287 let native = if let Some(cache) = &cache {
288 Some(cache.native_mbx.clone())
289 } else {
290 match classify_native_mbx(probe_native_version(&host, executor)?) {
291 NativeMbxStatus::Compatible(native) => Some(native),
292 NativeMbxStatus::Absent
293 | NativeMbxStatus::TooOld(_)
294 | NativeMbxStatus::Unknown { .. } => None,
295 }
296 };
297 let Some(native) = native else {
298 return Ok(());
301 };
302
303 let mut directories = mounted_directories.to_vec();
304 if let Some(cache) = &cache
305 && !directories.contains(&cache.directory)
306 {
307 directories.push(cache.directory.clone());
308 }
309 for directory in directories {
310 if let Some(cache) = &cache
313 && directory != cache.directory
314 {
315 publish_at(&host, cache, &directory, executor)?;
316 }
317 sync_mbx_binary_from_native(&host, &native, &directory, executor)?;
318 }
319 Ok(())
320}
321
322fn publish_at(
323 host: &CacheHost,
324 cache: &ResolvedBuildCache,
325 directory: &Path,
326 executor: &impl CommandExecutor,
327) -> Result<PathBuf> {
328 let mut projected = cache.clone();
329 projected.config_directory = configuration::shared_directory(directory);
330 projected.previous_config = configuration::read_file(
331 host,
332 &projected.config_directory.join("config.toml"),
333 executor,
334 )?;
335 ensure!(
336 configuration::apply(host, &projected, executor)?,
337 "machine configuration changed during application; retry with current settings"
338 );
339 Ok(projected.config_directory)
340}
341
342pub(super) fn prepare_session_configuration(
345 config: &Config,
346 backend: &targets::TargetLocator,
347 recorded: &SessionBuildCache,
348 executor: &impl CommandExecutor,
349) -> Result<PathBuf> {
350 let host = host_for_locator(backend).context("build cache has no container host")?;
351 let mut settings = config
352 .machines
353 .values()
354 .filter(|machine| {
355 CacheHost::for_machine(machine).is_some_and(|candidate| candidate.key() == host.key())
356 })
357 .filter_map(|machine| machine.build_cache())
358 .next()
359 .cloned()
360 .unwrap_or_default();
361 settings.directory = Some(recorded.directory.clone());
362 settings.enabled = Some(true);
365 let inspected = inspect_host(&host, &settings, executor)?;
366 let cache = inspected.cache.with_context(|| {
367 format!(
368 "build cache configuration unavailable: {}",
369 inspected
370 .preview
371 .off_reason
372 .map(|reason| reason.to_string())
373 .unwrap_or_else(|| "host inspection returned no cache".into())
374 )
375 })?;
376 publish_at(&host, &cache, &recorded.directory, executor)
377}
378
379pub(super) fn shared_configuration_file(directory: &Path) -> PathBuf {
381 configuration::shared_directory(directory).join("config.toml")
382}
383
384pub(crate) struct DoctorHostMbx {
386 pub host: String,
387 pub targets: Vec<String>,
388 pub status: DoctorHostMbxStatus,
389}
390
391pub(crate) enum DoctorHostMbxStatus {
392 Unsupported(String),
393 Absent,
394 Compatible(String),
395 TooOld(String),
396 Unknown(String),
397}
398
399enum NativeMbxStatus {
401 Absent,
402 Compatible(NativeMbx),
403 TooOld(NativeMbx),
404 Unknown { version: String, reason: String },
405}
406
407fn classify_native_mbx(native: Option<NativeMbx>) -> NativeMbxStatus {
408 let Some(native) = native else {
409 return NativeMbxStatus::Absent;
410 };
411 match semver::Version::parse(&native.version) {
412 Ok(_) if version_at_least(&native.version, MBX_VERSION) => {
413 NativeMbxStatus::Compatible(native)
414 }
415 Ok(_) => NativeMbxStatus::TooOld(native),
416 Err(_) => NativeMbxStatus::Unknown {
417 reason: format!(
418 "the host reported an unrecognized mbx version {:?}",
419 native.version
420 ),
421 version: native.version,
422 },
423 }
424}
425
426pub(super) fn version_at_least(found: &str, required: &str) -> bool {
427 matches!(
428 (semver::Version::parse(found), semver::Version::parse(required)),
429 (Ok(found), Ok(required)) if found >= required
430 )
431}
432
433fn cache_unavailable_reason(status: &NativeMbxStatus) -> String {
434 match status {
435 NativeMbxStatus::Absent => {
436 "mbx is not installed on the container host. Install mbx from Settings › Setup › Machines to enable the shared build cache.".into()
437 }
438 NativeMbxStatus::TooOld(native) => format!(
439 "host mbx {} is older than the minimum supported version {}; upgrade mbx from Settings › Setup › Machines to enable the shared build cache",
440 native.version, MBX_VERSION
441 ),
442 NativeMbxStatus::Unknown { reason, .. } => format!(
443 "{reason}; install or upgrade mbx from Settings › Setup › Machines to enable the shared build cache"
444 ),
445 NativeMbxStatus::Compatible(_) => unreachable!("compatible mbx enables the cache"),
446 }
447}
448
449pub(crate) fn doctor_host_mbx(
452 config: &Config,
453 executor: &impl CommandExecutor,
454) -> Vec<DoctorHostMbx> {
455 let mut hosts: std::collections::BTreeMap<String, (CacheHost, Vec<String>)> =
456 std::collections::BTreeMap::new();
457 let mut checks = Vec::new();
458 for (id, target) in &config.targets {
459 let container = match target {
460 TargetTemplate::LocalPodman { container }
461 | TargetTemplate::LocalDocker { container }
462 | TargetTemplate::SshPodman { container, .. }
463 | TargetTemplate::SshDocker { container, .. } => container,
464 _ => continue,
465 };
466 if container
467 .build_cache
468 .as_ref()
469 .and_then(|cache| cache.enabled)
470 == Some(false)
471 {
472 continue;
473 }
474 match CacheHost::for_path_target(target) {
475 Ok(host) => {
476 let key = host.key();
477 hosts
478 .entry(key)
479 .or_insert_with(|| (host, Vec::new()))
480 .1
481 .push(id.clone());
482 }
483 Err(error) => checks.push(DoctorHostMbx {
484 host: id.clone(),
485 targets: vec![id.clone()],
486 status: DoctorHostMbxStatus::Unknown(format!("{error:#}")),
487 }),
488 }
489 }
490 checks.extend(hosts.into_iter().map(|(key, (host, targets))| {
491 let status = (|| -> Result<DoctorHostMbxStatus> {
492 if !host_supports_cache(&host, executor)? {
493 return Ok(DoctorHostMbxStatus::Unsupported(UNSUPPORTED_HOST.into()));
494 }
495 Ok(
496 match classify_native_mbx(probe_native_version(&host, executor)?) {
497 NativeMbxStatus::Absent => DoctorHostMbxStatus::Absent,
498 NativeMbxStatus::Compatible(native) => {
499 DoctorHostMbxStatus::Compatible(native.version)
500 }
501 NativeMbxStatus::TooOld(native) => DoctorHostMbxStatus::TooOld(native.version),
502 NativeMbxStatus::Unknown { reason, .. } => DoctorHostMbxStatus::Unknown(reason),
503 },
504 )
505 })()
506 .unwrap_or_else(|error| DoctorHostMbxStatus::Unknown(format!("{error:#}")));
507 DoctorHostMbx {
508 host: key,
509 targets,
510 status,
511 }
512 }));
513 checks
514}
515
516#[derive(Clone)]
519struct Inspection {
520 preview: BuildCachePreview,
521 cache: Option<ResolvedBuildCache>,
523}
524
525fn inspect_host(
526 host: &CacheHost,
527 settings: &TargetBuildCache,
528 executor: &impl CommandExecutor,
529) -> Result<Inspection> {
530 if !host_supports_cache(host, executor)? {
531 return Ok(Inspection {
532 preview: BuildCachePreview {
533 native_mbx: None,
534 mbx_profile_file: None,
535 mbx_profile_warning: None,
536 mbx_manual_path_line: None,
537 directory: None,
538 max_total_size: None,
539 user_managed: false,
540 application: BuildCacheApplication::Pending,
541 budget_note: None,
542 stats: None,
543 off_reason: Some(BuildCacheOff::Unavailable(UNSUPPORTED_HOST.into())),
544 },
545 cache: None,
546 });
547 }
548 let off = |preview: BuildCachePreview| Inspection {
549 preview,
550 cache: None,
551 };
552 let compatibility = classify_native_mbx(probe_native_version(host, executor)?);
553 let native = match compatibility {
554 NativeMbxStatus::Compatible(native) => native,
555 status => {
556 let native_version = match &status {
557 NativeMbxStatus::TooOld(native) => Some(native.version.clone()),
558 NativeMbxStatus::Unknown { version, .. } => Some(version.clone()),
559 NativeMbxStatus::Absent | NativeMbxStatus::Compatible(_) => None,
560 };
561 return Ok(off(BuildCachePreview {
562 native_mbx: native_version,
563 mbx_profile_file: None,
564 mbx_profile_warning: None,
565 mbx_manual_path_line: None,
566 directory: None,
567 max_total_size: None,
568 user_managed: false,
569 application: BuildCacheApplication::Pending,
570 budget_note: None,
571 stats: None,
572 off_reason: Some(BuildCacheOff::Unavailable(cache_unavailable_reason(
573 &status,
574 ))),
575 }));
576 }
577 };
578 let native_version = Some(native.version.clone());
579 let directory = native_cache_directory(host, &native, executor)?;
580 ensure!(
581 directory.is_absolute(),
582 "build cache directory {} is not absolute",
583 directory.display()
584 );
585
586 let user_managed = true;
587 let config_directory = configuration::shared_directory(&directory);
588 let previous_config =
589 configuration::read_file(host, &config_directory.join("config.toml"), executor)?;
590 let text = host_config_file(host, executor)?;
591 let limit = match configuration::configured_limit(text.as_deref(), "gc", "max_total_size")? {
592 Some(size) => BuildCacheLimit::HostConfiguration(Some(size)),
593 None => BuildCacheLimit::MbxDefault(None),
594 };
595 let config_file = Some(text.unwrap_or_default());
596 let target_root = config_file
597 .as_deref()
598 .and_then(|text| relocated_target_root(text, &directory));
599 let mut application =
600 configuration::application(previous_config.as_deref(), config_file.as_deref());
601 if application == BuildCacheApplication::Pending
602 && let Some(Err(error)) = APPLICATIONS
603 .lock()
604 .expect("mbx applications")
605 .get(&format!("{}|{settings:?}", host.key()))
606 {
607 application = BuildCacheApplication::Failed(error.clone());
608 }
609 let stats = read_stats(host, &directory, executor);
612 let preview = |off_reason: Option<BuildCacheOff>| BuildCachePreview {
613 native_mbx: native_version.clone(),
614 mbx_profile_file: None,
615 mbx_profile_warning: None,
616 mbx_manual_path_line: None,
617 directory: Some(directory.clone()),
618 max_total_size: Some(limit.clone()),
619 user_managed,
620 application: application.clone(),
621 budget_note: Some(
622 "One budget covers shared compiler outputs, managed worktrees, and incremental state."
623 .into(),
624 ),
625 stats: stats.clone(),
626 off_reason,
627 };
628
629 let volume = nearest_existing_ancestor(host, &directory, executor)?;
632 if !settings.enabled.unwrap_or(true) {
635 return Ok(off(preview(Some(BuildCacheOff::TurnedOff))));
636 }
637 if !reflinks_supported(host, &volume, executor)? {
638 return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
639 "the filesystem under {} does not support reflinks, so restoring cached \
640 outputs would copy every byte",
641 directory.display()
642 ))))));
643 }
644 if let Some(reason) = unusable_filesystem(host, &volume, executor)? {
645 return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
646 "{} is on a {reason}, where mbx's file locks are unreliable",
647 directory.display()
648 ))))));
649 }
650
651 if let Some(root) = &target_root {
657 let root_volume = nearest_existing_ancestor(host, root, executor)?;
658 if !cross_reflinks_supported(host, &volume, &root_volume, executor)? {
659 tracing::warn!(
660 cache = %directory.display(),
661 target_root = %root.display(),
662 "the host's mbx target root does not share a mount with the build cache, \
663 so restoring a cached output copies every byte instead of cloning it"
664 );
665 }
666 }
667
668 Ok(Inspection {
669 preview: preview(None),
670 cache: Some(ResolvedBuildCache {
671 directory,
672 native_mbx: native,
673 target_root,
674 config_file,
675 config_directory,
676 previous_config,
677 }),
678 })
679}
680
681fn read_stats(
690 host: &CacheHost,
691 directory: &Path,
692 executor: &impl CommandExecutor,
693) -> Option<BuildCacheStats> {
694 #[derive(Default, serde::Deserialize)]
695 #[serde(default)]
696 struct Tally {
697 builds: u64,
698 cached_compilations: u64,
699 avoided_compiler_ns: u64,
700 reflinked_bytes: u64,
701 }
702
703 let path = directory.join(TALLY_RELATIVE);
704 let command = host.shell_command(
705 READ_CONFIG_SCRIPT,
706 LABEL,
707 [path.to_string_lossy().into_owned()],
708 "read the container host build cache totals",
709 );
710 let output = executor.execute(&command).ok()?;
711 if output.status != 0 {
712 return None;
713 }
714 let tally: Tally = serde_json::from_slice(&output.stdout)
717 .inspect_err(|error| {
718 tracing::debug!(
719 path = %path.display(),
720 "the build cache totals could not be read: {error}"
721 );
722 })
723 .ok()?;
724 Some(BuildCacheStats {
725 builds: tally.builds,
726 cached_compilations: tally.cached_compilations,
727 avoided_compiler_ns: tally.avoided_compiler_ns,
728 reflinked_bytes: tally.reflinked_bytes,
729 })
730}
731
732#[derive(Debug, Clone, PartialEq, Eq)]
734pub(crate) struct NativeMbx {
735 pub program: PathBuf,
736 pub version: String,
737}
738
739pub(super) const NATIVE_VERSION_SCRIPT: &str = r#"for candidate in "$(command -v mbx 2>/dev/null || true)" "$HOME/.local/bin/mbx" "$HOME/.cargo/bin/mbx"; do
742 [ -n "$candidate" ] && [ -x "$candidate" ] || continue
743 resolved=$(readlink -f -- "$candidate" 2>/dev/null) || continue
744 case "$resolved" in /*) ;; *) continue ;; esac
745 if version=$("$resolved" --version 2>/dev/null); then
746 printf '%s
747%s' "$resolved" "$version"
748 exit 0
749 fi
750done
751exit 1"#;
752
753pub(super) fn probe_native_version(
755 host: &CacheHost,
756 executor: &impl CommandExecutor,
757) -> Result<Option<NativeMbx>> {
758 let command = host.shell_command(
759 NATIVE_VERSION_SCRIPT,
760 LABEL,
761 [],
762 "read the container host mbx version",
763 );
764 let output = executor.execute(&command)?;
765 if output.status == 1 {
766 return Ok(None);
767 }
768 ensure!(
769 output.status == 0,
770 "mbx version probe exited with status {}",
771 output.status
772 );
773 parse_native_probe_output(&output.stdout).map(Some)
774}
775
776fn parse_native_probe_output(stdout: &[u8]) -> Result<NativeMbx> {
777 let text = String::from_utf8_lossy(stdout);
778 let (program, version) = text
779 .trim()
780 .split_once('\n')
781 .context("mbx version probe gave no version")?;
782 let version = version
783 .split_whitespace()
784 .next_back()
785 .context("mbx version probe gave an empty version")?;
786 ensure!(
787 Path::new(program).is_absolute(),
788 "mbx version probe returned a non-absolute executable path {program:?}"
789 );
790 Ok(NativeMbx {
791 program: PathBuf::from(program),
792 version: version.to_owned(),
793 })
794}
795
796#[derive(Debug, Clone, PartialEq, Eq)]
798pub(super) struct CachedMbxBinary {
799 pub path: PathBuf,
800 pub version: String,
801}
802
803#[derive(Debug, Clone, PartialEq, Eq)]
804pub(super) enum CachedMbxSync {
805 Ready(CachedMbxBinary),
806 Unavailable(String),
807}
808
809const SYNC_MBX_BINARY_SCRIPT: &str = r#"set -eu
810source=$1 destination=$2 expected=$3 probe_script=$4
811directory=$(dirname -- "$destination")
812mkdir -p -- "$directory"
813if command -v flock >/dev/null 2>&1; then
814 exec 9>"$directory/.mbx.lock"
815 flock -x 9
816fi
817probe_matches() {
818 current=$(sh -c "$probe_script" 2>/dev/null) || return 1
819 newline='
820'
821 current_program=${current%%"$newline"*}
822 current_version=${current##* }
823 [ "$current_program" = "$source" ] && [ "$current_version" = "$expected" ]
824}
825if ! probe_matches; then
826 echo "native mbx changed while cache synchronization was waiting" >&2
827 exit 75
828fi
829source_output=$("$source" --version 2>/dev/null) || {
830 echo "native mbx stopped working during cache synchronization" >&2
831 exit 2
832}
833source_version=${source_output##* }
834[ "$source_version" = "$expected" ] || {
835 echo "native mbx changed version during cache synchronization" >&2
836 exit 75
837}
838source_size=$(wc -c < "$source")
839if [ -f "$destination" ] && [ ! -L "$destination" ] && [ -x "$destination" ]; then
840 installed_output=$("$destination" --version 2>/dev/null) || installed_output=
841 installed_version=${installed_output##* }
842 installed_size=$(wc -c < "$destination")
843 if [ "$installed_version" = "$expected" ] && [ "$source_size" -eq "$installed_size" ]; then
844 if ! probe_matches; then
845 echo "native mbx changed during cache synchronization" >&2
846 exit 75
847 fi
848 printf 'unchanged\n'
849 exit 0
850 fi
851fi
852temporary=$(mktemp "${destination}.mjolnir.XXXXXX")
853trap 'rm -f -- "$temporary"' EXIT HUP INT TERM
854cp -- "$source" "$temporary"
855chmod 755 -- "$temporary"
856temporary_output=$("$temporary" --version 2>/dev/null) || {
857 echo "copied mbx cannot run on the container host" >&2
858 exit 2
859}
860temporary_version=${temporary_output##* }
861temporary_size=$(wc -c < "$temporary")
862[ "$temporary_version" = "$expected" ] && [ "$source_size" -eq "$temporary_size" ] || {
863 echo "copied mbx changed during cache synchronization" >&2
864 exit 2
865}
866mv -f -- "$temporary" "$destination"
867trap - EXIT HUP INT TERM
868if ! probe_matches; then
869 echo "native mbx changed during cache synchronization" >&2
870 exit 75
871fi
872printf 'synced\n'"#;
873
874pub(super) fn cache_binary_path(directory: &Path) -> PathBuf {
875 directory.join(MBX_COPY_RELATIVE)
876}
877
878pub(super) fn sync_current_mbx_binary(
882 host: &CacheHost,
883 directory: &Path,
884 executor: &impl CommandExecutor,
885) -> Result<CachedMbxSync> {
886 match classify_native_mbx(probe_native_version(host, executor)?) {
887 NativeMbxStatus::Compatible(native) => Ok(CachedMbxSync::Ready(
888 sync_mbx_binary_from_native(host, &native, directory, executor)?,
889 )),
890 status => Ok(CachedMbxSync::Unavailable(cache_unavailable_reason(
891 &status,
892 ))),
893 }
894}
895
896pub(super) fn sync_mbx_binary_from_native(
900 host: &CacheHost,
901 native: &NativeMbx,
902 directory: &Path,
903 executor: &impl CommandExecutor,
904) -> Result<CachedMbxBinary> {
905 let mut native = match classify_native_mbx(Some(native.clone())) {
906 NativeMbxStatus::Compatible(native) => native,
907 status => bail!("{}", cache_unavailable_reason(&status)),
908 };
909 ensure!(
910 directory.is_absolute(),
911 "build cache directory is not absolute: {}",
912 directory.display()
913 );
914 let path = cache_binary_path(directory);
915 let key = format!("{}|{}", host.key(), path.display());
916 let lock = MBX_SYNC_LOCKS
917 .lock()
918 .expect("mbx sync locks")
919 .entry(key)
920 .or_insert_with(|| std::sync::Arc::new(std::sync::Mutex::new(())))
921 .clone();
922 let _guard = lock.lock().unwrap_or_else(|error| error.into_inner());
923 for attempt in 0..3 {
924 let command = host.shell_command(
925 SYNC_MBX_BINARY_SCRIPT,
926 LABEL,
927 [
928 native.program.to_string_lossy().into_owned(),
929 path.to_string_lossy().into_owned(),
930 native.version.clone(),
931 NATIVE_VERSION_SCRIPT.to_owned(),
932 ],
933 "synchronize native mbx into the shared cache",
934 );
935 let output = executor.execute(&command)?;
936 if output.status == 0 {
937 return Ok(CachedMbxBinary {
938 path,
939 version: native.version,
940 });
941 }
942 ensure!(
943 output.status == 75,
944 "{} failed with status {}: {}",
945 command.purpose,
946 output.status,
947 String::from_utf8_lossy(&output.stderr).trim()
948 );
949 let current = probe_native_version(host, executor)?;
950 native = match classify_native_mbx(current) {
951 NativeMbxStatus::Compatible(native) => native,
952 status => bail!("{}", cache_unavailable_reason(&status)),
953 };
954 if attempt == 2 {
955 bail!("native mbx kept changing during cache synchronization");
956 }
957 }
958 unreachable!("the bounded synchronization retry loop returns or fails")
959}
960
961pub(super) fn available_bytes(report: &str) -> Option<u64> {
964 let row = report
965 .lines()
966 .filter(|line| !line.trim().is_empty())
967 .nth(1)?;
968 let fields = row.split_whitespace().collect::<Vec<_>>();
969 fields.get(fields.len().checked_sub(3)?)?.parse().ok()
970}
971
972fn native_cache_directory(
975 host: &CacheHost,
976 native: &NativeMbx,
977 executor: &impl CommandExecutor,
978) -> Result<PathBuf> {
979 let command = host.command(
980 vec![
981 native.program.to_string_lossy().into_owned(),
982 "cache".to_owned(),
983 "dir".to_owned(),
984 "--json".to_owned(),
985 ],
986 "read the container host mbx cache directory",
987 );
988 let output = checked(executor.execute(&command)?, &command)?;
989 let report: serde_json::Value =
990 serde_json::from_slice(&output.stdout).context("parse the mbx cache directory report")?;
991 let store = report
992 .get("store")
993 .and_then(serde_json::Value::as_str)
994 .context("the mbx cache directory report has no store path")?;
995 Path::new(store)
996 .parent()
997 .map(Path::to_path_buf)
998 .with_context(|| format!("mbx store path {store:?} has no parent"))
999}
1000
1001const READ_CONFIG_SCRIPT: &str = r#"[ -f "$1" ] || exit 3
1002cat -- "$1""#;
1003
1004fn host_config_file(host: &CacheHost, executor: &impl CommandExecutor) -> Result<Option<String>> {
1008 let directory = configuration::host_directory(host, executor)?;
1009 configuration::read_file(host, &directory.join("config.toml"), executor)
1010}
1011
1012pub(crate) fn local_storage_paths() -> Vec<PathBuf> {
1014 let config_file = dirs::config_dir()
1015 .map(|directory| directory.join("mbx/config.toml"))
1016 .and_then(|path| std::fs::read_to_string(path).ok());
1017 let cache_override = std::env::var_os("MBX_CACHE_DIR")
1018 .filter(|value| !value.is_empty())
1019 .map(PathBuf::from);
1020 let target_root_override = std::env::var_os("MBX_TARGET_ROOT")
1021 .filter(|value| !value.is_empty())
1022 .map(PathBuf::from);
1023 let default_cache = dirs::cache_dir().map(|directory| directory.join("mbx"));
1024
1025 resolve_local_storage_paths(
1026 cache_override,
1027 target_root_override,
1028 config_file.as_deref(),
1029 default_cache,
1030 )
1031}
1032
1033fn resolve_local_storage_paths(
1034 cache_override: Option<PathBuf>,
1035 target_root_override: Option<PathBuf>,
1036 config_file: Option<&str>,
1037 default_cache: Option<PathBuf>,
1038) -> Vec<PathBuf> {
1039 let document = config_file.and_then(|text| toml::from_str::<toml::Value>(text).ok());
1040 let configured_cache = document
1041 .as_ref()
1042 .and_then(|document| document.get("cache_dir"))
1043 .and_then(toml::Value::as_str)
1044 .map(PathBuf::from);
1045 let Some(cache) = cache_override.or(configured_cache).or(default_cache) else {
1046 return Vec::new();
1047 };
1048 let configured_target_root = document
1049 .as_ref()
1050 .and_then(|document| document.get("target"))
1051 .and_then(|target| target.get("root"))
1052 .and_then(toml::Value::as_str)
1053 .map(PathBuf::from);
1054 let target_root = target_root_override
1055 .or(configured_target_root)
1056 .map(|root| {
1057 if root.is_absolute() {
1058 root
1059 } else {
1060 cache.join(root)
1061 }
1062 })
1063 .unwrap_or_else(|| cache.join("targets"));
1064 vec![cache, target_root]
1065}
1066
1067fn relocated_target_root(config_file: &str, directory: &Path) -> Option<PathBuf> {
1070 let document: toml::Value = toml::from_str(config_file)
1071 .map_err(|error| tracing::warn!("the host mbx configuration is unreadable: {error}"))
1072 .ok()?;
1073 let root = document.get("target")?.get("root")?.as_str()?;
1074 let root = directory.join(root);
1075 (!root.starts_with(directory)).then_some(root)
1076}
1077
1078const NEAREST_ANCESTOR_SCRIPT: &str = r#"d=$1
1079while [ ! -d "$d" ]; do
1080 parent=$(dirname -- "$d")
1081 if [ "$parent" = "$d" ]; then
1082 break
1083 fi
1084 d=$parent
1085done
1086printf '%s' "$d""#;
1087
1088fn nearest_existing_ancestor(
1091 host: &CacheHost,
1092 directory: &Path,
1093 executor: &impl CommandExecutor,
1094) -> Result<PathBuf> {
1095 let command = host.shell_command(
1096 NEAREST_ANCESTOR_SCRIPT,
1097 LABEL,
1098 [directory.to_string_lossy().into_owned()],
1099 "locate the build cache volume",
1100 );
1101 let output = checked(executor.execute(&command)?, &command)?;
1102 let path = PathBuf::from(String::from_utf8(output.stdout).context("decode cache ancestor")?);
1103 ensure!(
1104 path.is_absolute(),
1105 "build cache volume {} is not absolute",
1106 path.display()
1107 );
1108 Ok(path)
1109}
1110
1111const REFLINK_SCRIPT: &str = r#"dir=$1
1112d=$(mktemp -d "$dir/.mj-reflink.XXXXXX") || exit 1
1113printf x > "$d/a" && cp --reflink=always "$d/a" "$d/b"
1114status=$?
1115rm -rf -- "$d"
1116exit $status"#;
1117
1118fn reflinks_supported(
1122 host: &CacheHost,
1123 volume: &Path,
1124 executor: &impl CommandExecutor,
1125) -> Result<bool> {
1126 let command = host.shell_command(
1127 REFLINK_SCRIPT,
1128 LABEL,
1129 [volume.to_string_lossy().into_owned()],
1130 "probe the build cache volume for reflinks",
1131 );
1132 Ok(executor.execute(&command)?.status == 0)
1133}
1134
1135const CROSS_REFLINK_SCRIPT: &str = r#"src=$1
1136dst=$2
1137s=$(mktemp -d "$src/.mj-reflink.XXXXXX") || exit 1
1138d=$(mktemp -d "$dst/.mj-reflink.XXXXXX") || { rm -rf -- "$s"; exit 1; }
1139printf x > "$s/a" && cp --reflink=always "$s/a" "$d/b"
1140status=$?
1141rm -rf -- "$s" "$d"
1142exit $status"#;
1143
1144fn cross_reflinks_supported(
1148 host: &CacheHost,
1149 store: &Path,
1150 target_root: &Path,
1151 executor: &impl CommandExecutor,
1152) -> Result<bool> {
1153 let command = host.shell_command(
1154 CROSS_REFLINK_SCRIPT,
1155 LABEL,
1156 [
1157 store.to_string_lossy().into_owned(),
1158 target_root.to_string_lossy().into_owned(),
1159 ],
1160 "probe the managed target root for reflinks from the build cache",
1161 );
1162 Ok(executor.execute(&command)?.status == 0)
1163}
1164
1165fn create_directory(
1166 host: &CacheHost,
1167 directory: &Path,
1168 executor: &impl CommandExecutor,
1169) -> Result<()> {
1170 let command = host.command(
1171 vec![
1172 "mkdir".to_owned(),
1173 "-p".to_owned(),
1174 "--".to_owned(),
1175 directory.to_string_lossy().into_owned(),
1176 ],
1177 "create the build cache directory",
1178 );
1179 checked(executor.execute(&command)?, &command).map(|_| ())
1180}
1181
1182fn unusable_filesystem(
1185 host: &CacheHost,
1186 directory: &Path,
1187 executor: &impl CommandExecutor,
1188) -> Result<Option<&'static str>> {
1189 let filesystems =
1190 targets::probe_filesystem_types(host.ssh(), &[directory.to_path_buf()], executor)?;
1191 let filesystem = filesystems
1192 .first()
1193 .context("the filesystem probe named no filesystem")?;
1194 Ok(targets::overlay_unsupported_filesystem(filesystem)
1198 .filter(|reason| matches!(*reason, "network filesystem" | "FUSE filesystem")))
1199}
1200
1201fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
1202 if output.status == 0 {
1203 return Ok(output);
1204 }
1205 bail!(
1206 "{} failed with status {}: {}",
1207 command.purpose,
1208 output.status,
1209 String::from_utf8_lossy(&output.stderr).trim()
1210 )
1211}
1212
1213pub(super) fn primary_repository_is_rust(
1219 host: &CacheHost,
1220 mirror: &Path,
1221 executor: &impl CommandExecutor,
1222) -> bool {
1223 let command = host.command(
1224 vec![
1225 "git".to_owned(),
1226 "--git-dir".to_owned(),
1227 mirror.to_string_lossy().into_owned(),
1228 "cat-file".to_owned(),
1229 "-e".to_owned(),
1230 "HEAD:Cargo.toml".to_owned(),
1231 ],
1232 "detect a Cargo workspace in the session repository",
1233 );
1234 matches!(executor.execute(&command), Ok(output) if output.status == 0)
1235}
1236
1237pub(super) fn prepare(
1241 target: &targets::TargetTemplate,
1242 session: &mj_core::state::SessionRecord,
1243 bundle: Option<&targets::ProjectBundleSpec>,
1244 clone_cache: Option<&super::git_cache::PreparedCloneCache>,
1245 mounts: &mut Vec<targets::AdditionalMount>,
1246 executor: &impl CommandExecutor,
1247) -> Option<SessionBuildCache> {
1248 session.container_workspace.as_ref()?;
1253 let resolved = resolve(target, executor)?;
1254 let host = supported_host(target)?.0;
1255 if session.build_cache.is_none() {
1256 let mirror = clone_cache?.mirror_for(&bundle?.primary)?;
1257 if !primary_repository_is_rust(&host, mirror, executor) {
1258 return None;
1259 }
1260 }
1261 match sync_current_mbx_binary(&host, &resolved.directory, executor) {
1262 Ok(CachedMbxSync::Ready(_)) => {}
1263 Ok(CachedMbxSync::Unavailable(reason)) => {
1264 tracing::warn!(
1265 host = host.key(),
1266 "sessions run without the shared build cache: {reason}"
1267 );
1268 executor.notify_notice(&format!(
1269 "The shared Rust build cache is unavailable: {reason}. The session will start without it."
1270 ));
1271 return None;
1272 }
1273 Err(error) => {
1274 tracing::warn!(
1275 host = host.key(),
1276 "the shared mbx binary could not be synchronized; the session runs without the build cache: {error:#}"
1277 );
1278 executor.notify_notice(&format!(
1279 "The shared Rust build cache is unavailable: {error:#}. The session will start without it."
1280 ));
1281 return None;
1282 }
1283 }
1284 let build_cache = SessionBuildCache {
1285 host: host.key(),
1286 directory: resolved.directory,
1287 max_size: None,
1288 target_root: resolved.target_root,
1289 };
1290 attach_mounts(&build_cache, mounts).then_some(build_cache)
1291}
1292
1293fn attach_mounts(
1297 build_cache: &SessionBuildCache,
1298 mounts: &mut Vec<targets::AdditionalMount>,
1299) -> bool {
1300 let mut wanted = vec![build_cache.directory.clone()];
1301 wanted.extend(build_cache.target_root.iter().cloned());
1302 for destination in &wanted {
1303 if mounts.iter().any(|mount| {
1304 mount.destination.starts_with(destination)
1305 || destination.starts_with(&mount.destination)
1306 }) {
1307 tracing::warn!(
1308 destination = %destination.display(),
1309 "an attached directory overlaps the build cache, so this session runs without it"
1310 );
1311 return false;
1312 }
1313 }
1314 for directory in std::iter::once(&build_cache.directory).chain(build_cache.target_root.iter()) {
1315 mounts.push(targets::AdditionalMount {
1316 source: directory.clone(),
1317 destination: directory.clone(),
1318 access: targets::MountAccess::Rw,
1319 });
1320 }
1321 true
1322}
1323
1324pub(super) fn host_for_locator(target: &targets::TargetLocator) -> Option<CacheHost> {
1327 match target {
1328 targets::TargetLocator::LocalPodman { .. } | targets::TargetLocator::LocalDocker { .. } => {
1329 Some(CacheHost::Local)
1330 }
1331 targets::TargetLocator::SshPodman { ssh, .. }
1332 | targets::TargetLocator::SshDocker { ssh, .. } => Some(CacheHost::Ssh(ssh.clone())),
1333 _ => None,
1334 }
1335}
1336
1337pub(super) fn sync_mbx_binary_for_container(
1339 target: &targets::TargetLocator,
1340 directory: &Path,
1341 executor: &impl CommandExecutor,
1342) -> Result<CachedMbxSync> {
1343 let host = host_for_locator(target).context("build cache has no container host")?;
1344 sync_current_mbx_binary(&host, directory, executor)
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349 use super::*;
1350 use crate::targets::{ContainerTemplate, SshTarget, TargetTemplate};
1351 use mj_core::config::ImagePullPolicy;
1352 use std::sync::Mutex;
1353
1354 static ISOLATED: Mutex<()> = Mutex::new(());
1357
1358 fn isolated() -> std::sync::MutexGuard<'static, ()> {
1359 let guard = ISOLATED.lock().unwrap_or_else(|error| error.into_inner());
1360 RESOLUTIONS.lock().expect("mbx resolutions").clear();
1361 APPLICATIONS.lock().expect("mbx applications").clear();
1362 guard
1363 }
1364
1365 #[derive(Default)]
1367 struct ProbeExecutor {
1368 answers: Vec<(&'static str, i32, String)>,
1369 seen: Mutex<Vec<String>>,
1370 }
1371
1372 impl ProbeExecutor {
1373 fn new(answers: &[(&'static str, i32, &str)]) -> Self {
1374 Self {
1375 answers: answers
1376 .iter()
1377 .map(|(needle, status, stdout)| (*needle, *status, (*stdout).to_owned()))
1378 .chain(std::iter::once(("uname -sm", 0, "Linux x86_64\n".into())))
1379 .collect(),
1380 seen: Mutex::new(Vec::new()),
1381 }
1382 }
1383
1384 fn ran(&self) -> Vec<String> {
1385 self.seen.lock().unwrap().clone()
1386 }
1387 }
1388
1389 impl CommandExecutor for ProbeExecutor {
1390 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1391 let line = format!("{} {}", command.program, command.args.join(" "));
1392 self.seen.lock().unwrap().push(line.clone());
1393 let searchable = if command.program == "ssh" {
1395 line.replace("'\\''", "'").replace("' '", " ")
1396 } else {
1397 line.clone()
1398 };
1399 if searchable.contains("hel-mbx-profile") {
1400 return Ok(CommandOutput {
1401 status: 0,
1402 stdout: b"preview\n~/.profile\nposix\n/home/jonathan/.local/share/mbx/bin"
1403 .to_vec(),
1404 stderr: Vec::new(),
1405 });
1406 }
1407 for (needle, status, stdout) in &self.answers {
1408 if searchable.contains(needle) {
1409 return Ok(CommandOutput {
1410 status: *status,
1411 stdout: stdout.clone().into_bytes(),
1412 stderr: Vec::new(),
1413 });
1414 }
1415 }
1416 Ok(CommandOutput {
1417 status: 127,
1418 stdout: Vec::new(),
1419 stderr: format!("no canned answer for {line}").into_bytes(),
1420 })
1421 }
1422 }
1423
1424 fn container(build_cache: Option<TargetBuildCache>) -> ContainerTemplate {
1425 ContainerTemplate {
1426 image: "example/image:latest".into(),
1427 pull_policy: ImagePullPolicy::Missing,
1428 extra_run_args: Vec::new(),
1429 workspace_storage: Default::default(),
1430 build_cache,
1431 }
1432 }
1433
1434 fn podman(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1435 TargetTemplate::LocalPodman(container(build_cache))
1436 }
1437
1438 fn docker(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1439 TargetTemplate::LocalDocker(container(build_cache))
1440 }
1441
1442 fn configured_local_machine() -> mj_core::config::Machine {
1445 serde_json::from_value(serde_json::json!({"kind": "local"})).unwrap()
1446 }
1447
1448 fn current_native_mbx() -> String {
1450 format!("/usr/local/bin/mbx\nmbx {MBX_VERSION}")
1451 }
1452
1453 fn native_host() -> Vec<(&'static str, i32, &'static str)> {
1454 vec![
1455 ("$resolved\" --version", 0, "/usr/local/bin/mbx\nmbx 1.22.0"),
1456 (
1457 "mbx cache dir --json",
1458 0,
1459 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1460 ),
1461 ("XDG_CONFIG_HOME", 0, "/home/dev/.config/mbx"),
1462 ("[ -f \"$1\" ]", 3, ""),
1463 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1464 ("mj-reflink", 0, ""),
1465 ("mkdir -p", 0, ""),
1466 ("stat -f -c %T", 0, "xfs"),
1467 ("source=$1 destination=$2 expected=$3", 0, "synced\n"),
1468 ]
1469 }
1470
1471 fn absent_host() -> Vec<(&'static str, i32, &'static str)> {
1472 vec![("$resolved\" --version", 1, "")]
1473 }
1474
1475 #[test]
1476 fn darwin_hosts_skip_cache_inspection_provisioning_and_reconciliation() {
1477 let _isolated = isolated();
1478 for remote in [false, true] {
1479 for enabled in [None, Some(true)] {
1480 let settings = TargetBuildCache {
1481 enabled,
1482 ..Default::default()
1483 };
1484 let machine: mj_core::config::Machine = if remote {
1485 serde_json::from_value(serde_json::json!({
1486 "kind": "ssh", "host": "mac.test", "user": "builder", "build_cache": settings,
1487 }))
1488 .unwrap()
1489 } else {
1490 mj_core::config::Machine::Local {
1491 build_cache: Some(settings.clone()),
1492 }
1493 };
1494 let target = if remote {
1495 TargetTemplate::SshPodman {
1496 ssh: SshTarget {
1497 destination: "builder@mac.test".into(),
1498 ssh_args: vec![],
1499 },
1500 container: container(Some(settings)),
1501 }
1502 } else {
1503 podman(Some(settings))
1504 };
1505 let executor = ProbeExecutor::new(&[
1507 ("uname -sm", 0, "Darwin arm64\n"),
1508 ("$resolved\" --version", 0, "mbx\nmbx 1.16.0"),
1509 ]);
1510 let preview = preview_build_cache(&machine, &executor).unwrap().unwrap();
1511 assert_eq!(
1512 preview.off_reason,
1513 Some(BuildCacheOff::Unavailable(UNSUPPORTED_HOST.into()))
1514 );
1515 assert!(preview.directory.is_none());
1516 assert!(resolve(&target, &executor).is_none());
1517 apply_machine_build_cache(&machine, &[PathBuf::from("/existing/cache")], &executor)
1518 .unwrap();
1519 let commands = executor.ran();
1520 assert!(!commands.is_empty());
1521 assert!(
1522 commands
1523 .iter()
1524 .all(|command| command.contains("uname") && command.contains("-sm")),
1525 "{commands:?}"
1526 );
1527 assert!(
1528 commands
1529 .iter()
1530 .all(|command| command.starts_with(if remote { "ssh " } else { "uname " }))
1531 );
1532 }
1533 }
1534 }
1535
1536 #[test]
1537 fn linux_ssh_cache_remains_available_on_any_controller_platform() {
1538 let _isolated = isolated();
1539 let executor = ProbeExecutor::new(&native_host());
1540 let target = TargetTemplate::SshDocker {
1541 ssh: SshTarget {
1542 destination: "builder@linux.test".into(),
1543 ssh_args: vec![],
1544 },
1545 container: container(None),
1546 };
1547 let cache = resolve(&target, &executor).unwrap();
1548 assert_eq!(cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1549 assert_eq!(cache.previous_config, cache.config_file);
1550 assert!(
1551 executor.ran().iter().all(
1552 |command| command.starts_with("ssh ") && command.contains("builder@linux.test")
1553 )
1554 );
1555 }
1556
1557 #[test]
1558 fn reconciliation_refreshes_active_mounts_when_cache_policy_is_disabled() {
1559 let _isolated = isolated();
1560 let machine = mj_core::config::Machine::Local {
1561 build_cache: Some(TargetBuildCache {
1562 enabled: Some(false),
1563 ..Default::default()
1564 }),
1565 };
1566 let executor = ProbeExecutor::new(&native_host());
1567 let mounted = PathBuf::from("/existing/cache");
1568
1569 apply_machine_build_cache(&machine, std::slice::from_ref(&mounted), &executor).unwrap();
1570
1571 assert!(executor.ran().iter().any(|command| {
1572 command.contains("source=$1 destination=$2 expected=$3")
1573 && command.contains("/existing/cache/.mjolnir/bin/mbx")
1574 }));
1575 }
1576
1577 #[test]
1578 fn recorded_darwin_cache_fails_explicitly_without_writing() {
1579 let executor = ProbeExecutor::new(&[("uname -sm", 0, "Darwin x86_64")]);
1580 let recorded = SessionBuildCache {
1581 host: "local".into(),
1582 directory: PathBuf::from("/existing/cache"),
1583 max_size: None,
1584 target_root: None,
1585 };
1586 let backend = targets::TargetLocator::LocalPodman {
1587 container_id: "saved-container".into(),
1588 workspace_storage: Default::default(),
1589 borrowed_from: None,
1590 };
1591 let error =
1592 prepare_session_configuration(&Config::default(), &backend, &recorded, &executor)
1593 .unwrap_err();
1594 assert!(format!("{error:#}").contains(UNSUPPORTED_HOST));
1595 assert_eq!(executor.ran(), ["uname -sm"]);
1596 }
1597
1598 #[test]
1599 fn failed_platform_probe_does_not_attempt_cache_operations() {
1600 let executor = ProbeExecutor::new(&[("uname -sm", 1, "")]);
1601 assert!(inspect_host(&CacheHost::Local, &TargetBuildCache::default(), &executor).is_err());
1602 assert_eq!(executor.ran(), ["uname -sm"]);
1603 }
1604
1605 #[test]
1606 fn installed_worker_reads_cache_configuration_from_its_recorded_host() {
1607 let executor = ProbeExecutor::new(&[
1608 ("XDG_CONFIG_HOME", 0, "/home/builder/.config/mbx"),
1609 ("$HOME", 0, "/home/builder"),
1610 ("[ -f \"$1\" ]", 0, "[gc]\nmax_total_size = '50GB'\n"),
1611 ]);
1612 let target = targets::TargetLocator::SshPodman {
1613 ssh: SshTarget {
1614 destination: "builder@recorded-cache.test".into(),
1615 ssh_args: vec![],
1616 },
1617 container_id: "saved-container".into(),
1618 workspace_storage: Default::default(),
1619 borrowed_from: None,
1620 };
1621 let config = host_config_file(&host_for_locator(&target).unwrap(), &executor)
1622 .unwrap()
1623 .unwrap();
1624 assert!(config.contains("50GB"));
1625 assert!(
1626 executor
1627 .seen
1628 .lock()
1629 .unwrap()
1630 .iter()
1631 .all(|command| command.contains("builder@recorded-cache.test"))
1632 );
1633 }
1634
1635 #[test]
1636 fn a_native_mbx_supplies_the_cache_directory_and_its_own_limits() {
1637 let _isolated = isolated();
1638 let executor = ProbeExecutor::new(&[
1639 ("$resolved\" --version", 0, current_native_mbx().as_str()),
1640 (
1641 "mbx cache dir --json",
1642 0,
1643 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1644 ),
1645 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1646 (
1647 "[ -f \"$1\" ]",
1648 0,
1649 "cache_dir = \"/mnt/fast/mbx-cache\"\n[gc]\nmax_size = \"500GiB\"\n",
1650 ),
1651 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1652 ("mj-reflink", 0, ""),
1653 ("mkdir -p", 0, ""),
1654 ("stat -f -c %T", 0, "xfs"),
1655 ]);
1656 let resolved = resolve(&podman(None), &executor).unwrap();
1657 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1658 assert_eq!(
1660 configuration::configured_limit(
1661 resolved.config_file.as_deref(),
1662 "gc",
1663 "max_total_size"
1664 )
1665 .unwrap(),
1666 None
1667 );
1668 assert_eq!(resolved.target_root, None);
1669 assert!(resolved.config_file.unwrap().contains("500GiB"));
1670 assert!(
1671 !executor
1672 .ran()
1673 .iter()
1674 .any(|line| line.contains("apply machine")),
1675 "a host configuration is never rewritten"
1676 );
1677 }
1678 #[test]
1680 fn looking_at_the_settings_page_lets_the_next_session_see_a_repaired_host() {
1681 let _isolated = isolated();
1682 let broken = ProbeExecutor::new(
1683 &native_host()
1684 .into_iter()
1685 .map(|(needle, status, stdout)| match needle {
1686 "mj-reflink" => (needle, 1, stdout),
1687 _ => (needle, status, stdout),
1688 })
1689 .collect::<Vec<_>>(),
1690 );
1691 assert!(
1692 resolve(&podman(None), &broken).is_none(),
1693 "a volume that cannot clone runs without the cache"
1694 );
1695
1696 let repaired = ProbeExecutor::new(&native_host());
1699 let preview = preview_build_cache(&configured_local_machine(), &repaired)
1700 .expect("the host answers")
1701 .expect("a local machine can hold a cache");
1702 assert_eq!(preview.off_reason, None);
1703
1704 assert!(
1705 resolve(&podman(None), &repaired).is_some(),
1706 "the next session asks the repaired host again instead of reusing the old verdict"
1707 );
1708 }
1709
1710 #[test]
1711 fn a_target_root_that_cannot_be_cloned_into_still_gets_the_cache() {
1712 let _isolated = isolated();
1713 let executor = ProbeExecutor::new(&[
1714 ("$resolved\" --version", 0, current_native_mbx().as_str()),
1715 (
1716 "mbx cache dir --json",
1717 0,
1718 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1719 ),
1720 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1721 (
1722 "[ -f \"$1\" ]",
1723 0,
1724 "[target]\nroot = \"/mnt/slow/mbx-targets\"\n",
1725 ),
1726 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1727 ("src=$1", 1, ""),
1730 ("mj-reflink", 0, ""),
1731 ("mkdir -p", 0, ""),
1732 ("stat -f -c %T", 0, "xfs"),
1733 ]);
1734 let resolved = resolve(&podman(None), &executor)
1735 .expect("a target root that copies instead of cloning is slower, not unusable");
1736 assert_eq!(
1737 resolved.target_root,
1738 Some(PathBuf::from("/mnt/slow/mbx-targets"))
1739 );
1740 assert!(
1741 executor.ran().iter().any(|line| line.contains("src=$1")),
1742 "the store and the target root are probed as a pair: {:?}",
1743 executor.ran()
1744 );
1745 }
1746
1747 #[test]
1748 fn a_target_root_inside_the_cache_directory_needs_no_second_mount() {
1749 assert_eq!(
1750 relocated_target_root("[target]\nroot = \"targets\"\n", Path::new("/cache")),
1751 None
1752 );
1753 assert_eq!(
1754 relocated_target_root("[target]\nroot = \"/cache/targets\"\n", Path::new("/cache")),
1755 None
1756 );
1757 }
1758
1759 #[test]
1760 fn local_storage_paths_follow_mbx_cache_and_target_root_resolution() {
1761 let default_cache = PathBuf::from("/home/dev/.cache/mbx");
1762 assert_eq!(
1763 resolve_local_storage_paths(
1764 None,
1765 None,
1766 Some(
1767 "cache_dir = \"/mnt/optane/mbx-cache\"\n\
1768 [target]\nroot = \"/mnt/optane/mbx-targets\"\n",
1769 ),
1770 Some(default_cache.clone()),
1771 ),
1772 vec![
1773 PathBuf::from("/mnt/optane/mbx-cache"),
1774 PathBuf::from("/mnt/optane/mbx-targets"),
1775 ]
1776 );
1777 assert_eq!(
1778 resolve_local_storage_paths(
1779 None,
1780 None,
1781 Some("cache_dir = \"/mnt/optane/mbx-cache\"\n[target]\nroot = \"views\"\n"),
1782 Some(default_cache.clone()),
1783 ),
1784 vec![
1785 PathBuf::from("/mnt/optane/mbx-cache"),
1786 PathBuf::from("/mnt/optane/mbx-cache/views"),
1787 ]
1788 );
1789 assert_eq!(
1790 resolve_local_storage_paths(None, None, None, Some(default_cache.clone())),
1791 vec![default_cache.clone(), default_cache.join("targets")]
1792 );
1793 assert_eq!(
1794 resolve_local_storage_paths(
1795 Some(PathBuf::from("/env/cache")),
1796 Some(PathBuf::from("views")),
1797 Some("cache_dir = \"/file/cache\"\n[target]\nroot = \"file-views\"\n"),
1798 Some(default_cache),
1799 ),
1800 vec![
1801 PathBuf::from("/env/cache"),
1802 PathBuf::from("/env/cache/views")
1803 ]
1804 );
1805 }
1806
1807 #[test]
1809 fn a_cargo_installed_mbx_off_the_path_is_queried_where_it_was_found() {
1810 let _isolated = isolated();
1811 let found = format!("/home/dev/.cargo/bin/mbx\nmbx {MBX_VERSION}");
1812 let mut answers: Vec<(&'static str, i32, &str)> = native_host();
1813 answers.retain(|(needle, _, _)| *needle != "$resolved\" --version");
1814 answers.push(("$resolved\" --version", 0, found.as_str()));
1815 answers.push((
1816 "/home/dev/.cargo/bin/mbx cache dir --json",
1817 0,
1818 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1819 ));
1820 let executor = ProbeExecutor::new(&answers);
1821 let resolved = resolve(&podman(None), &executor).unwrap();
1822 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1823 assert_eq!(
1824 resolved.native_mbx.program,
1825 PathBuf::from("/home/dev/.cargo/bin/mbx")
1826 );
1827 }
1828
1829 #[test]
1830 fn an_older_native_mbx_must_not_share_the_store() {
1831 let _isolated = isolated();
1832 let executor = ProbeExecutor::new(&[
1833 (
1834 "$resolved\" --version",
1835 0,
1836 "/home/jonathan/.local/bin/mbx\nmbx 1.15.0",
1837 ),
1838 ("hel-mbx-home", 0, "/home/jonathan"),
1839 ]);
1840 assert!(resolve(&podman(None), &executor).is_none());
1841 let preview = preview_build_cache(&configured_local_machine(), &executor)
1842 .unwrap()
1843 .unwrap();
1844 assert!(matches!(
1845 preview.off_reason,
1846 Some(BuildCacheOff::Unavailable(reason))
1847 if reason.contains("1.15.0") && reason.contains("Settings › Setup › Machines")
1848 ));
1849 }
1850
1851 #[test]
1852 fn a_host_without_mbx_has_no_shared_cache_and_preview_shows_setup_guidance() {
1853 let _isolated = isolated();
1854 let mut answers = absent_host();
1855 answers.push(("hel-mbx-home", 0, "/home/jonathan"));
1856 let executor = ProbeExecutor::new(&answers);
1857 assert!(resolve(&podman(None), &executor).is_none());
1858 let preview = preview_build_cache(&configured_local_machine(), &executor)
1859 .unwrap()
1860 .unwrap();
1861 assert_eq!(preview.native_mbx, None);
1862 assert_eq!(preview.directory, None);
1863 assert!(matches!(
1864 preview.off_reason,
1865 Some(BuildCacheOff::Unavailable(reason))
1866 if reason.contains("Settings › Setup › Machines")
1867 ));
1868 assert!(!executor.ran().iter().any(|line| line.starts_with("mkdir ")));
1871 }
1872
1873 #[test]
1874 fn a_native_installation_owns_the_budget_despite_saved_mj_overrides() {
1875 let _isolated = isolated();
1876 let native = current_native_mbx();
1877 let mut answers = vec![
1878 ("$resolved\" --version", 0, native.as_str()),
1879 (
1880 "mbx cache dir --json",
1881 0,
1882 r#"{"store":"/native/cache/actions"}"#,
1883 ),
1884 (
1885 "[ -f \"$1\" ]",
1886 0,
1887 "[gc]\nmax_total_size = '400GiB'\n[target]\nmax_size = 'none'\n",
1888 ),
1889 ];
1890 answers.extend(native_host());
1891 let executor = ProbeExecutor::new(&answers);
1892 let settings = TargetBuildCache {
1893 directory: Some("/ignored".into()),
1894 max_total_size: Some("1GB".into()),
1895 ..Default::default()
1896 };
1897 let inspection = inspect_host(&CacheHost::Local, &settings, &executor).unwrap();
1898 assert!(inspection.preview.user_managed);
1899 assert_eq!(inspection.preview.directory, Some("/native/cache".into()));
1900 assert_eq!(
1901 inspection.preview.max_total_size,
1902 Some(BuildCacheLimit::HostConfiguration(Some("400GiB".into())))
1903 );
1904 assert!(
1905 !executor
1906 .ran()
1907 .iter()
1908 .any(|command| command.contains(".mj-apply.lock"))
1909 );
1910 }
1911
1912 #[test]
1915 fn an_enabled_setting_does_not_survive_a_volume_without_reflinks() {
1916 let _isolated = isolated();
1917 let mut answers = native_host();
1918 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1919 answers.push(("mj-reflink", 1, ""));
1920 let executor = ProbeExecutor::new(&answers);
1921 assert_eq!(
1922 resolve(
1923 &podman(Some(TargetBuildCache {
1924 enabled: Some(true),
1925 directory: None,
1926 max_total_size: None,
1927 scheduler: Default::default(),
1928 })),
1929 &executor,
1930 ),
1931 None
1932 );
1933 assert_eq!(resolve(&podman(None), &executor), None);
1935 }
1936
1937 #[test]
1938 fn a_network_filesystem_runs_without_the_cache() {
1939 let _isolated = isolated();
1940 let mut answers = native_host();
1941 answers.retain(|(needle, _, _)| *needle != "stat -f -c %T");
1942 answers.push(("stat -f -c %T", 0, "nfs4"));
1943 let executor = ProbeExecutor::new(&answers);
1944 assert_eq!(resolve(&podman(None), &executor), None);
1945 }
1946
1947 #[test]
1948 fn local_podman_and_local_docker_inspect_one_machine_once() {
1949 let _isolated = isolated();
1950 let executor = ProbeExecutor::new(&native_host());
1951 let first = resolve(&podman(None), &executor).unwrap();
1952 let ran = executor.ran().len();
1953 assert!(ran > 0, "the first resolve inspects the host");
1954 let second = resolve(&docker(None), &executor).unwrap();
1955 assert_eq!(
1956 first, second,
1957 "both engines on this machine share one cache"
1958 );
1959 let added = executor.ran()[ran..].to_vec();
1963 assert_eq!(
1964 added.len(),
1965 1,
1966 "the second runtime is answered from the machine's recorded inspection: {added:?}"
1967 );
1968 assert!(
1969 added[0].contains("mkdir -p"),
1970 "the one repeated command creates the directory: {added:?}"
1971 );
1972 }
1973
1974 #[test]
1975 fn the_preview_reports_what_the_cache_has_already_done() {
1976 let _isolated = isolated();
1977 let mut answers = vec![(
1979 "tally.json",
1980 0,
1981 r#"{"version":1,"since_secs":1789824719,"builds":155,"cached_compilations":12050,"avoided_compiler_ns":6004997818721,"reflinked_bytes":47612059386}"#,
1982 )];
1983 answers.extend(native_host());
1984 let executor = ProbeExecutor::new(&answers);
1985 let preview = preview_build_cache(&configured_local_machine(), &executor)
1986 .expect("the host answers")
1987 .expect("a local machine can hold a cache");
1988 assert_eq!(
1989 preview.stats,
1990 Some(mj_core::state::BuildCacheStats {
1991 builds: 155,
1992 cached_compilations: 12050,
1993 avoided_compiler_ns: 6_004_997_818_721,
1994 reflinked_bytes: 47_612_059_386,
1995 })
1996 );
1997 }
1998 #[test]
1999 fn a_cache_nothing_has_used_yet_reports_no_totals() {
2000 let _isolated = isolated();
2001 let executor = ProbeExecutor::new(&native_host());
2004 let preview = preview_build_cache(&configured_local_machine(), &executor)
2005 .expect("the host answers")
2006 .expect("a local machine can hold a cache");
2007 assert_eq!(preview.stats, None);
2008 }
2009 fn bundle() -> targets::ProjectBundleSpec {
2010 targets::ProjectBundleSpec {
2011 primary: "main".into(),
2012 repositories: vec![targets::RepositorySpec {
2013 url: Some("https://github.com/example/main.git".into()),
2014 push_urls: Vec::new(),
2015 destination: "main".into(),
2016 git_ref: None,
2017 reference: None,
2018 }],
2019 }
2020 }
2021
2022 fn clone_cache() -> super::super::git_cache::PreparedCloneCache {
2023 super::super::git_cache::PreparedCloneCache::from_mirrors(
2024 [(
2025 "main".to_owned(),
2026 PathBuf::from("/home/dev/mirror/repo.git"),
2027 )]
2028 .into_iter()
2029 .collect(),
2030 )
2031 }
2032
2033 fn session(container_workspace: Option<&str>) -> mj_core::state::SessionRecord {
2034 let mut record = crate::controller::test_support::checkpoint_test_session("session-1");
2035 record.container_workspace = container_workspace.map(PathBuf::from);
2036 record
2037 }
2038
2039 #[test]
2040 fn a_rust_session_mounts_the_native_cache_and_records_its_synchronized_binary() {
2041 let _isolated = isolated();
2042 let mut answers = native_host();
2043 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2044 let executor = ProbeExecutor::new(&answers);
2045 let mut mounts = Vec::new();
2046 let build_cache = prepare(
2047 &podman(None),
2048 &session(Some("/workspace/session-1")),
2049 Some(&bundle()),
2050 Some(&clone_cache()),
2051 &mut mounts,
2052 &executor,
2053 )
2054 .expect("a Rust session uses the build cache");
2055 assert_eq!(build_cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
2056 assert_eq!(
2057 cache_binary_path(&build_cache.directory),
2058 PathBuf::from("/mnt/fast/mbx-cache/.mjolnir/bin/mbx")
2059 );
2060 assert_eq!(
2061 mounts,
2062 vec![targets::AdditionalMount {
2063 source: PathBuf::from("/mnt/fast/mbx-cache"),
2064 destination: PathBuf::from("/mnt/fast/mbx-cache"),
2065 access: targets::MountAccess::Rw,
2066 }]
2067 );
2068 }
2069
2070 #[test]
2071 fn a_session_at_the_legacy_shared_workspace_runs_without_the_cache() {
2072 let _isolated = isolated();
2073 let mut answers = native_host();
2074 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2075 let executor = ProbeExecutor::new(&answers);
2076 let mut mounts = Vec::new();
2077 assert_eq!(
2078 prepare(
2079 &podman(None),
2080 &session(None),
2081 Some(&bundle()),
2082 Some(&clone_cache()),
2083 &mut mounts,
2084 &executor,
2085 ),
2086 None
2087 );
2088 assert!(executor.ran().is_empty());
2089 }
2090
2091 #[test]
2092 fn a_resumed_session_uses_current_machine_policy_instead_of_its_saved_budget() {
2093 let _isolated = isolated();
2094 let executor = ProbeExecutor::new(&native_host());
2095 let mut record = session(Some("/workspace/session-1"));
2096 record.build_cache = Some(SessionBuildCache {
2097 host: "local".into(),
2098 directory: PathBuf::from("/mnt/fast/mbx-cache"),
2099 max_size: Some("1GB".into()),
2100 target_root: None,
2101 });
2102 let mut mounts = Vec::new();
2103 let build_cache =
2104 prepare(&podman(None), &record, None, None, &mut mounts, &executor).unwrap();
2105 assert_eq!(build_cache.max_size, None);
2106 assert_eq!(build_cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
2107 assert_eq!(mounts.len(), 1);
2108 assert!(
2109 executor
2110 .ran()
2111 .iter()
2112 .any(|line| line.contains(".mj-apply.lock"))
2113 );
2114 }
2115
2116 #[test]
2117 fn a_session_moved_to_another_host_resolves_its_build_cache_again() {
2118 let _isolated = isolated();
2119 let mut answers = native_host();
2120 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2121 let executor = ProbeExecutor::new(&answers);
2122 let mut record = session(Some("/workspace/session-1"));
2123 record.build_cache = Some(SessionBuildCache {
2124 host: "ssh:dev@example.test".into(),
2127 directory: PathBuf::from("/mnt/fast/mbx-cache"),
2128 max_size: None,
2129 target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
2130 });
2131 let mut mounts = Vec::new();
2132
2133 let build_cache = prepare(
2134 &podman(None),
2135 &record,
2136 Some(&bundle()),
2137 Some(&clone_cache()),
2138 &mut mounts,
2139 &executor,
2140 )
2141 .expect("the destination host qualifies on its own");
2142
2143 assert_eq!(build_cache.host, "local");
2144 assert_eq!(build_cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
2145 assert_eq!(build_cache.target_root, None);
2146 assert_eq!(
2147 mounts
2148 .iter()
2149 .map(|mount| mount.destination.clone())
2150 .collect::<Vec<_>>(),
2151 vec![PathBuf::from("/mnt/fast/mbx-cache")]
2152 );
2153 assert!(
2154 executor
2155 .ran()
2156 .iter()
2157 .any(|line| line.contains("mj-reflink"))
2158 );
2159 }
2160
2161 #[test]
2162 fn an_attached_directory_over_the_cache_wins() {
2163 let build_cache = SessionBuildCache {
2164 host: "local-podman".into(),
2165 directory: PathBuf::from("/mnt/fast/mbx-cache"),
2166 max_size: None,
2167 target_root: None,
2168 };
2169 let mut mounts = vec![targets::AdditionalMount {
2170 source: PathBuf::from("/elsewhere"),
2171 destination: PathBuf::from("/mnt/fast/mbx-cache/actions"),
2172 access: targets::MountAccess::Ro,
2173 }];
2174 assert!(!attach_mounts(&build_cache, &mut mounts));
2175 assert_eq!(mounts.len(), 1);
2176 }
2177
2178 #[test]
2179 fn versions_compare_by_release_order() {
2180 let native = |version: &str| NativeMbx {
2181 program: "/usr/local/bin/mbx".into(),
2182 version: version.into(),
2183 };
2184 assert!(matches!(
2185 classify_native_mbx(Some(native(MBX_VERSION))),
2186 NativeMbxStatus::Compatible(_)
2187 ));
2188 assert!(matches!(
2189 classify_native_mbx(Some(native("1.23.0"))),
2190 NativeMbxStatus::Compatible(_)
2191 ));
2192 assert!(matches!(
2193 classify_native_mbx(Some(native("1.15.0"))),
2194 NativeMbxStatus::TooOld(_)
2195 ));
2196 assert!(matches!(
2197 classify_native_mbx(Some(native("not-a-version"))),
2198 NativeMbxStatus::Unknown { .. }
2199 ));
2200 assert!(matches!(classify_native_mbx(None), NativeMbxStatus::Absent));
2201 }
2202
2203 #[test]
2204 fn available_bytes_reads_the_df_available_column() {
2205 assert_eq!(
2206 available_bytes(
2207 "Filesystem 1K-blocks Used Available Capacity Mounted on\n\
2208 /dev/sda1 1000 400 600 40% /\n"
2209 ),
2210 Some(600)
2211 );
2212 assert_eq!(available_bytes("Filesystem 1K-blocks\n"), None);
2213 }
2214
2215 #[cfg(target_os = "linux")]
2216 #[test]
2217 fn native_probe_prefers_path_then_user_bins_and_canonicalizes_symlinks() {
2218 use std::os::unix::fs::{PermissionsExt, symlink};
2219
2220 let root = tempfile::tempdir().unwrap();
2221 let path_bin = root.path().join("path-bin");
2222 let home = root.path().join("home");
2223 let local_bin = home.join(".local/bin");
2224 let cargo_bin = home.join(".cargo/bin");
2225 let real_bin = root.path().join("real");
2226 for directory in [&path_bin, &local_bin, &cargo_bin, &real_bin] {
2227 std::fs::create_dir_all(directory).unwrap();
2228 }
2229 let install = |path: &Path, version: &str| {
2230 std::fs::write(path, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2231 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2232 };
2233 let real_mbx = real_bin.join("mbx-real");
2234 install(&real_mbx, "1.30.0");
2235 symlink(&real_mbx, path_bin.join("mbx")).unwrap();
2236 install(&local_bin.join("mbx"), "1.31.0");
2237 install(&cargo_bin.join("mbx"), "1.32.0");
2238 let readlink = std::env::split_paths(&std::env::var_os("PATH").unwrap())
2239 .map(|directory| directory.join("readlink"))
2240 .find(|candidate| candidate.is_file())
2241 .expect("readlink utility is available");
2242 symlink(readlink, path_bin.join("readlink")).unwrap();
2243
2244 let mut command = CommandSpec::new("/bin/sh", ["-c", NATIVE_VERSION_SCRIPT])
2245 .purpose("test native mbx path resolution");
2246 command.clear_env = true;
2247 command
2248 .env
2249 .insert("PATH".into(), path_bin.display().to_string());
2250 command
2251 .env
2252 .insert("HOME".into(), home.display().to_string());
2253 let executor = targets::ProcessExecutor;
2254
2255 let output = executor.execute(&command).unwrap();
2256 assert_eq!(output.status, 0);
2257 assert_eq!(
2258 parse_native_probe_output(&output.stdout).unwrap(),
2259 NativeMbx {
2260 program: real_mbx.clone(),
2261 version: "1.30.0".into(),
2262 }
2263 );
2264
2265 std::fs::remove_file(path_bin.join("mbx")).unwrap();
2266 let output = executor.execute(&command).unwrap();
2267 assert_eq!(output.status, 0);
2268 assert_eq!(
2269 parse_native_probe_output(&output.stdout).unwrap().program,
2270 local_bin.join("mbx")
2271 );
2272
2273 std::fs::remove_file(local_bin.join("mbx")).unwrap();
2274 let output = executor.execute(&command).unwrap();
2275 assert_eq!(output.status, 0);
2276 assert_eq!(
2277 parse_native_probe_output(&output.stdout).unwrap().program,
2278 cargo_bin.join("mbx")
2279 );
2280 }
2281
2282 #[cfg(target_os = "linux")]
2283 #[test]
2284 fn cache_copy_refresh_is_atomic_and_skips_unchanged_binaries() {
2285 use std::os::unix::fs::{MetadataExt, PermissionsExt};
2286
2287 let root = tempfile::tempdir().unwrap();
2288 let source = root.path().join("native mbx");
2289 let native_bin = root.path().join("native-bin");
2290 let home = root.path().join("home");
2291 let cache = root.path().join("cache with spaces");
2292 std::fs::create_dir_all(&native_bin).unwrap();
2293 std::fs::create_dir_all(&home).unwrap();
2294 let install = |version: &str| {
2295 std::fs::write(&source, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2296 std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap();
2297 };
2298 let native = |version: &str| NativeMbx {
2299 program: source.clone(),
2300 version: version.to_owned(),
2301 };
2302 std::os::unix::fs::symlink(&source, native_bin.join("mbx")).unwrap();
2303 struct IsolatedHostExecutor {
2304 path: String,
2305 home: String,
2306 }
2307 impl CommandExecutor for IsolatedHostExecutor {
2308 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2309 let mut command = command.clone();
2310 command.env.insert("PATH".into(), self.path.clone());
2311 command.env.insert("HOME".into(), self.home.clone());
2312 targets::ProcessExecutor.execute(&command)
2313 }
2314 }
2315 let executor = IsolatedHostExecutor {
2316 path: format!("{}:/usr/bin:/bin", native_bin.display()),
2317 home: home.display().to_string(),
2318 };
2319
2320 install("1.22.0");
2321 let first =
2322 sync_mbx_binary_from_native(&CacheHost::Local, &native("1.22.0"), &cache, &executor)
2323 .unwrap();
2324 let copy = first.path;
2325 assert_eq!(copy, cache.join(".mjolnir/bin/mbx"));
2326 assert_eq!(
2327 std::fs::read(©).unwrap(),
2328 std::fs::read(&source).unwrap()
2329 );
2330 assert_eq!(
2331 std::fs::metadata(©).unwrap().permissions().mode() & 0o777,
2332 0o755
2333 );
2334 let original_inode = std::fs::metadata(©).unwrap().ino();
2335
2336 let unchanged =
2337 sync_mbx_binary_from_native(&CacheHost::Local, &native("1.22.0"), &cache, &executor)
2338 .unwrap();
2339 assert_eq!(unchanged.path, copy);
2340 assert_eq!(std::fs::metadata(©).unwrap().ino(), original_inode);
2341
2342 install("1.23.0");
2343 sync_mbx_binary_from_native(&CacheHost::Local, &native("1.23.0"), &cache, &executor)
2344 .unwrap();
2345 assert_ne!(std::fs::metadata(©).unwrap().ino(), original_inode);
2346 let version = executor
2347 .execute(&CommandSpec::new(
2348 copy.to_string_lossy().into_owned(),
2349 ["--version"],
2350 ))
2351 .unwrap();
2352 assert_eq!(
2353 String::from_utf8_lossy(&version.stdout).trim(),
2354 "mbx 1.23.0"
2355 );
2356 assert_eq!(
2357 std::fs::read_dir(cache.join(".mjolnir/bin"))
2358 .unwrap()
2359 .count(),
2360 2,
2361 "publication leaves only the copy and its cross-process lock file"
2362 );
2363 }
2364
2365 #[test]
2366 fn cache_sync_reprobes_and_retries_after_another_process_changes_the_host_binary() {
2367 struct ReprobeExecutor {
2368 syncs: std::sync::atomic::AtomicUsize,
2369 commands: Mutex<Vec<CommandSpec>>,
2370 }
2371
2372 impl CommandExecutor for ReprobeExecutor {
2373 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2374 self.commands.lock().unwrap().push(command.clone());
2375 let (status, stdout, stderr) = match command.purpose.as_str() {
2376 "synchronize native mbx into the shared cache"
2377 if self.syncs.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 =>
2378 {
2379 (75, Vec::new(), b"native mbx changed".to_vec())
2380 }
2381 "synchronize native mbx into the shared cache" => {
2382 (0, b"synced\n".to_vec(), Vec::new())
2383 }
2384 "read the container host mbx version" => {
2385 (0, b"/opt/mbx/current\nmbx 1.23.0".to_vec(), Vec::new())
2386 }
2387 purpose => bail!("unexpected command purpose {purpose}"),
2388 };
2389 Ok(CommandOutput {
2390 status,
2391 stdout,
2392 stderr,
2393 })
2394 }
2395 }
2396
2397 let directory = PathBuf::from("/tmp/mjolnir-mbx-retry");
2398 let executor = ReprobeExecutor {
2399 syncs: std::sync::atomic::AtomicUsize::new(0),
2400 commands: Mutex::new(Vec::new()),
2401 };
2402 let binary = sync_mbx_binary_from_native(
2403 &CacheHost::Local,
2404 &NativeMbx {
2405 program: PathBuf::from("/opt/mbx/old"),
2406 version: "1.22.0".into(),
2407 },
2408 &directory,
2409 &executor,
2410 )
2411 .unwrap();
2412
2413 assert_eq!(binary.version, "1.23.0");
2414 assert_eq!(
2415 executor
2416 .commands
2417 .lock()
2418 .unwrap()
2419 .iter()
2420 .map(|command| command.purpose.as_str())
2421 .collect::<Vec<_>>(),
2422 [
2423 "synchronize native mbx into the shared cache",
2424 "read the container host mbx version",
2425 "synchronize native mbx into the shared cache",
2426 ]
2427 );
2428 }
2429
2430 #[cfg(target_os = "linux")]
2431 #[test]
2432 fn cache_sync_without_flock_rechecks_after_rename_and_resynchronizes() {
2433 use std::os::unix::fs::{PermissionsExt, symlink};
2434
2435 let root = tempfile::tempdir().unwrap();
2436 let tools = root.path().join("tools");
2437 let home = root.path().join("home");
2438 let cache = root.path().join("cache");
2439 let old_source = root.path().join("mbx-1.22.0");
2440 let new_source = root.path().join("mbx-1.23.0");
2441 let native_path = tools.join("mbx");
2442 let flipped = root.path().join("flipped");
2443 std::fs::create_dir_all(&tools).unwrap();
2444 std::fs::create_dir_all(&home).unwrap();
2445
2446 for name in [
2447 "sh", "dirname", "mkdir", "readlink", "mktemp", "chmod", "wc", "mv", "rm", "ln",
2448 ] {
2449 let executable = std::env::split_paths(&std::env::var_os("PATH").unwrap())
2450 .map(|directory| directory.join(name))
2451 .find(|candidate| candidate.is_file())
2452 .unwrap_or_else(|| panic!("could not find test utility {name}"));
2453 symlink(executable, tools.join(name)).unwrap();
2454 }
2455
2456 let real_cp = std::env::split_paths(&std::env::var_os("PATH").unwrap())
2457 .map(|directory| directory.join("cp"))
2458 .find(|candidate| candidate.is_file())
2459 .unwrap();
2460 let cp_wrapper = tools.join("cp");
2461 std::fs::write(
2462 &cp_wrapper,
2463 r#"#!/bin/sh
2464"$MBX_TEST_REAL_CP" "$@" || exit $?
2465if [ ! -e "$MBX_TEST_FLIPPED" ]; then
2466 : > "$MBX_TEST_FLIPPED"
2467 rm -f -- "$MBX_TEST_NATIVE"
2468 ln -s -- "$MBX_TEST_NEW_SOURCE" "$MBX_TEST_NATIVE"
2469fi"#,
2470 )
2471 .unwrap();
2472 std::fs::set_permissions(&cp_wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
2473 for (path, version) in [(&old_source, "1.22.0"), (&new_source, "1.23.0")] {
2474 std::fs::write(path, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2475 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2476 }
2477 symlink(&old_source, &native_path).unwrap();
2478
2479 struct FallbackExecutor {
2480 tools: PathBuf,
2481 home: PathBuf,
2482 real_cp: PathBuf,
2483 flipped: PathBuf,
2484 native: PathBuf,
2485 new_source: PathBuf,
2486 }
2487 impl CommandExecutor for FallbackExecutor {
2488 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2489 let mut command = command.clone();
2490 command
2491 .env
2492 .insert("PATH".into(), self.tools.display().to_string());
2493 command
2494 .env
2495 .insert("HOME".into(), self.home.display().to_string());
2496 command.env.insert(
2497 "MBX_TEST_REAL_CP".into(),
2498 self.real_cp.display().to_string(),
2499 );
2500 command.env.insert(
2501 "MBX_TEST_FLIPPED".into(),
2502 self.flipped.display().to_string(),
2503 );
2504 command
2505 .env
2506 .insert("MBX_TEST_NATIVE".into(), self.native.display().to_string());
2507 command.env.insert(
2508 "MBX_TEST_NEW_SOURCE".into(),
2509 self.new_source.display().to_string(),
2510 );
2511 targets::ProcessExecutor.execute(&command)
2512 }
2513 }
2514 let executor = FallbackExecutor {
2515 tools,
2516 home,
2517 real_cp,
2518 flipped: flipped.clone(),
2519 native: native_path,
2520 new_source,
2521 };
2522
2523 let binary = sync_mbx_binary_from_native(
2524 &CacheHost::Local,
2525 &NativeMbx {
2526 program: old_source,
2527 version: "1.22.0".into(),
2528 },
2529 &cache,
2530 &executor,
2531 )
2532 .unwrap();
2533
2534 assert!(
2535 flipped.exists(),
2536 "the test copy did not switch host versions"
2537 );
2538 assert_eq!(binary.version, "1.23.0");
2539 let copied = targets::ProcessExecutor
2540 .execute(&CommandSpec::new(
2541 binary.path.to_string_lossy().into_owned(),
2542 ["--version"],
2543 ))
2544 .unwrap();
2545 assert_eq!(String::from_utf8_lossy(&copied.stdout).trim(), "mbx 1.23.0");
2546 }
2547
2548 #[cfg(target_os = "linux")]
2549 #[test]
2550 fn cross_process_cache_sync_cannot_publish_a_stale_host_binary_last() {
2551 use std::os::unix::fs::{PermissionsExt, symlink};
2552 use std::sync::atomic::{AtomicBool, Ordering};
2553
2554 let root = tempfile::tempdir().unwrap();
2555 let source_dir = root.path().join("sources");
2556 let native_bin = root.path().join("native-bin");
2557 let wrapper_bin = root.path().join("wrapper-bin");
2558 let home = root.path().join("home");
2559 let cache = root.path().join("cache");
2560 let lock = cache.join(".mjolnir/bin/.mbx.lock");
2561 let ready = root.path().join("lock-ready");
2562 let release = root.path().join("release-lock");
2563 std::fs::create_dir_all(&source_dir).unwrap();
2564 std::fs::create_dir_all(&native_bin).unwrap();
2565 std::fs::create_dir_all(&wrapper_bin).unwrap();
2566 std::fs::create_dir_all(&home).unwrap();
2567 std::fs::create_dir_all(lock.parent().unwrap()).unwrap();
2568
2569 let old_source = source_dir.join("mbx-1.22.0");
2570 let new_source = source_dir.join("mbx-1.23.0");
2571 for (path, version) in [(&old_source, "1.22.0"), (&new_source, "1.23.0")] {
2572 std::fs::write(path, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2573 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2574 }
2575 let native_path = native_bin.join("mbx");
2576 symlink(&old_source, &native_path).unwrap();
2577
2578 let flock_wrapper = wrapper_bin.join("flock");
2582 std::fs::write(
2583 &flock_wrapper,
2584 "#!/bin/sh\n[ -z \"${MBX_TEST_FLOCK_MARKER:-}\" ] || : > \"$MBX_TEST_FLOCK_MARKER\"\nexec /usr/bin/flock \"$@\"\n",
2585 )
2586 .unwrap();
2587 std::fs::set_permissions(&flock_wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
2588
2589 let path_value = format!(
2590 "{}:{}:/usr/bin:/bin",
2591 wrapper_bin.display(),
2592 native_bin.display()
2593 );
2594 let executor = crate::targets::ProcessExecutor;
2595 let holder_script = r#"set -eu
2596exec 9>"$1"
2597flock -x 9
2598: > "$2"
2599while [ ! -e "$3" ]; do sleep 0.01; done"#;
2600 let mut holder = CommandSpec::new(
2601 "sh",
2602 vec![
2603 "-c".into(),
2604 holder_script.into(),
2605 "mbx-lock-holder".into(),
2606 lock.to_string_lossy().into_owned(),
2607 ready.to_string_lossy().into_owned(),
2608 release.to_string_lossy().into_owned(),
2609 ],
2610 )
2611 .purpose("hold test mbx lock");
2612 holder.env.insert("PATH".into(), path_value.clone());
2613 holder.env.insert("HOME".into(), home.display().to_string());
2614 let holder_thread = std::thread::spawn(move || executor.execute(&holder));
2615
2616 let deadline = std::time::Instant::now() + Duration::from_secs(3);
2617 while !ready.exists() && std::time::Instant::now() < deadline {
2618 std::thread::sleep(Duration::from_millis(10));
2619 }
2620 if !ready.exists() {
2621 std::fs::write(&release, "release").unwrap();
2622 let _ = holder_thread.join();
2623 panic!("the test lock holder did not acquire flock");
2624 }
2625
2626 let make_sync = |source: &Path, version: &str| {
2627 let mut command = CommandSpec::new(
2628 "sh",
2629 [
2630 "-c".to_owned(),
2631 SYNC_MBX_BINARY_SCRIPT.to_owned(),
2632 "test-mbx-sync".to_owned(),
2633 source.to_string_lossy().into_owned(),
2634 cache_binary_path(&cache).to_string_lossy().into_owned(),
2635 version.to_owned(),
2636 NATIVE_VERSION_SCRIPT.to_owned(),
2637 ],
2638 )
2639 .purpose("run cross-process mbx sync test");
2640 command.clear_env = true;
2641 command.env.insert("PATH".into(), path_value.clone());
2642 command
2643 .env
2644 .insert("HOME".into(), home.display().to_string());
2645 command
2646 };
2647
2648 let old_marker = root.path().join("old-reached-flock");
2649 let old_command = make_sync(&old_source, "1.22.0");
2650 let old_executor = crate::targets::ProcessExecutor;
2651 let old_done = std::sync::Arc::new(AtomicBool::new(false));
2652 let old_done_thread = old_done.clone();
2653 let mut old_command = old_command;
2654 old_command.env.insert(
2655 "MBX_TEST_FLOCK_MARKER".into(),
2656 old_marker.display().to_string(),
2657 );
2658 let old_thread = std::thread::spawn(move || {
2659 let result = old_executor.execute(&old_command);
2660 old_done_thread.store(true, Ordering::SeqCst);
2661 result
2662 });
2663 let deadline = std::time::Instant::now() + Duration::from_secs(3);
2664 while !old_marker.exists() && std::time::Instant::now() < deadline {
2665 std::thread::sleep(Duration::from_millis(10));
2666 }
2667 let old_waited = old_marker.exists() && !old_done.load(Ordering::SeqCst);
2668
2669 std::fs::remove_file(&native_path).unwrap();
2670 symlink(&new_source, &native_path).unwrap();
2671 let new_command = make_sync(&new_source, "1.23.0");
2672 let new_executor = crate::targets::ProcessExecutor;
2673 let new_thread = std::thread::spawn(move || new_executor.execute(&new_command));
2674 std::thread::sleep(Duration::from_millis(40));
2675 std::fs::write(&release, "release").unwrap();
2676
2677 let holder_output = holder_thread.join().unwrap().unwrap();
2678 let old_output = old_thread.join().unwrap().unwrap();
2679 let new_output = new_thread.join().unwrap().unwrap();
2680 assert_eq!(holder_output.status, 0, "{holder_output:?}");
2681 assert!(old_waited, "the stale synchronizer did not wait on flock");
2682 assert_eq!(old_output.status, 75, "{old_output:?}");
2683 assert_eq!(new_output.status, 0, "{new_output:?}");
2684
2685 let version = crate::targets::ProcessExecutor
2686 .execute(&CommandSpec::new(
2687 cache_binary_path(&cache).to_string_lossy().into_owned(),
2688 ["--version"],
2689 ))
2690 .unwrap();
2691 assert_eq!(
2692 String::from_utf8_lossy(&version.stdout).trim(),
2693 "mbx 1.23.0"
2694 );
2695 }
2696
2697 #[test]
2698 fn absent_or_too_old_native_mbx_leaves_the_existing_cache_copy() {
2699 struct ProbeAnswer {
2700 status: i32,
2701 stdout: Vec<u8>,
2702 }
2703
2704 impl CommandExecutor for ProbeAnswer {
2705 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
2706 Ok(CommandOutput {
2707 status: self.status,
2708 stdout: self.stdout.clone(),
2709 stderr: Vec::new(),
2710 })
2711 }
2712 }
2713
2714 let root = tempfile::tempdir().unwrap();
2715 let cache = root.path().join("cache");
2716 let copy = cache_binary_path(&cache);
2717 std::fs::create_dir_all(copy.parent().unwrap()).unwrap();
2718 std::fs::write(©, b"previous compatible copy").unwrap();
2719
2720 for answer in [
2721 ProbeAnswer {
2722 status: 1,
2723 stdout: Vec::new(),
2724 },
2725 ProbeAnswer {
2726 status: 0,
2727 stdout: "/opt/old/mbx\nmbx 1.21.0".into(),
2728 },
2729 ] {
2730 let result = sync_current_mbx_binary(&CacheHost::Local, &cache, &answer).unwrap();
2731 assert!(matches!(result, CachedMbxSync::Unavailable(_)));
2732 assert_eq!(std::fs::read(©).unwrap(), b"previous compatible copy");
2733 }
2734 }
2735
2736 #[test]
2737 fn the_preview_reports_native_cache_values_without_creating_directories() {
2738 let _isolated = isolated();
2739 let executor = ProbeExecutor::new(&native_host());
2740 let preview = preview_build_cache(&configured_local_machine(), &executor)
2741 .unwrap()
2742 .unwrap();
2743 assert_eq!(preview.native_mbx.as_deref(), Some(MBX_VERSION));
2744 assert_eq!(preview.mbx_profile_file, None);
2745 assert_eq!(
2746 preview.directory,
2747 Some(PathBuf::from("/mnt/fast/mbx-cache"))
2748 );
2749 assert_eq!(
2750 preview.max_total_size,
2751 Some(BuildCacheLimit::MbxDefault(None))
2752 );
2753 assert_eq!(preview.off_reason, None);
2754 assert!(!executor.ran().iter().any(|line| line.starts_with("mkdir ")));
2757 }
2758}