1use crate::client;
8use crate::clone_backend::{free_space, same_volume, select_backend};
9use crate::config::Config;
10use crate::envfile;
11use crate::mask::MaskFile;
12use crate::names::{format_duration, is_clone_id};
13use crate::paths::Home;
14use crate::pg::cluster::{check_socket_path, log_tail};
15use crate::pg::{check_extensions, discover_with, find_major, initdb, start, stop, wait_ready};
16use crate::pg::{managed, parse_version, PgInstall, StartOptions};
17use crate::proc_util::{is_alive, process_start_time};
18use crate::protocol::{DaemonStatus, MigrateCmd, Request, Response, TemplateSource};
19use crate::service;
20use crate::state::SCHEMA_VERSION;
21use crate::templates::{
22 age_secs, hash_dir, is_build_socket_name, now, parse_gen_dir, parse_leftover,
23};
24use rusqlite::{Connection, OpenFlags};
25use serde::Serialize;
26use std::collections::HashSet;
27use std::net::{IpAddr, SocketAddr};
28use std::os::unix::fs::PermissionsExt;
29use std::path::{Path, PathBuf};
30use std::time::Duration;
31
32#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
33#[serde(rename_all = "lowercase")]
34pub enum Level {
35 Ok,
36 Warn,
37 Fail,
38}
39
40#[derive(Serialize, Clone, Debug)]
41pub struct Check {
42 pub name: &'static str,
43 pub level: Level,
44 pub detail: String,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub fix: Option<String>,
47}
48
49impl Check {
50 fn new(name: &'static str, level: Level, detail: impl Into<String>) -> Check {
51 Check {
52 name,
53 level,
54 detail: detail.into(),
55 fix: None,
56 }
57 }
58
59 fn fix(mut self, fix: impl Into<String>) -> Check {
60 self.fix = Some(fix.into());
61 self
62 }
63}
64
65pub fn exit_code(checks: &[Check]) -> u8 {
67 u8::from(checks.iter().any(|c| c.level == Level::Fail))
68}
69
70pub fn render(checks: &[Check]) -> String {
72 let mut out = String::new();
73 for c in checks {
74 let tag = match c.level {
75 Level::Ok => "ok ",
76 Level::Warn => "warn",
77 Level::Fail => "FAIL",
78 };
79 let detail = c.detail.replace('\n', "\n ");
80 out.push_str(&format!("{tag} {:<18} {detail}\n", c.name));
81 if let Some(f) = &c.fix {
82 out.push_str(&format!(" {:<18} fix: {f}\n", ""));
83 }
84 }
85 out
86}
87
88fn summarize(name: &'static str, findings: Vec<(Level, String)>, ok: &str) -> Check {
90 let level = findings.iter().map(|f| f.0).max().unwrap_or(Level::Ok);
91 if findings.is_empty() {
92 return Check::new(name, Level::Ok, ok);
93 }
94 let detail = findings
95 .into_iter()
96 .map(|f| f.1)
97 .collect::<Vec<_>>()
98 .join("\n");
99 Check::new(name, level, detail)
100}
101
102struct TplRow {
105 name: String,
106 source: Option<TemplateSource>,
107 migrations: Option<PathBuf>,
108 migrate: Option<MigrateCmd>,
109 max_age_secs: u64,
110 current_gen: u32,
111 paused: bool,
112 last_error: Option<String>,
113}
114
115struct GenRow {
116 template: String,
117 gen: u32,
118 dir: PathBuf,
119 pg_major: u32,
120 extensions: Vec<String>,
121 migrations_hash: Option<String>,
122 built_at: i64,
123}
124
125struct CloneRow {
126 id: String,
127 name: Option<String>,
128 dir: PathBuf,
129 suspect: bool,
130 expires_at: i64,
131 parent_pid: Option<i32>,
132 parent_start_time: Option<u64>,
133}
134
135struct EnvRef {
136 clone_id: Option<String>,
138 file: PathBuf,
139}
140
141struct TplExtra {
143 name: String,
144 mask_path: Option<PathBuf>,
145 base: Option<String>,
147 branch: Option<String>,
148}
149
150struct EventStats {
152 rows: u64,
153 oldest: Option<i64>,
154 bytes: u64,
155}
156
157#[derive(Default)]
158struct Snapshot {
159 templates: Vec<TplRow>,
160 gens: Vec<GenRow>,
161 clones: Vec<CloneRow>,
162 pending: usize,
163 env_files: Vec<EnvRef>,
164 tpl_extra: Vec<TplExtra>,
165 mask_hashes: Vec<(String, u32, Option<String>)>,
167 events: Option<EventStats>,
168}
169
170enum StoreView {
171 Missing,
172 Old,
174 Broken(String),
175 Ready(Snapshot),
176}
177
178fn rows<T>(
179 conn: &Connection,
180 sql: &str,
181 f: impl FnMut(&rusqlite::Row) -> rusqlite::Result<T>,
182) -> rusqlite::Result<Vec<T>> {
183 conn.prepare(sql)?.query_map([], f)?.collect()
184}
185
186fn json<T: serde::de::DeserializeOwned>(s: Option<String>) -> Option<T> {
187 serde_json::from_str(&s?).ok()
188}
189
190fn read_store(home: &Home) -> StoreView {
191 let path = home.state_db();
192 if !path.is_file() {
193 return StoreView::Missing;
194 }
195 match read_snapshot(&path) {
196 Ok(v) => v,
197 Err(e) => StoreView::Broken(e.to_string()),
198 }
199}
200
201fn read_snapshot(path: &Path) -> rusqlite::Result<StoreView> {
202 let conn = Connection::open_with_flags(
203 path,
204 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
205 )?;
206 let version: i32 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
207 if version > SCHEMA_VERSION {
208 return Ok(StoreView::Broken(format!(
209 "state.db has schema version {version}; this mitosis knows up to {SCHEMA_VERSION}"
210 )));
211 }
212 if version < SCHEMA_VERSION {
213 return Ok(StoreView::Old);
214 }
215 let templates = rows(
216 &conn,
217 "SELECT name, source, migrations, migrate, max_age_secs, current_gen, paused, last_error \
218 FROM templates ORDER BY name",
219 |r| {
220 Ok(TplRow {
221 name: r.get(0)?,
222 source: json(r.get(1)?),
223 migrations: r.get::<_, Option<String>>(2)?.map(PathBuf::from),
224 migrate: json(r.get(3)?),
225 max_age_secs: r.get::<_, i64>(4)?.max(0) as u64,
226 current_gen: r.get(5)?,
227 paused: r.get(6)?,
228 last_error: r.get(7)?,
229 })
230 },
231 )?;
232 let gens = rows(
233 &conn,
234 "SELECT template, gen, dir, pg_major, extensions, migrations_hash, built_at FROM generations",
235 |r| {
236 Ok(GenRow {
237 template: r.get(0)?,
238 gen: r.get(1)?,
239 dir: PathBuf::from(r.get::<_, String>(2)?),
240 pg_major: r.get(3)?,
241 extensions: json(r.get(4)?).unwrap_or_default(),
242 migrations_hash: r.get(5)?,
243 built_at: r.get(6)?,
244 })
245 },
246 )?;
247 let clones = rows(
248 &conn,
249 "SELECT id, name, dir, suspect, expires_at, parent_pid, parent_start_time FROM clones",
250 |r| {
251 Ok(CloneRow {
252 id: r.get(0)?,
253 name: r.get(1)?,
254 dir: PathBuf::from(r.get::<_, String>(2)?),
255 suspect: r.get(3)?,
256 expires_at: r.get(4)?,
257 parent_pid: r.get(5)?,
258 parent_start_time: r.get::<_, Option<i64>>(6)?.map(|t| t as u64),
259 })
260 },
261 )?;
262 let pending = rows(&conn, "SELECT file FROM pending_restores", |r| {
264 Ok(EnvRef {
265 clone_id: None,
266 file: PathBuf::from(r.get::<_, String>(0)?),
267 })
268 })?;
269 let records = rows(&conn, "SELECT clone_id, file FROM env_records", |r| {
270 Ok(EnvRef {
271 clone_id: Some(r.get(0)?),
272 file: PathBuf::from(r.get::<_, String>(1)?),
273 })
274 })?;
275 let pending_n = pending.len();
276 let mut env_files = pending;
277 env_files.extend(records);
278 let tpl_extra = rows(
279 &conn,
280 "SELECT name, mask_path, base, branch FROM templates",
281 |r| {
282 Ok(TplExtra {
283 name: r.get(0)?,
284 mask_path: r.get::<_, Option<String>>(1)?.map(PathBuf::from),
285 base: r.get(2)?,
286 branch: r.get(3)?,
287 })
288 },
289 )?;
290 let mask_hashes = rows(
291 &conn,
292 "SELECT template, gen, mask_hash FROM generations",
293 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
294 )?;
295 let events = conn.query_row(
296 "SELECT COUNT(*), MIN(ts), COALESCE(SUM(length(detail)), 0) FROM events",
297 [],
298 |r| {
299 Ok(EventStats {
300 rows: r.get::<_, i64>(0)?.max(0) as u64,
301 oldest: r.get(1)?,
302 bytes: r.get::<_, i64>(2)?.max(0) as u64,
303 })
304 },
305 )?;
306 let events = Some(events);
307 Ok(StoreView::Ready(Snapshot {
308 templates,
309 gens,
310 clones,
311 pending: pending_n,
312 env_files,
313 tpl_extra,
314 mask_hashes,
315 events,
316 }))
317}
318
319impl Snapshot {
320 fn current(&self, t: &TplRow) -> Option<&GenRow> {
321 self.gens
322 .iter()
323 .find(|g| g.template == t.name && g.gen == t.current_gen)
324 }
325}
326
327fn entries(dir: &Path) -> Vec<(String, PathBuf)> {
330 let Ok(rd) = std::fs::read_dir(dir) else {
331 return Vec::new();
332 };
333 rd.flatten()
334 .map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
335 .collect()
336}
337
338fn orphan_folders(home: &Home, snap: &Snapshot) -> Vec<PathBuf> {
341 let ids: HashSet<&str> = snap.clones.iter().map(|c| c.id.as_str()).collect();
342 let gen_dirs: HashSet<String> = snap
343 .gens
344 .iter()
345 .filter_map(|g| g.dir.file_name().map(|f| f.to_string_lossy().into_owned()))
346 .collect();
347 let mut out = Vec::new();
348 for (id, path) in entries(&home.clones_dir()) {
349 if is_clone_id(&id) && !ids.contains(id.as_str()) {
350 out.push(path);
351 }
352 }
353 for (name, path) in entries(&home.sockets_dir()) {
354 if is_build_socket_name(&name) || (is_clone_id(&name) && !ids.contains(name.as_str())) {
355 out.push(path);
356 }
357 }
358 for (file, path) in entries(&home.templates_dir()) {
359 if !gen_dirs.contains(&file)
360 && (parse_leftover(&file).is_some() || parse_gen_dir(&file).is_some())
361 {
362 out.push(path);
363 }
364 }
365 out.sort();
366 out
367}
368
369fn stale_rows(snap: &Snapshot) -> Vec<String> {
371 let mut out = Vec::new();
372 for c in &snap.clones {
373 if !c.dir.exists() {
374 out.push(format!(
375 "clone {} has no folder {}",
376 c.name.as_deref().unwrap_or(&c.id),
377 c.dir.display()
378 ));
379 }
380 }
381 for g in &snap.gens {
382 if !g.dir.exists() {
383 out.push(format!(
384 "template {} generation {} has no folder {}",
385 g.template,
386 g.gen,
387 g.dir.display()
388 ));
389 }
390 }
391 out
392}
393
394fn doomed_clones(snap: &Snapshot, now: i64) -> Vec<String> {
398 let mut out = Vec::new();
399 for c in &snap.clones {
400 let who = c.name.as_deref().unwrap_or(&c.id);
401 if c.expires_at <= now {
402 let ago = format_duration(Duration::from_secs((now - c.expires_at) as u64));
403 out.push(format!("clone {who} (expired {ago} ago)"));
404 } else if let Some(pid) = c.parent_pid {
405 let alive = is_alive(pid)
406 && c.parent_start_time
407 .is_none_or(|t| process_start_time(pid) == Some(t));
408 if !alive {
409 out.push(format!("clone {who} (its parent process {pid} is gone)"));
410 }
411 }
412 }
413 out
414}
415
416async fn smoke_inner(
419 install: &PgInstall,
420 dir: &Path,
421 port: u16,
422 log: &Path,
423) -> std::result::Result<(), String> {
424 std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
425 let data = dir.join("data");
426 let (i, d) = (install.clone(), data.clone());
427 tokio::task::spawn_blocking(move || initdb(&i, &d))
428 .await
429 .map_err(|e| e.to_string())?
430 .map_err(|e| e.message)?;
431 let opts = StartOptions {
432 port,
433 socket_dir: dir.join("s"),
434 listen_tcp: false,
435 durable: false,
436 log_file: log.to_path_buf(),
437 };
438 let (i, d, o) = (install.clone(), data, opts.clone());
439 let pg = tokio::task::spawn_blocking(move || start(&i, &d, &o))
440 .await
441 .map_err(|e| e.to_string())?
442 .map_err(|e| e.message)?;
443 wait_ready(install, &pg, &opts, Duration::from_secs(30))
444 .await
445 .map_err(|e| e.message)
446}
447
448fn sweep_smoke(home: &Home, installs: &[PgInstall]) {
452 for (name, dir) in entries(&home.logs_dir()) {
453 let Some(rest) = name.strip_prefix("doctor-") else {
454 continue;
455 };
456 let parts = rest
457 .split_once('-')
458 .and_then(|(m, p)| Some((m.parse::<u32>().ok()?, p.parse::<i32>().ok()?)));
459 let Some((major, pid)) = parts else { continue };
460 if pid == std::process::id() as i32 || is_alive(pid) {
461 continue;
462 }
463 let data = dir.join("data");
464 if data.join("postmaster.pid").exists() {
465 let Some(i) = installs.iter().find(|i| i.major == major) else {
466 continue;
467 };
468 if stop(i, &data, None, Duration::from_secs(10)).is_err() {
469 continue;
470 }
471 }
472 let _ = std::fs::remove_dir_all(&dir);
473 }
474}
475
476async fn smoke(home: &Home, install: &PgInstall, port: u16) -> std::result::Result<(), String> {
479 let dir = home
480 .logs_dir()
481 .join(format!("doctor-{}-{}", install.major, std::process::id()));
482 let log = dir.join("postgres.log");
483 let res = smoke_inner(install, &dir, port, &log).await;
484 let tail = if res.is_err() {
485 log_tail(&log, 10)
486 } else {
487 String::new()
488 };
489 let (i, d) = (install.clone(), dir.join("data"));
490 let stopped =
491 tokio::task::spawn_blocking(move || stop(&i, &d, None, Duration::from_secs(10))).await;
492 let stop_err = match stopped {
493 Ok(Ok(())) => None,
494 Ok(Err(e)) => Some(e.message),
495 Err(e) => Some(e.to_string()),
496 };
497 if let Some(e) = stop_err {
499 return Err(format!(
500 "could not stop the throwaway server ({e}); left {} in place",
501 dir.display()
502 ));
503 }
504 let _ = std::fs::remove_dir_all(&dir);
505 res.map_err(|e| {
506 if tail.is_empty() {
507 e
508 } else {
509 format!("{e}\n{tail}")
510 }
511 })
512}
513
514fn mode(path: &Path) -> Option<u32> {
517 std::fs::metadata(path)
518 .ok()
519 .map(|m| m.permissions().mode() & 0o777)
520}
521
522fn check_fs(home: &Home, cfg: &Config) -> (Check, Option<&'static str>) {
524 if !home.root.is_dir() {
525 let c = Check::new(
526 "home",
527 Level::Fail,
528 format!("{} does not exist", home.root.display()),
529 )
530 .fix("run any mitosis command to create it, or set MITOSIS_HOME");
531 return (c, None);
532 }
533 let backend = match select_backend(home, cfg) {
534 Ok(b) => b,
535 Err(e) => {
536 let fix = e
537 .hint
538 .unwrap_or_else(|| "set backend in config.toml".into());
539 return (Check::new("home", Level::Fail, e.message).fix(fix), None);
540 }
541 };
542 (check_volume(home, &*backend), Some(backend.name()))
543}
544
545fn check_volume(home: &Home, backend: &dyn crate::clone_backend::CloneBackend) -> Check {
547 if let Err(e) = backend.check_supported(&home.root) {
548 return Check::new("home", Level::Fail, e.message).fix(
549 e.hint
550 .unwrap_or_else(|| "use a supported file system".into()),
551 );
552 }
553 let (t, c) = (home.templates_dir(), home.clones_dir());
554 if t.is_dir() && c.is_dir() {
555 match same_volume(&t, &c) {
556 Ok(true) => {}
557 Ok(false) => {
558 return Check::new(
559 "home",
560 Level::Fail,
561 "templates/ and clones/ are on different volumes",
562 )
563 .fix("keep the whole home on one volume");
564 }
565 Err(e) => return Check::new("home", Level::Fail, e.message),
566 }
567 }
568 Check::new(
569 "home",
570 Level::Ok,
571 format!(
572 "{} on the {} back end, one volume",
573 home.root.display(),
574 backend.name()
575 ),
576 )
577}
578
579fn check_backend_speed(backend: Option<&str>) -> Check {
581 match backend {
582 Some("copy") => Check::new(
583 "backend_speed",
584 Level::Warn,
585 crate::clone_backend::SLOW_WARNING,
586 )
587 .fix("put MITOSIS_HOME on Btrfs, XFS (mkfs.xfs -m reflink=1) or ZFS and set backend = \"auto\""),
588 Some(b) => Check::new("backend_speed", Level::Ok, format!("{b} clones share data")),
589 None => Check::new("backend_speed", Level::Ok, "skipped: no usable back end"),
591 }
592}
593
594#[cfg(target_os = "linux")]
597pub fn zfs_checks(dataset: &str) -> Vec<Check> {
598 crate::clone_backend::zfs::probes(dataset)
599 .into_iter()
600 .map(|p| {
601 let level = match (p.ok, p.warn) {
602 (true, _) => Level::Ok,
603 (false, true) => Level::Warn,
604 (false, false) => Level::Fail,
605 };
606 let c = Check::new(p.name, level, p.detail);
607 match p.fix {
608 Some(f) => c.fix(f),
609 None => c,
610 }
611 })
612 .collect()
613}
614
615#[cfg(target_os = "linux")]
617fn check_zfs(cfg: &Config) -> Vec<Check> {
618 match &cfg.zfs_dataset {
619 Some(ds) => zfs_checks(ds),
620 None if cfg.backend == crate::clone_backend::BackendKind::Zfs => vec![Check::new(
621 "zfs_dataset",
622 Level::Fail,
623 "backend = \"zfs\" needs zfs_dataset",
624 )
625 .fix("mitosis config set zfs_dataset <dataset>, for example tank/mitosis")],
626 None => Vec::new(),
627 }
628}
629
630fn check_permissions(home: &Home) -> Check {
631 if !home.root.is_dir() {
632 return Check::new("permissions", Level::Ok, "skipped: no home folder");
633 }
634 let mut f = Vec::new();
635 if !home.marker().exists() {
636 f.push((
637 Level::Warn,
638 "no .mitosis-home marker: gc will not touch this folder".to_string(),
639 ));
640 }
641 for (path, want, level) in [
642 (home.root.clone(), 0o700, Level::Fail),
643 (home.state_db(), 0o600, Level::Fail),
644 (home.daemon_sock(), 0o600, Level::Fail),
645 ] {
646 if let Some(m) = mode(&path) {
647 if m != want {
648 f.push((
649 level,
650 format!("{} is {m:04o}, want {want:04o}", path.display()),
651 ));
652 }
653 }
654 }
655 let fix = format!("chmod 700 \"{}\"", home.root.display());
656 let c = summarize("permissions", f, "marker present, modes 0700 / 0600");
657 if c.level == Level::Ok {
658 c
659 } else {
660 c.fix(fix)
661 }
662}
663
664fn check_socket_len(home: &Home, cfg: &Config) -> Check {
665 let port = *cfg.port_range.end();
666 let sample = home.sockets_dir().join("c000000");
667 match check_socket_path(&sample, port) {
668 Ok(()) => Check::new(
669 "socket_path",
670 Level::Ok,
671 "clone socket paths fit the 103-byte limit",
672 ),
673 Err(e) => Check::new("socket_path", Level::Fail, e.message).fix(
674 e.hint
675 .unwrap_or_else(|| "set MITOSIS_HOME to a shorter path".into()),
676 ),
677 }
678}
679
680async fn check_daemon(home: &Home) -> (Check, bool, Option<DaemonStatus>) {
681 let ask = client::request_if_running(home, Request::Status);
682 match tokio::time::timeout(Duration::from_secs(5), ask).await {
683 Err(_) => (
684 Check::new("daemon", Level::Warn, "did not answer within 5 s")
685 .fix("restart it: mitosis daemon stop"),
686 true,
687 None,
688 ),
689 Ok(Ok(None)) => (
690 Check::new("daemon", Level::Ok, "not running (starts on demand)"),
691 false,
692 None,
693 ),
694 Ok(Ok(Some(Response::Status(s)))) => {
695 let mine = env!("CARGO_PKG_VERSION");
696 let check = if s.version == mine {
697 Check::new("daemon", Level::Ok, format!("running, version {mine}"))
698 } else {
699 Check::new(
700 "daemon",
701 Level::Warn,
702 format!("running version {}, this binary is {mine}", s.version),
703 )
704 .fix("mitosis daemon stop (the next command starts the new one)")
705 };
706 (check, true, Some(s))
707 }
708 Ok(Ok(Some(_))) => (
709 Check::new(
710 "daemon",
711 Level::Warn,
712 "unexpected reply to a status request",
713 ),
714 true,
715 None,
716 ),
717 Ok(Err(e)) => {
718 let c = Check::new("daemon", Level::Warn, e.message);
719 (e.hint.map_or(c.clone(), |h| c.fix(h)), true, None)
720 }
721 }
722}
723
724async fn service_status() -> Option<service::ServiceStatus> {
725 tokio::task::spawn_blocking(service::status)
726 .await
727 .ok()
728 .and_then(|r| r.ok())
729}
730
731fn check_service(st: &Option<service::ServiceStatus>) -> Check {
732 match st {
733 None => Check::new("service", Level::Warn, "could not ask the service manager"),
734 Some(s) if !s.installed => Check::new("service", Level::Ok, "not installed (optional)"),
735 Some(s) if !s.binary_exists => {
736 Check::new("service", Level::Warn, "its binary no longer exists")
737 .fix("mitosis service install")
738 }
739 Some(s) if !s.loaded => Check::new("service", Level::Warn, "installed but not loaded")
740 .fix("mitosis service install"),
741 Some(_) => match service::linger_hint() {
742 Some(hint) => Check::new(
743 "service",
744 Level::Warn,
745 "installed and loaded, but it stops when you log out",
746 )
747 .fix(hint),
748 None => Check::new("service", Level::Ok, "installed and loaded"),
749 },
750 }
751}
752
753async fn port_holder(port: u16) -> Option<String> {
756 let run = tokio::process::Command::new("lsof")
757 .args(["-nP", "-Fpc", &format!("-iTCP:{port}"), "-sTCP:LISTEN"])
758 .stdin(std::process::Stdio::null())
759 .stderr(std::process::Stdio::null())
760 .output();
761 let out = tokio::time::timeout(Duration::from_secs(5), run)
762 .await
763 .ok()?
764 .ok()?;
765 let text = String::from_utf8_lossy(&out.stdout);
766 let (mut pid, mut found) = (None, Vec::new());
767 for line in text.lines() {
768 match line.split_at_checked(1) {
769 Some(("p", p)) => pid = Some(p.to_string()),
770 Some(("c", c)) => {
771 let who = format!("{c} (pid {})", pid.take().unwrap_or_default());
772 if !found.contains(&who) {
773 found.push(who);
774 }
775 }
776 _ => {}
777 }
778 }
779 (!found.is_empty()).then(|| found.join(", "))
780}
781
782async fn check_router(cfg: &Config, daemon_up: bool, daemon: Option<&DaemonStatus>) -> Check {
785 let name = "router";
786 if !cfg.router {
787 return Check::new(
788 name,
789 Level::Ok,
790 "off (router = off): clone URLs are the direct URLs",
791 );
792 }
793 let addr = SocketAddr::new(cfg.router_listen, cfg.router_port);
794 let taken = |what: String, holder: Option<String>| {
795 let who = holder.map_or(
796 format!(
797 "could not tell which process (try: lsof -nP -iTCP:{} -sTCP:LISTEN)",
798 addr.port()
799 ),
800 |h| format!("held by {h}"),
801 );
802 Check::new(name, Level::Fail, format!("{what}; {who}")).fix(
803 "stop that process, or move the router: mitosis config set router_port <port> and mitosis daemon stop. Clones answer on their direct_url meanwhile (mitosis env <clone> --direct, which wakes a sleeping clone; or mitosis wake <clone>)"
804 .to_string(),
805 )
806 };
807 if daemon_up {
808 return match daemon.map(|d| &d.router) {
809 None => Check::new(name, Level::Warn, "the daemon did not report its router"),
810 Some(None) => Check::new(
811 name,
812 Level::Warn,
813 "the config has the router on but the running daemon has it off",
814 )
815 .fix("mitosis daemon stop (the next command starts it with the router)"),
816 Some(Some(r)) if r.listening => Check::new(
817 name,
818 Level::Ok,
819 format!("listening on {}:{}", r.listen, r.port),
820 ),
821 Some(Some(r)) => taken(
822 format!(
823 "the daemon is not listening: {}",
824 r.error.as_deref().unwrap_or("no reason given")
825 ),
826 port_holder(r.port).await,
827 ),
828 };
829 }
830 match std::net::TcpListener::bind(addr) {
831 Ok(_) => Check::new(
832 name,
833 Level::Ok,
834 format!("{addr} is free; the daemon binds it when it starts"),
835 ),
836 Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => taken(
837 format!("router port {} is taken", addr.port()),
838 port_holder(addr.port()).await,
839 ),
840 Err(e) => Check::new(name, Level::Fail, format!("cannot listen on {addr}: {e}"))
841 .fix("check router_listen and router_port: mitosis config"),
842 }
843}
844
845fn check_router_listen(cfg: &Config) -> Option<Check> {
847 cfg.router.then(|| {
848 let ip: IpAddr = cfg.router_listen;
849 if ip.is_loopback() {
850 return Check::new(
851 "router_listen",
852 Level::Ok,
853 format!("{ip}: this Mac only (Docker Desktop containers reach it as host.docker.internal)"),
854 );
855 }
856 Check::new(
857 "router_listen",
858 Level::Warn,
859 format!("{ip}: the router is reachable from the network (each clone's password still protects it)"),
860 )
861 .fix("mitosis config set router_listen 127.0.0.1")
862 })
863}
864
865fn check_managed(home: &Home) -> Check {
867 let name = "managed_postgres";
868 let installs = match managed::list(home) {
869 Ok(v) => v,
870 Err(e) => return Check::new(name, Level::Warn, e.message),
871 };
872 if installs.is_empty() {
873 return Check::new(
874 name,
875 Level::Ok,
876 "none installed (mitosis pg install <major>)",
877 );
878 }
879 let mut f = Vec::new();
880 for i in &installs {
881 let bin = i.dir.join("bin/postgres");
882 let out = std::process::Command::new(&bin)
883 .arg("--version")
884 .stdin(std::process::Stdio::null())
885 .output();
886 let problem = match out {
887 Err(e) => Some(e.to_string()),
888 Ok(o) if !o.status.success() => Some(format!("exited with {}", o.status)),
889 Ok(o) => match parse_version(&String::from_utf8_lossy(&o.stdout)) {
890 Some((major, _)) if major == i.major => None,
891 _ => Some("its version output is not Postgres ".to_string() + &i.major.to_string()),
892 },
893 };
894 if let Some(p) = problem {
895 f.push((
896 Level::Warn,
897 format!(
898 "managed Postgres {} does not run ({}): {p}",
899 i.version,
900 bin.display()
901 ),
902 ));
903 }
904 }
905 let c = summarize(
906 name,
907 f,
908 &format!(
909 "{} build(s), all run: {}",
910 installs.len(),
911 installs
912 .iter()
913 .map(|i| i.version.as_str())
914 .collect::<Vec<_>>()
915 .join(", ")
916 ),
917 );
918 if c.level == Level::Ok {
919 c
920 } else {
921 c.fix("mitosis pg remove <version>, then mitosis pg install <major>")
922 }
923}
924
925fn check_masks(snap: &Snapshot) -> Check {
928 let mut f = Vec::new();
929 let mut masked = 0;
930 for x in &snap.tpl_extra {
931 let Some(path) = &x.mask_path else { continue };
932 masked += 1;
933 let Some(t) = snap.templates.iter().find(|t| t.name == x.name) else {
934 continue;
935 };
936 if snap.current(t).is_none() {
937 continue; }
939 if !path.is_file() {
940 f.push((
941 Level::Fail,
942 format!(
943 "template {}: mask file {} is missing",
944 x.name,
945 path.display()
946 ),
947 ));
948 continue;
949 }
950 let built = snap
951 .mask_hashes
952 .iter()
953 .find(|(n, g, _)| *n == x.name && *g == t.current_gen)
954 .and_then(|(_, _, h)| h.as_deref());
955 if MaskFile::hash(path).ok().as_deref() != built {
956 f.push((
957 Level::Warn,
958 format!(
959 "template {}: mask file {} changed since it was built; run: mitosis template refresh {}",
960 x.name,
961 path.display(),
962 x.name
963 ),
964 ));
965 }
966 }
967 if masked == 0 {
968 return Check::new("masking", Level::Ok, "no masked templates");
969 }
970 summarize("masking", f, "mask files present and unchanged")
971}
972
973fn branch_gone(dir: &Path, branch: &str) -> bool {
976 std::process::Command::new("git")
977 .arg("-C")
978 .arg(dir)
979 .args(["rev-parse", "--verify", "--quiet"])
980 .arg(format!("refs/heads/{branch}"))
981 .stdin(std::process::Stdio::null())
982 .stdout(std::process::Stdio::null())
983 .stderr(std::process::Stdio::null())
984 .status()
985 .is_ok_and(|s| s.code() == Some(1))
986}
987
988async fn check_branches(snap: &Snapshot) -> Check {
990 let name = "branch_templates";
991 let mut jobs = Vec::new();
992 let mut n = 0;
993 for x in &snap.tpl_extra {
994 let (Some(base), Some(branch)) = (&x.base, &x.branch) else {
995 continue;
996 };
997 n += 1;
998 let dir = snap
1000 .templates
1001 .iter()
1002 .find(|t| &t.name == base)
1003 .and_then(|t| t.migrate.as_ref())
1004 .map(|m| m.cwd.clone())
1005 .filter(|d| d.is_dir());
1006 if let Some(dir) = dir {
1007 jobs.push((x.name.clone(), dir, branch.clone()));
1008 }
1009 }
1010 if n == 0 {
1011 return Check::new(name, Level::Ok, "no branch templates");
1012 }
1013 let gone = tokio::task::spawn_blocking(move || {
1014 jobs.into_iter()
1015 .filter(|(_, dir, branch)| branch_gone(dir, branch))
1016 .collect::<Vec<_>>()
1017 })
1018 .await
1019 .unwrap_or_default();
1020 let f = gone
1021 .into_iter()
1022 .map(|(t, dir, branch)| {
1023 (
1024 Level::Warn,
1025 format!(
1026 "template {t}: branch {branch} no longer exists in {}; gc drops it",
1027 dir.display()
1028 ),
1029 )
1030 })
1031 .collect();
1032 let c = summarize(name, f, "every branch still exists");
1033 if c.level == Level::Ok {
1034 c
1035 } else {
1036 c.fix("mitosis gc (a template a clone still uses is kept)")
1037 }
1038}
1039
1040const EVENT_KEEP_ROWS: u64 = 50_000;
1042const EVENT_KEEP_SECS: i64 = 30 * 86_400;
1043
1044fn check_events(snap: &Snapshot) -> Check {
1045 let name = "events";
1046 let Some(e) = &snap.events else {
1047 return Check::new(name, Level::Ok, "no event log yet");
1048 };
1049 let age = e.oldest.map_or(0, |ts| now().saturating_sub(ts).max(0));
1050 let size = format!(
1051 "{} event(s), about {} kB, oldest {} ago",
1052 e.rows,
1053 e.bytes / 1000,
1054 format_duration(Duration::from_secs(age as u64))
1055 );
1056 if e.rows > EVENT_KEEP_ROWS || age > EVENT_KEEP_SECS + 86_400 {
1058 return Check::new(
1059 name,
1060 Level::Warn,
1061 format!(
1062 "{size}; past the 30 day / 50000 row retention, the daemon prunes it while it runs"
1063 ),
1064 )
1065 .fix("run any mitosis command so the daemon starts and prunes");
1066 }
1067 Check::new(name, Level::Ok, size)
1068}
1069
1070fn check_postgres(installs: &[PgInstall]) -> Check {
1071 match installs.first() {
1072 None => Check::new("postgres", Level::Fail, "no Postgres found").fix(
1073 crate::pg::binaries::install_hint(crate::pg::binaries::LATEST_PG_MAJOR),
1074 ),
1075 Some(_) => Check::new(
1076 "postgres",
1077 Level::Ok,
1078 installs
1079 .iter()
1080 .map(|i| format!("{} ({})", i.version, i.bin_dir.display()))
1081 .collect::<Vec<_>>()
1082 .join(", "),
1083 ),
1084 }
1085}
1086
1087fn check_template_installs(snap: &Snapshot, installs: &[PgInstall]) -> Check {
1088 let mut f = Vec::new();
1089 let mut hint = None;
1090 for t in &snap.templates {
1091 let Some(g) = snap.current(t) else { continue };
1092 match find_major(installs, g.pg_major) {
1093 Err(e) => {
1094 f.push((
1095 Level::Fail,
1096 format!(
1097 "template {} needs Postgres {}: {}",
1098 t.name, g.pg_major, e.message
1099 ),
1100 ));
1101 hint = e.hint;
1102 }
1103 Ok(i) => {
1104 if let Err(e) = check_extensions(i, &g.extensions) {
1105 f.push((Level::Fail, format!("template {}: {}", t.name, e.message)));
1106 hint = hint.or(e.hint);
1107 }
1108 }
1109 }
1110 }
1111 let c = summarize(
1112 "template_installs",
1113 f,
1114 "every template's Postgres major and extensions are installed",
1115 );
1116 match hint {
1117 Some(h) if c.level != Level::Ok => c.fix(h),
1118 _ => c,
1119 }
1120}
1121
1122async fn check_smoke(home: &Home, cfg: &Config, installs: &[PgInstall]) -> Check {
1123 if !home.logs_dir().is_dir() {
1124 return Check::new("postgres_smoke", Level::Ok, "skipped: no home folder");
1125 }
1126 let port = *cfg.port_range.start();
1128 let h = home.clone();
1129 let swept = installs.to_vec();
1130 let _ = tokio::task::spawn_blocking(move || sweep_smoke(&h, &swept)).await;
1131 let mut f = Vec::new();
1132 let mut ok = Vec::new();
1133 for i in installs {
1134 match smoke(home, i, port).await {
1135 Ok(()) => ok.push(i.major.to_string()),
1136 Err(e) => f.push((Level::Fail, format!("Postgres {}: {e}", i.major))),
1137 }
1138 }
1139 summarize(
1140 "postgres_smoke",
1141 f,
1142 &if ok.is_empty() {
1143 "nothing to test".to_string()
1144 } else {
1145 format!(
1146 "initdb, start and stop worked for Postgres {}",
1147 ok.join(", ")
1148 )
1149 },
1150 )
1151}
1152
1153fn check_disk(home: &Home, cfg: &Config) -> Check {
1154 let probe = if home.root.is_dir() {
1155 &home.root
1156 } else {
1157 return Check::new("disk", Level::Ok, "skipped: no home folder");
1158 };
1159 match free_space(probe) {
1160 Err(e) => Check::new("disk", Level::Warn, e.message),
1161 Ok(free) if free < cfg.min_free_bytes => Check::new(
1162 "disk",
1163 Level::Fail,
1164 format!(
1165 "{free} bytes free, below min_free_disk {}",
1166 cfg.min_free_bytes
1167 ),
1168 )
1169 .fix("free disk space, or lower it: mitosis config set min_free_disk 1GB"),
1170 Ok(free) => Check::new("disk", Level::Ok, format!("{free} bytes free")),
1171 }
1172}
1173
1174fn check_templates(snap: &Snapshot) -> Check {
1175 let mut f = Vec::new();
1176 for t in &snap.templates {
1177 let Some(g) = snap.current(t) else { continue };
1178 if age_secs(g.built_at, now()) > t.max_age_secs {
1179 f.push((
1180 Level::Warn,
1181 format!(
1182 "template {} is stale; run: mitosis template refresh {}",
1183 t.name, t.name
1184 ),
1185 ));
1186 }
1187 if let Some(dir) = t.migrations.as_deref().filter(|d| d.is_dir()) {
1188 if hash_dir(dir).ok() != g.migrations_hash {
1189 f.push((
1190 Level::Warn,
1191 format!(
1192 "template {}: migrations in {} changed since it was built",
1193 t.name,
1194 dir.display()
1195 ),
1196 ));
1197 }
1198 }
1199 if let Some(e) = &t.last_error {
1200 f.push((
1201 Level::Warn,
1202 format!("template {}: last build failed: {e}", t.name),
1203 ));
1204 }
1205 if t.paused {
1206 f.push((Level::Warn, format!("template {} is paused", t.name)));
1207 }
1208 }
1209 summarize("templates", f, "fresh, no errors, none paused")
1210}
1211
1212fn check_suspect(snap: &Snapshot) -> Check {
1213 let f = snap
1214 .clones
1215 .iter()
1216 .filter(|c| c.suspect)
1217 .map(|c| {
1218 let n = c.name.as_deref().unwrap_or(&c.id);
1219 (
1220 Level::Warn,
1221 format!("clone {n} restarted after an unclean shutdown with fsync off"),
1222 )
1223 })
1224 .collect();
1225 summarize("suspect_clones", f, "none")
1226}
1227
1228fn check_clone_names(snap: &Snapshot) -> Check {
1230 let f = snap
1231 .clones
1232 .iter()
1233 .filter_map(|c| c.name.as_deref())
1234 .filter(|n| crate::names::is_clone_id(n))
1235 .map(|n| {
1236 (
1237 Level::Warn,
1238 format!(
1239 "clone {n} has a name that looks like a clone id: router URLs by that name \
1240 may reach the clone with that id; rename by recreating"
1241 ),
1242 )
1243 })
1244 .collect();
1245 summarize("clone_names", f, "none look like clone ids")
1246}
1247
1248fn check_gc(home: &Home, snap: &Snapshot, daemon_up: bool) -> Check {
1249 let orphans = orphan_folders(home, snap);
1250 let mut f: Vec<(Level, String)> = orphans
1251 .iter()
1252 .map(|p| (Level::Warn, format!("orphan folder {}", p.display())))
1253 .collect();
1254 f.extend(stale_rows(snap).into_iter().map(|s| (Level::Warn, s)));
1255 let doomed = doomed_clones(snap, now());
1256 f.extend(
1257 doomed
1258 .iter()
1259 .map(|d| (Level::Warn, format!("gc would destroy {d}"))),
1260 );
1261 if daemon_up && !orphans.is_empty() {
1262 f.push((
1263 Level::Warn,
1264 "a build or create in progress owns some of these until it finishes".into(),
1265 ));
1266 }
1267 let c = summarize("gc_dry_run", f, "nothing for gc to clean");
1268 if c.level == Level::Ok {
1269 c
1270 } else if doomed.is_empty() {
1271 c.fix("mitosis doctor --fix (or mitosis gc)")
1272 } else {
1273 c.fix("mitosis gc (it destroys the clones listed above too)")
1275 }
1276}
1277
1278fn check_env(snap: &Snapshot) -> Check {
1279 let ids: HashSet<&str> = snap.clones.iter().map(|c| c.id.as_str()).collect();
1280 let mut f = Vec::new();
1281 if snap.pending > 0 {
1282 f.push((
1283 Level::Warn,
1284 format!("{} pending .env restore(s) not finished", snap.pending),
1285 ));
1286 }
1287 for r in &snap.env_files {
1288 if let Some(id) = &r.clone_id {
1289 if !ids.contains(id.as_str()) {
1290 f.push((
1291 Level::Warn,
1292 format!("env record for gone clone {id} in {}", r.file.display()),
1293 ));
1294 }
1295 }
1296 }
1297 let c = summarize("env_records", f, "no pending restores, no orphaned records");
1298 if c.level == Level::Ok {
1299 c
1300 } else {
1301 c.fix("mitosis doctor --fix")
1302 }
1303}
1304
1305fn guarded(path: &Path, home_dir: &Path) -> bool {
1307 ["Documents", "Desktop", "Library/Mobile Documents"]
1308 .iter()
1309 .any(|d| path.starts_with(home_dir.join(d)))
1310}
1311
1312fn check_privacy(snap: &Snapshot, svc: &Option<service::ServiceStatus>) -> Check {
1313 let name = "macos_privacy";
1314 if !svc.as_ref().is_some_and(|s| s.installed) {
1315 return Check::new(name, Level::Ok, "no login service installed");
1316 }
1317 let Some(home_dir) = std::env::var_os("HOME").map(PathBuf::from) else {
1318 return Check::new(name, Level::Ok, "HOME is not set; skipped");
1319 };
1320 let mut f = Vec::new();
1321 let mut flag = |what: &str, owner: &str, p: &Path| {
1322 if guarded(p, &home_dir) {
1323 f.push((
1324 Level::Warn,
1325 format!(
1326 "{what} of {owner} is under a macOS-protected folder: {}",
1327 p.display()
1328 ),
1329 ));
1330 }
1331 };
1332 for t in &snap.templates {
1333 if let Some(TemplateSource::Seed { cwd: Some(c), .. }) = &t.source {
1334 flag("seed folder", &t.name, c);
1335 }
1336 if let Some(m) = &t.migrations {
1337 flag("migrations folder", &t.name, m);
1338 }
1339 if let Some(m) = &t.migrate {
1340 flag("migrate folder", &t.name, &m.cwd);
1341 }
1342 }
1343 for r in &snap.env_files {
1344 flag("env file", "a clone", &r.file);
1345 }
1346 let c = summarize(name, f, "nothing under Documents, Desktop or iCloud Drive");
1347 if c.level == Level::Ok {
1348 c
1349 } else {
1350 c.fix("move it out of Documents/Desktop/iCloud, or grant the binary Full Disk Access (see the README on macOS privacy)")
1351 }
1352}
1353
1354async fn apply_fix(home: &Home) -> Check {
1357 let StoreView::Ready(snap) = read_store(home) else {
1358 return Check::new("fix", Level::Ok, "nothing to fix: no current-schema store");
1359 };
1360 let mut f = Vec::new();
1361 if snap.pending > 0 {
1362 let (done, warnings) = envfile::finish_pending_counted(home).await;
1363 f.push((
1364 Level::Ok,
1365 format!(
1366 "finished {done} of {} pending .env restore(s)",
1367 snap.pending
1368 ),
1369 ));
1370 f.extend(warnings.into_iter().map(|w| (Level::Warn, w)));
1371 }
1372 let doomed = doomed_clones(&snap, now());
1373 if !doomed.is_empty() {
1374 f.push((
1376 Level::Warn,
1377 format!(
1378 "gc skipped: it would also destroy {} clone(s) you did not ask about: {}",
1379 doomed.len(),
1380 doomed.join(", ")
1381 ),
1382 ));
1383 f.push((Level::Warn, "run `mitosis gc` to run it anyway".into()));
1384 } else if !orphan_folders(home, &snap).is_empty() || !stale_rows(&snap).is_empty() {
1385 match client::request(home, Request::Gc).await {
1386 Ok(Response::Gc(r)) => {
1387 f.push((
1388 Level::Ok,
1389 format!(
1390 "gc: destroyed {}, orphan folders removed {}, records dropped {}",
1391 r.destroyed.len(),
1392 r.orphan_folders_removed.len(),
1393 r.records_dropped.len()
1394 ),
1395 ));
1396 f.extend(
1397 r.failed
1398 .into_iter()
1399 .map(|x| (Level::Warn, format!("gc failed: {}: {}", x.item, x.error))),
1400 );
1401 }
1402 Ok(_) => f.push((Level::Warn, "gc: unexpected daemon reply".into())),
1403 Err(e) => f.push((Level::Warn, format!("gc: {}", e.message))),
1404 }
1405 }
1406 summarize("fix", f, "nothing to fix")
1407}
1408
1409pub async fn run(home: &Home, fix: bool) -> Vec<Check> {
1410 let mut out = Vec::new();
1411 if fix {
1412 out.push(apply_fix(home).await);
1413 }
1414 let loaded = Config::load(home);
1415 let (home_check, backend) = check_fs(home, &loaded.clone().unwrap_or_default());
1416 out.push(home_check);
1417 out.push(check_backend_speed(backend));
1418 #[cfg(target_os = "linux")]
1419 out.extend(check_zfs(&loaded.clone().unwrap_or_default()));
1420 out.push(check_permissions(home));
1421
1422 let cfg = match loaded {
1423 Ok(c) => {
1424 out.push(Check::new(
1425 "config",
1426 Level::Ok,
1427 "config.toml and environment are valid",
1428 ));
1429 c
1430 }
1431 Err(e) => {
1432 let c = Check::new("config", Level::Fail, e.message);
1433 out.push(e.hint.map_or(c.clone(), |h| c.fix(h)));
1434 Config::default()
1435 }
1436 };
1437 out.push(check_socket_len(home, &cfg));
1438
1439 let (daemon, daemon_up, status) = check_daemon(home).await;
1440 out.push(daemon);
1441 out.push(check_router(&cfg, daemon_up, status.as_ref()).await);
1442 out.extend(check_router_listen(&cfg));
1443 let svc = service_status().await;
1444 out.push(check_service(&svc));
1445
1446 let h = home.clone();
1447 let installs = tokio::task::spawn_blocking(move || discover_with(Some(&h)))
1448 .await
1449 .unwrap_or_default();
1450 out.push(check_postgres(&installs));
1451 let h = home.clone();
1452 out.push(
1453 tokio::task::spawn_blocking(move || check_managed(&h))
1454 .await
1455 .unwrap_or_else(|e| Check::new("managed_postgres", Level::Warn, e.to_string())),
1456 );
1457
1458 let view = read_store(home);
1459 match &view {
1460 StoreView::Missing => out.push(Check::new("store", Level::Ok, "no state.db yet")),
1461 StoreView::Old => out.push(Check::new(
1462 "store",
1463 Level::Warn,
1464 "state.db is from an older version: upgraded on the next daemon start; store checks skipped",
1465 )),
1466 StoreView::Broken(e) => out.push(Check::new("store", Level::Fail, format!("cannot read state.db: {e}"))),
1467 StoreView::Ready(snap) => {
1468 out.push(Check::new(
1469 "store",
1470 Level::Ok,
1471 format!("{} template(s), {} clone(s)", snap.templates.len(), snap.clones.len()),
1472 ));
1473 out.push(check_template_installs(snap, &installs));
1474 }
1475 }
1476 out.push(check_smoke(home, &cfg, &installs).await);
1477 out.push(check_disk(home, &cfg));
1478 if let StoreView::Ready(snap) = &view {
1479 out.push(check_templates(snap));
1480 out.push(check_suspect(snap));
1481 out.push(check_clone_names(snap));
1482 out.push(check_gc(home, snap, daemon_up));
1483 out.push(check_env(snap));
1484 out.push(check_privacy(snap, &svc));
1485 out.push(check_masks(snap));
1486 out.push(check_branches(snap).await);
1487 out.push(check_events(snap));
1488 }
1489 out
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494 use super::*;
1495
1496 fn c(level: Level) -> Check {
1497 Check::new("x", level, "d")
1498 }
1499
1500 #[test]
1501 fn exit_code_is_one_on_fail() {
1502 assert_eq!(exit_code(&[c(Level::Ok), c(Level::Warn)]), 0);
1503 assert_eq!(exit_code(&[c(Level::Ok), c(Level::Fail)]), 1);
1504 assert_eq!(exit_code(&[]), 0);
1505 }
1506
1507 #[test]
1508 fn json_shape() {
1509 let v = serde_json::to_value([
1510 Check::new("home", Level::Ok, "fine"),
1511 Check::new("disk", Level::Fail, "low").fix("free space"),
1512 ])
1513 .unwrap();
1514 assert_eq!(v[0]["name"], "home");
1515 assert_eq!(v[0]["level"], "ok");
1516 assert_eq!(v[0]["detail"], "fine");
1517 assert!(v[0].get("fix").is_none());
1518 assert_eq!(v[1]["level"], "fail");
1519 assert_eq!(v[1]["fix"], "free space");
1520 }
1521
1522 #[test]
1523 fn summarize_takes_the_worst_level() {
1524 let s = summarize(
1525 "n",
1526 vec![(Level::Warn, "a".into()), (Level::Fail, "b".into())],
1527 "ok",
1528 );
1529 assert_eq!((s.level, s.detail.as_str()), (Level::Fail, "a\nb"));
1530 assert_eq!(summarize("n", vec![], "fine").detail, "fine");
1531 }
1532
1533 #[test]
1534 fn guarded_folders() {
1535 let h = Path::new("/Users/u");
1536 assert!(guarded(Path::new("/Users/u/Documents/app/.env"), h));
1537 assert!(guarded(Path::new("/Users/u/Library/Mobile Documents/x"), h));
1538 assert!(!guarded(Path::new("/Users/u/Developer/app"), h));
1539 assert!(!guarded(Path::new("/Users/u/DocumentsX"), h));
1540 }
1541
1542 #[test]
1543 fn orphans_follow_gc_rules_and_delete_nothing() {
1544 let tmp = tempfile::tempdir().unwrap();
1545 let home = Home::at(tmp.path().to_path_buf());
1546 home.ensure_dirs().unwrap();
1547 for p in [
1548 home.clones_dir().join("c0000aa"),
1549 home.clones_dir().join("notmine"),
1550 home.sockets_dir().join("build-54321"),
1551 home.templates_dir().join("main@2"),
1552 home.templates_dir().join("main@1"),
1553 home.templates_dir().join(".main.building-5"),
1554 home.templates_dir().join("random"),
1555 ] {
1556 std::fs::create_dir_all(&p).unwrap();
1557 }
1558 let snap = Snapshot {
1559 gens: vec![GenRow {
1560 template: "main".into(),
1561 gen: 1,
1562 dir: home.templates_dir().join("main@1"),
1563 pg_major: 18,
1564 extensions: vec![],
1565 migrations_hash: None,
1566 built_at: 0,
1567 }],
1568 ..Snapshot::default()
1569 };
1570 let got: Vec<String> = orphan_folders(&home, &snap)
1571 .iter()
1572 .map(|p| p.strip_prefix(tmp.path()).unwrap().display().to_string())
1573 .collect();
1574 assert_eq!(
1575 got,
1576 [
1577 "clones/c0000aa",
1578 "sockets/build-54321",
1579 "templates/.main.building-5",
1580 "templates/main@2"
1581 ]
1582 );
1583 assert!(home.clones_dir().join("c0000aa").exists());
1584 }
1585
1586 fn clone_row(id: &str, expires_at: i64, parent: Option<i32>) -> crate::state::CloneRow {
1587 crate::state::CloneRow {
1588 id: id.into(),
1589 name: None,
1590 template: "main".into(),
1591 dir: PathBuf::from(format!("/h/clones/{id}")),
1592 port: 54321,
1593 socket_dir: PathBuf::from(format!("/h/sockets/{id}")),
1594 password: "pw".into(),
1595 database: "app".into(),
1596 pg_pid: None,
1597 pg_start_time: None,
1598 parent_pid: parent,
1599 parent_start_time: None,
1600 created_at: 0,
1601 expires_at,
1602 durable: false,
1603 status: crate::state::CloneStatus::Ready,
1604 generation: 1,
1605 protected: false,
1606 suspect: false,
1607 }
1608 }
1609
1610 #[test]
1611 fn doomed_clones_lists_expired_and_orphaned_ones() {
1612 let row = |id: &str, expires_at, parent_pid| CloneRow {
1613 id: id.into(),
1614 name: None,
1615 dir: PathBuf::new(),
1616 suspect: false,
1617 expires_at,
1618 parent_pid,
1619 parent_start_time: None,
1620 };
1621 let snap = Snapshot {
1622 clones: vec![
1623 row("c000001", 100, None),
1624 row("c000002", 10_000, None),
1625 row("c000003", 10_000, Some(std::process::id() as i32)),
1626 row("c000004", 10_000, Some(2_000_000_000)),
1628 ],
1629 ..Snapshot::default()
1630 };
1631 let got = doomed_clones(&snap, 7_300);
1632 assert_eq!(got.len(), 2, "{got:?}");
1633 assert!(got[0].starts_with("clone c000001 (expired 2h"), "{got:?}");
1634 assert!(
1635 got[1].contains("c000004") && got[1].contains("parent"),
1636 "{got:?}"
1637 );
1638 }
1639
1640 #[tokio::test]
1641 async fn fix_leaves_gc_alone_while_it_would_destroy_clones() {
1642 let tmp = tempfile::tempdir().unwrap();
1643 let home = Home::at(tmp.path().to_path_buf());
1644 home.ensure_dirs().unwrap();
1645 {
1646 let store = crate::state::Store::open(&home.state_db()).unwrap();
1647 store.insert_clone(&clone_row("c000001", 1, None)).unwrap();
1648 }
1649 let orphan = home.clones_dir().join("c000002");
1650 std::fs::create_dir_all(&orphan).unwrap();
1651 let check = apply_fix(&home).await;
1652 assert!(check.detail.contains("gc skipped"), "{}", check.detail);
1653 assert!(check.detail.contains("c000001"), "{}", check.detail);
1654 assert!(orphan.exists());
1656 assert!(!home.daemon_sock().exists());
1657 let StoreView::Ready(snap) = read_store(&home) else {
1659 panic!("store not readable");
1660 };
1661 let gc = check_gc(&home, &snap, false);
1662 assert!(
1663 gc.detail.contains("gc would destroy clone c000001"),
1664 "{}",
1665 gc.detail
1666 );
1667 }
1668
1669 #[test]
1670 fn sweep_removes_folders_of_dead_runs_only() {
1671 let tmp = tempfile::tempdir().unwrap();
1672 let home = Home::at(tmp.path().to_path_buf());
1673 home.ensure_dirs().unwrap();
1674 let dead = home.logs_dir().join("doctor-18-2000000000");
1675 let mine = home
1676 .logs_dir()
1677 .join(format!("doctor-18-{}", std::process::id()));
1678 let other = home.logs_dir().join("service.log.d");
1679 for d in [&dead, &mine, &other] {
1680 std::fs::create_dir_all(d.join("data")).unwrap();
1681 }
1682 sweep_smoke(&home, &[]);
1683 assert!(!dead.exists());
1684 assert!(mine.exists());
1685 assert!(other.exists());
1686 }
1687}