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