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