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