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