Skip to main content

mitosis/
doctor.rs

1//! `mitosis doctor`: read-only health checks that work with the daemon down.
2//!
3//! The store is opened read-only and never migrated; nothing here deletes or starts anything
4//! except `--fix` (finish pending `.env` restores, then gc unless it would also destroy clones) and
5//! the throwaway Postgres
6//! per installed major, which lives under `<home>/logs/doctor-*` and is always cleaned up.
7use 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
65/// 1 when any check failed, else 0.
66pub fn exit_code(checks: &[Check]) -> u8 {
67    u8::from(checks.iter().any(|c| c.level == Level::Fail))
68}
69
70/// One line per check (continuation lines of a detail are indented), then its fix.
71pub 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
88/// Worst level of `findings`, their text joined; `ok` when there are none.
89fn 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
102// ---- read-only view of state.db -------------------------------------------------------------
103
104struct 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    /// `None` for a pending restore.
137    clone_id: Option<String>,
138    file: PathBuf,
139}
140
141/// v1.0 template columns, read apart so a store that lacks one still gets its other checks.
142struct TplExtra {
143    name: String,
144    mask_path: Option<PathBuf>,
145    /// A branch template: its base template and git branch.
146    base: Option<String>,
147    branch: Option<String>,
148}
149
150/// Size of the event log.
151struct 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    /// `(template, gen, mask_hash)` of every generation.
166    mask_hashes: Vec<(String, u32, Option<String>)>,
167    events: Option<EventStats>,
168}
169
170enum StoreView {
171    Missing,
172    /// A v0.1 store (or an empty file): the daemon upgrades it on its next start.
173    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    // A current-schema store has every table: a failed read is a real error, not an empty list.
263    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
327// ---- gc dry run ------------------------------------------------------------------------------
328
329fn 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
338/// Folders gc would remove: the same ownership rules as `lifecycle::remove_orphans`, by name
339/// only, and nothing is touched. Entries Mitosis did not create are not listed.
340fn 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
369/// Rows whose folder is gone.
370fn 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
394/// Clones a gc would destroy for their TTL or because their parent process is gone, as one line
395/// each. Looked at by name and time only: a clone whose server also died is restarted or removed
396/// by gc as its kind decides, which this does not predict.
397fn 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
416// ---- throwaway Postgres ----------------------------------------------------------------------
417
418async 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
448/// Removes `logs/doctor-<major>-<pid>` folders that an interrupted doctor run left behind: the
449/// process that made one is gone. A server still running in one is stopped first, and a folder
450/// whose server will not stop stays.
451fn 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
476/// initdb, start and stop one Postgres of `install` in a temp folder, and clean up either way.
477/// The server listens on its socket only, so no port is ever taken.
478async 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    // A server we could not stop keeps its folder: deleting it under a live postmaster helps nobody.
498    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
514// ---- the checks ------------------------------------------------------------------------------
515
516fn mode(path: &Path) -> Option<u32> {
517    std::fs::metadata(path)
518        .ok()
519        .map(|m| m.permissions().mode() & 0o777)
520}
521
522/// The `home` check, with the back end it selected (none when that failed).
523fn 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
545/// The rest of the `home` check, on the selected back end.
546fn 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
579/// `backend`: the name of the back end the `home` check selected.
580fn 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        // `home` already reports an unusable back end.
590        None => Check::new("backend_speed", Level::Ok, "skipped: no usable back end"),
591    }
592}
593
594/// The ZFS checks of `mitosis zfs setup` for `dataset`: `zfs_tool`, `zfs_dataset`,
595/// `zfs_permissions` and `zfs_sudo`; each failure carries its fix.
596#[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/// `zfs_checks` when the settings use ZFS (`backend = "zfs"`, or a `zfs_dataset` is set).
616#[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
753/// `name (pid N)` of the process listening on TCP `port`, from `lsof`; none when lsof is missing
754/// or finds nothing.
755async 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
782/// The router is listening, or its port is free for the daemon to bind. `daemon` is the running
783/// daemon's answer (none when it is down, or up but silent).
784async 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
845/// A router that listens beyond loopback is reachable from the network.
846fn 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
865/// Managed builds under `<home>/pg/`: each one must run. Blocking.
866fn 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
925/// Masked templates: the masking file is still there and is the one the current generation was
926/// built with.
927fn 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; // nothing built yet
938        }
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
973/// True when git says `branch` is not in the repository at `dir` (`rev-parse --verify` exits 1),
974/// the test gc applies (`lifecycle::branch_deleted`, private there). Blocking.
975fn 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
988/// Branch templates whose branch no longer exists in the base's repository: gc drops them.
989async 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        // The same repository gc looks in: the base's migrate folder.
999        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
1040/// The scheduler keeps 30 days and 50,000 rows of events.
1041const 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    // A day of slack: the scheduler prunes on its own timer.
1057    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    // Only names the socket file: the throwaway server opens no TCP listener.
1127    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
1228/// Clones from 0.2 whose name looks like a clone id (new names may not).
1229fn 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        // --fix leaves gc alone while it would destroy clones; that is a decision for the user.
1274        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
1305/// Folders macOS guards with privacy prompts a launchd service cannot answer.
1306fn 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
1354/// `--fix`: finish pending `.env` restores, then gc when the dry run found something. This is the
1355/// only place that may start the daemon.
1356async 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        // gc destroys what is due, along with the orphans; the user decides that, not --fix.
1375        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                // Far above any real pid limit on macOS.
1627                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        // Nothing was removed, and no daemon was asked (there is none to answer).
1655        assert!(orphan.exists());
1656        assert!(!home.daemon_sock().exists());
1657        // The dry run lists the same clone.
1658        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}