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        place_measured_path(
650            &mut filesystems,
651            FilesystemSpace {
652                mount: (*mount).to_owned(),
653                available_bytes: available.saturating_mul(1024),
654                total_bytes: total.saturating_mul(1024),
655                reserved_bytes: total
656                    .saturating_sub(used)
657                    .saturating_sub(available)
658                    .saturating_mul(1024),
659                paths: vec![normalize_target_path(path, home.as_deref())],
660            },
661        );
662    }
663    (home, filesystems)
664}
665
666/// Add one measurement, a filesystem holding one path, keeping one entry per
667/// filesystem and the first reading of each.
668fn place_measured_path(filesystems: &mut Vec<FilesystemSpace>, measured: FilesystemSpace) {
669    match filesystems
670        .iter_mut()
671        .find(|known| known.mount == measured.mount)
672    {
673        Some(known) => {
674            for path in measured.paths {
675                if !known.paths.contains(&path) {
676                    known.paths.push(path);
677                }
678            }
679        }
680        None => filesystems.push(measured),
681    }
682}
683
684/// What [`STORAGE_PROBE_SCRIPT`] reports, measured on Windows through the
685/// volume APIs, since Windows has no POSIX `df`. Each path is measured at its
686/// nearest existing ancestor, on the volume it is mounted from. A path that
687/// is not absolute here, such as a Linux container engine's
688/// `/var/lib/docker`, lives inside a VM that Windows cannot measure, and is
689/// left out like a path `df` cannot read.
690#[cfg(windows)]
691pub fn measure_windows_filesystems(paths: &[String]) -> Vec<FilesystemSpace> {
692    use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _};
693    use windows_sys::Win32::Storage::FileSystem::{GetDiskFreeSpaceExW, GetVolumePathNameW};
694
695    let wide = |text: &std::ffi::OsStr| text.encode_wide().chain([0]).collect::<Vec<u16>>();
696    let measure = |path: &std::path::Path| -> Option<FilesystemSpace> {
697        let existing = path.ancestors().find(|ancestor| ancestor.exists())?;
698        let mut volume = vec![0u16; 1024];
699        // SAFETY: the name is NUL-terminated and the buffer length is passed.
700        let found = unsafe {
701            GetVolumePathNameW(
702                wide(existing.as_os_str()).as_ptr(),
703                volume.as_mut_ptr(),
704                volume.len() as u32,
705            )
706        };
707        if found == 0 {
708            return None;
709        }
710        volume.truncate(volume.iter().position(|&unit| unit == 0)?);
711        let (mut available, mut total, mut free) = (0u64, 0u64, 0u64);
712        volume.push(0);
713        // SAFETY: the volume name is NUL-terminated and each out-pointer is a
714        // live u64.
715        let measured =
716            unsafe { GetDiskFreeSpaceExW(volume.as_ptr(), &mut available, &mut total, &mut free) };
717        volume.pop();
718        (measured != 0).then(|| FilesystemSpace {
719            mount: std::ffi::OsString::from_wide(&volume)
720                .to_string_lossy()
721                .into_owned(),
722            available_bytes: available,
723            total_bytes: total,
724            // Free space beyond the caller's quota, which it cannot write.
725            reserved_bytes: free.saturating_sub(available),
726            paths: vec![path.to_string_lossy().into_owned()],
727        })
728    };
729    let mut filesystems = Vec::new();
730    for path in paths.iter().map(std::path::Path::new) {
731        if let Some(measured) = path.is_absolute().then(|| measure(path)).flatten() {
732            place_measured_path(&mut filesystems, measured);
733        }
734    }
735    filesystems
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741
742    // Hard-won: 540c9202: A full target filesystem stopped all workers and recovery repeatedly wrote to the same disk.
743    #[test]
744    fn recognises_a_full_disk_in_every_tool_s_words() {
745        for text in [
746            // scp to a full home directory, as on precision-3260.
747            "scp: write remote \".local/share/hel/workers/abc/hel.prepared-upgrade-stage-1.next\": No space left on device",
748            // A worker's own exit reason.
749            "relay coordinator failed: append journal: No space left on device (os error 28)",
750            // podman cp and docker cp.
751            "Error: copying to container: write /var/lib/hel/workers/x/hel: no space left on device",
752            "Error response from daemon: ENOSPC: no space left on device, write",
753            "cp: error writing '/home/u/.cache/mjolnir/uploads/x': Disk quota exceeded",
754            "write failed (os error 28)",
755        ] {
756            assert!(reports_no_space(text), "{text}");
757        }
758        for text in [
759            "Permission denied (publickey)",
760            "ssh: connect to host precision-3260 port 22: Connection timed out",
761            "No such file or directory",
762            "error 280 while writing",
763        ] {
764            assert!(!reports_no_space(text), "{text}");
765        }
766    }
767
768    // Hard-won: 540c9202: A full target filesystem made repeated recovery writes fail without identifying the affected path.
769    #[test]
770    fn a_no_space_failure_names_the_path_it_could_not_write() {
771        assert_eq!(
772            no_space_path(
773                "scp: write remote \".local/share/hel/workers/abc/hel.prepared-upgrade-stage-1.next\": No space left on device"
774            )
775            .as_deref(),
776            Some(".local/share/hel/workers/abc/hel.prepared-upgrade-stage-1.next")
777        );
778        assert_eq!(
779            no_space_path("cp: error writing '/home/u/Projects/x/y': No space left on device")
780                .as_deref(),
781            Some("/home/u/Projects/x/y")
782        );
783        assert_eq!(
784            no_space_path("Error: write /var/lib/hel/workers/x/hel: no space left on device")
785                .as_deref(),
786            Some("/var/lib/hel/workers/x/hel")
787        );
788        assert_eq!(
789            no_space_path("Error response from daemon: ENOSPC: no space left on device, write"),
790            None
791        );
792    }
793
794    /// precision-3260: `~/Projects` is its own filesystem beside `/`. Paths
795    /// on one filesystem make one record, and a write is judged by the
796    /// filesystem it lands on.
797    // Hard-won: 540c9202: Root and Projects were separate filesystems, but workers stopped when only one filled.
798    #[test]
799    fn storage_lines_keep_one_record_per_filesystem_and_place_each_path() {
800        let output = b"home=/home/jonathan\n\
801storage=0\t491134172\t467026656\t/\t.local/share/hel/workers\n\
802storage=0\t491134172\t467026656\t/\t/tmp\n\
803storage=41943040\t976762584\t900000000\t/home/jonathan/Projects\t~/Projects\n\
804garbage\n";
805        let (home, filesystems) = parse_storage_lines(output);
806        assert_eq!(home.as_deref(), Some("/home/jonathan"));
807        assert_eq!(filesystems.len(), 2);
808        assert_eq!(filesystems[0].mount, "/");
809        assert_eq!(
810            filesystems[0].paths,
811            ["/home/jonathan/.local/share/hel/workers", "/tmp"]
812        );
813        assert_eq!(
814            filesystems[0].reserved_bytes,
815            (491134172 - 467026656) * 1024
816        );
817        let view = TargetStorageView::evaluate(
818            "precision-3260",
819            home,
820            &filesystems,
821            Some(1),
822            |_| None,
823            None,
824        );
825        let worker = ".local/share/hel/workers/abc/hel.next";
826        let clone = "/home/jonathan/Projects/app/.mj/clones/abc";
827        assert_eq!(view.filesystem_for(worker).unwrap().space.mount, "/");
828        assert_eq!(
829            view.filesystem_for(clone).unwrap().space.mount,
830            "/home/jonathan/Projects"
831        );
832        assert!(view.refuse_write(worker, 100 << 20, "stage").is_some());
833        assert!(view.refuse_write(clone, 100 << 20, "restore").is_none());
834        // A path under nothing measured is not judged.
835        assert!(view.filesystem_for("/srv/elsewhere").is_none());
836        assert!(view.refuse_write("/srv/elsewhere", 1, "write").is_none());
837        let problem = view.problem_for([clone, worker]).unwrap();
838        assert!(
839            problem.starts_with(
840                "disk full: precision-3260 has 0 B free on / (the filesystem reserves 24.69 GB more for root)"
841            ),
842            "{problem}"
843        );
844        assert_eq!(
845            view.filesystem_lines(),
846            [
847                "/: 0 B free, 24.69 GB reserved for root (full)",
848                "/home/jonathan/Projects: 42.95 GB free, 35.66 GB reserved for root"
849            ]
850        );
851    }
852
853    #[test]
854    fn an_unattributed_failure_counts_every_filesystem_full_until_measured() {
855        let (home, filesystems) =
856            parse_storage_lines(b"home=/h\nstorage=41943040\t99999999\t1\t/\t/tmp\n");
857        let view = TargetStorageView::evaluate(
858            "host",
859            home,
860            &filesystems,
861            Some(1),
862            |_| None,
863            Some("write failed: No space left on device".into()),
864        );
865        assert!(view.problem_for(["/tmp/x"]).is_some());
866    }
867
868    #[cfg(unix)]
869    #[test]
870    fn storage_probe_script_measures_missing_paths_at_an_existing_ancestor() {
871        let directory = tempfile::tempdir().unwrap();
872        let missing = directory.path().join("not/yet/created");
873        let output = std::process::Command::new("sh")
874            .arg("-c")
875            .arg(STORAGE_PROBE_SCRIPT)
876            .arg("mj-storage")
877            .arg(&missing)
878            .arg(directory.path())
879            .output()
880            .unwrap();
881        assert!(output.status.success());
882        let (home, filesystems) = parse_storage_lines(&output.stdout);
883        assert!(home.is_some());
884        assert_eq!(filesystems.len(), 1, "{output:?}");
885        assert!(filesystems[0].total_bytes > 0);
886        assert_eq!(filesystems[0].paths.len(), 2);
887    }
888
889    #[cfg(windows)]
890    #[test]
891    fn windows_storage_measures_missing_paths_at_an_existing_ancestor() {
892        let directory = tempfile::tempdir().unwrap();
893        let missing = directory.path().join("not/yet/created");
894        let paths = [
895            missing.to_string_lossy().into_owned(),
896            directory.path().to_string_lossy().into_owned(),
897            // Inside Docker Desktop's VM, which Windows cannot measure.
898            "/var/lib/docker".to_owned(),
899        ];
900        let filesystems = measure_windows_filesystems(&paths);
901        assert_eq!(filesystems.len(), 1, "{filesystems:?}");
902        assert!(
903            missing.starts_with(&filesystems[0].mount),
904            "{filesystems:?}"
905        );
906        assert!(filesystems[0].available_bytes <= filesystems[0].total_bytes);
907        assert_eq!(filesystems[0].paths, paths[..2]);
908    }
909}