1use std::io::Read;
14use std::path::{Path, PathBuf};
15use std::time::{Duration, Instant};
16
17use anyhow::{Context, Result, bail, ensure};
18use sha2::{Digest, Sha256};
19
20use super::cache_host::CacheHost;
21use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec};
22use mj_core::config::{BuildCacheConfig, TargetBuildCache};
23use mj_core::state::{BuildCacheLimit, BuildCachePreview, SessionBuildCache};
24
25pub(super) const MBX_VERSION: &str = "1.12.0";
28
29const MBX_X86_64_SHA256: &str = "b0d90013e5e4e55419b75db897a6b9eed0f0e3b7bc49edf355e0e6490fda6de2";
30const MBX_AARCH64_SHA256: &str = "738b97bf260137aed70cd3cf849925d1f1bec5f1ba446f5e61ed851ae649bc7b";
31
32const MBX_BINARY_ENV: &str = "MJ_MBX_BINARY";
35
36const DEFAULT_CACHE_RELATIVE: &str = ".cache/mbx";
37const HOST_CONFIG_RELATIVE: &str = ".config/mbx/config.toml";
38const DEFAULT_MAX_BYTES: u64 = 100_000_000_000;
40const RESOLUTION_LIFETIME: Duration = Duration::from_secs(600);
41const LABEL: &str = "hel-mbx";
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub(super) struct ResolvedBuildCache {
46 pub directory: PathBuf,
48 pub max_size: Option<String>,
51 pub target_root: Option<PathBuf>,
54 pub config_file: Option<String>,
57}
58
59type Resolutions = std::collections::BTreeMap<String, (Instant, Option<ResolvedBuildCache>)>;
63
64static RESOLUTIONS: std::sync::LazyLock<std::sync::Mutex<Resolutions>> =
65 std::sync::LazyLock::new(|| std::sync::Mutex::new(Resolutions::new()));
66
67pub(super) fn resolve(
70 target: &targets::TargetTemplate,
71 global: &BuildCacheConfig,
72 executor: &impl CommandExecutor,
73) -> Option<ResolvedBuildCache> {
74 if !global.enabled {
75 return None;
76 }
77 let (host, settings) = supported_host(target)?;
78 let key = format!("{}|{settings:?}", host.key());
79 if let Some((recorded, resolution)) = RESOLUTIONS.lock().expect("mbx resolutions").get(&key)
80 && recorded.elapsed() < RESOLUTION_LIFETIME
81 {
82 return resolution.clone();
83 }
84 let resolution = match resolve_host(&host, &settings, executor) {
85 Ok(resolution) => resolution,
86 Err(error) => {
87 tracing::warn!(host = key, "build cache unavailable: {error:#}");
88 None
89 }
90 };
91 RESOLUTIONS
92 .lock()
93 .expect("mbx resolutions")
94 .insert(key, (Instant::now(), resolution.clone()));
95 resolution
96}
97
98fn supported_host(target: &targets::TargetTemplate) -> Option<(CacheHost, TargetBuildCache)> {
102 let settings = match target {
103 targets::TargetTemplate::LocalPodman(container)
104 | targets::TargetTemplate::LocalDocker(container)
105 | targets::TargetTemplate::SshPodman { container, .. }
106 | targets::TargetTemplate::SshDocker { container, .. } => {
107 container.build_cache.clone().unwrap_or_default()
108 }
109 targets::TargetTemplate::AppleContainer(_)
110 | targets::TargetTemplate::LocalBare
111 | targets::TargetTemplate::AwsEc2(_)
112 | targets::TargetTemplate::SshBare { .. } => return None,
113 };
114 Some((CacheHost::for_target(target)?, settings))
115}
116
117fn resolve_host(
118 host: &CacheHost,
119 settings: &TargetBuildCache,
120 executor: &impl CommandExecutor,
121) -> Result<Option<ResolvedBuildCache>> {
122 let inspection = inspect_host(host, settings, executor)?;
123 match inspection.cache {
124 Some(cache) => {
125 create_directory(host, &cache.directory, executor)?;
126 Ok(Some(cache))
127 }
128 None => {
129 if let Some(reason) = &inspection.preview.off_reason {
130 tracing::warn!(
131 directory = inspection
132 .preview
133 .directory
134 .as_ref()
135 .map(|d| d.display().to_string()),
136 "sessions on this target run without the build cache: {reason}"
137 );
138 }
139 Ok(None)
140 }
141 }
142}
143
144pub fn preview_build_cache(
148 machine: &mj_core::config::Machine,
149 global: &BuildCacheConfig,
150 executor: &impl CommandExecutor,
151) -> Result<Option<BuildCachePreview>> {
152 let Some(host) = CacheHost::for_machine(machine) else {
153 return Ok(None);
154 };
155 let settings = machine.build_cache().cloned().unwrap_or_default();
156 if !global.enabled {
157 return Ok(Some(BuildCachePreview {
158 native_mbx: None,
159 directory: None,
160 max_size: None,
161 off_reason: Some("the build cache is turned off for every machine".into()),
162 }));
163 }
164 inspect_host(&host, &settings, executor).map(|inspection| Some(inspection.preview))
165}
166
167struct Inspection {
170 preview: BuildCachePreview,
171 cache: Option<ResolvedBuildCache>,
173}
174
175fn inspect_host(
176 host: &CacheHost,
177 settings: &TargetBuildCache,
178 executor: &impl CommandExecutor,
179) -> Result<Inspection> {
180 let native = native_version(host, executor);
181 let native_version = native.as_ref().map(|native| native.version.clone());
182 let off = |preview: BuildCachePreview| Inspection {
183 preview,
184 cache: None,
185 };
186 if let Some(version) = &native_version
187 && !version_at_least(version, MBX_VERSION)
188 {
189 return Ok(off(BuildCachePreview {
190 native_mbx: native_version.clone(),
191 directory: None,
192 max_size: None,
193 off_reason: Some(format!(
194 "the host's mbx {version} is older than the {MBX_VERSION} Mjolnir installs, \
195 so they cannot share a store"
196 )),
197 }));
198 }
199 let directory = match &settings.directory {
200 Some(directory) => directory.clone(),
201 None => match &native {
202 Some(native) => native_cache_directory(host, native, executor)?,
203 None => home(host, executor)?.join(DEFAULT_CACHE_RELATIVE),
204 },
205 };
206 ensure!(
207 directory.is_absolute(),
208 "build cache directory {} is not absolute",
209 directory.display()
210 );
211
212 let config_file = host_config_file(host, executor)?;
213 let target_root = config_file
214 .as_deref()
215 .and_then(|text| relocated_target_root(text, &directory));
216
217 let max_size = match (&settings.max_size, &config_file) {
218 (Some(max_size), _) => Some(max_size.clone()),
219 (None, Some(_)) => None,
221 (None, None) => Some(default_max_size(host, &directory, executor)?),
222 };
223 let limit = match (&max_size, &config_file) {
224 (Some(max_size), _) => BuildCacheLimit::Size(max_size.clone()),
225 (None, Some(text)) => BuildCacheLimit::HostConfiguration(configured_max_size(text)),
226 (None, None) => unreachable!("a missing budget is derived above"),
227 };
228 let preview = |off_reason: Option<String>| BuildCachePreview {
229 native_mbx: native_version.clone(),
230 directory: Some(directory.clone()),
231 max_size: Some(limit.clone()),
232 off_reason,
233 };
234
235 let volume = nearest_existing_ancestor(host, &directory, executor)?;
238 let enabled = match settings.enabled {
239 Some(enabled) => enabled,
240 None => reflinks_supported(host, &volume, executor)?,
241 };
242 if !enabled {
243 let reason = if settings.enabled == Some(false) {
244 "turned off for this target".to_owned()
245 } else {
246 format!(
247 "the filesystem under {} does not support reflinks, so restoring cached \
248 outputs would copy every byte",
249 directory.display()
250 )
251 };
252 return Ok(off(preview(Some(reason))));
253 }
254 if let Some(reason) = unusable_filesystem(host, &volume, executor)? {
255 return Ok(off(preview(Some(format!(
256 "{} is on a {reason}, where mbx's file locks are unreliable",
257 directory.display()
258 )))));
259 }
260
261 Ok(Inspection {
262 preview: preview(None),
263 cache: Some(ResolvedBuildCache {
264 directory,
265 max_size,
266 target_root,
267 config_file,
268 }),
269 })
270}
271
272fn configured_max_size(config_file: &str) -> Option<String> {
274 let document: toml::Value = toml::from_str(config_file).ok()?;
275 document
276 .get("gc")?
277 .get("max_size")?
278 .as_str()
279 .map(str::to_owned)
280}
281
282fn version_at_least(found: &str, required: &str) -> bool {
284 let parse = |text: &str| semver::Version::parse(text.trim()).ok();
285 match (parse(found), parse(required)) {
286 (Some(found), Some(required)) => found >= required,
287 _ => false,
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
294struct NativeMbx {
295 program: String,
296 version: String,
297}
298
299const NATIVE_VERSION_SCRIPT: &str = r#"for m in mbx "$HOME/.cargo/bin/mbx"; do
302 if v=$("$m" --version 2>/dev/null); then
303 printf '%s
304%s' "$m" "$v"
305 exit 0
306 fi
307done
308exit 1"#;
309
310fn native_version(host: &CacheHost, executor: &impl CommandExecutor) -> Option<NativeMbx> {
313 let command = host.shell_command(
314 NATIVE_VERSION_SCRIPT,
315 LABEL,
316 [],
317 "read the container host mbx version",
318 );
319 let output = executor.execute(&command).ok()?;
320 if output.status != 0 {
321 return None;
322 }
323 let text = String::from_utf8_lossy(&output.stdout);
324 let (program, version) = text.trim().split_once('\n')?;
325 Some(NativeMbx {
326 program: program.to_owned(),
327 version: version.split_whitespace().next_back()?.to_owned(),
328 })
329}
330
331fn native_cache_directory(
334 host: &CacheHost,
335 native: &NativeMbx,
336 executor: &impl CommandExecutor,
337) -> Result<PathBuf> {
338 let command = host.command(
339 vec![
340 native.program.clone(),
341 "cache".to_owned(),
342 "dir".to_owned(),
343 "--json".to_owned(),
344 ],
345 "read the container host mbx cache directory",
346 );
347 let output = checked(executor.execute(&command)?, &command)?;
348 let report: serde_json::Value =
349 serde_json::from_slice(&output.stdout).context("parse the mbx cache directory report")?;
350 let store = report
351 .get("store")
352 .and_then(serde_json::Value::as_str)
353 .context("the mbx cache directory report has no store path")?;
354 Path::new(store)
355 .parent()
356 .map(Path::to_path_buf)
357 .with_context(|| format!("mbx store path {store:?} has no parent"))
358}
359
360fn home(host: &CacheHost, executor: &impl CommandExecutor) -> Result<PathBuf> {
361 let command = host.shell_command(
362 r#"printf '%s' "$HOME""#,
363 LABEL,
364 [],
365 "locate the container host home directory",
366 );
367 let output = checked(executor.execute(&command)?, &command)?;
368 let home = PathBuf::from(String::from_utf8(output.stdout).context("decode host HOME")?);
369 ensure!(home.is_absolute(), "container host HOME is not absolute");
370 Ok(home)
371}
372
373const READ_CONFIG_SCRIPT: &str = r#"[ -f "$1" ] || exit 3
374cat -- "$1""#;
375
376fn host_config_file(host: &CacheHost, executor: &impl CommandExecutor) -> Result<Option<String>> {
380 let path = home(host, executor)?.join(HOST_CONFIG_RELATIVE);
381 let command = host.shell_command(
382 READ_CONFIG_SCRIPT,
383 LABEL,
384 [path.to_string_lossy().into_owned()],
385 "read the container host mbx configuration",
386 );
387 let output = executor.execute(&command)?;
388 if output.status == 3 {
389 return Ok(None);
390 }
391 let output = checked(output, &command)?;
392 Ok(Some(
393 String::from_utf8(output.stdout).context("decode the host mbx configuration")?,
394 ))
395}
396
397fn relocated_target_root(config_file: &str, directory: &Path) -> Option<PathBuf> {
400 let document: toml::Value = toml::from_str(config_file)
401 .map_err(|error| tracing::warn!("the host mbx configuration is unreadable: {error}"))
402 .ok()?;
403 let root = document.get("target")?.get("root")?.as_str()?;
404 let root = directory.join(root);
405 (!root.starts_with(directory)).then_some(root)
406}
407
408const NEAREST_ANCESTOR_SCRIPT: &str = r#"d=$1
409while [ ! -d "$d" ]; do
410 parent=$(dirname -- "$d")
411 if [ "$parent" = "$d" ]; then
412 break
413 fi
414 d=$parent
415done
416printf '%s' "$d""#;
417
418fn nearest_existing_ancestor(
421 host: &CacheHost,
422 directory: &Path,
423 executor: &impl CommandExecutor,
424) -> Result<PathBuf> {
425 let command = host.shell_command(
426 NEAREST_ANCESTOR_SCRIPT,
427 LABEL,
428 [directory.to_string_lossy().into_owned()],
429 "locate the build cache volume",
430 );
431 let output = checked(executor.execute(&command)?, &command)?;
432 let path = PathBuf::from(String::from_utf8(output.stdout).context("decode cache ancestor")?);
433 ensure!(
434 path.is_absolute(),
435 "build cache volume {} is not absolute",
436 path.display()
437 );
438 Ok(path)
439}
440
441fn default_max_size(
444 host: &CacheHost,
445 directory: &Path,
446 executor: &impl CommandExecutor,
447) -> Result<String> {
448 let volume = nearest_existing_ancestor(host, directory, executor)?;
449 let command = host.command(
450 vec![
451 "df".to_owned(),
452 "-B1".to_owned(),
453 "-P".to_owned(),
454 "--".to_owned(),
455 volume.to_string_lossy().into_owned(),
456 ],
457 "measure the build cache volume",
458 );
459 let output = checked(executor.execute(&command)?, &command)?;
460 let available = available_bytes(&String::from_utf8_lossy(&output.stdout))
461 .context("read the free space on the build cache volume")?;
462 Ok(format!("{}B", DEFAULT_MAX_BYTES.min(available / 4)))
463}
464
465fn available_bytes(report: &str) -> Option<u64> {
469 let row = report
470 .lines()
471 .filter(|line| !line.trim().is_empty())
472 .nth(1)?;
473 let fields = row.split_whitespace().collect::<Vec<_>>();
474 let available = fields.get(fields.len().checked_sub(3)?)?;
476 available.parse().ok()
477}
478
479const REFLINK_SCRIPT: &str = r#"dir=$1
480d=$(mktemp -d "$dir/.mj-reflink.XXXXXX") || exit 1
481printf x > "$d/a" && cp --reflink=always "$d/a" "$d/b"
482status=$?
483rm -rf -- "$d"
484exit $status"#;
485
486fn reflinks_supported(
490 host: &CacheHost,
491 volume: &Path,
492 executor: &impl CommandExecutor,
493) -> Result<bool> {
494 let command = host.shell_command(
495 REFLINK_SCRIPT,
496 LABEL,
497 [volume.to_string_lossy().into_owned()],
498 "probe the build cache volume for reflinks",
499 );
500 Ok(executor.execute(&command)?.status == 0)
501}
502
503fn create_directory(
504 host: &CacheHost,
505 directory: &Path,
506 executor: &impl CommandExecutor,
507) -> Result<()> {
508 let command = host.command(
509 vec![
510 "mkdir".to_owned(),
511 "-p".to_owned(),
512 "--".to_owned(),
513 directory.to_string_lossy().into_owned(),
514 ],
515 "create the build cache directory",
516 );
517 checked(executor.execute(&command)?, &command).map(|_| ())
518}
519
520fn unusable_filesystem(
523 host: &CacheHost,
524 directory: &Path,
525 executor: &impl CommandExecutor,
526) -> Result<Option<&'static str>> {
527 let filesystems =
528 targets::probe_filesystem_types(host.ssh(), &[directory.to_path_buf()], executor)?;
529 let filesystem = filesystems
530 .first()
531 .context("the filesystem probe named no filesystem")?;
532 Ok(targets::overlay_unsupported_filesystem(filesystem)
536 .filter(|reason| matches!(*reason, "network filesystem" | "FUSE filesystem")))
537}
538
539fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
540 if output.status == 0 {
541 return Ok(output);
542 }
543 bail!(
544 "{} failed with status {}: {}",
545 command.purpose,
546 output.status,
547 String::from_utf8_lossy(&output.stderr).trim()
548 )
549}
550
551pub(super) fn binary_for(
556 locator: &targets::TargetLocator,
557 executor: &impl CommandExecutor,
558) -> Result<PathBuf> {
559 let triple = super::worker_binary::target_architecture(locator, executor)?;
560 if let Some(path) = std::env::var_os(MBX_BINARY_ENV) {
561 let path = PathBuf::from(path);
562 ensure!(
563 path.is_file(),
564 "{MBX_BINARY_ENV} does not name a file: {}",
565 path.display()
566 );
567 if triple == host_architecture() {
568 return Ok(path);
569 }
570 tracing::warn!(
571 triple,
572 "{MBX_BINARY_ENV} is for this machine's architecture; downloading the pinned mbx \
573 for the target instead"
574 );
575 }
576 download(triple)
577}
578
579fn host_architecture() -> &'static str {
582 if cfg!(target_arch = "aarch64") {
583 "aarch64"
584 } else {
585 "x86_64"
586 }
587}
588
589fn release_url(triple: &str) -> String {
590 format!(
591 "https://github.com/jdx/mr-boxington/releases/download/v{MBX_VERSION}/mbx-{triple}-unknown-linux-musl.tar.gz"
592 )
593}
594
595fn expected_digest(triple: &str) -> Result<&'static str> {
596 match triple {
597 "x86_64" => Ok(MBX_X86_64_SHA256),
598 "aarch64" => Ok(MBX_AARCH64_SHA256),
599 _ => bail!("no pinned mbx release for {triple}"),
600 }
601}
602
603fn download(triple: &str) -> Result<PathBuf> {
606 let expected = expected_digest(triple)?;
607 let directory = mj_core::config::data_dir()
608 .join("mbx")
609 .join(MBX_VERSION)
610 .join(triple);
611 let destination = directory.join("mbx");
612 if destination.is_file() {
613 return Ok(destination);
614 }
615 std::fs::create_dir_all(&directory)
616 .with_context(|| format!("create the mbx cache {}", directory.display()))?;
617 let url = release_url(triple);
618 let archive = reqwest::blocking::Client::builder()
619 .timeout(Duration::from_secs(120))
620 .build()?
621 .get(&url)
622 .send()
623 .with_context(|| format!("download {url}"))?
624 .error_for_status()
625 .with_context(|| format!("download {url}"))?
626 .bytes()?;
627 let actual = mj_core::hex::lower_hex(Sha256::digest(&archive));
628 ensure!(
629 actual.eq_ignore_ascii_case(expected),
630 "downloaded mbx checksum mismatch: expected {expected}, got {actual}"
631 );
632 let binary = extract_binary(&archive)?;
633 let mut temporary = tempfile::NamedTempFile::new_in(&directory)?;
634 std::io::Write::write_all(&mut temporary, &binary)?;
635 temporary.as_file_mut().sync_all()?;
636 #[cfg(unix)]
637 {
638 use std::os::unix::fs::PermissionsExt;
639 std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o700))?;
640 }
641 match temporary.persist_noclobber(&destination) {
642 Ok(_) => Ok(destination),
643 Err(error) if destination.is_file() => {
644 drop(error);
645 Ok(destination)
646 }
647 Err(error) => Err(error.error)
648 .with_context(|| format!("publish the mbx binary {}", destination.display())),
649 }
650}
651
652fn extract_binary(archive: &[u8]) -> Result<Vec<u8>> {
655 let mut reader = tar::Archive::new(flate2::read::GzDecoder::new(archive));
656 for entry in reader.entries().context("read the mbx release archive")? {
657 let mut entry = entry.context("read the mbx release archive")?;
658 if entry.path().context("read an mbx archive path")?.as_ref() != Path::new("mbx") {
659 continue;
660 }
661 let mut bytes = Vec::new();
662 entry
663 .read_to_end(&mut bytes)
664 .context("read the mbx binary from its release archive")?;
665 return Ok(bytes);
666 }
667 bail!("the mbx release archive contains no mbx binary")
668}
669
670pub(super) fn primary_repository_is_rust(
676 host: &CacheHost,
677 mirror: &Path,
678 executor: &impl CommandExecutor,
679) -> bool {
680 let command = host.command(
681 vec![
682 "git".to_owned(),
683 "--git-dir".to_owned(),
684 mirror.to_string_lossy().into_owned(),
685 "cat-file".to_owned(),
686 "-e".to_owned(),
687 "HEAD:Cargo.toml".to_owned(),
688 ],
689 "detect a Cargo workspace in the session repository",
690 );
691 matches!(executor.execute(&command), Ok(output) if output.status == 0)
692}
693
694pub(super) fn prepare(
698 target: &targets::TargetTemplate,
699 global: &BuildCacheConfig,
700 session: &mj_core::state::SessionRecord,
701 bundle: Option<&targets::ProjectBundleSpec>,
702 clone_cache: Option<&super::git_cache::PreparedCloneCache>,
703 mounts: &mut Vec<targets::AdditionalMount>,
704 executor: &impl CommandExecutor,
705) -> Option<SessionBuildCache> {
706 let host_key = supported_host(target).map(|(host, _)| host.key());
709 if let Some(recorded) = &session.build_cache {
710 if host_key.as_deref() == Some(recorded.host.as_str()) {
711 return attach_mounts(recorded, mounts).then(|| recorded.clone());
712 }
713 tracing::info!(
714 session_id = session.id,
715 recorded_host = recorded.host,
716 host = host_key.as_deref().unwrap_or("unsupported target"),
717 "the session moved to another container host, so its build cache is resolved again"
718 );
719 }
720 session.container_workspace.as_ref()?;
723 let resolved = resolve(target, global, executor)?;
724 let host = supported_host(target)?.0;
725 let mirror = clone_cache?.mirror_for(&bundle?.primary)?;
726 if !primary_repository_is_rust(&host, mirror, executor) {
727 return None;
728 }
729 let build_cache = SessionBuildCache {
730 host: host.key(),
731 directory: resolved.directory,
732 max_size: resolved.max_size,
733 target_root: resolved.target_root,
734 };
735 attach_mounts(&build_cache, mounts).then_some(build_cache)
736}
737
738fn attach_mounts(
742 build_cache: &SessionBuildCache,
743 mounts: &mut Vec<targets::AdditionalMount>,
744) -> bool {
745 let wanted = std::iter::once(&build_cache.directory)
746 .chain(build_cache.target_root.iter())
747 .collect::<Vec<_>>();
748 for directory in &wanted {
749 if mounts.iter().any(|mount| {
750 mount.destination.starts_with(directory) || directory.starts_with(&mount.destination)
751 }) {
752 tracing::warn!(
753 directory = %directory.display(),
754 "an attached directory overlaps the build cache, so this session runs without it"
755 );
756 return false;
757 }
758 }
759 for directory in wanted {
760 mounts.push(targets::AdditionalMount {
761 source: directory.clone(),
762 destination: directory.clone(),
763 access: targets::MountAccess::Rw,
764 });
765 }
766 true
767}
768
769#[cfg(test)]
772pub(super) fn attach_mounts_for_tests(
773 build_cache: &SessionBuildCache,
774 mounts: &mut Vec<targets::AdditionalMount>,
775) -> bool {
776 attach_mounts(build_cache, mounts)
777}
778
779pub(super) fn host_configuration(
782 target: &targets::TargetTemplate,
783 global: &BuildCacheConfig,
784 executor: &impl CommandExecutor,
785) -> Option<String> {
786 resolve(target, global, executor)?.config_file
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792 use crate::targets::{ContainerTemplate, SshTarget, TargetTemplate};
793 use mj_core::config::ImagePullPolicy;
794 use std::sync::Mutex;
795
796 static ISOLATED: Mutex<()> = Mutex::new(());
799
800 fn isolated() -> std::sync::MutexGuard<'static, ()> {
801 let guard = ISOLATED.lock().unwrap_or_else(|error| error.into_inner());
802 RESOLUTIONS.lock().expect("mbx resolutions").clear();
803 guard
804 }
805
806 #[derive(Default)]
808 struct ProbeExecutor {
809 answers: Vec<(&'static str, i32, String)>,
810 seen: Mutex<Vec<String>>,
811 }
812
813 impl ProbeExecutor {
814 fn new(answers: &[(&'static str, i32, &str)]) -> Self {
815 Self {
816 answers: answers
817 .iter()
818 .map(|(needle, status, stdout)| (*needle, *status, (*stdout).to_owned()))
819 .collect(),
820 seen: Mutex::new(Vec::new()),
821 }
822 }
823
824 fn ran(&self) -> Vec<String> {
825 self.seen.lock().unwrap().clone()
826 }
827 }
828
829 impl CommandExecutor for ProbeExecutor {
830 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
831 let line = format!("{} {}", command.program, command.args.join(" "));
832 self.seen.lock().unwrap().push(line.clone());
833 for (needle, status, stdout) in &self.answers {
834 if line.contains(needle) {
835 return Ok(CommandOutput {
836 status: *status,
837 stdout: stdout.clone().into_bytes(),
838 stderr: Vec::new(),
839 });
840 }
841 }
842 Ok(CommandOutput {
843 status: 127,
844 stdout: Vec::new(),
845 stderr: format!("no canned answer for {line}").into_bytes(),
846 })
847 }
848 }
849
850 fn container(build_cache: Option<TargetBuildCache>) -> ContainerTemplate {
851 ContainerTemplate {
852 image: "example/image:latest".into(),
853 pull_policy: ImagePullPolicy::Missing,
854 extra_run_args: Vec::new(),
855 workspace_storage: Default::default(),
856 build_cache,
857 }
858 }
859
860 fn podman(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
861 TargetTemplate::LocalPodman(container(build_cache))
862 }
863
864 fn docker(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
865 TargetTemplate::LocalDocker(container(build_cache))
866 }
867
868 fn configured_local_machine() -> mj_core::config::Machine {
871 serde_json::from_value(serde_json::json!({"kind": "local"})).unwrap()
872 }
873
874 fn plain_host() -> Vec<(&'static str, i32, &'static str)> {
877 vec![
878 ("$m\" --version", 1, ""),
879 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
880 ("[ -f \"$1\" ]", 3, ""),
881 ("while [ ! -d", 0, "/home/dev"),
882 (
883 "df -B1 -P",
884 0,
885 "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 1000000000000 0 800000000000 20% /home\n",
886 ),
887 ("mj-reflink", 0, ""),
888 ("mkdir -p", 0, ""),
889 ("stat -f -c %T", 0, "xfs"),
890 ]
891 }
892
893 #[test]
894 fn a_native_mbx_supplies_the_cache_directory_and_its_own_limits() {
895 let _isolated = isolated();
896 let executor = ProbeExecutor::new(&[
897 ("$m\" --version", 0, "mbx\nmbx 1.12.0"),
898 (
899 "mbx cache dir --json",
900 0,
901 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
902 ),
903 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
904 (
905 "[ -f \"$1\" ]",
906 0,
907 "cache_dir = \"/mnt/fast/mbx-cache\"\n[gc]\nmax_size = \"500GiB\"\n",
908 ),
909 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
910 ("mj-reflink", 0, ""),
911 ("mkdir -p", 0, ""),
912 ("stat -f -c %T", 0, "xfs"),
913 ]);
914 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
915 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
916 assert_eq!(resolved.max_size, None);
918 assert_eq!(resolved.target_root, None);
919 assert!(resolved.config_file.unwrap().contains("500GiB"));
920 assert!(
921 !executor.ran().iter().any(|line| line.contains("df -B1")),
922 "a host with its own configuration is not measured"
923 );
924 }
925
926 #[test]
927 fn a_relocated_target_root_is_reported_for_its_own_mount() {
928 let _isolated = isolated();
929 let executor = ProbeExecutor::new(&[
930 ("$m\" --version", 0, "mbx\nmbx 1.12.0"),
931 (
932 "mbx cache dir --json",
933 0,
934 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
935 ),
936 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
937 (
938 "[ -f \"$1\" ]",
939 0,
940 "[target]\nroot = \"/mnt/fast/mbx-targets\"\n",
941 ),
942 ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
943 ("mj-reflink", 0, ""),
944 ("mkdir -p", 0, ""),
945 ("stat -f -c %T", 0, "xfs"),
946 ]);
947 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
948 assert_eq!(
949 resolved.target_root,
950 Some(PathBuf::from("/mnt/fast/mbx-targets"))
951 );
952 }
953
954 #[test]
955 fn a_target_root_inside_the_cache_directory_needs_no_second_mount() {
956 assert_eq!(
957 relocated_target_root("[target]\nroot = \"targets\"\n", Path::new("/cache")),
958 None
959 );
960 assert_eq!(
961 relocated_target_root("[target]\nroot = \"/cache/targets\"\n", Path::new("/cache")),
962 None
963 );
964 }
965
966 #[test]
967 fn a_cargo_installed_mbx_off_the_path_is_queried_where_it_was_found() {
968 let _isolated = isolated();
969 let mut answers = plain_host();
970 answers.retain(|(needle, _, _)| *needle != "$m\" --version");
971 answers.push(("$m\" --version", 0, "/home/dev/.cargo/bin/mbx\nmbx 1.12.0"));
972 answers.push((
973 "/home/dev/.cargo/bin/mbx cache dir --json",
974 0,
975 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
976 ));
977 let executor = ProbeExecutor::new(&answers);
978 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
979 assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
980 }
981
982 #[test]
983 fn an_older_native_mbx_must_not_share_the_store() {
984 let _isolated = isolated();
985 let executor = ProbeExecutor::new(&[("$m\" --version", 0, "mbx\nmbx 1.11.9")]);
986 assert_eq!(
987 resolve(&podman(None), &BuildCacheConfig::default(), &executor),
988 None
989 );
990 }
991
992 #[test]
993 fn a_host_without_mbx_falls_back_to_the_default_cache_directory() {
994 let _isolated = isolated();
995 let executor = ProbeExecutor::new(&plain_host());
996 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
997 assert_eq!(resolved.directory, PathBuf::from("/home/dev/.cache/mbx"));
998 assert_eq!(resolved.max_size.as_deref(), Some("100000000000B"));
1000 }
1001
1002 #[test]
1003 fn a_small_volume_takes_a_quarter_of_its_free_space() {
1004 let _isolated = isolated();
1005 let mut answers = plain_host();
1006 answers.retain(|(needle, _, _)| *needle != "df -B1 -P");
1007 answers.push((
1008 "df -B1 -P",
1009 0,
1010 "Filesystem 1B-blocks Used Available Capacity Mounted\n/dev/sda1 100000000 60000000 40000000 60% /home\n",
1011 ));
1012 let executor = ProbeExecutor::new(&answers);
1013 let resolved = resolve(&podman(None), &BuildCacheConfig::default(), &executor).unwrap();
1014 assert_eq!(resolved.max_size.as_deref(), Some("10000000B"));
1015 }
1016
1017 #[test]
1018 fn target_overrides_win_over_every_default() {
1019 let _isolated = isolated();
1020 let mut answers = plain_host();
1021 answers.push(("mbx cache dir", 0, r#"{"store":"/other/actions"}"#));
1022 let executor = ProbeExecutor::new(&answers);
1023 let resolved = resolve(
1024 &podman(Some(TargetBuildCache {
1025 enabled: Some(true),
1026 directory: Some(PathBuf::from("/mnt/nvme/mbx")),
1027 max_size: Some("250GiB".into()),
1028 })),
1029 &BuildCacheConfig::default(),
1030 &executor,
1031 )
1032 .unwrap();
1033 assert_eq!(resolved.directory, PathBuf::from("/mnt/nvme/mbx"));
1034 assert_eq!(resolved.max_size.as_deref(), Some("250GiB"));
1035 assert!(
1036 !executor
1037 .ran()
1038 .iter()
1039 .any(|line| line.contains("mj-reflink")),
1040 "an explicit enabled setting skips the reflink probe"
1041 );
1042 }
1043
1044 #[test]
1045 fn a_volume_without_reflinks_runs_without_the_cache() {
1046 let _isolated = isolated();
1047 let mut answers = plain_host();
1048 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1049 answers.push(("mj-reflink", 1, ""));
1050 let executor = ProbeExecutor::new(&answers);
1051 assert_eq!(
1052 resolve(&podman(None), &BuildCacheConfig::default(), &executor),
1053 None
1054 );
1055 }
1056
1057 #[test]
1058 fn the_preview_names_the_resolved_values_and_the_reason_the_cache_is_off() {
1059 let _isolated = isolated();
1060 let mut answers = plain_host();
1061 answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1062 answers.push(("mj-reflink", 1, ""));
1063 let executor = ProbeExecutor::new(&answers);
1064 let preview = preview_build_cache(
1065 &configured_local_machine(),
1066 &BuildCacheConfig::default(),
1067 &executor,
1068 )
1069 .unwrap()
1070 .unwrap();
1071 assert_eq!(preview.native_mbx, None);
1072 assert_eq!(
1073 preview.directory,
1074 Some(PathBuf::from("/home/dev/.cache/mbx"))
1075 );
1076 assert_eq!(
1077 preview.max_size,
1078 Some(BuildCacheLimit::Size("100000000000B".into()))
1079 );
1080 assert!(
1081 preview
1082 .off_reason
1083 .as_deref()
1084 .is_some_and(|reason| reason.contains("reflinks")),
1085 "{:?}",
1086 preview.off_reason
1087 );
1088 assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1090
1091 let executor = ProbeExecutor::new(&[
1092 ("$m\" --version", 0, "mbx\nmbx 1.12.0"),
1093 (
1094 "mbx cache dir --json",
1095 0,
1096 r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1097 ),
1098 (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1099 ("[ -f \"$1\" ]", 0, "[gc]\nmax_size = \"500GiB\"\n"),
1100 ("while [ ! -d", 0, "/mnt/fast"),
1101 ("mj-reflink", 0, ""),
1102 ("stat -f -c %T", 0, "xfs"),
1103 ]);
1104 let preview = preview_build_cache(
1105 &configured_local_machine(),
1106 &BuildCacheConfig::default(),
1107 &executor,
1108 )
1109 .unwrap()
1110 .unwrap();
1111 assert_eq!(preview.native_mbx.as_deref(), Some("1.12.0"));
1112 assert_eq!(
1113 preview.directory,
1114 Some(PathBuf::from("/mnt/fast/mbx-cache"))
1115 );
1116 assert_eq!(
1117 preview.max_size,
1118 Some(BuildCacheLimit::HostConfiguration(Some("500GiB".into())))
1119 );
1120 assert_eq!(preview.off_reason, None);
1121 assert!(!executor.ran().iter().any(|line| line.contains("mkdir")));
1122 }
1123
1124 #[test]
1125 fn a_network_filesystem_runs_without_the_cache() {
1126 let _isolated = isolated();
1127 let mut answers = plain_host();
1128 answers.retain(|(needle, _, _)| *needle != "stat -f -c %T");
1129 answers.push(("stat -f -c %T", 0, "nfs4"));
1130 let executor = ProbeExecutor::new(&answers);
1131 assert_eq!(
1132 resolve(&podman(None), &BuildCacheConfig::default(), &executor),
1133 None
1134 );
1135 }
1136
1137 #[test]
1138 fn the_global_switch_short_circuits_every_host_command() {
1139 let _isolated = isolated();
1140 let executor = ProbeExecutor::new(&plain_host());
1141 assert_eq!(
1142 resolve(
1143 &podman(None),
1144 &BuildCacheConfig { enabled: false },
1145 &executor
1146 ),
1147 None
1148 );
1149 assert!(executor.ran().is_empty());
1150 }
1151
1152 #[test]
1153 fn local_podman_and_local_docker_inspect_one_machine_once() {
1154 let _isolated = isolated();
1155 let executor = ProbeExecutor::new(&plain_host());
1156 let settings = BuildCacheConfig::default();
1157 let first = resolve(&podman(None), &settings, &executor).unwrap();
1158 let ran = executor.ran().len();
1159 assert!(ran > 0, "the first resolve inspects the host");
1160 let second = resolve(&docker(None), &settings, &executor).unwrap();
1161 assert_eq!(
1162 first, second,
1163 "both engines on this machine share one cache"
1164 );
1165 assert_eq!(
1166 executor.ran().len(),
1167 ran,
1168 "the second runtime is answered from the machine's recorded inspection: {:?}",
1169 executor.ran()
1170 );
1171 }
1172
1173 #[test]
1174 fn a_machine_without_a_standing_host_has_no_build_cache_preview() {
1175 let _isolated = isolated();
1176 let executor = ProbeExecutor::new(&plain_host());
1177 let fleet: mj_core::config::Machine = serde_json::from_value(serde_json::json!({
1178 "kind": "aws-ec2",
1179 "region": "us-east-1",
1180 "launch_template": "lt-1",
1181 "ssh_user": "ubuntu",
1182 }))
1183 .unwrap();
1184 assert_eq!(
1185 preview_build_cache(&fleet, &BuildCacheConfig::default(), &executor).unwrap(),
1186 None
1187 );
1188 assert!(executor.ran().is_empty());
1189 }
1190
1191 #[test]
1192 fn apple_and_bare_targets_have_no_shared_build_cache() {
1193 let _isolated = isolated();
1194 let executor = ProbeExecutor::new(&plain_host());
1195 for target in [
1196 TargetTemplate::AppleContainer(container(None)),
1197 TargetTemplate::LocalBare,
1198 TargetTemplate::SshBare {
1199 ssh: SshTarget {
1200 destination: "dev@example.test".into(),
1201 ssh_args: Vec::new(),
1202 },
1203 workspace_prefix: "workspaces".into(),
1204 },
1205 ] {
1206 assert_eq!(
1207 resolve(&target, &BuildCacheConfig::default(), &executor),
1208 None,
1209 "{target:?}"
1210 );
1211 }
1212 assert!(executor.ran().is_empty());
1213 }
1214
1215 fn bundle() -> targets::ProjectBundleSpec {
1216 targets::ProjectBundleSpec {
1217 primary: "main".into(),
1218 repositories: vec![targets::RepositorySpec {
1219 url: Some("https://github.com/example/main.git".into()),
1220 push_urls: Vec::new(),
1221 destination: "main".into(),
1222 git_ref: None,
1223 reference: None,
1224 }],
1225 }
1226 }
1227
1228 fn clone_cache() -> super::super::git_cache::PreparedCloneCache {
1229 super::super::git_cache::PreparedCloneCache::from_mirrors(
1230 [(
1231 "main".to_owned(),
1232 PathBuf::from("/home/dev/mirror/repo.git"),
1233 )]
1234 .into_iter()
1235 .collect(),
1236 )
1237 }
1238
1239 fn session(container_workspace: Option<&str>) -> mj_core::state::SessionRecord {
1240 let mut record = crate::controller::test_support::checkpoint_test_session("session-1");
1241 record.container_workspace = container_workspace.map(PathBuf::from);
1242 record
1243 }
1244
1245 #[test]
1246 fn a_rust_session_mounts_the_cache_at_the_host_path() {
1247 let _isolated = isolated();
1248 let mut answers = plain_host();
1249 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1250 let executor = ProbeExecutor::new(&answers);
1251 let mut mounts = Vec::new();
1252 let build_cache = prepare(
1253 &podman(None),
1254 &BuildCacheConfig::default(),
1255 &session(Some("/workspace/session-1")),
1256 Some(&bundle()),
1257 Some(&clone_cache()),
1258 &mut mounts,
1259 &executor,
1260 )
1261 .expect("a Rust session uses the build cache");
1262 assert_eq!(build_cache.directory, PathBuf::from("/home/dev/.cache/mbx"));
1263 assert_eq!(
1264 mounts,
1265 vec![targets::AdditionalMount {
1266 source: PathBuf::from("/home/dev/.cache/mbx"),
1267 destination: PathBuf::from("/home/dev/.cache/mbx"),
1268 access: targets::MountAccess::Rw,
1269 }]
1270 );
1271 }
1272
1273 #[test]
1274 fn a_repository_without_a_root_manifest_runs_without_the_cache() {
1275 let _isolated = isolated();
1276 let mut answers = plain_host();
1277 answers.push(("cat-file -e HEAD:Cargo.toml", 1, ""));
1278 let executor = ProbeExecutor::new(&answers);
1279 let mut mounts = Vec::new();
1280 assert_eq!(
1281 prepare(
1282 &podman(None),
1283 &BuildCacheConfig::default(),
1284 &session(Some("/workspace/session-1")),
1285 Some(&bundle()),
1286 Some(&clone_cache()),
1287 &mut mounts,
1288 &executor,
1289 ),
1290 None
1291 );
1292 assert!(mounts.is_empty());
1293 }
1294
1295 #[test]
1296 fn a_session_at_the_legacy_shared_workspace_runs_without_the_cache() {
1297 let _isolated = isolated();
1298 let mut answers = plain_host();
1299 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1300 let executor = ProbeExecutor::new(&answers);
1301 let mut mounts = Vec::new();
1302 assert_eq!(
1303 prepare(
1304 &podman(None),
1305 &BuildCacheConfig::default(),
1306 &session(None),
1307 Some(&bundle()),
1308 Some(&clone_cache()),
1309 &mut mounts,
1310 &executor,
1311 ),
1312 None
1313 );
1314 assert!(executor.ran().is_empty());
1315 }
1316
1317 #[test]
1318 fn a_session_without_a_prepared_clone_cache_runs_without_the_cache() {
1319 let _isolated = isolated();
1320 let mut answers = plain_host();
1321 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1322 let executor = ProbeExecutor::new(&answers);
1323 let mut mounts = Vec::new();
1324 assert_eq!(
1325 prepare(
1326 &podman(None),
1327 &BuildCacheConfig::default(),
1328 &session(Some("/workspace/session-1")),
1329 Some(&bundle()),
1330 None,
1331 &mut mounts,
1332 &executor,
1333 ),
1334 None
1335 );
1336 }
1337
1338 #[test]
1339 fn an_apple_target_never_shares_a_build_cache() {
1340 let _isolated = isolated();
1341 let mut answers = plain_host();
1342 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1343 let executor = ProbeExecutor::new(&answers);
1344 let mut mounts = Vec::new();
1345 assert_eq!(
1346 prepare(
1347 &TargetTemplate::AppleContainer(container(None)),
1348 &BuildCacheConfig::default(),
1349 &session(Some("/workspace/session-1")),
1350 Some(&bundle()),
1351 Some(&clone_cache()),
1352 &mut mounts,
1353 &executor,
1354 ),
1355 None
1356 );
1357 assert!(executor.ran().is_empty());
1358 }
1359
1360 #[test]
1361 fn a_resumed_session_reuses_its_recorded_cache_without_resolving_again() {
1362 let _isolated = isolated();
1363 let executor = ProbeExecutor::new(&[]);
1364 let mut record = session(Some("/workspace/session-1"));
1365 record.build_cache = Some(SessionBuildCache {
1366 host: "local".into(),
1367 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1368 max_size: None,
1369 target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
1370 });
1371 let mut mounts = Vec::new();
1372 let build_cache = prepare(
1373 &podman(None),
1374 &BuildCacheConfig::default(),
1375 &record,
1376 None,
1377 None,
1378 &mut mounts,
1379 &executor,
1380 )
1381 .expect("a resumed session keeps its build cache");
1382 assert_eq!(build_cache, record.build_cache.unwrap());
1383 assert_eq!(
1384 mounts
1385 .iter()
1386 .map(|mount| mount.destination.clone())
1387 .collect::<Vec<_>>(),
1388 vec![
1389 PathBuf::from("/mnt/fast/mbx-cache"),
1390 PathBuf::from("/mnt/fast/mbx-targets"),
1391 ]
1392 );
1393 assert!(executor.ran().is_empty());
1394 }
1395
1396 #[test]
1397 fn a_session_moved_to_another_host_resolves_its_build_cache_again() {
1398 let _isolated = isolated();
1399 let mut answers = plain_host();
1400 answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1401 let executor = ProbeExecutor::new(&answers);
1402 let mut record = session(Some("/workspace/session-1"));
1403 record.build_cache = Some(SessionBuildCache {
1404 host: "ssh:dev@example.test".into(),
1407 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1408 max_size: None,
1409 target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
1410 });
1411 let mut mounts = Vec::new();
1412
1413 let build_cache = prepare(
1414 &podman(None),
1415 &BuildCacheConfig::default(),
1416 &record,
1417 Some(&bundle()),
1418 Some(&clone_cache()),
1419 &mut mounts,
1420 &executor,
1421 )
1422 .expect("the destination host qualifies on its own");
1423
1424 assert_eq!(build_cache.host, "local");
1425 assert_eq!(build_cache.directory, PathBuf::from("/home/dev/.cache/mbx"));
1426 assert_eq!(build_cache.target_root, None);
1427 assert_eq!(
1428 mounts
1429 .iter()
1430 .map(|mount| mount.destination.clone())
1431 .collect::<Vec<_>>(),
1432 vec![PathBuf::from("/home/dev/.cache/mbx")]
1433 );
1434 assert!(
1435 executor
1436 .ran()
1437 .iter()
1438 .any(|line| line.contains("mj-reflink"))
1439 );
1440 }
1441
1442 #[test]
1443 fn an_attached_directory_over_the_cache_wins() {
1444 let build_cache = SessionBuildCache {
1445 host: "local-podman".into(),
1446 directory: PathBuf::from("/mnt/fast/mbx-cache"),
1447 max_size: None,
1448 target_root: None,
1449 };
1450 let mut mounts = vec![targets::AdditionalMount {
1451 source: PathBuf::from("/elsewhere"),
1452 destination: PathBuf::from("/mnt/fast/mbx-cache/actions"),
1453 access: targets::MountAccess::Ro,
1454 }];
1455 assert!(!attach_mounts(&build_cache, &mut mounts));
1456 assert_eq!(mounts.len(), 1);
1457 }
1458
1459 #[test]
1460 fn versions_compare_by_release_order() {
1461 assert!(version_at_least("1.12.0", "1.12.0"));
1462 assert!(version_at_least("1.12.1", "1.12.0"));
1463 assert!(version_at_least("2.0.0", "1.12.0"));
1464 assert!(!version_at_least("1.11.9", "1.12.0"));
1465 assert!(!version_at_least("1.9.0", "1.12.0"));
1466 assert!(!version_at_least("not-a-version", "1.12.0"));
1467 }
1468
1469 #[test]
1470 fn free_space_is_read_from_the_available_column() {
1471 assert_eq!(
1472 available_bytes(
1473 "Filesystem 1B-blocks Used Available Capacity Mounted on\n\
1474 /dev/sda1 1000 400 600 40% /\n"
1475 ),
1476 Some(600)
1477 );
1478 assert_eq!(available_bytes("Filesystem 1B-blocks\n"), None);
1479 }
1480}