Skip to main content

mj_controller/controller/
mbx.rs

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