Skip to main content

mj_controller/
doctor.rs

1//! Actionable host and configuration prerequisite checks.
2
3use std::io::Write;
4use std::path::Path;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use anyhow::Result;
8use serde::Serialize;
9
10use crate::controller::{WorkerBinaryAvailability, worker_binary_prerequisite_for_arch};
11use crate::setup::{
12    DiscoveredHome, discover_harness_homes_with_executor, harness_is_authenticated_with_executor,
13};
14use crate::targets::{
15    BoundedProcessExecutor, CommandExecutor, CommandSpec, CommandTimedOut,
16    ContainerTemplate as RuntimeContainerTemplate, PodmanProbe, ProcessExecutor,
17    SshTarget as RuntimeSshTarget, TargetTemplate as RuntimeTargetTemplate, failed_podman_probe,
18    run_setup_smoke_test, ssh_command, ssh_connectivity_probe, ssh_validation_command,
19    verify_local_docker, verify_local_podman, verify_ssh_docker, verify_ssh_podman,
20};
21use mj_core::config::{
22    Config, ContainerTemplate, HarnessKind, HarnessProfile, TargetTemplate, config_path,
23};
24use mj_core::credentials::login_command;
25
26// Only the image for the Apple container smoke test when the config has no
27// apple-container target. This intentionally stays a small stock image rather
28// than setup::DEFAULT_IMAGE: the check just proves the runtime can start a
29// container, and pulling the multi-gigabyte agent-dev image to do that would be
30// a poor trade.
31const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
32const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
33
34/// How long a single prerequisite probe may take before doctor reports it as a
35/// fixable check instead of waiting for it.
36///
37/// Every probe outside the opt-in smoke tests is a local or short network call,
38/// so this only ever fires for a wedged runtime socket, a blackholed network,
39/// or a credential helper waiting on something that will never arrive.
40pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
41
42/// The executor `mj doctor` and `mj setup` run their prerequisite probes
43/// through: one deadline per probe, so a wedged runtime cannot hang the run.
44pub const fn probe_executor() -> BoundedProcessExecutor {
45    BoundedProcessExecutor::new(PROBE_TIMEOUT)
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "lowercase")]
50pub enum CheckStatus {
51    Ready,
52    Warning,
53    Fixable,
54    Unsupported,
55}
56
57impl CheckStatus {
58    pub const fn label(self) -> &'static str {
59        match self {
60            Self::Ready => "ready",
61            Self::Warning => "warning",
62            Self::Fixable => "fixable",
63            Self::Unsupported => "unsupported",
64        }
65    }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
69pub struct DoctorCheck {
70    pub id: String,
71    pub title: String,
72    pub status: CheckStatus,
73    pub detail: String,
74    pub remediation: Option<String>,
75}
76
77impl DoctorCheck {
78    fn ready(id: impl Into<String>, title: impl Into<String>, detail: impl Into<String>) -> Self {
79        Self {
80            id: id.into(),
81            title: title.into(),
82            status: CheckStatus::Ready,
83            detail: detail.into(),
84            remediation: None,
85        }
86    }
87
88    fn warning(
89        id: impl Into<String>,
90        title: impl Into<String>,
91        detail: impl Into<String>,
92        remediation: impl Into<String>,
93    ) -> Self {
94        Self {
95            id: id.into(),
96            title: title.into(),
97            status: CheckStatus::Warning,
98            detail: detail.into(),
99            remediation: Some(remediation.into()),
100        }
101    }
102
103    pub(crate) fn fixable(
104        id: impl Into<String>,
105        title: impl Into<String>,
106        detail: impl Into<String>,
107        remediation: impl Into<String>,
108    ) -> Self {
109        Self {
110            id: id.into(),
111            title: title.into(),
112            status: CheckStatus::Fixable,
113            detail: detail.into(),
114            remediation: Some(remediation.into()),
115        }
116    }
117
118    fn unsupported(
119        id: impl Into<String>,
120        title: impl Into<String>,
121        detail: impl Into<String>,
122    ) -> Self {
123        Self {
124            id: id.into(),
125            title: title.into(),
126            status: CheckStatus::Unsupported,
127            detail: detail.into(),
128            remediation: None,
129        }
130    }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct DoctorOptions {
135    pub smoke: bool,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum ApplePlatform {
140    Linux,
141    Macos {
142        architecture: String,
143        major_version: u32,
144    },
145    Other(String),
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum InstructionsPlatform {
150    Linux,
151    Macos,
152}
153
154pub fn run_current(options: DoctorOptions) -> Vec<DoctorCheck> {
155    if options.smoke {
156        // A smoke test may legitimately pull a multi-gigabyte image, which no
157        // probe deadline could tell apart from a hung runtime, so an opt-in
158        // `--smoke` run keeps waiting for its commands.
159        return run_with(
160            &ProcessExecutor,
161            current_apple_platform(&ProcessExecutor),
162            options,
163        );
164    }
165    let executor = probe_executor();
166    run_with(&executor, current_apple_platform(&executor), options)
167}
168
169pub fn run_with(
170    executor: &impl CommandExecutor,
171    apple_platform: ApplePlatform,
172    options: DoctorOptions,
173) -> Vec<DoctorCheck> {
174    run_with_config_path(&config_path(), executor, apple_platform, options)
175}
176
177/// The same checks as [`run_with`], against an explicit configuration file.
178///
179/// `mj setup` uses this to report on the configuration it just wrote, so a
180/// first run ends with exactly the summary and remediations `mj doctor`
181/// would print.
182pub fn run_with_config_path(
183    config_path: &Path,
184    executor: &impl CommandExecutor,
185    apple_platform: ApplePlatform,
186    options: DoctorOptions,
187) -> Vec<DoctorCheck> {
188    let (config, mut checks) = configuration_checks(config_path);
189    checks.push(harness_discovery_check(config.as_ref(), executor));
190    checks.extend(harness_checks(config.as_ref(), executor));
191    checks.extend(subagent_eligibility_checks(config.as_ref()));
192    checks.extend(podman_checks(config.as_ref(), executor, options.smoke));
193    checks.extend(docker_checks(config.as_ref(), executor, options.smoke));
194    checks.extend(ssh_bare_checks(config.as_ref(), executor));
195    checks.extend(ssh_podman_checks(config.as_ref(), executor, options.smoke));
196    checks.extend(ssh_docker_checks(config.as_ref(), executor, options.smoke));
197    checks.extend(aws_checks(config.as_ref(), executor));
198    checks.extend(worker_binary_checks(config.as_ref()));
199    checks.push(apple_container_check(
200        &apple_platform,
201        executor,
202        options.smoke,
203        apple_container_image(config.as_ref()),
204    ));
205    checks
206}
207
208fn harness_discovery_check(
209    config: Option<&Config>,
210    executor: &impl CommandExecutor,
211) -> DoctorCheck {
212    let home = dirs::home_dir();
213    let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
214        std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
215    });
216    let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
217    harness_discovery_check_from(
218        &discovered,
219        config.is_some_and(|config| !config.profiles.is_empty()),
220    )
221}
222
223fn harness_discovery_check_from(
224    discovered: &[DiscoveredHome],
225    has_configured_profiles: bool,
226) -> DoctorCheck {
227    if discovered.is_empty() {
228        return if has_configured_profiles {
229            DoctorCheck::ready(
230                "harness.discovery",
231                "Harness home discovery",
232                "No default or environment-overridden harness homes were found; configured profile homes are checked below.",
233            )
234        } else {
235            DoctorCheck::fixable(
236                "harness.discovery",
237                "Harness home discovery",
238                "No Codex, Claude Code, Kimi Code, or Grok Build home was found in the default or environment-overridden locations.",
239                "Install and sign in to a supported harness, then open F7 Settings → Agent Profiles.",
240            )
241        };
242    }
243
244    let homes = discovered
245        .iter()
246        .map(|home| {
247            let authentication = if home.authenticated {
248                "authenticated"
249            } else {
250                "not authenticated"
251            };
252            format!(
253                "{} at {} ({authentication})",
254                home.kind.display_name(),
255                home.path.display()
256            )
257        })
258        .collect::<Vec<_>>()
259        .join("; ");
260    DoctorCheck::ready(
261        "harness.discovery",
262        "Harness home discovery",
263        format!("Discovered {homes}. Configured profile authentication is checked below."),
264    )
265}
266
267pub fn all_ready(checks: &[DoctorCheck]) -> bool {
268    checks
269        .iter()
270        .all(|check| check.status != CheckStatus::Fixable)
271}
272
273pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
274    for check in checks {
275        writeln!(
276            output,
277            "{} {}: {}",
278            check.status.label(),
279            check.title,
280            check.detail
281        )?;
282        if let Some(remediation) = &check.remediation {
283            writeln!(output, "  remediation: {remediation}")?;
284        }
285    }
286    Ok(())
287}
288
289pub fn setup_instructions(platform: InstructionsPlatform) -> String {
290    match platform {
291        InstructionsPlatform::Linux => format!(
292            "# Hel setup instructions for Linux\n\n\
293This page is self-contained. Follow this exact loop as the user who will run Hel:\n\n\
2941. Run `mj doctor --json`.\n\
2952. Follow every `fixable` remediation from its JSON output.\n\
2963. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
2974. Finish with `mj doctor --json --smoke` to verify every configured container\n\
298   image end to end, and resolve anything it reports as `fixable`.\n\n\
299For a coding-agent handoff, provide this entire instructions page together with\n\
300the latest `mj doctor --json` output.\n\n\
301## Linux container-runtime postconditions\n\n{}\n\n{}",
302            crate::targets::PODMAN_DOCUMENTATION,
303            crate::targets::DOCKER_DOCUMENTATION
304        ),
305        InstructionsPlatform::Macos => format!(
306            "# Hel setup instructions for macOS\n\n\
307This page is self-contained. Follow this exact loop as the user who will run Hel:\n\n\
3081. Run `mj doctor --json`.\n\
3092. Follow every `fixable` remediation from its JSON output.\n\
3103. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\n\
311For a coding-agent handoff, provide this entire instructions page together with\n\
312the latest `mj doctor --json` output.\n\n\
313## Apple container runtime\n\n\
314Hel's Apple container target requires Apple silicon and macOS 26 or newer.\n\
315On an Intel Mac or an older macOS release, the target is unsupported; use a\n\
316local Podman, SSH, or AWS target instead.\n\n\
317If the `container` command is absent, install only the official signed package:\n\n\
318<https://github.com/apple/container#initial-install>\n\n\
319Hel never downloads or installs that package. If doctor reports a stopped\n\
320daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
321Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
322Apple container is ready only when that smoke test creates a disposable\n\
323container, executes `true` in it, and removes it successfully. Use the image\n\
324configured by an `apple-container` target; without one, doctor uses\n\
325`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
326## Shared Hel prerequisites\n\n\
327`mj doctor --json` also checks the configuration, each configured harness home\n\
328and authentication marker, selected container worker binaries, and any relevant\n\
329Podman prerequisites. Resolve every `fixable` status before starting a session."
330        ),
331    }
332}
333
334fn configuration_checks(path: &Path) -> (Option<Config>, Vec<DoctorCheck>) {
335    if !path.exists() {
336        return (
337            None,
338            vec![DoctorCheck::fixable(
339                "config",
340                "Mjolnir configuration",
341                format!("{} does not exist", path.display()),
342                "Open Mjolnir and press F7 for Settings to add an agent profile.",
343            )],
344        );
345    }
346    // A config a newer build wrote is not broken TOML: replacing it with
347    // `mj setup` would discard that build's settings. Say what is actually
348    // wrong before the load below reports it as invalid.
349    if let Some(found) = mj_core::config::newer_version_on_disk(path) {
350        return (
351            None,
352            vec![DoctorCheck::fixable(
353                "config",
354                "Mjolnir configuration",
355                format!(
356                    "{} was written by a newer Mjolnir (config version {found}; this build supports {})",
357                    path.display(),
358                    mj_core::config::CONFIG_VERSION
359                ),
360                "Update Mjolnir to that build or newer. Do not lower the version value by hand or replace the file.",
361            )],
362        );
363    }
364    match Config::load_from(path) {
365        Ok(config) => {
366            let mut checks = vec![DoctorCheck::ready(
367                "config",
368                "Mjolnir configuration",
369                format!("{} is valid", path.display()),
370            )];
371            if config.enabled_profiles().next().is_none() || config.bundles.is_empty() {
372                checks.push(DoctorCheck::fixable(
373                    "config.session-prerequisites",
374                    "Session configuration",
375                    "An enabled profile and project bundle are required for configured bundle sessions. Local targets are supplied automatically.",
376                    "Open F7 Settings to add or enable agent profiles and projects.",
377                ));
378            } else {
379                checks.push(DoctorCheck::ready(
380                    "config.session-prerequisites",
381                    "Session configuration",
382                    "At least one profile, bundle, and target are configured.",
383                ));
384            }
385            (Some(config), checks)
386        }
387        Err(error) => (
388            None,
389            vec![DoctorCheck::fixable(
390                "config",
391                "Mjolnir configuration",
392                format!("{} is invalid: {error:#}", path.display()),
393                "Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
394            )],
395        ),
396    }
397}
398
399fn harness_checks(config: Option<&Config>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
400    let Some(config) = config else {
401        return vec![DoctorCheck::fixable(
402            "harness.profiles",
403            "Harness profiles",
404            "Harness homes cannot be checked until config.toml is valid.",
405            "Fix config.toml, then rerun `mj doctor --json`.",
406        )];
407    };
408    if config.profiles.is_empty() {
409        return vec![DoctorCheck::fixable(
410            "harness.profiles",
411            "Harness profiles",
412            "No harness profiles are configured.",
413            "Open F7 Settings → Agent Profiles to detect accounts or add a profile.",
414        )];
415    }
416    config
417        .profiles
418        .iter()
419        .map(|(id, profile)| {
420            let title = format!("Harness profile {id}");
421            if !profile.enabled {
422                return DoctorCheck::ready(
423                    format!("harness.{id}"),
424                    title,
425                    "Profile is disabled; home and authentication checks were skipped.",
426                );
427            }
428            if !profile.home.is_dir() {
429                return DoctorCheck::fixable(
430                    format!("harness.{id}"),
431                    title,
432                    format!("{} does not exist", profile.home.display()),
433                    format!(
434                        "{} If this profile should use an existing installation, select its home in Setup.",
435                        harness_login_remediation(id, profile)
436                    ),
437                );
438            }
439            if !harness_is_authenticated_with_executor(profile, executor) {
440                return DoctorCheck::fixable(
441                    format!("harness.{id}"),
442                    title,
443                    format!(
444                        "No usable authentication was detected for {}",
445                        profile.home.display()
446                    ),
447                    harness_login_remediation(id, profile),
448                );
449            }
450            DoctorCheck::ready(
451                format!("harness.{id}"),
452                title,
453                format!(
454                    "{} is present and authentication is available",
455                    profile.home.display()
456                ),
457            )
458        })
459        .collect()
460}
461
462/// Warn about a profile that is both listed for sub-agent use and disabled.
463///
464/// The daemon keeps running and simply does not offer such a profile to a
465/// parent, because the delegation candidates and the spawn gate both require an
466/// enabled profile. This surfaces the contradiction so the eligible list and
467/// the profile's `enabled` flag can be reconciled, rather than leaving a profile
468/// the user meant to use silently unavailable.
469fn subagent_eligibility_checks(config: Option<&Config>) -> Vec<DoctorCheck> {
470    let Some(config) = config else {
471        return Vec::new();
472    };
473    config
474        .subagents
475        .eligible_profiles
476        .iter()
477        .filter(|(_, eligible)| **eligible)
478        .filter_map(|(id, _)| {
479            let profile = config.profiles.get(id)?;
480            (!profile.enabled).then(|| {
481                DoctorCheck::warning(
482                    format!("subagents.{id}"),
483                    format!("Sub-agent profile {id}"),
484                    format!(
485                        "Profile {id:?} is listed in [subagents.eligible_profiles] but is disabled, so it is not offered for sub-agent use."
486                    ),
487                    format!(
488                        "Re-enable profile {id:?}, or remove it from [subagents.eligible_profiles]."
489                    ),
490                )
491            })
492        })
493        .collect()
494}
495
496/// Point an unauthenticated profile at `mj login`, which already knows how to
497/// sign each harness in.
498///
499/// The underlying command is named only for the reader's benefit; it comes from
500/// [`login_command`], the one place that tracks what each harness CLI actually
501/// accepts, so this text cannot drift away from what `mj login` runs.
502fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
503    let (program, arguments) = match login_command(profile) {
504        Ok(command) => command,
505        // An API-key profile has no login to recommend; say what is missing
506        // instead. The authentication gate normally passes such a profile, so
507        // this text appears only when its configuration file is absent.
508        Err(error) => return format!("{error} Check {}.", profile.home.display()),
509    };
510    format!(
511        "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
512        arguments.join(" "),
513        profile.home.display()
514    )
515}
516
517/// Host Podman prerequisites, then one image check per `local-podman` target.
518///
519/// The image checks run only after the host preflight passes, because a broken
520/// Podman installation already reports its own actionable check.
521fn podman_checks(
522    config: Option<&Config>,
523    executor: &impl CommandExecutor,
524    smoke: bool,
525) -> Vec<DoctorCheck> {
526    let preflight = podman_check(config, executor);
527    let preflight_passed = preflight.status == CheckStatus::Ready;
528    let mut checks = vec![preflight];
529    if preflight_passed {
530        checks.extend(podman_image_checks(config, executor, smoke));
531    }
532    checks
533}
534
535fn podman_check(config: Option<&Config>, executor: &impl CommandExecutor) -> DoctorCheck {
536    let Some(config) = config else {
537        return DoctorCheck::unsupported(
538            "runtime.podman",
539            "Rootless Podman",
540            "Podman prerequisites cannot be evaluated until config.toml is valid.",
541        );
542    };
543    if local_podman_targets(config).is_empty() {
544        return DoctorCheck::unsupported(
545            "runtime.podman",
546            "Rootless Podman",
547            "No local-podman target is configured.",
548        );
549    }
550    local_podman_runtime_check(executor)
551}
552
553/// Probe the local rootless Podman prerequisites and phrase the result as a
554/// doctor check.
555///
556/// This is the single source of truth for Podman availability wording and
557/// remediation. `mj setup` calls it directly so its runtime list reports the
558/// same detail and fix that `mj doctor` would.
559pub fn local_podman_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
560    match verify_local_podman(executor) {
561        Ok(preflight) => DoctorCheck::ready(
562            "runtime.podman",
563            "Rootless Podman",
564            format!("Podman {} has a valid rootless UID map.", preflight.version),
565        ),
566        Err(error) => {
567            let detail = format!("{error:#}");
568            DoctorCheck::fixable(
569                "runtime.podman",
570                "Rootless Podman",
571                detail,
572                podman_remediation(&error),
573            )
574        }
575    }
576}
577
578fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
579    config
580        .targets
581        .iter()
582        .filter_map(|(id, target)| match target {
583            TargetTemplate::LocalPodman { container } => Some((id, container)),
584            _ => None,
585        })
586        .collect()
587}
588
589fn podman_image_checks(
590    config: Option<&Config>,
591    executor: &impl CommandExecutor,
592    smoke: bool,
593) -> Vec<DoctorCheck> {
594    let Some(config) = config else {
595        return Vec::new();
596    };
597    local_podman_targets(config)
598        .into_iter()
599        .map(|(id, container)| podman_image_check(id, &container.image, executor, smoke))
600        .collect()
601}
602
603fn podman_image_check(
604    id: &str,
605    image: &str,
606    executor: &impl CommandExecutor,
607    smoke: bool,
608) -> DoctorCheck {
609    let check_id = format!("runtime.podman.image.{id}");
610    let title = format!("Podman image for target {id}");
611    if smoke {
612        let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
613            build_cache: None,
614            image: image.to_owned(),
615            pull_policy: Default::default(),
616            extra_run_args: vec![],
617            workspace_storage: Default::default(),
618        });
619        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
620            Ok(()) => DoctorCheck::ready(
621                check_id,
622                title,
623                format!("Disposable run/exec/remove smoke test passed for image {image}."),
624            ),
625            Err(error) => DoctorCheck::fixable(
626                check_id,
627                title,
628                format!(
629                    "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
630                ),
631                "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
632            ),
633        };
634    }
635
636    let command = CommandSpec::new("podman", ["image", "exists", image])
637        .purpose("check Podman image presence");
638    match executor.execute(&command) {
639        Ok(output) if output.status == 0 => DoctorCheck::ready(
640            check_id,
641            title,
642            format!("Image {image} is present in local Podman storage."),
643        ),
644        Ok(_) => DoctorCheck::fixable(
645            check_id,
646            title,
647            format!("Image {image} is not present in local Podman storage."),
648            missing_image_remediation(image),
649        ),
650        Err(error) => DoctorCheck::fixable(
651            check_id,
652            title,
653            format!(
654                "Could not check whether image {image} is present in local Podman storage: {error}"
655            ),
656            missing_image_remediation(image),
657        ),
658    }
659}
660
661fn missing_image_remediation(image: &str) -> String {
662    format!(
663        "Pull it with `podman pull {image}`, build it from containers/Containerfile.agent-dev, or run `mj doctor --json --smoke` to verify the full pull-and-run path."
664    )
665}
666
667/// Host Docker prerequisites, then one image check per `local-docker` target.
668fn docker_checks(
669    config: Option<&Config>,
670    executor: &impl CommandExecutor,
671    smoke: bool,
672) -> Vec<DoctorCheck> {
673    let Some(config) = config else {
674        return vec![DoctorCheck::unsupported(
675            "runtime.docker",
676            "Docker",
677            "Docker prerequisites cannot be evaluated until config.toml is valid.",
678        )];
679    };
680    let targets = local_docker_targets(config);
681    if targets.is_empty() {
682        return vec![DoctorCheck::unsupported(
683            "runtime.docker",
684            "Docker",
685            "No local-docker target is configured.",
686        )];
687    }
688    let preflight = local_docker_runtime_check(executor);
689    if preflight.status != CheckStatus::Ready {
690        return vec![preflight];
691    }
692    let mut checks = vec![preflight];
693    checks.extend(
694        targets
695            .into_iter()
696            .map(|(id, container)| docker_image_check(id, &container.image, executor, smoke)),
697    );
698    checks
699}
700
701pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
702    match verify_local_docker(executor) {
703        Ok(preflight) => DoctorCheck::ready(
704            "runtime.docker",
705            "Docker",
706            format!(
707                "Docker {} is connected to a Linux daemon.",
708                preflight.version
709            ),
710        ),
711        Err(error) => DoctorCheck::fixable(
712            "runtime.docker",
713            "Docker",
714            format!("{error:#}"),
715            "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
716        ),
717    }
718}
719
720fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
721    config
722        .targets
723        .iter()
724        .filter_map(|(id, target)| match target {
725            TargetTemplate::LocalDocker { container } => Some((id, container)),
726            _ => None,
727        })
728        .collect()
729}
730
731fn docker_image_check(
732    id: &str,
733    image: &str,
734    executor: &impl CommandExecutor,
735    smoke: bool,
736) -> DoctorCheck {
737    let check_id = format!("runtime.docker.image.{id}");
738    let title = format!("Docker image for target {id}");
739    if smoke {
740        let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
741            build_cache: None,
742            image: image.to_owned(),
743            pull_policy: Default::default(),
744            extra_run_args: vec![],
745            workspace_storage: Default::default(),
746        });
747        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
748            Ok(()) => DoctorCheck::ready(
749                check_id,
750                title,
751                format!(
752                    "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
753                ),
754            ),
755            Err(error) => DoctorCheck::fixable(
756                check_id,
757                title,
758                format!(
759                    "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
760                ),
761                "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
762            ),
763        };
764    }
765    let command = CommandSpec::new("docker", ["image", "inspect", image])
766        .purpose("check Docker image presence");
767    match executor.execute(&command) {
768        Ok(output) if output.status == 0 => DoctorCheck::ready(
769            check_id,
770            title,
771            format!("Image {image} is present in Docker storage."),
772        ),
773        Ok(_) => DoctorCheck::fixable(
774            check_id,
775            title,
776            format!("Image {image} is not present in Docker storage."),
777            format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
778        ),
779        Err(error) => DoctorCheck::fixable(
780            check_id,
781            title,
782            format!("Could not inspect Docker image {image}: {error}"),
783            format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
784        ),
785    }
786}
787
788/// The outcome of the shared SSH connectivity probe.
789///
790/// Both SSH-backed checks run this first: an unreachable host makes every
791/// later probe fail with a misleading message.
792enum SshConnectivity {
793    Reachable,
794    Failed { detail: String, remediation: String },
795}
796
797/// Probe `ssh <destination> true` and map any failure to a copy-paste fix.
798///
799/// Hel never generates keys, runs `ssh-copy-id`, or accepts a host key on the
800/// user's behalf; it only says exactly which command would fix the failure.
801fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
802    let destination = &ssh.destination;
803    let command = ssh_connectivity_probe(ssh);
804    match executor.execute(&command) {
805        Err(error) => SshConnectivity::Failed {
806            detail: format!("Could not run `ssh {destination} true`: {error:#}"),
807            remediation: ssh_launch_failure_remediation(&error, ssh),
808        },
809        Ok(output) if output.status != 0 => {
810            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
811            SshConnectivity::Failed {
812                detail: format!("`ssh {destination} true` failed: {stderr}"),
813                remediation: ssh_failure_remediation(&stderr, ssh),
814            }
815        }
816        Ok(_) => SshConnectivity::Reachable,
817    }
818}
819
820const SSH_MISSING_REMEDIATION: &str = "Install an OpenSSH client and put `ssh` on PATH: `sudo apt update && sudo apt install -y openssh-client` (Debian/Ubuntu) or `sudo dnf install -y openssh-clients` (Fedora).";
821
822/// What OpenSSH reported, as far as doctor needs to tell the cases apart.
823///
824/// OpenSSH is an external tool, so its wording is the only signal available.
825/// This is the one place in doctor that reads it; everything downstream works
826/// from the classification rather than the text.
827#[derive(Debug, Clone, Copy, PartialEq, Eq)]
828enum SshFailure {
829    UntrustedHostKey,
830    Unauthenticated,
831    ClientMissing,
832    /// The host answered nothing at all.
833    Unreachable,
834    Unrecognized,
835}
836
837fn classify_ssh_stderr(stderr: &str) -> SshFailure {
838    const UNTRUSTED_HOST_KEY: [&str; 3] = [
839        "Host key verification failed",
840        "No ECDSA host key is known",
841        "REMOTE HOST IDENTIFICATION HAS CHANGED",
842    ];
843    const UNAUTHENTICATED: [&str; 4] = [
844        "Permission denied",
845        "Too many authentication failures",
846        "no matching host key",
847        "Authentication failed",
848    ];
849    const CLIENT_MISSING: [&str; 2] = ["ssh: command not found", "No such file or directory"];
850    const UNREACHABLE: [&str; 3] = [
851        "Connection timed out",
852        "No route to host",
853        "Network is unreachable",
854    ];
855
856    let reported = |signatures: &[&str]| signatures.iter().any(|text| stderr.contains(text));
857    if reported(&UNTRUSTED_HOST_KEY) {
858        SshFailure::UntrustedHostKey
859    } else if reported(&UNAUTHENTICATED) {
860        SshFailure::Unauthenticated
861    } else if reported(&CLIENT_MISSING) {
862        SshFailure::ClientMissing
863    } else if reported(&UNREACHABLE) {
864        SshFailure::Unreachable
865    } else {
866        SshFailure::Unrecognized
867    }
868}
869
870/// Map a failure to run `ssh` at all (as opposed to `ssh` exiting nonzero)
871/// to the command that fixes it.
872fn ssh_launch_failure_remediation(error: &anyhow::Error, ssh: &RuntimeSshTarget) -> String {
873    if error.downcast_ref::<CommandTimedOut>().is_some() {
874        return ssh_unreachable_remediation(ssh);
875    }
876    let missing_binary = error.chain().any(|cause| {
877        cause
878            .downcast_ref::<std::io::Error>()
879            .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
880    });
881    if missing_binary {
882        return SSH_MISSING_REMEDIATION.to_owned();
883    }
884    format!(
885        "Run `ssh {} true` by hand and resolve the error it reports: {error:#}",
886        ssh.destination
887    )
888}
889
890/// The host answered nothing: it is asleep, behind a down VPN, or the cloud
891/// session that exposes it has expired.
892fn ssh_unreachable_remediation(ssh: &RuntimeSshTarget) -> String {
893    let host = ssh_host_only(&ssh.destination);
894    format!(
895        "Check that {host} is up and reachable from this machine: wake it, bring up the VPN, or refresh the cloud session that exposes it, then run `ssh {} true` by hand.",
896        ssh.destination
897    )
898}
899
900/// Map `ssh -o BatchMode=yes` stderr to the command that fixes it.
901fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
902    let destination = &ssh.destination;
903    match classify_ssh_stderr(stderr) {
904        SshFailure::UntrustedHostKey => {
905            let host = ssh_host_only(destination);
906            format!(
907                "Add the host key with `ssh-keyscan -H {host} >> ~/.ssh/known_hosts`. Verify the fingerprint out of band before trusting it; if the key changed, remove the stale entry with `ssh-keygen -R {host}` first."
908            )
909        }
910        SshFailure::Unauthenticated => match ssh_identity_file(ssh) {
911            Some(identity) => format!(
912                "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
913            ),
914            None => {
915                format!("Install your public key on the host with `ssh-copy-id {destination}`.")
916            }
917        },
918        SshFailure::ClientMissing => SSH_MISSING_REMEDIATION.to_owned(),
919        SshFailure::Unreachable => ssh_unreachable_remediation(ssh),
920        SshFailure::Unrecognized => {
921            format!(
922                "Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}"
923            )
924        }
925    }
926}
927
928/// The host part of an OpenSSH destination, without any `user@` prefix.
929fn ssh_host_only(destination: &str) -> &str {
930    destination
931        .rsplit_once('@')
932        .map_or(destination, |(_, host)| host)
933}
934
935/// The identity file provisioning passes, recovered from the built ssh args.
936fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
937    let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
938    ssh.ssh_args.get(position + 1).map(String::as_str)
939}
940
941/// One check per `ssh-bare` target: can Hel reach the host noninteractively?
942fn ssh_bare_checks(config: Option<&Config>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
943    let Some(config) = config else {
944        return Vec::new();
945    };
946    config
947        .targets
948        .iter()
949        .filter_map(|(id, target)| match target {
950            TargetTemplate::SshBare { ssh, .. } => {
951                Some(ssh_bare_check(id, &RuntimeSshTarget::from(ssh), executor))
952            }
953            _ => None,
954        })
955        .collect()
956}
957
958fn ssh_bare_check(
959    id: &str,
960    ssh: &RuntimeSshTarget,
961    executor: &impl CommandExecutor,
962) -> DoctorCheck {
963    let check_id = format!("runtime.ssh-bare.{id}");
964    let title = format!("SSH access for target {id}");
965    match ssh_connectivity(ssh, executor) {
966        SshConnectivity::Reachable => DoctorCheck::ready(
967            check_id,
968            title,
969            format!(
970                "`ssh {} true` succeeds noninteractively from this host.",
971                ssh.destination
972            ),
973        ),
974        SshConnectivity::Failed {
975            detail,
976            remediation,
977        } => DoctorCheck::fixable(check_id, title, detail, remediation),
978    }
979}
980
981/// Two checks per `ssh-podman` target: the same Podman probes run over SSH,
982/// then the host limits that only bite under provisioning load.
983fn ssh_podman_checks(
984    config: Option<&Config>,
985    executor: &impl CommandExecutor,
986    smoke: bool,
987) -> Vec<DoctorCheck> {
988    let Some(config) = config else {
989        return Vec::new();
990    };
991    config
992        .targets
993        .iter()
994        .flat_map(|(id, target)| match target {
995            TargetTemplate::SshPodman { ssh, container, .. } => {
996                let ssh = RuntimeSshTarget::from(ssh);
997                let (check, reachable) =
998                    ssh_podman_check(id, &ssh, &container.image, executor, smoke);
999                let mut checks = vec![check];
1000                // An unreachable host has one problem, not two.
1001                if reachable {
1002                    checks.push(ssh_podman_limits_check(id, &ssh, executor));
1003                }
1004                checks
1005            }
1006            _ => Vec::new(),
1007        })
1008        .collect()
1009}
1010
1011/// The Podman check for one target, paired with whether the host answered SSH
1012/// at all: the caller skips its follow-up probes when it did not.
1013fn ssh_podman_check(
1014    id: &str,
1015    ssh: &RuntimeSshTarget,
1016    image: &str,
1017    executor: &impl CommandExecutor,
1018    smoke: bool,
1019) -> (DoctorCheck, bool) {
1020    let check_id = format!("runtime.ssh-podman.{id}");
1021    let title = format!("Remote Podman for target {id}");
1022    // Connectivity first: a remote Podman probe on an unreachable host reports
1023    // a Podman problem the user does not have.
1024    if let SshConnectivity::Failed {
1025        detail,
1026        remediation,
1027    } = ssh_connectivity(ssh, executor)
1028    {
1029        return (
1030            DoctorCheck::fixable(check_id, title, detail, remediation),
1031            false,
1032        );
1033    }
1034    (
1035        ssh_podman_runtime_check(check_id, title, ssh, image, executor, smoke),
1036        true,
1037    )
1038}
1039
1040/// The Podman half of the target's checks, on a host already known reachable.
1041fn ssh_podman_runtime_check(
1042    check_id: String,
1043    title: String,
1044    ssh: &RuntimeSshTarget,
1045    image: &str,
1046    executor: &impl CommandExecutor,
1047    smoke: bool,
1048) -> DoctorCheck {
1049    let destination = &ssh.destination;
1050    let preflight = match verify_ssh_podman(ssh, executor) {
1051        Ok(preflight) => preflight,
1052        Err(error) => {
1053            let detail = format!("{error:#}");
1054            let remediation = match podman_remediation_match(&error) {
1055                Some(remediation) => format!("On {destination}: {remediation}"),
1056                None => format!(
1057                    "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4 or newer there (see docs/PODMAN.md)."
1058                ),
1059            };
1060            return DoctorCheck::fixable(check_id, title, detail, remediation);
1061        }
1062    };
1063    let linger_warning = preflight.warnings.first();
1064    if !smoke && let Some(warning) = linger_warning {
1065        return DoctorCheck::warning(
1066            check_id,
1067            title,
1068            format!(
1069                "Remote rootless Podman {} is available via {destination}, but {}",
1070                preflight.version, warning.detail
1071            ),
1072            &warning.remediation,
1073        );
1074    }
1075    if !smoke {
1076        return DoctorCheck::ready(
1077            check_id,
1078            title,
1079            format!(
1080                "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
1081                preflight.version
1082            ),
1083        );
1084    }
1085
1086    let target = RuntimeTargetTemplate::SshPodman {
1087        ssh: ssh.clone(),
1088        container: RuntimeContainerTemplate {
1089            build_cache: None,
1090            image: image.to_owned(),
1091            pull_policy: Default::default(),
1092            extra_run_args: vec![],
1093            workspace_storage: Default::default(),
1094        },
1095    };
1096    match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1097        Ok(()) => match linger_warning {
1098            Some(warning) => DoctorCheck::warning(
1099                check_id,
1100                title,
1101                format!(
1102                    "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
1103                    warning.detail
1104                ),
1105                &warning.remediation,
1106            ),
1107            None => DoctorCheck::ready(
1108                check_id,
1109                title,
1110                format!(
1111                    "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
1112                ),
1113            ),
1114        },
1115        Err(error) => DoctorCheck::fixable(
1116            check_id,
1117            title,
1118            format!(
1119                "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1120            ),
1121            format!(
1122                "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
1123            ),
1124        ),
1125    }
1126}
1127
1128/// Host limits that cause provisioning failures under load, read on their own SSH
1129/// round trip so the provisioning preflight never pays for them.
1130///
1131/// Every crun container takes a session keyring, so `podman run` fails with
1132/// `crun: create keyring` once the login user's keyring quota is exhausted, and
1133/// sshd refuses new connections past `MaxStartups`. `sshd -T` needs root, so the
1134/// directive is read from the config files instead; drop-ins may be unreadable,
1135/// which the script reports rather than guessing.
1136const SSH_PODMAN_HOST_LIMITS_SCRIPT: &str = r#"
1137if [ -r /proc/sys/kernel/keys/maxkeys ]; then
1138    printf 'keys.max=%s\n' "$(cat /proc/sys/kernel/keys/maxkeys)"
1139fi
1140if [ -r /proc/key-users ]; then
1141    awk -v uid="$(id -u)" '
1142        { user = $1; sub(/:$/, "", user) }
1143        user == uid {
1144            split($4, quota, "/")
1145            printf "keys.used=%s\nkeys.quota=%s\n", quota[1], quota[2]
1146        }
1147    ' /proc/key-users
1148fi
1149unreadable=0
1150maxstartups=
1151# A drop-in directory that cannot be listed hides any override it holds.
1152if [ -d /etc/ssh/sshd_config.d ] && ! [ -r /etc/ssh/sshd_config.d ]; then
1153    unreadable=1
1154fi
1155for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
1156    [ -e "$file" ] || continue
1157    if [ -r "$file" ]; then
1158        match=$(grep -i '^[[:space:]]*maxstartups[[:space:]]' "$file" 2>/dev/null | tail -n 1)
1159        [ -n "$match" ] && maxstartups=$(printf '%s\n' "$match" | awk '{ print $2 }')
1160    else
1161        unreadable=1
1162    fi
1163done
1164[ -n "$maxstartups" ] && printf 'maxstartups=%s\n' "$maxstartups"
1165[ "$unreadable" = 1 ] && printf 'maxstartups.unreadable=1\n'
1166exit 0
1167"#;
1168
1169/// Keyring use at or above this share of the quota is reported as a warning:
1170/// the remaining headroom is a few concurrent containers, not a comfortable
1171/// margin.
1172const KEYRING_PRESSURE_PERCENT: u64 = 80;
1173
1174/// What `SSH_PODMAN_HOST_LIMITS_SCRIPT` managed to read. Every field is
1175/// optional: an unreadable file is reported, never guessed at.
1176#[derive(Debug, Default, PartialEq, Eq)]
1177struct HostLimits {
1178    keys_used: Option<u64>,
1179    keys_quota: Option<u64>,
1180    keys_max: Option<u64>,
1181    max_startups: Option<String>,
1182    max_startups_unreadable: bool,
1183}
1184
1185fn parse_host_limits(stdout: &[u8]) -> HostLimits {
1186    let text = String::from_utf8_lossy(stdout);
1187    let mut limits = HostLimits::default();
1188    for line in text.lines() {
1189        let Some((name, value)) = line.split_once('=') else {
1190            continue;
1191        };
1192        let value = value.trim();
1193        match name.trim() {
1194            "keys.used" => limits.keys_used = value.parse().ok(),
1195            "keys.quota" => limits.keys_quota = value.parse().ok(),
1196            "keys.max" => limits.keys_max = value.parse().ok(),
1197            "maxstartups" if !value.is_empty() => limits.max_startups = Some(value.to_owned()),
1198            "maxstartups.unreadable" => limits.max_startups_unreadable = value == "1",
1199            _ => {}
1200        }
1201    }
1202    limits
1203}
1204
1205impl HostLimits {
1206    /// True when the script produced nothing a reader could act on.
1207    fn is_empty(&self) -> bool {
1208        self.keys_used.is_none()
1209            && self.keys_quota.is_none()
1210            && self.keys_max.is_none()
1211            && self.max_startups.is_none()
1212            && !self.max_startups_unreadable
1213    }
1214
1215    fn keyring_is_under_pressure(&self) -> bool {
1216        match (self.keys_used, self.keys_quota) {
1217            (Some(used), Some(quota)) if quota > 0 => {
1218                used.saturating_mul(100) >= quota.saturating_mul(KEYRING_PRESSURE_PERCENT)
1219            }
1220            _ => false,
1221        }
1222    }
1223
1224    fn keyring_sentence(&self, destination: &str) -> String {
1225        match (self.keys_used, self.keys_quota) {
1226            (Some(used), Some(quota)) => {
1227                let system = match self.keys_max {
1228                    Some(max) => format!(", and `kernel.keys.maxkeys` is {max}"),
1229                    None => String::new(),
1230                };
1231                format!(
1232                    "The login user on {destination} holds {used} of its {quota} kernel keyring quota{system}."
1233                )
1234            }
1235            _ => format!(
1236                "The kernel keyring quota for the login user on {destination} could not be read."
1237            ),
1238        }
1239    }
1240
1241    fn max_startups_sentence(&self) -> String {
1242        match (&self.max_startups, self.max_startups_unreadable) {
1243            (Some(value), _) => format!("sshd MaxStartups is {value}."),
1244            (None, true) => "sshd MaxStartups is not set in a readable sshd_config file, so sshd's default applies unless an unreadable drop-in overrides it.".to_owned(),
1245            (None, false) => {
1246                "sshd MaxStartups is not set in sshd_config, so sshd's default applies.".to_owned()
1247            }
1248        }
1249    }
1250}
1251
1252/// Report the two host limits that made provisioning fail under load. The
1253/// target still works when they cannot be read, so an unreadable host is a
1254/// warning with a manual command, never a `fixable` runtime failure.
1255fn ssh_podman_limits_check(
1256    id: &str,
1257    ssh: &RuntimeSshTarget,
1258    executor: &impl CommandExecutor,
1259) -> DoctorCheck {
1260    let check_id = format!("runtime.ssh-podman.{id}.limits");
1261    let title = format!("Host limits for target {id}");
1262    let destination = &ssh.destination;
1263    let manual = || {
1264        format!(
1265            "Read them by hand on {destination}: `cat /proc/key-users /proc/sys/kernel/keys/maxkeys` and `grep -ri maxstartups /etc/ssh/sshd_config /etc/ssh/sshd_config.d`."
1266        )
1267    };
1268    let command = ssh_validation_command(
1269        ssh,
1270        vec![
1271            "sh".to_owned(),
1272            "-c".to_owned(),
1273            SSH_PODMAN_HOST_LIMITS_SCRIPT.to_owned(),
1274        ],
1275        "read ssh-podman host limits",
1276    );
1277    let limits = match executor.execute(&command) {
1278        Ok(output) if output.status == 0 => parse_host_limits(&output.stdout),
1279        Ok(output) => {
1280            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1281            return DoctorCheck::warning(
1282                check_id,
1283                title,
1284                format!(
1285                    "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {stderr}"
1286                ),
1287                manual(),
1288            );
1289        }
1290        Err(error) => {
1291            return DoctorCheck::warning(
1292                check_id,
1293                title,
1294                format!(
1295                    "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {error}"
1296                ),
1297                manual(),
1298            );
1299        }
1300    };
1301    if limits.is_empty() {
1302        return DoctorCheck::warning(
1303            check_id,
1304            title,
1305            format!("{destination} reported no readable kernel keyring or sshd limits."),
1306            manual(),
1307        );
1308    }
1309    let detail = format!(
1310        "{} {}",
1311        limits.keyring_sentence(destination),
1312        limits.max_startups_sentence()
1313    );
1314    if limits.keyring_is_under_pressure() {
1315        return DoctorCheck::warning(
1316            check_id,
1317            title,
1318            format!(
1319                "{detail} Every container takes a session keyring, so `podman run` fails with `crun: create keyring` once the quota is gone."
1320            ),
1321            format!(
1322                "Raise `kernel.keys.maxkeys` and `kernel.keys.maxbytes` with sysctl on {destination}, and close finished sessions promptly."
1323            ),
1324        );
1325    }
1326    DoctorCheck::ready(check_id, title, detail)
1327}
1328
1329/// One check per `ssh-docker` target: Docker daemon, image, and optional
1330/// remote OverlayFS smoke test, all executed on the SSH host.
1331fn ssh_docker_checks(
1332    config: Option<&Config>,
1333    executor: &impl CommandExecutor,
1334    smoke: bool,
1335) -> Vec<DoctorCheck> {
1336    let Some(config) = config else {
1337        return Vec::new();
1338    };
1339    config
1340        .targets
1341        .iter()
1342        .filter_map(|(id, target)| match target {
1343            TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
1344                id,
1345                &RuntimeSshTarget::from(ssh),
1346                &container.image,
1347                executor,
1348                smoke,
1349            )),
1350            _ => None,
1351        })
1352        .collect()
1353}
1354
1355fn ssh_docker_check(
1356    id: &str,
1357    ssh: &RuntimeSshTarget,
1358    image: &str,
1359    executor: &impl CommandExecutor,
1360    smoke: bool,
1361) -> DoctorCheck {
1362    let check_id = format!("runtime.ssh-docker.{id}");
1363    let title = format!("Remote Docker for target {id}");
1364    let destination = &ssh.destination;
1365    if let SshConnectivity::Failed {
1366        detail,
1367        remediation,
1368    } = ssh_connectivity(ssh, executor)
1369    {
1370        return DoctorCheck::fixable(check_id, title, detail, remediation);
1371    }
1372
1373    let preflight = match verify_ssh_docker(ssh, executor) {
1374        Ok(preflight) => preflight,
1375        Err(error) => {
1376            let detail = format!("{error:#}");
1377            return DoctorCheck::fixable(
1378                check_id,
1379                title,
1380                detail,
1381                format!(
1382                    "Verify `ssh {destination}` succeeds noninteractively from this host, then install and start Docker Engine there; make sure `docker info` succeeds for the configured SSH user."
1383                ),
1384            );
1385        }
1386    };
1387
1388    if smoke {
1389        let target = RuntimeTargetTemplate::SshDocker {
1390            ssh: ssh.clone(),
1391            container: RuntimeContainerTemplate {
1392                build_cache: None,
1393                image: image.to_owned(),
1394                pull_policy: Default::default(),
1395                extra_run_args: vec![],
1396                workspace_storage: Default::default(),
1397            },
1398        };
1399        return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1400            Ok(()) => DoctorCheck::ready(
1401                check_id,
1402                title,
1403                format!(
1404                    "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
1405                    preflight.version
1406                ),
1407            ),
1408            Err(error) => DoctorCheck::fixable(
1409                check_id,
1410                title,
1411                format!(
1412                    "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1413                ),
1414                format!(
1415                    "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
1416                ),
1417            ),
1418        };
1419    }
1420
1421    let image_command = ssh_command(
1422        ssh,
1423        [
1424            "docker".to_owned(),
1425            "image".to_owned(),
1426            "inspect".to_owned(),
1427            image.to_owned(),
1428        ]
1429        .to_vec(),
1430    )
1431    .purpose("check remote Docker image presence");
1432    match executor.execute(&image_command) {
1433        Ok(output) if output.status == 0 => DoctorCheck::ready(
1434            check_id,
1435            title,
1436            format!(
1437                "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
1438                preflight.version
1439            ),
1440        ),
1441        Ok(output) => DoctorCheck::fixable(
1442            check_id,
1443            title,
1444            format!(
1445                "Image {image} is not present in remote Docker storage on {destination}: {}",
1446                String::from_utf8_lossy(&output.stderr).trim()
1447            ),
1448            format!(
1449                "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
1450            ),
1451        ),
1452        Err(error) => DoctorCheck::fixable(
1453            check_id,
1454            title,
1455            format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
1456            format!(
1457                "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
1458            ),
1459        ),
1460    }
1461}
1462
1463/// Shared disposable-container identity for every doctor smoke test.
1464fn doctor_smoke_id() -> String {
1465    format!(
1466        "doctor-{}-{:x}",
1467        std::process::id(),
1468        SystemTime::now()
1469            .duration_since(UNIX_EPOCH)
1470            .unwrap_or_default()
1471            .as_nanos()
1472    )
1473}
1474
1475fn podman_remediation(error: &anyhow::Error) -> &'static str {
1476    podman_remediation_match(error).unwrap_or(
1477        "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
1478    )
1479}
1480
1481/// Map a Podman preflight failure to its specific remediation, if one applies.
1482///
1483/// The preflight reports which postcondition failed on the error itself, so
1484/// the fix is chosen from that probe rather than by matching the message text
1485/// this repository just produced. A failure that is not a probe result, such
1486/// as an unreachable SSH host, has no specific fix here.
1487fn podman_remediation_match(error: &anyhow::Error) -> Option<&'static str> {
1488    failed_podman_probe(error).map(PodmanProbe::remediation)
1489}
1490
1491const AWS_CLI_INSTALL_URL: &str =
1492    "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
1493
1494/// One check per `aws-ec2` target: the AWS CLI, its credentials, and the
1495/// configured launch template.
1496fn aws_checks(config: Option<&Config>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1497    let Some(config) = config else {
1498        return Vec::new();
1499    };
1500    config
1501        .targets
1502        .iter()
1503        .filter_map(|(id, target)| match target {
1504            TargetTemplate::AwsEc2 {
1505                aws_profile,
1506                region,
1507                launch_template,
1508                ..
1509            } => Some(aws_target_check(
1510                id,
1511                aws_profile.as_deref(),
1512                region,
1513                launch_template,
1514                executor,
1515            )),
1516            _ => None,
1517        })
1518        .collect()
1519}
1520
1521/// The profile and region every AWS probe carries, applied exactly the way
1522/// provisioning applies them in `targets`.
1523fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
1524    vec![
1525        "--profile".to_owned(),
1526        profile.unwrap_or("default").to_owned(),
1527        "--region".to_owned(),
1528        region.to_owned(),
1529    ]
1530}
1531
1532fn aws_target_check(
1533    id: &str,
1534    profile: Option<&str>,
1535    region: &str,
1536    launch_template: &str,
1537    executor: &impl CommandExecutor,
1538) -> DoctorCheck {
1539    let check_id = format!("runtime.aws-ec2.{id}");
1540    let title = format!("AWS EC2 target {id}");
1541    let profile_label = profile.unwrap_or("default");
1542
1543    let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
1544    match executor.execute(&version) {
1545        Err(error) => {
1546            return DoctorCheck::fixable(
1547                check_id,
1548                title,
1549                format!("The `aws` command is not available: {error}"),
1550                format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
1551            );
1552        }
1553        Ok(output) if output.status != 0 => {
1554            return DoctorCheck::fixable(
1555                check_id,
1556                title,
1557                format!(
1558                    "`aws --version` failed: {}",
1559                    String::from_utf8_lossy(&output.stderr).trim()
1560                ),
1561                format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
1562            );
1563        }
1564        Ok(_) => {}
1565    }
1566
1567    let mut identity_args = aws_global_args(profile, region);
1568    identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
1569    identity_args.extend(["--output".to_owned(), "json".to_owned()]);
1570    let identity =
1571        CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
1572    match executor.execute(&identity) {
1573        Err(error) => {
1574            return DoctorCheck::fixable(
1575                check_id,
1576                title,
1577                format!("Could not run `aws sts get-caller-identity`: {error}"),
1578                format!(
1579                    "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1580                ),
1581            );
1582        }
1583        Ok(output) if output.status != 0 => {
1584            return DoctorCheck::fixable(
1585                check_id,
1586                title,
1587                format!(
1588                    "AWS credentials for profile {profile_label} are not usable: {}",
1589                    String::from_utf8_lossy(&output.stderr).trim()
1590                ),
1591                format!(
1592                    "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1593                ),
1594            );
1595        }
1596        Ok(_) => {}
1597    }
1598
1599    // Launch templates are addressed by id when they carry the `lt-` prefix
1600    // and by name otherwise, the same split provisioning uses.
1601    let by_id = launch_template.starts_with("lt-");
1602    let mut template_args = aws_global_args(profile, region);
1603    template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
1604    template_args.extend([
1605        if by_id {
1606            "--launch-template-ids".to_owned()
1607        } else {
1608            "--launch-template-names".to_owned()
1609        },
1610        launch_template.to_owned(),
1611    ]);
1612    template_args.extend(["--output".to_owned(), "json".to_owned()]);
1613    let template =
1614        CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
1615    let template_remediation = format!(
1616        "Create the launch template in {region}, or point this target at an existing one; `aws --profile {profile_label} --region {region} ec2 describe-launch-templates` lists them."
1617    );
1618    match executor.execute(&template) {
1619        Err(error) => DoctorCheck::fixable(
1620            check_id,
1621            title,
1622            format!("Could not query launch template {launch_template}: {error}"),
1623            template_remediation,
1624        ),
1625        Ok(output) if output.status != 0 => DoctorCheck::fixable(
1626            check_id,
1627            title,
1628            format!(
1629                "Launch template {launch_template} was not found in {region}: {}",
1630                String::from_utf8_lossy(&output.stderr).trim()
1631            ),
1632            template_remediation,
1633        ),
1634        Ok(_) => DoctorCheck::ready(
1635            check_id,
1636            title,
1637            format!(
1638                "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
1639            ),
1640        ),
1641    }
1642}
1643
1644fn worker_binary_checks(config: Option<&Config>) -> Vec<DoctorCheck> {
1645    let Some(config) = config else {
1646        return vec![DoctorCheck::fixable(
1647            "worker.containers",
1648            "Container worker binary",
1649            "Worker availability cannot be checked until config.toml is valid.",
1650            "Fix config.toml, then rerun `mj doctor --json`.",
1651        )];
1652    };
1653    let containers = config
1654        .targets
1655        .iter()
1656        .filter_map(|(id, target)| match target {
1657            TargetTemplate::LocalPodman { container }
1658            | TargetTemplate::LocalDocker { container }
1659            | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
1660            TargetTemplate::SshPodman { container, .. } => {
1661                Some((id, container, Some("ssh-podman")))
1662            }
1663            TargetTemplate::SshDocker { container, .. } => {
1664                Some((id, container, Some("ssh-docker")))
1665            }
1666            _ => None,
1667        })
1668        .collect::<Vec<_>>();
1669    if containers.is_empty() {
1670        return vec![DoctorCheck::unsupported(
1671            "worker.containers",
1672            "Container worker binary",
1673            "No container target is configured.",
1674        )];
1675    }
1676    containers
1677        .into_iter()
1678        .map(|(id, container, remote_kind)| {
1679            if let Some(remote_kind) = remote_kind
1680                && container.platform.is_none()
1681            {
1682                // The remote CPU architecture is only observable once the host
1683                // is reachable, so an explicit `platform` is required here.
1684                return DoctorCheck::unsupported(
1685                    format!("worker.{id}"),
1686                    format!("Container worker binary for target {id}"),
1687                    format!(
1688                        "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
1689                    ),
1690                );
1691            }
1692            worker_binary_check(id, container)
1693        })
1694        .collect()
1695}
1696
1697fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
1698    let title = format!("Container worker binary for target {id}");
1699    let arch = match container_architecture(container.platform.as_deref()) {
1700        Ok(arch) => arch,
1701        Err(reason) => {
1702            return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
1703        }
1704    };
1705    let triple = format!("{arch}-unknown-linux-musl");
1706    match worker_binary_prerequisite_for_arch(arch) {
1707        Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
1708            format!("worker.{id}"),
1709            title,
1710            format!(
1711                "{triple} worker is available from {source}: {}",
1712                path.display()
1713            ),
1714        ),
1715        Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
1716            format!("worker.{id}"),
1717            title,
1718            format!("{triple} worker will be verified and downloaded from {url} when needed."),
1719        ),
1720        Err(error) => DoctorCheck::fixable(
1721            format!("worker.{id}"),
1722            title,
1723            format!("No usable {triple} worker source: {error:#}"),
1724            format!(
1725                "Build it with `cargo build --release --target {triple} -p brokk-mj-worker --bin mj-worker`, install `mj-worker-{triple}` beside `mj`, or set MJ_WORKER_BINARY, MJ_WORKER_DIR, or MJ_WORKER_URL with MJ_WORKER_SHA256."
1726            ),
1727        ),
1728    }
1729}
1730
1731fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
1732    let candidate = platform.unwrap_or(std::env::consts::ARCH);
1733    let candidate = candidate
1734        .split('/')
1735        .rev()
1736        .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
1737        .unwrap_or(candidate);
1738    match candidate {
1739        "x86_64" | "amd64" => Ok("x86_64"),
1740        "aarch64" | "arm64" => Ok("aarch64"),
1741        other => Err(format!(
1742            "Container architecture {other:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
1743        )),
1744    }
1745}
1746
1747fn apple_container_image(config: Option<&Config>) -> String {
1748    config
1749        .and_then(|config| {
1750            config.targets.values().find_map(|target| match target {
1751                TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
1752                _ => None,
1753            })
1754        })
1755        .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
1756}
1757
1758pub fn apple_container_check(
1759    platform: &ApplePlatform,
1760    executor: &impl CommandExecutor,
1761    smoke: bool,
1762    image: String,
1763) -> DoctorCheck {
1764    match platform {
1765        ApplePlatform::Linux => {
1766            return DoctorCheck::unsupported(
1767                "runtime.apple-container",
1768                "Apple container runtime",
1769                "macOS only",
1770            );
1771        }
1772        ApplePlatform::Other(current) => {
1773            return DoctorCheck::unsupported(
1774                "runtime.apple-container",
1775                "Apple container runtime",
1776                format!("macOS only (current platform: {current})"),
1777            );
1778        }
1779        ApplePlatform::Macos {
1780            architecture,
1781            major_version,
1782        } if architecture != "aarch64" && architecture != "arm64" => {
1783            return DoctorCheck::unsupported(
1784                "runtime.apple-container",
1785                "Apple container runtime",
1786                "Apple container requires Apple silicon; Intel Macs are unsupported.",
1787            );
1788        }
1789        ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
1790            return DoctorCheck::unsupported(
1791                "runtime.apple-container",
1792                "Apple container runtime",
1793                format!("Apple container requires macOS 26 or newer (found {major_version})."),
1794            );
1795        }
1796        ApplePlatform::Macos { .. } => {}
1797    }
1798
1799    let daemon = apple_container_daemon_check(executor);
1800    if daemon.status != CheckStatus::Ready {
1801        return daemon;
1802    }
1803
1804    if !smoke {
1805        return DoctorCheck::fixable(
1806            "runtime.apple-container",
1807            "Apple container runtime",
1808            "The daemon is running, but the required disposable smoke test was not requested.",
1809            "Run `mj doctor --json --smoke`.",
1810        );
1811    }
1812
1813    let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
1814        build_cache: None,
1815        image,
1816        pull_policy: Default::default(),
1817        extra_run_args: vec![],
1818        workspace_storage: Default::default(),
1819    });
1820    match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1821        Ok(()) => DoctorCheck::ready(
1822            "runtime.apple-container",
1823            "Apple container runtime",
1824            "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
1825        ),
1826        Err(error) => DoctorCheck::fixable(
1827            "runtime.apple-container",
1828            "Apple container runtime",
1829            format!("Disposable run/exec/remove smoke test failed: {error:#}"),
1830            "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
1831        ),
1832    }
1833}
1834
1835/// Probe that the Apple `container` command is installed and its daemon is
1836/// running, phrased as a doctor check.
1837///
1838/// Split out of [`apple_container_check`] so `mj setup` can reuse the same
1839/// probes and remediation text without also demanding the opt-in smoke test.
1840/// The caller is responsible for platform gating.
1841pub fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
1842    let installed =
1843        CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
1844    match executor.execute(&installed) {
1845        Err(error) => {
1846            return DoctorCheck::fixable(
1847                "runtime.apple-container",
1848                "Apple container runtime",
1849                format!("The `container` command is not available: {error}"),
1850                format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
1851            );
1852        }
1853        Ok(output) if output.status != 0 => {
1854            return DoctorCheck::fixable(
1855                "runtime.apple-container",
1856                "Apple container runtime",
1857                format!(
1858                    "The installed `container --version` command failed: {}",
1859                    String::from_utf8_lossy(&output.stderr).trim()
1860                ),
1861                format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
1862            );
1863        }
1864        Ok(_) => {}
1865    }
1866
1867    let status =
1868        CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
1869    match executor.execute(&status) {
1870        Ok(output) if output.status == 0 => DoctorCheck::ready(
1871            "runtime.apple-container",
1872            "Apple container runtime",
1873            "Installed, and the Apple container daemon is running.",
1874        ),
1875        Ok(output) => DoctorCheck::fixable(
1876            "runtime.apple-container",
1877            "Apple container runtime",
1878            format!(
1879                "The Apple container daemon is stopped: {}",
1880                String::from_utf8_lossy(&output.stderr).trim()
1881            ),
1882            "Run `container system start`.",
1883        ),
1884        Err(error) => DoctorCheck::fixable(
1885            "runtime.apple-container",
1886            "Apple container runtime",
1887            format!("Could not query the Apple container daemon: {error}"),
1888            "Run `container system start`.",
1889        ),
1890    }
1891}
1892
1893pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
1894    if cfg!(target_os = "linux") {
1895        return ApplePlatform::Linux;
1896    }
1897    if !cfg!(target_os = "macos") {
1898        return ApplePlatform::Other(std::env::consts::OS.into());
1899    }
1900    let major_version = executor
1901        .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
1902        .ok()
1903        .filter(|output| output.status == 0)
1904        .and_then(|output| {
1905            String::from_utf8(output.stdout)
1906                .ok()
1907                .and_then(|value| value.trim().split('.').next()?.parse().ok())
1908        })
1909        .unwrap_or(0);
1910    ApplePlatform::Macos {
1911        architecture: std::env::consts::ARCH.into(),
1912        major_version,
1913    }
1914}
1915
1916#[cfg(test)]
1917mod tests;