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. The ancestor walk ends whenever a step
612/// fails or makes no progress, so a host without `dirname` cannot spin it.
613pub const STORAGE_PROBE_SCRIPT: &str = r#"
614printf 'home=%s\n' "$HOME"
615for hel_path in "$@"; do
616    hel_probe=$hel_path
617    while [ ! -e "$hel_probe" ]; do
618        hel_parent=$(dirname -- "$hel_probe") || break
619        [ "$hel_parent" != "$hel_probe" ] || break
620        hel_probe=$hel_parent
621    done
622    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 }'
623done
624"#;
625
626/// Parse [`STORAGE_PROBE_SCRIPT`] output into the probing user's home and one
627/// entry per filesystem, each listing the measured paths on it.
628pub fn parse_storage_lines(output: &[u8]) -> (Option<String>, Vec<FilesystemSpace>) {
629    let text = String::from_utf8_lossy(output);
630    let home = text
631        .lines()
632        .find_map(|line| line.strip_prefix("home="))
633        .map(str::trim)
634        .filter(|home| home.starts_with('/'))
635        .map(str::to_owned);
636    let mut filesystems: Vec<FilesystemSpace> = Vec::new();
637    for line in text.lines() {
638        let Some(row) = line.strip_prefix("storage=") else {
639            continue;
640        };
641        let fields = row.split('\t').collect::<Vec<_>>();
642        let [available, total, used, mount, path] = fields.as_slice() else {
643            continue;
644        };
645        let (Ok(available), Ok(total), Ok(used)) = (
646            available.parse::<u64>(),
647            total.parse::<u64>(),
648            used.parse::<u64>(),
649        ) else {
650            continue;
651        };
652        place_measured_path(
653            &mut filesystems,
654            FilesystemSpace {
655                mount: (*mount).to_owned(),
656                available_bytes: available.saturating_mul(1024),
657                total_bytes: total.saturating_mul(1024),
658                reserved_bytes: total
659                    .saturating_sub(used)
660                    .saturating_sub(available)
661                    .saturating_mul(1024),
662                paths: vec![normalize_target_path(path, home.as_deref())],
663            },
664        );
665    }
666    (home, filesystems)
667}
668
669/// Add one measurement, a filesystem holding one path, keeping one entry per
670/// filesystem and the first reading of each.
671fn place_measured_path(filesystems: &mut Vec<FilesystemSpace>, measured: FilesystemSpace) {
672    match filesystems
673        .iter_mut()
674        .find(|known| known.mount == measured.mount)
675    {
676        Some(known) => {
677            for path in measured.paths {
678                if !known.paths.contains(&path) {
679                    known.paths.push(path);
680                }
681            }
682        }
683        None => filesystems.push(measured),
684    }
685}
686
687/// What [`STORAGE_PROBE_SCRIPT`] reports, measured on Windows through the
688/// volume APIs, since Windows has no POSIX `df`. Each path is measured at its
689/// nearest existing ancestor, on the volume it is mounted from. A path that
690/// is not absolute here, such as a Linux container engine's
691/// `/var/lib/docker`, lives inside a VM that Windows cannot measure, and is
692/// left out like a path `df` cannot read.
693#[cfg(windows)]
694pub fn measure_windows_filesystems(paths: &[String]) -> Vec<FilesystemSpace> {
695    use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _};
696    use windows_sys::Win32::Storage::FileSystem::{GetDiskFreeSpaceExW, GetVolumePathNameW};
697
698    let wide = |text: &std::ffi::OsStr| text.encode_wide().chain([0]).collect::<Vec<u16>>();
699    let measure = |path: &std::path::Path| -> Option<FilesystemSpace> {
700        let existing = path.ancestors().find(|ancestor| ancestor.exists())?;
701        let mut volume = vec![0u16; 1024];
702        // SAFETY: the name is NUL-terminated and the buffer length is passed.
703        let found = unsafe {
704            GetVolumePathNameW(
705                wide(existing.as_os_str()).as_ptr(),
706                volume.as_mut_ptr(),
707                volume.len() as u32,
708            )
709        };
710        if found == 0 {
711            return None;
712        }
713        volume.truncate(volume.iter().position(|&unit| unit == 0)?);
714        let (mut available, mut total, mut free) = (0u64, 0u64, 0u64);
715        volume.push(0);
716        // SAFETY: the volume name is NUL-terminated and each out-pointer is a
717        // live u64.
718        let measured =
719            unsafe { GetDiskFreeSpaceExW(volume.as_ptr(), &mut available, &mut total, &mut free) };
720        volume.pop();
721        (measured != 0).then(|| FilesystemSpace {
722            mount: std::ffi::OsString::from_wide(&volume)
723                .to_string_lossy()
724                .into_owned(),
725            available_bytes: available,
726            total_bytes: total,
727            // Free space beyond the caller's quota, which it cannot write.
728            reserved_bytes: free.saturating_sub(available),
729            paths: vec![path.to_string_lossy().into_owned()],
730        })
731    };
732    let mut filesystems = Vec::new();
733    for path in paths.iter().map(std::path::Path::new) {
734        if let Some(measured) = path.is_absolute().then(|| measure(path)).flatten() {
735            place_measured_path(&mut filesystems, measured);
736        }
737    }
738    filesystems
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744
745    // Hard-won: 540c9202: A full target filesystem stopped all workers and recovery repeatedly wrote to the same disk.
746    #[test]
747    fn recognises_a_full_disk_in_every_tool_s_words() {
748        for text in [
749            // scp to a full home directory, as on precision-3260.
750            "scp: write remote \".local/share/hel/workers/abc/hel.prepared-upgrade-stage-1.next\": No space left on device",
751            // A worker's own exit reason.
752            "relay coordinator failed: append journal: No space left on device (os error 28)",
753            // podman cp and docker cp.
754            "Error: copying to container: write /var/lib/hel/workers/x/hel: no space left on device",
755            "Error response from daemon: ENOSPC: no space left on device, write",
756            "cp: error writing '/home/u/.cache/mjolnir/uploads/x': Disk quota exceeded",
757            "write failed (os error 28)",
758        ] {
759            assert!(reports_no_space(text), "{text}");
760        }
761        for text in [
762            "Permission denied (publickey)",
763            "ssh: connect to host precision-3260 port 22: Connection timed out",
764            "No such file or directory",
765            "error 280 while writing",
766        ] {
767            assert!(!reports_no_space(text), "{text}");
768        }
769    }
770
771    // Hard-won: 540c9202: A full target filesystem made repeated recovery writes fail without identifying the affected path.
772    #[test]
773    fn a_no_space_failure_names_the_path_it_could_not_write() {
774        assert_eq!(
775            no_space_path(
776                "scp: write remote \".local/share/hel/workers/abc/hel.prepared-upgrade-stage-1.next\": No space left on device"
777            )
778            .as_deref(),
779            Some(".local/share/hel/workers/abc/hel.prepared-upgrade-stage-1.next")
780        );
781        assert_eq!(
782            no_space_path("cp: error writing '/home/u/Projects/x/y': No space left on device")
783                .as_deref(),
784            Some("/home/u/Projects/x/y")
785        );
786        assert_eq!(
787            no_space_path("Error: write /var/lib/hel/workers/x/hel: no space left on device")
788                .as_deref(),
789            Some("/var/lib/hel/workers/x/hel")
790        );
791        assert_eq!(
792            no_space_path("Error response from daemon: ENOSPC: no space left on device, write"),
793            None
794        );
795    }
796
797    /// precision-3260: `~/Projects` is its own filesystem beside `/`. Paths
798    /// on one filesystem make one record, and a write is judged by the
799    /// filesystem it lands on.
800    // Hard-won: 540c9202: Root and Projects were separate filesystems, but workers stopped when only one filled.
801    #[test]
802    fn storage_lines_keep_one_record_per_filesystem_and_place_each_path() {
803        let output = b"home=/home/jonathan\n\
804storage=0\t491134172\t467026656\t/\t.local/share/hel/workers\n\
805storage=0\t491134172\t467026656\t/\t/tmp\n\
806storage=41943040\t976762584\t900000000\t/home/jonathan/Projects\t~/Projects\n\
807garbage\n";
808        let (home, filesystems) = parse_storage_lines(output);
809        assert_eq!(home.as_deref(), Some("/home/jonathan"));
810        assert_eq!(filesystems.len(), 2);
811        assert_eq!(filesystems[0].mount, "/");
812        assert_eq!(
813            filesystems[0].paths,
814            ["/home/jonathan/.local/share/hel/workers", "/tmp"]
815        );
816        assert_eq!(
817            filesystems[0].reserved_bytes,
818            (491134172 - 467026656) * 1024
819        );
820        let view = TargetStorageView::evaluate(
821            "precision-3260",
822            home,
823            &filesystems,
824            Some(1),
825            |_| None,
826            None,
827        );
828        let worker = ".local/share/hel/workers/abc/hel.next";
829        let clone = "/home/jonathan/Projects/app/.mj/clones/abc";
830        assert_eq!(view.filesystem_for(worker).unwrap().space.mount, "/");
831        assert_eq!(
832            view.filesystem_for(clone).unwrap().space.mount,
833            "/home/jonathan/Projects"
834        );
835        assert!(view.refuse_write(worker, 100 << 20, "stage").is_some());
836        assert!(view.refuse_write(clone, 100 << 20, "restore").is_none());
837        // A path under nothing measured is not judged.
838        assert!(view.filesystem_for("/srv/elsewhere").is_none());
839        assert!(view.refuse_write("/srv/elsewhere", 1, "write").is_none());
840        let problem = view.problem_for([clone, worker]).unwrap();
841        assert!(
842            problem.starts_with(
843                "disk full: precision-3260 has 0 B free on / (the filesystem reserves 24.69 GB more for root)"
844            ),
845            "{problem}"
846        );
847        assert_eq!(
848            view.filesystem_lines(),
849            [
850                "/: 0 B free, 24.69 GB reserved for root (full)",
851                "/home/jonathan/Projects: 42.95 GB free, 35.66 GB reserved for root"
852            ]
853        );
854    }
855
856    #[test]
857    fn an_unattributed_failure_counts_every_filesystem_full_until_measured() {
858        let (home, filesystems) =
859            parse_storage_lines(b"home=/h\nstorage=41943040\t99999999\t1\t/\t/tmp\n");
860        let view = TargetStorageView::evaluate(
861            "host",
862            home,
863            &filesystems,
864            Some(1),
865            |_| None,
866            Some("write failed: No space left on device".into()),
867        );
868        assert!(view.problem_for(["/tmp/x"]).is_some());
869    }
870
871    #[cfg(unix)]
872    #[test]
873    fn storage_probe_script_measures_missing_paths_at_an_existing_ancestor() {
874        let directory = tempfile::tempdir().unwrap();
875        let missing = directory.path().join("not/yet/created");
876        let output = std::process::Command::new("sh")
877            .arg("-c")
878            .arg(STORAGE_PROBE_SCRIPT)
879            .arg("mj-storage")
880            .arg(&missing)
881            .arg(directory.path())
882            .output()
883            .unwrap();
884        assert!(output.status.success());
885        let (home, filesystems) = parse_storage_lines(&output.stdout);
886        assert!(home.is_some());
887        assert_eq!(filesystems.len(), 1, "{output:?}");
888        assert!(filesystems[0].total_bytes > 0);
889        assert_eq!(filesystems[0].paths.len(), 2);
890    }
891
892    // Hard-won: a6ecbe614: without `dirname` on PATH, the probe of a missing path spun forever and outlived its daemon.
893    #[cfg(unix)]
894    #[test]
895    fn storage_probe_script_ends_when_the_host_lacks_dirname() {
896        let directory = tempfile::tempdir().unwrap();
897        let shell = ["/bin/sh", "/usr/bin/sh"]
898            .into_iter()
899            .map(std::path::Path::new)
900            .find(|path| path.is_file())
901            .expect("find a POSIX sh");
902        let tools = directory.path().join("tools");
903        std::fs::create_dir(&tools).unwrap();
904        std::os::unix::fs::symlink(shell, tools.join("sh")).unwrap();
905        let mut child = std::process::Command::new(shell)
906            .arg("-c")
907            .arg(STORAGE_PROBE_SCRIPT)
908            .arg("mj-storage")
909            .arg(directory.path().join("not/yet/created"))
910            .env("PATH", &tools)
911            .stdin(std::process::Stdio::null())
912            .stdout(std::process::Stdio::null())
913            .stderr(std::process::Stdio::null())
914            .spawn()
915            .unwrap();
916        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
917        while child.try_wait().unwrap().is_none() {
918            if std::time::Instant::now() >= deadline {
919                child.kill().unwrap();
920                child.wait().unwrap();
921                panic!("the storage probe kept walking a path it could not shorten");
922            }
923            std::thread::sleep(std::time::Duration::from_millis(20));
924        }
925    }
926
927    #[cfg(windows)]
928    #[test]
929    fn windows_storage_measures_missing_paths_at_an_existing_ancestor() {
930        let directory = tempfile::tempdir().unwrap();
931        let missing = directory.path().join("not/yet/created");
932        let paths = [
933            missing.to_string_lossy().into_owned(),
934            directory.path().to_string_lossy().into_owned(),
935            // Inside Docker Desktop's VM, which Windows cannot measure.
936            "/var/lib/docker".to_owned(),
937        ];
938        let filesystems = measure_windows_filesystems(&paths);
939        assert_eq!(filesystems.len(), 1, "{filesystems:?}");
940        assert!(
941            missing.starts_with(&filesystems[0].mount),
942            "{filesystems:?}"
943        );
944        assert!(filesystems[0].available_bytes <= filesystems[0].total_bytes);
945        assert_eq!(filesystems[0].paths, paths[..2]);
946    }
947}