Skip to main content

mj_core/targets/
storage.rs

1//! Recognising a full disk on a target, and measuring a target's free space.
2//!
3//! Mjolnir writes to targets through `scp`, `ssh`, container engines and its
4//! own worker. Each says "the disk is full" in its own words, so the one
5//! classifier lives here and every executor reports through it.
6//!
7//! Free space is kept per filesystem: a host can have a full `~/Projects`
8//! beside a healthy `/`, and a write is judged by the filesystem it lands on.
9//! Paths are target text (POSIX on every target Mjolnir writes to), so they
10//! are interpreted here, at that boundary, and nowhere else.
11
12use std::sync::OnceLock;
13
14use super::{CommandOutput, CommandSpec};
15
16/// Whether `text` reports that a write failed for lack of space.
17///
18/// Covers `No space left on device` (scp, ssh, coreutils, podman, docker and
19/// Rust's `os error 28`) and `Disk quota exceeded` (EDQUOT): a quota stops
20/// writes the same way a full filesystem does.
21pub fn reports_no_space(text: &str) -> bool {
22    let text = text.to_ascii_lowercase();
23    text.contains("no space left on device")
24        || text.contains("enospc")
25        || text.contains("os error 28)")
26        || text.contains("disk quota exceeded")
27        || text.contains("edquot")
28}
29
30/// The target path a no-space failure names, when it names one: scp's
31/// `write remote "PATH"`, coreutils' `cp: error writing 'PATH'`, or a
32/// `PATH: No space left on device` prefix.
33pub fn no_space_path(text: &str) -> Option<String> {
34    let line = text.lines().find(|line| reports_no_space(line))?;
35    let quoted = |open: &str, close: char| {
36        let start = line.find(open)? + open.len();
37        let end = line[start..].find(close)?;
38        Some(line[start..start + end].to_owned())
39    };
40    if let Some(path) = quoted("write remote \"", '"') {
41        return Some(path);
42    }
43    if let Some(path) = quoted("error writing '", '\'') {
44        return Some(path);
45    }
46    if let Some(path) = quoted("error writing \"", '"') {
47        return Some(path);
48    }
49    // `cat: /path: No space left on device`, `write /path: no space left`.
50    line.split([' ', ':'])
51        .find(|word| word.starts_with('/') && word.len() > 1)
52        .map(str::to_owned)
53}
54
55/// The host a target command ran on, as the storage owner names it: the SSH
56/// destination without its user, or [`LOCAL_STORAGE_HOST`].
57pub fn storage_host_of_destination(destination: Option<&str>) -> String {
58    match destination {
59        Some(destination) => ssh_host_name(destination).to_owned(),
60        None => LOCAL_STORAGE_HOST.to_owned(),
61    }
62}
63
64/// The host part of an OpenSSH destination such as `build@host`.
65pub fn ssh_host_name(destination: &str) -> &str {
66    destination
67        .rsplit_once('@')
68        .map_or(destination, |(_, host)| host)
69}
70
71/// The name the storage owner gives the machine the daemon runs on.
72pub const LOCAL_STORAGE_HOST: &str = "local";
73
74/// Where worker roots live under an SSH or EC2 user's home.
75pub const REMOTE_WORKERS_DIRECTORY: &str = ".local/share/hel/workers";
76/// Where staged profile homes live under an SSH or EC2 user's home.
77pub const REMOTE_PROFILES_DIRECTORY: &str = ".local/share/hel/profiles";
78/// Upload staging and binary caches under an SSH user's home.
79pub const REMOTE_CACHE_DIRECTORY: &str = ".cache/mjolnir";
80/// mbx's default cache under a user's home.
81pub const DEFAULT_BUILD_CACHE_DIRECTORY: &str = ".cache/mbx";
82/// Temporary files every target writes.
83pub const TEMPORARY_DIRECTORY: &str = "/tmp";
84
85/// Container engines whose storage Mjolnir measures.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum ContainerStorage {
88    Podman,
89    Docker,
90}
91
92impl ContainerStorage {
93    /// Where the engine keeps images and container layers, which hold every
94    /// path inside a container: rootless Podman under the user's home, Docker
95    /// under its root directory.
96    pub fn path(self) -> &'static str {
97        match self {
98            Self::Podman => ".local/share/containers",
99            Self::Docker => "/var/lib/docker",
100        }
101    }
102}
103
104/// The filesystems one session writes to: its worker root, workspace (the
105/// project directory or managed clone), staged profile home and `/tmp`; a
106/// container session writes them all inside its engine's storage.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct SessionStoragePaths {
109    pub host: String,
110    pub worker_root: String,
111    pub others: Vec<String>,
112}
113
114impl SessionStoragePaths {
115    pub fn all(&self) -> impl Iterator<Item = &str> {
116        std::iter::once(self.worker_root.as_str()).chain(self.others.iter().map(String::as_str))
117    }
118}
119
120/// [`SessionStoragePaths`] for a recorded target. `project_directory` is the
121/// session's checkout on a local bare target.
122pub fn session_storage_paths(
123    locator: &crate::state::TargetLocator,
124    session_id: &str,
125    project_directory: Option<&std::path::Path>,
126) -> SessionStoragePaths {
127    use crate::state::TargetLocator as Recorded;
128    let text = |path: &std::path::Path| path.to_string_lossy().into_owned();
129    let remote_profile = format!("{REMOTE_PROFILES_DIRECTORY}/{session_id}");
130    let (host, worker_root, others) = match locator {
131        Recorded::LocalBare { worker_root } => (
132            LOCAL_STORAGE_HOST.to_owned(),
133            text(worker_root),
134            project_directory
135                .map(text)
136                .into_iter()
137                .chain([TEMPORARY_DIRECTORY.to_owned()])
138                .collect(),
139        ),
140        Recorded::LocalPodman { .. } => (
141            LOCAL_STORAGE_HOST.to_owned(),
142            local_home_path(ContainerStorage::Podman.path()),
143            Vec::new(),
144        ),
145        Recorded::LocalDocker { .. } => (
146            LOCAL_STORAGE_HOST.to_owned(),
147            ContainerStorage::Docker.path().to_owned(),
148            Vec::new(),
149        ),
150        // Apple's container runtime keeps its storage in a VM disk image.
151        Recorded::AppleContainer { .. } => {
152            (LOCAL_STORAGE_HOST.to_owned(), String::new(), Vec::new())
153        }
154        Recorded::AwsEc2 {
155            address,
156            instance_id,
157        } => (
158            address.clone().unwrap_or_else(|| instance_id.clone()),
159            format!("{REMOTE_WORKERS_DIRECTORY}/{session_id}"),
160            vec![
161                format!(".local/share/hel/workspaces/{session_id}"),
162                remote_profile,
163                TEMPORARY_DIRECTORY.to_owned(),
164            ],
165        ),
166        Recorded::SshBare {
167            host,
168            workspace,
169            worker_id,
170        } => (
171            ssh_host_name(host).to_owned(),
172            format!(
173                "{REMOTE_WORKERS_DIRECTORY}/{}",
174                worker_id.as_deref().unwrap_or(session_id)
175            ),
176            vec![
177                text(workspace),
178                remote_profile,
179                TEMPORARY_DIRECTORY.to_owned(),
180            ],
181        ),
182        Recorded::SshPodman { host, .. } => (
183            ssh_host_name(host).to_owned(),
184            ContainerStorage::Podman.path().to_owned(),
185            Vec::new(),
186        ),
187        Recorded::SshDocker { host, .. } => (
188            ssh_host_name(host).to_owned(),
189            ContainerStorage::Docker.path().to_owned(),
190            Vec::new(),
191        ),
192    };
193    SessionStoragePaths {
194        host,
195        worker_root,
196        others,
197    }
198}
199
200/// A home-relative path made absolute against this machine's home.
201pub fn local_home_path(relative: &str) -> String {
202    match dirs::home_dir() {
203        Some(home) => home.join(relative).to_string_lossy().into_owned(),
204        None => relative.to_owned(),
205    }
206}
207
208/// A target path as the board compares it: absolute against `home`, without
209/// a trailing slash. `~/x` and `x` are home-relative, as for ssh and scp.
210pub fn normalize_target_path(path: &str, home: Option<&str>) -> String {
211    let path = path.trim();
212    let joined = if path.starts_with('/') {
213        path.to_owned()
214    } else {
215        let relative = path.strip_prefix("~/").unwrap_or(path);
216        let relative = relative.strip_prefix("./").unwrap_or(relative);
217        match home {
218            Some(home) if relative.is_empty() || relative == "~" || relative == "." => {
219                home.to_owned()
220            }
221            Some(home) => format!("{}/{relative}", home.trim_end_matches('/')),
222            None => relative.to_owned(),
223        }
224    };
225    if joined.len() > 1 {
226        joined.trim_end_matches('/').to_owned()
227    } else {
228        joined
229    }
230}
231
232/// Whether `path` is `base` or lies under it, by whole components.
233fn path_within(path: &str, base: &str) -> bool {
234    path == base
235        || base == "/"
236        || path
237            .strip_prefix(base)
238            .is_some_and(|rest| rest.starts_with('/'))
239}
240
241/// A write failed on `host` because its disk is full.
242pub type NoSpaceObserver = fn(host: &str, detail: &str);
243
244static OBSERVER: OnceLock<NoSpaceObserver> = OnceLock::new();
245
246/// Install the process's owner of target storage health. Only the daemon
247/// installs one; other processes classify nothing.
248pub fn set_no_space_observer(observer: NoSpaceObserver) {
249    let _ = OBSERVER.set(observer);
250}
251
252/// Report `detail` to the installed owner when it says the disk on `host` is
253/// full. Returns whether it did.
254pub fn report_if_no_space(host: &str, detail: &str) -> bool {
255    if !reports_no_space(detail) {
256        return false;
257    }
258    if let Some(observer) = OBSERVER.get() {
259        observer(host, detail.trim());
260    }
261    true
262}
263
264/// Called by every process executor once a command has finished.
265pub(super) fn observe_command_output(command: &CommandSpec, output: &CommandOutput) {
266    if output.status == 0 {
267        return;
268    }
269    let stderr = String::from_utf8_lossy(&output.stderr);
270    let host = storage_host_of_destination(command.ssh_destination.as_deref());
271    if report_if_no_space(&host, &stderr) {
272        tracing::warn!(
273            %host,
274            purpose = command.purpose.as_str(),
275            "target command failed because the disk is full: {}",
276            stderr.trim()
277        );
278    }
279}
280
281/// One filesystem Mjolnir writes to on a target, as `df -Pk` reports it.
282#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
283pub struct FilesystemSpace {
284    /// Where the filesystem is mounted on the target.
285    pub mount: String,
286    /// Bytes an unprivileged writer can still use (`f_bavail`).
287    pub available_bytes: u64,
288    pub total_bytes: u64,
289    /// Bytes the filesystem keeps back for root (ext4's reserve): free, but
290    /// not to Mjolnir's non-root writers.
291    #[serde(default)]
292    pub reserved_bytes: u64,
293    /// The measured paths that live on this filesystem, normalized. A write
294    /// belongs to the filesystem of the longest of these that contains it.
295    #[serde(default)]
296    pub paths: Vec<String>,
297}
298
299/// What one probe found on one host: every filesystem Mjolnir writes to there.
300#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
301pub struct HostStorageSample {
302    pub host: String,
303    /// The probing user's home, against which relative paths are read.
304    #[serde(default)]
305    pub home: Option<String>,
306    pub filesystems: Vec<FilesystemSpace>,
307}
308
309/// A write must leave at least this much free for the sessions already
310/// running on the filesystem: their journals, logs and harness writes are
311/// what fail first on a full disk. A flat amount rather than a percentage,
312/// because 2% of a 4 TB disk would refuse writes with 80 GB free.
313pub const WRITE_RESERVE_BYTES: u64 = 1 << 30;
314
315/// Below this a filesystem is shown as low on space; nothing is refused.
316pub const LOW_SPACE_BYTES: u64 = 5 << 30;
317
318#[derive(
319    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
320)]
321#[serde(rename_all = "snake_case")]
322pub enum StorageCondition {
323    Ok,
324    Low,
325    /// Below [`WRITE_RESERVE_BYTES`], or a write to it failed for lack of
326    /// space since the last measurement. Writes are refused and recovery waits.
327    Full,
328}
329
330/// One filesystem with the storage owner's verdict.
331#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
332pub struct FilesystemView {
333    #[serde(flatten)]
334    pub space: FilesystemSpace,
335    pub condition: StorageCondition,
336    /// The failed write that marked this filesystem full, until a later
337    /// measurement replaces it.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub no_space_detail: Option<String>,
340}
341
342/// The storage owner's published answer for one host.
343#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
344pub struct TargetStorageView {
345    pub host: String,
346    #[serde(default)]
347    pub home: Option<String>,
348    #[serde(default)]
349    pub filesystems: Vec<FilesystemView>,
350    #[serde(default)]
351    pub sampled_at_epoch_seconds: Option<u64>,
352    /// A write failed for lack of space without naming where. Every
353    /// filesystem on the host counts as full until the measurement it
354    /// triggered says which one is.
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub unattributed_no_space: Option<String>,
357}
358
359impl TargetStorageView {
360    /// Judge each filesystem of one host from its latest measurement and any
361    /// failure since.
362    pub fn evaluate(
363        host: &str,
364        home: Option<String>,
365        filesystems: &[FilesystemSpace],
366        sampled_at_epoch_seconds: Option<u64>,
367        no_space: impl Fn(&FilesystemSpace) -> Option<String>,
368        unattributed_no_space: Option<String>,
369    ) -> Self {
370        let filesystems = filesystems
371            .iter()
372            .map(|space| {
373                let no_space_detail = no_space(space);
374                let condition = if no_space_detail.is_some()
375                    || unattributed_no_space.is_some()
376                    || space.available_bytes < WRITE_RESERVE_BYTES
377                {
378                    StorageCondition::Full
379                } else if space.available_bytes < LOW_SPACE_BYTES {
380                    StorageCondition::Low
381                } else {
382                    StorageCondition::Ok
383                };
384                FilesystemView {
385                    space: space.clone(),
386                    condition,
387                    no_space_detail,
388                }
389            })
390            .collect();
391        Self {
392            host: host.to_owned(),
393            home,
394            filesystems,
395            sampled_at_epoch_seconds,
396            unattributed_no_space,
397        }
398    }
399
400    /// The filesystem `path` lands on: the one holding the longest measured
401    /// path that contains it.
402    pub fn filesystem_for(&self, path: &str) -> Option<&FilesystemView> {
403        let path = normalize_target_path(path, self.home.as_deref());
404        self.filesystems
405            .iter()
406            .flat_map(|filesystem| {
407                filesystem
408                    .space
409                    .paths
410                    .iter()
411                    .filter(|base| path_within(&path, base))
412                    .map(move |base| (base.len(), filesystem))
413            })
414            .max_by_key(|(length, _)| *length)
415            .map(|(_, filesystem)| filesystem)
416    }
417
418    /// The worst filesystem among those `paths` land on.
419    pub fn worst_for<'a>(
420        &self,
421        paths: impl IntoIterator<Item = &'a str>,
422    ) -> Option<&FilesystemView> {
423        paths
424            .into_iter()
425            .filter(|path| !path.is_empty())
426            .filter_map(|path| self.filesystem_for(path))
427            .max_by_key(|filesystem| filesystem.condition)
428    }
429
430    /// The worst filesystem on the host, for a one-line summary.
431    pub fn worst(&self) -> Option<&FilesystemView> {
432        self.filesystems.iter().max_by(|left, right| {
433            left.condition
434                .cmp(&right.condition)
435                .then(right.space.available_bytes.cmp(&left.space.available_bytes))
436        })
437    }
438
439    /// The full filesystem one of `paths` lands on, or every filesystem's
440    /// stand-in while a failure is still unattributed.
441    pub fn full_for<'a>(
442        &self,
443        paths: impl IntoIterator<Item = &'a str>,
444    ) -> Option<&FilesystemView> {
445        self.worst_for(paths)
446            .filter(|filesystem| filesystem.condition == StorageCondition::Full)
447    }
448
449    /// "precision-3260 has 0 B free on /home (the filesystem reserves 24.69
450    /// GB more for root)".
451    pub fn explanation(&self, filesystem: &FilesystemView) -> String {
452        let space = &filesystem.space;
453        let mut text = format!(
454            "{} has {} free on {}",
455            self.host,
456            crate::move_workspace::format_bytes(space.available_bytes),
457            space.mount
458        );
459        if space.reserved_bytes >= WRITE_RESERVE_BYTES {
460            text.push_str(&format!(
461                " (the filesystem reserves {} more for root)",
462                crate::move_workspace::format_bytes(space.reserved_bytes)
463            ));
464        }
465        if filesystem.condition == StorageCondition::Full
466            && space.available_bytes >= WRITE_RESERVE_BYTES
467            && let Some(detail) = filesystem
468                .no_space_detail
469                .as_ref()
470                .or(self.unattributed_no_space.as_ref())
471        {
472            text.push_str(&format!("; a write failed: {detail}"));
473        }
474        text
475    }
476
477    /// One line per filesystem: mount, free space, root reserve, and a flag
478    /// on the full or low ones.
479    pub fn filesystem_lines(&self) -> Vec<String> {
480        self.filesystems
481            .iter()
482            .map(|filesystem| {
483                let space = &filesystem.space;
484                let mut line = format!(
485                    "{}: {} free",
486                    space.mount,
487                    crate::move_workspace::format_bytes(space.available_bytes)
488                );
489                if space.reserved_bytes >= WRITE_RESERVE_BYTES {
490                    line.push_str(&format!(
491                        ", {} reserved for root",
492                        crate::move_workspace::format_bytes(space.reserved_bytes)
493                    ));
494                }
495                match filesystem.condition {
496                    StorageCondition::Full => line.push_str(" (full)"),
497                    StorageCondition::Low => line.push_str(" (low)"),
498                    StorageCondition::Ok => {}
499                }
500                line
501            })
502            .collect()
503    }
504
505    /// A short summary of every filesystem, such as "/: 41.0 GB · /home:
506    /// 0 B full".
507    pub fn short_status(&self) -> String {
508        if self.filesystems.is_empty() {
509            return "free space unknown".to_owned();
510        }
511        self.filesystems
512            .iter()
513            .map(|filesystem| {
514                let free = crate::move_workspace::format_bytes(filesystem.space.available_bytes);
515                match filesystem.condition {
516                    StorageCondition::Full => format!("{} {free} full", filesystem.space.mount),
517                    StorageCondition::Low => format!("{} {free} low", filesystem.space.mount),
518                    StorageCondition::Ok => format!("{} {free}", filesystem.space.mount),
519                }
520            })
521            .collect::<Vec<_>>()
522            .join(" · ")
523    }
524
525    /// "disk full: precision-3260 has 0 B free on /home …" when one of
526    /// `paths` lands on a full filesystem.
527    pub fn problem_for<'a>(&self, paths: impl IntoIterator<Item = &'a str>) -> Option<String> {
528        let paths = paths.into_iter().collect::<Vec<_>>();
529        match self.full_for(paths.iter().copied()) {
530            Some(filesystem) => Some(format!("disk full: {}", self.explanation(filesystem))),
531            // A failure no measurement has placed yet holds every path.
532            None => self
533                .unattributed_explanation()
534                .filter(|_| paths.iter().any(|path| !path.is_empty()))
535                .map(|explanation| format!("disk full: {explanation}")),
536        }
537    }
538
539    /// "host ran out of disk space: …" for a failure no measurement has
540    /// placed on a filesystem yet.
541    fn unattributed_explanation(&self) -> Option<String> {
542        self.unattributed_no_space
543            .as_ref()
544            .map(|detail| format!("{} ran out of disk space: {detail}", self.host))
545    }
546
547    /// The refusal for a write of `bytes` (zero when the size is unknown) to
548    /// `path`, or `None` when it fits beside [`WRITE_RESERVE_BYTES`] or the
549    /// path's filesystem has not been measured.
550    pub fn refuse_write(&self, path: &str, bytes: u64, what: &str) -> Option<String> {
551        let explanation = match self.filesystem_for(path) {
552            Some(filesystem) => {
553                let fits = filesystem.condition != StorageCondition::Full
554                    && filesystem.space.available_bytes
555                        >= bytes.saturating_add(WRITE_RESERVE_BYTES);
556                if fits {
557                    return None;
558                }
559                self.explanation(filesystem)
560            }
561            None => self.unattributed_explanation()?,
562        };
563        let size = if bytes > 0 {
564            format!(" ({})", crate::move_workspace::format_bytes(bytes))
565        } else {
566            String::new()
567        };
568        Some(format!(
569            "Cannot {what}{size}: {explanation}. Mjolnir keeps {} free for running sessions; free space on {} to continue.",
570            crate::move_workspace::format_bytes(WRITE_RESERVE_BYTES),
571            self.host
572        ))
573    }
574}
575
576/// The storage hosts a capacity row stands for: the host itself, or each
577/// instance a fleet's last reading measured.
578pub fn capacity_storage_hosts(
579    target: &super::DeploymentCapacityTarget,
580    usage: Option<&super::DeploymentCapacityUsage>,
581) -> Vec<String> {
582    match target.kind {
583        super::DeploymentCapacityKind::Host => vec![target.host.clone()],
584        super::DeploymentCapacityKind::AwsFleet => usage
585            .map(|usage| {
586                usage
587                    .storage
588                    .iter()
589                    .map(|sample| sample.host.clone())
590                    .collect()
591            })
592            .unwrap_or_default(),
593    }
594}
595
596/// The views a row that stands for `hosts` shows.
597pub fn views_for<'a>(
598    views: &'a [TargetStorageView],
599    hosts: &[String],
600) -> Vec<&'a TargetStorageView> {
601    views
602        .iter()
603        .filter(|view| hosts.contains(&view.host))
604        .collect()
605}
606
607/// Shell that prints the probing user's home, then measures each path given
608/// as an argument at its nearest existing ancestor:
609/// `storage=<avail-kib>\t<total-kib>\t<used-kib>\t<mount>\t<path>`. `df -Pk`
610/// is POSIX, so it reads the same on Linux and macOS; its "Available" column
611/// is what a non-root user can write.
612pub const STORAGE_PROBE_SCRIPT: &str = r#"
613printf 'home=%s\n' "$HOME"
614for hel_path in "$@"; do
615    hel_probe=$hel_path
616    while [ ! -e "$hel_probe" ] && [ "$hel_probe" != / ] && [ "$hel_probe" != . ]; do
617        hel_probe=$(dirname -- "$hel_probe")
618    done
619    df -Pk -- "$hel_probe" 2>/dev/null | awk -v hel_path="$hel_path" 'NR == 2 { mount = $6; for (i = 7; i <= NF; i++) mount = mount " " $i; printf "storage=%s\t%s\t%s\t%s\t%s\n", $4, $2, $3, mount, hel_path }'
620done
621"#;
622
623/// Parse [`STORAGE_PROBE_SCRIPT`] output into the probing user's home and one
624/// entry per filesystem, each listing the measured paths on it.
625pub fn parse_storage_lines(output: &[u8]) -> (Option<String>, Vec<FilesystemSpace>) {
626    let text = String::from_utf8_lossy(output);
627    let home = text
628        .lines()
629        .find_map(|line| line.strip_prefix("home="))
630        .map(str::trim)
631        .filter(|home| home.starts_with('/'))
632        .map(str::to_owned);
633    let mut filesystems: Vec<FilesystemSpace> = Vec::new();
634    for line in text.lines() {
635        let Some(row) = line.strip_prefix("storage=") else {
636            continue;
637        };
638        let fields = row.split('\t').collect::<Vec<_>>();
639        let [available, total, used, mount, path] = fields.as_slice() else {
640            continue;
641        };
642        let (Ok(available), Ok(total), Ok(used)) = (
643            available.parse::<u64>(),
644            total.parse::<u64>(),
645            used.parse::<u64>(),
646        ) else {
647            continue;
648        };
649        let path = normalize_target_path(path, home.as_deref());
650        match filesystems.iter_mut().find(|known| known.mount == *mount) {
651            Some(known) => {
652                if !known.paths.contains(&path) {
653                    known.paths.push(path);
654                }
655            }
656            None => filesystems.push(FilesystemSpace {
657                mount: (*mount).to_owned(),
658                available_bytes: available.saturating_mul(1024),
659                total_bytes: total.saturating_mul(1024),
660                reserved_bytes: total
661                    .saturating_sub(used)
662                    .saturating_sub(available)
663                    .saturating_mul(1024),
664                paths: vec![path],
665            }),
666        }
667    }
668    (home, filesystems)
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    // Hard-won: 540c9202: A full target filesystem stopped all workers and recovery repeatedly wrote to the same disk.
676    #[test]
677    fn recognises_a_full_disk_in_every_tool_s_words() {
678        for text in [
679            // scp to a full home directory, as on precision-3260.
680            "scp: write remote \".local/share/hel/workers/abc/hel.prepared-upgrade-stage-1.next\": No space left on device",
681            // A worker's own exit reason.
682            "relay coordinator failed: append journal: No space left on device (os error 28)",
683            // podman cp and docker cp.
684            "Error: copying to container: write /var/lib/hel/workers/x/hel: no space left on device",
685            "Error response from daemon: ENOSPC: no space left on device, write",
686            "cp: error writing '/home/u/.cache/mjolnir/uploads/x': Disk quota exceeded",
687            "write failed (os error 28)",
688        ] {
689            assert!(reports_no_space(text), "{text}");
690        }
691        for text in [
692            "Permission denied (publickey)",
693            "ssh: connect to host precision-3260 port 22: Connection timed out",
694            "No such file or directory",
695            "error 280 while writing",
696        ] {
697            assert!(!reports_no_space(text), "{text}");
698        }
699    }
700
701    // Hard-won: 540c9202: A full target filesystem made repeated recovery writes fail without identifying the affected path.
702    #[test]
703    fn a_no_space_failure_names_the_path_it_could_not_write() {
704        assert_eq!(
705            no_space_path(
706                "scp: write remote \".local/share/hel/workers/abc/hel.prepared-upgrade-stage-1.next\": No space left on device"
707            )
708            .as_deref(),
709            Some(".local/share/hel/workers/abc/hel.prepared-upgrade-stage-1.next")
710        );
711        assert_eq!(
712            no_space_path("cp: error writing '/home/u/Projects/x/y': No space left on device")
713                .as_deref(),
714            Some("/home/u/Projects/x/y")
715        );
716        assert_eq!(
717            no_space_path("Error: write /var/lib/hel/workers/x/hel: no space left on device")
718                .as_deref(),
719            Some("/var/lib/hel/workers/x/hel")
720        );
721        assert_eq!(
722            no_space_path("Error response from daemon: ENOSPC: no space left on device, write"),
723            None
724        );
725    }
726
727    /// precision-3260: `~/Projects` is its own filesystem beside `/`. Paths
728    /// on one filesystem make one record, and a write is judged by the
729    /// filesystem it lands on.
730    // Hard-won: 540c9202: Root and Projects were separate filesystems, but workers stopped when only one filled.
731    #[test]
732    fn storage_lines_keep_one_record_per_filesystem_and_place_each_path() {
733        let output = b"home=/home/jonathan\n\
734storage=0\t491134172\t467026656\t/\t.local/share/hel/workers\n\
735storage=0\t491134172\t467026656\t/\t/tmp\n\
736storage=41943040\t976762584\t900000000\t/home/jonathan/Projects\t~/Projects\n\
737garbage\n";
738        let (home, filesystems) = parse_storage_lines(output);
739        assert_eq!(home.as_deref(), Some("/home/jonathan"));
740        assert_eq!(filesystems.len(), 2);
741        assert_eq!(filesystems[0].mount, "/");
742        assert_eq!(
743            filesystems[0].paths,
744            ["/home/jonathan/.local/share/hel/workers", "/tmp"]
745        );
746        assert_eq!(
747            filesystems[0].reserved_bytes,
748            (491134172 - 467026656) * 1024
749        );
750        let view = TargetStorageView::evaluate(
751            "precision-3260",
752            home,
753            &filesystems,
754            Some(1),
755            |_| None,
756            None,
757        );
758        let worker = ".local/share/hel/workers/abc/hel.next";
759        let clone = "/home/jonathan/Projects/app/.mj/clones/abc";
760        assert_eq!(view.filesystem_for(worker).unwrap().space.mount, "/");
761        assert_eq!(
762            view.filesystem_for(clone).unwrap().space.mount,
763            "/home/jonathan/Projects"
764        );
765        assert!(view.refuse_write(worker, 100 << 20, "stage").is_some());
766        assert!(view.refuse_write(clone, 100 << 20, "restore").is_none());
767        // A path under nothing measured is not judged.
768        assert!(view.filesystem_for("/srv/elsewhere").is_none());
769        assert!(view.refuse_write("/srv/elsewhere", 1, "write").is_none());
770        let problem = view.problem_for([clone, worker]).unwrap();
771        assert!(
772            problem.starts_with(
773                "disk full: precision-3260 has 0 B free on / (the filesystem reserves 24.69 GB more for root)"
774            ),
775            "{problem}"
776        );
777        assert_eq!(
778            view.filesystem_lines(),
779            [
780                "/: 0 B free, 24.69 GB reserved for root (full)",
781                "/home/jonathan/Projects: 42.95 GB free, 35.66 GB reserved for root"
782            ]
783        );
784    }
785
786    #[test]
787    fn an_unattributed_failure_counts_every_filesystem_full_until_measured() {
788        let (home, filesystems) =
789            parse_storage_lines(b"home=/h\nstorage=41943040\t99999999\t1\t/\t/tmp\n");
790        let view = TargetStorageView::evaluate(
791            "host",
792            home,
793            &filesystems,
794            Some(1),
795            |_| None,
796            Some("write failed: No space left on device".into()),
797        );
798        assert!(view.problem_for(["/tmp/x"]).is_some());
799    }
800
801    #[cfg(unix)]
802    #[test]
803    fn storage_probe_script_measures_missing_paths_at_an_existing_ancestor() {
804        let directory = tempfile::tempdir().unwrap();
805        let missing = directory.path().join("not/yet/created");
806        let output = std::process::Command::new("sh")
807            .arg("-c")
808            .arg(STORAGE_PROBE_SCRIPT)
809            .arg("mj-storage")
810            .arg(&missing)
811            .arg(directory.path())
812            .output()
813            .unwrap();
814        assert!(output.status.success());
815        let (home, filesystems) = parse_storage_lines(&output.stdout);
816        assert!(home.is_some());
817        assert_eq!(filesystems.len(), 1, "{output:?}");
818        assert!(filesystems[0].total_bytes > 0);
819        assert_eq!(filesystems[0].paths.len(), 2);
820    }
821}