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, Config, TargetBuildCache, TargetTemplate};
23use mj_core::state::{
24 BuildCacheLimit, BuildCacheOff, BuildCachePreview, BuildCacheStats, SessionBuildCache,
25};
26
27pub(crate) const MBX_VERSION: &str = "1.16.0";
30
31const MBX_X86_64_SHA256: &str = "be74eb96c62e774d90e8e036ed3d5541bde682802b11dfb1bf340d57276f5ab1";
32const MBX_AARCH64_SHA256: &str = "01cce632e7bacacd78935226e778c5199f6942a55789645eb3e56c662f448792";
33
34const MBX_BINARY_ENV: &str = "MJ_MBX_BINARY";
37
38const DEFAULT_CACHE_RELATIVE: &str = ".cache/mbx";
39const HOST_CONFIG_RELATIVE: &str = ".config/mbx/config.toml";
40const TALLY_RELATIVE: &str = "actions/savings/v1/tally.json";
42const DEFAULT_MAX_BYTES: u64 = 100_000_000_000;
44const RESOLUTION_LIFETIME: Duration = Duration::from_secs(600);
45const LABEL: &str = "hel-mbx";
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub(super) struct ResolvedBuildCache {
50 pub directory: PathBuf,
52 pub max_size: Option<String>,
57 pub target_root: Option<PathBuf>,
60 pub config_file: Option<String>,
63}
64
65type Resolutions = std::collections::BTreeMap<String, (Instant, Result<Inspection, String>)>;
70
71static RESOLUTIONS: std::sync::LazyLock<std::sync::Mutex<Resolutions>> =
72 std::sync::LazyLock::new(|| std::sync::Mutex::new(Resolutions::new()));
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum Freshness {
78 Memoized,
80 Fresh,
84}
85
86fn inspect(
90 host: &CacheHost,
91 settings: &TargetBuildCache,
92 global: &BuildCacheConfig,
93 freshness: Freshness,
94 executor: &impl CommandExecutor,
95) -> Result<Inspection> {
96 if !global.enabled {
99 return Ok(Inspection {
100 preview: BuildCachePreview {
101 native_mbx: None,
102 directory: None,
103 max_size: None,
104 stats: None,
105 off_reason: Some(BuildCacheOff::Unavailable(
106 "the build cache is turned off for every machine".into(),
107 )),
108 },
109 cache: None,
110 });
111 }
112 let key = format!("{}|{settings:?}", host.key());
113 if freshness == Freshness::Memoized
114 && let Some((recorded, inspection)) = RESOLUTIONS.lock().expect("mbx resolutions").get(&key)
115 && recorded.elapsed() < RESOLUTION_LIFETIME
116 {
117 return inspection.clone().map_err(|error| anyhow::anyhow!(error));
118 }
119 let inspection = inspect_host(host, settings, executor);
120 let recorded = match &inspection {
121 Ok(inspection) => Ok(inspection.clone()),
122 Err(error) => Err(format!("{error:#}")),
123 };
124 RESOLUTIONS
125 .lock()
126 .expect("mbx resolutions")
127 .insert(key, (Instant::now(), recorded));
128 inspection
129}
130
131pub(super) fn resolve(
134 target: &targets::TargetTemplate,
135 global: &BuildCacheConfig,
136 executor: &impl CommandExecutor,
137) -> Option<ResolvedBuildCache> {
138 let (host, settings) = supported_host(target)?;
139 let inspection = match inspect(&host, &settings, global, Freshness::Memoized, executor) {
140 Ok(inspection) => inspection,
141 Err(error) => {
142 tracing::warn!(host = host.key(), "build cache unavailable: {error:#}");
143 return None;
144 }
145 };
146 let Some(cache) = inspection.cache else {
147 if let Some(reason) = &inspection.preview.off_reason {
148 tracing::warn!(
149 directory = inspection
150 .preview
151 .directory
152 .as_ref()
153 .map(|directory| directory.display().to_string()),
154 "sessions on this target run without the build cache: {reason}"
155 );
156 }
157 return None;
158 };
159 if let Err(error) = create_directory(&host, &cache.directory, executor) {
164 tracing::warn!(
165 directory = %cache.directory.display(),
166 "the build cache directory could not be created: {error:#}"
167 );
168 return None;
169 }
170 Some(cache)
171}
172
173fn supported_host(target: &targets::TargetTemplate) -> Option<(CacheHost, TargetBuildCache)> {
177 let settings = match target {
178 targets::TargetTemplate::LocalPodman(container)
179 | targets::TargetTemplate::LocalDocker(container)
180 | targets::TargetTemplate::SshPodman { container, .. }
181 | targets::TargetTemplate::SshDocker { container, .. } => {
182 container.build_cache.clone().unwrap_or_default()
183 }
184 targets::TargetTemplate::AppleContainer(_)
185 | targets::TargetTemplate::LocalBare
186 | targets::TargetTemplate::AwsEc2(_)
187 | targets::TargetTemplate::SshBare { .. } => return None,
188 };
189 Some((CacheHost::for_target(target)?, settings))
190}
191
192pub fn preview_build_cache(
196 machine: &mj_core::config::Machine,
197 global: &BuildCacheConfig,
198 executor: &impl CommandExecutor,
199) -> Result<Option<BuildCachePreview>> {
200 let Some(host) = CacheHost::for_machine(machine) else {
201 return Ok(None);
202 };
203 let settings = machine.build_cache().cloned().unwrap_or_default();
204 inspect(&host, &settings, global, Freshness::Fresh, executor)
205 .map(|inspection| Some(inspection.preview))
206}
207
208pub(crate) struct DoctorHostMbx {
210 pub host: String,
211 pub targets: Vec<String>,
212 pub status: DoctorHostMbxStatus,
213}
214
215pub(crate) enum DoctorHostMbxStatus {
216 Absent,
217 Compatible(String),
218 TooOld(String),
219 Unknown(String),
220}
221
222pub(crate) fn doctor_host_mbx(
225 config: &Config,
226 executor: &impl CommandExecutor,
227) -> Vec<DoctorHostMbx> {
228 let mut hosts: std::collections::BTreeMap<String, (CacheHost, Vec<String>)> =
229 std::collections::BTreeMap::new();
230 let mut checks = Vec::new();
231 if !config.build_cache.enabled {
232 return checks;
233 }
234 for (id, target) in &config.targets {
235 let container = match target {
236 TargetTemplate::LocalPodman { container }
237 | TargetTemplate::LocalDocker { container }
238 | TargetTemplate::SshPodman { container, .. }
239 | TargetTemplate::SshDocker { container, .. } => container,
240 _ => continue,
241 };
242 if container
243 .build_cache
244 .as_ref()
245 .and_then(|cache| cache.enabled)
246 == Some(false)
247 {
248 continue;
249 }
250 match CacheHost::for_path_target(target) {
251 Ok(host) => {
252 let key = host.key();
253 hosts
254 .entry(key)
255 .or_insert_with(|| (host, Vec::new()))
256 .1
257 .push(id.clone());
258 }
259 Err(error) => checks.push(DoctorHostMbx {
260 host: id.clone(),
261 targets: vec![id.clone()],
262 status: DoctorHostMbxStatus::Unknown(format!("{error:#}")),
263 }),
264 }
265 }
266 checks.extend(hosts.into_iter().map(|(key, (host, targets))| {
267 let status = match probe_native_version(&host, executor) {
268 Ok(None) => DoctorHostMbxStatus::Absent,
269 Ok(Some(native)) if semver::Version::parse(&native.version).is_err() => {
270 DoctorHostMbxStatus::Unknown(format!(
271 "the host reported an unrecognized mbx version {:?}",
272 native.version
273 ))
274 }
275 Ok(Some(native)) if version_at_least(&native.version, MBX_VERSION) => {
276 DoctorHostMbxStatus::Compatible(native.version)
277 }
278 Ok(Some(native)) => DoctorHostMbxStatus::TooOld(native.version),
279 Err(error) => DoctorHostMbxStatus::Unknown(format!("{error:#}")),
280 };
281 DoctorHostMbx {
282 host: key,
283 targets,
284 status,
285 }
286 }));
287 checks
288}
289
290#[derive(Clone)]
293struct Inspection {
294 preview: BuildCachePreview,
295 cache: Option<ResolvedBuildCache>,
297}
298
299fn inspect_host(
300 host: &CacheHost,
301 settings: &TargetBuildCache,
302 executor: &impl CommandExecutor,
303) -> Result<Inspection> {
304 let native = native_version(host, executor);
305 let native_version = native.as_ref().map(|native| native.version.clone());
306 let off = |preview: BuildCachePreview| Inspection {
307 preview,
308 cache: None,
309 };
310 if let Some(version) = &native_version
311 && !version_at_least(version, MBX_VERSION)
312 {
313 return Ok(off(BuildCachePreview {
314 native_mbx: native_version.clone(),
315 directory: None,
316 max_size: None,
317 stats: None,
318 off_reason: Some(BuildCacheOff::Unavailable(format!(
319 "the host's mbx {version} is older than the {MBX_VERSION} Mjolnir installs, \
320 so they cannot share a store"
321 ))),
322 }));
323 }
324 let directory = match &settings.directory {
325 Some(directory) => directory.clone(),
326 None => match &native {
327 Some(native) => native_cache_directory(host, native, executor)?,
328 None => host.home(executor)?.join(DEFAULT_CACHE_RELATIVE),
329 },
330 };
331 ensure!(
332 directory.is_absolute(),
333 "build cache directory {} is not absolute",
334 directory.display()
335 );
336
337 let config_file = host_config_file(host, executor)?;
338 let target_root = config_file
339 .as_deref()
340 .and_then(|text| relocated_target_root(text, &directory));
341
342 let max_size = match (&settings.max_size, &config_file) {
343 (Some(max_size), _) => Some(max_size.clone()),
344 (None, Some(_)) => None,
346 (None, None) => Some(default_max_size(host, &directory, executor)?),
347 };
348 let limit = match (&max_size, &config_file) {
349 (Some(max_size), _) => BuildCacheLimit::Size(max_size.clone()),
350 (None, Some(text)) => BuildCacheLimit::HostConfiguration(configured_max_size(text)),
351 (None, None) => unreachable!("a missing budget is derived above"),
352 };
353 let stats = read_stats(host, &directory, executor);
356 let preview = |off_reason: Option<BuildCacheOff>| BuildCachePreview {
357 native_mbx: native_version.clone(),
358 directory: Some(directory.clone()),
359 max_size: Some(limit.clone()),
360 stats: stats.clone(),
361 off_reason,
362 };
363
364 let volume = nearest_existing_ancestor(host, &directory, executor)?;
367 if !settings.enabled.unwrap_or(true) {
370 return Ok(off(preview(Some(BuildCacheOff::TurnedOff))));
371 }
372 if !reflinks_supported(host, &volume, executor)? {
373 return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
374 "the filesystem under {} does not support reflinks, so restoring cached \
375 outputs would copy every byte",
376 directory.display()
377 ))))));
378 }
379 if let Some(reason) = unusable_filesystem(host, &volume, executor)? {
380 return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
381 "{} is on a {reason}, where mbx's file locks are unreliable",
382 directory.display()
383 ))))));
384 }
385
386 if let Some(root) = &target_root {
392 let root_volume = nearest_existing_ancestor(host, root, executor)?;
393 if !cross_reflinks_supported(host, &volume, &root_volume, executor)? {
394 tracing::warn!(
395 cache = %directory.display(),
396 target_root = %root.display(),
397 "the host's mbx target root does not share a mount with the build cache, \
398 so restoring a cached output copies every byte instead of cloning it"
399 );
400 }
401 }
402
403 Ok(Inspection {
404 preview: preview(None),
405 cache: Some(ResolvedBuildCache {
406 directory,
407 max_size,
408 target_root,
409 config_file,
410 }),
411 })
412}
413
414fn configured_max_size(config_file: &str) -> Option<String> {
419 let document: toml::Value = toml::from_str(config_file).ok()?;
420 let gc = document.get("gc")?;
421 gc.get("max_total_size")
422 .or_else(|| gc.get("max_size"))?
423 .as_str()
424 .map(str::to_owned)
425}
426
427fn read_stats(
436 host: &CacheHost,
437 directory: &Path,
438 executor: &impl CommandExecutor,
439) -> Option<BuildCacheStats> {
440 #[derive(Default, serde::Deserialize)]
441 #[serde(default)]
442 struct Tally {
443 builds: u64,
444 cached_compilations: u64,
445 avoided_compiler_ns: u64,
446 reflinked_bytes: u64,
447 }
448
449 let path = directory.join(TALLY_RELATIVE);
450 let command = host.shell_command(
451 READ_CONFIG_SCRIPT,
452 LABEL,
453 [path.to_string_lossy().into_owned()],
454 "read the container host build cache totals",
455 );
456 let output = executor.execute(&command).ok()?;
457 if output.status != 0 {
458 return None;
459 }
460 let tally: Tally = serde_json::from_slice(&output.stdout)
463 .inspect_err(|error| {
464 tracing::debug!(
465 path = %path.display(),
466 "the build cache totals could not be read: {error}"
467 );
468 })
469 .ok()?;
470 Some(BuildCacheStats {
471 builds: tally.builds,
472 cached_compilations: tally.cached_compilations,
473 avoided_compiler_ns: tally.avoided_compiler_ns,
474 reflinked_bytes: tally.reflinked_bytes,
475 })
476}
477
478fn version_at_least(found: &str, required: &str) -> bool {
480 let parse = |text: &str| semver::Version::parse(text.trim()).ok();
481 match (parse(found), parse(required)) {
482 (Some(found), Some(required)) => found >= required,
483 _ => false,
485 }
486}
487
488#[derive(Debug, Clone, PartialEq, Eq)]
490struct NativeMbx {
491 program: String,
492 version: String,
493}
494
495const NATIVE_VERSION_SCRIPT: &str = r#"for m in mbx "$HOME/.cargo/bin/mbx"; do
498 if v=$("$m" --version 2>/dev/null); then
499 printf '%s
500%s' "$m" "$v"
501 exit 0
502 fi
503done
504exit 1"#;
505
506fn native_version(host: &CacheHost, executor: &impl CommandExecutor) -> Option<NativeMbx> {
509 probe_native_version(host, executor).ok().flatten()
510}
511
512fn probe_native_version(
513 host: &CacheHost,
514 executor: &impl CommandExecutor,
515) -> Result<Option<NativeMbx>> {
516 let command = host.shell_command(
517 NATIVE_VERSION_SCRIPT,
518 LABEL,
519 [],
520 "read the container host mbx version",
521 );
522 let output = executor.execute(&command)?;
523 if output.status == 1 {
524 return Ok(None);
525 }
526 ensure!(
527 output.status == 0,
528 "mbx version probe exited with status {}",
529 output.status
530 );
531 let text = String::from_utf8_lossy(&output.stdout);
532 let (program, version) = text
533 .trim()
534 .split_once('\n')
535 .context("mbx version probe gave no version")?;
536 let version = version
537 .split_whitespace()
538 .next_back()
539 .context("mbx version probe gave an empty version")?;
540 Ok(Some(NativeMbx {
541 program: program.to_owned(),
542 version: version.to_owned(),
543 }))
544}
545
546fn native_cache_directory(
549 host: &CacheHost,
550 native: &NativeMbx,
551 executor: &impl CommandExecutor,
552) -> Result<PathBuf> {
553 let command = host.command(
554 vec![
555 native.program.clone(),
556 "cache".to_owned(),
557 "dir".to_owned(),
558 "--json".to_owned(),
559 ],
560 "read the container host mbx cache directory",
561 );
562 let output = checked(executor.execute(&command)?, &command)?;
563 let report: serde_json::Value =
564 serde_json::from_slice(&output.stdout).context("parse the mbx cache directory report")?;
565 let store = report
566 .get("store")
567 .and_then(serde_json::Value::as_str)
568 .context("the mbx cache directory report has no store path")?;
569 Path::new(store)
570 .parent()
571 .map(Path::to_path_buf)
572 .with_context(|| format!("mbx store path {store:?} has no parent"))
573}
574
575const READ_CONFIG_SCRIPT: &str = r#"[ -f "$1" ] || exit 3
576cat -- "$1""#;
577
578fn host_config_file(host: &CacheHost, executor: &impl CommandExecutor) -> Result<Option<String>> {
582 let path = host.home(executor)?.join(HOST_CONFIG_RELATIVE);
583 let command = host.shell_command(
584 READ_CONFIG_SCRIPT,
585 LABEL,
586 [path.to_string_lossy().into_owned()],
587 "read the container host mbx configuration",
588 );
589 let output = executor.execute(&command)?;
590 if output.status == 3 {
591 return Ok(None);
592 }
593 let output = checked(output, &command)?;
594 Ok(Some(
595 String::from_utf8(output.stdout).context("decode the host mbx configuration")?,
596 ))
597}
598
599fn relocated_target_root(config_file: &str, directory: &Path) -> Option<PathBuf> {
602 let document: toml::Value = toml::from_str(config_file)
603 .map_err(|error| tracing::warn!("the host mbx configuration is unreadable: {error}"))
604 .ok()?;
605 let root = document.get("target")?.get("root")?.as_str()?;
606 let root = directory.join(root);
607 (!root.starts_with(directory)).then_some(root)
608}
609
610const NEAREST_ANCESTOR_SCRIPT: &str = r#"d=$1
611while [ ! -d "$d" ]; do
612 parent=$(dirname -- "$d")
613 if [ "$parent" = "$d" ]; then
614 break
615 fi
616 d=$parent
617done
618printf '%s' "$d""#;
619
620fn nearest_existing_ancestor(
623 host: &CacheHost,
624 directory: &Path,
625 executor: &impl CommandExecutor,
626) -> Result<PathBuf> {
627 let command = host.shell_command(
628 NEAREST_ANCESTOR_SCRIPT,
629 LABEL,
630 [directory.to_string_lossy().into_owned()],
631 "locate the build cache volume",
632 );
633 let output = checked(executor.execute(&command)?, &command)?;
634 let path = PathBuf::from(String::from_utf8(output.stdout).context("decode cache ancestor")?);
635 ensure!(
636 path.is_absolute(),
637 "build cache volume {} is not absolute",
638 path.display()
639 );
640 Ok(path)
641}
642
643fn default_max_size(
651 host: &CacheHost,
652 directory: &Path,
653 executor: &impl CommandExecutor,
654) -> Result<String> {
655 let volume = nearest_existing_ancestor(host, directory, executor)?;
656 let command = host.command(
657 vec![
658 "df".to_owned(),
659 "-B1".to_owned(),
660 "-P".to_owned(),
661 "--".to_owned(),
662 volume.to_string_lossy().into_owned(),
663 ],
664 "measure the build cache volume",
665 );
666 let output = checked(executor.execute(&command)?, &command)?;
667 let available = available_bytes(&String::from_utf8_lossy(&output.stdout))
668 .context("read the free space on the build cache volume")?;
669 Ok(format!("{}B", DEFAULT_MAX_BYTES.min(available / 4)))
670}
671
672fn available_bytes(report: &str) -> Option<u64> {
676 let row = report
677 .lines()
678 .filter(|line| !line.trim().is_empty())
679 .nth(1)?;
680 let fields = row.split_whitespace().collect::<Vec<_>>();
681 let available = fields.get(fields.len().checked_sub(3)?)?;
683 available.parse().ok()
684}
685
686const REFLINK_SCRIPT: &str = r#"dir=$1
687d=$(mktemp -d "$dir/.mj-reflink.XXXXXX") || exit 1
688printf x > "$d/a" && cp --reflink=always "$d/a" "$d/b"
689status=$?
690rm -rf -- "$d"
691exit $status"#;
692
693fn reflinks_supported(
697 host: &CacheHost,
698 volume: &Path,
699 executor: &impl CommandExecutor,
700) -> Result<bool> {
701 let command = host.shell_command(
702 REFLINK_SCRIPT,
703 LABEL,
704 [volume.to_string_lossy().into_owned()],
705 "probe the build cache volume for reflinks",
706 );
707 Ok(executor.execute(&command)?.status == 0)
708}
709
710const CROSS_REFLINK_SCRIPT: &str = r#"src=$1
711dst=$2
712s=$(mktemp -d "$src/.mj-reflink.XXXXXX") || exit 1
713d=$(mktemp -d "$dst/.mj-reflink.XXXXXX") || { rm -rf -- "$s"; exit 1; }
714printf x > "$s/a" && cp --reflink=always "$s/a" "$d/b"
715status=$?
716rm -rf -- "$s" "$d"
717exit $status"#;
718
719fn cross_reflinks_supported(
723 host: &CacheHost,
724 store: &Path,
725 target_root: &Path,
726 executor: &impl CommandExecutor,
727) -> Result<bool> {
728 let command = host.shell_command(
729 CROSS_REFLINK_SCRIPT,
730 LABEL,
731 [
732 store.to_string_lossy().into_owned(),
733 target_root.to_string_lossy().into_owned(),
734 ],
735 "probe the managed target root for reflinks from the build cache",
736 );
737 Ok(executor.execute(&command)?.status == 0)
738}
739
740fn create_directory(
741 host: &CacheHost,
742 directory: &Path,
743 executor: &impl CommandExecutor,
744) -> Result<()> {
745 let command = host.command(
746 vec![
747 "mkdir".to_owned(),
748 "-p".to_owned(),
749 "--".to_owned(),
750 directory.to_string_lossy().into_owned(),
751 ],
752 "create the build cache directory",
753 );
754 checked(executor.execute(&command)?, &command).map(|_| ())
755}
756
757fn unusable_filesystem(
760 host: &CacheHost,
761 directory: &Path,
762 executor: &impl CommandExecutor,
763) -> Result<Option<&'static str>> {
764 let filesystems =
765 targets::probe_filesystem_types(host.ssh(), &[directory.to_path_buf()], executor)?;
766 let filesystem = filesystems
767 .first()
768 .context("the filesystem probe named no filesystem")?;
769 Ok(targets::overlay_unsupported_filesystem(filesystem)
773 .filter(|reason| matches!(*reason, "network filesystem" | "FUSE filesystem")))
774}
775
776fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
777 if output.status == 0 {
778 return Ok(output);
779 }
780 bail!(
781 "{} failed with status {}: {}",
782 command.purpose,
783 output.status,
784 String::from_utf8_lossy(&output.stderr).trim()
785 )
786}
787
788pub(super) fn binary_for(
793 locator: &targets::TargetLocator,
794 executor: &impl CommandExecutor,
795) -> Result<PathBuf> {
796 let triple = super::worker_binary::target_architecture(locator, executor)?;
797 if let Some(path) = std::env::var_os(MBX_BINARY_ENV) {
798 let path = PathBuf::from(path);
799 ensure!(
800 path.is_file(),
801 "{MBX_BINARY_ENV} does not name a file: {}",
802 path.display()
803 );
804 if triple == host_architecture() {
805 return Ok(path);
806 }
807 tracing::warn!(
808 triple,
809 "{MBX_BINARY_ENV} is for this machine's architecture; downloading the pinned mbx \
810 for the target instead"
811 );
812 }
813 download(triple)
814}
815
816fn host_architecture() -> &'static str {
819 if cfg!(target_arch = "aarch64") {
820 "aarch64"
821 } else {
822 "x86_64"
823 }
824}
825
826fn release_url(triple: &str) -> String {
827 format!(
828 "https://github.com/jdx/mr-boxington/releases/download/v{MBX_VERSION}/mbx-{triple}-unknown-linux-musl.tar.gz"
829 )
830}
831
832fn expected_digest(triple: &str) -> Result<&'static str> {
833 match triple {
834 "x86_64" => Ok(MBX_X86_64_SHA256),
835 "aarch64" => Ok(MBX_AARCH64_SHA256),
836 _ => bail!("no pinned mbx release for {triple}"),
837 }
838}
839
840fn download(triple: &str) -> Result<PathBuf> {
843 let expected = expected_digest(triple)?;
844 let directory = mj_core::config::data_dir()
845 .join("mbx")
846 .join(MBX_VERSION)
847 .join(triple);
848 let destination = directory.join("mbx");
849 if destination.is_file() {
850 return Ok(destination);
851 }
852 std::fs::create_dir_all(&directory)
853 .with_context(|| format!("create the mbx cache {}", directory.display()))?;
854 let url = release_url(triple);
855 let archive = reqwest::blocking::Client::builder()
856 .timeout(Duration::from_secs(120))
857 .build()?
858 .get(&url)
859 .send()
860 .with_context(|| format!("download {url}"))?
861 .error_for_status()
862 .with_context(|| format!("download {url}"))?
863 .bytes()?;
864 let actual = mj_core::hex::lower_hex(Sha256::digest(&archive));
865 ensure!(
866 actual.eq_ignore_ascii_case(expected),
867 "downloaded mbx checksum mismatch: expected {expected}, got {actual}"
868 );
869 let binary = extract_binary(&archive)?;
870 let mut temporary = tempfile::NamedTempFile::new_in(&directory)?;
871 std::io::Write::write_all(&mut temporary, &binary)?;
872 temporary.as_file_mut().sync_all()?;
873 #[cfg(unix)]
874 {
875 use std::os::unix::fs::PermissionsExt;
876 std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o700))?;
877 }
878 match temporary.persist_noclobber(&destination) {
879 Ok(_) => Ok(destination),
880 Err(error) if destination.is_file() => {
881 drop(error);
882 Ok(destination)
883 }
884 Err(error) => Err(error.error)
885 .with_context(|| format!("publish the mbx binary {}", destination.display())),
886 }
887}
888
889fn extract_binary(archive: &[u8]) -> Result<Vec<u8>> {
892 let mut reader = tar::Archive::new(flate2::read::GzDecoder::new(archive));
893 for entry in reader.entries().context("read the mbx release archive")? {
894 let mut entry = entry.context("read the mbx release archive")?;
895 if entry.path().context("read an mbx archive path")?.as_ref() != Path::new("mbx") {
896 continue;
897 }
898 let mut bytes = Vec::new();
899 entry
900 .read_to_end(&mut bytes)
901 .context("read the mbx binary from its release archive")?;
902 return Ok(bytes);
903 }
904 bail!("the mbx release archive contains no mbx binary")
905}
906
907pub(super) fn primary_repository_is_rust(
913 host: &CacheHost,
914 mirror: &Path,
915 executor: &impl CommandExecutor,
916) -> bool {
917 let command = host.command(
918 vec![
919 "git".to_owned(),
920 "--git-dir".to_owned(),
921 mirror.to_string_lossy().into_owned(),
922 "cat-file".to_owned(),
923 "-e".to_owned(),
924 "HEAD:Cargo.toml".to_owned(),
925 ],
926 "detect a Cargo workspace in the session repository",
927 );
928 matches!(executor.execute(&command), Ok(output) if output.status == 0)
929}
930
931pub(super) fn prepare(
935 target: &targets::TargetTemplate,
936 global: &BuildCacheConfig,
937 session: &mj_core::state::SessionRecord,
938 bundle: Option<&targets::ProjectBundleSpec>,
939 clone_cache: Option<&super::git_cache::PreparedCloneCache>,
940 mounts: &mut Vec<targets::AdditionalMount>,
941 executor: &impl CommandExecutor,
942) -> Option<SessionBuildCache> {
943 let host_key = supported_host(target).map(|(host, _)| host.key());
946 if let Some(recorded) = &session.build_cache {
947 if host_key.as_deref() == Some(recorded.host.as_str()) {
948 return attach_mounts(recorded, mounts).then(|| recorded.clone());
949 }
950 tracing::info!(
951 session_id = session.id,
952 recorded_host = recorded.host,
953 host = host_key.as_deref().unwrap_or("unsupported target"),
954 "the session moved to another container host, so its build cache is resolved again"
955 );
956 }
957 session.container_workspace.as_ref()?;
960 let resolved = resolve(target, global, executor)?;
961 let host = supported_host(target)?.0;
962 let mirror = clone_cache?.mirror_for(&bundle?.primary)?;
963 if !primary_repository_is_rust(&host, mirror, executor) {
964 return None;
965 }
966 let build_cache = SessionBuildCache {
967 host: host.key(),
968 directory: resolved.directory,
969 max_size: resolved.max_size,
970 target_root: resolved.target_root,
971 };
972 attach_mounts(&build_cache, mounts).then_some(build_cache)
973}
974
975fn attach_mounts(
979 build_cache: &SessionBuildCache,
980 mounts: &mut Vec<targets::AdditionalMount>,
981) -> bool {
982 let wanted = std::iter::once(&build_cache.directory)
983 .chain(build_cache.target_root.iter())
984 .collect::<Vec<_>>();
985 for directory in &wanted {
986 if mounts.iter().any(|mount| {
987 mount.destination.starts_with(directory) || directory.starts_with(&mount.destination)
988 }) {
989 tracing::warn!(
990 directory = %directory.display(),
991 "an attached directory overlaps the build cache, so this session runs without it"
992 );
993 return false;
994 }
995 }
996 for directory in wanted {
997 mounts.push(targets::AdditionalMount {
998 source: directory.clone(),
999 destination: directory.clone(),
1000 access: targets::MountAccess::Rw,
1001 });
1002 }
1003 true
1004}
1005
1006#[cfg(test)]
1009pub(super) fn attach_mounts_for_tests(
1010 build_cache: &SessionBuildCache,
1011 mounts: &mut Vec<targets::AdditionalMount>,
1012) -> bool {
1013 attach_mounts(build_cache, mounts)
1014}
1015
1016pub(super) fn host_configuration(
1019 target: &targets::TargetLocator,
1020 executor: &impl CommandExecutor,
1021) -> Result<Option<String>> {
1022 let host = match target {
1023 targets::TargetLocator::LocalPodman { .. }
1024 | targets::TargetLocator::LocalDocker { .. }
1025 | targets::TargetLocator::AppleContainer { .. } => CacheHost::Local,
1026 targets::TargetLocator::SshPodman { ssh, .. }
1027 | targets::TargetLocator::SshDocker { ssh, .. } => CacheHost::Ssh(ssh.clone()),
1028 _ => return Ok(None),
1029 };
1030 host_config_file(&host, executor)
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035 use super::*;
1036 use crate::targets::{ContainerTemplate, SshTarget, TargetTemplate};
1037 use mj_core::config::ImagePullPolicy;
1038 use std::sync::Mutex;
1039
1040 static ISOLATED: Mutex<()> = Mutex::new(());
1043
1044 fn isolated() -> std::sync::MutexGuard<'static, ()> {
1045 let guard = ISOLATED.lock().unwrap_or_else(|error| error.into_inner());
1046 RESOLUTIONS.lock().expect("mbx resolutions").clear();
1047 guard
1048 }
1049
1050 #[derive(Default)]
1052 struct ProbeExecutor {
1053 answers: Vec<(&'static str, i32, String)>,
1054 seen: Mutex<Vec<String>>,
1055 }
1056
1057 impl ProbeExecutor {
1058 fn new(answers: &[(&'static str, i32, &str)]) -> Self {
1059 Self {
1060 answers: answers
1061 .iter()
1062 .map(|(needle, status, stdout)| (*needle, *status, (*stdout).to_owned()))
1063 .collect(),
1064 seen: Mutex::new(Vec::new()),
1065 }
1066 }
1067
1068 fn ran(&self) -> Vec<String> {
1069 self.seen.lock().unwrap().clone()
1070 }
1071 }
1072
1073 impl CommandExecutor for ProbeExecutor {
1074 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1075 let line = format!("{} {}", command.program, command.args.join(" "));
1076 self.seen.lock().unwrap().push(line.clone());
1077 for (needle, status, stdout) in &self.answers {
1078 if line.contains(needle) {
1079 return Ok(CommandOutput {
1080 status: *status,
1081 stdout: stdout.clone().into_bytes(),
1082 stderr: Vec::new(),
1083 });
1084 }
1085 }
1086 Ok(CommandOutput {
1087 status: 127,
1088 stdout: Vec::new(),
1089 stderr: format!("no canned answer for {line}").into_bytes(),
1090 })
1091 }
1092 }
1093
1094 fn container(build_cache: Option<TargetBuildCache>) -> ContainerTemplate {
1095 ContainerTemplate {
1096 image: "example/image:latest".into(),
1097 pull_policy: ImagePullPolicy::Missing,
1098 extra_run_args: Vec::new(),
1099 workspace_storage: Default::default(),
1100 build_cache,
1101 }
1102 }
1103
1104 fn podman(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1105 TargetTemplate::LocalPodman(container(build_cache))
1106 }
1107
1108 fn docker(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1109 TargetTemplate::LocalDocker(container(build_cache))
1110 }
1111
1112 fn configured_local_machine() -> mj_core::config::Machine {
1115 serde_json::from_value(serde_json::json!({"kind": "local"})).unwrap()
1116 }
1117
1118 fn default_cache_directory() -> PathBuf {
1121 dirs::home_dir()
1122 .expect("a home directory")
1123 .join(DEFAULT_CACHE_RELATIVE)
1124 }
1125
1126 fn current_native_mbx() -> String {
1130 format!("mbx\nmbx {MBX_VERSION}")
1131 }
1132
1133 fn plain_host() -> Vec<(&'static str, i32, &'static str)> {
1134 vec![
1135 ("$m\" --version", 1, ""),
1136 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1137 ("[ -f \"$1\" ]", 3, ""),
1138 ("while [ ! -d", 0, "/home/dev"),
1139 (
1140 "df -B1 -P",
1141 0,
1142 "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 1000000000000 0 800000000000 20% /home\n",
1143 ),
1144 ("mj-reflink", 0, ""),
1145 ("mkdir -p", 0, ""),
1146 ("stat -f -c %T", 0, "xfs"),
1147 ]
1148 }
1149
1150 #[test]
1151 fn installed_worker_reads_cache_configuration_from_its_recorded_host() {
1152 let executor = ProbeExecutor::new(&[
1153 ("$HOME", 0, "/home/builder"),
1154 ("[ -f \"$1\" ]", 0, "[gc]\nmax_total_size = '50GB'\n"),
1155 ]);
1156 let target = targets::TargetLocator::SshPodman {
1157 ssh: SshTarget {
1158 destination: "builder@recorded-cache.test".into(),
1159 ssh_args: vec![],
1160 },
1161 container_id: "saved-container".into(),
1162 workspace_storage: Default::default(),
1163 borrowed_from: None,
1164 };
1165 let config = host_configuration(&target, &executor).unwrap().unwrap();
1166 assert!(config.contains("50GB"));
1167 assert!(
1168 executor
1169 .seen
1170 .lock()
1171 .unwrap()
1172 .iter()
1173 .all(|command| command.contains("builder@recorded-cache.test"))
1174 );
1175 }
1176
1177 #[test]
1178 fn a_native_mbx_supplies_the_cache_directory_and_its_own_limits() {
1179 let _isolated = isolated();
1180 let executor = ProbeExecutor::new(&[
1181 ("$m\" --version", 0, current_native_mbx().as_str()),
1182 (
1183 "mbx cache dir --json",
1184 0,
1185 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1186 ),
1187 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1188 (
1189 "[ -f \"$1\" ]",
1190 0,
1191 "cache_dir = \"/mnt/fast/mbx-cache\"\n[gc]\nmax_size = \"500GiB\"\n",
1192 ),
1193 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1194 ("mj-reflink", 0, ""),
1195 ("mkdir -p", 0, ""),
1196 ("stat -f -c %T", 0, "xfs"),
1197 ]);
1198 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1199 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1200 assert_eq!(resolved.max_size, None);
1202 assert_eq!(resolved.target_root, None);
1203 assert!(resolved.config_file.unwrap().contains("500GiB"));
1204 assert!(
1205 !executor.ran().iter().any(|line| line.contains("df -B1")),
1206 "a host with its own configuration is not measured"
1207 );
1208 }
1209
1210 #[test]
1211 fn looking_at_the_settings_page_lets_the_next_session_see_a_repaired_host() {
1212 let _isolated = isolated();
1213 let broken = ProbeExecutor::new(
1214 &plain_host()
1215 .into_iter()
1216 .map(|(needle, status, stdout)| match needle {
1217 "mj-reflink" => (needle, 1, stdout),
1218 _ => (needle, status, stdout),
1219 })
1220 .collect::<Vec<_>>(),
1221 );
1222 assert!(
1223 resolve(&podman(None), &BuildCacheConfig::default(), &broken).is_none(),
1224 "a volume that cannot clone runs without the cache"
1225 );
1226
1227 let repaired = ProbeExecutor::new(&plain_host());
1230 let preview = preview_build_cache(
1231 &configured_local_machine(),
1232 &BuildCacheConfig::default(),
1233 &repaired,
1234 )
1235 .expect("the host answers")
1236 .expect("a local machine can hold a cache");
1237 assert_eq!(preview.off_reason, None);
1238
1239 assert!(
1240 resolve(&podman(None), &BuildCacheConfig::default(), &repaired).is_some(),
1241 "the next session asks the repaired host again instead of reusing the old verdict"
1242 );
1243 }
1244
1245 #[test]
1246 fn a_relocated_target_root_is_reported_for_its_own_mount() {
1247 let _isolated = isolated();
1248 let executor = ProbeExecutor::new(&[
1249 ("$m\" --version", 0, current_native_mbx().as_str()),
1250 (
1251 "mbx cache dir --json",
1252 0,
1253 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1254 ),
1255 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1256 (
1257 "[ -f \"$1\" ]",
1258 0,
1259 "[target]\nroot = \"/mnt/fast/mbx-targets\"\n",
1260 ),
1261 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1262 ("mj-reflink", 0, ""),
1263 ("mkdir -p", 0, ""),
1264 ("stat -f -c %T", 0, "xfs"),
1265 ]);
1266 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1267 assert_eq!(
1268 resolved.target_root,
1269 Some(PathBuf::from("/mnt/fast/mbx-targets"))
1270 );
1271 }
1272
1273 #[test]
1274 fn a_target_root_that_cannot_be_cloned_into_still_gets_the_cache() {
1275 let _isolated = isolated();
1276 let executor = ProbeExecutor::new(&[
1277 ("$m\" --version", 0, current_native_mbx().as_str()),
1278 (
1279 "mbx cache dir --json",
1280 0,
1281 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1282 ),
1283 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1284 (
1285 "[ -f \"$1\" ]",
1286 0,
1287 "[target]\nroot = \"/mnt/slow/mbx-targets\"\n",
1288 ),
1289 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1290 ("src=$1", 1, ""),
1293 ("mj-reflink", 0, ""),
1294 ("mkdir -p", 0, ""),
1295 ("stat -f -c %T", 0, "xfs"),
1296 ]);
1297 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor)
1298 .expect("a target root that copies instead of cloning is slower, not unusable");
1299 assert_eq!(
1300 resolved.target_root,
1301 Some(PathBuf::from("/mnt/slow/mbx-targets"))
1302 );
1303 assert!(
1304 executor.ran().iter().any(|line| line.contains("src=$1")),
1305 "the store and the target root are probed as a pair: {:?}",
1306 executor.ran()
1307 );
1308 }
1309
1310 #[test]
1311 fn a_target_root_inside_the_cache_directory_needs_no_second_mount() {
1312 assert_eq!(
1313 relocated_target_root("[target]\nroot = \"targets\"\n", Path::new("/cache")),
1314 None
1315 );
1316 assert_eq!(
1317 relocated_target_root("[target]\nroot = \"/cache/targets\"\n", Path::new("/cache")),
1318 None
1319 );
1320 }
1321
1322 #[test]
1323 fn a_cargo_installed_mbx_off_the_path_is_queried_where_it_was_found() {
1324 let _isolated = isolated();
1325 let found = format!("/home/dev/.cargo/bin/mbx\nmbx {MBX_VERSION}");
1326 let mut answers: Vec<(&'static str, i32, &str)> = plain_host();
1327 answers.retain(|(needle, _, _)| *needle != "$m\" --version");
1328 answers.push(("$m\" --version", 0, found.as_str()));
1329 answers.push((
1330 "/home/dev/.cargo/bin/mbx cache dir --json",
1331 0,
1332 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1333 ));
1334 let executor = ProbeExecutor::new(&answers);
1335 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1336 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1337 }
1338
1339 #[test]
1340 fn an_older_native_mbx_must_not_share_the_store() {
1341 let _isolated = isolated();
1342 let executor = ProbeExecutor::new(&[("$m\" --version", 0, "mbx\nmbx 1.15.0")]);
1343 assert_eq!(
1344 resolve(&podman(None), &BuildCacheConfig::default(), &executor),
1345 None
1346 );
1347 }
1348
1349 #[test]
1350 fn a_host_without_mbx_falls_back_to_the_default_cache_directory() {
1351 let _isolated = isolated();
1352 let executor = ProbeExecutor::new(&plain_host());
1353 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1354 assert_eq!(resolved.directory, default_cache_directory());
1355 assert_eq!(resolved.max_size.as_deref(), Some("100000000000B"));
1357 }
1358
1359 #[test]
1360 fn a_small_volume_takes_a_quarter_of_its_free_space() {
1361 let _isolated = isolated();
1362 let mut answers = plain_host();
1363 answers.retain(|(needle, _, _)| *needle != "df -B1 -P");
1364 answers.push((
1365 "df -B1 -P",
1366 0,
1367 "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 100000000 60000000 40000000 60% /home\n",
1368 ));
1369 let executor = ProbeExecutor::new(&answers);
1370 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1371 assert_eq!(resolved.max_size.as_deref(), Some("10000000B"));
1372 }
1373
1374 #[test]
1375 fn target_overrides_win_over_every_default() {
1376 let _isolated = isolated();
1377 let mut answers = plain_host();
1378 answers.push(("mbx cache dir", 0, r#"{"store":"/other/actions"}"#));
1379 let executor = ProbeExecutor::new(&answers);
1380 let resolved = resolve(
1381 &podman(Some(TargetBuildCache {
1382 enabled: Some(true),
1383 directory: Some(PathBuf::from("/mnt/nvme/mbx")),
1384 max_size: Some("250GiB".into()),
1385 })),
1386 &BuildCacheConfig::default(),
1387 &executor,
1388 )
1389 .unwrap();
1390 assert_eq!(resolved.directory, PathBuf::from("/mnt/nvme/mbx"));
1391 assert_eq!(resolved.max_size.as_deref(), Some("250GiB"));
1392 }
1393
1394 #[test]
1397 fn an_enabled_setting_does_not_survive_a_volume_without_reflinks() {
1398 let _isolated = isolated();
1399 let mut answers = plain_host();
1400 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1401 answers.push(("mj-reflink", 1, ""));
1402 let executor = ProbeExecutor::new(&answers);
1403 assert_eq!(
1404 resolve(
1405 &podman(Some(TargetBuildCache {
1406 enabled: Some(true),
1407 directory: None,
1408 max_size: None,
1409 })),
1410 &BuildCacheConfig::default(),
1411 &executor,
1412 ),
1413 None
1414 );
1415 }
1416
1417 #[test]
1418 fn a_volume_without_reflinks_runs_without_the_cache() {
1419 let _isolated = isolated();
1420 let mut answers = plain_host();
1421 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1422 answers.push(("mj-reflink", 1, ""));
1423 let executor = ProbeExecutor::new(&answers);
1424 assert_eq!(
1425 resolve(&podman(None), &BuildCacheConfig::default(), &executor),
1426 None
1427 );
1428 }
1429
1430 #[test]
1431 fn the_preview_names_the_resolved_values_and_the_reason_the_cache_is_off() {
1432 let _isolated = isolated();
1433 let mut answers = plain_host();
1434 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1435 answers.push(("mj-reflink", 1, ""));
1436 let executor = ProbeExecutor::new(&answers);
1437 let preview = preview_build_cache(
1438 &configured_local_machine(),
1439 &BuildCacheConfig::default(),
1440 &executor,
1441 )
1442 .unwrap()
1443 .unwrap();
1444 assert_eq!(preview.native_mbx, None);
1445 assert_eq!(preview.directory, Some(default_cache_directory()));
1446 assert_eq!(
1447 preview.max_size,
1448 Some(BuildCacheLimit::Size("100000000000B".into()))
1449 );
1450 assert!(
1451 matches!(&preview.off_reason, Some(BuildCacheOff::Unavailable(reason)) if reason.contains("reflinks")),
1452 "{:?}",
1453 preview.off_reason
1454 );
1455 assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1457
1458 let executor = ProbeExecutor::new(&[
1459 ("$m\" --version", 0, current_native_mbx().as_str()),
1460 (
1461 "mbx cache dir --json",
1462 0,
1463 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1464 ),
1465 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1466 ("[ -f \"$1\" ]", 0, "[gc]\nmax_size = \"500GiB\"\n"),
1467 ("while [ ! -d", 0, "/mnt/fast"),
1468 ("mj-reflink", 0, ""),
1469 ("stat -f -c %T", 0, "xfs"),
1470 ]);
1471 let preview = preview_build_cache(
1472 &configured_local_machine(),
1473 &BuildCacheConfig::default(),
1474 &executor,
1475 )
1476 .unwrap()
1477 .unwrap();
1478 assert_eq!(preview.native_mbx.as_deref(), Some(MBX_VERSION));
1479 assert_eq!(
1480 preview.directory,
1481 Some(PathBuf::from("/mnt/fast/mbx-cache"))
1482 );
1483 assert_eq!(
1484 preview.max_size,
1485 Some(BuildCacheLimit::HostConfiguration(Some("500GiB".into())))
1486 );
1487 assert_eq!(preview.off_reason, None);
1488 assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1489 }
1490
1491 #[test]
1492 fn a_network_filesystem_runs_without_the_cache() {
1493 let _isolated = isolated();
1494 let mut answers = plain_host();
1495 answers.retain(|(needle, _, _)| *needle != "stat -f -c %T");
1496 answers.push(("stat -f -c %T", 0, "nfs4"));
1497 let executor = ProbeExecutor::new(&answers);
1498 assert_eq!(
1499 resolve(&podman(None), &BuildCacheConfig::default(), &executor),
1500 None
1501 );
1502 }
1503
1504 #[test]
1505 fn the_global_switch_short_circuits_every_host_command() {
1506 let _isolated = isolated();
1507 let executor = ProbeExecutor::new(&plain_host());
1508 assert_eq!(
1509 resolve(
1510 &podman(None),
1511 &BuildCacheConfig { enabled: false },
1512 &executor
1513 ),
1514 None
1515 );
1516 assert!(executor.ran().is_empty());
1517 }
1518
1519 #[test]
1520 fn local_podman_and_local_docker_inspect_one_machine_once() {
1521 let _isolated = isolated();
1522 let executor = ProbeExecutor::new(&plain_host());
1523 let settings = BuildCacheConfig::default();
1524 let first = resolve(&podman(None), &settings, &executor).unwrap();
1525 let ran = executor.ran().len();
1526 assert!(ran > 0, "the first resolve inspects the host");
1527 let second = resolve(&docker(None), &settings, &executor).unwrap();
1528 assert_eq!(
1529 first, second,
1530 "both engines on this machine share one cache"
1531 );
1532 let added = executor.ran()[ran..].to_vec();
1536 assert_eq!(
1537 added.len(),
1538 1,
1539 "the second runtime is answered from the machine's recorded inspection: {added:?}"
1540 );
1541 assert!(
1542 added[0].contains("mkdir -p"),
1543 "the one repeated command creates the directory: {added:?}"
1544 );
1545 }
1546
1547 #[test]
1548 fn the_preview_reports_what_the_cache_has_already_done() {
1549 let _isolated = isolated();
1550 let mut answers = vec![(
1552 "tally.json",
1553 0,
1554 r#"{"version":1,"since_secs":1789824719,"builds":155,"cached_compilations":12050,"avoided_compiler_ns":6004997818721,"reflinked_bytes":47612059386}"#,
1555 )];
1556 answers.extend(plain_host());
1557 let executor = ProbeExecutor::new(&answers);
1558 let preview = preview_build_cache(
1559 &configured_local_machine(),
1560 &BuildCacheConfig::default(),
1561 &executor,
1562 )
1563 .expect("the host answers")
1564 .expect("a local machine can hold a cache");
1565 assert_eq!(
1566 preview.stats,
1567 Some(mj_core::state::BuildCacheStats {
1568 builds: 155,
1569 cached_compilations: 12050,
1570 avoided_compiler_ns: 6_004_997_818_721,
1571 reflinked_bytes: 47_612_059_386,
1572 })
1573 );
1574 }
1575
1576 #[test]
1577 fn a_cache_nothing_has_used_yet_reports_no_totals() {
1578 let _isolated = isolated();
1579 let executor = ProbeExecutor::new(&plain_host());
1582 let preview = preview_build_cache(
1583 &configured_local_machine(),
1584 &BuildCacheConfig::default(),
1585 &executor,
1586 )
1587 .expect("the host answers")
1588 .expect("a local machine can hold a cache");
1589 assert_eq!(preview.stats, None);
1590 }
1591
1592 #[test]
1593 fn a_machine_without_a_standing_host_has_no_build_cache_preview() {
1594 let _isolated = isolated();
1595 let executor = ProbeExecutor::new(&plain_host());
1596 let fleet: mj_core::config::Machine = serde_json::from_value(serde_json::json!({
1597 "kind": "aws-ec2",
1598 "region": "us-east-1",
1599 "launch_template": "lt-1",
1600 "ssh_user": "ubuntu",
1601 }))
1602 .unwrap();
1603 assert_eq!(
1604 preview_build_cache(&fleet, &BuildCacheConfig::default(), &executor).unwrap(),
1605 None
1606 );
1607 assert!(executor.ran().is_empty());
1608 }
1609
1610 #[test]
1611 fn apple_and_bare_targets_have_no_shared_build_cache() {
1612 let _isolated = isolated();
1613 let executor = ProbeExecutor::new(&plain_host());
1614 for target in [
1615 TargetTemplate::AppleContainer(container(None)),
1616 TargetTemplate::LocalBare,
1617 TargetTemplate::SshBare {
1618 ssh: SshTarget {
1619 destination: "dev@example.test".into(),
1620 ssh_args: Vec::new(),
1621 },
1622 workspace_prefix: "workspaces".into(),
1623 },
1624 ] {
1625 assert_eq!(
1626 resolve(&target, &BuildCacheConfig::default(), &executor),
1627 None,
1628 "{target:?}"
1629 );
1630 }
1631 assert!(executor.ran().is_empty());
1632 }
1633
1634 fn bundle() -> targets::ProjectBundleSpec {
1635 targets::ProjectBundleSpec {
1636 primary: "main".into(),
1637 repositories: vec![targets::RepositorySpec {
1638 url: Some("https://github.com/example/main.git".into()),
1639 push_urls: Vec::new(),
1640 destination: "main".into(),
1641 git_ref: None,
1642 reference: None,
1643 }],
1644 }
1645 }
1646
1647 fn clone_cache() -> super::super::git_cache::PreparedCloneCache {
1648 super::super::git_cache::PreparedCloneCache::from_mirrors(
1649 [(
1650 "main".to_owned(),
1651 PathBuf::from("/home/dev/mirror/repo.git"),
1652 )]
1653 .into_iter()
1654 .collect(),
1655 )
1656 }
1657
1658 fn session(container_workspace: Option<&str>) -> mj_core::state::SessionRecord {
1659 let mut record = crate::controller::test_support::checkpoint_test_session("session-1");
1660 record.container_workspace = container_workspace.map(PathBuf::from);
1661 record
1662 }
1663
1664 #[test]
1665 fn a_rust_session_mounts_the_cache_at_the_host_path() {
1666 let _isolated = isolated();
1667 let mut answers = plain_host();
1668 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1669 let executor = ProbeExecutor::new(&answers);
1670 let mut mounts = Vec::new();
1671 let build_cache = prepare(
1672 &podman(None),
1673 &BuildCacheConfig::default(),
1674 &session(Some("/workspace/session-1")),
1675 Some(&bundle()),
1676 Some(&clone_cache()),
1677 &mut mounts,
1678 &executor,
1679 )
1680 .expect("a Rust session uses the build cache");
1681 assert_eq!(build_cache.directory, default_cache_directory());
1682 assert_eq!(
1683 mounts,
1684 vec![targets::AdditionalMount {
1685 source: default_cache_directory(),
1686 destination: default_cache_directory(),
1687 access: targets::MountAccess::Rw,
1688 }]
1689 );
1690 }
1691
1692 #[test]
1693 fn a_repository_without_a_root_manifest_runs_without_the_cache() {
1694 let _isolated = isolated();
1695 let mut answers = plain_host();
1696 answers.push(("cat-file -e HEAD:Cargo.toml", 1, ""));
1697 let executor = ProbeExecutor::new(&answers);
1698 let mut mounts = Vec::new();
1699 assert_eq!(
1700 prepare(
1701 &podman(None),
1702 &BuildCacheConfig::default(),
1703 &session(Some("/workspace/session-1")),
1704 Some(&bundle()),
1705 Some(&clone_cache()),
1706 &mut mounts,
1707 &executor,
1708 ),
1709 None
1710 );
1711 assert!(mounts.is_empty());
1712 }
1713
1714 #[test]
1715 fn a_session_at_the_legacy_shared_workspace_runs_without_the_cache() {
1716 let _isolated = isolated();
1717 let mut answers = plain_host();
1718 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1719 let executor = ProbeExecutor::new(&answers);
1720 let mut mounts = Vec::new();
1721 assert_eq!(
1722 prepare(
1723 &podman(None),
1724 &BuildCacheConfig::default(),
1725 &session(None),
1726 Some(&bundle()),
1727 Some(&clone_cache()),
1728 &mut mounts,
1729 &executor,
1730 ),
1731 None
1732 );
1733 assert!(executor.ran().is_empty());
1734 }
1735
1736 #[test]
1737 fn a_session_without_a_prepared_clone_cache_runs_without_the_cache() {
1738 let _isolated = isolated();
1739 let mut answers = plain_host();
1740 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1741 let executor = ProbeExecutor::new(&answers);
1742 let mut mounts = Vec::new();
1743 assert_eq!(
1744 prepare(
1745 &podman(None),
1746 &BuildCacheConfig::default(),
1747 &session(Some("/workspace/session-1")),
1748 Some(&bundle()),
1749 None,
1750 &mut mounts,
1751 &executor,
1752 ),
1753 None
1754 );
1755 }
1756
1757 #[test]
1758 fn an_apple_target_never_shares_a_build_cache() {
1759 let _isolated = isolated();
1760 let mut answers = plain_host();
1761 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1762 let executor = ProbeExecutor::new(&answers);
1763 let mut mounts = Vec::new();
1764 assert_eq!(
1765 prepare(
1766 &TargetTemplate::AppleContainer(container(None)),
1767 &BuildCacheConfig::default(),
1768 &session(Some("/workspace/session-1")),
1769 Some(&bundle()),
1770 Some(&clone_cache()),
1771 &mut mounts,
1772 &executor,
1773 ),
1774 None
1775 );
1776 assert!(executor.ran().is_empty());
1777 }
1778
1779 #[test]
1780 fn a_resumed_session_reuses_its_recorded_cache_without_resolving_again() {
1781 let _isolated = isolated();
1782 let executor = ProbeExecutor::new(&[]);
1783 let mut record = session(Some("/workspace/session-1"));
1784 record.build_cache = Some(SessionBuildCache {
1785 host: "local".into(),
1786 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1787 max_size: None,
1788 target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
1789 });
1790 let mut mounts = Vec::new();
1791 let build_cache = prepare(
1792 &podman(None),
1793 &BuildCacheConfig::default(),
1794 &record,
1795 None,
1796 None,
1797 &mut mounts,
1798 &executor,
1799 )
1800 .expect("a resumed session keeps its build cache");
1801 assert_eq!(build_cache, record.build_cache.unwrap());
1802 assert_eq!(
1803 mounts
1804 .iter()
1805 .map(|mount| mount.destination.clone())
1806 .collect::<Vec<_>>(),
1807 vec![
1808 PathBuf::from("/mnt/fast/mbx-cache"),
1809 PathBuf::from("/mnt/fast/mbx-targets"),
1810 ]
1811 );
1812 assert!(executor.ran().is_empty());
1813 }
1814
1815 #[test]
1816 fn a_session_moved_to_another_host_resolves_its_build_cache_again() {
1817 let _isolated = isolated();
1818 let mut answers = plain_host();
1819 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1820 let executor = ProbeExecutor::new(&answers);
1821 let mut record = session(Some("/workspace/session-1"));
1822 record.build_cache = Some(SessionBuildCache {
1823 host: "ssh:dev@example.test".into(),
1826 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1827 max_size: None,
1828 target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
1829 });
1830 let mut mounts = Vec::new();
1831
1832 let build_cache = prepare(
1833 &podman(None),
1834 &BuildCacheConfig::default(),
1835 &record,
1836 Some(&bundle()),
1837 Some(&clone_cache()),
1838 &mut mounts,
1839 &executor,
1840 )
1841 .expect("the destination host qualifies on its own");
1842
1843 assert_eq!(build_cache.host, "local");
1844 assert_eq!(build_cache.directory, default_cache_directory());
1845 assert_eq!(build_cache.target_root, None);
1846 assert_eq!(
1847 mounts
1848 .iter()
1849 .map(|mount| mount.destination.clone())
1850 .collect::<Vec<_>>(),
1851 vec![default_cache_directory()]
1852 );
1853 assert!(
1854 executor
1855 .ran()
1856 .iter()
1857 .any(|line| line.contains("mj-reflink"))
1858 );
1859 }
1860
1861 #[test]
1862 fn an_attached_directory_over_the_cache_wins() {
1863 let build_cache = SessionBuildCache {
1864 host: "local-podman".into(),
1865 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1866 max_size: None,
1867 target_root: None,
1868 };
1869 let mut mounts = vec![targets::AdditionalMount {
1870 source: PathBuf::from("/elsewhere"),
1871 destination: PathBuf::from("/mnt/fast/mbx-cache/actions"),
1872 access: targets::MountAccess::Ro,
1873 }];
1874 assert!(!attach_mounts(&build_cache, &mut mounts));
1875 assert_eq!(mounts.len(), 1);
1876 }
1877
1878 #[test]
1879 fn versions_compare_by_release_order() {
1880 assert!(version_at_least("1.12.0", "1.12.0"));
1881 assert!(version_at_least("1.12.1", "1.12.0"));
1882 assert!(version_at_least("2.0.0", "1.12.0"));
1883 assert!(!version_at_least("1.11.9", "1.12.0"));
1884 assert!(!version_at_least("1.9.0", "1.12.0"));
1885 assert!(!version_at_least("not-a-version", "1.12.0"));
1886 }
1887
1888 #[test]
1889 fn free_space_is_read_from_the_available_column() {
1890 assert_eq!(
1891 available_bytes(
1892 "Filesystem 1B-blocks Used Available Capacity Mounted on\n\
1893 /dev/sda1 1000 400 600 40% /\n"
1894 ),
1895 Some(600)
1896 );
1897 assert_eq!(available_bytes("Filesystem 1B-blocks\n"), None);
1898 }
1899}