1use std::sync::OnceLock;
13
14use super::{CommandOutput, CommandSpec};
15
16pub 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
30pub 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 line.split([' ', ':'])
51 .find(|word| word.starts_with('/') && word.len() > 1)
52 .map(str::to_owned)
53}
54
55pub 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
64pub fn ssh_host_name(destination: &str) -> &str {
66 destination
67 .rsplit_once('@')
68 .map_or(destination, |(_, host)| host)
69}
70
71pub const LOCAL_STORAGE_HOST: &str = "local";
73
74pub const REMOTE_WORKERS_DIRECTORY: &str = ".local/share/hel/workers";
76pub const REMOTE_PROFILES_DIRECTORY: &str = ".local/share/hel/profiles";
78pub const REMOTE_CACHE_DIRECTORY: &str = ".cache/mjolnir";
80pub const DEFAULT_BUILD_CACHE_DIRECTORY: &str = ".cache/mbx";
82pub const TEMPORARY_DIRECTORY: &str = "/tmp";
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum ContainerStorage {
88 Podman,
89 Docker,
90}
91
92impl ContainerStorage {
93 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#[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
120pub 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 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
200pub 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
208pub 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
232fn 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
241pub type NoSpaceObserver = fn(host: &str, detail: &str);
243
244static OBSERVER: OnceLock<NoSpaceObserver> = OnceLock::new();
245
246pub fn set_no_space_observer(observer: NoSpaceObserver) {
249 let _ = OBSERVER.set(observer);
250}
251
252pub 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
264pub(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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
283pub struct FilesystemSpace {
284 pub mount: String,
286 pub available_bytes: u64,
288 pub total_bytes: u64,
289 #[serde(default)]
292 pub reserved_bytes: u64,
293 #[serde(default)]
296 pub paths: Vec<String>,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
301pub struct HostStorageSample {
302 pub host: String,
303 #[serde(default)]
305 pub home: Option<String>,
306 pub filesystems: Vec<FilesystemSpace>,
307}
308
309pub const WRITE_RESERVE_BYTES: u64 = 1 << 30;
314
315pub 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 Full,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
332pub struct FilesystemView {
333 #[serde(flatten)]
334 pub space: FilesystemSpace,
335 pub condition: StorageCondition,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub no_space_detail: Option<String>,
340}
341
342#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub unattributed_no_space: Option<String>,
357}
358
359impl TargetStorageView {
360 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 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 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 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 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 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 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 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 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 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 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 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
576pub 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
596pub 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
607pub 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
623pub 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 #[test]
677 fn recognises_a_full_disk_in_every_tool_s_words() {
678 for text in [
679 "scp: write remote \".local/share/hel/workers/abc/hel.prepared-upgrade-stage-1.next\": No space left on device",
681 "relay coordinator failed: append journal: No space left on device (os error 28)",
683 "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 #[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 #[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 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}