Skip to main content

mj_controller/controller/
mbx.rs

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