1use std::io::Read;
14use std::path::{Path, PathBuf};
15use std::time::{Duration, Instant};
16
17use anyhow::{Context, Result, bail, ensure};
18use sha2::{Digest, Sha256};
19
20use super::cache_host::CacheHost;
21use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec};
22use mj_core::config::{BuildCacheConfig, TargetBuildCache};
23use mj_core::state::{BuildCacheLimit, BuildCachePreview, SessionBuildCache};
24
25pub(super) const MBX_VERSION: &str = "1.12.0";
28
29const MBX_X86_64_SHA256: &str = "b0d90013e5e4e55419b75db897a6b9eed0f0e3b7bc49edf355e0e6490fda6de2";
30const MBX_AARCH64_SHA256: &str = "738b97bf260137aed70cd3cf849925d1f1bec5f1ba446f5e61ed851ae649bc7b";
31
32const MBX_BINARY_ENV: &str = "MJ_MBX_BINARY";
35
36const DEFAULT_CACHE_RELATIVE: &str = ".cache/mbx";
37const HOST_CONFIG_RELATIVE: &str = ".config/mbx/config.toml";
38const DEFAULT_MAX_BYTES: u64 = 100_000_000_000;
40const RESOLUTION_LIFETIME: Duration = Duration::from_secs(600);
41const LABEL: &str = "hel-mbx";
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub(super) struct ResolvedBuildCache {
46 pub directory: PathBuf,
48 pub max_size: Option<String>,
51 pub target_root: Option<PathBuf>,
54 pub config_file: Option<String>,
57}
58
59type Resolutions = std::collections::BTreeMap<String, (Instant, Option<ResolvedBuildCache>)>;
63
64static RESOLUTIONS: std::sync::LazyLock<std::sync::Mutex<Resolutions>> =
65 std::sync::LazyLock::new(|| std::sync::Mutex::new(Resolutions::new()));
66
67pub(super) fn resolve(
70 target: &targets::TargetTemplate,
71 global: &BuildCacheConfig,
72 executor: &impl CommandExecutor,
73) -> Option<ResolvedBuildCache> {
74 if !global.enabled {
75 return None;
76 }
77 let (host, settings) = supported_host(target)?;
78 let key = format!("{}|{settings:?}", host.key());
79 if let Some((recorded, resolution)) = RESOLUTIONS.lock().expect("mbx resolutions").get(&key)
80 && recorded.elapsed() < RESOLUTION_LIFETIME
81 {
82 return resolution.clone();
83 }
84 let resolution = match resolve_host(&host, &settings, executor) {
85 Ok(resolution) => resolution,
86 Err(error) => {
87 tracing::warn!(host = key, "build cache unavailable: {error:#}");
88 None
89 }
90 };
91 RESOLUTIONS
92 .lock()
93 .expect("mbx resolutions")
94 .insert(key, (Instant::now(), resolution.clone()));
95 resolution
96}
97
98fn supported_host(target: &targets::TargetTemplate) -> Option<(CacheHost, TargetBuildCache)> {
102 let settings = match target {
103 targets::TargetTemplate::LocalPodman(container)
104 | targets::TargetTemplate::LocalDocker(container)
105 | targets::TargetTemplate::SshPodman { container, .. }
106 | targets::TargetTemplate::SshDocker { container, .. } => {
107 container.build_cache.clone().unwrap_or_default()
108 }
109 targets::TargetTemplate::AppleContainer(_)
110 | targets::TargetTemplate::LocalBare
111 | targets::TargetTemplate::AwsEc2(_)
112 | targets::TargetTemplate::SshBare { .. } => return None,
113 };
114 Some((CacheHost::for_target(target)?, settings))
115}
116
117fn resolve_host(
118 host: &CacheHost,
119 settings: &TargetBuildCache,
120 executor: &impl CommandExecutor,
121) -> Result<Option<ResolvedBuildCache>> {
122 let inspection = inspect_host(host, settings, executor)?;
123 match inspection.cache {
124 Some(cache) => {
125 create_directory(host, &cache.directory, executor)?;
126 Ok(Some(cache))
127 }
128 None => {
129 if let Some(reason) = &inspection.preview.off_reason {
130 tracing::warn!(
131 directory = inspection
132 .preview
133 .directory
134 .as_ref()
135 .map(|d| d.display().to_string()),
136 "sessions on this target run without the build cache: {reason}"
137 );
138 }
139 Ok(None)
140 }
141 }
142}
143
144pub fn preview_build_cache(
148 machine: &mj_core::config::Machine,
149 global: &BuildCacheConfig,
150 executor: &impl CommandExecutor,
151) -> Result<Option<BuildCachePreview>> {
152 let Some(host) = CacheHost::for_machine(machine) else {
153 return Ok(None);
154 };
155 let settings = machine.build_cache().cloned().unwrap_or_default();
156 if !global.enabled {
157 return Ok(Some(BuildCachePreview {
158 native_mbx: None,
159 directory: None,
160 max_size: None,
161 off_reason: Some("the build cache is turned off for every machine".into()),
162 }));
163 }
164 inspect_host(&host, &settings, executor).map(|inspection| Some(inspection.preview))
165}
166
167struct Inspection {
170 preview: BuildCachePreview,
171 cache: Option<ResolvedBuildCache>,
173}
174
175fn inspect_host(
176 host: &CacheHost,
177 settings: &TargetBuildCache,
178 executor: &impl CommandExecutor,
179) -> Result<Inspection> {
180 let native = native_version(host, executor);
181 let native_version = native.as_ref().map(|native| native.version.clone());
182 let off = |preview: BuildCachePreview| Inspection {
183 preview,
184 cache: None,
185 };
186 if let Some(version) = &native_version
187 && !version_at_least(version, MBX_VERSION)
188 {
189 return Ok(off(BuildCachePreview {
190 native_mbx: native_version.clone(),
191 directory: None,
192 max_size: None,
193 off_reason: Some(format!(
194 "the host's mbx {version} is older than the {MBX_VERSION} Mjolnir installs, \
195 so they cannot share a store"
196 )),
197 }));
198 }
199 let directory = match &settings.directory {
200 Some(directory) => directory.clone(),
201 None => match &native {
202 Some(native) => native_cache_directory(host, native, executor)?,
203 None => host.home(executor)?.join(DEFAULT_CACHE_RELATIVE),
204 },
205 };
206 ensure!(
207 directory.is_absolute(),
208 "build cache directory {} is not absolute",
209 directory.display()
210 );
211
212 let config_file = host_config_file(host, executor)?;
213 let target_root = config_file
214 .as_deref()
215 .and_then(|text| relocated_target_root(text, &directory));
216
217 let max_size = match (&settings.max_size, &config_file) {
218 (Some(max_size), _) => Some(max_size.clone()),
219 (None, Some(_)) => None,
221 (None, None) => Some(default_max_size(host, &directory, executor)?),
222 };
223 let limit = match (&max_size, &config_file) {
224 (Some(max_size), _) => BuildCacheLimit::Size(max_size.clone()),
225 (None, Some(text)) => BuildCacheLimit::HostConfiguration(configured_max_size(text)),
226 (None, None) => unreachable!("a missing budget is derived above"),
227 };
228 let preview = |off_reason: Option<String>| BuildCachePreview {
229 native_mbx: native_version.clone(),
230 directory: Some(directory.clone()),
231 max_size: Some(limit.clone()),
232 off_reason,
233 };
234
235 let volume = nearest_existing_ancestor(host, &directory, executor)?;
238 let enabled = match settings.enabled {
239 Some(enabled) => enabled,
240 None => reflinks_supported(host, &volume, executor)?,
241 };
242 if !enabled {
243 let reason = if settings.enabled == Some(false) {
244 "turned off for this target".to_owned()
245 } else {
246 format!(
247 "the filesystem under {} does not support reflinks, so restoring cached \
248 outputs would copy every byte",
249 directory.display()
250 )
251 };
252 return Ok(off(preview(Some(reason))));
253 }
254 if let Some(reason) = unusable_filesystem(host, &volume, executor)? {
255 return Ok(off(preview(Some(format!(
256 "{} is on a {reason}, where mbx's file locks are unreliable",
257 directory.display()
258 )))));
259 }
260
261 Ok(Inspection {
262 preview: preview(None),
263 cache: Some(ResolvedBuildCache {
264 directory,
265 max_size,
266 target_root,
267 config_file,
268 }),
269 })
270}
271
272fn configured_max_size(config_file: &str) -> Option<String> {
274 let document: toml::Value = toml::from_str(config_file).ok()?;
275 document
276 .get("gc")?
277 .get("max_size")?
278 .as_str()
279 .map(str::to_owned)
280}
281
282fn version_at_least(found: &str, required: &str) -> bool {
284 let parse = |text: &str| semver::Version::parse(text.trim()).ok();
285 match (parse(found), parse(required)) {
286 (Some(found), Some(required)) => found >= required,
287 _ => false,
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
294struct NativeMbx {
295 program: String,
296 version: String,
297}
298
299const NATIVE_VERSION_SCRIPT: &str = r#"for m in mbx "$HOME/.cargo/bin/mbx"; do
302 if v=$("$m" --version 2>/dev/null); then
303 printf '%s
304%s' "$m" "$v"
305 exit 0
306 fi
307done
308exit 1"#;
309
310fn native_version(host: &CacheHost, executor: &impl CommandExecutor) -> Option<NativeMbx> {
313 let command = host.shell_command(
314 NATIVE_VERSION_SCRIPT,
315 LABEL,
316 [],
317 "read the container host mbx version",
318 );
319 let output = executor.execute(&command).ok()?;
320 if output.status != 0 {
321 return None;
322 }
323 let text = String::from_utf8_lossy(&output.stdout);
324 let (program, version) = text.trim().split_once('\n')?;
325 Some(NativeMbx {
326 program: program.to_owned(),
327 version: version.split_whitespace().next_back()?.to_owned(),
328 })
329}
330
331fn native_cache_directory(
334 host: &CacheHost,
335 native: &NativeMbx,
336 executor: &impl CommandExecutor,
337) -> Result<PathBuf> {
338 let command = host.command(
339 vec![
340 native.program.clone(),
341 "cache".to_owned(),
342 "dir".to_owned(),
343 "--json".to_owned(),
344 ],
345 "read the container host mbx cache directory",
346 );
347 let output = checked(executor.execute(&command)?, &command)?;
348 let report: serde_json::Value =
349 serde_json::from_slice(&output.stdout).context("parse the mbx cache directory report")?;
350 let store = report
351 .get("store")
352 .and_then(serde_json::Value::as_str)
353 .context("the mbx cache directory report has no store path")?;
354 Path::new(store)
355 .parent()
356 .map(Path::to_path_buf)
357 .with_context(|| format!("mbx store path {store:?} has no parent"))
358}
359
360const READ_CONFIG_SCRIPT: &str = r#"[ -f "$1" ] || exit 3
361cat -- "$1""#;
362
363fn host_config_file(host: &CacheHost, executor: &impl CommandExecutor) -> Result<Option<String>> {
367 let path = host.home(executor)?.join(HOST_CONFIG_RELATIVE);
368 let command = host.shell_command(
369 READ_CONFIG_SCRIPT,
370 LABEL,
371 [path.to_string_lossy().into_owned()],
372 "read the container host mbx configuration",
373 );
374 let output = executor.execute(&command)?;
375 if output.status == 3 {
376 return Ok(None);
377 }
378 let output = checked(output, &command)?;
379 Ok(Some(
380 String::from_utf8(output.stdout).context("decode the host mbx configuration")?,
381 ))
382}
383
384fn relocated_target_root(config_file: &str, directory: &Path) -> Option<PathBuf> {
387 let document: toml::Value = toml::from_str(config_file)
388 .map_err(|error| tracing::warn!("the host mbx configuration is unreadable: {error}"))
389 .ok()?;
390 let root = document.get("target")?.get("root")?.as_str()?;
391 let root = directory.join(root);
392 (!root.starts_with(directory)).then_some(root)
393}
394
395const NEAREST_ANCESTOR_SCRIPT: &str = r#"d=$1
396while [ ! -d "$d" ]; do
397 parent=$(dirname -- "$d")
398 if [ "$parent" = "$d" ]; then
399 break
400 fi
401 d=$parent
402done
403printf '%s' "$d""#;
404
405fn nearest_existing_ancestor(
408 host: &CacheHost,
409 directory: &Path,
410 executor: &impl CommandExecutor,
411) -> Result<PathBuf> {
412 let command = host.shell_command(
413 NEAREST_ANCESTOR_SCRIPT,
414 LABEL,
415 [directory.to_string_lossy().into_owned()],
416 "locate the build cache volume",
417 );
418 let output = checked(executor.execute(&command)?, &command)?;
419 let path = PathBuf::from(String::from_utf8(output.stdout).context("decode cache ancestor")?);
420 ensure!(
421 path.is_absolute(),
422 "build cache volume {} is not absolute",
423 path.display()
424 );
425 Ok(path)
426}
427
428fn default_max_size(
431 host: &CacheHost,
432 directory: &Path,
433 executor: &impl CommandExecutor,
434) -> Result<String> {
435 let volume = nearest_existing_ancestor(host, directory, executor)?;
436 let command = host.command(
437 vec![
438 "df".to_owned(),
439 "-B1".to_owned(),
440 "-P".to_owned(),
441 "--".to_owned(),
442 volume.to_string_lossy().into_owned(),
443 ],
444 "measure the build cache volume",
445 );
446 let output = checked(executor.execute(&command)?, &command)?;
447 let available = available_bytes(&String::from_utf8_lossy(&output.stdout))
448 .context("read the free space on the build cache volume")?;
449 Ok(format!("{}B", DEFAULT_MAX_BYTES.min(available / 4)))
450}
451
452fn available_bytes(report: &str) -> Option<u64> {
456 let row = report
457 .lines()
458 .filter(|line| !line.trim().is_empty())
459 .nth(1)?;
460 let fields = row.split_whitespace().collect::<Vec<_>>();
461 let available = fields.get(fields.len().checked_sub(3)?)?;
463 available.parse().ok()
464}
465
466const REFLINK_SCRIPT: &str = r#"dir=$1
467d=$(mktemp -d "$dir/.mj-reflink.XXXXXX") || exit 1
468printf x > "$d/a" && cp --reflink=always "$d/a" "$d/b"
469status=$?
470rm -rf -- "$d"
471exit $status"#;
472
473fn reflinks_supported(
477 host: &CacheHost,
478 volume: &Path,
479 executor: &impl CommandExecutor,
480) -> Result<bool> {
481 let command = host.shell_command(
482 REFLINK_SCRIPT,
483 LABEL,
484 [volume.to_string_lossy().into_owned()],
485 "probe the build cache volume for reflinks",
486 );
487 Ok(executor.execute(&command)?.status == 0)
488}
489
490fn create_directory(
491 host: &CacheHost,
492 directory: &Path,
493 executor: &impl CommandExecutor,
494) -> Result<()> {
495 let command = host.command(
496 vec![
497 "mkdir".to_owned(),
498 "-p".to_owned(),
499 "--".to_owned(),
500 directory.to_string_lossy().into_owned(),
501 ],
502 "create the build cache directory",
503 );
504 checked(executor.execute(&command)?, &command).map(|_| ())
505}
506
507fn unusable_filesystem(
510 host: &CacheHost,
511 directory: &Path,
512 executor: &impl CommandExecutor,
513) -> Result<Option<&'static str>> {
514 let filesystems =
515 targets::probe_filesystem_types(host.ssh(), &[directory.to_path_buf()], executor)?;
516 let filesystem = filesystems
517 .first()
518 .context("the filesystem probe named no filesystem")?;
519 Ok(targets::overlay_unsupported_filesystem(filesystem)
523 .filter(|reason| matches!(*reason, "network filesystem" | "FUSE filesystem")))
524}
525
526fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
527 if output.status == 0 {
528 return Ok(output);
529 }
530 bail!(
531 "{} failed with status {}: {}",
532 command.purpose,
533 output.status,
534 String::from_utf8_lossy(&output.stderr).trim()
535 )
536}
537
538pub(super) fn binary_for(
543 locator: &targets::TargetLocator,
544 executor: &impl CommandExecutor,
545) -> Result<PathBuf> {
546 let triple = super::worker_binary::target_architecture(locator, executor)?;
547 if let Some(path) = std::env::var_os(MBX_BINARY_ENV) {
548 let path = PathBuf::from(path);
549 ensure!(
550 path.is_file(),
551 "{MBX_BINARY_ENV} does not name a file: {}",
552 path.display()
553 );
554 if triple == host_architecture() {
555 return Ok(path);
556 }
557 tracing::warn!(
558 triple,
559 "{MBX_BINARY_ENV} is for this machine's architecture; downloading the pinned mbx \
560 for the target instead"
561 );
562 }
563 download(triple)
564}
565
566fn host_architecture() -> &'static str {
569 if cfg!(target_arch = "aarch64") {
570 "aarch64"
571 } else {
572 "x86_64"
573 }
574}
575
576fn release_url(triple: &str) -> String {
577 format!(
578 "https://github.com/jdx/mr-boxington/releases/download/v{MBX_VERSION}/mbx-{triple}-unknown-linux-musl.tar.gz"
579 )
580}
581
582fn expected_digest(triple: &str) -> Result<&'static str> {
583 match triple {
584 "x86_64" => Ok(MBX_X86_64_SHA256),
585 "aarch64" => Ok(MBX_AARCH64_SHA256),
586 _ => bail!("no pinned mbx release for {triple}"),
587 }
588}
589
590fn download(triple: &str) -> Result<PathBuf> {
593 let expected = expected_digest(triple)?;
594 let directory = mj_core::config::data_dir()
595 .join("mbx")
596 .join(MBX_VERSION)
597 .join(triple);
598 let destination = directory.join("mbx");
599 if destination.is_file() {
600 return Ok(destination);
601 }
602 std::fs::create_dir_all(&directory)
603 .with_context(|| format!("create the mbx cache {}", directory.display()))?;
604 let url = release_url(triple);
605 let archive = reqwest::blocking::Client::builder()
606 .timeout(Duration::from_secs(120))
607 .build()?
608 .get(&url)
609 .send()
610 .with_context(|| format!("download {url}"))?
611 .error_for_status()
612 .with_context(|| format!("download {url}"))?
613 .bytes()?;
614 let actual = mj_core::hex::lower_hex(Sha256::digest(&archive));
615 ensure!(
616 actual.eq_ignore_ascii_case(expected),
617 "downloaded mbx checksum mismatch: expected {expected}, got {actual}"
618 );
619 let binary = extract_binary(&archive)?;
620 let mut temporary = tempfile::NamedTempFile::new_in(&directory)?;
621 std::io::Write::write_all(&mut temporary, &binary)?;
622 temporary.as_file_mut().sync_all()?;
623 #[cfg(unix)]
624 {
625 use std::os::unix::fs::PermissionsExt;
626 std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o700))?;
627 }
628 match temporary.persist_noclobber(&destination) {
629 Ok(_) => Ok(destination),
630 Err(error) if destination.is_file() => {
631 drop(error);
632 Ok(destination)
633 }
634 Err(error) => Err(error.error)
635 .with_context(|| format!("publish the mbx binary {}", destination.display())),
636 }
637}
638
639fn extract_binary(archive: &[u8]) -> Result<Vec<u8>> {
642 let mut reader = tar::Archive::new(flate2::read::GzDecoder::new(archive));
643 for entry in reader.entries().context("read the mbx release archive")? {
644 let mut entry = entry.context("read the mbx release archive")?;
645 if entry.path().context("read an mbx archive path")?.as_ref() != Path::new("mbx") {
646 continue;
647 }
648 let mut bytes = Vec::new();
649 entry
650 .read_to_end(&mut bytes)
651 .context("read the mbx binary from its release archive")?;
652 return Ok(bytes);
653 }
654 bail!("the mbx release archive contains no mbx binary")
655}
656
657pub(super) fn primary_repository_is_rust(
663 host: &CacheHost,
664 mirror: &Path,
665 executor: &impl CommandExecutor,
666) -> bool {
667 let command = host.command(
668 vec![
669 "git".to_owned(),
670 "--git-dir".to_owned(),
671 mirror.to_string_lossy().into_owned(),
672 "cat-file".to_owned(),
673 "-e".to_owned(),
674 "HEAD:Cargo.toml".to_owned(),
675 ],
676 "detect a Cargo workspace in the session repository",
677 );
678 matches!(executor.execute(&command), Ok(output) if output.status == 0)
679}
680
681pub(super) fn prepare(
685 target: &targets::TargetTemplate,
686 global: &BuildCacheConfig,
687 session: &mj_core::state::SessionRecord,
688 bundle: Option<&targets::ProjectBundleSpec>,
689 clone_cache: Option<&super::git_cache::PreparedCloneCache>,
690 mounts: &mut Vec<targets::AdditionalMount>,
691 executor: &impl CommandExecutor,
692) -> Option<SessionBuildCache> {
693 let host_key = supported_host(target).map(|(host, _)| host.key());
696 if let Some(recorded) = &session.build_cache {
697 if host_key.as_deref() == Some(recorded.host.as_str()) {
698 return attach_mounts(recorded, mounts).then(|| recorded.clone());
699 }
700 tracing::info!(
701 session_id = session.id,
702 recorded_host = recorded.host,
703 host = host_key.as_deref().unwrap_or("unsupported target"),
704 "the session moved to another container host, so its build cache is resolved again"
705 );
706 }
707 session.container_workspace.as_ref()?;
710 let resolved = resolve(target, global, executor)?;
711 let host = supported_host(target)?.0;
712 let mirror = clone_cache?.mirror_for(&bundle?.primary)?;
713 if !primary_repository_is_rust(&host, mirror, executor) {
714 return None;
715 }
716 let build_cache = SessionBuildCache {
717 host: host.key(),
718 directory: resolved.directory,
719 max_size: resolved.max_size,
720 target_root: resolved.target_root,
721 };
722 attach_mounts(&build_cache, mounts).then_some(build_cache)
723}
724
725fn attach_mounts(
729 build_cache: &SessionBuildCache,
730 mounts: &mut Vec<targets::AdditionalMount>,
731) -> bool {
732 let wanted = std::iter::once(&build_cache.directory)
733 .chain(build_cache.target_root.iter())
734 .collect::<Vec<_>>();
735 for directory in &wanted {
736 if mounts.iter().any(|mount| {
737 mount.destination.starts_with(directory) || directory.starts_with(&mount.destination)
738 }) {
739 tracing::warn!(
740 directory = %directory.display(),
741 "an attached directory overlaps the build cache, so this session runs without it"
742 );
743 return false;
744 }
745 }
746 for directory in wanted {
747 mounts.push(targets::AdditionalMount {
748 source: directory.clone(),
749 destination: directory.clone(),
750 access: targets::MountAccess::Rw,
751 });
752 }
753 true
754}
755
756#[cfg(test)]
759pub(super) fn attach_mounts_for_tests(
760 build_cache: &SessionBuildCache,
761 mounts: &mut Vec<targets::AdditionalMount>,
762) -> bool {
763 attach_mounts(build_cache, mounts)
764}
765
766pub(super) fn host_configuration(
769 target: &targets::TargetTemplate,
770 global: &BuildCacheConfig,
771 executor: &impl CommandExecutor,
772) -> Option<String> {
773 resolve(target, global, executor)?.config_file
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use crate::targets::{ContainerTemplate, SshTarget, TargetTemplate};
780 use mj_core::config::ImagePullPolicy;
781 use std::sync::Mutex;
782
783 static ISOLATED: Mutex<()> = Mutex::new(());
786
787 fn isolated() -> std::sync::MutexGuard<'static, ()> {
788 let guard = ISOLATED.lock().unwrap_or_else(|error| error.into_inner());
789 RESOLUTIONS.lock().expect("mbx resolutions").clear();
790 guard
791 }
792
793 #[derive(Default)]
795 struct ProbeExecutor {
796 answers: Vec<(&'static str, i32, String)>,
797 seen: Mutex<Vec<String>>,
798 }
799
800 impl ProbeExecutor {
801 fn new(answers: &[(&'static str, i32, &str)]) -> Self {
802 Self {
803 answers: answers
804 .iter()
805 .map(|(needle, status, stdout)| (*needle, *status, (*stdout).to_owned()))
806 .collect(),
807 seen: Mutex::new(Vec::new()),
808 }
809 }
810
811 fn ran(&self) -> Vec<String> {
812 self.seen.lock().unwrap().clone()
813 }
814 }
815
816 impl CommandExecutor for ProbeExecutor {
817 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
818 let line = format!("{} {}", command.program, command.args.join(" "));
819 self.seen.lock().unwrap().push(line.clone());
820 for (needle, status, stdout) in &self.answers {
821 if line.contains(needle) {
822 return Ok(CommandOutput {
823 status: *status,
824 stdout: stdout.clone().into_bytes(),
825 stderr: Vec::new(),
826 });
827 }
828 }
829 Ok(CommandOutput {
830 status: 127,
831 stdout: Vec::new(),
832 stderr: format!("no canned answer for {line}").into_bytes(),
833 })
834 }
835 }
836
837 fn container(build_cache: Option<TargetBuildCache>) -> ContainerTemplate {
838 ContainerTemplate {
839 image: "example/image:latest".into(),
840 pull_policy: ImagePullPolicy::Missing,
841 extra_run_args: Vec::new(),
842 workspace_storage: Default::default(),
843 build_cache,
844 }
845 }
846
847 fn podman(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
848 TargetTemplate::LocalPodman(container(build_cache))
849 }
850
851 fn docker(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
852 TargetTemplate::LocalDocker(container(build_cache))
853 }
854
855 fn configured_local_machine() -> mj_core::config::Machine {
858 serde_json::from_value(serde_json::json!({"kind": "local"})).unwrap()
859 }
860
861 fn default_cache_directory() -> PathBuf {
864 dirs::home_dir()
865 .expect("a home directory")
866 .join(DEFAULT_CACHE_RELATIVE)
867 }
868
869 fn plain_host() -> Vec<(&'static str, i32, &'static str)> {
872 vec![
873 ("$m\" --version", 1, ""),
874 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
875 ("[ -f \"$1\" ]", 3, ""),
876 ("while [ ! -d", 0, "/home/dev"),
877 (
878 "df -B1 -P",
879 0,
880 "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 1000000000000 0 800000000000 20% /home\n",
881 ),
882 ("mj-reflink", 0, ""),
883 ("mkdir -p", 0, ""),
884 ("stat -f -c %T", 0, "xfs"),
885 ]
886 }
887
888 #[test]
889 fn a_native_mbx_supplies_the_cache_directory_and_its_own_limits() {
890 let _isolated = isolated();
891 let executor = ProbeExecutor::new(&[
892 ("$m\" --version", 0, "mbx\nmbx 1.12.0"),
893 (
894 "mbx cache dir --json",
895 0,
896 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
897 ),
898 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
899 (
900 "[ -f \"$1\" ]",
901 0,
902 "cache_dir = \"/mnt/fast/mbx-cache\"\n[gc]\nmax_size = \"500GiB\"\n",
903 ),
904 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
905 ("mj-reflink", 0, ""),
906 ("mkdir -p", 0, ""),
907 ("stat -f -c %T", 0, "xfs"),
908 ]);
909 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
910 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
911 assert_eq!(resolved.max_size, None);
913 assert_eq!(resolved.target_root, None);
914 assert!(resolved.config_file.unwrap().contains("500GiB"));
915 assert!(
916 !executor.ran().iter().any(|line| line.contains("df -B1")),
917 "a host with its own configuration is not measured"
918 );
919 }
920
921 #[test]
922 fn a_relocated_target_root_is_reported_for_its_own_mount() {
923 let _isolated = isolated();
924 let executor = ProbeExecutor::new(&[
925 ("$m\" --version", 0, "mbx\nmbx 1.12.0"),
926 (
927 "mbx cache dir --json",
928 0,
929 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
930 ),
931 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
932 (
933 "[ -f \"$1\" ]",
934 0,
935 "[target]\nroot = \"/mnt/fast/mbx-targets\"\n",
936 ),
937 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
938 ("mj-reflink", 0, ""),
939 ("mkdir -p", 0, ""),
940 ("stat -f -c %T", 0, "xfs"),
941 ]);
942 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
943 assert_eq!(
944 resolved.target_root,
945 Some(PathBuf::from("/mnt/fast/mbx-targets"))
946 );
947 }
948
949 #[test]
950 fn a_target_root_inside_the_cache_directory_needs_no_second_mount() {
951 assert_eq!(
952 relocated_target_root("[target]\nroot = \"targets\"\n", Path::new("/cache")),
953 None
954 );
955 assert_eq!(
956 relocated_target_root("[target]\nroot = \"/cache/targets\"\n", Path::new("/cache")),
957 None
958 );
959 }
960
961 #[test]
962 fn a_cargo_installed_mbx_off_the_path_is_queried_where_it_was_found() {
963 let _isolated = isolated();
964 let mut answers = plain_host();
965 answers.retain(|(needle, _, _)| *needle != "$m\" --version");
966 answers.push(("$m\" --version", 0, "/home/dev/.cargo/bin/mbx\nmbx 1.12.0"));
967 answers.push((
968 "/home/dev/.cargo/bin/mbx cache dir --json",
969 0,
970 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
971 ));
972 let executor = ProbeExecutor::new(&answers);
973 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
974 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
975 }
976
977 #[test]
978 fn an_older_native_mbx_must_not_share_the_store() {
979 let _isolated = isolated();
980 let executor = ProbeExecutor::new(&[("$m\" --version", 0, "mbx\nmbx 1.11.9")]);
981 assert_eq!(
982 resolve(&podman(None), &BuildCacheConfig::default(), &executor),
983 None
984 );
985 }
986
987 #[test]
988 fn a_host_without_mbx_falls_back_to_the_default_cache_directory() {
989 let _isolated = isolated();
990 let executor = ProbeExecutor::new(&plain_host());
991 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
992 assert_eq!(resolved.directory, default_cache_directory());
993 assert_eq!(resolved.max_size.as_deref(), Some("100000000000B"));
995 }
996
997 #[test]
998 fn a_small_volume_takes_a_quarter_of_its_free_space() {
999 let _isolated = isolated();
1000 let mut answers = plain_host();
1001 answers.retain(|(needle, _, _)| *needle != "df -B1 -P");
1002 answers.push((
1003 "df -B1 -P",
1004 0,
1005 "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 100000000 60000000 40000000 60% /home\n",
1006 ));
1007 let executor = ProbeExecutor::new(&answers);
1008 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1009 assert_eq!(resolved.max_size.as_deref(), Some("10000000B"));
1010 }
1011
1012 #[test]
1013 fn target_overrides_win_over_every_default() {
1014 let _isolated = isolated();
1015 let mut answers = plain_host();
1016 answers.push(("mbx cache dir", 0, r#"{"store":"/other/actions"}"#));
1017 let executor = ProbeExecutor::new(&answers);
1018 let resolved = resolve(
1019 &podman(Some(TargetBuildCache {
1020 enabled: Some(true),
1021 directory: Some(PathBuf::from("/mnt/nvme/mbx")),
1022 max_size: Some("250GiB".into()),
1023 })),
1024 &BuildCacheConfig::default(),
1025 &executor,
1026 )
1027 .unwrap();
1028 assert_eq!(resolved.directory, PathBuf::from("/mnt/nvme/mbx"));
1029 assert_eq!(resolved.max_size.as_deref(), Some("250GiB"));
1030 assert!(
1031 !executor
1032 .ran()
1033 .iter()
1034 .any(|line| line.contains("mj-reflink")),
1035 "an explicit enabled setting skips the reflink probe"
1036 );
1037 }
1038
1039 #[test]
1040 fn a_volume_without_reflinks_runs_without_the_cache() {
1041 let _isolated = isolated();
1042 let mut answers = plain_host();
1043 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1044 answers.push(("mj-reflink", 1, ""));
1045 let executor = ProbeExecutor::new(&answers);
1046 assert_eq!(
1047 resolve(&podman(None), &BuildCacheConfig::default(), &executor),
1048 None
1049 );
1050 }
1051
1052 #[test]
1053 fn the_preview_names_the_resolved_values_and_the_reason_the_cache_is_off() {
1054 let _isolated = isolated();
1055 let mut answers = plain_host();
1056 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1057 answers.push(("mj-reflink", 1, ""));
1058 let executor = ProbeExecutor::new(&answers);
1059 let preview = preview_build_cache(
1060 &configured_local_machine(),
1061 &BuildCacheConfig::default(),
1062 &executor,
1063 )
1064 .unwrap()
1065 .unwrap();
1066 assert_eq!(preview.native_mbx, None);
1067 assert_eq!(preview.directory, Some(default_cache_directory()));
1068 assert_eq!(
1069 preview.max_size,
1070 Some(BuildCacheLimit::Size("100000000000B".into()))
1071 );
1072 assert!(
1073 preview
1074 .off_reason
1075 .as_deref()
1076 .is_some_and(|reason| reason.contains("reflinks")),
1077 "{:?}",
1078 preview.off_reason
1079 );
1080 assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1082
1083 let executor = ProbeExecutor::new(&[
1084 ("$m\" --version", 0, "mbx\nmbx 1.12.0"),
1085 (
1086 "mbx cache dir --json",
1087 0,
1088 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1089 ),
1090 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1091 ("[ -f \"$1\" ]", 0, "[gc]\nmax_size = \"500GiB\"\n"),
1092 ("while [ ! -d", 0, "/mnt/fast"),
1093 ("mj-reflink", 0, ""),
1094 ("stat -f -c %T", 0, "xfs"),
1095 ]);
1096 let preview = preview_build_cache(
1097 &configured_local_machine(),
1098 &BuildCacheConfig::default(),
1099 &executor,
1100 )
1101 .unwrap()
1102 .unwrap();
1103 assert_eq!(preview.native_mbx.as_deref(), Some("1.12.0"));
1104 assert_eq!(
1105 preview.directory,
1106 Some(PathBuf::from("/mnt/fast/mbx-cache"))
1107 );
1108 assert_eq!(
1109 preview.max_size,
1110 Some(BuildCacheLimit::HostConfiguration(Some("500GiB".into())))
1111 );
1112 assert_eq!(preview.off_reason, None);
1113 assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1114 }
1115
1116 #[test]
1117 fn a_network_filesystem_runs_without_the_cache() {
1118 let _isolated = isolated();
1119 let mut answers = plain_host();
1120 answers.retain(|(needle, _, _)| *needle != "stat -f -c %T");
1121 answers.push(("stat -f -c %T", 0, "nfs4"));
1122 let executor = ProbeExecutor::new(&answers);
1123 assert_eq!(
1124 resolve(&podman(None), &BuildCacheConfig::default(), &executor),
1125 None
1126 );
1127 }
1128
1129 #[test]
1130 fn the_global_switch_short_circuits_every_host_command() {
1131 let _isolated = isolated();
1132 let executor = ProbeExecutor::new(&plain_host());
1133 assert_eq!(
1134 resolve(
1135 &podman(None),
1136 &BuildCacheConfig { enabled: false },
1137 &executor
1138 ),
1139 None
1140 );
1141 assert!(executor.ran().is_empty());
1142 }
1143
1144 #[test]
1145 fn local_podman_and_local_docker_inspect_one_machine_once() {
1146 let _isolated = isolated();
1147 let executor = ProbeExecutor::new(&plain_host());
1148 let settings = BuildCacheConfig::default();
1149 let first = resolve(&podman(None), &settings, &executor).unwrap();
1150 let ran = executor.ran().len();
1151 assert!(ran > 0, "the first resolve inspects the host");
1152 let second = resolve(&docker(None), &settings, &executor).unwrap();
1153 assert_eq!(
1154 first, second,
1155 "both engines on this machine share one cache"
1156 );
1157 assert_eq!(
1158 executor.ran().len(),
1159 ran,
1160 "the second runtime is answered from the machine's recorded inspection: {:?}",
1161 executor.ran()
1162 );
1163 }
1164
1165 #[test]
1166 fn a_machine_without_a_standing_host_has_no_build_cache_preview() {
1167 let _isolated = isolated();
1168 let executor = ProbeExecutor::new(&plain_host());
1169 let fleet: mj_core::config::Machine = serde_json::from_value(serde_json::json!({
1170 "kind": "aws-ec2",
1171 "region": "us-east-1",
1172 "launch_template": "lt-1",
1173 "ssh_user": "ubuntu",
1174 }))
1175 .unwrap();
1176 assert_eq!(
1177 preview_build_cache(&fleet, &BuildCacheConfig::default(), &executor).unwrap(),
1178 None
1179 );
1180 assert!(executor.ran().is_empty());
1181 }
1182
1183 #[test]
1184 fn apple_and_bare_targets_have_no_shared_build_cache() {
1185 let _isolated = isolated();
1186 let executor = ProbeExecutor::new(&plain_host());
1187 for target in [
1188 TargetTemplate::AppleContainer(container(None)),
1189 TargetTemplate::LocalBare,
1190 TargetTemplate::SshBare {
1191 ssh: SshTarget {
1192 destination: "dev@example.test".into(),
1193 ssh_args: Vec::new(),
1194 },
1195 workspace_prefix: "workspaces".into(),
1196 },
1197 ] {
1198 assert_eq!(
1199 resolve(&target, &BuildCacheConfig::default(), &executor),
1200 None,
1201 "{target:?}"
1202 );
1203 }
1204 assert!(executor.ran().is_empty());
1205 }
1206
1207 fn bundle() -> targets::ProjectBundleSpec {
1208 targets::ProjectBundleSpec {
1209 primary: "main".into(),
1210 repositories: vec![targets::RepositorySpec {
1211 url: Some("https://github.com/example/main.git".into()),
1212 push_urls: Vec::new(),
1213 destination: "main".into(),
1214 git_ref: None,
1215 reference: None,
1216 }],
1217 }
1218 }
1219
1220 fn clone_cache() -> super::super::git_cache::PreparedCloneCache {
1221 super::super::git_cache::PreparedCloneCache::from_mirrors(
1222 [(
1223 "main".to_owned(),
1224 PathBuf::from("/home/dev/mirror/repo.git"),
1225 )]
1226 .into_iter()
1227 .collect(),
1228 )
1229 }
1230
1231 fn session(container_workspace: Option<&str>) -> mj_core::state::SessionRecord {
1232 let mut record = crate::controller::test_support::checkpoint_test_session("session-1");
1233 record.container_workspace = container_workspace.map(PathBuf::from);
1234 record
1235 }
1236
1237 #[test]
1238 fn a_rust_session_mounts_the_cache_at_the_host_path() {
1239 let _isolated = isolated();
1240 let mut answers = plain_host();
1241 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1242 let executor = ProbeExecutor::new(&answers);
1243 let mut mounts = Vec::new();
1244 let build_cache = prepare(
1245 &podman(None),
1246 &BuildCacheConfig::default(),
1247 &session(Some("/workspace/session-1")),
1248 Some(&bundle()),
1249 Some(&clone_cache()),
1250 &mut mounts,
1251 &executor,
1252 )
1253 .expect("a Rust session uses the build cache");
1254 assert_eq!(build_cache.directory, default_cache_directory());
1255 assert_eq!(
1256 mounts,
1257 vec![targets::AdditionalMount {
1258 source: default_cache_directory(),
1259 destination: default_cache_directory(),
1260 access: targets::MountAccess::Rw,
1261 }]
1262 );
1263 }
1264
1265 #[test]
1266 fn a_repository_without_a_root_manifest_runs_without_the_cache() {
1267 let _isolated = isolated();
1268 let mut answers = plain_host();
1269 answers.push(("cat-file -e HEAD:Cargo.toml", 1, ""));
1270 let executor = ProbeExecutor::new(&answers);
1271 let mut mounts = Vec::new();
1272 assert_eq!(
1273 prepare(
1274 &podman(None),
1275 &BuildCacheConfig::default(),
1276 &session(Some("/workspace/session-1")),
1277 Some(&bundle()),
1278 Some(&clone_cache()),
1279 &mut mounts,
1280 &executor,
1281 ),
1282 None
1283 );
1284 assert!(mounts.is_empty());
1285 }
1286
1287 #[test]
1288 fn a_session_at_the_legacy_shared_workspace_runs_without_the_cache() {
1289 let _isolated = isolated();
1290 let mut answers = plain_host();
1291 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1292 let executor = ProbeExecutor::new(&answers);
1293 let mut mounts = Vec::new();
1294 assert_eq!(
1295 prepare(
1296 &podman(None),
1297 &BuildCacheConfig::default(),
1298 &session(None),
1299 Some(&bundle()),
1300 Some(&clone_cache()),
1301 &mut mounts,
1302 &executor,
1303 ),
1304 None
1305 );
1306 assert!(executor.ran().is_empty());
1307 }
1308
1309 #[test]
1310 fn a_session_without_a_prepared_clone_cache_runs_without_the_cache() {
1311 let _isolated = isolated();
1312 let mut answers = plain_host();
1313 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1314 let executor = ProbeExecutor::new(&answers);
1315 let mut mounts = Vec::new();
1316 assert_eq!(
1317 prepare(
1318 &podman(None),
1319 &BuildCacheConfig::default(),
1320 &session(Some("/workspace/session-1")),
1321 Some(&bundle()),
1322 None,
1323 &mut mounts,
1324 &executor,
1325 ),
1326 None
1327 );
1328 }
1329
1330 #[test]
1331 fn an_apple_target_never_shares_a_build_cache() {
1332 let _isolated = isolated();
1333 let mut answers = plain_host();
1334 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1335 let executor = ProbeExecutor::new(&answers);
1336 let mut mounts = Vec::new();
1337 assert_eq!(
1338 prepare(
1339 &TargetTemplate::AppleContainer(container(None)),
1340 &BuildCacheConfig::default(),
1341 &session(Some("/workspace/session-1")),
1342 Some(&bundle()),
1343 Some(&clone_cache()),
1344 &mut mounts,
1345 &executor,
1346 ),
1347 None
1348 );
1349 assert!(executor.ran().is_empty());
1350 }
1351
1352 #[test]
1353 fn a_resumed_session_reuses_its_recorded_cache_without_resolving_again() {
1354 let _isolated = isolated();
1355 let executor = ProbeExecutor::new(&[]);
1356 let mut record = session(Some("/workspace/session-1"));
1357 record.build_cache = Some(SessionBuildCache {
1358 host: "local".into(),
1359 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1360 max_size: None,
1361 target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
1362 });
1363 let mut mounts = Vec::new();
1364 let build_cache = prepare(
1365 &podman(None),
1366 &BuildCacheConfig::default(),
1367 &record,
1368 None,
1369 None,
1370 &mut mounts,
1371 &executor,
1372 )
1373 .expect("a resumed session keeps its build cache");
1374 assert_eq!(build_cache, record.build_cache.unwrap());
1375 assert_eq!(
1376 mounts
1377 .iter()
1378 .map(|mount| mount.destination.clone())
1379 .collect::<Vec<_>>(),
1380 vec![
1381 PathBuf::from("/mnt/fast/mbx-cache"),
1382 PathBuf::from("/mnt/fast/mbx-targets"),
1383 ]
1384 );
1385 assert!(executor.ran().is_empty());
1386 }
1387
1388 #[test]
1389 fn a_session_moved_to_another_host_resolves_its_build_cache_again() {
1390 let _isolated = isolated();
1391 let mut answers = plain_host();
1392 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1393 let executor = ProbeExecutor::new(&answers);
1394 let mut record = session(Some("/workspace/session-1"));
1395 record.build_cache = Some(SessionBuildCache {
1396 host: "ssh:dev@example.test".into(),
1399 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1400 max_size: None,
1401 target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
1402 });
1403 let mut mounts = Vec::new();
1404
1405 let build_cache = prepare(
1406 &podman(None),
1407 &BuildCacheConfig::default(),
1408 &record,
1409 Some(&bundle()),
1410 Some(&clone_cache()),
1411 &mut mounts,
1412 &executor,
1413 )
1414 .expect("the destination host qualifies on its own");
1415
1416 assert_eq!(build_cache.host, "local");
1417 assert_eq!(build_cache.directory, default_cache_directory());
1418 assert_eq!(build_cache.target_root, None);
1419 assert_eq!(
1420 mounts
1421 .iter()
1422 .map(|mount| mount.destination.clone())
1423 .collect::<Vec<_>>(),
1424 vec![default_cache_directory()]
1425 );
1426 assert!(
1427 executor
1428 .ran()
1429 .iter()
1430 .any(|line| line.contains("mj-reflink"))
1431 );
1432 }
1433
1434 #[test]
1435 fn an_attached_directory_over_the_cache_wins() {
1436 let build_cache = SessionBuildCache {
1437 host: "local-podman".into(),
1438 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1439 max_size: None,
1440 target_root: None,
1441 };
1442 let mut mounts = vec![targets::AdditionalMount {
1443 source: PathBuf::from("/elsewhere"),
1444 destination: PathBuf::from("/mnt/fast/mbx-cache/actions"),
1445 access: targets::MountAccess::Ro,
1446 }];
1447 assert!(!attach_mounts(&build_cache, &mut mounts));
1448 assert_eq!(mounts.len(), 1);
1449 }
1450
1451 #[test]
1452 fn versions_compare_by_release_order() {
1453 assert!(version_at_least("1.12.0", "1.12.0"));
1454 assert!(version_at_least("1.12.1", "1.12.0"));
1455 assert!(version_at_least("2.0.0", "1.12.0"));
1456 assert!(!version_at_least("1.11.9", "1.12.0"));
1457 assert!(!version_at_least("1.9.0", "1.12.0"));
1458 assert!(!version_at_least("not-a-version", "1.12.0"));
1459 }
1460
1461 #[test]
1462 fn free_space_is_read_from_the_available_column() {
1463 assert_eq!(
1464 available_bytes(
1465 "Filesystem 1B-blocks Used Available Capacity Mounted on\n\
1466 /dev/sda1 1000 400 600 40% /\n"
1467 ),
1468 Some(600)
1469 );
1470 assert_eq!(available_bytes("Filesystem 1B-blocks\n"), None);
1471 }
1472}