1use 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
26const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
32const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
33
34pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
41
42pub 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 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
177pub 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 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
462fn 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
496fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
503 let (program, arguments) = match login_command(profile) {
504 Ok(command) => command,
505 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
517fn 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
553pub 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
667fn 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
788enum SshConnectivity {
793 Reachable,
794 Failed { detail: String, remediation: String },
795}
796
797fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
828enum SshFailure {
829 UntrustedHostKey,
830 Unauthenticated,
831 ClientMissing,
832 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
870fn 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
890fn 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
900fn 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
928fn ssh_host_only(destination: &str) -> &str {
930 destination
931 .rsplit_once('@')
932 .map_or(destination, |(_, host)| host)
933}
934
935fn 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
941fn 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
981fn 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 if reachable {
1002 checks.push(ssh_podman_limits_check(id, &ssh, executor));
1003 }
1004 checks
1005 }
1006 _ => Vec::new(),
1007 })
1008 .collect()
1009}
1010
1011fn 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 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
1040fn 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
1128const 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
1169const KEYRING_PRESSURE_PERCENT: u64 = 80;
1173
1174#[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 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
1252fn 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
1329fn 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
1463fn 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
1481fn 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
1494fn 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
1521fn 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 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 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
1835pub 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;