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
1012fn relocated_target_root(config_file: &str, directory: &Path) -> Option<PathBuf> {
1015 let document: toml::Value = toml::from_str(config_file)
1016 .map_err(|error| tracing::warn!("the host mbx configuration is unreadable: {error}"))
1017 .ok()?;
1018 let root = document.get("target")?.get("root")?.as_str()?;
1019 let root = directory.join(root);
1020 (!root.starts_with(directory)).then_some(root)
1021}
1022
1023const NEAREST_ANCESTOR_SCRIPT: &str = r#"d=$1
1024while [ ! -d "$d" ]; do
1025 parent=$(dirname -- "$d")
1026 if [ "$parent" = "$d" ]; then
1027 break
1028 fi
1029 d=$parent
1030done
1031printf '%s' "$d""#;
1032
1033fn nearest_existing_ancestor(
1036 host: &CacheHost,
1037 directory: &Path,
1038 executor: &impl CommandExecutor,
1039) -> Result<PathBuf> {
1040 let command = host.shell_command(
1041 NEAREST_ANCESTOR_SCRIPT,
1042 LABEL,
1043 [directory.to_string_lossy().into_owned()],
1044 "locate the build cache volume",
1045 );
1046 let output = checked(executor.execute(&command)?, &command)?;
1047 let path = PathBuf::from(String::from_utf8(output.stdout).context("decode cache ancestor")?);
1048 ensure!(
1049 path.is_absolute(),
1050 "build cache volume {} is not absolute",
1051 path.display()
1052 );
1053 Ok(path)
1054}
1055
1056const REFLINK_SCRIPT: &str = r#"dir=$1
1057d=$(mktemp -d "$dir/.mj-reflink.XXXXXX") || exit 1
1058printf x > "$d/a" && cp --reflink=always "$d/a" "$d/b"
1059status=$?
1060rm -rf -- "$d"
1061exit $status"#;
1062
1063fn reflinks_supported(
1067 host: &CacheHost,
1068 volume: &Path,
1069 executor: &impl CommandExecutor,
1070) -> Result<bool> {
1071 let command = host.shell_command(
1072 REFLINK_SCRIPT,
1073 LABEL,
1074 [volume.to_string_lossy().into_owned()],
1075 "probe the build cache volume for reflinks",
1076 );
1077 Ok(executor.execute(&command)?.status == 0)
1078}
1079
1080const CROSS_REFLINK_SCRIPT: &str = r#"src=$1
1081dst=$2
1082s=$(mktemp -d "$src/.mj-reflink.XXXXXX") || exit 1
1083d=$(mktemp -d "$dst/.mj-reflink.XXXXXX") || { rm -rf -- "$s"; exit 1; }
1084printf x > "$s/a" && cp --reflink=always "$s/a" "$d/b"
1085status=$?
1086rm -rf -- "$s" "$d"
1087exit $status"#;
1088
1089fn cross_reflinks_supported(
1093 host: &CacheHost,
1094 store: &Path,
1095 target_root: &Path,
1096 executor: &impl CommandExecutor,
1097) -> Result<bool> {
1098 let command = host.shell_command(
1099 CROSS_REFLINK_SCRIPT,
1100 LABEL,
1101 [
1102 store.to_string_lossy().into_owned(),
1103 target_root.to_string_lossy().into_owned(),
1104 ],
1105 "probe the managed target root for reflinks from the build cache",
1106 );
1107 Ok(executor.execute(&command)?.status == 0)
1108}
1109
1110fn create_directory(
1111 host: &CacheHost,
1112 directory: &Path,
1113 executor: &impl CommandExecutor,
1114) -> Result<()> {
1115 let command = host.command(
1116 vec![
1117 "mkdir".to_owned(),
1118 "-p".to_owned(),
1119 "--".to_owned(),
1120 directory.to_string_lossy().into_owned(),
1121 ],
1122 "create the build cache directory",
1123 );
1124 checked(executor.execute(&command)?, &command).map(|_| ())
1125}
1126
1127fn unusable_filesystem(
1130 host: &CacheHost,
1131 directory: &Path,
1132 executor: &impl CommandExecutor,
1133) -> Result<Option<&'static str>> {
1134 let filesystems =
1135 targets::probe_filesystem_types(host.ssh(), &[directory.to_path_buf()], executor)?;
1136 let filesystem = filesystems
1137 .first()
1138 .context("the filesystem probe named no filesystem")?;
1139 Ok(targets::overlay_unsupported_filesystem(filesystem)
1143 .filter(|reason| matches!(*reason, "network filesystem" | "FUSE filesystem")))
1144}
1145
1146fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
1147 if output.status == 0 {
1148 return Ok(output);
1149 }
1150 bail!(
1151 "{} failed with status {}: {}",
1152 command.purpose,
1153 output.status,
1154 String::from_utf8_lossy(&output.stderr).trim()
1155 )
1156}
1157
1158pub(super) fn primary_repository_is_rust(
1164 host: &CacheHost,
1165 mirror: &Path,
1166 executor: &impl CommandExecutor,
1167) -> bool {
1168 let command = host.command(
1169 vec![
1170 "git".to_owned(),
1171 "--git-dir".to_owned(),
1172 mirror.to_string_lossy().into_owned(),
1173 "cat-file".to_owned(),
1174 "-e".to_owned(),
1175 "HEAD:Cargo.toml".to_owned(),
1176 ],
1177 "detect a Cargo workspace in the session repository",
1178 );
1179 matches!(executor.execute(&command), Ok(output) if output.status == 0)
1180}
1181
1182pub(super) fn prepare(
1186 target: &targets::TargetTemplate,
1187 session: &mj_core::state::SessionRecord,
1188 bundle: Option<&targets::ProjectBundleSpec>,
1189 clone_cache: Option<&super::git_cache::PreparedCloneCache>,
1190 mounts: &mut Vec<targets::AdditionalMount>,
1191 executor: &impl CommandExecutor,
1192) -> Option<SessionBuildCache> {
1193 session.container_workspace.as_ref()?;
1198 let resolved = resolve(target, executor)?;
1199 let host = supported_host(target)?.0;
1200 if session.build_cache.is_none() {
1201 let mirror = clone_cache?.mirror_for(&bundle?.primary)?;
1202 if !primary_repository_is_rust(&host, mirror, executor) {
1203 return None;
1204 }
1205 }
1206 match sync_current_mbx_binary(&host, &resolved.directory, executor) {
1207 Ok(CachedMbxSync::Ready(_)) => {}
1208 Ok(CachedMbxSync::Unavailable(reason)) => {
1209 tracing::warn!(
1210 host = host.key(),
1211 "sessions run without the shared build cache: {reason}"
1212 );
1213 executor.notify_notice(&format!(
1214 "The shared Rust build cache is unavailable: {reason}. The session will start without it."
1215 ));
1216 return None;
1217 }
1218 Err(error) => {
1219 tracing::warn!(
1220 host = host.key(),
1221 "the shared mbx binary could not be synchronized; the session runs without the build cache: {error:#}"
1222 );
1223 executor.notify_notice(&format!(
1224 "The shared Rust build cache is unavailable: {error:#}. The session will start without it."
1225 ));
1226 return None;
1227 }
1228 }
1229 let build_cache = SessionBuildCache {
1230 host: host.key(),
1231 directory: resolved.directory,
1232 max_size: None,
1233 target_root: resolved.target_root,
1234 };
1235 attach_mounts(&build_cache, mounts).then_some(build_cache)
1236}
1237
1238fn attach_mounts(
1242 build_cache: &SessionBuildCache,
1243 mounts: &mut Vec<targets::AdditionalMount>,
1244) -> bool {
1245 let mut wanted = vec![build_cache.directory.clone()];
1246 wanted.extend(build_cache.target_root.iter().cloned());
1247 for destination in &wanted {
1248 if mounts.iter().any(|mount| {
1249 mount.destination.starts_with(destination)
1250 || destination.starts_with(&mount.destination)
1251 }) {
1252 tracing::warn!(
1253 destination = %destination.display(),
1254 "an attached directory overlaps the build cache, so this session runs without it"
1255 );
1256 return false;
1257 }
1258 }
1259 for directory in std::iter::once(&build_cache.directory).chain(build_cache.target_root.iter()) {
1260 mounts.push(targets::AdditionalMount {
1261 source: directory.clone(),
1262 destination: directory.clone(),
1263 access: targets::MountAccess::Rw,
1264 });
1265 }
1266 true
1267}
1268
1269pub(super) fn host_for_locator(target: &targets::TargetLocator) -> Option<CacheHost> {
1272 match target {
1273 targets::TargetLocator::LocalPodman { .. } | targets::TargetLocator::LocalDocker { .. } => {
1274 Some(CacheHost::Local)
1275 }
1276 targets::TargetLocator::SshPodman { ssh, .. }
1277 | targets::TargetLocator::SshDocker { ssh, .. } => Some(CacheHost::Ssh(ssh.clone())),
1278 _ => None,
1279 }
1280}
1281
1282pub(super) fn sync_mbx_binary_for_container(
1284 target: &targets::TargetLocator,
1285 directory: &Path,
1286 executor: &impl CommandExecutor,
1287) -> Result<CachedMbxSync> {
1288 let host = host_for_locator(target).context("build cache has no container host")?;
1289 sync_current_mbx_binary(&host, directory, executor)
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294 use super::*;
1295 use crate::targets::{ContainerTemplate, SshTarget, TargetTemplate};
1296 use mj_core::config::ImagePullPolicy;
1297 use std::sync::Mutex;
1298
1299 static ISOLATED: Mutex<()> = Mutex::new(());
1302
1303 fn isolated() -> std::sync::MutexGuard<'static, ()> {
1304 let guard = ISOLATED.lock().unwrap_or_else(|error| error.into_inner());
1305 RESOLUTIONS.lock().expect("mbx resolutions").clear();
1306 APPLICATIONS.lock().expect("mbx applications").clear();
1307 guard
1308 }
1309
1310 #[derive(Default)]
1312 struct ProbeExecutor {
1313 answers: Vec<(&'static str, i32, String)>,
1314 seen: Mutex<Vec<String>>,
1315 }
1316
1317 impl ProbeExecutor {
1318 fn new(answers: &[(&'static str, i32, &str)]) -> Self {
1319 Self {
1320 answers: answers
1321 .iter()
1322 .map(|(needle, status, stdout)| (*needle, *status, (*stdout).to_owned()))
1323 .chain(std::iter::once(("uname -sm", 0, "Linux x86_64\n".into())))
1324 .collect(),
1325 seen: Mutex::new(Vec::new()),
1326 }
1327 }
1328
1329 fn ran(&self) -> Vec<String> {
1330 self.seen.lock().unwrap().clone()
1331 }
1332 }
1333
1334 impl CommandExecutor for ProbeExecutor {
1335 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1336 let line = format!("{} {}", command.program, command.args.join(" "));
1337 self.seen.lock().unwrap().push(line.clone());
1338 let searchable = if command.program == "ssh" {
1340 line.replace("'\\''", "'").replace("' '", " ")
1341 } else {
1342 line.clone()
1343 };
1344 if searchable.contains("hel-mbx-profile") {
1345 return Ok(CommandOutput {
1346 status: 0,
1347 stdout: b"preview\n~/.profile\nposix\n/home/jonathan/.local/share/mbx/bin"
1348 .to_vec(),
1349 stderr: Vec::new(),
1350 });
1351 }
1352 for (needle, status, stdout) in &self.answers {
1353 if searchable.contains(needle) {
1354 return Ok(CommandOutput {
1355 status: *status,
1356 stdout: stdout.clone().into_bytes(),
1357 stderr: Vec::new(),
1358 });
1359 }
1360 }
1361 Ok(CommandOutput {
1362 status: 127,
1363 stdout: Vec::new(),
1364 stderr: format!("no canned answer for {line}").into_bytes(),
1365 })
1366 }
1367 }
1368
1369 fn container(build_cache: Option<TargetBuildCache>) -> ContainerTemplate {
1370 ContainerTemplate {
1371 image: "example/image:latest".into(),
1372 pull_policy: ImagePullPolicy::Missing,
1373 extra_run_args: Vec::new(),
1374 workspace_storage: Default::default(),
1375 build_cache,
1376 }
1377 }
1378
1379 fn podman(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1380 TargetTemplate::LocalPodman(container(build_cache))
1381 }
1382
1383 fn docker(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1384 TargetTemplate::LocalDocker(container(build_cache))
1385 }
1386
1387 fn configured_local_machine() -> mj_core::config::Machine {
1390 serde_json::from_value(serde_json::json!({"kind": "local"})).unwrap()
1391 }
1392
1393 fn current_native_mbx() -> String {
1395 format!("/usr/local/bin/mbx\nmbx {MBX_VERSION}")
1396 }
1397
1398 fn native_host() -> Vec<(&'static str, i32, &'static str)> {
1399 vec![
1400 ("$resolved\" --version", 0, "/usr/local/bin/mbx\nmbx 1.22.0"),
1401 (
1402 "mbx cache dir --json",
1403 0,
1404 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1405 ),
1406 ("XDG_CONFIG_HOME", 0, "/home/dev/.config/mbx"),
1407 ("[ -f \"$1\" ]", 3, ""),
1408 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1409 ("mj-reflink", 0, ""),
1410 ("mkdir -p", 0, ""),
1411 ("stat -f -c %T", 0, "xfs"),
1412 ("source=$1 destination=$2 expected=$3", 0, "synced\n"),
1413 ]
1414 }
1415
1416 fn absent_host() -> Vec<(&'static str, i32, &'static str)> {
1417 vec![("$resolved\" --version", 1, "")]
1418 }
1419
1420 #[test]
1421 fn darwin_hosts_skip_cache_inspection_provisioning_and_reconciliation() {
1422 let _isolated = isolated();
1423 for remote in [false, true] {
1424 for enabled in [None, Some(true)] {
1425 let settings = TargetBuildCache {
1426 enabled,
1427 ..Default::default()
1428 };
1429 let machine: mj_core::config::Machine = if remote {
1430 serde_json::from_value(serde_json::json!({
1431 "kind": "ssh", "host": "mac.test", "user": "builder", "build_cache": settings,
1432 }))
1433 .unwrap()
1434 } else {
1435 mj_core::config::Machine::Local {
1436 build_cache: Some(settings.clone()),
1437 }
1438 };
1439 let target = if remote {
1440 TargetTemplate::SshPodman {
1441 ssh: SshTarget {
1442 destination: "builder@mac.test".into(),
1443 ssh_args: vec![],
1444 },
1445 container: container(Some(settings)),
1446 }
1447 } else {
1448 podman(Some(settings))
1449 };
1450 let executor = ProbeExecutor::new(&[
1452 ("uname -sm", 0, "Darwin arm64\n"),
1453 ("$resolved\" --version", 0, "mbx\nmbx 1.16.0"),
1454 ]);
1455 let preview = preview_build_cache(&machine, &executor).unwrap().unwrap();
1456 assert_eq!(
1457 preview.off_reason,
1458 Some(BuildCacheOff::Unavailable(UNSUPPORTED_HOST.into()))
1459 );
1460 assert!(preview.directory.is_none());
1461 assert!(resolve(&target, &executor).is_none());
1462 apply_machine_build_cache(&machine, &[PathBuf::from("/existing/cache")], &executor)
1463 .unwrap();
1464 let commands = executor.ran();
1465 assert!(!commands.is_empty());
1466 assert!(
1467 commands
1468 .iter()
1469 .all(|command| command.contains("uname") && command.contains("-sm")),
1470 "{commands:?}"
1471 );
1472 assert!(
1473 commands
1474 .iter()
1475 .all(|command| command.starts_with(if remote { "ssh " } else { "uname " }))
1476 );
1477 }
1478 }
1479 }
1480
1481 #[test]
1482 fn linux_ssh_cache_remains_available_on_any_controller_platform() {
1483 let _isolated = isolated();
1484 let executor = ProbeExecutor::new(&native_host());
1485 let target = TargetTemplate::SshDocker {
1486 ssh: SshTarget {
1487 destination: "builder@linux.test".into(),
1488 ssh_args: vec![],
1489 },
1490 container: container(None),
1491 };
1492 let cache = resolve(&target, &executor).unwrap();
1493 assert_eq!(cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1494 assert_eq!(cache.previous_config, cache.config_file);
1495 assert!(
1496 executor.ran().iter().all(
1497 |command| command.starts_with("ssh ") && command.contains("builder@linux.test")
1498 )
1499 );
1500 }
1501
1502 #[test]
1503 fn reconciliation_refreshes_active_mounts_when_cache_policy_is_disabled() {
1504 let _isolated = isolated();
1505 let machine = mj_core::config::Machine::Local {
1506 build_cache: Some(TargetBuildCache {
1507 enabled: Some(false),
1508 ..Default::default()
1509 }),
1510 };
1511 let executor = ProbeExecutor::new(&native_host());
1512 let mounted = PathBuf::from("/existing/cache");
1513
1514 apply_machine_build_cache(&machine, std::slice::from_ref(&mounted), &executor).unwrap();
1515
1516 assert!(executor.ran().iter().any(|command| {
1517 command.contains("source=$1 destination=$2 expected=$3")
1518 && command.contains("/existing/cache/.mjolnir/bin/mbx")
1519 }));
1520 }
1521
1522 #[test]
1523 fn recorded_darwin_cache_fails_explicitly_without_writing() {
1524 let executor = ProbeExecutor::new(&[("uname -sm", 0, "Darwin x86_64")]);
1525 let recorded = SessionBuildCache {
1526 host: "local".into(),
1527 directory: PathBuf::from("/existing/cache"),
1528 max_size: None,
1529 target_root: None,
1530 };
1531 let backend = targets::TargetLocator::LocalPodman {
1532 container_id: "saved-container".into(),
1533 workspace_storage: Default::default(),
1534 borrowed_from: None,
1535 };
1536 let error =
1537 prepare_session_configuration(&Config::default(), &backend, &recorded, &executor)
1538 .unwrap_err();
1539 assert!(format!("{error:#}").contains(UNSUPPORTED_HOST));
1540 assert_eq!(executor.ran(), ["uname -sm"]);
1541 }
1542
1543 #[test]
1544 fn failed_platform_probe_does_not_attempt_cache_operations() {
1545 let executor = ProbeExecutor::new(&[("uname -sm", 1, "")]);
1546 assert!(inspect_host(&CacheHost::Local, &TargetBuildCache::default(), &executor).is_err());
1547 assert_eq!(executor.ran(), ["uname -sm"]);
1548 }
1549
1550 #[test]
1551 fn installed_worker_reads_cache_configuration_from_its_recorded_host() {
1552 let executor = ProbeExecutor::new(&[
1553 ("XDG_CONFIG_HOME", 0, "/home/builder/.config/mbx"),
1554 ("$HOME", 0, "/home/builder"),
1555 ("[ -f \"$1\" ]", 0, "[gc]\nmax_total_size = '50GB'\n"),
1556 ]);
1557 let target = targets::TargetLocator::SshPodman {
1558 ssh: SshTarget {
1559 destination: "builder@recorded-cache.test".into(),
1560 ssh_args: vec![],
1561 },
1562 container_id: "saved-container".into(),
1563 workspace_storage: Default::default(),
1564 borrowed_from: None,
1565 };
1566 let config = host_config_file(&host_for_locator(&target).unwrap(), &executor)
1567 .unwrap()
1568 .unwrap();
1569 assert!(config.contains("50GB"));
1570 assert!(
1571 executor
1572 .seen
1573 .lock()
1574 .unwrap()
1575 .iter()
1576 .all(|command| command.contains("builder@recorded-cache.test"))
1577 );
1578 }
1579
1580 #[test]
1581 fn a_native_mbx_supplies_the_cache_directory_and_its_own_limits() {
1582 let _isolated = isolated();
1583 let executor = ProbeExecutor::new(&[
1584 ("$resolved\" --version", 0, current_native_mbx().as_str()),
1585 (
1586 "mbx cache dir --json",
1587 0,
1588 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1589 ),
1590 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1591 (
1592 "[ -f \"$1\" ]",
1593 0,
1594 "cache_dir = \"/mnt/fast/mbx-cache\"\n[gc]\nmax_size = \"500GiB\"\n",
1595 ),
1596 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1597 ("mj-reflink", 0, ""),
1598 ("mkdir -p", 0, ""),
1599 ("stat -f -c %T", 0, "xfs"),
1600 ]);
1601 let resolved = resolve(&podman(None), &executor).unwrap();
1602 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1603 assert_eq!(
1605 configuration::configured_limit(
1606 resolved.config_file.as_deref(),
1607 "gc",
1608 "max_total_size"
1609 )
1610 .unwrap(),
1611 None
1612 );
1613 assert_eq!(resolved.target_root, None);
1614 assert!(resolved.config_file.unwrap().contains("500GiB"));
1615 assert!(
1616 !executor
1617 .ran()
1618 .iter()
1619 .any(|line| line.contains("apply machine")),
1620 "a host configuration is never rewritten"
1621 );
1622 }
1623 #[test]
1625 fn looking_at_the_settings_page_lets_the_next_session_see_a_repaired_host() {
1626 let _isolated = isolated();
1627 let broken = ProbeExecutor::new(
1628 &native_host()
1629 .into_iter()
1630 .map(|(needle, status, stdout)| match needle {
1631 "mj-reflink" => (needle, 1, stdout),
1632 _ => (needle, status, stdout),
1633 })
1634 .collect::<Vec<_>>(),
1635 );
1636 assert!(
1637 resolve(&podman(None), &broken).is_none(),
1638 "a volume that cannot clone runs without the cache"
1639 );
1640
1641 let repaired = ProbeExecutor::new(&native_host());
1644 let preview = preview_build_cache(&configured_local_machine(), &repaired)
1645 .expect("the host answers")
1646 .expect("a local machine can hold a cache");
1647 assert_eq!(preview.off_reason, None);
1648
1649 assert!(
1650 resolve(&podman(None), &repaired).is_some(),
1651 "the next session asks the repaired host again instead of reusing the old verdict"
1652 );
1653 }
1654
1655 #[test]
1656 fn a_target_root_that_cannot_be_cloned_into_still_gets_the_cache() {
1657 let _isolated = isolated();
1658 let executor = ProbeExecutor::new(&[
1659 ("$resolved\" --version", 0, current_native_mbx().as_str()),
1660 (
1661 "mbx cache dir --json",
1662 0,
1663 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1664 ),
1665 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1666 (
1667 "[ -f \"$1\" ]",
1668 0,
1669 "[target]\nroot = \"/mnt/slow/mbx-targets\"\n",
1670 ),
1671 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1672 ("src=$1", 1, ""),
1675 ("mj-reflink", 0, ""),
1676 ("mkdir -p", 0, ""),
1677 ("stat -f -c %T", 0, "xfs"),
1678 ]);
1679 let resolved = resolve(&podman(None), &executor)
1680 .expect("a target root that copies instead of cloning is slower, not unusable");
1681 assert_eq!(
1682 resolved.target_root,
1683 Some(PathBuf::from("/mnt/slow/mbx-targets"))
1684 );
1685 assert!(
1686 executor.ran().iter().any(|line| line.contains("src=$1")),
1687 "the store and the target root are probed as a pair: {:?}",
1688 executor.ran()
1689 );
1690 }
1691
1692 #[test]
1693 fn a_target_root_inside_the_cache_directory_needs_no_second_mount() {
1694 assert_eq!(
1695 relocated_target_root("[target]\nroot = \"targets\"\n", Path::new("/cache")),
1696 None
1697 );
1698 assert_eq!(
1699 relocated_target_root("[target]\nroot = \"/cache/targets\"\n", Path::new("/cache")),
1700 None
1701 );
1702 }
1703
1704 #[test]
1706 fn a_cargo_installed_mbx_off_the_path_is_queried_where_it_was_found() {
1707 let _isolated = isolated();
1708 let found = format!("/home/dev/.cargo/bin/mbx\nmbx {MBX_VERSION}");
1709 let mut answers: Vec<(&'static str, i32, &str)> = native_host();
1710 answers.retain(|(needle, _, _)| *needle != "$resolved\" --version");
1711 answers.push(("$resolved\" --version", 0, found.as_str()));
1712 answers.push((
1713 "/home/dev/.cargo/bin/mbx cache dir --json",
1714 0,
1715 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1716 ));
1717 let executor = ProbeExecutor::new(&answers);
1718 let resolved = resolve(&podman(None), &executor).unwrap();
1719 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1720 assert_eq!(
1721 resolved.native_mbx.program,
1722 PathBuf::from("/home/dev/.cargo/bin/mbx")
1723 );
1724 }
1725
1726 #[test]
1727 fn an_older_native_mbx_must_not_share_the_store() {
1728 let _isolated = isolated();
1729 let executor = ProbeExecutor::new(&[
1730 (
1731 "$resolved\" --version",
1732 0,
1733 "/home/jonathan/.local/bin/mbx\nmbx 1.15.0",
1734 ),
1735 ("hel-mbx-home", 0, "/home/jonathan"),
1736 ]);
1737 assert!(resolve(&podman(None), &executor).is_none());
1738 let preview = preview_build_cache(&configured_local_machine(), &executor)
1739 .unwrap()
1740 .unwrap();
1741 assert!(matches!(
1742 preview.off_reason,
1743 Some(BuildCacheOff::Unavailable(reason))
1744 if reason.contains("1.15.0") && reason.contains("Settings › Setup › Machines")
1745 ));
1746 }
1747
1748 #[test]
1749 fn a_host_without_mbx_has_no_shared_cache_and_preview_shows_setup_guidance() {
1750 let _isolated = isolated();
1751 let mut answers = absent_host();
1752 answers.push(("hel-mbx-home", 0, "/home/jonathan"));
1753 let executor = ProbeExecutor::new(&answers);
1754 assert!(resolve(&podman(None), &executor).is_none());
1755 let preview = preview_build_cache(&configured_local_machine(), &executor)
1756 .unwrap()
1757 .unwrap();
1758 assert_eq!(preview.native_mbx, None);
1759 assert_eq!(preview.directory, None);
1760 assert!(matches!(
1761 preview.off_reason,
1762 Some(BuildCacheOff::Unavailable(reason))
1763 if reason.contains("Settings › Setup › Machines")
1764 ));
1765 assert!(!executor.ran().iter().any(|line| line.starts_with("mkdir ")));
1768 }
1769
1770 #[test]
1771 fn a_native_installation_owns_the_budget_despite_saved_mj_overrides() {
1772 let _isolated = isolated();
1773 let native = current_native_mbx();
1774 let mut answers = vec![
1775 ("$resolved\" --version", 0, native.as_str()),
1776 (
1777 "mbx cache dir --json",
1778 0,
1779 r#"{"store":"/native/cache/actions"}"#,
1780 ),
1781 (
1782 "[ -f \"$1\" ]",
1783 0,
1784 "[gc]\nmax_total_size = '400GiB'\n[target]\nmax_size = 'none'\n",
1785 ),
1786 ];
1787 answers.extend(native_host());
1788 let executor = ProbeExecutor::new(&answers);
1789 let settings = TargetBuildCache {
1790 directory: Some("/ignored".into()),
1791 max_total_size: Some("1GB".into()),
1792 ..Default::default()
1793 };
1794 let inspection = inspect_host(&CacheHost::Local, &settings, &executor).unwrap();
1795 assert!(inspection.preview.user_managed);
1796 assert_eq!(inspection.preview.directory, Some("/native/cache".into()));
1797 assert_eq!(
1798 inspection.preview.max_total_size,
1799 Some(BuildCacheLimit::HostConfiguration(Some("400GiB".into())))
1800 );
1801 assert!(
1802 !executor
1803 .ran()
1804 .iter()
1805 .any(|command| command.contains(".mj-apply.lock"))
1806 );
1807 }
1808
1809 #[test]
1812 fn an_enabled_setting_does_not_survive_a_volume_without_reflinks() {
1813 let _isolated = isolated();
1814 let mut answers = native_host();
1815 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1816 answers.push(("mj-reflink", 1, ""));
1817 let executor = ProbeExecutor::new(&answers);
1818 assert_eq!(
1819 resolve(
1820 &podman(Some(TargetBuildCache {
1821 enabled: Some(true),
1822 directory: None,
1823 max_total_size: None,
1824 scheduler: Default::default(),
1825 })),
1826 &executor,
1827 ),
1828 None
1829 );
1830 assert_eq!(resolve(&podman(None), &executor), None);
1832 }
1833
1834 #[test]
1835 fn a_network_filesystem_runs_without_the_cache() {
1836 let _isolated = isolated();
1837 let mut answers = native_host();
1838 answers.retain(|(needle, _, _)| *needle != "stat -f -c %T");
1839 answers.push(("stat -f -c %T", 0, "nfs4"));
1840 let executor = ProbeExecutor::new(&answers);
1841 assert_eq!(resolve(&podman(None), &executor), None);
1842 }
1843
1844 #[test]
1845 fn local_podman_and_local_docker_inspect_one_machine_once() {
1846 let _isolated = isolated();
1847 let executor = ProbeExecutor::new(&native_host());
1848 let first = resolve(&podman(None), &executor).unwrap();
1849 let ran = executor.ran().len();
1850 assert!(ran > 0, "the first resolve inspects the host");
1851 let second = resolve(&docker(None), &executor).unwrap();
1852 assert_eq!(
1853 first, second,
1854 "both engines on this machine share one cache"
1855 );
1856 let added = executor.ran()[ran..].to_vec();
1860 assert_eq!(
1861 added.len(),
1862 1,
1863 "the second runtime is answered from the machine's recorded inspection: {added:?}"
1864 );
1865 assert!(
1866 added[0].contains("mkdir -p"),
1867 "the one repeated command creates the directory: {added:?}"
1868 );
1869 }
1870
1871 #[test]
1872 fn the_preview_reports_what_the_cache_has_already_done() {
1873 let _isolated = isolated();
1874 let mut answers = vec![(
1876 "tally.json",
1877 0,
1878 r#"{"version":1,"since_secs":1789824719,"builds":155,"cached_compilations":12050,"avoided_compiler_ns":6004997818721,"reflinked_bytes":47612059386}"#,
1879 )];
1880 answers.extend(native_host());
1881 let executor = ProbeExecutor::new(&answers);
1882 let preview = preview_build_cache(&configured_local_machine(), &executor)
1883 .expect("the host answers")
1884 .expect("a local machine can hold a cache");
1885 assert_eq!(
1886 preview.stats,
1887 Some(mj_core::state::BuildCacheStats {
1888 builds: 155,
1889 cached_compilations: 12050,
1890 avoided_compiler_ns: 6_004_997_818_721,
1891 reflinked_bytes: 47_612_059_386,
1892 })
1893 );
1894 }
1895 #[test]
1896 fn a_cache_nothing_has_used_yet_reports_no_totals() {
1897 let _isolated = isolated();
1898 let executor = ProbeExecutor::new(&native_host());
1901 let preview = preview_build_cache(&configured_local_machine(), &executor)
1902 .expect("the host answers")
1903 .expect("a local machine can hold a cache");
1904 assert_eq!(preview.stats, None);
1905 }
1906 fn bundle() -> targets::ProjectBundleSpec {
1907 targets::ProjectBundleSpec {
1908 primary: "main".into(),
1909 repositories: vec![targets::RepositorySpec {
1910 url: Some("https://github.com/example/main.git".into()),
1911 push_urls: Vec::new(),
1912 destination: "main".into(),
1913 git_ref: None,
1914 reference: None,
1915 }],
1916 }
1917 }
1918
1919 fn clone_cache() -> super::super::git_cache::PreparedCloneCache {
1920 super::super::git_cache::PreparedCloneCache::from_mirrors(
1921 [(
1922 "main".to_owned(),
1923 PathBuf::from("/home/dev/mirror/repo.git"),
1924 )]
1925 .into_iter()
1926 .collect(),
1927 )
1928 }
1929
1930 fn session(container_workspace: Option<&str>) -> mj_core::state::SessionRecord {
1931 let mut record = crate::controller::test_support::checkpoint_test_session("session-1");
1932 record.container_workspace = container_workspace.map(PathBuf::from);
1933 record
1934 }
1935
1936 #[test]
1937 fn a_rust_session_mounts_the_native_cache_and_records_its_synchronized_binary() {
1938 let _isolated = isolated();
1939 let mut answers = native_host();
1940 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1941 let executor = ProbeExecutor::new(&answers);
1942 let mut mounts = Vec::new();
1943 let build_cache = prepare(
1944 &podman(None),
1945 &session(Some("/workspace/session-1")),
1946 Some(&bundle()),
1947 Some(&clone_cache()),
1948 &mut mounts,
1949 &executor,
1950 )
1951 .expect("a Rust session uses the build cache");
1952 assert_eq!(build_cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1953 assert_eq!(
1954 cache_binary_path(&build_cache.directory),
1955 PathBuf::from("/mnt/fast/mbx-cache/.mjolnir/bin/mbx")
1956 );
1957 assert_eq!(
1958 mounts,
1959 vec![targets::AdditionalMount {
1960 source: PathBuf::from("/mnt/fast/mbx-cache"),
1961 destination: PathBuf::from("/mnt/fast/mbx-cache"),
1962 access: targets::MountAccess::Rw,
1963 }]
1964 );
1965 }
1966
1967 #[test]
1968 fn a_session_at_the_legacy_shared_workspace_runs_without_the_cache() {
1969 let _isolated = isolated();
1970 let mut answers = native_host();
1971 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1972 let executor = ProbeExecutor::new(&answers);
1973 let mut mounts = Vec::new();
1974 assert_eq!(
1975 prepare(
1976 &podman(None),
1977 &session(None),
1978 Some(&bundle()),
1979 Some(&clone_cache()),
1980 &mut mounts,
1981 &executor,
1982 ),
1983 None
1984 );
1985 assert!(executor.ran().is_empty());
1986 }
1987
1988 #[test]
1989 fn a_resumed_session_uses_current_machine_policy_instead_of_its_saved_budget() {
1990 let _isolated = isolated();
1991 let executor = ProbeExecutor::new(&native_host());
1992 let mut record = session(Some("/workspace/session-1"));
1993 record.build_cache = Some(SessionBuildCache {
1994 host: "local".into(),
1995 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1996 max_size: Some("1GB".into()),
1997 target_root: None,
1998 });
1999 let mut mounts = Vec::new();
2000 let build_cache =
2001 prepare(&podman(None), &record, None, None, &mut mounts, &executor).unwrap();
2002 assert_eq!(build_cache.max_size, None);
2003 assert_eq!(build_cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
2004 assert_eq!(mounts.len(), 1);
2005 assert!(
2006 executor
2007 .ran()
2008 .iter()
2009 .any(|line| line.contains(".mj-apply.lock"))
2010 );
2011 }
2012
2013 #[test]
2014 fn a_session_moved_to_another_host_resolves_its_build_cache_again() {
2015 let _isolated = isolated();
2016 let mut answers = native_host();
2017 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2018 let executor = ProbeExecutor::new(&answers);
2019 let mut record = session(Some("/workspace/session-1"));
2020 record.build_cache = Some(SessionBuildCache {
2021 host: "ssh:dev@example.test".into(),
2024 directory: PathBuf::from("/mnt/fast/mbx-cache"),
2025 max_size: None,
2026 target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
2027 });
2028 let mut mounts = Vec::new();
2029
2030 let build_cache = prepare(
2031 &podman(None),
2032 &record,
2033 Some(&bundle()),
2034 Some(&clone_cache()),
2035 &mut mounts,
2036 &executor,
2037 )
2038 .expect("the destination host qualifies on its own");
2039
2040 assert_eq!(build_cache.host, "local");
2041 assert_eq!(build_cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
2042 assert_eq!(build_cache.target_root, None);
2043 assert_eq!(
2044 mounts
2045 .iter()
2046 .map(|mount| mount.destination.clone())
2047 .collect::<Vec<_>>(),
2048 vec![PathBuf::from("/mnt/fast/mbx-cache")]
2049 );
2050 assert!(
2051 executor
2052 .ran()
2053 .iter()
2054 .any(|line| line.contains("mj-reflink"))
2055 );
2056 }
2057
2058 #[test]
2059 fn an_attached_directory_over_the_cache_wins() {
2060 let build_cache = SessionBuildCache {
2061 host: "local-podman".into(),
2062 directory: PathBuf::from("/mnt/fast/mbx-cache"),
2063 max_size: None,
2064 target_root: None,
2065 };
2066 let mut mounts = vec![targets::AdditionalMount {
2067 source: PathBuf::from("/elsewhere"),
2068 destination: PathBuf::from("/mnt/fast/mbx-cache/actions"),
2069 access: targets::MountAccess::Ro,
2070 }];
2071 assert!(!attach_mounts(&build_cache, &mut mounts));
2072 assert_eq!(mounts.len(), 1);
2073 }
2074
2075 #[test]
2076 fn versions_compare_by_release_order() {
2077 let native = |version: &str| NativeMbx {
2078 program: "/usr/local/bin/mbx".into(),
2079 version: version.into(),
2080 };
2081 assert!(matches!(
2082 classify_native_mbx(Some(native(MBX_VERSION))),
2083 NativeMbxStatus::Compatible(_)
2084 ));
2085 assert!(matches!(
2086 classify_native_mbx(Some(native("1.23.0"))),
2087 NativeMbxStatus::Compatible(_)
2088 ));
2089 assert!(matches!(
2090 classify_native_mbx(Some(native("1.15.0"))),
2091 NativeMbxStatus::TooOld(_)
2092 ));
2093 assert!(matches!(
2094 classify_native_mbx(Some(native("not-a-version"))),
2095 NativeMbxStatus::Unknown { .. }
2096 ));
2097 assert!(matches!(classify_native_mbx(None), NativeMbxStatus::Absent));
2098 }
2099
2100 #[test]
2101 fn available_bytes_reads_the_df_available_column() {
2102 assert_eq!(
2103 available_bytes(
2104 "Filesystem 1K-blocks Used Available Capacity Mounted on\n\
2105 /dev/sda1 1000 400 600 40% /\n"
2106 ),
2107 Some(600)
2108 );
2109 assert_eq!(available_bytes("Filesystem 1K-blocks\n"), None);
2110 }
2111
2112 #[cfg(target_os = "linux")]
2113 #[test]
2114 fn native_probe_prefers_path_then_user_bins_and_canonicalizes_symlinks() {
2115 use std::os::unix::fs::{PermissionsExt, symlink};
2116
2117 let root = tempfile::tempdir().unwrap();
2118 let path_bin = root.path().join("path-bin");
2119 let home = root.path().join("home");
2120 let local_bin = home.join(".local/bin");
2121 let cargo_bin = home.join(".cargo/bin");
2122 let real_bin = root.path().join("real");
2123 for directory in [&path_bin, &local_bin, &cargo_bin, &real_bin] {
2124 std::fs::create_dir_all(directory).unwrap();
2125 }
2126 let install = |path: &Path, version: &str| {
2127 std::fs::write(path, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2128 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2129 };
2130 let real_mbx = real_bin.join("mbx-real");
2131 install(&real_mbx, "1.30.0");
2132 symlink(&real_mbx, path_bin.join("mbx")).unwrap();
2133 install(&local_bin.join("mbx"), "1.31.0");
2134 install(&cargo_bin.join("mbx"), "1.32.0");
2135 let readlink = std::env::split_paths(&std::env::var_os("PATH").unwrap())
2136 .map(|directory| directory.join("readlink"))
2137 .find(|candidate| candidate.is_file())
2138 .expect("readlink utility is available");
2139 symlink(readlink, path_bin.join("readlink")).unwrap();
2140
2141 let mut command = CommandSpec::new("/bin/sh", ["-c", NATIVE_VERSION_SCRIPT])
2142 .purpose("test native mbx path resolution");
2143 command.clear_env = true;
2144 command
2145 .env
2146 .insert("PATH".into(), path_bin.display().to_string());
2147 command
2148 .env
2149 .insert("HOME".into(), home.display().to_string());
2150 let executor = targets::ProcessExecutor;
2151
2152 let output = executor.execute(&command).unwrap();
2153 assert_eq!(output.status, 0);
2154 assert_eq!(
2155 parse_native_probe_output(&output.stdout).unwrap(),
2156 NativeMbx {
2157 program: real_mbx.clone(),
2158 version: "1.30.0".into(),
2159 }
2160 );
2161
2162 std::fs::remove_file(path_bin.join("mbx")).unwrap();
2163 let output = executor.execute(&command).unwrap();
2164 assert_eq!(output.status, 0);
2165 assert_eq!(
2166 parse_native_probe_output(&output.stdout).unwrap().program,
2167 local_bin.join("mbx")
2168 );
2169
2170 std::fs::remove_file(local_bin.join("mbx")).unwrap();
2171 let output = executor.execute(&command).unwrap();
2172 assert_eq!(output.status, 0);
2173 assert_eq!(
2174 parse_native_probe_output(&output.stdout).unwrap().program,
2175 cargo_bin.join("mbx")
2176 );
2177 }
2178
2179 #[cfg(target_os = "linux")]
2180 #[test]
2181 fn cache_copy_refresh_is_atomic_and_skips_unchanged_binaries() {
2182 use std::os::unix::fs::{MetadataExt, PermissionsExt};
2183
2184 let root = tempfile::tempdir().unwrap();
2185 let source = root.path().join("native mbx");
2186 let native_bin = root.path().join("native-bin");
2187 let home = root.path().join("home");
2188 let cache = root.path().join("cache with spaces");
2189 std::fs::create_dir_all(&native_bin).unwrap();
2190 std::fs::create_dir_all(&home).unwrap();
2191 let install = |version: &str| {
2192 std::fs::write(&source, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2193 std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap();
2194 };
2195 let native = |version: &str| NativeMbx {
2196 program: source.clone(),
2197 version: version.to_owned(),
2198 };
2199 std::os::unix::fs::symlink(&source, native_bin.join("mbx")).unwrap();
2200 struct IsolatedHostExecutor {
2201 path: String,
2202 home: String,
2203 }
2204 impl CommandExecutor for IsolatedHostExecutor {
2205 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2206 let mut command = command.clone();
2207 command.env.insert("PATH".into(), self.path.clone());
2208 command.env.insert("HOME".into(), self.home.clone());
2209 targets::ProcessExecutor.execute(&command)
2210 }
2211 }
2212 let executor = IsolatedHostExecutor {
2213 path: format!("{}:/usr/bin:/bin", native_bin.display()),
2214 home: home.display().to_string(),
2215 };
2216
2217 install("1.22.0");
2218 let first =
2219 sync_mbx_binary_from_native(&CacheHost::Local, &native("1.22.0"), &cache, &executor)
2220 .unwrap();
2221 let copy = first.path;
2222 assert_eq!(copy, cache.join(".mjolnir/bin/mbx"));
2223 assert_eq!(
2224 std::fs::read(©).unwrap(),
2225 std::fs::read(&source).unwrap()
2226 );
2227 assert_eq!(
2228 std::fs::metadata(©).unwrap().permissions().mode() & 0o777,
2229 0o755
2230 );
2231 let original_inode = std::fs::metadata(©).unwrap().ino();
2232
2233 let unchanged =
2234 sync_mbx_binary_from_native(&CacheHost::Local, &native("1.22.0"), &cache, &executor)
2235 .unwrap();
2236 assert_eq!(unchanged.path, copy);
2237 assert_eq!(std::fs::metadata(©).unwrap().ino(), original_inode);
2238
2239 install("1.23.0");
2240 sync_mbx_binary_from_native(&CacheHost::Local, &native("1.23.0"), &cache, &executor)
2241 .unwrap();
2242 assert_ne!(std::fs::metadata(©).unwrap().ino(), original_inode);
2243 let version = executor
2244 .execute(&CommandSpec::new(
2245 copy.to_string_lossy().into_owned(),
2246 ["--version"],
2247 ))
2248 .unwrap();
2249 assert_eq!(
2250 String::from_utf8_lossy(&version.stdout).trim(),
2251 "mbx 1.23.0"
2252 );
2253 assert_eq!(
2254 std::fs::read_dir(cache.join(".mjolnir/bin"))
2255 .unwrap()
2256 .count(),
2257 2,
2258 "publication leaves only the copy and its cross-process lock file"
2259 );
2260 }
2261
2262 #[test]
2263 fn cache_sync_reprobes_and_retries_after_another_process_changes_the_host_binary() {
2264 struct ReprobeExecutor {
2265 syncs: std::sync::atomic::AtomicUsize,
2266 commands: Mutex<Vec<CommandSpec>>,
2267 }
2268
2269 impl CommandExecutor for ReprobeExecutor {
2270 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2271 self.commands.lock().unwrap().push(command.clone());
2272 let (status, stdout, stderr) = match command.purpose.as_str() {
2273 "synchronize native mbx into the shared cache"
2274 if self.syncs.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 =>
2275 {
2276 (75, Vec::new(), b"native mbx changed".to_vec())
2277 }
2278 "synchronize native mbx into the shared cache" => {
2279 (0, b"synced\n".to_vec(), Vec::new())
2280 }
2281 "read the container host mbx version" => {
2282 (0, b"/opt/mbx/current\nmbx 1.23.0".to_vec(), Vec::new())
2283 }
2284 purpose => bail!("unexpected command purpose {purpose}"),
2285 };
2286 Ok(CommandOutput {
2287 status,
2288 stdout,
2289 stderr,
2290 })
2291 }
2292 }
2293
2294 let directory = PathBuf::from("/tmp/mjolnir-mbx-retry");
2295 let executor = ReprobeExecutor {
2296 syncs: std::sync::atomic::AtomicUsize::new(0),
2297 commands: Mutex::new(Vec::new()),
2298 };
2299 let binary = sync_mbx_binary_from_native(
2300 &CacheHost::Local,
2301 &NativeMbx {
2302 program: PathBuf::from("/opt/mbx/old"),
2303 version: "1.22.0".into(),
2304 },
2305 &directory,
2306 &executor,
2307 )
2308 .unwrap();
2309
2310 assert_eq!(binary.version, "1.23.0");
2311 assert_eq!(
2312 executor
2313 .commands
2314 .lock()
2315 .unwrap()
2316 .iter()
2317 .map(|command| command.purpose.as_str())
2318 .collect::<Vec<_>>(),
2319 [
2320 "synchronize native mbx into the shared cache",
2321 "read the container host mbx version",
2322 "synchronize native mbx into the shared cache",
2323 ]
2324 );
2325 }
2326
2327 #[cfg(target_os = "linux")]
2328 #[test]
2329 fn cache_sync_without_flock_rechecks_after_rename_and_resynchronizes() {
2330 use std::os::unix::fs::{PermissionsExt, symlink};
2331
2332 let root = tempfile::tempdir().unwrap();
2333 let tools = root.path().join("tools");
2334 let home = root.path().join("home");
2335 let cache = root.path().join("cache");
2336 let old_source = root.path().join("mbx-1.22.0");
2337 let new_source = root.path().join("mbx-1.23.0");
2338 let native_path = tools.join("mbx");
2339 let flipped = root.path().join("flipped");
2340 std::fs::create_dir_all(&tools).unwrap();
2341 std::fs::create_dir_all(&home).unwrap();
2342
2343 for name in [
2344 "sh", "dirname", "mkdir", "readlink", "mktemp", "chmod", "wc", "mv", "rm", "ln",
2345 ] {
2346 let executable = std::env::split_paths(&std::env::var_os("PATH").unwrap())
2347 .map(|directory| directory.join(name))
2348 .find(|candidate| candidate.is_file())
2349 .unwrap_or_else(|| panic!("could not find test utility {name}"));
2350 symlink(executable, tools.join(name)).unwrap();
2351 }
2352
2353 let real_cp = std::env::split_paths(&std::env::var_os("PATH").unwrap())
2354 .map(|directory| directory.join("cp"))
2355 .find(|candidate| candidate.is_file())
2356 .unwrap();
2357 let cp_wrapper = tools.join("cp");
2358 std::fs::write(
2359 &cp_wrapper,
2360 r#"#!/bin/sh
2361"$MBX_TEST_REAL_CP" "$@" || exit $?
2362if [ ! -e "$MBX_TEST_FLIPPED" ]; then
2363 : > "$MBX_TEST_FLIPPED"
2364 rm -f -- "$MBX_TEST_NATIVE"
2365 ln -s -- "$MBX_TEST_NEW_SOURCE" "$MBX_TEST_NATIVE"
2366fi"#,
2367 )
2368 .unwrap();
2369 std::fs::set_permissions(&cp_wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
2370 for (path, version) in [(&old_source, "1.22.0"), (&new_source, "1.23.0")] {
2371 std::fs::write(path, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2372 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2373 }
2374 symlink(&old_source, &native_path).unwrap();
2375
2376 struct FallbackExecutor {
2377 tools: PathBuf,
2378 home: PathBuf,
2379 real_cp: PathBuf,
2380 flipped: PathBuf,
2381 native: PathBuf,
2382 new_source: PathBuf,
2383 }
2384 impl CommandExecutor for FallbackExecutor {
2385 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2386 let mut command = command.clone();
2387 command
2388 .env
2389 .insert("PATH".into(), self.tools.display().to_string());
2390 command
2391 .env
2392 .insert("HOME".into(), self.home.display().to_string());
2393 command.env.insert(
2394 "MBX_TEST_REAL_CP".into(),
2395 self.real_cp.display().to_string(),
2396 );
2397 command.env.insert(
2398 "MBX_TEST_FLIPPED".into(),
2399 self.flipped.display().to_string(),
2400 );
2401 command
2402 .env
2403 .insert("MBX_TEST_NATIVE".into(), self.native.display().to_string());
2404 command.env.insert(
2405 "MBX_TEST_NEW_SOURCE".into(),
2406 self.new_source.display().to_string(),
2407 );
2408 targets::ProcessExecutor.execute(&command)
2409 }
2410 }
2411 let executor = FallbackExecutor {
2412 tools,
2413 home,
2414 real_cp,
2415 flipped: flipped.clone(),
2416 native: native_path,
2417 new_source,
2418 };
2419
2420 let binary = sync_mbx_binary_from_native(
2421 &CacheHost::Local,
2422 &NativeMbx {
2423 program: old_source,
2424 version: "1.22.0".into(),
2425 },
2426 &cache,
2427 &executor,
2428 )
2429 .unwrap();
2430
2431 assert!(
2432 flipped.exists(),
2433 "the test copy did not switch host versions"
2434 );
2435 assert_eq!(binary.version, "1.23.0");
2436 let copied = targets::ProcessExecutor
2437 .execute(&CommandSpec::new(
2438 binary.path.to_string_lossy().into_owned(),
2439 ["--version"],
2440 ))
2441 .unwrap();
2442 assert_eq!(String::from_utf8_lossy(&copied.stdout).trim(), "mbx 1.23.0");
2443 }
2444
2445 #[cfg(target_os = "linux")]
2446 #[test]
2447 fn cross_process_cache_sync_cannot_publish_a_stale_host_binary_last() {
2448 use std::os::unix::fs::{PermissionsExt, symlink};
2449 use std::sync::atomic::{AtomicBool, Ordering};
2450
2451 let root = tempfile::tempdir().unwrap();
2452 let source_dir = root.path().join("sources");
2453 let native_bin = root.path().join("native-bin");
2454 let wrapper_bin = root.path().join("wrapper-bin");
2455 let home = root.path().join("home");
2456 let cache = root.path().join("cache");
2457 let lock = cache.join(".mjolnir/bin/.mbx.lock");
2458 let ready = root.path().join("lock-ready");
2459 let release = root.path().join("release-lock");
2460 std::fs::create_dir_all(&source_dir).unwrap();
2461 std::fs::create_dir_all(&native_bin).unwrap();
2462 std::fs::create_dir_all(&wrapper_bin).unwrap();
2463 std::fs::create_dir_all(&home).unwrap();
2464 std::fs::create_dir_all(lock.parent().unwrap()).unwrap();
2465
2466 let old_source = source_dir.join("mbx-1.22.0");
2467 let new_source = source_dir.join("mbx-1.23.0");
2468 for (path, version) in [(&old_source, "1.22.0"), (&new_source, "1.23.0")] {
2469 std::fs::write(path, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2470 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2471 }
2472 let native_path = native_bin.join("mbx");
2473 symlink(&old_source, &native_path).unwrap();
2474
2475 let flock_wrapper = wrapper_bin.join("flock");
2479 std::fs::write(
2480 &flock_wrapper,
2481 "#!/bin/sh\n[ -z \"${MBX_TEST_FLOCK_MARKER:-}\" ] || : > \"$MBX_TEST_FLOCK_MARKER\"\nexec /usr/bin/flock \"$@\"\n",
2482 )
2483 .unwrap();
2484 std::fs::set_permissions(&flock_wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
2485
2486 let path_value = format!(
2487 "{}:{}:/usr/bin:/bin",
2488 wrapper_bin.display(),
2489 native_bin.display()
2490 );
2491 let executor = crate::targets::ProcessExecutor;
2492 let holder_script = r#"set -eu
2493exec 9>"$1"
2494flock -x 9
2495: > "$2"
2496while [ ! -e "$3" ]; do sleep 0.01; done"#;
2497 let mut holder = CommandSpec::new(
2498 "sh",
2499 vec![
2500 "-c".into(),
2501 holder_script.into(),
2502 "mbx-lock-holder".into(),
2503 lock.to_string_lossy().into_owned(),
2504 ready.to_string_lossy().into_owned(),
2505 release.to_string_lossy().into_owned(),
2506 ],
2507 )
2508 .purpose("hold test mbx lock");
2509 holder.env.insert("PATH".into(), path_value.clone());
2510 holder.env.insert("HOME".into(), home.display().to_string());
2511 let holder_thread = std::thread::spawn(move || executor.execute(&holder));
2512
2513 let deadline = std::time::Instant::now() + Duration::from_secs(3);
2514 while !ready.exists() && std::time::Instant::now() < deadline {
2515 std::thread::sleep(Duration::from_millis(10));
2516 }
2517 if !ready.exists() {
2518 std::fs::write(&release, "release").unwrap();
2519 let _ = holder_thread.join();
2520 panic!("the test lock holder did not acquire flock");
2521 }
2522
2523 let make_sync = |source: &Path, version: &str| {
2524 let mut command = CommandSpec::new(
2525 "sh",
2526 [
2527 "-c".to_owned(),
2528 SYNC_MBX_BINARY_SCRIPT.to_owned(),
2529 "test-mbx-sync".to_owned(),
2530 source.to_string_lossy().into_owned(),
2531 cache_binary_path(&cache).to_string_lossy().into_owned(),
2532 version.to_owned(),
2533 NATIVE_VERSION_SCRIPT.to_owned(),
2534 ],
2535 )
2536 .purpose("run cross-process mbx sync test");
2537 command.clear_env = true;
2538 command.env.insert("PATH".into(), path_value.clone());
2539 command
2540 .env
2541 .insert("HOME".into(), home.display().to_string());
2542 command
2543 };
2544
2545 let old_marker = root.path().join("old-reached-flock");
2546 let old_command = make_sync(&old_source, "1.22.0");
2547 let old_executor = crate::targets::ProcessExecutor;
2548 let old_done = std::sync::Arc::new(AtomicBool::new(false));
2549 let old_done_thread = old_done.clone();
2550 let mut old_command = old_command;
2551 old_command.env.insert(
2552 "MBX_TEST_FLOCK_MARKER".into(),
2553 old_marker.display().to_string(),
2554 );
2555 let old_thread = std::thread::spawn(move || {
2556 let result = old_executor.execute(&old_command);
2557 old_done_thread.store(true, Ordering::SeqCst);
2558 result
2559 });
2560 let deadline = std::time::Instant::now() + Duration::from_secs(3);
2561 while !old_marker.exists() && std::time::Instant::now() < deadline {
2562 std::thread::sleep(Duration::from_millis(10));
2563 }
2564 let old_waited = old_marker.exists() && !old_done.load(Ordering::SeqCst);
2565
2566 std::fs::remove_file(&native_path).unwrap();
2567 symlink(&new_source, &native_path).unwrap();
2568 let new_command = make_sync(&new_source, "1.23.0");
2569 let new_executor = crate::targets::ProcessExecutor;
2570 let new_thread = std::thread::spawn(move || new_executor.execute(&new_command));
2571 std::thread::sleep(Duration::from_millis(40));
2572 std::fs::write(&release, "release").unwrap();
2573
2574 let holder_output = holder_thread.join().unwrap().unwrap();
2575 let old_output = old_thread.join().unwrap().unwrap();
2576 let new_output = new_thread.join().unwrap().unwrap();
2577 assert_eq!(holder_output.status, 0, "{holder_output:?}");
2578 assert!(old_waited, "the stale synchronizer did not wait on flock");
2579 assert_eq!(old_output.status, 75, "{old_output:?}");
2580 assert_eq!(new_output.status, 0, "{new_output:?}");
2581
2582 let version = crate::targets::ProcessExecutor
2583 .execute(&CommandSpec::new(
2584 cache_binary_path(&cache).to_string_lossy().into_owned(),
2585 ["--version"],
2586 ))
2587 .unwrap();
2588 assert_eq!(
2589 String::from_utf8_lossy(&version.stdout).trim(),
2590 "mbx 1.23.0"
2591 );
2592 }
2593
2594 #[test]
2595 fn absent_or_too_old_native_mbx_leaves_the_existing_cache_copy() {
2596 struct ProbeAnswer {
2597 status: i32,
2598 stdout: Vec<u8>,
2599 }
2600
2601 impl CommandExecutor for ProbeAnswer {
2602 fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
2603 Ok(CommandOutput {
2604 status: self.status,
2605 stdout: self.stdout.clone(),
2606 stderr: Vec::new(),
2607 })
2608 }
2609 }
2610
2611 let root = tempfile::tempdir().unwrap();
2612 let cache = root.path().join("cache");
2613 let copy = cache_binary_path(&cache);
2614 std::fs::create_dir_all(copy.parent().unwrap()).unwrap();
2615 std::fs::write(©, b"previous compatible copy").unwrap();
2616
2617 for answer in [
2618 ProbeAnswer {
2619 status: 1,
2620 stdout: Vec::new(),
2621 },
2622 ProbeAnswer {
2623 status: 0,
2624 stdout: "/opt/old/mbx\nmbx 1.21.0".into(),
2625 },
2626 ] {
2627 let result = sync_current_mbx_binary(&CacheHost::Local, &cache, &answer).unwrap();
2628 assert!(matches!(result, CachedMbxSync::Unavailable(_)));
2629 assert_eq!(std::fs::read(©).unwrap(), b"previous compatible copy");
2630 }
2631 }
2632
2633 #[test]
2634 fn the_preview_reports_native_cache_values_without_creating_directories() {
2635 let _isolated = isolated();
2636 let executor = ProbeExecutor::new(&native_host());
2637 let preview = preview_build_cache(&configured_local_machine(), &executor)
2638 .unwrap()
2639 .unwrap();
2640 assert_eq!(preview.native_mbx.as_deref(), Some(MBX_VERSION));
2641 assert_eq!(preview.mbx_profile_file, None);
2642 assert_eq!(
2643 preview.directory,
2644 Some(PathBuf::from("/mnt/fast/mbx-cache"))
2645 );
2646 assert_eq!(
2647 preview.max_total_size,
2648 Some(BuildCacheLimit::MbxDefault(None))
2649 );
2650 assert_eq!(preview.off_reason, None);
2651 assert!(!executor.ran().iter().any(|line| line.starts_with("mkdir ")));
2654 }
2655}