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