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//! Cache discovery can leave new sessions uncached. Once a cache is selected,
11//! configuration failures are reported rather than launching with stale policy.
12
13mod configuration;
14pub(crate) mod service;
15
16use std::io::Read;
17use std::path::{Path, PathBuf};
18use std::time::{Duration, Instant};
19
20use anyhow::{Context, Result, bail, ensure};
21use sha2::{Digest, Sha256};
22
23use super::cache_host::CacheHost;
24use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec};
25use mj_core::config::{Config, TargetBuildCache, TargetTemplate};
26use mj_core::state::{
27    BuildCacheApplication, BuildCacheLimit, BuildCacheOff, BuildCachePreview, BuildCacheStats,
28    SessionBuildCache,
29};
30
31/// The mbx release containers run. A native mbx older than this must not share
32/// the same store, so a host that has one runs its sessions without the cache.
33pub(crate) const MBX_VERSION: &str = "1.21.0";
34
35const MBX_X86_64_SHA256: &str = "5225a3b77f90e1cd3d1ef0d1054ccd4593ae19b2c98b815af1b82a4dfed0f97b";
36const MBX_AARCH64_SHA256: &str = "107f86955f8323ea96ca90ca2d06d49b9b1b99789dd8007f71f9c59bb17bb6d7";
37
38/// Overrides the download with a local mbx binary for the current machine's
39/// architecture. Used for development against an unreleased mbx.
40const MBX_BINARY_ENV: &str = "MJ_MBX_BINARY";
41
42const DEFAULT_CACHE_RELATIVE: &str = ".cache/mbx";
43/// mbx's running totals, relative to the cache directory.
44const TALLY_RELATIVE: &str = "actions/savings/v1/tally.json";
45/// The cap on the computed default total budget: 100 GB, in SI bytes.
46const DEFAULT_MAX_BYTES: u64 = 100_000_000_000;
47const RESOLUTION_LIFETIME: Duration = Duration::from_secs(600);
48const LABEL: &str = "hel-mbx";
49const UNSUPPORTED_HOST: &str = "Mjolnir's shared mbx cache requires a Linux host. Native mbx on macOS must be installed and configured separately.";
50
51/// Ask the cache host, not the controller or a container running on that host.
52fn host_supports_cache(host: &CacheHost, executor: &impl CommandExecutor) -> Result<bool> {
53    let command = host.command(
54        vec!["uname".into(), "-sm".into()],
55        "detect build cache host platform",
56    );
57    let output = checked(executor.execute(&command)?, &command)?;
58    let platform = targets::TargetPlatform::parse(
59        std::str::from_utf8(&output.stdout).context("decode build cache host platform")?,
60    )?;
61    Ok(platform.os == targets::TargetOs::Linux)
62}
63
64/// What a container target's host offers as a build cache.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub(super) struct ResolvedBuildCache {
67    /// Cache directory on the host, mounted at the same path in the container.
68    pub directory: PathBuf,
69    /// A `[target] root` the host configuration relocates outside the cache
70    /// directory, which the container needs mounted at the same path too.
71    pub target_root: Option<PathBuf>,
72    /// Desired policy for the shared machine file, never a private session copy.
73    pub config_file: Option<String>,
74    pub config_directory: PathBuf,
75    pub previous_config: Option<String>,
76}
77
78/// Cached host inspections, keyed by host and per-target settings. An
79/// inspection runs several commands on the host, and a burst of new sessions
80/// must not repeat them for each one. A failure is remembered too, so a host
81/// that cannot answer is not re-probed by every session in that burst.
82type Resolutions = std::collections::BTreeMap<String, (Instant, Result<Inspection, String>)>;
83
84static RESOLUTIONS: std::sync::LazyLock<std::sync::Mutex<Resolutions>> =
85    std::sync::LazyLock::new(|| std::sync::Mutex::new(Resolutions::new()));
86
87type Applications = std::collections::BTreeMap<String, Result<(), String>>;
88static APPLICATIONS: std::sync::LazyLock<std::sync::Mutex<Applications>> =
89    std::sync::LazyLock::new(Default::default);
90
91/// Whether a caller can be served a memoized answer or needs the host asked
92/// again.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94enum Freshness {
95    /// Provisioning: an answer from the last `RESOLUTION_LIFETIME` will do.
96    Memoized,
97    /// The settings screen, which is read precisely when somebody has just
98    /// changed something on the host. A fresh answer also replaces the
99    /// memoized one, so the next session sees the same thing the screen does.
100    Fresh,
101}
102
103/// The one place a host is inspected. Sessions and the settings screen differ
104/// only in the freshness they ask for, so they cannot drift into reporting
105/// different things about the same host.
106fn inspect(
107    host: &CacheHost,
108    settings: &TargetBuildCache,
109    freshness: Freshness,
110    executor: &impl CommandExecutor,
111) -> Result<Inspection> {
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    executor: &impl CommandExecutor,
136) -> Option<ResolvedBuildCache> {
137    let (host, settings) = supported_host(target)?;
138    let inspection = match inspect(&host, &settings, Freshness::Memoized, executor) {
139        Ok(inspection) => inspection,
140        Err(error) => {
141            tracing::warn!(host = host.key(), "build cache unavailable: {error:#}");
142            return None;
143        }
144    };
145    let Some(cache) = inspection.cache else {
146        if let Some(reason) = &inspection.preview.off_reason {
147            tracing::warn!(
148                directory = inspection
149                    .preview
150                    .directory
151                    .as_ref()
152                    .map(|directory| directory.display().to_string()),
153                "sessions on this target run without the build cache: {reason}"
154            );
155        }
156        return None;
157    };
158    // Creating the directory is the one side effect a session has and the
159    // settings screen does not, so it sits here rather than inside the shared
160    // inspection. It runs per session because a memoized inspection says what
161    // the host looked like, not that the directory still exists.
162    if let Err(error) = create_directory(&host, &cache.directory, executor) {
163        tracing::warn!(
164            directory = %cache.directory.display(),
165            "the build cache directory could not be created: {error:#}"
166        );
167        return None;
168    }
169    match apply_cache(&host, &settings, cache, executor) {
170        Ok(cache) => Some(cache),
171        Err(error) => {
172            tracing::warn!(
173                host = host.key(),
174                "applying machine build cache configuration failed: {error:#}"
175            );
176            executor.notify_notice(&format!(
177                "Build cache configuration could not be applied: {error:#}"
178            ));
179            None
180        }
181    }
182}
183
184fn apply_cache(
185    host: &CacheHost,
186    settings: &TargetBuildCache,
187    mut cache: ResolvedBuildCache,
188    executor: &impl CommandExecutor,
189) -> Result<ResolvedBuildCache> {
190    let key = format!("{}|{settings:?}", host.key());
191    let result = (|| {
192        if !configuration::apply(host, &cache, executor)? {
193            // Another application won. Accept it only if it already installs
194            // this policy; never replay an older desired value over a newer one.
195            cache = inspect(host, settings, Freshness::Fresh, executor)?
196                .cache
197                .context("build cache became unavailable during application")?;
198            ensure!(
199                cache.previous_config == cache.config_file,
200                "machine build cache policy changed during application; retry with current machine settings"
201            );
202        }
203        cache.previous_config = cache.config_file.clone();
204        if let Some((_, Ok(inspection))) =
205            RESOLUTIONS.lock().expect("mbx resolutions").get_mut(&key)
206        {
207            inspection.cache = Some(cache.clone());
208            inspection.preview.application = BuildCacheApplication::Applied;
209        }
210        Ok(cache)
211    })();
212    APPLICATIONS.lock().expect("mbx applications").insert(
213        key,
214        result
215            .as_ref()
216            .map(|_| ())
217            .map_err(|error| format!("{error:#}")),
218    );
219    result
220}
221
222/// The targets that can share a host build cache. Apple `container` runs each
223/// container in its own virtual machine, where file locks across the shared
224/// store are unverified, and bare and EC2 targets are out of scope.
225fn supported_host(target: &targets::TargetTemplate) -> Option<(CacheHost, TargetBuildCache)> {
226    let settings = match target {
227        targets::TargetTemplate::LocalPodman(container)
228        | targets::TargetTemplate::LocalDocker(container)
229        | targets::TargetTemplate::SshPodman { container, .. }
230        | targets::TargetTemplate::SshDocker { container, .. } => {
231            container.build_cache.clone().unwrap_or_default()
232        }
233        targets::TargetTemplate::AppleContainer(_)
234        | targets::TargetTemplate::LocalBare
235        | targets::TargetTemplate::AwsEc2(_)
236        | targets::TargetTemplate::SshBare { .. } => return None,
237    };
238    Some((CacheHost::for_target(target)?, settings))
239}
240
241/// What the settings screen shows for one machine's blank build cache fields:
242/// the same host inspection a session runs, without creating the directory.
243/// `None` when the machine has no standing host to share a cache on.
244pub fn preview_build_cache(
245    machine: &mj_core::config::Machine,
246    executor: &impl CommandExecutor,
247) -> Result<Option<BuildCachePreview>> {
248    let Some(host) = CacheHost::for_machine(machine) else {
249        return Ok(None);
250    };
251    let settings = machine.build_cache().cloned().unwrap_or_default();
252    inspect(&host, &settings, Freshness::Fresh, executor).map(|inspection| Some(inspection.preview))
253}
254
255/// Apply one machine's desired policy. Both provisioning and the daemon use
256/// the same compare-and-replace operation; native settings are only read.
257pub(crate) fn apply_machine_build_cache(
258    machine: &mj_core::config::Machine,
259    mounted_directories: &[PathBuf],
260    executor: &impl CommandExecutor,
261) -> Result<()> {
262    let Some(host) = CacheHost::for_machine(machine) else {
263        return Ok(());
264    };
265    let settings = machine.build_cache().cloned().unwrap_or_default();
266    let inspected = inspect(&host, &settings, Freshness::Fresh, executor)?;
267    if let Some(cache) = inspected.cache {
268        let cache = apply_cache(&host, &settings, cache, executor)?;
269        // Existing containers retain their mounts if placement changes. Publish
270        // the same machine policy to each still-mounted cache, once per path.
271        for directory in mounted_directories {
272            if directory != &cache.directory {
273                publish_at(&host, &cache, directory, executor)?;
274            }
275        }
276    }
277    Ok(())
278}
279
280fn publish_at(
281    host: &CacheHost,
282    cache: &ResolvedBuildCache,
283    directory: &Path,
284    executor: &impl CommandExecutor,
285) -> Result<PathBuf> {
286    let mut projected = cache.clone();
287    projected.config_directory = configuration::shared_directory(directory);
288    projected.previous_config = configuration::read_file(
289        host,
290        &projected.config_directory.join("config.toml"),
291        executor,
292    )?;
293    ensure!(
294        configuration::apply(host, &projected, executor)?,
295        "machine configuration changed during application; retry with current settings"
296    );
297    Ok(projected.config_directory)
298}
299
300/// Upgrade an existing container through its existing cache mount. Resolving
301/// ownership uses its actual host, never a target name that can be reassigned.
302pub(super) fn prepare_session_configuration(
303    config: &Config,
304    backend: &targets::TargetLocator,
305    recorded: &SessionBuildCache,
306    executor: &impl CommandExecutor,
307) -> Result<PathBuf> {
308    let host = host_for_locator(backend).context("build cache has no container host")?;
309    let mut settings = config
310        .machines
311        .values()
312        .filter(|machine| {
313            CacheHost::for_machine(machine).is_some_and(|candidate| candidate.key() == host.key())
314        })
315        .filter_map(|machine| machine.build_cache())
316        .next()
317        .cloned()
318        .unwrap_or_default();
319    settings.directory = Some(recorded.directory.clone());
320    // A disabled cache still exists in already provisioned containers. Keep
321    // its policy current until the session no longer mounts it.
322    settings.enabled = Some(true);
323    let inspected = inspect_host(&host, &settings, executor)?;
324    let cache = inspected.cache.with_context(|| {
325        format!(
326            "build cache configuration unavailable: {}",
327            inspected
328                .preview
329                .off_reason
330                .map(|reason| reason.to_string())
331                .unwrap_or_else(|| "host inspection returned no cache".into())
332        )
333    })?;
334    publish_at(&host, &cache, &recorded.directory, executor)
335}
336
337/// Native mbx compatibility for a host used by configured container targets.
338pub(crate) struct DoctorHostMbx {
339    pub host: String,
340    pub targets: Vec<String>,
341    pub status: DoctorHostMbxStatus,
342}
343
344pub(crate) enum DoctorHostMbxStatus {
345    Unsupported(String),
346    Absent,
347    Compatible(String),
348    TooOld(String),
349    Unknown(String),
350}
351
352/// Check each relevant host once. The cache is optional, so disabled caches
353/// and hosts with no Podman or Docker target need no compatibility check.
354pub(crate) fn doctor_host_mbx(
355    config: &Config,
356    executor: &impl CommandExecutor,
357) -> Vec<DoctorHostMbx> {
358    let mut hosts: std::collections::BTreeMap<String, (CacheHost, Vec<String>)> =
359        std::collections::BTreeMap::new();
360    let mut checks = Vec::new();
361    for (id, target) in &config.targets {
362        let container = match target {
363            TargetTemplate::LocalPodman { container }
364            | TargetTemplate::LocalDocker { container }
365            | TargetTemplate::SshPodman { container, .. }
366            | TargetTemplate::SshDocker { container, .. } => container,
367            _ => continue,
368        };
369        if container
370            .build_cache
371            .as_ref()
372            .and_then(|cache| cache.enabled)
373            == Some(false)
374        {
375            continue;
376        }
377        match CacheHost::for_path_target(target) {
378            Ok(host) => {
379                let key = host.key();
380                hosts
381                    .entry(key)
382                    .or_insert_with(|| (host, Vec::new()))
383                    .1
384                    .push(id.clone());
385            }
386            Err(error) => checks.push(DoctorHostMbx {
387                host: id.clone(),
388                targets: vec![id.clone()],
389                status: DoctorHostMbxStatus::Unknown(format!("{error:#}")),
390            }),
391        }
392    }
393    checks.extend(hosts.into_iter().map(|(key, (host, targets))| {
394        let status = (|| -> Result<DoctorHostMbxStatus> {
395            if !host_supports_cache(&host, executor)? {
396                return Ok(DoctorHostMbxStatus::Unsupported(UNSUPPORTED_HOST.into()));
397            }
398            Ok(match probe_native_version(&host, executor)? {
399                None => DoctorHostMbxStatus::Absent,
400                Some(native) if semver::Version::parse(&native.version).is_err() => {
401                    DoctorHostMbxStatus::Unknown(format!(
402                        "the host reported an unrecognized mbx version {:?}",
403                        native.version
404                    ))
405                }
406                Some(native) if version_at_least(&native.version, MBX_VERSION) => {
407                    DoctorHostMbxStatus::Compatible(native.version)
408                }
409                Some(native) => DoctorHostMbxStatus::TooOld(native.version),
410            })
411        })()
412        .unwrap_or_else(|error| DoctorHostMbxStatus::Unknown(format!("{error:#}")));
413        DoctorHostMbx {
414            host: key,
415            targets,
416            status,
417        }
418    }));
419    checks
420}
421
422/// Everything the host says about a target's build cache, read without
423/// changing the host.
424#[derive(Clone)]
425struct Inspection {
426    preview: BuildCachePreview,
427    /// The cache a session would mount, or `None` when it runs without one.
428    cache: Option<ResolvedBuildCache>,
429}
430
431fn inspect_host(
432    host: &CacheHost,
433    settings: &TargetBuildCache,
434    executor: &impl CommandExecutor,
435) -> Result<Inspection> {
436    if !host_supports_cache(host, executor)? {
437        return Ok(Inspection {
438            preview: BuildCachePreview {
439                native_mbx: None,
440                directory: None,
441                max_total_size: None,
442                user_managed: false,
443                application: BuildCacheApplication::Pending,
444                budget_note: None,
445                stats: None,
446                off_reason: Some(BuildCacheOff::Unavailable(UNSUPPORTED_HOST.into())),
447            },
448            cache: None,
449        });
450    }
451    let native = probe_native_version(host, executor)?;
452    let native_version = native.as_ref().map(|native| native.version.clone());
453    let off = |preview: BuildCachePreview| Inspection {
454        preview,
455        cache: None,
456    };
457    if let Some(version) = &native_version
458        && !version_at_least(version, MBX_VERSION)
459    {
460        return Ok(off(BuildCachePreview {
461            native_mbx: native_version.clone(),
462            directory: None,
463            max_total_size: None,
464            user_managed: true,
465            application: BuildCacheApplication::Pending,
466            budget_note: None,
467            stats: None,
468            off_reason: Some(BuildCacheOff::Unavailable(format!(
469                "the host's mbx {version} is older than the {MBX_VERSION} Mjolnir installs, \
470                 so they cannot share a store"
471            ))),
472        }));
473    }
474    let directory = match &native {
475        Some(native) => native_cache_directory(host, native, executor)?,
476        None => settings
477            .directory
478            .clone()
479            .unwrap_or(host.home(executor)?.join(DEFAULT_CACHE_RELATIVE)),
480    };
481    ensure!(
482        directory.is_absolute(),
483        "build cache directory {} is not absolute",
484        directory.display()
485    );
486
487    let user_managed = native.is_some();
488    let config_directory = configuration::shared_directory(&directory);
489    let previous_config =
490        configuration::read_file(host, &config_directory.join("config.toml"), executor)?;
491    let (config_file, limit) = if user_managed {
492        let text = host_config_file(host, executor)?;
493        let limit = match configuration::configured_limit(text.as_deref(), "gc", "max_total_size")?
494        {
495            Some(size) => BuildCacheLimit::HostConfiguration(Some(size)),
496            None => BuildCacheLimit::MbxDefault(None),
497        };
498        (Some(text.unwrap_or_default()), limit)
499    } else {
500        let automatic = match configuration::automatic_total(previous_config.as_deref()) {
501            Some(size) => size,
502            None => default_max_size(host, &directory, executor)?,
503        };
504        let limit = match &settings.max_total_size {
505            Some(size) => BuildCacheLimit::Size(size.clone()),
506            None => BuildCacheLimit::MjDefault(automatic.clone()),
507        };
508        (
509            Some(configuration::managed_document(settings, &automatic)?),
510            limit,
511        )
512    };
513    let target_root = config_file
514        .as_deref()
515        .and_then(|text| relocated_target_root(text, &directory));
516    let mut application =
517        configuration::application(previous_config.as_deref(), config_file.as_deref());
518    if application == BuildCacheApplication::Pending
519        && let Some(Err(error)) = APPLICATIONS
520            .lock()
521            .expect("mbx applications")
522            .get(&format!("{}|{settings:?}", host.key()))
523    {
524        application = BuildCacheApplication::Failed(error.clone());
525    }
526    // Read before the checks below, so a host that cannot share the cache
527    // right now still reports what the cache did while it could.
528    let stats = read_stats(host, &directory, executor);
529    let preview = |off_reason: Option<BuildCacheOff>| BuildCachePreview {
530        native_mbx: native_version.clone(),
531        directory: Some(directory.clone()),
532        max_total_size: Some(limit.clone()),
533        user_managed,
534        application: application.clone(),
535        budget_note: Some(
536            "One budget covers shared compiler outputs, managed worktrees, and incremental state."
537                .into(),
538        ),
539        stats: stats.clone(),
540        off_reason,
541    };
542
543    // The directory may not exist yet; its filesystem is its nearest
544    // existing ancestor's.
545    let volume = nearest_existing_ancestor(host, &directory, executor)?;
546    // A machine that is not turned off still has to support the cache: an
547    // explicit `enabled = true` cannot make a volume without reflinks usable.
548    if !settings.enabled.unwrap_or(true) {
549        return Ok(off(preview(Some(BuildCacheOff::TurnedOff))));
550    }
551    if !reflinks_supported(host, &volume, executor)? {
552        return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
553            "the filesystem under {} does not support reflinks, so restoring cached \
554             outputs would copy every byte",
555            directory.display()
556        ))))));
557    }
558    if let Some(reason) = unusable_filesystem(host, &volume, executor)? {
559        return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
560            "{} is on a {reason}, where mbx's file locks are unreliable",
561            directory.display()
562        ))))));
563    }
564
565    // A relocated target root is a separate mount, and a restore into it is a
566    // clone only when it shares one with the store. Copying instead is correct
567    // and much slower, and mbx's materializer falls back to it without saying
568    // so, which makes this the only place it can be noticed. It is reported
569    // rather than disqualifying: a slow cache still beats no cache.
570    if let Some(root) = &target_root {
571        let root_volume = nearest_existing_ancestor(host, root, executor)?;
572        if !cross_reflinks_supported(host, &volume, &root_volume, executor)? {
573            tracing::warn!(
574                cache = %directory.display(),
575                target_root = %root.display(),
576                "the host's mbx target root does not share a mount with the build cache, \
577                 so restoring a cached output copies every byte instead of cloning it"
578            );
579        }
580    }
581
582    Ok(Inspection {
583        preview: preview(None),
584        cache: Some(ResolvedBuildCache {
585            directory,
586            target_root,
587            config_file,
588            config_directory,
589            previous_config,
590        }),
591    })
592}
593
594/// mbx's running totals for this cache, or `None` when it has none yet.
595///
596/// Read from the tally file rather than by running `mbx stats`, which also
597/// walks the content-addressed store to size it: that took 90 seconds on a
598/// 540 GB cache here, where the tally is a few hundred bytes. It also means
599/// the numbers need no mbx binary on the host.
600///
601/// A cache that has never been used has no tally, which is not a failure.
602fn read_stats(
603    host: &CacheHost,
604    directory: &Path,
605    executor: &impl CommandExecutor,
606) -> Option<BuildCacheStats> {
607    #[derive(Default, serde::Deserialize)]
608    #[serde(default)]
609    struct Tally {
610        builds: u64,
611        cached_compilations: u64,
612        avoided_compiler_ns: u64,
613        reflinked_bytes: u64,
614    }
615
616    let path = directory.join(TALLY_RELATIVE);
617    let command = host.shell_command(
618        READ_CONFIG_SCRIPT,
619        LABEL,
620        [path.to_string_lossy().into_owned()],
621        "read the container host build cache totals",
622    );
623    let output = executor.execute(&command).ok()?;
624    if output.status != 0 {
625        return None;
626    }
627    // A newer mbx may add counters; unknown ones are ignored rather than
628    // costing the whole report, exactly as mbx reads the file itself.
629    let tally: Tally = serde_json::from_slice(&output.stdout)
630        .inspect_err(|error| {
631            tracing::debug!(
632                path = %path.display(),
633                "the build cache totals could not be read: {error}"
634            );
635        })
636        .ok()?;
637    Some(BuildCacheStats {
638        builds: tally.builds,
639        cached_compilations: tally.cached_compilations,
640        avoided_compiler_ns: tally.avoided_compiler_ns,
641        reflinked_bytes: tally.reflinked_bytes,
642    })
643}
644
645/// `true` when `found` is at least `required`, comparing release versions.
646fn version_at_least(found: &str, required: &str) -> bool {
647    let parse = |text: &str| semver::Version::parse(text.trim()).ok();
648    match (parse(found), parse(required)) {
649        (Some(found), Some(required)) => found >= required,
650        // An unparsable version is not evidence of a new enough mbx.
651        _ => false,
652    }
653}
654
655/// The host's own mbx: the program that runs it and its version.
656#[derive(Debug, Clone, PartialEq, Eq)]
657struct NativeMbx {
658    program: String,
659    version: String,
660}
661
662/// An SSH command runs in a non-login shell whose `PATH` lacks the user's
663/// Cargo bin directory, so a `cargo install`ed mbx is looked up there too.
664const NATIVE_VERSION_SCRIPT: &str = r#"for m in mbx "$HOME/.cargo/bin/mbx"; do
665    if v=$("$m" --version 2>/dev/null); then
666        printf '%s
667%s' "$m" "$v"
668        exit 0
669    fi
670done
671exit 1"#;
672
673/// The host's own mbx, or `None` when neither `PATH` nor `~/.cargo/bin`
674/// has one.
675fn probe_native_version(
676    host: &CacheHost,
677    executor: &impl CommandExecutor,
678) -> Result<Option<NativeMbx>> {
679    let command = host.shell_command(
680        NATIVE_VERSION_SCRIPT,
681        LABEL,
682        [],
683        "read the container host mbx version",
684    );
685    let output = executor.execute(&command)?;
686    if output.status == 1 {
687        return Ok(None);
688    }
689    ensure!(
690        output.status == 0,
691        "mbx version probe exited with status {}",
692        output.status
693    );
694    let text = String::from_utf8_lossy(&output.stdout);
695    let (program, version) = text
696        .trim()
697        .split_once('\n')
698        .context("mbx version probe gave no version")?;
699    let version = version
700        .split_whitespace()
701        .next_back()
702        .context("mbx version probe gave an empty version")?;
703    Ok(Some(NativeMbx {
704        program: program.to_owned(),
705        version: version.to_owned(),
706    }))
707}
708
709/// The host's own cache directory. `mbx cache dir` prints the store, which is
710/// the `actions` directory inside the cache directory.
711fn native_cache_directory(
712    host: &CacheHost,
713    native: &NativeMbx,
714    executor: &impl CommandExecutor,
715) -> Result<PathBuf> {
716    let command = host.command(
717        vec![
718            native.program.clone(),
719            "cache".to_owned(),
720            "dir".to_owned(),
721            "--json".to_owned(),
722        ],
723        "read the container host mbx cache directory",
724    );
725    let output = checked(executor.execute(&command)?, &command)?;
726    let report: serde_json::Value =
727        serde_json::from_slice(&output.stdout).context("parse the mbx cache directory report")?;
728    let store = report
729        .get("store")
730        .and_then(serde_json::Value::as_str)
731        .context("the mbx cache directory report has no store path")?;
732    Path::new(store)
733        .parent()
734        .map(Path::to_path_buf)
735        .with_context(|| format!("mbx store path {store:?} has no parent"))
736}
737
738const READ_CONFIG_SCRIPT: &str = r#"[ -f "$1" ] || exit 3
739cat -- "$1""#;
740
741/// The host's `~/.config/mbx/config.toml`, which containers receive verbatim
742/// so their mbx uses the host's own limits. mbx has no command that prints its
743/// effective configuration, so the file itself is the only accurate source.
744fn host_config_file(host: &CacheHost, executor: &impl CommandExecutor) -> Result<Option<String>> {
745    let directory = configuration::host_directory(host, executor)?;
746    configuration::read_file(host, &directory.join("config.toml"), executor)
747}
748
749/// The `[target] root` a host configuration sets, when it lies outside the
750/// cache directory and therefore needs its own mount.
751fn relocated_target_root(config_file: &str, directory: &Path) -> Option<PathBuf> {
752    let document: toml::Value = toml::from_str(config_file)
753        .map_err(|error| tracing::warn!("the host mbx configuration is unreadable: {error}"))
754        .ok()?;
755    let root = document.get("target")?.get("root")?.as_str()?;
756    let root = directory.join(root);
757    (!root.starts_with(directory)).then_some(root)
758}
759
760const NEAREST_ANCESTOR_SCRIPT: &str = r#"d=$1
761while [ ! -d "$d" ]; do
762    parent=$(dirname -- "$d")
763    if [ "$parent" = "$d" ]; then
764        break
765    fi
766    d=$parent
767done
768printf '%s' "$d""#;
769
770/// The deepest existing directory at or above `directory`. The cache directory
771/// may not exist yet, and both `df` and the reflink probe need a real one.
772fn nearest_existing_ancestor(
773    host: &CacheHost,
774    directory: &Path,
775    executor: &impl CommandExecutor,
776) -> Result<PathBuf> {
777    let command = host.shell_command(
778        NEAREST_ANCESTOR_SCRIPT,
779        LABEL,
780        [directory.to_string_lossy().into_owned()],
781        "locate the build cache volume",
782    );
783    let output = checked(executor.execute(&command)?, &command)?;
784    let path = PathBuf::from(String::from_utf8(output.stdout).context("decode cache ancestor")?);
785    ensure!(
786        path.is_absolute(),
787        "build cache volume {} is not absolute",
788        path.display()
789    );
790    Ok(path)
791}
792
793/// The budget mj gives a host that has no mbx configuration of its own: the
794/// smaller of 100 GB and a quarter of the free space on the cache volume.
795///
796/// It is written as `gc.max_total_size`, so it bounds the whole cache
797/// including shared compiler outputs, managed worktrees, and incremental state.
798fn default_max_size(
799    host: &CacheHost,
800    directory: &Path,
801    executor: &impl CommandExecutor,
802) -> Result<String> {
803    let volume = nearest_existing_ancestor(host, directory, executor)?;
804    let command = host.command(
805        vec![
806            "df".to_owned(),
807            "-B1".to_owned(),
808            "-P".to_owned(),
809            "--".to_owned(),
810            volume.to_string_lossy().into_owned(),
811        ],
812        "measure the build cache volume",
813    );
814    let output = checked(executor.execute(&command)?, &command)?;
815    let available = available_bytes(&String::from_utf8_lossy(&output.stdout))
816        .context("read the free space on the build cache volume")?;
817    Ok(format!("{}B", DEFAULT_MAX_BYTES.min(available / 4)))
818}
819
820/// The available column of `df -B1 -P` output, which is the fourth field of
821/// the row after the header. A long device name wraps in some `df`
822/// implementations, so the fields are counted from the end of the last row.
823pub(super) fn available_bytes(report: &str) -> Option<u64> {
824    let row = report
825        .lines()
826        .filter(|line| !line.trim().is_empty())
827        .nth(1)?;
828    let fields = row.split_whitespace().collect::<Vec<_>>();
829    // ... size used available capacity mounted-on
830    let available = fields.get(fields.len().checked_sub(3)?)?;
831    available.parse().ok()
832}
833
834const REFLINK_SCRIPT: &str = r#"dir=$1
835d=$(mktemp -d "$dir/.mj-reflink.XXXXXX") || exit 1
836printf x > "$d/a" && cp --reflink=always "$d/a" "$d/b"
837status=$?
838rm -rf -- "$d"
839exit $status"#;
840
841/// Whether the cache volume can clone files instead of copying their bytes.
842/// Reflinks are what make restoring a cached output nearly free, so a host
843/// without them defaults to running without the cache.
844fn reflinks_supported(
845    host: &CacheHost,
846    volume: &Path,
847    executor: &impl CommandExecutor,
848) -> Result<bool> {
849    let command = host.shell_command(
850        REFLINK_SCRIPT,
851        LABEL,
852        [volume.to_string_lossy().into_owned()],
853        "probe the build cache volume for reflinks",
854    );
855    Ok(executor.execute(&command)?.status == 0)
856}
857
858const CROSS_REFLINK_SCRIPT: &str = r#"src=$1
859dst=$2
860s=$(mktemp -d "$src/.mj-reflink.XXXXXX") || exit 1
861d=$(mktemp -d "$dst/.mj-reflink.XXXXXX") || { rm -rf -- "$s"; exit 1; }
862printf x > "$s/a" && cp --reflink=always "$s/a" "$d/b"
863status=$?
864rm -rf -- "$s" "$d"
865exit $status"#;
866
867/// Whether a cached output can be cloned from the store into the managed
868/// target root instead of copied. `FICLONE` fails across two mounts even when
869/// both are the same filesystem, so this asks the pair rather than each side.
870fn cross_reflinks_supported(
871    host: &CacheHost,
872    store: &Path,
873    target_root: &Path,
874    executor: &impl CommandExecutor,
875) -> Result<bool> {
876    let command = host.shell_command(
877        CROSS_REFLINK_SCRIPT,
878        LABEL,
879        [
880            store.to_string_lossy().into_owned(),
881            target_root.to_string_lossy().into_owned(),
882        ],
883        "probe the managed target root for reflinks from the build cache",
884    );
885    Ok(executor.execute(&command)?.status == 0)
886}
887
888fn create_directory(
889    host: &CacheHost,
890    directory: &Path,
891    executor: &impl CommandExecutor,
892) -> Result<()> {
893    let command = host.command(
894        vec![
895            "mkdir".to_owned(),
896            "-p".to_owned(),
897            "--".to_owned(),
898            directory.to_string_lossy().into_owned(),
899        ],
900        "create the build cache directory",
901    );
902    checked(executor.execute(&command)?, &command).map(|_| ())
903}
904
905/// A filesystem mbx cannot use. It refuses NFS outright, and file locks over
906/// FUSE, virtiofs, and 9p are unreliable, which a shared store depends on.
907fn unusable_filesystem(
908    host: &CacheHost,
909    directory: &Path,
910    executor: &impl CommandExecutor,
911) -> Result<Option<&'static str>> {
912    let filesystems =
913        targets::probe_filesystem_types(host.ssh(), &[directory.to_path_buf()], executor)?;
914    let filesystem = filesystems
915        .first()
916        .context("the filesystem probe named no filesystem")?;
917    // `overlay_unsupported_filesystem` already groups virtiofs and 9p with the
918    // network filesystems. The other reasons it gives are about stacking an
919    // overlay, which a plain read-write bind mount does not do.
920    Ok(targets::overlay_unsupported_filesystem(filesystem)
921        .filter(|reason| matches!(*reason, "network filesystem" | "FUSE filesystem")))
922}
923
924fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
925    if output.status == 0 {
926        return Ok(output);
927    }
928    bail!(
929        "{} failed with status {}: {}",
930        command.purpose,
931        output.status,
932        String::from_utf8_lossy(&output.stderr).trim()
933    )
934}
935
936// -- the pinned mbx binary ------------------------------------------------
937
938/// The mbx binary to install in a container of this architecture, downloading
939/// and verifying the pinned release on first use.
940pub(super) fn binary_for(
941    locator: &targets::TargetLocator,
942    executor: &impl CommandExecutor,
943) -> Result<PathBuf> {
944    let triple = super::worker_binary::target_architecture(locator, executor)?;
945    if let Some(path) = std::env::var_os(MBX_BINARY_ENV) {
946        let path = PathBuf::from(path);
947        ensure!(
948            path.is_file(),
949            "{MBX_BINARY_ENV} does not name a file: {}",
950            path.display()
951        );
952        if triple == host_architecture() {
953            return Ok(path);
954        }
955        tracing::warn!(
956            triple,
957            "{MBX_BINARY_ENV} is for this machine's architecture; downloading the pinned mbx \
958             for the target instead"
959        );
960    }
961    download(triple)
962}
963
964/// This machine's architecture in the same spelling `target_architecture`
965/// reports, so a local override is not handed to a foreign container.
966fn host_architecture() -> &'static str {
967    if cfg!(target_arch = "aarch64") {
968        "aarch64"
969    } else {
970        "x86_64"
971    }
972}
973
974fn release_url(triple: &str) -> String {
975    format!(
976        "https://github.com/jdx/mr-boxington/releases/download/v{MBX_VERSION}/mbx-{triple}-unknown-linux-musl.tar.gz"
977    )
978}
979
980fn expected_digest(triple: &str) -> Result<&'static str> {
981    match triple {
982        "x86_64" => Ok(MBX_X86_64_SHA256),
983        "aarch64" => Ok(MBX_AARCH64_SHA256),
984        _ => bail!("no pinned mbx release for {triple}"),
985    }
986}
987
988/// Download the pinned release once into the data directory. The archive is
989/// verified against the release checksum before anything is extracted.
990fn download(triple: &str) -> Result<PathBuf> {
991    let expected = expected_digest(triple)?;
992    let directory = mj_core::config::data_dir()
993        .join("mbx")
994        .join(MBX_VERSION)
995        .join(triple);
996    let destination = directory.join("mbx");
997    if destination.is_file() {
998        return Ok(destination);
999    }
1000    std::fs::create_dir_all(&directory)
1001        .with_context(|| format!("create the mbx cache {}", directory.display()))?;
1002    let url = release_url(triple);
1003    let archive = reqwest::blocking::Client::builder()
1004        .timeout(Duration::from_secs(120))
1005        .build()?
1006        .get(&url)
1007        .send()
1008        .with_context(|| format!("download {url}"))?
1009        .error_for_status()
1010        .with_context(|| format!("download {url}"))?
1011        .bytes()?;
1012    let actual = mj_core::hex::lower_hex(Sha256::digest(&archive));
1013    ensure!(
1014        actual.eq_ignore_ascii_case(expected),
1015        "downloaded mbx checksum mismatch: expected {expected}, got {actual}"
1016    );
1017    let binary = extract_binary(&archive)?;
1018    let mut temporary = tempfile::NamedTempFile::new_in(&directory)?;
1019    std::io::Write::write_all(&mut temporary, &binary)?;
1020    temporary.as_file_mut().sync_all()?;
1021    #[cfg(unix)]
1022    {
1023        use std::os::unix::fs::PermissionsExt;
1024        std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o700))?;
1025    }
1026    match temporary.persist_noclobber(&destination) {
1027        Ok(_) => Ok(destination),
1028        Err(error) if destination.is_file() => {
1029            drop(error);
1030            Ok(destination)
1031        }
1032        Err(error) => Err(error.error)
1033            .with_context(|| format!("publish the mbx binary {}", destination.display())),
1034    }
1035}
1036
1037/// The single `mbx` file from the release archive, which also carries its
1038/// licence texts.
1039fn extract_binary(archive: &[u8]) -> Result<Vec<u8>> {
1040    let mut reader = tar::Archive::new(flate2::read::GzDecoder::new(archive));
1041    for entry in reader.entries().context("read the mbx release archive")? {
1042        let mut entry = entry.context("read the mbx release archive")?;
1043        if entry.path().context("read an mbx archive path")?.as_ref() != Path::new("mbx") {
1044            continue;
1045        }
1046        let mut bytes = Vec::new();
1047        entry
1048            .read_to_end(&mut bytes)
1049            .context("read the mbx binary from its release archive")?;
1050        return Ok(bytes);
1051    }
1052    bail!("the mbx release archive contains no mbx binary")
1053}
1054
1055// -- per-session decision -------------------------------------------------
1056
1057/// Whether the primary repository is a Cargo workspace, read from the host
1058/// mirror the clone cache prepared. A repository whose manifest is not at its
1059/// root, and a session whose clone cache was not prepared, run without mbx.
1060pub(super) fn primary_repository_is_rust(
1061    host: &CacheHost,
1062    mirror: &Path,
1063    executor: &impl CommandExecutor,
1064) -> bool {
1065    let command = host.command(
1066        vec![
1067            "git".to_owned(),
1068            "--git-dir".to_owned(),
1069            mirror.to_string_lossy().into_owned(),
1070            "cat-file".to_owned(),
1071            "-e".to_owned(),
1072            "HEAD:Cargo.toml".to_owned(),
1073        ],
1074        "detect a Cargo workspace in the session repository",
1075    );
1076    matches!(executor.execute(&command), Ok(output) if output.status == 0)
1077}
1078
1079/// Decide the build cache for one session and attach its mounts, returning the
1080/// placement to record on the session. Resumes and moves resolve current
1081/// machine policy instead of reviving a saved session budget.
1082pub(super) fn prepare(
1083    target: &targets::TargetTemplate,
1084    session: &mj_core::state::SessionRecord,
1085    bundle: Option<&targets::ProjectBundleSpec>,
1086    clone_cache: Option<&super::git_cache::PreparedCloneCache>,
1087    mounts: &mut Vec<targets::AdditionalMount>,
1088    executor: &impl CommandExecutor,
1089) -> Option<SessionBuildCache> {
1090    // This function prepares container mounts. Budgets always come from the
1091    // machine; a previously recorded budget is never revived on recreation.
1092    // A session at the legacy shared `/workspace` would collide with every
1093    // other legacy session in mbx's path-keyed records.
1094    session.container_workspace.as_ref()?;
1095    let resolved = resolve(target, executor)?;
1096    let host = supported_host(target)?.0;
1097    if session.build_cache.is_none() {
1098        let mirror = clone_cache?.mirror_for(&bundle?.primary)?;
1099        if !primary_repository_is_rust(&host, mirror, executor) {
1100            return None;
1101        }
1102    }
1103    let build_cache = SessionBuildCache {
1104        host: host.key(),
1105        directory: resolved.directory,
1106        max_size: None,
1107        target_root: resolved.target_root,
1108    };
1109    attach_mounts(&build_cache, mounts).then_some(build_cache)
1110}
1111
1112/// Mount the cache, and a relocated target root, read-write at the same
1113/// absolute paths the host uses. An attached directory that already covers one
1114/// of those paths wins, and the session runs without the cache.
1115fn attach_mounts(
1116    build_cache: &SessionBuildCache,
1117    mounts: &mut Vec<targets::AdditionalMount>,
1118) -> bool {
1119    let wanted = std::iter::once(&build_cache.directory)
1120        .chain(build_cache.target_root.iter())
1121        .collect::<Vec<_>>();
1122    for directory in &wanted {
1123        if mounts.iter().any(|mount| {
1124            mount.destination.starts_with(directory) || directory.starts_with(&mount.destination)
1125        }) {
1126            tracing::warn!(
1127                directory = %directory.display(),
1128                "an attached directory overlaps the build cache, so this session runs without it"
1129            );
1130            return false;
1131        }
1132    }
1133    for directory in wanted {
1134        mounts.push(targets::AdditionalMount {
1135            source: directory.clone(),
1136            destination: directory.clone(),
1137            access: targets::MountAccess::Rw,
1138        });
1139    }
1140    true
1141}
1142
1143/// `attach_mounts` for the provisioning tests, which check the container
1144/// arguments the mounts produce.
1145#[cfg(test)]
1146pub(super) fn attach_mounts_for_tests(
1147    build_cache: &SessionBuildCache,
1148    mounts: &mut Vec<targets::AdditionalMount>,
1149) -> bool {
1150    attach_mounts(build_cache, mounts)
1151}
1152
1153/// Read the configuration on the host that actually owns this container.
1154/// The named template may have been removed or reassigned since creation.
1155fn host_for_locator(target: &targets::TargetLocator) -> Option<CacheHost> {
1156    match target {
1157        targets::TargetLocator::LocalPodman { .. } | targets::TargetLocator::LocalDocker { .. } => {
1158            Some(CacheHost::Local)
1159        }
1160        targets::TargetLocator::SshPodman { ssh, .. }
1161        | targets::TargetLocator::SshDocker { ssh, .. } => Some(CacheHost::Ssh(ssh.clone())),
1162        _ => None,
1163    }
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168    use super::*;
1169    use crate::targets::{ContainerTemplate, SshTarget, TargetTemplate};
1170    use mj_core::config::ImagePullPolicy;
1171    use std::sync::Mutex;
1172
1173    /// The resolution cache is process-wide, so tests that exercise it run one
1174    /// at a time and start from an empty cache.
1175    static ISOLATED: Mutex<()> = Mutex::new(());
1176
1177    fn isolated() -> std::sync::MutexGuard<'static, ()> {
1178        let guard = ISOLATED.lock().unwrap_or_else(|error| error.into_inner());
1179        RESOLUTIONS.lock().expect("mbx resolutions").clear();
1180        APPLICATIONS.lock().expect("mbx applications").clear();
1181        guard
1182    }
1183
1184    /// Answers canned commands by a substring of their joined argument list.
1185    #[derive(Default)]
1186    struct ProbeExecutor {
1187        answers: Vec<(&'static str, i32, String)>,
1188        seen: Mutex<Vec<String>>,
1189    }
1190
1191    impl ProbeExecutor {
1192        fn new(answers: &[(&'static str, i32, &str)]) -> Self {
1193            Self {
1194                answers: answers
1195                    .iter()
1196                    .map(|(needle, status, stdout)| (*needle, *status, (*stdout).to_owned()))
1197                    .chain(std::iter::once(("uname -sm", 0, "Linux x86_64\n".into())))
1198                    .collect(),
1199                seen: Mutex::new(Vec::new()),
1200            }
1201        }
1202
1203        fn ran(&self) -> Vec<String> {
1204            self.seen.lock().unwrap().clone()
1205        }
1206    }
1207
1208    impl CommandExecutor for ProbeExecutor {
1209        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1210            let line = format!("{} {}", command.program, command.args.join(" "));
1211            self.seen.lock().unwrap().push(line.clone());
1212            // Undo the quoting added by join_remote_command for fixture matching.
1213            let searchable = if command.program == "ssh" {
1214                line.replace("'\\''", "'").replace("' '", " ")
1215            } else {
1216                line.clone()
1217            };
1218            for (needle, status, stdout) in &self.answers {
1219                if searchable.contains(needle) {
1220                    return Ok(CommandOutput {
1221                        status: *status,
1222                        stdout: stdout.clone().into_bytes(),
1223                        stderr: Vec::new(),
1224                    });
1225                }
1226            }
1227            Ok(CommandOutput {
1228                status: 127,
1229                stdout: Vec::new(),
1230                stderr: format!("no canned answer for {line}").into_bytes(),
1231            })
1232        }
1233    }
1234
1235    fn container(build_cache: Option<TargetBuildCache>) -> ContainerTemplate {
1236        ContainerTemplate {
1237            image: "example/image:latest".into(),
1238            pull_policy: ImagePullPolicy::Missing,
1239            extra_run_args: Vec::new(),
1240            workspace_storage: Default::default(),
1241            build_cache,
1242        }
1243    }
1244
1245    fn podman(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1246        TargetTemplate::LocalPodman(container(build_cache))
1247    }
1248
1249    fn docker(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1250        TargetTemplate::LocalDocker(container(build_cache))
1251    }
1252
1253    /// The settings draft's view of this machine with blank build cache
1254    /// fields.
1255    fn configured_local_machine() -> mj_core::config::Machine {
1256        serde_json::from_value(serde_json::json!({"kind": "local"})).unwrap()
1257    }
1258
1259    /// Where a local host with no mbx configuration of its own keeps the
1260    /// cache: this machine's home, which the controller reads directly.
1261    fn default_cache_directory() -> PathBuf {
1262        dirs::home_dir()
1263            .expect("a home directory")
1264            .join(DEFAULT_CACHE_RELATIVE)
1265    }
1266
1267    /// The canned answers a host with no native mbx and a reflink-capable
1268    /// home directory gives.
1269    /// A native mbx's `--version` answer at the release containers run.
1270    fn current_native_mbx() -> String {
1271        format!("mbx\nmbx {MBX_VERSION}")
1272    }
1273
1274    fn plain_host() -> Vec<(&'static str, i32, &'static str)> {
1275        vec![
1276            ("$m\" --version", 1, ""),
1277            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1278            ("[ -f \"$1\" ]", 3, ""),
1279            ("while [ ! -d", 0, "/home/dev"),
1280            (
1281                "df -B1 -P",
1282                0,
1283                "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 1000000000000 0 800000000000 20% /home\n",
1284            ),
1285            ("mj-reflink", 0, ""),
1286            ("mkdir -p", 0, ""),
1287            ("stat -f -c %T", 0, "xfs"),
1288        ]
1289    }
1290
1291    #[test]
1292    fn darwin_hosts_skip_cache_inspection_provisioning_and_reconciliation() {
1293        let _isolated = isolated();
1294        for remote in [false, true] {
1295            for enabled in [None, Some(true)] {
1296                let settings = TargetBuildCache {
1297                    enabled,
1298                    ..Default::default()
1299                };
1300                let machine: mj_core::config::Machine = if remote {
1301                    serde_json::from_value(serde_json::json!({
1302                        "kind": "ssh", "host": "mac.test", "user": "builder", "build_cache": settings,
1303                    }))
1304                    .unwrap()
1305                } else {
1306                    mj_core::config::Machine::Local {
1307                        build_cache: Some(settings.clone()),
1308                    }
1309                };
1310                let target = if remote {
1311                    TargetTemplate::SshPodman {
1312                        ssh: SshTarget {
1313                            destination: "builder@mac.test".into(),
1314                            ssh_args: vec![],
1315                        },
1316                        container: container(Some(settings)),
1317                    }
1318                } else {
1319                    podman(Some(settings))
1320                };
1321                // An installed native mbx must not enable Mjolnir's integration.
1322                let executor = ProbeExecutor::new(&[
1323                    ("uname -sm", 0, "Darwin arm64\n"),
1324                    ("$m\" --version", 0, "mbx\nmbx 1.16.0"),
1325                ]);
1326                let preview = preview_build_cache(&machine, &executor).unwrap().unwrap();
1327                assert_eq!(
1328                    preview.off_reason,
1329                    Some(BuildCacheOff::Unavailable(UNSUPPORTED_HOST.into()))
1330                );
1331                assert!(preview.directory.is_none());
1332                assert!(resolve(&target, &executor).is_none());
1333                apply_machine_build_cache(&machine, &[PathBuf::from("/existing/cache")], &executor)
1334                    .unwrap();
1335                let commands = executor.ran();
1336                assert!(!commands.is_empty());
1337                assert!(
1338                    commands
1339                        .iter()
1340                        .all(|command| command.contains("uname") && command.contains("-sm")),
1341                    "{commands:?}"
1342                );
1343                assert!(
1344                    commands
1345                        .iter()
1346                        .all(|command| command.starts_with(if remote { "ssh " } else { "uname " }))
1347                );
1348            }
1349        }
1350    }
1351
1352    #[test]
1353    fn linux_ssh_cache_remains_available_on_any_controller_platform() {
1354        let _isolated = isolated();
1355        let executor = ProbeExecutor::new(&plain_host());
1356        let target = TargetTemplate::SshDocker {
1357            ssh: SshTarget {
1358                destination: "builder@linux.test".into(),
1359                ssh_args: vec![],
1360            },
1361            container: container(None),
1362        };
1363        let cache = resolve(&target, &executor).unwrap();
1364        assert_eq!(cache.directory, PathBuf::from("/home/dev/.cache/mbx"));
1365        assert_eq!(cache.previous_config, cache.config_file);
1366        assert!(
1367            executor.ran().iter().all(
1368                |command| command.starts_with("ssh ") && command.contains("builder@linux.test")
1369            )
1370        );
1371    }
1372
1373    #[test]
1374    fn recorded_darwin_cache_fails_explicitly_without_writing() {
1375        let executor = ProbeExecutor::new(&[("uname -sm", 0, "Darwin x86_64")]);
1376        let recorded = SessionBuildCache {
1377            host: "local".into(),
1378            directory: PathBuf::from("/existing/cache"),
1379            max_size: None,
1380            target_root: None,
1381        };
1382        let backend = targets::TargetLocator::LocalPodman {
1383            container_id: "saved-container".into(),
1384            workspace_storage: Default::default(),
1385            borrowed_from: None,
1386        };
1387        let error =
1388            prepare_session_configuration(&Config::default(), &backend, &recorded, &executor)
1389                .unwrap_err();
1390        assert!(format!("{error:#}").contains(UNSUPPORTED_HOST));
1391        assert_eq!(executor.ran(), ["uname -sm"]);
1392    }
1393
1394    #[test]
1395    fn failed_platform_probe_does_not_attempt_cache_operations() {
1396        let executor = ProbeExecutor::new(&[("uname -sm", 1, "")]);
1397        assert!(inspect_host(&CacheHost::Local, &TargetBuildCache::default(), &executor).is_err());
1398        assert_eq!(executor.ran(), ["uname -sm"]);
1399    }
1400
1401    #[test]
1402    fn installed_worker_reads_cache_configuration_from_its_recorded_host() {
1403        let executor = ProbeExecutor::new(&[
1404            ("XDG_CONFIG_HOME", 0, "/home/builder/.config/mbx"),
1405            ("$HOME", 0, "/home/builder"),
1406            ("[ -f \"$1\" ]", 0, "[gc]\nmax_total_size = '50GB'\n"),
1407        ]);
1408        let target = targets::TargetLocator::SshPodman {
1409            ssh: SshTarget {
1410                destination: "builder@recorded-cache.test".into(),
1411                ssh_args: vec![],
1412            },
1413            container_id: "saved-container".into(),
1414            workspace_storage: Default::default(),
1415            borrowed_from: None,
1416        };
1417        let config = host_config_file(&host_for_locator(&target).unwrap(), &executor)
1418            .unwrap()
1419            .unwrap();
1420        assert!(config.contains("50GB"));
1421        assert!(
1422            executor
1423                .seen
1424                .lock()
1425                .unwrap()
1426                .iter()
1427                .all(|command| command.contains("builder@recorded-cache.test"))
1428        );
1429    }
1430
1431    #[test]
1432    fn a_native_mbx_supplies_the_cache_directory_and_its_own_limits() {
1433        let _isolated = isolated();
1434        let executor = ProbeExecutor::new(&[
1435            ("$m\" --version", 0, current_native_mbx().as_str()),
1436            (
1437                "mbx cache dir --json",
1438                0,
1439                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1440            ),
1441            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1442            (
1443                "[ -f \"$1\" ]",
1444                0,
1445                "cache_dir = \"/mnt/fast/mbx-cache\"\n[gc]\nmax_size = \"500GiB\"\n",
1446            ),
1447            ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1448            ("mj-reflink", 0, ""),
1449            ("mkdir -p", 0, ""),
1450            ("stat -f -c %T", 0, "xfs"),
1451        ]);
1452        let resolved = resolve(&podman(None), &executor).unwrap();
1453        assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1454        // The host's own configuration file carries the budget.
1455        assert_eq!(
1456            configuration::configured_limit(
1457                resolved.config_file.as_deref(),
1458                "gc",
1459                "max_total_size"
1460            )
1461            .unwrap(),
1462            None
1463        );
1464        assert_eq!(resolved.target_root, None);
1465        assert!(resolved.config_file.unwrap().contains("500GiB"));
1466        assert!(
1467            !executor
1468                .ran()
1469                .iter()
1470                .any(|line| line.contains("apply machine")),
1471            "a host configuration is never rewritten"
1472        );
1473    }
1474
1475    #[test]
1476    fn looking_at_the_settings_page_lets_the_next_session_see_a_repaired_host() {
1477        let _isolated = isolated();
1478        let broken = ProbeExecutor::new(
1479            &plain_host()
1480                .into_iter()
1481                .map(|(needle, status, stdout)| match needle {
1482                    "mj-reflink" => (needle, 1, stdout),
1483                    _ => (needle, status, stdout),
1484                })
1485                .collect::<Vec<_>>(),
1486        );
1487        assert!(
1488            resolve(&podman(None), &broken).is_none(),
1489            "a volume that cannot clone runs without the cache"
1490        );
1491
1492        // The host is repaired, and the user opens the machine's build cache
1493        // page to check.
1494        let repaired = ProbeExecutor::new(&plain_host());
1495        let preview = preview_build_cache(&configured_local_machine(), &repaired)
1496            .expect("the host answers")
1497            .expect("a local machine can hold a cache");
1498        assert_eq!(preview.off_reason, None);
1499
1500        assert!(
1501            resolve(&podman(None), &repaired).is_some(),
1502            "the next session asks the repaired host again instead of reusing the old verdict"
1503        );
1504    }
1505
1506    #[test]
1507    fn a_relocated_target_root_is_reported_for_its_own_mount() {
1508        let _isolated = isolated();
1509        let executor = ProbeExecutor::new(&[
1510            ("$m\" --version", 0, current_native_mbx().as_str()),
1511            (
1512                "mbx cache dir --json",
1513                0,
1514                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1515            ),
1516            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1517            (
1518                "[ -f \"$1\" ]",
1519                0,
1520                "[target]\nroot = \"/mnt/fast/mbx-targets\"\n",
1521            ),
1522            ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1523            ("mj-reflink", 0, ""),
1524            ("mkdir -p", 0, ""),
1525            ("stat -f -c %T", 0, "xfs"),
1526        ]);
1527        let resolved = resolve(&podman(None), &executor).unwrap();
1528        assert_eq!(
1529            resolved.target_root,
1530            Some(PathBuf::from("/mnt/fast/mbx-targets"))
1531        );
1532    }
1533
1534    #[test]
1535    fn a_target_root_that_cannot_be_cloned_into_still_gets_the_cache() {
1536        let _isolated = isolated();
1537        let executor = ProbeExecutor::new(&[
1538            ("$m\" --version", 0, current_native_mbx().as_str()),
1539            (
1540                "mbx cache dir --json",
1541                0,
1542                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1543            ),
1544            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1545            (
1546                "[ -f \"$1\" ]",
1547                0,
1548                "[target]\nroot = \"/mnt/slow/mbx-targets\"\n",
1549            ),
1550            ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1551            // Cloning from the store into the relocated root fails; cloning
1552            // within the store still works.
1553            ("src=$1", 1, ""),
1554            ("mj-reflink", 0, ""),
1555            ("mkdir -p", 0, ""),
1556            ("stat -f -c %T", 0, "xfs"),
1557        ]);
1558        let resolved = resolve(&podman(None), &executor)
1559            .expect("a target root that copies instead of cloning is slower, not unusable");
1560        assert_eq!(
1561            resolved.target_root,
1562            Some(PathBuf::from("/mnt/slow/mbx-targets"))
1563        );
1564        assert!(
1565            executor.ran().iter().any(|line| line.contains("src=$1")),
1566            "the store and the target root are probed as a pair: {:?}",
1567            executor.ran()
1568        );
1569    }
1570
1571    #[test]
1572    fn a_target_root_inside_the_cache_directory_needs_no_second_mount() {
1573        assert_eq!(
1574            relocated_target_root("[target]\nroot = \"targets\"\n", Path::new("/cache")),
1575            None
1576        );
1577        assert_eq!(
1578            relocated_target_root("[target]\nroot = \"/cache/targets\"\n", Path::new("/cache")),
1579            None
1580        );
1581    }
1582
1583    #[test]
1584    fn a_cargo_installed_mbx_off_the_path_is_queried_where_it_was_found() {
1585        let _isolated = isolated();
1586        let found = format!("/home/dev/.cargo/bin/mbx\nmbx {MBX_VERSION}");
1587        let mut answers: Vec<(&'static str, i32, &str)> = plain_host();
1588        answers.retain(|(needle, _, _)| *needle != "$m\" --version");
1589        answers.push(("$m\" --version", 0, found.as_str()));
1590        answers.push((
1591            "/home/dev/.cargo/bin/mbx cache dir --json",
1592            0,
1593            r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1594        ));
1595        let executor = ProbeExecutor::new(&answers);
1596        let resolved = resolve(&podman(None), &executor).unwrap();
1597        assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1598    }
1599
1600    #[test]
1601    fn an_older_native_mbx_must_not_share_the_store() {
1602        let _isolated = isolated();
1603        let executor = ProbeExecutor::new(&[("$m\" --version", 0, "mbx\nmbx 1.15.0")]);
1604        assert_eq!(resolve(&podman(None), &executor), None);
1605    }
1606
1607    #[test]
1608    fn a_host_without_mbx_falls_back_to_the_default_cache_directory() {
1609        let _isolated = isolated();
1610        let executor = ProbeExecutor::new(&plain_host());
1611        let resolved = resolve(&podman(None), &executor).unwrap();
1612        assert_eq!(resolved.directory, default_cache_directory());
1613        // min(100 GB, 800 GB / 4) is the 100 GB cap.
1614        assert_eq!(
1615            configuration::configured_limit(
1616                resolved.config_file.as_deref(),
1617                "gc",
1618                "max_total_size"
1619            )
1620            .unwrap()
1621            .as_deref(),
1622            Some("100000000000B")
1623        );
1624    }
1625
1626    #[test]
1627    fn a_small_volume_takes_a_quarter_of_its_free_space() {
1628        let _isolated = isolated();
1629        let mut answers = plain_host();
1630        answers.retain(|(needle, _, _)| *needle != "df -B1 -P");
1631        answers.push((
1632            "df -B1 -P",
1633            0,
1634            "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 100000000 60000000 40000000 60% /home\n",
1635        ));
1636        let executor = ProbeExecutor::new(&answers);
1637        let resolved = resolve(&podman(None), &executor).unwrap();
1638        assert_eq!(
1639            configuration::configured_limit(
1640                resolved.config_file.as_deref(),
1641                "gc",
1642                "max_total_size"
1643            )
1644            .unwrap()
1645            .as_deref(),
1646            Some("10000000B")
1647        );
1648    }
1649
1650    #[test]
1651    fn target_overrides_win_over_every_default() {
1652        let _isolated = isolated();
1653        let mut answers = plain_host();
1654        answers.push(("mbx cache dir", 0, r#"{"store":"/other/actions"}"#));
1655        let executor = ProbeExecutor::new(&answers);
1656        let resolved = resolve(
1657            &podman(Some(TargetBuildCache {
1658                enabled: Some(true),
1659                directory: Some(PathBuf::from("/mnt/nvme/mbx")),
1660                max_total_size: Some("250GiB".into()),
1661            })),
1662            &executor,
1663        )
1664        .unwrap();
1665        assert_eq!(resolved.directory, PathBuf::from("/mnt/nvme/mbx"));
1666        assert_eq!(
1667            configuration::configured_limit(
1668                resolved.config_file.as_deref(),
1669                "gc",
1670                "max_total_size"
1671            )
1672            .unwrap()
1673            .as_deref(),
1674            Some("250GiB")
1675        );
1676    }
1677
1678    #[test]
1679    fn one_total_budget_resolves_without_component_caps() {
1680        let _isolated = isolated();
1681        let executor = ProbeExecutor::new(&plain_host());
1682        let settings = TargetBuildCache {
1683            max_total_size: Some("500GiB".into()),
1684            ..Default::default()
1685        };
1686        let inspection = inspect_host(&CacheHost::Local, &settings, &executor).unwrap();
1687        assert!(!inspection.preview.user_managed);
1688        assert_eq!(
1689            inspection.preview.max_total_size,
1690            Some(BuildCacheLimit::Size("500GiB".into()))
1691        );
1692        assert_eq!(
1693            inspection.preview.application,
1694            BuildCacheApplication::Pending
1695        );
1696        let cache = inspection.cache.unwrap();
1697        assert_eq!(
1698            configuration::configured_limit(cache.config_file.as_deref(), "target", "max_size")
1699                .unwrap()
1700                .as_deref(),
1701            None
1702        );
1703        assert!(
1704            !executor
1705                .ran()
1706                .iter()
1707                .any(|command| command.contains(".mj-apply.lock")),
1708            "preview never applies a setting"
1709        );
1710    }
1711
1712    #[test]
1713    fn a_native_installation_owns_the_budget_despite_saved_mj_overrides() {
1714        let _isolated = isolated();
1715        let native = current_native_mbx();
1716        let mut answers = vec![
1717            ("$m\" --version", 0, native.as_str()),
1718            (
1719                "mbx cache dir --json",
1720                0,
1721                r#"{"store":"/native/cache/actions"}"#,
1722            ),
1723            (
1724                "[ -f \"$1\" ]",
1725                0,
1726                "[gc]\nmax_total_size = '400GiB'\n[target]\nmax_size = 'none'\n",
1727            ),
1728        ];
1729        answers.extend(plain_host());
1730        let executor = ProbeExecutor::new(&answers);
1731        let settings = TargetBuildCache {
1732            directory: Some("/ignored".into()),
1733            max_total_size: Some("1GB".into()),
1734            ..Default::default()
1735        };
1736        let inspection = inspect_host(&CacheHost::Local, &settings, &executor).unwrap();
1737        assert!(inspection.preview.user_managed);
1738        assert_eq!(inspection.preview.directory, Some("/native/cache".into()));
1739        assert_eq!(
1740            inspection.preview.max_total_size,
1741            Some(BuildCacheLimit::HostConfiguration(Some("400GiB".into())))
1742        );
1743        assert!(
1744            !executor
1745                .ran()
1746                .iter()
1747                .any(|command| command.contains(".mj-apply.lock"))
1748        );
1749    }
1750
1751    #[test]
1752    fn the_automatic_total_is_reused_instead_of_following_free_space() {
1753        let _isolated = isolated();
1754        let saved = configuration::managed_document(&TargetBuildCache::default(), "17GB").unwrap();
1755        let mut answers = vec![(".mjolnir/config/mbx/config.toml", 0, saved.as_str())];
1756        answers.extend(plain_host());
1757        let executor = ProbeExecutor::new(&answers);
1758        let preview = inspect_host(&CacheHost::Local, &TargetBuildCache::default(), &executor)
1759            .unwrap()
1760            .preview;
1761        assert_eq!(
1762            preview.max_total_size,
1763            Some(BuildCacheLimit::MjDefault("17GB".into()))
1764        );
1765        assert_eq!(preview.application, BuildCacheApplication::Applied);
1766    }
1767
1768    #[test]
1769    fn legacy_managed_budgets_are_replaced_by_a_fresh_shared_default() {
1770        let _isolated = isolated();
1771        let saved = "# mj automatic total: 17GB\n[gc]\nmax_total_size = '500GiB'\n[target]\nmax_size = '250GiB'\n";
1772        let mut answers = vec![(".mjolnir/config/mbx/config.toml", 0, saved)];
1773        answers.extend(plain_host());
1774        let executor = ProbeExecutor::new(&answers);
1775        let inspection =
1776            inspect_host(&CacheHost::Local, &TargetBuildCache::default(), &executor).unwrap();
1777        assert_eq!(
1778            inspection.preview.max_total_size,
1779            Some(BuildCacheLimit::MjDefault("100000000000B".into()))
1780        );
1781        assert_eq!(
1782            inspection.preview.application,
1783            BuildCacheApplication::Pending
1784        );
1785        let cache = inspection.cache.unwrap();
1786        let policy: toml::Value = toml::from_str(cache.config_file.as_deref().unwrap()).unwrap();
1787        assert_eq!(
1788            policy["gc"]["max_total_size"].as_str(),
1789            Some("100000000000B")
1790        );
1791        assert!(policy.get("target").is_none());
1792        assert!(policy["gc"].get("max_size").is_none());
1793    }
1794
1795    /// Turning the cache on cannot override the host: without reflinks a
1796    /// restore would copy every byte, so sessions still run without it.
1797    #[test]
1798    fn an_enabled_setting_does_not_survive_a_volume_without_reflinks() {
1799        let _isolated = isolated();
1800        let mut answers = plain_host();
1801        answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1802        answers.push(("mj-reflink", 1, ""));
1803        let executor = ProbeExecutor::new(&answers);
1804        assert_eq!(
1805            resolve(
1806                &podman(Some(TargetBuildCache {
1807                    enabled: Some(true),
1808                    directory: None,
1809                    max_total_size: None,
1810                })),
1811                &executor,
1812            ),
1813            None
1814        );
1815    }
1816
1817    #[test]
1818    fn a_volume_without_reflinks_runs_without_the_cache() {
1819        let _isolated = isolated();
1820        let mut answers = plain_host();
1821        answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1822        answers.push(("mj-reflink", 1, ""));
1823        let executor = ProbeExecutor::new(&answers);
1824        assert_eq!(resolve(&podman(None), &executor), None);
1825    }
1826
1827    #[test]
1828    fn the_preview_names_the_resolved_values_and_the_reason_the_cache_is_off() {
1829        let _isolated = isolated();
1830        let mut answers = plain_host();
1831        answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1832        answers.push(("mj-reflink", 1, ""));
1833        let executor = ProbeExecutor::new(&answers);
1834        let preview = preview_build_cache(&configured_local_machine(), &executor)
1835            .unwrap()
1836            .unwrap();
1837        assert_eq!(preview.native_mbx, None);
1838        assert_eq!(preview.directory, Some(default_cache_directory()));
1839        assert_eq!(
1840            preview.max_total_size,
1841            Some(BuildCacheLimit::MjDefault("100000000000B".into()))
1842        );
1843        assert!(
1844            matches!(&preview.off_reason, Some(BuildCacheOff::Unavailable(reason)) if reason.contains("reflinks")),
1845            "{:?}",
1846            preview.off_reason
1847        );
1848        // A preview reads the host; it never creates the directory.
1849        assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1850
1851        let executor = ProbeExecutor::new(&[
1852            ("$m\" --version", 0, current_native_mbx().as_str()),
1853            (
1854                "mbx cache dir --json",
1855                0,
1856                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1857            ),
1858            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1859            ("[ -f \"$1\" ]", 0, "[gc]\nmax_size = \"500GiB\"\n"),
1860            ("while [ ! -d", 0, "/mnt/fast"),
1861            ("mj-reflink", 0, ""),
1862            ("stat -f -c %T", 0, "xfs"),
1863        ]);
1864        let preview = preview_build_cache(&configured_local_machine(), &executor)
1865            .unwrap()
1866            .unwrap();
1867        assert_eq!(preview.native_mbx.as_deref(), Some(MBX_VERSION));
1868        assert_eq!(
1869            preview.directory,
1870            Some(PathBuf::from("/mnt/fast/mbx-cache"))
1871        );
1872        assert_eq!(
1873            preview.max_total_size,
1874            Some(BuildCacheLimit::MbxDefault(None))
1875        );
1876        assert_eq!(preview.off_reason, None);
1877        assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1878    }
1879
1880    #[test]
1881    fn a_network_filesystem_runs_without_the_cache() {
1882        let _isolated = isolated();
1883        let mut answers = plain_host();
1884        answers.retain(|(needle, _, _)| *needle != "stat -f -c %T");
1885        answers.push(("stat -f -c %T", 0, "nfs4"));
1886        let executor = ProbeExecutor::new(&answers);
1887        assert_eq!(resolve(&podman(None), &executor), None);
1888    }
1889
1890    #[test]
1891    fn a_machine_opt_out_does_not_disable_default_cache_policy() {
1892        let _isolated = isolated();
1893        let executor = ProbeExecutor::new(&plain_host());
1894        let disabled = podman(Some(TargetBuildCache {
1895            enabled: Some(false),
1896            ..Default::default()
1897        }));
1898        assert!(resolve(&disabled, &executor).is_none());
1899        assert!(
1900            !executor
1901                .ran()
1902                .iter()
1903                .any(|command| command.contains("mkdir -p") || command.contains(".mj-apply.lock"))
1904        );
1905        assert!(resolve(&podman(None), &executor).is_some());
1906        assert!(resolve(&disabled, &executor).is_none());
1907    }
1908
1909    #[test]
1910    fn local_podman_and_local_docker_inspect_one_machine_once() {
1911        let _isolated = isolated();
1912        let executor = ProbeExecutor::new(&plain_host());
1913        let first = resolve(&podman(None), &executor).unwrap();
1914        let ran = executor.ran().len();
1915        assert!(ran > 0, "the first resolve inspects the host");
1916        let second = resolve(&docker(None), &executor).unwrap();
1917        assert_eq!(
1918            first, second,
1919            "both engines on this machine share one cache"
1920        );
1921        // The inspection is memoized; creating the directory is not, because a
1922        // remembered inspection says what the host looked like, not that the
1923        // directory still exists.
1924        let added = executor.ran()[ran..].to_vec();
1925        assert_eq!(
1926            added.len(),
1927            1,
1928            "the second runtime is answered from the machine's recorded inspection: {added:?}"
1929        );
1930        assert!(
1931            added[0].contains("mkdir -p"),
1932            "the one repeated command creates the directory: {added:?}"
1933        );
1934    }
1935
1936    #[test]
1937    fn the_preview_reports_what_the_cache_has_already_done() {
1938        let _isolated = isolated();
1939        // Ahead of the configuration read, which tests the same `[ -f ]`.
1940        let mut answers = vec![(
1941            "tally.json",
1942            0,
1943            r#"{"version":1,"since_secs":1789824719,"builds":155,"cached_compilations":12050,"avoided_compiler_ns":6004997818721,"reflinked_bytes":47612059386}"#,
1944        )];
1945        answers.extend(plain_host());
1946        let executor = ProbeExecutor::new(&answers);
1947        let preview = preview_build_cache(&configured_local_machine(), &executor)
1948            .expect("the host answers")
1949            .expect("a local machine can hold a cache");
1950        assert_eq!(
1951            preview.stats,
1952            Some(mj_core::state::BuildCacheStats {
1953                builds: 155,
1954                cached_compilations: 12050,
1955                avoided_compiler_ns: 6_004_997_818_721,
1956                reflinked_bytes: 47_612_059_386,
1957            })
1958        );
1959    }
1960
1961    #[test]
1962    fn a_cache_nothing_has_used_yet_reports_no_totals() {
1963        let _isolated = isolated();
1964        // `plain_host` answers every `[ -f ]` with 3: no configuration file
1965        // and no tally beside the store.
1966        let executor = ProbeExecutor::new(&plain_host());
1967        let preview = preview_build_cache(&configured_local_machine(), &executor)
1968            .expect("the host answers")
1969            .expect("a local machine can hold a cache");
1970        assert_eq!(preview.stats, None);
1971    }
1972
1973    #[test]
1974    fn a_machine_without_a_standing_host_has_no_build_cache_preview() {
1975        let _isolated = isolated();
1976        let executor = ProbeExecutor::new(&plain_host());
1977        let fleet: mj_core::config::Machine = serde_json::from_value(serde_json::json!({
1978            "kind": "aws-ec2",
1979            "region": "us-east-1",
1980            "launch_template": "lt-1",
1981            "ssh_user": "ubuntu",
1982        }))
1983        .unwrap();
1984        assert_eq!(preview_build_cache(&fleet, &executor).unwrap(), None);
1985        assert!(executor.ran().is_empty());
1986    }
1987
1988    #[test]
1989    fn apple_and_bare_targets_have_no_shared_build_cache() {
1990        let _isolated = isolated();
1991        let executor = ProbeExecutor::new(&plain_host());
1992        for target in [
1993            TargetTemplate::AppleContainer(container(None)),
1994            TargetTemplate::LocalBare,
1995            TargetTemplate::SshBare {
1996                ssh: SshTarget {
1997                    destination: "dev@example.test".into(),
1998                    ssh_args: Vec::new(),
1999                },
2000                workspace_prefix: "workspaces".into(),
2001            },
2002        ] {
2003            assert_eq!(resolve(&target, &executor), None, "{target:?}");
2004        }
2005        assert!(executor.ran().is_empty());
2006    }
2007
2008    fn bundle() -> targets::ProjectBundleSpec {
2009        targets::ProjectBundleSpec {
2010            primary: "main".into(),
2011            repositories: vec![targets::RepositorySpec {
2012                url: Some("https://github.com/example/main.git".into()),
2013                push_urls: Vec::new(),
2014                destination: "main".into(),
2015                git_ref: None,
2016                reference: None,
2017            }],
2018        }
2019    }
2020
2021    fn clone_cache() -> super::super::git_cache::PreparedCloneCache {
2022        super::super::git_cache::PreparedCloneCache::from_mirrors(
2023            [(
2024                "main".to_owned(),
2025                PathBuf::from("/home/dev/mirror/repo.git"),
2026            )]
2027            .into_iter()
2028            .collect(),
2029        )
2030    }
2031
2032    fn session(container_workspace: Option<&str>) -> mj_core::state::SessionRecord {
2033        let mut record = crate::controller::test_support::checkpoint_test_session("session-1");
2034        record.container_workspace = container_workspace.map(PathBuf::from);
2035        record
2036    }
2037
2038    #[test]
2039    fn a_rust_session_mounts_the_cache_at_the_host_path() {
2040        let _isolated = isolated();
2041        let mut answers = plain_host();
2042        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2043        let executor = ProbeExecutor::new(&answers);
2044        let mut mounts = Vec::new();
2045        let build_cache = prepare(
2046            &podman(None),
2047            &session(Some("/workspace/session-1")),
2048            Some(&bundle()),
2049            Some(&clone_cache()),
2050            &mut mounts,
2051            &executor,
2052        )
2053        .expect("a Rust session uses the build cache");
2054        assert_eq!(build_cache.directory, default_cache_directory());
2055        assert_eq!(
2056            mounts,
2057            vec![targets::AdditionalMount {
2058                source: default_cache_directory(),
2059                destination: default_cache_directory(),
2060                access: targets::MountAccess::Rw,
2061            }]
2062        );
2063    }
2064
2065    #[test]
2066    fn a_repository_without_a_root_manifest_runs_without_the_cache() {
2067        let _isolated = isolated();
2068        let mut answers = plain_host();
2069        answers.push(("cat-file -e HEAD:Cargo.toml", 1, ""));
2070        let executor = ProbeExecutor::new(&answers);
2071        let mut mounts = Vec::new();
2072        assert_eq!(
2073            prepare(
2074                &podman(None),
2075                &session(Some("/workspace/session-1")),
2076                Some(&bundle()),
2077                Some(&clone_cache()),
2078                &mut mounts,
2079                &executor,
2080            ),
2081            None
2082        );
2083        assert!(mounts.is_empty());
2084    }
2085
2086    #[test]
2087    fn a_session_at_the_legacy_shared_workspace_runs_without_the_cache() {
2088        let _isolated = isolated();
2089        let mut answers = plain_host();
2090        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2091        let executor = ProbeExecutor::new(&answers);
2092        let mut mounts = Vec::new();
2093        assert_eq!(
2094            prepare(
2095                &podman(None),
2096                &session(None),
2097                Some(&bundle()),
2098                Some(&clone_cache()),
2099                &mut mounts,
2100                &executor,
2101            ),
2102            None
2103        );
2104        assert!(executor.ran().is_empty());
2105    }
2106
2107    #[test]
2108    fn a_session_without_a_prepared_clone_cache_runs_without_the_cache() {
2109        let _isolated = isolated();
2110        let mut answers = plain_host();
2111        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2112        let executor = ProbeExecutor::new(&answers);
2113        let mut mounts = Vec::new();
2114        assert_eq!(
2115            prepare(
2116                &podman(None),
2117                &session(Some("/workspace/session-1")),
2118                Some(&bundle()),
2119                None,
2120                &mut mounts,
2121                &executor,
2122            ),
2123            None
2124        );
2125    }
2126
2127    #[test]
2128    fn an_apple_target_never_shares_a_build_cache() {
2129        let _isolated = isolated();
2130        let mut answers = plain_host();
2131        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2132        let executor = ProbeExecutor::new(&answers);
2133        let mut mounts = Vec::new();
2134        assert_eq!(
2135            prepare(
2136                &TargetTemplate::AppleContainer(container(None)),
2137                &session(Some("/workspace/session-1")),
2138                Some(&bundle()),
2139                Some(&clone_cache()),
2140                &mut mounts,
2141                &executor,
2142            ),
2143            None
2144        );
2145        assert!(executor.ran().is_empty());
2146    }
2147
2148    #[test]
2149    fn a_resumed_session_uses_current_machine_policy_instead_of_its_saved_budget() {
2150        let _isolated = isolated();
2151        let executor = ProbeExecutor::new(&plain_host());
2152        let mut record = session(Some("/workspace/session-1"));
2153        record.build_cache = Some(SessionBuildCache {
2154            host: "local".into(),
2155            directory: default_cache_directory(),
2156            max_size: Some("1GB".into()),
2157            target_root: None,
2158        });
2159        let mut mounts = Vec::new();
2160        let build_cache =
2161            prepare(&podman(None), &record, None, None, &mut mounts, &executor).unwrap();
2162        assert_eq!(build_cache.max_size, None);
2163        assert_eq!(build_cache.directory, default_cache_directory());
2164        assert_eq!(mounts.len(), 1);
2165        assert!(
2166            executor
2167                .ran()
2168                .iter()
2169                .any(|line| line.contains(".mj-apply.lock"))
2170        );
2171    }
2172
2173    #[test]
2174    fn a_session_moved_to_another_host_resolves_its_build_cache_again() {
2175        let _isolated = isolated();
2176        let mut answers = plain_host();
2177        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2178        let executor = ProbeExecutor::new(&answers);
2179        let mut record = session(Some("/workspace/session-1"));
2180        record.build_cache = Some(SessionBuildCache {
2181            // The host the session was provisioned on, which the target below
2182            // is not.
2183            host: "ssh:dev@example.test".into(),
2184            directory: PathBuf::from("/mnt/fast/mbx-cache"),
2185            max_size: None,
2186            target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
2187        });
2188        let mut mounts = Vec::new();
2189
2190        let build_cache = prepare(
2191            &podman(None),
2192            &record,
2193            Some(&bundle()),
2194            Some(&clone_cache()),
2195            &mut mounts,
2196            &executor,
2197        )
2198        .expect("the destination host qualifies on its own");
2199
2200        assert_eq!(build_cache.host, "local");
2201        assert_eq!(build_cache.directory, default_cache_directory());
2202        assert_eq!(build_cache.target_root, None);
2203        assert_eq!(
2204            mounts
2205                .iter()
2206                .map(|mount| mount.destination.clone())
2207                .collect::<Vec<_>>(),
2208            vec![default_cache_directory()]
2209        );
2210        assert!(
2211            executor
2212                .ran()
2213                .iter()
2214                .any(|line| line.contains("mj-reflink"))
2215        );
2216    }
2217
2218    #[test]
2219    fn an_attached_directory_over_the_cache_wins() {
2220        let build_cache = SessionBuildCache {
2221            host: "local-podman".into(),
2222            directory: PathBuf::from("/mnt/fast/mbx-cache"),
2223            max_size: None,
2224            target_root: None,
2225        };
2226        let mut mounts = vec![targets::AdditionalMount {
2227            source: PathBuf::from("/elsewhere"),
2228            destination: PathBuf::from("/mnt/fast/mbx-cache/actions"),
2229            access: targets::MountAccess::Ro,
2230        }];
2231        assert!(!attach_mounts(&build_cache, &mut mounts));
2232        assert_eq!(mounts.len(), 1);
2233    }
2234
2235    #[test]
2236    fn versions_compare_by_release_order() {
2237        assert!(version_at_least("1.12.0", "1.12.0"));
2238        assert!(version_at_least("1.12.1", "1.12.0"));
2239        assert!(version_at_least("2.0.0", "1.12.0"));
2240        assert!(!version_at_least("1.11.9", "1.12.0"));
2241        assert!(!version_at_least("1.9.0", "1.12.0"));
2242        assert!(!version_at_least("not-a-version", "1.12.0"));
2243    }
2244
2245    #[test]
2246    fn free_space_is_read_from_the_available_column() {
2247        assert_eq!(
2248            available_bytes(
2249                "Filesystem 1B-blocks Used Available Capacity Mounted on\n\
2250                 /dev/sda1 1000 400 600 40% /\n"
2251            ),
2252            Some(600)
2253        );
2254        assert_eq!(available_bytes("Filesystem 1B-blocks\n"), None);
2255    }
2256}