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 => home(host, 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
360fn home(host: &CacheHost, executor: &impl CommandExecutor) -> Result<PathBuf> {
361    let command = host.shell_command(
362        r#"printf '%s' "$HOME""#,
363        LABEL,
364        [],
365        "locate the container host home directory",
366    );
367    let output = checked(executor.execute(&command)?, &command)?;
368    let home = PathBuf::from(String::from_utf8(output.stdout).context("decode host HOME")?);
369    ensure!(home.is_absolute(), "container host HOME is not absolute");
370    Ok(home)
371}
372
373const READ_CONFIG_SCRIPT: &str = r#"[ -f "$1" ] || exit 3
374cat -- "$1""#;
375
376/// The host's `~/.config/mbx/config.toml`, which containers receive verbatim
377/// so their mbx uses the host's own limits. mbx has no command that prints its
378/// effective configuration, so the file itself is the only accurate source.
379fn host_config_file(host: &CacheHost, executor: &impl CommandExecutor) -> Result<Option<String>> {
380    let path = home(host, executor)?.join(HOST_CONFIG_RELATIVE);
381    let command = host.shell_command(
382        READ_CONFIG_SCRIPT,
383        LABEL,
384        [path.to_string_lossy().into_owned()],
385        "read the container host mbx configuration",
386    );
387    let output = executor.execute(&command)?;
388    if output.status == 3 {
389        return Ok(None);
390    }
391    let output = checked(output, &command)?;
392    Ok(Some(
393        String::from_utf8(output.stdout).context("decode the host mbx configuration")?,
394    ))
395}
396
397/// The `[target] root` a host configuration sets, when it lies outside the
398/// cache directory and therefore needs its own mount.
399fn relocated_target_root(config_file: &str, directory: &Path) -> Option<PathBuf> {
400    let document: toml::Value = toml::from_str(config_file)
401        .map_err(|error| tracing::warn!("the host mbx configuration is unreadable: {error}"))
402        .ok()?;
403    let root = document.get("target")?.get("root")?.as_str()?;
404    let root = directory.join(root);
405    (!root.starts_with(directory)).then_some(root)
406}
407
408const NEAREST_ANCESTOR_SCRIPT: &str = r#"d=$1
409while [ ! -d "$d" ]; do
410    parent=$(dirname -- "$d")
411    if [ "$parent" = "$d" ]; then
412        break
413    fi
414    d=$parent
415done
416printf '%s' "$d""#;
417
418/// The deepest existing directory at or above `directory`. The cache directory
419/// may not exist yet, and both `df` and the reflink probe need a real one.
420fn nearest_existing_ancestor(
421    host: &CacheHost,
422    directory: &Path,
423    executor: &impl CommandExecutor,
424) -> Result<PathBuf> {
425    let command = host.shell_command(
426        NEAREST_ANCESTOR_SCRIPT,
427        LABEL,
428        [directory.to_string_lossy().into_owned()],
429        "locate the build cache volume",
430    );
431    let output = checked(executor.execute(&command)?, &command)?;
432    let path = PathBuf::from(String::from_utf8(output.stdout).context("decode cache ancestor")?);
433    ensure!(
434        path.is_absolute(),
435        "build cache volume {} is not absolute",
436        path.display()
437    );
438    Ok(path)
439}
440
441/// The budget mj gives a host that has no mbx configuration of its own: the
442/// smaller of 100 GB and a quarter of the free space on the cache volume.
443fn default_max_size(
444    host: &CacheHost,
445    directory: &Path,
446    executor: &impl CommandExecutor,
447) -> Result<String> {
448    let volume = nearest_existing_ancestor(host, directory, executor)?;
449    let command = host.command(
450        vec![
451            "df".to_owned(),
452            "-B1".to_owned(),
453            "-P".to_owned(),
454            "--".to_owned(),
455            volume.to_string_lossy().into_owned(),
456        ],
457        "measure the build cache volume",
458    );
459    let output = checked(executor.execute(&command)?, &command)?;
460    let available = available_bytes(&String::from_utf8_lossy(&output.stdout))
461        .context("read the free space on the build cache volume")?;
462    Ok(format!("{}B", DEFAULT_MAX_BYTES.min(available / 4)))
463}
464
465/// The available column of `df -B1 -P` output, which is the fourth field of
466/// the row after the header. A long device name wraps in some `df`
467/// implementations, so the fields are counted from the end of the last row.
468fn available_bytes(report: &str) -> Option<u64> {
469    let row = report
470        .lines()
471        .filter(|line| !line.trim().is_empty())
472        .nth(1)?;
473    let fields = row.split_whitespace().collect::<Vec<_>>();
474    // ... size used available capacity mounted-on
475    let available = fields.get(fields.len().checked_sub(3)?)?;
476    available.parse().ok()
477}
478
479const REFLINK_SCRIPT: &str = r#"dir=$1
480d=$(mktemp -d "$dir/.mj-reflink.XXXXXX") || exit 1
481printf x > "$d/a" && cp --reflink=always "$d/a" "$d/b"
482status=$?
483rm -rf -- "$d"
484exit $status"#;
485
486/// Whether the cache volume can clone files instead of copying their bytes.
487/// Reflinks are what make restoring a cached output nearly free, so a host
488/// without them defaults to running without the cache.
489fn reflinks_supported(
490    host: &CacheHost,
491    volume: &Path,
492    executor: &impl CommandExecutor,
493) -> Result<bool> {
494    let command = host.shell_command(
495        REFLINK_SCRIPT,
496        LABEL,
497        [volume.to_string_lossy().into_owned()],
498        "probe the build cache volume for reflinks",
499    );
500    Ok(executor.execute(&command)?.status == 0)
501}
502
503fn create_directory(
504    host: &CacheHost,
505    directory: &Path,
506    executor: &impl CommandExecutor,
507) -> Result<()> {
508    let command = host.command(
509        vec![
510            "mkdir".to_owned(),
511            "-p".to_owned(),
512            "--".to_owned(),
513            directory.to_string_lossy().into_owned(),
514        ],
515        "create the build cache directory",
516    );
517    checked(executor.execute(&command)?, &command).map(|_| ())
518}
519
520/// A filesystem mbx cannot use. It refuses NFS outright, and file locks over
521/// FUSE, virtiofs, and 9p are unreliable, which a shared store depends on.
522fn unusable_filesystem(
523    host: &CacheHost,
524    directory: &Path,
525    executor: &impl CommandExecutor,
526) -> Result<Option<&'static str>> {
527    let filesystems =
528        targets::probe_filesystem_types(host.ssh(), &[directory.to_path_buf()], executor)?;
529    let filesystem = filesystems
530        .first()
531        .context("the filesystem probe named no filesystem")?;
532    // `overlay_unsupported_filesystem` already groups virtiofs and 9p with the
533    // network filesystems. The other reasons it gives are about stacking an
534    // overlay, which a plain read-write bind mount does not do.
535    Ok(targets::overlay_unsupported_filesystem(filesystem)
536        .filter(|reason| matches!(*reason, "network filesystem" | "FUSE filesystem")))
537}
538
539fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
540    if output.status == 0 {
541        return Ok(output);
542    }
543    bail!(
544        "{} failed with status {}: {}",
545        command.purpose,
546        output.status,
547        String::from_utf8_lossy(&output.stderr).trim()
548    )
549}
550
551// -- the pinned mbx binary ------------------------------------------------
552
553/// The mbx binary to install in a container of this architecture, downloading
554/// and verifying the pinned release on first use.
555pub(super) fn binary_for(
556    locator: &targets::TargetLocator,
557    executor: &impl CommandExecutor,
558) -> Result<PathBuf> {
559    let triple = super::worker_binary::target_architecture(locator, executor)?;
560    if let Some(path) = std::env::var_os(MBX_BINARY_ENV) {
561        let path = PathBuf::from(path);
562        ensure!(
563            path.is_file(),
564            "{MBX_BINARY_ENV} does not name a file: {}",
565            path.display()
566        );
567        if triple == host_architecture() {
568            return Ok(path);
569        }
570        tracing::warn!(
571            triple,
572            "{MBX_BINARY_ENV} is for this machine's architecture; downloading the pinned mbx \
573             for the target instead"
574        );
575    }
576    download(triple)
577}
578
579/// This machine's architecture in the same spelling `target_architecture`
580/// reports, so a local override is not handed to a foreign container.
581fn host_architecture() -> &'static str {
582    if cfg!(target_arch = "aarch64") {
583        "aarch64"
584    } else {
585        "x86_64"
586    }
587}
588
589fn release_url(triple: &str) -> String {
590    format!(
591        "https://github.com/jdx/mr-boxington/releases/download/v{MBX_VERSION}/mbx-{triple}-unknown-linux-musl.tar.gz"
592    )
593}
594
595fn expected_digest(triple: &str) -> Result<&'static str> {
596    match triple {
597        "x86_64" => Ok(MBX_X86_64_SHA256),
598        "aarch64" => Ok(MBX_AARCH64_SHA256),
599        _ => bail!("no pinned mbx release for {triple}"),
600    }
601}
602
603/// Download the pinned release once into the data directory. The archive is
604/// verified against the release checksum before anything is extracted.
605fn download(triple: &str) -> Result<PathBuf> {
606    let expected = expected_digest(triple)?;
607    let directory = mj_core::config::data_dir()
608        .join("mbx")
609        .join(MBX_VERSION)
610        .join(triple);
611    let destination = directory.join("mbx");
612    if destination.is_file() {
613        return Ok(destination);
614    }
615    std::fs::create_dir_all(&directory)
616        .with_context(|| format!("create the mbx cache {}", directory.display()))?;
617    let url = release_url(triple);
618    let archive = reqwest::blocking::Client::builder()
619        .timeout(Duration::from_secs(120))
620        .build()?
621        .get(&url)
622        .send()
623        .with_context(|| format!("download {url}"))?
624        .error_for_status()
625        .with_context(|| format!("download {url}"))?
626        .bytes()?;
627    let actual = mj_core::hex::lower_hex(Sha256::digest(&archive));
628    ensure!(
629        actual.eq_ignore_ascii_case(expected),
630        "downloaded mbx checksum mismatch: expected {expected}, got {actual}"
631    );
632    let binary = extract_binary(&archive)?;
633    let mut temporary = tempfile::NamedTempFile::new_in(&directory)?;
634    std::io::Write::write_all(&mut temporary, &binary)?;
635    temporary.as_file_mut().sync_all()?;
636    #[cfg(unix)]
637    {
638        use std::os::unix::fs::PermissionsExt;
639        std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o700))?;
640    }
641    match temporary.persist_noclobber(&destination) {
642        Ok(_) => Ok(destination),
643        Err(error) if destination.is_file() => {
644            drop(error);
645            Ok(destination)
646        }
647        Err(error) => Err(error.error)
648            .with_context(|| format!("publish the mbx binary {}", destination.display())),
649    }
650}
651
652/// The single `mbx` file from the release archive, which also carries its
653/// licence texts.
654fn extract_binary(archive: &[u8]) -> Result<Vec<u8>> {
655    let mut reader = tar::Archive::new(flate2::read::GzDecoder::new(archive));
656    for entry in reader.entries().context("read the mbx release archive")? {
657        let mut entry = entry.context("read the mbx release archive")?;
658        if entry.path().context("read an mbx archive path")?.as_ref() != Path::new("mbx") {
659            continue;
660        }
661        let mut bytes = Vec::new();
662        entry
663            .read_to_end(&mut bytes)
664            .context("read the mbx binary from its release archive")?;
665        return Ok(bytes);
666    }
667    bail!("the mbx release archive contains no mbx binary")
668}
669
670// -- per-session decision -------------------------------------------------
671
672/// Whether the primary repository is a Cargo workspace, read from the host
673/// mirror the clone cache prepared. A repository whose manifest is not at its
674/// root, and a session whose clone cache was not prepared, run without mbx.
675pub(super) fn primary_repository_is_rust(
676    host: &CacheHost,
677    mirror: &Path,
678    executor: &impl CommandExecutor,
679) -> bool {
680    let command = host.command(
681        vec![
682            "git".to_owned(),
683            "--git-dir".to_owned(),
684            mirror.to_string_lossy().into_owned(),
685            "cat-file".to_owned(),
686            "-e".to_owned(),
687            "HEAD:Cargo.toml".to_owned(),
688        ],
689        "detect a Cargo workspace in the session repository",
690    );
691    matches!(executor.execute(&command), Ok(output) if output.status == 0)
692}
693
694/// Decide the build cache for one session and attach its mounts, returning the
695/// value to record on the session. A session that already carries a decision
696/// (resume, move, or a sub-agent child) reuses it without resolving again.
697pub(super) fn prepare(
698    target: &targets::TargetTemplate,
699    global: &BuildCacheConfig,
700    session: &mj_core::state::SessionRecord,
701    bundle: Option<&targets::ProjectBundleSpec>,
702    clone_cache: Option<&super::git_cache::PreparedCloneCache>,
703    mounts: &mut Vec<targets::AdditionalMount>,
704    executor: &impl CommandExecutor,
705) -> Option<SessionBuildCache> {
706    // A recorded cache is a directory on one particular host, so it only
707    // survives a resume that stays on that host.
708    let host_key = supported_host(target).map(|(host, _)| host.key());
709    if let Some(recorded) = &session.build_cache {
710        if host_key.as_deref() == Some(recorded.host.as_str()) {
711            return attach_mounts(recorded, mounts).then(|| recorded.clone());
712        }
713        tracing::info!(
714            session_id = session.id,
715            recorded_host = recorded.host,
716            host = host_key.as_deref().unwrap_or("unsupported target"),
717            "the session moved to another container host, so its build cache is resolved again"
718        );
719    }
720    // A session at the legacy shared `/workspace` would collide with every
721    // other legacy session in mbx's path-keyed records.
722    session.container_workspace.as_ref()?;
723    let resolved = resolve(target, global, executor)?;
724    let host = supported_host(target)?.0;
725    let mirror = clone_cache?.mirror_for(&bundle?.primary)?;
726    if !primary_repository_is_rust(&host, mirror, executor) {
727        return None;
728    }
729    let build_cache = SessionBuildCache {
730        host: host.key(),
731        directory: resolved.directory,
732        max_size: resolved.max_size,
733        target_root: resolved.target_root,
734    };
735    attach_mounts(&build_cache, mounts).then_some(build_cache)
736}
737
738/// Mount the cache, and a relocated target root, read-write at the same
739/// absolute paths the host uses. An attached directory that already covers one
740/// of those paths wins, and the session runs without the cache.
741fn attach_mounts(
742    build_cache: &SessionBuildCache,
743    mounts: &mut Vec<targets::AdditionalMount>,
744) -> bool {
745    let wanted = std::iter::once(&build_cache.directory)
746        .chain(build_cache.target_root.iter())
747        .collect::<Vec<_>>();
748    for directory in &wanted {
749        if mounts.iter().any(|mount| {
750            mount.destination.starts_with(directory) || directory.starts_with(&mount.destination)
751        }) {
752            tracing::warn!(
753                directory = %directory.display(),
754                "an attached directory overlaps the build cache, so this session runs without it"
755            );
756            return false;
757        }
758    }
759    for directory in wanted {
760        mounts.push(targets::AdditionalMount {
761            source: directory.clone(),
762            destination: directory.clone(),
763            access: targets::MountAccess::Rw,
764        });
765    }
766    true
767}
768
769/// `attach_mounts` for the provisioning tests, which check the container
770/// arguments the mounts produce.
771#[cfg(test)]
772pub(super) fn attach_mounts_for_tests(
773    build_cache: &SessionBuildCache,
774    mounts: &mut Vec<targets::AdditionalMount>,
775) -> bool {
776    attach_mounts(build_cache, mounts)
777}
778
779/// The host's mbx configuration file for this target, read through the cached
780/// resolution so the worker install does not repeat the host commands.
781pub(super) fn host_configuration(
782    target: &targets::TargetTemplate,
783    global: &BuildCacheConfig,
784    executor: &impl CommandExecutor,
785) -> Option<String> {
786    resolve(target, global, executor)?.config_file
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use crate::targets::{ContainerTemplate, SshTarget, TargetTemplate};
793    use mj_core::config::ImagePullPolicy;
794    use std::sync::Mutex;
795
796    /// The resolution cache is process-wide, so tests that exercise it run one
797    /// at a time and start from an empty cache.
798    static ISOLATED: Mutex<()> = Mutex::new(());
799
800    fn isolated() -> std::sync::MutexGuard<'static, ()> {
801        let guard = ISOLATED.lock().unwrap_or_else(|error| error.into_inner());
802        RESOLUTIONS.lock().expect("mbx resolutions").clear();
803        guard
804    }
805
806    /// Answers canned commands by a substring of their joined argument list.
807    #[derive(Default)]
808    struct ProbeExecutor {
809        answers: Vec<(&'static str, i32, String)>,
810        seen: Mutex<Vec<String>>,
811    }
812
813    impl ProbeExecutor {
814        fn new(answers: &[(&'static str, i32, &str)]) -> Self {
815            Self {
816                answers: answers
817                    .iter()
818                    .map(|(needle, status, stdout)| (*needle, *status, (*stdout).to_owned()))
819                    .collect(),
820                seen: Mutex::new(Vec::new()),
821            }
822        }
823
824        fn ran(&self) -> Vec<String> {
825            self.seen.lock().unwrap().clone()
826        }
827    }
828
829    impl CommandExecutor for ProbeExecutor {
830        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
831            let line = format!("{} {}", command.program, command.args.join(" "));
832            self.seen.lock().unwrap().push(line.clone());
833            for (needle, status, stdout) in &self.answers {
834                if line.contains(needle) {
835                    return Ok(CommandOutput {
836                        status: *status,
837                        stdout: stdout.clone().into_bytes(),
838                        stderr: Vec::new(),
839                    });
840                }
841            }
842            Ok(CommandOutput {
843                status: 127,
844                stdout: Vec::new(),
845                stderr: format!("no canned answer for {line}").into_bytes(),
846            })
847        }
848    }
849
850    fn container(build_cache: Option<TargetBuildCache>) -> ContainerTemplate {
851        ContainerTemplate {
852            image: "example/image:latest".into(),
853            pull_policy: ImagePullPolicy::Missing,
854            extra_run_args: Vec::new(),
855            workspace_storage: Default::default(),
856            build_cache,
857        }
858    }
859
860    fn podman(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
861        TargetTemplate::LocalPodman(container(build_cache))
862    }
863
864    fn docker(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
865        TargetTemplate::LocalDocker(container(build_cache))
866    }
867
868    /// The settings draft's view of this machine with blank build cache
869    /// fields.
870    fn configured_local_machine() -> mj_core::config::Machine {
871        serde_json::from_value(serde_json::json!({"kind": "local"})).unwrap()
872    }
873
874    /// The canned answers a host with no native mbx and a reflink-capable
875    /// home directory gives.
876    fn plain_host() -> Vec<(&'static str, i32, &'static str)> {
877        vec![
878            ("$m\" --version", 1, ""),
879            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
880            ("[ -f \"$1\" ]", 3, ""),
881            ("while [ ! -d", 0, "/home/dev"),
882            (
883                "df -B1 -P",
884                0,
885                "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 1000000000000 0 800000000000 20% /home\n",
886            ),
887            ("mj-reflink", 0, ""),
888            ("mkdir -p", 0, ""),
889            ("stat -f -c %T", 0, "xfs"),
890        ]
891    }
892
893    #[test]
894    fn a_native_mbx_supplies_the_cache_directory_and_its_own_limits() {
895        let _isolated = isolated();
896        let executor = ProbeExecutor::new(&[
897            ("$m\" --version", 0, "mbx\nmbx 1.12.0"),
898            (
899                "mbx cache dir --json",
900                0,
901                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
902            ),
903            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
904            (
905                "[ -f \"$1\" ]",
906                0,
907                "cache_dir = \"/mnt/fast/mbx-cache\"\n[gc]\nmax_size = \"500GiB\"\n",
908            ),
909            ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
910            ("mj-reflink", 0, ""),
911            ("mkdir -p", 0, ""),
912            ("stat -f -c %T", 0, "xfs"),
913        ]);
914        let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
915        assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
916        // The host's own configuration file carries the budget.
917        assert_eq!(resolved.max_size, None);
918        assert_eq!(resolved.target_root, None);
919        assert!(resolved.config_file.unwrap().contains("500GiB"));
920        assert!(
921            !executor.ran().iter().any(|line| line.contains("df -B1")),
922            "a host with its own configuration is not measured"
923        );
924    }
925
926    #[test]
927    fn a_relocated_target_root_is_reported_for_its_own_mount() {
928        let _isolated = isolated();
929        let executor = ProbeExecutor::new(&[
930            ("$m\" --version", 0, "mbx\nmbx 1.12.0"),
931            (
932                "mbx cache dir --json",
933                0,
934                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
935            ),
936            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
937            (
938                "[ -f \"$1\" ]",
939                0,
940                "[target]\nroot = \"/mnt/fast/mbx-targets\"\n",
941            ),
942            ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
943            ("mj-reflink", 0, ""),
944            ("mkdir -p", 0, ""),
945            ("stat -f -c %T", 0, "xfs"),
946        ]);
947        let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
948        assert_eq!(
949            resolved.target_root,
950            Some(PathBuf::from("/mnt/fast/mbx-targets"))
951        );
952    }
953
954    #[test]
955    fn a_target_root_inside_the_cache_directory_needs_no_second_mount() {
956        assert_eq!(
957            relocated_target_root("[target]\nroot = \"targets\"\n", Path::new("/cache")),
958            None
959        );
960        assert_eq!(
961            relocated_target_root("[target]\nroot = \"/cache/targets\"\n", Path::new("/cache")),
962            None
963        );
964    }
965
966    #[test]
967    fn a_cargo_installed_mbx_off_the_path_is_queried_where_it_was_found() {
968        let _isolated = isolated();
969        let mut answers = plain_host();
970        answers.retain(|(needle, _, _)| *needle != "$m\" --version");
971        answers.push(("$m\" --version", 0, "/home/dev/.cargo/bin/mbx\nmbx 1.12.0"));
972        answers.push((
973            "/home/dev/.cargo/bin/mbx cache dir --json",
974            0,
975            r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
976        ));
977        let executor = ProbeExecutor::new(&answers);
978        let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
979        assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
980    }
981
982    #[test]
983    fn an_older_native_mbx_must_not_share_the_store() {
984        let _isolated = isolated();
985        let executor = ProbeExecutor::new(&[("$m\" --version", 0, "mbx\nmbx 1.11.9")]);
986        assert_eq!(
987            resolve(&podman(None), &BuildCacheConfig::default(), &executor),
988            None
989        );
990    }
991
992    #[test]
993    fn a_host_without_mbx_falls_back_to_the_default_cache_directory() {
994        let _isolated = isolated();
995        let executor = ProbeExecutor::new(&plain_host());
996        let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
997        assert_eq!(resolved.directory, PathBuf::from("/home/dev/.cache/mbx"));
998        // min(100 GB, 800 GB / 4) is the 100 GB cap.
999        assert_eq!(resolved.max_size.as_deref(), Some("100000000000B"));
1000    }
1001
1002    #[test]
1003    fn a_small_volume_takes_a_quarter_of_its_free_space() {
1004        let _isolated = isolated();
1005        let mut answers = plain_host();
1006        answers.retain(|(needle, _, _)| *needle != "df -B1 -P");
1007        answers.push((
1008            "df -B1 -P",
1009            0,
1010            "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 100000000 60000000 40000000 60% /home\n",
1011        ));
1012        let executor = ProbeExecutor::new(&answers);
1013        let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1014        assert_eq!(resolved.max_size.as_deref(), Some("10000000B"));
1015    }
1016
1017    #[test]
1018    fn target_overrides_win_over_every_default() {
1019        let _isolated = isolated();
1020        let mut answers = plain_host();
1021        answers.push(("mbx cache dir", 0, r#"{"store":"/other/actions"}"#));
1022        let executor = ProbeExecutor::new(&answers);
1023        let resolved = resolve(
1024            &podman(Some(TargetBuildCache {
1025                enabled: Some(true),
1026                directory: Some(PathBuf::from("/mnt/nvme/mbx")),
1027                max_size: Some("250GiB".into()),
1028            })),
1029            &BuildCacheConfig::default(),
1030            &executor,
1031        )
1032        .unwrap();
1033        assert_eq!(resolved.directory, PathBuf::from("/mnt/nvme/mbx"));
1034        assert_eq!(resolved.max_size.as_deref(), Some("250GiB"));
1035        assert!(
1036            !executor
1037                .ran()
1038                .iter()
1039                .any(|line| line.contains("mj-reflink")),
1040            "an explicit enabled setting skips the reflink probe"
1041        );
1042    }
1043
1044    #[test]
1045    fn a_volume_without_reflinks_runs_without_the_cache() {
1046        let _isolated = isolated();
1047        let mut answers = plain_host();
1048        answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1049        answers.push(("mj-reflink", 1, ""));
1050        let executor = ProbeExecutor::new(&answers);
1051        assert_eq!(
1052            resolve(&podman(None), &BuildCacheConfig::default(), &executor),
1053            None
1054        );
1055    }
1056
1057    #[test]
1058    fn the_preview_names_the_resolved_values_and_the_reason_the_cache_is_off() {
1059        let _isolated = isolated();
1060        let mut answers = plain_host();
1061        answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1062        answers.push(("mj-reflink", 1, ""));
1063        let executor = ProbeExecutor::new(&answers);
1064        let preview = preview_build_cache(
1065            &configured_local_machine(),
1066            &BuildCacheConfig::default(),
1067            &executor,
1068        )
1069        .unwrap()
1070        .unwrap();
1071        assert_eq!(preview.native_mbx, None);
1072        assert_eq!(
1073            preview.directory,
1074            Some(PathBuf::from("/home/dev/.cache/mbx"))
1075        );
1076        assert_eq!(
1077            preview.max_size,
1078            Some(BuildCacheLimit::Size("100000000000B".into()))
1079        );
1080        assert!(
1081            preview
1082                .off_reason
1083                .as_deref()
1084                .is_some_and(|reason| reason.contains("reflinks")),
1085            "{:?}",
1086            preview.off_reason
1087        );
1088        // A preview reads the host; it never creates the directory.
1089        assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1090
1091        let executor = ProbeExecutor::new(&[
1092            ("$m\" --version", 0, "mbx\nmbx 1.12.0"),
1093            (
1094                "mbx cache dir --json",
1095                0,
1096                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1097            ),
1098            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1099            ("[ -f \"$1\" ]", 0, "[gc]\nmax_size = \"500GiB\"\n"),
1100            ("while [ ! -d", 0, "/mnt/fast"),
1101            ("mj-reflink", 0, ""),
1102            ("stat -f -c %T", 0, "xfs"),
1103        ]);
1104        let preview = preview_build_cache(
1105            &configured_local_machine(),
1106            &BuildCacheConfig::default(),
1107            &executor,
1108        )
1109        .unwrap()
1110        .unwrap();
1111        assert_eq!(preview.native_mbx.as_deref(), Some("1.12.0"));
1112        assert_eq!(
1113            preview.directory,
1114            Some(PathBuf::from("/mnt/fast/mbx-cache"))
1115        );
1116        assert_eq!(
1117            preview.max_size,
1118            Some(BuildCacheLimit::HostConfiguration(Some("500GiB".into())))
1119        );
1120        assert_eq!(preview.off_reason, None);
1121        assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1122    }
1123
1124    #[test]
1125    fn a_network_filesystem_runs_without_the_cache() {
1126        let _isolated = isolated();
1127        let mut answers = plain_host();
1128        answers.retain(|(needle, _, _)| *needle != "stat -f -c %T");
1129        answers.push(("stat -f -c %T", 0, "nfs4"));
1130        let executor = ProbeExecutor::new(&answers);
1131        assert_eq!(
1132            resolve(&podman(None), &BuildCacheConfig::default(), &executor),
1133            None
1134        );
1135    }
1136
1137    #[test]
1138    fn the_global_switch_short_circuits_every_host_command() {
1139        let _isolated = isolated();
1140        let executor = ProbeExecutor::new(&plain_host());
1141        assert_eq!(
1142            resolve(
1143                &podman(None),
1144                &BuildCacheConfig { enabled: false },
1145                &executor
1146            ),
1147            None
1148        );
1149        assert!(executor.ran().is_empty());
1150    }
1151
1152    #[test]
1153    fn local_podman_and_local_docker_inspect_one_machine_once() {
1154        let _isolated = isolated();
1155        let executor = ProbeExecutor::new(&plain_host());
1156        let settings = BuildCacheConfig::default();
1157        let first = resolve(&podman(None), &settings, &executor).unwrap();
1158        let ran = executor.ran().len();
1159        assert!(ran > 0, "the first resolve inspects the host");
1160        let second = resolve(&docker(None), &settings, &executor).unwrap();
1161        assert_eq!(
1162            first, second,
1163            "both engines on this machine share one cache"
1164        );
1165        assert_eq!(
1166            executor.ran().len(),
1167            ran,
1168            "the second runtime is answered from the machine's recorded inspection: {:?}",
1169            executor.ran()
1170        );
1171    }
1172
1173    #[test]
1174    fn a_machine_without_a_standing_host_has_no_build_cache_preview() {
1175        let _isolated = isolated();
1176        let executor = ProbeExecutor::new(&plain_host());
1177        let fleet: mj_core::config::Machine = serde_json::from_value(serde_json::json!({
1178            "kind": "aws-ec2",
1179            "region": "us-east-1",
1180            "launch_template": "lt-1",
1181            "ssh_user": "ubuntu",
1182        }))
1183        .unwrap();
1184        assert_eq!(
1185            preview_build_cache(&fleet, &BuildCacheConfig::default(), &executor).unwrap(),
1186            None
1187        );
1188        assert!(executor.ran().is_empty());
1189    }
1190
1191    #[test]
1192    fn apple_and_bare_targets_have_no_shared_build_cache() {
1193        let _isolated = isolated();
1194        let executor = ProbeExecutor::new(&plain_host());
1195        for target in [
1196            TargetTemplate::AppleContainer(container(None)),
1197            TargetTemplate::LocalBare,
1198            TargetTemplate::SshBare {
1199                ssh: SshTarget {
1200                    destination: "dev@example.test".into(),
1201                    ssh_args: Vec::new(),
1202                },
1203                workspace_prefix: "workspaces".into(),
1204            },
1205        ] {
1206            assert_eq!(
1207                resolve(&target, &BuildCacheConfig::default(), &executor),
1208                None,
1209                "{target:?}"
1210            );
1211        }
1212        assert!(executor.ran().is_empty());
1213    }
1214
1215    fn bundle() -> targets::ProjectBundleSpec {
1216        targets::ProjectBundleSpec {
1217            primary: "main".into(),
1218            repositories: vec![targets::RepositorySpec {
1219                url: Some("https://github.com/example/main.git".into()),
1220                push_urls: Vec::new(),
1221                destination: "main".into(),
1222                git_ref: None,
1223                reference: None,
1224            }],
1225        }
1226    }
1227
1228    fn clone_cache() -> super::super::git_cache::PreparedCloneCache {
1229        super::super::git_cache::PreparedCloneCache::from_mirrors(
1230            [(
1231                "main".to_owned(),
1232                PathBuf::from("/home/dev/mirror/repo.git"),
1233            )]
1234            .into_iter()
1235            .collect(),
1236        )
1237    }
1238
1239    fn session(container_workspace: Option<&str>) -> mj_core::state::SessionRecord {
1240        let mut record = crate::controller::test_support::checkpoint_test_session("session-1");
1241        record.container_workspace = container_workspace.map(PathBuf::from);
1242        record
1243    }
1244
1245    #[test]
1246    fn a_rust_session_mounts_the_cache_at_the_host_path() {
1247        let _isolated = isolated();
1248        let mut answers = plain_host();
1249        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1250        let executor = ProbeExecutor::new(&answers);
1251        let mut mounts = Vec::new();
1252        let build_cache = prepare(
1253            &podman(None),
1254            &BuildCacheConfig::default(),
1255            &session(Some("/workspace/session-1")),
1256            Some(&bundle()),
1257            Some(&clone_cache()),
1258            &mut mounts,
1259            &executor,
1260        )
1261        .expect("a Rust session uses the build cache");
1262        assert_eq!(build_cache.directory, PathBuf::from("/home/dev/.cache/mbx"));
1263        assert_eq!(
1264            mounts,
1265            vec![targets::AdditionalMount {
1266                source: PathBuf::from("/home/dev/.cache/mbx"),
1267                destination: PathBuf::from("/home/dev/.cache/mbx"),
1268                access: targets::MountAccess::Rw,
1269            }]
1270        );
1271    }
1272
1273    #[test]
1274    fn a_repository_without_a_root_manifest_runs_without_the_cache() {
1275        let _isolated = isolated();
1276        let mut answers = plain_host();
1277        answers.push(("cat-file -e HEAD:Cargo.toml", 1, ""));
1278        let executor = ProbeExecutor::new(&answers);
1279        let mut mounts = Vec::new();
1280        assert_eq!(
1281            prepare(
1282                &podman(None),
1283                &BuildCacheConfig::default(),
1284                &session(Some("/workspace/session-1")),
1285                Some(&bundle()),
1286                Some(&clone_cache()),
1287                &mut mounts,
1288                &executor,
1289            ),
1290            None
1291        );
1292        assert!(mounts.is_empty());
1293    }
1294
1295    #[test]
1296    fn a_session_at_the_legacy_shared_workspace_runs_without_the_cache() {
1297        let _isolated = isolated();
1298        let mut answers = plain_host();
1299        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1300        let executor = ProbeExecutor::new(&answers);
1301        let mut mounts = Vec::new();
1302        assert_eq!(
1303            prepare(
1304                &podman(None),
1305                &BuildCacheConfig::default(),
1306                &session(None),
1307                Some(&bundle()),
1308                Some(&clone_cache()),
1309                &mut mounts,
1310                &executor,
1311            ),
1312            None
1313        );
1314        assert!(executor.ran().is_empty());
1315    }
1316
1317    #[test]
1318    fn a_session_without_a_prepared_clone_cache_runs_without_the_cache() {
1319        let _isolated = isolated();
1320        let mut answers = plain_host();
1321        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1322        let executor = ProbeExecutor::new(&answers);
1323        let mut mounts = Vec::new();
1324        assert_eq!(
1325            prepare(
1326                &podman(None),
1327                &BuildCacheConfig::default(),
1328                &session(Some("/workspace/session-1")),
1329                Some(&bundle()),
1330                None,
1331                &mut mounts,
1332                &executor,
1333            ),
1334            None
1335        );
1336    }
1337
1338    #[test]
1339    fn an_apple_target_never_shares_a_build_cache() {
1340        let _isolated = isolated();
1341        let mut answers = plain_host();
1342        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1343        let executor = ProbeExecutor::new(&answers);
1344        let mut mounts = Vec::new();
1345        assert_eq!(
1346            prepare(
1347                &TargetTemplate::AppleContainer(container(None)),
1348                &BuildCacheConfig::default(),
1349                &session(Some("/workspace/session-1")),
1350                Some(&bundle()),
1351                Some(&clone_cache()),
1352                &mut mounts,
1353                &executor,
1354            ),
1355            None
1356        );
1357        assert!(executor.ran().is_empty());
1358    }
1359
1360    #[test]
1361    fn a_resumed_session_reuses_its_recorded_cache_without_resolving_again() {
1362        let _isolated = isolated();
1363        let executor = ProbeExecutor::new(&[]);
1364        let mut record = session(Some("/workspace/session-1"));
1365        record.build_cache = Some(SessionBuildCache {
1366            host: "local".into(),
1367            directory: PathBuf::from("/mnt/fast/mbx-cache"),
1368            max_size: None,
1369            target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
1370        });
1371        let mut mounts = Vec::new();
1372        let build_cache = prepare(
1373            &podman(None),
1374            &BuildCacheConfig::default(),
1375            &record,
1376            None,
1377            None,
1378            &mut mounts,
1379            &executor,
1380        )
1381        .expect("a resumed session keeps its build cache");
1382        assert_eq!(build_cache, record.build_cache.unwrap());
1383        assert_eq!(
1384            mounts
1385                .iter()
1386                .map(|mount| mount.destination.clone())
1387                .collect::<Vec<_>>(),
1388            vec![
1389                PathBuf::from("/mnt/fast/mbx-cache"),
1390                PathBuf::from("/mnt/fast/mbx-targets"),
1391            ]
1392        );
1393        assert!(executor.ran().is_empty());
1394    }
1395
1396    #[test]
1397    fn a_session_moved_to_another_host_resolves_its_build_cache_again() {
1398        let _isolated = isolated();
1399        let mut answers = plain_host();
1400        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1401        let executor = ProbeExecutor::new(&answers);
1402        let mut record = session(Some("/workspace/session-1"));
1403        record.build_cache = Some(SessionBuildCache {
1404            // The host the session was provisioned on, which the target below
1405            // is not.
1406            host: "ssh:dev@example.test".into(),
1407            directory: PathBuf::from("/mnt/fast/mbx-cache"),
1408            max_size: None,
1409            target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
1410        });
1411        let mut mounts = Vec::new();
1412
1413        let build_cache = prepare(
1414            &podman(None),
1415            &BuildCacheConfig::default(),
1416            &record,
1417            Some(&bundle()),
1418            Some(&clone_cache()),
1419            &mut mounts,
1420            &executor,
1421        )
1422        .expect("the destination host qualifies on its own");
1423
1424        assert_eq!(build_cache.host, "local");
1425        assert_eq!(build_cache.directory, PathBuf::from("/home/dev/.cache/mbx"));
1426        assert_eq!(build_cache.target_root, None);
1427        assert_eq!(
1428            mounts
1429                .iter()
1430                .map(|mount| mount.destination.clone())
1431                .collect::<Vec<_>>(),
1432            vec![PathBuf::from("/home/dev/.cache/mbx")]
1433        );
1434        assert!(
1435            executor
1436                .ran()
1437                .iter()
1438                .any(|line| line.contains("mj-reflink"))
1439        );
1440    }
1441
1442    #[test]
1443    fn an_attached_directory_over_the_cache_wins() {
1444        let build_cache = SessionBuildCache {
1445            host: "local-podman".into(),
1446            directory: PathBuf::from("/mnt/fast/mbx-cache"),
1447            max_size: None,
1448            target_root: None,
1449        };
1450        let mut mounts = vec![targets::AdditionalMount {
1451            source: PathBuf::from("/elsewhere"),
1452            destination: PathBuf::from("/mnt/fast/mbx-cache/actions"),
1453            access: targets::MountAccess::Ro,
1454        }];
1455        assert!(!attach_mounts(&build_cache, &mut mounts));
1456        assert_eq!(mounts.len(), 1);
1457    }
1458
1459    #[test]
1460    fn versions_compare_by_release_order() {
1461        assert!(version_at_least("1.12.0", "1.12.0"));
1462        assert!(version_at_least("1.12.1", "1.12.0"));
1463        assert!(version_at_least("2.0.0", "1.12.0"));
1464        assert!(!version_at_least("1.11.9", "1.12.0"));
1465        assert!(!version_at_least("1.9.0", "1.12.0"));
1466        assert!(!version_at_least("not-a-version", "1.12.0"));
1467    }
1468
1469    #[test]
1470    fn free_space_is_read_from_the_available_column() {
1471        assert_eq!(
1472            available_bytes(
1473                "Filesystem 1B-blocks Used Available Capacity Mounted on\n\
1474                 /dev/sda1 1000 400 600 40% /\n"
1475            ),
1476            Some(600)
1477        );
1478        assert_eq!(available_bytes("Filesystem 1B-blocks\n"), None);
1479    }
1480}