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::{
24 BuildCacheLimit, BuildCacheOff, BuildCachePreview, BuildCacheStats, SessionBuildCache,
25};
26
27pub(super) const MBX_VERSION: &str = "1.15.0";
30
31const MBX_X86_64_SHA256: &str = "bf9fcc7ed39e4923588f6c25a781aa59eb466fec347102a3bb1a0c2acd12ebf8";
32const MBX_AARCH64_SHA256: &str = "d0bb6c0eb4b4ba9abed39516ab769ca62429e3e47b548848ac40becee6870c2a";
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
208#[derive(Clone)]
211struct Inspection {
212 preview: BuildCachePreview,
213 cache: Option<ResolvedBuildCache>,
215}
216
217fn inspect_host(
218 host: &CacheHost,
219 settings: &TargetBuildCache,
220 executor: &impl CommandExecutor,
221) -> Result<Inspection> {
222 let native = native_version(host, executor);
223 let native_version = native.as_ref().map(|native| native.version.clone());
224 let off = |preview: BuildCachePreview| Inspection {
225 preview,
226 cache: None,
227 };
228 if let Some(version) = &native_version
229 && !version_at_least(version, MBX_VERSION)
230 {
231 return Ok(off(BuildCachePreview {
232 native_mbx: native_version.clone(),
233 directory: None,
234 max_size: None,
235 stats: None,
236 off_reason: Some(BuildCacheOff::Unavailable(format!(
237 "the host's mbx {version} is older than the {MBX_VERSION} Mjolnir installs, \
238 so they cannot share a store"
239 ))),
240 }));
241 }
242 let directory = match &settings.directory {
243 Some(directory) => directory.clone(),
244 None => match &native {
245 Some(native) => native_cache_directory(host, native, executor)?,
246 None => host.home(executor)?.join(DEFAULT_CACHE_RELATIVE),
247 },
248 };
249 ensure!(
250 directory.is_absolute(),
251 "build cache directory {} is not absolute",
252 directory.display()
253 );
254
255 let config_file = host_config_file(host, executor)?;
256 let target_root = config_file
257 .as_deref()
258 .and_then(|text| relocated_target_root(text, &directory));
259
260 let max_size = match (&settings.max_size, &config_file) {
261 (Some(max_size), _) => Some(max_size.clone()),
262 (None, Some(_)) => None,
264 (None, None) => Some(default_max_size(host, &directory, executor)?),
265 };
266 let limit = match (&max_size, &config_file) {
267 (Some(max_size), _) => BuildCacheLimit::Size(max_size.clone()),
268 (None, Some(text)) => BuildCacheLimit::HostConfiguration(configured_max_size(text)),
269 (None, None) => unreachable!("a missing budget is derived above"),
270 };
271 let stats = read_stats(host, &directory, executor);
274 let preview = |off_reason: Option<BuildCacheOff>| BuildCachePreview {
275 native_mbx: native_version.clone(),
276 directory: Some(directory.clone()),
277 max_size: Some(limit.clone()),
278 stats: stats.clone(),
279 off_reason,
280 };
281
282 let volume = nearest_existing_ancestor(host, &directory, executor)?;
285 if !settings.enabled.unwrap_or(true) {
288 return Ok(off(preview(Some(BuildCacheOff::TurnedOff))));
289 }
290 if !reflinks_supported(host, &volume, executor)? {
291 return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
292 "the filesystem under {} does not support reflinks, so restoring cached \
293 outputs would copy every byte",
294 directory.display()
295 ))))));
296 }
297 if let Some(reason) = unusable_filesystem(host, &volume, executor)? {
298 return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
299 "{} is on a {reason}, where mbx's file locks are unreliable",
300 directory.display()
301 ))))));
302 }
303
304 if let Some(root) = &target_root {
310 let root_volume = nearest_existing_ancestor(host, root, executor)?;
311 if !cross_reflinks_supported(host, &volume, &root_volume, executor)? {
312 tracing::warn!(
313 cache = %directory.display(),
314 target_root = %root.display(),
315 "the host's mbx target root does not share a mount with the build cache, \
316 so restoring a cached output copies every byte instead of cloning it"
317 );
318 }
319 }
320
321 Ok(Inspection {
322 preview: preview(None),
323 cache: Some(ResolvedBuildCache {
324 directory,
325 max_size,
326 target_root,
327 config_file,
328 }),
329 })
330}
331
332fn configured_max_size(config_file: &str) -> Option<String> {
337 let document: toml::Value = toml::from_str(config_file).ok()?;
338 let gc = document.get("gc")?;
339 gc.get("max_total_size")
340 .or_else(|| gc.get("max_size"))?
341 .as_str()
342 .map(str::to_owned)
343}
344
345fn read_stats(
354 host: &CacheHost,
355 directory: &Path,
356 executor: &impl CommandExecutor,
357) -> Option<BuildCacheStats> {
358 #[derive(Default, serde::Deserialize)]
359 #[serde(default)]
360 struct Tally {
361 builds: u64,
362 cached_compilations: u64,
363 avoided_compiler_ns: u64,
364 reflinked_bytes: u64,
365 }
366
367 let path = directory.join(TALLY_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 build cache totals",
373 );
374 let output = executor.execute(&command).ok()?;
375 if output.status != 0 {
376 return None;
377 }
378 let tally: Tally = serde_json::from_slice(&output.stdout)
381 .inspect_err(|error| {
382 tracing::debug!(
383 path = %path.display(),
384 "the build cache totals could not be read: {error}"
385 );
386 })
387 .ok()?;
388 Some(BuildCacheStats {
389 builds: tally.builds,
390 cached_compilations: tally.cached_compilations,
391 avoided_compiler_ns: tally.avoided_compiler_ns,
392 reflinked_bytes: tally.reflinked_bytes,
393 })
394}
395
396fn version_at_least(found: &str, required: &str) -> bool {
398 let parse = |text: &str| semver::Version::parse(text.trim()).ok();
399 match (parse(found), parse(required)) {
400 (Some(found), Some(required)) => found >= required,
401 _ => false,
403 }
404}
405
406#[derive(Debug, Clone, PartialEq, Eq)]
408struct NativeMbx {
409 program: String,
410 version: String,
411}
412
413const NATIVE_VERSION_SCRIPT: &str = r#"for m in mbx "$HOME/.cargo/bin/mbx"; do
416 if v=$("$m" --version 2>/dev/null); then
417 printf '%s
418%s' "$m" "$v"
419 exit 0
420 fi
421done
422exit 1"#;
423
424fn native_version(host: &CacheHost, executor: &impl CommandExecutor) -> Option<NativeMbx> {
427 let command = host.shell_command(
428 NATIVE_VERSION_SCRIPT,
429 LABEL,
430 [],
431 "read the container host mbx version",
432 );
433 let output = executor.execute(&command).ok()?;
434 if output.status != 0 {
435 return None;
436 }
437 let text = String::from_utf8_lossy(&output.stdout);
438 let (program, version) = text.trim().split_once('\n')?;
439 Some(NativeMbx {
440 program: program.to_owned(),
441 version: version.split_whitespace().next_back()?.to_owned(),
442 })
443}
444
445fn native_cache_directory(
448 host: &CacheHost,
449 native: &NativeMbx,
450 executor: &impl CommandExecutor,
451) -> Result<PathBuf> {
452 let command = host.command(
453 vec![
454 native.program.clone(),
455 "cache".to_owned(),
456 "dir".to_owned(),
457 "--json".to_owned(),
458 ],
459 "read the container host mbx cache directory",
460 );
461 let output = checked(executor.execute(&command)?, &command)?;
462 let report: serde_json::Value =
463 serde_json::from_slice(&output.stdout).context("parse the mbx cache directory report")?;
464 let store = report
465 .get("store")
466 .and_then(serde_json::Value::as_str)
467 .context("the mbx cache directory report has no store path")?;
468 Path::new(store)
469 .parent()
470 .map(Path::to_path_buf)
471 .with_context(|| format!("mbx store path {store:?} has no parent"))
472}
473
474const READ_CONFIG_SCRIPT: &str = r#"[ -f "$1" ] || exit 3
475cat -- "$1""#;
476
477fn host_config_file(host: &CacheHost, executor: &impl CommandExecutor) -> Result<Option<String>> {
481 let path = host.home(executor)?.join(HOST_CONFIG_RELATIVE);
482 let command = host.shell_command(
483 READ_CONFIG_SCRIPT,
484 LABEL,
485 [path.to_string_lossy().into_owned()],
486 "read the container host mbx configuration",
487 );
488 let output = executor.execute(&command)?;
489 if output.status == 3 {
490 return Ok(None);
491 }
492 let output = checked(output, &command)?;
493 Ok(Some(
494 String::from_utf8(output.stdout).context("decode the host mbx configuration")?,
495 ))
496}
497
498fn relocated_target_root(config_file: &str, directory: &Path) -> Option<PathBuf> {
501 let document: toml::Value = toml::from_str(config_file)
502 .map_err(|error| tracing::warn!("the host mbx configuration is unreadable: {error}"))
503 .ok()?;
504 let root = document.get("target")?.get("root")?.as_str()?;
505 let root = directory.join(root);
506 (!root.starts_with(directory)).then_some(root)
507}
508
509const NEAREST_ANCESTOR_SCRIPT: &str = r#"d=$1
510while [ ! -d "$d" ]; do
511 parent=$(dirname -- "$d")
512 if [ "$parent" = "$d" ]; then
513 break
514 fi
515 d=$parent
516done
517printf '%s' "$d""#;
518
519fn nearest_existing_ancestor(
522 host: &CacheHost,
523 directory: &Path,
524 executor: &impl CommandExecutor,
525) -> Result<PathBuf> {
526 let command = host.shell_command(
527 NEAREST_ANCESTOR_SCRIPT,
528 LABEL,
529 [directory.to_string_lossy().into_owned()],
530 "locate the build cache volume",
531 );
532 let output = checked(executor.execute(&command)?, &command)?;
533 let path = PathBuf::from(String::from_utf8(output.stdout).context("decode cache ancestor")?);
534 ensure!(
535 path.is_absolute(),
536 "build cache volume {} is not absolute",
537 path.display()
538 );
539 Ok(path)
540}
541
542fn default_max_size(
550 host: &CacheHost,
551 directory: &Path,
552 executor: &impl CommandExecutor,
553) -> Result<String> {
554 let volume = nearest_existing_ancestor(host, directory, executor)?;
555 let command = host.command(
556 vec![
557 "df".to_owned(),
558 "-B1".to_owned(),
559 "-P".to_owned(),
560 "--".to_owned(),
561 volume.to_string_lossy().into_owned(),
562 ],
563 "measure the build cache volume",
564 );
565 let output = checked(executor.execute(&command)?, &command)?;
566 let available = available_bytes(&String::from_utf8_lossy(&output.stdout))
567 .context("read the free space on the build cache volume")?;
568 Ok(format!("{}B", DEFAULT_MAX_BYTES.min(available / 4)))
569}
570
571fn available_bytes(report: &str) -> Option<u64> {
575 let row = report
576 .lines()
577 .filter(|line| !line.trim().is_empty())
578 .nth(1)?;
579 let fields = row.split_whitespace().collect::<Vec<_>>();
580 let available = fields.get(fields.len().checked_sub(3)?)?;
582 available.parse().ok()
583}
584
585const REFLINK_SCRIPT: &str = r#"dir=$1
586d=$(mktemp -d "$dir/.mj-reflink.XXXXXX") || exit 1
587printf x > "$d/a" && cp --reflink=always "$d/a" "$d/b"
588status=$?
589rm -rf -- "$d"
590exit $status"#;
591
592fn reflinks_supported(
596 host: &CacheHost,
597 volume: &Path,
598 executor: &impl CommandExecutor,
599) -> Result<bool> {
600 let command = host.shell_command(
601 REFLINK_SCRIPT,
602 LABEL,
603 [volume.to_string_lossy().into_owned()],
604 "probe the build cache volume for reflinks",
605 );
606 Ok(executor.execute(&command)?.status == 0)
607}
608
609const CROSS_REFLINK_SCRIPT: &str = r#"src=$1
610dst=$2
611s=$(mktemp -d "$src/.mj-reflink.XXXXXX") || exit 1
612d=$(mktemp -d "$dst/.mj-reflink.XXXXXX") || { rm -rf -- "$s"; exit 1; }
613printf x > "$s/a" && cp --reflink=always "$s/a" "$d/b"
614status=$?
615rm -rf -- "$s" "$d"
616exit $status"#;
617
618fn cross_reflinks_supported(
622 host: &CacheHost,
623 store: &Path,
624 target_root: &Path,
625 executor: &impl CommandExecutor,
626) -> Result<bool> {
627 let command = host.shell_command(
628 CROSS_REFLINK_SCRIPT,
629 LABEL,
630 [
631 store.to_string_lossy().into_owned(),
632 target_root.to_string_lossy().into_owned(),
633 ],
634 "probe the managed target root for reflinks from the build cache",
635 );
636 Ok(executor.execute(&command)?.status == 0)
637}
638
639fn create_directory(
640 host: &CacheHost,
641 directory: &Path,
642 executor: &impl CommandExecutor,
643) -> Result<()> {
644 let command = host.command(
645 vec![
646 "mkdir".to_owned(),
647 "-p".to_owned(),
648 "--".to_owned(),
649 directory.to_string_lossy().into_owned(),
650 ],
651 "create the build cache directory",
652 );
653 checked(executor.execute(&command)?, &command).map(|_| ())
654}
655
656fn unusable_filesystem(
659 host: &CacheHost,
660 directory: &Path,
661 executor: &impl CommandExecutor,
662) -> Result<Option<&'static str>> {
663 let filesystems =
664 targets::probe_filesystem_types(host.ssh(), &[directory.to_path_buf()], executor)?;
665 let filesystem = filesystems
666 .first()
667 .context("the filesystem probe named no filesystem")?;
668 Ok(targets::overlay_unsupported_filesystem(filesystem)
672 .filter(|reason| matches!(*reason, "network filesystem" | "FUSE filesystem")))
673}
674
675fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
676 if output.status == 0 {
677 return Ok(output);
678 }
679 bail!(
680 "{} failed with status {}: {}",
681 command.purpose,
682 output.status,
683 String::from_utf8_lossy(&output.stderr).trim()
684 )
685}
686
687pub(super) fn binary_for(
692 locator: &targets::TargetLocator,
693 executor: &impl CommandExecutor,
694) -> Result<PathBuf> {
695 let triple = super::worker_binary::target_architecture(locator, executor)?;
696 if let Some(path) = std::env::var_os(MBX_BINARY_ENV) {
697 let path = PathBuf::from(path);
698 ensure!(
699 path.is_file(),
700 "{MBX_BINARY_ENV} does not name a file: {}",
701 path.display()
702 );
703 if triple == host_architecture() {
704 return Ok(path);
705 }
706 tracing::warn!(
707 triple,
708 "{MBX_BINARY_ENV} is for this machine's architecture; downloading the pinned mbx \
709 for the target instead"
710 );
711 }
712 download(triple)
713}
714
715fn host_architecture() -> &'static str {
718 if cfg!(target_arch = "aarch64") {
719 "aarch64"
720 } else {
721 "x86_64"
722 }
723}
724
725fn release_url(triple: &str) -> String {
726 format!(
727 "https://github.com/jdx/mr-boxington/releases/download/v{MBX_VERSION}/mbx-{triple}-unknown-linux-musl.tar.gz"
728 )
729}
730
731fn expected_digest(triple: &str) -> Result<&'static str> {
732 match triple {
733 "x86_64" => Ok(MBX_X86_64_SHA256),
734 "aarch64" => Ok(MBX_AARCH64_SHA256),
735 _ => bail!("no pinned mbx release for {triple}"),
736 }
737}
738
739fn download(triple: &str) -> Result<PathBuf> {
742 let expected = expected_digest(triple)?;
743 let directory = mj_core::config::data_dir()
744 .join("mbx")
745 .join(MBX_VERSION)
746 .join(triple);
747 let destination = directory.join("mbx");
748 if destination.is_file() {
749 return Ok(destination);
750 }
751 std::fs::create_dir_all(&directory)
752 .with_context(|| format!("create the mbx cache {}", directory.display()))?;
753 let url = release_url(triple);
754 let archive = reqwest::blocking::Client::builder()
755 .timeout(Duration::from_secs(120))
756 .build()?
757 .get(&url)
758 .send()
759 .with_context(|| format!("download {url}"))?
760 .error_for_status()
761 .with_context(|| format!("download {url}"))?
762 .bytes()?;
763 let actual = mj_core::hex::lower_hex(Sha256::digest(&archive));
764 ensure!(
765 actual.eq_ignore_ascii_case(expected),
766 "downloaded mbx checksum mismatch: expected {expected}, got {actual}"
767 );
768 let binary = extract_binary(&archive)?;
769 let mut temporary = tempfile::NamedTempFile::new_in(&directory)?;
770 std::io::Write::write_all(&mut temporary, &binary)?;
771 temporary.as_file_mut().sync_all()?;
772 #[cfg(unix)]
773 {
774 use std::os::unix::fs::PermissionsExt;
775 std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o700))?;
776 }
777 match temporary.persist_noclobber(&destination) {
778 Ok(_) => Ok(destination),
779 Err(error) if destination.is_file() => {
780 drop(error);
781 Ok(destination)
782 }
783 Err(error) => Err(error.error)
784 .with_context(|| format!("publish the mbx binary {}", destination.display())),
785 }
786}
787
788fn extract_binary(archive: &[u8]) -> Result<Vec<u8>> {
791 let mut reader = tar::Archive::new(flate2::read::GzDecoder::new(archive));
792 for entry in reader.entries().context("read the mbx release archive")? {
793 let mut entry = entry.context("read the mbx release archive")?;
794 if entry.path().context("read an mbx archive path")?.as_ref() != Path::new("mbx") {
795 continue;
796 }
797 let mut bytes = Vec::new();
798 entry
799 .read_to_end(&mut bytes)
800 .context("read the mbx binary from its release archive")?;
801 return Ok(bytes);
802 }
803 bail!("the mbx release archive contains no mbx binary")
804}
805
806pub(super) fn primary_repository_is_rust(
812 host: &CacheHost,
813 mirror: &Path,
814 executor: &impl CommandExecutor,
815) -> bool {
816 let command = host.command(
817 vec![
818 "git".to_owned(),
819 "--git-dir".to_owned(),
820 mirror.to_string_lossy().into_owned(),
821 "cat-file".to_owned(),
822 "-e".to_owned(),
823 "HEAD:Cargo.toml".to_owned(),
824 ],
825 "detect a Cargo workspace in the session repository",
826 );
827 matches!(executor.execute(&command), Ok(output) if output.status == 0)
828}
829
830pub(super) fn prepare(
834 target: &targets::TargetTemplate,
835 global: &BuildCacheConfig,
836 session: &mj_core::state::SessionRecord,
837 bundle: Option<&targets::ProjectBundleSpec>,
838 clone_cache: Option<&super::git_cache::PreparedCloneCache>,
839 mounts: &mut Vec<targets::AdditionalMount>,
840 executor: &impl CommandExecutor,
841) -> Option<SessionBuildCache> {
842 let host_key = supported_host(target).map(|(host, _)| host.key());
845 if let Some(recorded) = &session.build_cache {
846 if host_key.as_deref() == Some(recorded.host.as_str()) {
847 return attach_mounts(recorded, mounts).then(|| recorded.clone());
848 }
849 tracing::info!(
850 session_id = session.id,
851 recorded_host = recorded.host,
852 host = host_key.as_deref().unwrap_or("unsupported target"),
853 "the session moved to another container host, so its build cache is resolved again"
854 );
855 }
856 session.container_workspace.as_ref()?;
859 let resolved = resolve(target, global, executor)?;
860 let host = supported_host(target)?.0;
861 let mirror = clone_cache?.mirror_for(&bundle?.primary)?;
862 if !primary_repository_is_rust(&host, mirror, executor) {
863 return None;
864 }
865 let build_cache = SessionBuildCache {
866 host: host.key(),
867 directory: resolved.directory,
868 max_size: resolved.max_size,
869 target_root: resolved.target_root,
870 };
871 attach_mounts(&build_cache, mounts).then_some(build_cache)
872}
873
874fn attach_mounts(
878 build_cache: &SessionBuildCache,
879 mounts: &mut Vec<targets::AdditionalMount>,
880) -> bool {
881 let wanted = std::iter::once(&build_cache.directory)
882 .chain(build_cache.target_root.iter())
883 .collect::<Vec<_>>();
884 for directory in &wanted {
885 if mounts.iter().any(|mount| {
886 mount.destination.starts_with(directory) || directory.starts_with(&mount.destination)
887 }) {
888 tracing::warn!(
889 directory = %directory.display(),
890 "an attached directory overlaps the build cache, so this session runs without it"
891 );
892 return false;
893 }
894 }
895 for directory in wanted {
896 mounts.push(targets::AdditionalMount {
897 source: directory.clone(),
898 destination: directory.clone(),
899 access: targets::MountAccess::Rw,
900 });
901 }
902 true
903}
904
905#[cfg(test)]
908pub(super) fn attach_mounts_for_tests(
909 build_cache: &SessionBuildCache,
910 mounts: &mut Vec<targets::AdditionalMount>,
911) -> bool {
912 attach_mounts(build_cache, mounts)
913}
914
915pub(super) fn host_configuration(
918 target: &targets::TargetTemplate,
919 global: &BuildCacheConfig,
920 executor: &impl CommandExecutor,
921) -> Option<String> {
922 resolve(target, global, executor)?.config_file
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928 use crate::targets::{ContainerTemplate, SshTarget, TargetTemplate};
929 use mj_core::config::ImagePullPolicy;
930 use std::sync::Mutex;
931
932 static ISOLATED: Mutex<()> = Mutex::new(());
935
936 fn isolated() -> std::sync::MutexGuard<'static, ()> {
937 let guard = ISOLATED.lock().unwrap_or_else(|error| error.into_inner());
938 RESOLUTIONS.lock().expect("mbx resolutions").clear();
939 guard
940 }
941
942 #[derive(Default)]
944 struct ProbeExecutor {
945 answers: Vec<(&'static str, i32, String)>,
946 seen: Mutex<Vec<String>>,
947 }
948
949 impl ProbeExecutor {
950 fn new(answers: &[(&'static str, i32, &str)]) -> Self {
951 Self {
952 answers: answers
953 .iter()
954 .map(|(needle, status, stdout)| (*needle, *status, (*stdout).to_owned()))
955 .collect(),
956 seen: Mutex::new(Vec::new()),
957 }
958 }
959
960 fn ran(&self) -> Vec<String> {
961 self.seen.lock().unwrap().clone()
962 }
963 }
964
965 impl CommandExecutor for ProbeExecutor {
966 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
967 let line = format!("{} {}", command.program, command.args.join(" "));
968 self.seen.lock().unwrap().push(line.clone());
969 for (needle, status, stdout) in &self.answers {
970 if line.contains(needle) {
971 return Ok(CommandOutput {
972 status: *status,
973 stdout: stdout.clone().into_bytes(),
974 stderr: Vec::new(),
975 });
976 }
977 }
978 Ok(CommandOutput {
979 status: 127,
980 stdout: Vec::new(),
981 stderr: format!("no canned answer for {line}").into_bytes(),
982 })
983 }
984 }
985
986 fn container(build_cache: Option<TargetBuildCache>) -> ContainerTemplate {
987 ContainerTemplate {
988 image: "example/image:latest".into(),
989 pull_policy: ImagePullPolicy::Missing,
990 extra_run_args: Vec::new(),
991 workspace_storage: Default::default(),
992 build_cache,
993 }
994 }
995
996 fn podman(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
997 TargetTemplate::LocalPodman(container(build_cache))
998 }
999
1000 fn docker(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1001 TargetTemplate::LocalDocker(container(build_cache))
1002 }
1003
1004 fn configured_local_machine() -> mj_core::config::Machine {
1007 serde_json::from_value(serde_json::json!({"kind": "local"})).unwrap()
1008 }
1009
1010 fn default_cache_directory() -> PathBuf {
1013 dirs::home_dir()
1014 .expect("a home directory")
1015 .join(DEFAULT_CACHE_RELATIVE)
1016 }
1017
1018 fn plain_host() -> Vec<(&'static str, i32, &'static str)> {
1021 vec![
1022 ("$m\" --version", 1, ""),
1023 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1024 ("[ -f \"$1\" ]", 3, ""),
1025 ("while [ ! -d", 0, "/home/dev"),
1026 (
1027 "df -B1 -P",
1028 0,
1029 "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 1000000000000 0 800000000000 20% /home\n",
1030 ),
1031 ("mj-reflink", 0, ""),
1032 ("mkdir -p", 0, ""),
1033 ("stat -f -c %T", 0, "xfs"),
1034 ]
1035 }
1036
1037 #[test]
1038 fn a_native_mbx_supplies_the_cache_directory_and_its_own_limits() {
1039 let _isolated = isolated();
1040 let executor = ProbeExecutor::new(&[
1041 ("$m\" --version", 0, "mbx\nmbx 1.15.0"),
1042 (
1043 "mbx cache dir --json",
1044 0,
1045 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1046 ),
1047 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1048 (
1049 "[ -f \"$1\" ]",
1050 0,
1051 "cache_dir = \"/mnt/fast/mbx-cache\"\n[gc]\nmax_size = \"500GiB\"\n",
1052 ),
1053 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1054 ("mj-reflink", 0, ""),
1055 ("mkdir -p", 0, ""),
1056 ("stat -f -c %T", 0, "xfs"),
1057 ]);
1058 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1059 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1060 assert_eq!(resolved.max_size, None);
1062 assert_eq!(resolved.target_root, None);
1063 assert!(resolved.config_file.unwrap().contains("500GiB"));
1064 assert!(
1065 !executor.ran().iter().any(|line| line.contains("df -B1")),
1066 "a host with its own configuration is not measured"
1067 );
1068 }
1069
1070 #[test]
1071 fn looking_at_the_settings_page_lets_the_next_session_see_a_repaired_host() {
1072 let _isolated = isolated();
1073 let broken = ProbeExecutor::new(
1074 &plain_host()
1075 .into_iter()
1076 .map(|(needle, status, stdout)| match needle {
1077 "mj-reflink" => (needle, 1, stdout),
1078 _ => (needle, status, stdout),
1079 })
1080 .collect::<Vec<_>>(),
1081 );
1082 assert!(
1083 resolve(&podman(None), &BuildCacheConfig::default(), &broken).is_none(),
1084 "a volume that cannot clone runs without the cache"
1085 );
1086
1087 let repaired = ProbeExecutor::new(&plain_host());
1090 let preview = preview_build_cache(
1091 &configured_local_machine(),
1092 &BuildCacheConfig::default(),
1093 &repaired,
1094 )
1095 .expect("the host answers")
1096 .expect("a local machine can hold a cache");
1097 assert_eq!(preview.off_reason, None);
1098
1099 assert!(
1100 resolve(&podman(None), &BuildCacheConfig::default(), &repaired).is_some(),
1101 "the next session asks the repaired host again instead of reusing the old verdict"
1102 );
1103 }
1104
1105 #[test]
1106 fn a_relocated_target_root_is_reported_for_its_own_mount() {
1107 let _isolated = isolated();
1108 let executor = ProbeExecutor::new(&[
1109 ("$m\" --version", 0, "mbx\nmbx 1.15.0"),
1110 (
1111 "mbx cache dir --json",
1112 0,
1113 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1114 ),
1115 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1116 (
1117 "[ -f \"$1\" ]",
1118 0,
1119 "[target]\nroot = \"/mnt/fast/mbx-targets\"\n",
1120 ),
1121 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1122 ("mj-reflink", 0, ""),
1123 ("mkdir -p", 0, ""),
1124 ("stat -f -c %T", 0, "xfs"),
1125 ]);
1126 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1127 assert_eq!(
1128 resolved.target_root,
1129 Some(PathBuf::from("/mnt/fast/mbx-targets"))
1130 );
1131 }
1132
1133 #[test]
1134 fn a_target_root_that_cannot_be_cloned_into_still_gets_the_cache() {
1135 let _isolated = isolated();
1136 let executor = ProbeExecutor::new(&[
1137 ("$m\" --version", 0, "mbx\nmbx 1.15.0"),
1138 (
1139 "mbx cache dir --json",
1140 0,
1141 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1142 ),
1143 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1144 (
1145 "[ -f \"$1\" ]",
1146 0,
1147 "[target]\nroot = \"/mnt/slow/mbx-targets\"\n",
1148 ),
1149 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1150 ("src=$1", 1, ""),
1153 ("mj-reflink", 0, ""),
1154 ("mkdir -p", 0, ""),
1155 ("stat -f -c %T", 0, "xfs"),
1156 ]);
1157 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor)
1158 .expect("a target root that copies instead of cloning is slower, not unusable");
1159 assert_eq!(
1160 resolved.target_root,
1161 Some(PathBuf::from("/mnt/slow/mbx-targets"))
1162 );
1163 assert!(
1164 executor.ran().iter().any(|line| line.contains("src=$1")),
1165 "the store and the target root are probed as a pair: {:?}",
1166 executor.ran()
1167 );
1168 }
1169
1170 #[test]
1171 fn a_target_root_inside_the_cache_directory_needs_no_second_mount() {
1172 assert_eq!(
1173 relocated_target_root("[target]\nroot = \"targets\"\n", Path::new("/cache")),
1174 None
1175 );
1176 assert_eq!(
1177 relocated_target_root("[target]\nroot = \"/cache/targets\"\n", Path::new("/cache")),
1178 None
1179 );
1180 }
1181
1182 #[test]
1183 fn a_cargo_installed_mbx_off_the_path_is_queried_where_it_was_found() {
1184 let _isolated = isolated();
1185 let mut answers = plain_host();
1186 answers.retain(|(needle, _, _)| *needle != "$m\" --version");
1187 answers.push(("$m\" --version", 0, "/home/dev/.cargo/bin/mbx\nmbx 1.15.0"));
1188 answers.push((
1189 "/home/dev/.cargo/bin/mbx cache dir --json",
1190 0,
1191 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1192 ));
1193 let executor = ProbeExecutor::new(&answers);
1194 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1195 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1196 }
1197
1198 #[test]
1199 fn an_older_native_mbx_must_not_share_the_store() {
1200 let _isolated = isolated();
1201 let executor = ProbeExecutor::new(&[("$m\" --version", 0, "mbx\nmbx 1.11.9")]);
1202 assert_eq!(
1203 resolve(&podman(None), &BuildCacheConfig::default(), &executor),
1204 None
1205 );
1206 }
1207
1208 #[test]
1209 fn a_host_without_mbx_falls_back_to_the_default_cache_directory() {
1210 let _isolated = isolated();
1211 let executor = ProbeExecutor::new(&plain_host());
1212 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1213 assert_eq!(resolved.directory, default_cache_directory());
1214 assert_eq!(resolved.max_size.as_deref(), Some("100000000000B"));
1216 }
1217
1218 #[test]
1219 fn a_small_volume_takes_a_quarter_of_its_free_space() {
1220 let _isolated = isolated();
1221 let mut answers = plain_host();
1222 answers.retain(|(needle, _, _)| *needle != "df -B1 -P");
1223 answers.push((
1224 "df -B1 -P",
1225 0,
1226 "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 100000000 60000000 40000000 60% /home\n",
1227 ));
1228 let executor = ProbeExecutor::new(&answers);
1229 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1230 assert_eq!(resolved.max_size.as_deref(), Some("10000000B"));
1231 }
1232
1233 #[test]
1234 fn target_overrides_win_over_every_default() {
1235 let _isolated = isolated();
1236 let mut answers = plain_host();
1237 answers.push(("mbx cache dir", 0, r#"{"store":"/other/actions"}"#));
1238 let executor = ProbeExecutor::new(&answers);
1239 let resolved = resolve(
1240 &podman(Some(TargetBuildCache {
1241 enabled: Some(true),
1242 directory: Some(PathBuf::from("/mnt/nvme/mbx")),
1243 max_size: Some("250GiB".into()),
1244 })),
1245 &BuildCacheConfig::default(),
1246 &executor,
1247 )
1248 .unwrap();
1249 assert_eq!(resolved.directory, PathBuf::from("/mnt/nvme/mbx"));
1250 assert_eq!(resolved.max_size.as_deref(), Some("250GiB"));
1251 }
1252
1253 #[test]
1256 fn an_enabled_setting_does_not_survive_a_volume_without_reflinks() {
1257 let _isolated = isolated();
1258 let mut answers = plain_host();
1259 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1260 answers.push(("mj-reflink", 1, ""));
1261 let executor = ProbeExecutor::new(&answers);
1262 assert_eq!(
1263 resolve(
1264 &podman(Some(TargetBuildCache {
1265 enabled: Some(true),
1266 directory: None,
1267 max_size: None,
1268 })),
1269 &BuildCacheConfig::default(),
1270 &executor,
1271 ),
1272 None
1273 );
1274 }
1275
1276 #[test]
1277 fn a_volume_without_reflinks_runs_without_the_cache() {
1278 let _isolated = isolated();
1279 let mut answers = plain_host();
1280 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1281 answers.push(("mj-reflink", 1, ""));
1282 let executor = ProbeExecutor::new(&answers);
1283 assert_eq!(
1284 resolve(&podman(None), &BuildCacheConfig::default(), &executor),
1285 None
1286 );
1287 }
1288
1289 #[test]
1290 fn the_preview_names_the_resolved_values_and_the_reason_the_cache_is_off() {
1291 let _isolated = isolated();
1292 let mut answers = plain_host();
1293 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1294 answers.push(("mj-reflink", 1, ""));
1295 let executor = ProbeExecutor::new(&answers);
1296 let preview = preview_build_cache(
1297 &configured_local_machine(),
1298 &BuildCacheConfig::default(),
1299 &executor,
1300 )
1301 .unwrap()
1302 .unwrap();
1303 assert_eq!(preview.native_mbx, None);
1304 assert_eq!(preview.directory, Some(default_cache_directory()));
1305 assert_eq!(
1306 preview.max_size,
1307 Some(BuildCacheLimit::Size("100000000000B".into()))
1308 );
1309 assert!(
1310 matches!(&preview.off_reason, Some(BuildCacheOff::Unavailable(reason)) if reason.contains("reflinks")),
1311 "{:?}",
1312 preview.off_reason
1313 );
1314 assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1316
1317 let executor = ProbeExecutor::new(&[
1318 ("$m\" --version", 0, "mbx\nmbx 1.15.0"),
1319 (
1320 "mbx cache dir --json",
1321 0,
1322 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1323 ),
1324 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1325 ("[ -f \"$1\" ]", 0, "[gc]\nmax_size = \"500GiB\"\n"),
1326 ("while [ ! -d", 0, "/mnt/fast"),
1327 ("mj-reflink", 0, ""),
1328 ("stat -f -c %T", 0, "xfs"),
1329 ]);
1330 let preview = preview_build_cache(
1331 &configured_local_machine(),
1332 &BuildCacheConfig::default(),
1333 &executor,
1334 )
1335 .unwrap()
1336 .unwrap();
1337 assert_eq!(preview.native_mbx.as_deref(), Some("1.15.0"));
1338 assert_eq!(
1339 preview.directory,
1340 Some(PathBuf::from("/mnt/fast/mbx-cache"))
1341 );
1342 assert_eq!(
1343 preview.max_size,
1344 Some(BuildCacheLimit::HostConfiguration(Some("500GiB".into())))
1345 );
1346 assert_eq!(preview.off_reason, None);
1347 assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1348 }
1349
1350 #[test]
1351 fn a_network_filesystem_runs_without_the_cache() {
1352 let _isolated = isolated();
1353 let mut answers = plain_host();
1354 answers.retain(|(needle, _, _)| *needle != "stat -f -c %T");
1355 answers.push(("stat -f -c %T", 0, "nfs4"));
1356 let executor = ProbeExecutor::new(&answers);
1357 assert_eq!(
1358 resolve(&podman(None), &BuildCacheConfig::default(), &executor),
1359 None
1360 );
1361 }
1362
1363 #[test]
1364 fn the_global_switch_short_circuits_every_host_command() {
1365 let _isolated = isolated();
1366 let executor = ProbeExecutor::new(&plain_host());
1367 assert_eq!(
1368 resolve(
1369 &podman(None),
1370 &BuildCacheConfig { enabled: false },
1371 &executor
1372 ),
1373 None
1374 );
1375 assert!(executor.ran().is_empty());
1376 }
1377
1378 #[test]
1379 fn local_podman_and_local_docker_inspect_one_machine_once() {
1380 let _isolated = isolated();
1381 let executor = ProbeExecutor::new(&plain_host());
1382 let settings = BuildCacheConfig::default();
1383 let first = resolve(&podman(None), &settings, &executor).unwrap();
1384 let ran = executor.ran().len();
1385 assert!(ran > 0, "the first resolve inspects the host");
1386 let second = resolve(&docker(None), &settings, &executor).unwrap();
1387 assert_eq!(
1388 first, second,
1389 "both engines on this machine share one cache"
1390 );
1391 let added = executor.ran()[ran..].to_vec();
1395 assert_eq!(
1396 added.len(),
1397 1,
1398 "the second runtime is answered from the machine's recorded inspection: {added:?}"
1399 );
1400 assert!(
1401 added[0].contains("mkdir -p"),
1402 "the one repeated command creates the directory: {added:?}"
1403 );
1404 }
1405
1406 #[test]
1407 fn the_preview_reports_what_the_cache_has_already_done() {
1408 let _isolated = isolated();
1409 let mut answers = vec![(
1411 "tally.json",
1412 0,
1413 r#"{"version":1,"since_secs":1789824719,"builds":155,"cached_compilations":12050,"avoided_compiler_ns":6004997818721,"reflinked_bytes":47612059386}"#,
1414 )];
1415 answers.extend(plain_host());
1416 let executor = ProbeExecutor::new(&answers);
1417 let preview = preview_build_cache(
1418 &configured_local_machine(),
1419 &BuildCacheConfig::default(),
1420 &executor,
1421 )
1422 .expect("the host answers")
1423 .expect("a local machine can hold a cache");
1424 assert_eq!(
1425 preview.stats,
1426 Some(mj_core::state::BuildCacheStats {
1427 builds: 155,
1428 cached_compilations: 12050,
1429 avoided_compiler_ns: 6_004_997_818_721,
1430 reflinked_bytes: 47_612_059_386,
1431 })
1432 );
1433 }
1434
1435 #[test]
1436 fn a_cache_nothing_has_used_yet_reports_no_totals() {
1437 let _isolated = isolated();
1438 let executor = ProbeExecutor::new(&plain_host());
1441 let preview = preview_build_cache(
1442 &configured_local_machine(),
1443 &BuildCacheConfig::default(),
1444 &executor,
1445 )
1446 .expect("the host answers")
1447 .expect("a local machine can hold a cache");
1448 assert_eq!(preview.stats, None);
1449 }
1450
1451 #[test]
1452 fn a_machine_without_a_standing_host_has_no_build_cache_preview() {
1453 let _isolated = isolated();
1454 let executor = ProbeExecutor::new(&plain_host());
1455 let fleet: mj_core::config::Machine = serde_json::from_value(serde_json::json!({
1456 "kind": "aws-ec2",
1457 "region": "us-east-1",
1458 "launch_template": "lt-1",
1459 "ssh_user": "ubuntu",
1460 }))
1461 .unwrap();
1462 assert_eq!(
1463 preview_build_cache(&fleet, &BuildCacheConfig::default(), &executor).unwrap(),
1464 None
1465 );
1466 assert!(executor.ran().is_empty());
1467 }
1468
1469 #[test]
1470 fn apple_and_bare_targets_have_no_shared_build_cache() {
1471 let _isolated = isolated();
1472 let executor = ProbeExecutor::new(&plain_host());
1473 for target in [
1474 TargetTemplate::AppleContainer(container(None)),
1475 TargetTemplate::LocalBare,
1476 TargetTemplate::SshBare {
1477 ssh: SshTarget {
1478 destination: "dev@example.test".into(),
1479 ssh_args: Vec::new(),
1480 },
1481 workspace_prefix: "workspaces".into(),
1482 },
1483 ] {
1484 assert_eq!(
1485 resolve(&target, &BuildCacheConfig::default(), &executor),
1486 None,
1487 "{target:?}"
1488 );
1489 }
1490 assert!(executor.ran().is_empty());
1491 }
1492
1493 fn bundle() -> targets::ProjectBundleSpec {
1494 targets::ProjectBundleSpec {
1495 primary: "main".into(),
1496 repositories: vec![targets::RepositorySpec {
1497 url: Some("https://github.com/example/main.git".into()),
1498 push_urls: Vec::new(),
1499 destination: "main".into(),
1500 git_ref: None,
1501 reference: None,
1502 }],
1503 }
1504 }
1505
1506 fn clone_cache() -> super::super::git_cache::PreparedCloneCache {
1507 super::super::git_cache::PreparedCloneCache::from_mirrors(
1508 [(
1509 "main".to_owned(),
1510 PathBuf::from("/home/dev/mirror/repo.git"),
1511 )]
1512 .into_iter()
1513 .collect(),
1514 )
1515 }
1516
1517 fn session(container_workspace: Option<&str>) -> mj_core::state::SessionRecord {
1518 let mut record = crate::controller::test_support::checkpoint_test_session("session-1");
1519 record.container_workspace = container_workspace.map(PathBuf::from);
1520 record
1521 }
1522
1523 #[test]
1524 fn a_rust_session_mounts_the_cache_at_the_host_path() {
1525 let _isolated = isolated();
1526 let mut answers = plain_host();
1527 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1528 let executor = ProbeExecutor::new(&answers);
1529 let mut mounts = Vec::new();
1530 let build_cache = prepare(
1531 &podman(None),
1532 &BuildCacheConfig::default(),
1533 &session(Some("/workspace/session-1")),
1534 Some(&bundle()),
1535 Some(&clone_cache()),
1536 &mut mounts,
1537 &executor,
1538 )
1539 .expect("a Rust session uses the build cache");
1540 assert_eq!(build_cache.directory, default_cache_directory());
1541 assert_eq!(
1542 mounts,
1543 vec![targets::AdditionalMount {
1544 source: default_cache_directory(),
1545 destination: default_cache_directory(),
1546 access: targets::MountAccess::Rw,
1547 }]
1548 );
1549 }
1550
1551 #[test]
1552 fn a_repository_without_a_root_manifest_runs_without_the_cache() {
1553 let _isolated = isolated();
1554 let mut answers = plain_host();
1555 answers.push(("cat-file -e HEAD:Cargo.toml", 1, ""));
1556 let executor = ProbeExecutor::new(&answers);
1557 let mut mounts = Vec::new();
1558 assert_eq!(
1559 prepare(
1560 &podman(None),
1561 &BuildCacheConfig::default(),
1562 &session(Some("/workspace/session-1")),
1563 Some(&bundle()),
1564 Some(&clone_cache()),
1565 &mut mounts,
1566 &executor,
1567 ),
1568 None
1569 );
1570 assert!(mounts.is_empty());
1571 }
1572
1573 #[test]
1574 fn a_session_at_the_legacy_shared_workspace_runs_without_the_cache() {
1575 let _isolated = isolated();
1576 let mut answers = plain_host();
1577 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1578 let executor = ProbeExecutor::new(&answers);
1579 let mut mounts = Vec::new();
1580 assert_eq!(
1581 prepare(
1582 &podman(None),
1583 &BuildCacheConfig::default(),
1584 &session(None),
1585 Some(&bundle()),
1586 Some(&clone_cache()),
1587 &mut mounts,
1588 &executor,
1589 ),
1590 None
1591 );
1592 assert!(executor.ran().is_empty());
1593 }
1594
1595 #[test]
1596 fn a_session_without_a_prepared_clone_cache_runs_without_the_cache() {
1597 let _isolated = isolated();
1598 let mut answers = plain_host();
1599 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1600 let executor = ProbeExecutor::new(&answers);
1601 let mut mounts = Vec::new();
1602 assert_eq!(
1603 prepare(
1604 &podman(None),
1605 &BuildCacheConfig::default(),
1606 &session(Some("/workspace/session-1")),
1607 Some(&bundle()),
1608 None,
1609 &mut mounts,
1610 &executor,
1611 ),
1612 None
1613 );
1614 }
1615
1616 #[test]
1617 fn an_apple_target_never_shares_a_build_cache() {
1618 let _isolated = isolated();
1619 let mut answers = plain_host();
1620 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1621 let executor = ProbeExecutor::new(&answers);
1622 let mut mounts = Vec::new();
1623 assert_eq!(
1624 prepare(
1625 &TargetTemplate::AppleContainer(container(None)),
1626 &BuildCacheConfig::default(),
1627 &session(Some("/workspace/session-1")),
1628 Some(&bundle()),
1629 Some(&clone_cache()),
1630 &mut mounts,
1631 &executor,
1632 ),
1633 None
1634 );
1635 assert!(executor.ran().is_empty());
1636 }
1637
1638 #[test]
1639 fn a_resumed_session_reuses_its_recorded_cache_without_resolving_again() {
1640 let _isolated = isolated();
1641 let executor = ProbeExecutor::new(&[]);
1642 let mut record = session(Some("/workspace/session-1"));
1643 record.build_cache = Some(SessionBuildCache {
1644 host: "local".into(),
1645 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1646 max_size: None,
1647 target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
1648 });
1649 let mut mounts = Vec::new();
1650 let build_cache = prepare(
1651 &podman(None),
1652 &BuildCacheConfig::default(),
1653 &record,
1654 None,
1655 None,
1656 &mut mounts,
1657 &executor,
1658 )
1659 .expect("a resumed session keeps its build cache");
1660 assert_eq!(build_cache, record.build_cache.unwrap());
1661 assert_eq!(
1662 mounts
1663 .iter()
1664 .map(|mount| mount.destination.clone())
1665 .collect::<Vec<_>>(),
1666 vec![
1667 PathBuf::from("/mnt/fast/mbx-cache"),
1668 PathBuf::from("/mnt/fast/mbx-targets"),
1669 ]
1670 );
1671 assert!(executor.ran().is_empty());
1672 }
1673
1674 #[test]
1675 fn a_session_moved_to_another_host_resolves_its_build_cache_again() {
1676 let _isolated = isolated();
1677 let mut answers = plain_host();
1678 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1679 let executor = ProbeExecutor::new(&answers);
1680 let mut record = session(Some("/workspace/session-1"));
1681 record.build_cache = Some(SessionBuildCache {
1682 host: "ssh:dev@example.test".into(),
1685 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1686 max_size: None,
1687 target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
1688 });
1689 let mut mounts = Vec::new();
1690
1691 let build_cache = prepare(
1692 &podman(None),
1693 &BuildCacheConfig::default(),
1694 &record,
1695 Some(&bundle()),
1696 Some(&clone_cache()),
1697 &mut mounts,
1698 &executor,
1699 )
1700 .expect("the destination host qualifies on its own");
1701
1702 assert_eq!(build_cache.host, "local");
1703 assert_eq!(build_cache.directory, default_cache_directory());
1704 assert_eq!(build_cache.target_root, None);
1705 assert_eq!(
1706 mounts
1707 .iter()
1708 .map(|mount| mount.destination.clone())
1709 .collect::<Vec<_>>(),
1710 vec![default_cache_directory()]
1711 );
1712 assert!(
1713 executor
1714 .ran()
1715 .iter()
1716 .any(|line| line.contains("mj-reflink"))
1717 );
1718 }
1719
1720 #[test]
1721 fn an_attached_directory_over_the_cache_wins() {
1722 let build_cache = SessionBuildCache {
1723 host: "local-podman".into(),
1724 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1725 max_size: None,
1726 target_root: None,
1727 };
1728 let mut mounts = vec![targets::AdditionalMount {
1729 source: PathBuf::from("/elsewhere"),
1730 destination: PathBuf::from("/mnt/fast/mbx-cache/actions"),
1731 access: targets::MountAccess::Ro,
1732 }];
1733 assert!(!attach_mounts(&build_cache, &mut mounts));
1734 assert_eq!(mounts.len(), 1);
1735 }
1736
1737 #[test]
1738 fn versions_compare_by_release_order() {
1739 assert!(version_at_least("1.12.0", "1.12.0"));
1740 assert!(version_at_least("1.12.1", "1.12.0"));
1741 assert!(version_at_least("2.0.0", "1.12.0"));
1742 assert!(!version_at_least("1.11.9", "1.12.0"));
1743 assert!(!version_at_least("1.9.0", "1.12.0"));
1744 assert!(!version_at_least("not-a-version", "1.12.0"));
1745 }
1746
1747 #[test]
1748 fn free_space_is_read_from_the_available_column() {
1749 assert_eq!(
1750 available_bytes(
1751 "Filesystem 1B-blocks Used Available Capacity Mounted on\n\
1752 /dev/sda1 1000 400 600 40% /\n"
1753 ),
1754 Some(600)
1755 );
1756 assert_eq!(available_bytes("Filesystem 1B-blocks\n"), None);
1757 }
1758}