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::hel_controller::{
11 WorkerBinaryAvailability, backend_ssh, worker_binary_prerequisite_for_arch,
12};
13use crate::hel_setup::{
14 DiscoveredHome, discover_harness_homes_with_executor, harness_is_authenticated_with_executor,
15};
16use hel::hel_config::{
17 ContainerTemplate, HarnessKind, HarnessProfile, HelConfig, TargetTemplate, config_path,
18};
19use hel::hel_credentials::login_command;
20use hel::hel_targets::{
21 BoundedProcessExecutor, CommandExecutor, CommandSpec,
22 ContainerTemplate as RuntimeContainerTemplate, ProcessExecutor, SshTarget as RuntimeSshTarget,
23 TargetTemplate as RuntimeTargetTemplate, run_setup_smoke_test, ssh_command,
24 ssh_connectivity_probe, verify_local_docker, verify_local_podman, verify_ssh_docker,
25 verify_ssh_podman,
26};
27
28const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
34const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
35
36pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
43
44pub const fn probe_executor() -> BoundedProcessExecutor {
47 BoundedProcessExecutor::new(PROBE_TIMEOUT)
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "lowercase")]
52pub enum CheckStatus {
53 Ready,
54 Warning,
55 Fixable,
56 Unsupported,
57}
58
59impl CheckStatus {
60 pub const fn label(self) -> &'static str {
61 match self {
62 Self::Ready => "ready",
63 Self::Warning => "warning",
64 Self::Fixable => "fixable",
65 Self::Unsupported => "unsupported",
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71pub struct DoctorCheck {
72 pub id: String,
73 pub title: String,
74 pub status: CheckStatus,
75 pub detail: String,
76 pub remediation: Option<String>,
77}
78
79impl DoctorCheck {
80 fn ready(id: impl Into<String>, title: impl Into<String>, detail: impl Into<String>) -> Self {
81 Self {
82 id: id.into(),
83 title: title.into(),
84 status: CheckStatus::Ready,
85 detail: detail.into(),
86 remediation: None,
87 }
88 }
89
90 fn warning(
91 id: impl Into<String>,
92 title: impl Into<String>,
93 detail: impl Into<String>,
94 remediation: impl Into<String>,
95 ) -> Self {
96 Self {
97 id: id.into(),
98 title: title.into(),
99 status: CheckStatus::Warning,
100 detail: detail.into(),
101 remediation: Some(remediation.into()),
102 }
103 }
104
105 pub(crate) fn fixable(
106 id: impl Into<String>,
107 title: impl Into<String>,
108 detail: impl Into<String>,
109 remediation: impl Into<String>,
110 ) -> Self {
111 Self {
112 id: id.into(),
113 title: title.into(),
114 status: CheckStatus::Fixable,
115 detail: detail.into(),
116 remediation: Some(remediation.into()),
117 }
118 }
119
120 fn unsupported(
121 id: impl Into<String>,
122 title: impl Into<String>,
123 detail: impl Into<String>,
124 ) -> Self {
125 Self {
126 id: id.into(),
127 title: title.into(),
128 status: CheckStatus::Unsupported,
129 detail: detail.into(),
130 remediation: None,
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct DoctorOptions {
137 pub smoke: bool,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum ApplePlatform {
142 Linux,
143 Macos {
144 architecture: String,
145 major_version: u32,
146 },
147 Other(String),
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum InstructionsPlatform {
152 Linux,
153 Macos,
154}
155
156pub fn run_current(options: DoctorOptions) -> Vec<DoctorCheck> {
157 if options.smoke {
158 return run_with(
162 &ProcessExecutor,
163 current_apple_platform(&ProcessExecutor),
164 options,
165 );
166 }
167 let executor = probe_executor();
168 run_with(&executor, current_apple_platform(&executor), options)
169}
170
171pub fn run_with(
172 executor: &impl CommandExecutor,
173 apple_platform: ApplePlatform,
174 options: DoctorOptions,
175) -> Vec<DoctorCheck> {
176 run_with_config_path(&config_path(), executor, apple_platform, options)
177}
178
179pub fn run_with_config_path(
185 config_path: &Path,
186 executor: &impl CommandExecutor,
187 apple_platform: ApplePlatform,
188 options: DoctorOptions,
189) -> Vec<DoctorCheck> {
190 let (config, mut checks) = configuration_checks(config_path);
191 checks.push(harness_discovery_check(config.as_ref(), executor));
192 checks.extend(harness_checks(config.as_ref(), executor));
193 checks.extend(podman_checks(config.as_ref(), executor, options.smoke));
194 checks.extend(docker_checks(config.as_ref(), executor, options.smoke));
195 checks.extend(ssh_bare_checks(config.as_ref(), executor));
196 checks.extend(ssh_podman_checks(config.as_ref(), executor, options.smoke));
197 checks.extend(ssh_docker_checks(config.as_ref(), executor, options.smoke));
198 checks.extend(aws_checks(config.as_ref(), executor));
199 checks.extend(worker_binary_checks(config.as_ref()));
200 checks.push(apple_container_check(
201 &apple_platform,
202 executor,
203 options.smoke,
204 apple_container_image(config.as_ref()),
205 ));
206 checks
207}
208
209fn harness_discovery_check(
210 config: Option<&HelConfig>,
211 executor: &impl CommandExecutor,
212) -> DoctorCheck {
213 let home = dirs::home_dir();
214 let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
215 std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
216 });
217 let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
218 harness_discovery_check_from(
219 &discovered,
220 config.is_some_and(|config| !config.profiles.is_empty()),
221 )
222}
223
224fn harness_discovery_check_from(
225 discovered: &[DiscoveredHome],
226 has_configured_profiles: bool,
227) -> DoctorCheck {
228 if discovered.is_empty() {
229 return if has_configured_profiles {
230 DoctorCheck::ready(
231 "harness.discovery",
232 "Harness home discovery",
233 "No default or environment-overridden harness homes were found; configured profile homes are checked below.",
234 )
235 } else {
236 DoctorCheck::fixable(
237 "harness.discovery",
238 "Harness home discovery",
239 "No Codex, Claude Code, Kimi Code, or Grok Build home was found in the default or environment-overridden locations.",
240 "Install and sign in to a supported harness, then run `mj setup`.",
241 )
242 };
243 }
244
245 let homes = discovered
246 .iter()
247 .map(|home| {
248 let authentication = if home.authenticated {
249 "authenticated"
250 } else {
251 "not authenticated"
252 };
253 format!(
254 "{} at {} ({authentication})",
255 home.kind.display_name(),
256 home.path.display()
257 )
258 })
259 .collect::<Vec<_>>()
260 .join("; ");
261 DoctorCheck::ready(
262 "harness.discovery",
263 "Harness home discovery",
264 format!("Discovered {homes}. Configured profile authentication is checked below."),
265 )
266}
267
268pub fn all_ready(checks: &[DoctorCheck]) -> bool {
269 checks
270 .iter()
271 .all(|check| check.status != CheckStatus::Fixable)
272}
273
274pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
275 for check in checks {
276 writeln!(
277 output,
278 "{} {}: {}",
279 check.status.label(),
280 check.title,
281 check.detail
282 )?;
283 if let Some(remediation) = &check.remediation {
284 writeln!(output, " remediation: {remediation}")?;
285 }
286 }
287 Ok(())
288}
289
290pub fn setup_instructions(platform: InstructionsPlatform) -> String {
291 match platform {
292 InstructionsPlatform::Linux => format!(
293 "# Hel setup instructions for Linux\n\n\
294This page is self-contained. Follow this exact loop as the user who will run Hel:\n\n\
2951. Run `mj doctor --json`.\n\
2962. Follow every `fixable` remediation from its JSON output.\n\
2973. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
2984. Finish with `mj doctor --json --smoke` to verify every configured container\n\
299 image end to end, and resolve anything it reports as `fixable`.\n\n\
300For a coding-agent handoff, provide this entire instructions page together with\n\
301the latest `mj doctor --json` output.\n\n\
302## Linux container-runtime postconditions\n\n{}\n\n{}",
303 hel::hel_targets::PODMAN_DOCUMENTATION,
304 hel::hel_targets::DOCKER_DOCUMENTATION
305 ),
306 InstructionsPlatform::Macos => format!(
307 "# Hel setup instructions for macOS\n\n\
308This page is self-contained. Follow this exact loop as the user who will run Hel:\n\n\
3091. Run `mj doctor --json`.\n\
3102. Follow every `fixable` remediation from its JSON output.\n\
3113. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\n\
312For a coding-agent handoff, provide this entire instructions page together with\n\
313the latest `mj doctor --json` output.\n\n\
314## Apple container runtime\n\n\
315Hel's Apple container target requires Apple silicon and macOS 26 or newer.\n\
316On an Intel Mac or an older macOS release, the target is unsupported; use a\n\
317local Podman, SSH, or AWS target instead.\n\n\
318If the `container` command is absent, install only the official signed package:\n\n\
319<https://github.com/apple/container#initial-install>\n\n\
320Hel never downloads or installs that package. If doctor reports a stopped\n\
321daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
322Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
323Apple container is ready only when that smoke test creates a disposable\n\
324container, executes `true` in it, and removes it successfully. Use the image\n\
325configured by an `apple-container` target; without one, doctor uses\n\
326`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
327## Shared Hel prerequisites\n\n\
328`mj doctor --json` also checks the configuration, each configured harness home\n\
329and authentication marker, selected container worker binaries, and any relevant\n\
330Podman prerequisites. Resolve every `fixable` status before starting a session."
331 ),
332 }
333}
334
335fn configuration_checks(path: &Path) -> (Option<HelConfig>, Vec<DoctorCheck>) {
336 if !path.exists() {
337 return (
338 None,
339 vec![DoctorCheck::fixable(
340 "config",
341 "Mjolnir configuration",
342 format!("{} does not exist", path.display()),
343 "Run `mj setup` to create config.toml.",
344 )],
345 );
346 }
347 match HelConfig::load_from(path) {
348 Ok(config) => {
349 let mut checks = vec![match config.newer_build_notice() {
350 Some(notice) => DoctorCheck::warning(
353 "config",
354 "Mjolnir configuration",
355 format!("{}: {notice}", path.display()),
356 "Update Mjolnir, or change settings with the newer build.",
357 ),
358 None => DoctorCheck::ready(
359 "config",
360 "Mjolnir configuration",
361 format!("{} is valid", path.display()),
362 ),
363 }];
364 if config.enabled_profiles().next().is_none()
365 || config.bundles.is_empty()
366 || config.targets.is_empty()
367 {
368 checks.push(DoctorCheck::fixable(
369 "config.session-prerequisites",
370 "Session configuration",
371 "At least one enabled profile, bundle, and target are required.",
372 "Run `mj setup`, or enable or add profiles, bundles, and targets in config.toml.",
373 ));
374 } else {
375 checks.push(DoctorCheck::ready(
376 "config.session-prerequisites",
377 "Session configuration",
378 "At least one profile, bundle, and target are configured.",
379 ));
380 }
381 (Some(config), checks)
382 }
383 Err(error) => (
384 None,
385 vec![DoctorCheck::fixable(
386 "config",
387 "Mjolnir configuration",
388 format!("{} is invalid: {error:#}", path.display()),
389 "Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
390 )],
391 ),
392 }
393}
394
395fn harness_checks(config: Option<&HelConfig>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
396 let Some(config) = config else {
397 return vec![DoctorCheck::fixable(
398 "harness.profiles",
399 "Harness profiles",
400 "Harness homes cannot be checked until config.toml is valid.",
401 "Fix config.toml, then rerun `mj doctor --json`.",
402 )];
403 };
404 if config.profiles.is_empty() {
405 return vec![DoctorCheck::fixable(
406 "harness.profiles",
407 "Harness profiles",
408 "No harness profiles are configured.",
409 "Run `mj setup` to discover homes, or add a profile to config.toml.",
410 )];
411 }
412 config
413 .profiles
414 .iter()
415 .map(|(id, profile)| {
416 let title = format!("Harness profile {id}");
417 if !profile.enabled {
418 return DoctorCheck::ready(
419 format!("harness.{id}"),
420 title,
421 "Profile is disabled; home and authentication checks were skipped.",
422 );
423 }
424 if !profile.home.is_dir() {
425 return DoctorCheck::fixable(
426 format!("harness.{id}"),
427 title,
428 format!("{} does not exist", profile.home.display()),
429 format!(
430 "{} If this profile should use an existing installation, select its home in Setup.",
431 harness_login_remediation(id, profile)
432 ),
433 );
434 }
435 if !harness_is_authenticated_with_executor(profile.kind, &profile.home, executor) {
436 return DoctorCheck::fixable(
437 format!("harness.{id}"),
438 title,
439 format!(
440 "No usable authentication was detected for {}",
441 profile.home.display()
442 ),
443 harness_login_remediation(id, profile),
444 );
445 }
446 DoctorCheck::ready(
447 format!("harness.{id}"),
448 title,
449 format!(
450 "{} is present and authentication is available",
451 profile.home.display()
452 ),
453 )
454 })
455 .collect()
456}
457
458fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
465 let (program, arguments) = login_command(profile);
466 format!(
467 "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
468 arguments.join(" "),
469 profile.home.display()
470 )
471}
472
473fn podman_checks(
478 config: Option<&HelConfig>,
479 executor: &impl CommandExecutor,
480 smoke: bool,
481) -> Vec<DoctorCheck> {
482 let preflight = podman_check(config, executor);
483 let preflight_passed = preflight.status == CheckStatus::Ready;
484 let mut checks = vec![preflight];
485 if preflight_passed {
486 checks.extend(podman_image_checks(config, executor, smoke));
487 }
488 checks
489}
490
491fn podman_check(config: Option<&HelConfig>, executor: &impl CommandExecutor) -> DoctorCheck {
492 let Some(config) = config else {
493 return DoctorCheck::unsupported(
494 "runtime.podman",
495 "Rootless Podman",
496 "Podman prerequisites cannot be evaluated until config.toml is valid.",
497 );
498 };
499 if local_podman_targets(config).is_empty() {
500 return DoctorCheck::unsupported(
501 "runtime.podman",
502 "Rootless Podman",
503 "No local-podman target is configured.",
504 );
505 }
506 local_podman_runtime_check(executor)
507}
508
509pub fn local_podman_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
516 match verify_local_podman(executor) {
517 Ok(preflight) => DoctorCheck::ready(
518 "runtime.podman",
519 "Rootless Podman",
520 format!("Podman {} has a valid rootless UID map.", preflight.version),
521 ),
522 Err(error) => {
523 let detail = format!("{error:#}");
524 DoctorCheck::fixable(
525 "runtime.podman",
526 "Rootless Podman",
527 detail.clone(),
528 podman_remediation(&detail),
529 )
530 }
531 }
532}
533
534fn local_podman_targets(config: &HelConfig) -> Vec<(&String, &ContainerTemplate)> {
535 config
536 .targets
537 .iter()
538 .filter_map(|(id, target)| match target {
539 TargetTemplate::LocalPodman { container } => Some((id, container)),
540 _ => None,
541 })
542 .collect()
543}
544
545fn podman_image_checks(
546 config: Option<&HelConfig>,
547 executor: &impl CommandExecutor,
548 smoke: bool,
549) -> Vec<DoctorCheck> {
550 let Some(config) = config else {
551 return Vec::new();
552 };
553 local_podman_targets(config)
554 .into_iter()
555 .map(|(id, container)| podman_image_check(id, &container.image, executor, smoke))
556 .collect()
557}
558
559fn podman_image_check(
560 id: &str,
561 image: &str,
562 executor: &impl CommandExecutor,
563 smoke: bool,
564) -> DoctorCheck {
565 let check_id = format!("runtime.podman.image.{id}");
566 let title = format!("Podman image for target {id}");
567 if smoke {
568 let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
569 image: image.to_owned(),
570 pull_policy: Default::default(),
571 extra_run_args: vec![],
572 workspace_storage: Default::default(),
573 });
574 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
575 Ok(()) => DoctorCheck::ready(
576 check_id,
577 title,
578 format!("Disposable run/exec/remove smoke test passed for image {image}."),
579 ),
580 Err(error) => DoctorCheck::fixable(
581 check_id,
582 title,
583 format!(
584 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
585 ),
586 "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
587 ),
588 };
589 }
590
591 let command = CommandSpec::new("podman", ["image", "exists", image])
592 .purpose("check Podman image presence");
593 match executor.execute(&command) {
594 Ok(output) if output.status == 0 => DoctorCheck::ready(
595 check_id,
596 title,
597 format!("Image {image} is present in local Podman storage."),
598 ),
599 Ok(_) => DoctorCheck::fixable(
600 check_id,
601 title,
602 format!("Image {image} is not present in local Podman storage."),
603 missing_image_remediation(image),
604 ),
605 Err(error) => DoctorCheck::fixable(
606 check_id,
607 title,
608 format!(
609 "Could not check whether image {image} is present in local Podman storage: {error}"
610 ),
611 missing_image_remediation(image),
612 ),
613 }
614}
615
616fn missing_image_remediation(image: &str) -> String {
617 format!(
618 "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."
619 )
620}
621
622fn docker_checks(
624 config: Option<&HelConfig>,
625 executor: &impl CommandExecutor,
626 smoke: bool,
627) -> Vec<DoctorCheck> {
628 let Some(config) = config else {
629 return vec![DoctorCheck::unsupported(
630 "runtime.docker",
631 "Docker",
632 "Docker prerequisites cannot be evaluated until config.toml is valid.",
633 )];
634 };
635 let targets = local_docker_targets(config);
636 if targets.is_empty() {
637 return vec![DoctorCheck::unsupported(
638 "runtime.docker",
639 "Docker",
640 "No local-docker target is configured.",
641 )];
642 }
643 let preflight = local_docker_runtime_check(executor);
644 if preflight.status != CheckStatus::Ready {
645 return vec![preflight];
646 }
647 let mut checks = vec![preflight];
648 checks.extend(
649 targets
650 .into_iter()
651 .map(|(id, container)| docker_image_check(id, &container.image, executor, smoke)),
652 );
653 checks
654}
655
656pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
657 match verify_local_docker(executor) {
658 Ok(preflight) => DoctorCheck::ready(
659 "runtime.docker",
660 "Docker",
661 format!(
662 "Docker {} is connected to a Linux daemon.",
663 preflight.version
664 ),
665 ),
666 Err(error) => DoctorCheck::fixable(
667 "runtime.docker",
668 "Docker",
669 format!("{error:#}"),
670 "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
671 ),
672 }
673}
674
675fn local_docker_targets(config: &HelConfig) -> Vec<(&String, &ContainerTemplate)> {
676 config
677 .targets
678 .iter()
679 .filter_map(|(id, target)| match target {
680 TargetTemplate::LocalDocker { container } => Some((id, container)),
681 _ => None,
682 })
683 .collect()
684}
685
686fn docker_image_check(
687 id: &str,
688 image: &str,
689 executor: &impl CommandExecutor,
690 smoke: bool,
691) -> DoctorCheck {
692 let check_id = format!("runtime.docker.image.{id}");
693 let title = format!("Docker image for target {id}");
694 if smoke {
695 let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
696 image: image.to_owned(),
697 pull_policy: Default::default(),
698 extra_run_args: vec![],
699 workspace_storage: Default::default(),
700 });
701 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
702 Ok(()) => DoctorCheck::ready(
703 check_id,
704 title,
705 format!(
706 "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
707 ),
708 ),
709 Err(error) => DoctorCheck::fixable(
710 check_id,
711 title,
712 format!(
713 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
714 ),
715 "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
716 ),
717 };
718 }
719 let command = CommandSpec::new("docker", ["image", "inspect", image])
720 .purpose("check Docker image presence");
721 match executor.execute(&command) {
722 Ok(output) if output.status == 0 => DoctorCheck::ready(
723 check_id,
724 title,
725 format!("Image {image} is present in Docker storage."),
726 ),
727 Ok(_) => DoctorCheck::fixable(
728 check_id,
729 title,
730 format!("Image {image} is not present in Docker storage."),
731 format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
732 ),
733 Err(error) => DoctorCheck::fixable(
734 check_id,
735 title,
736 format!("Could not inspect Docker image {image}: {error}"),
737 format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
738 ),
739 }
740}
741
742enum SshConnectivity {
747 Reachable,
748 Failed { detail: String, remediation: String },
749}
750
751fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
756 let destination = &ssh.destination;
757 let command = ssh_connectivity_probe(ssh);
758 match executor.execute(&command) {
759 Err(error) => SshConnectivity::Failed {
760 detail: format!("Could not run `ssh {destination} true`: {error}"),
761 remediation: SSH_MISSING_REMEDIATION.to_owned(),
762 },
763 Ok(output) if output.status != 0 => {
764 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
765 SshConnectivity::Failed {
766 detail: format!("`ssh {destination} true` failed: {stderr}"),
767 remediation: ssh_failure_remediation(&stderr, ssh),
768 }
769 }
770 Ok(_) => SshConnectivity::Reachable,
771 }
772}
773
774const 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).";
775
776fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
778 let destination = &ssh.destination;
779 if stderr.contains("Host key verification failed")
780 || stderr.contains("No ECDSA host key is known")
781 || stderr.contains("REMOTE HOST IDENTIFICATION HAS CHANGED")
782 {
783 let host = ssh_host_only(destination);
784 return format!(
785 "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."
786 );
787 }
788 if stderr.contains("Permission denied")
789 || stderr.contains("Too many authentication failures")
790 || stderr.contains("no matching host key")
791 || stderr.contains("Authentication failed")
792 {
793 return match ssh_identity_file(ssh) {
794 Some(identity) => format!(
795 "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
796 ),
797 None => {
798 format!("Install your public key on the host with `ssh-copy-id {destination}`.")
799 }
800 };
801 }
802 if stderr.contains("ssh: command not found") || stderr.contains("No such file or directory") {
803 return SSH_MISSING_REMEDIATION.to_owned();
804 }
805 format!("Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}")
806}
807
808fn ssh_host_only(destination: &str) -> &str {
810 destination
811 .rsplit_once('@')
812 .map_or(destination, |(_, host)| host)
813}
814
815fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
817 let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
818 ssh.ssh_args.get(position + 1).map(String::as_str)
819}
820
821fn ssh_bare_checks(
823 config: Option<&HelConfig>,
824 executor: &impl CommandExecutor,
825) -> Vec<DoctorCheck> {
826 let Some(config) = config else {
827 return Vec::new();
828 };
829 config
830 .targets
831 .iter()
832 .filter_map(|(id, target)| match target {
833 TargetTemplate::SshBare { ssh, .. } => {
834 Some(ssh_bare_check(id, &backend_ssh(ssh), executor))
835 }
836 _ => None,
837 })
838 .collect()
839}
840
841fn ssh_bare_check(
842 id: &str,
843 ssh: &RuntimeSshTarget,
844 executor: &impl CommandExecutor,
845) -> DoctorCheck {
846 let check_id = format!("runtime.ssh-bare.{id}");
847 let title = format!("SSH access for target {id}");
848 match ssh_connectivity(ssh, executor) {
849 SshConnectivity::Reachable => DoctorCheck::ready(
850 check_id,
851 title,
852 format!(
853 "`ssh {} true` succeeds noninteractively from this host.",
854 ssh.destination
855 ),
856 ),
857 SshConnectivity::Failed {
858 detail,
859 remediation,
860 } => DoctorCheck::fixable(check_id, title, detail, remediation),
861 }
862}
863
864fn ssh_podman_checks(
866 config: Option<&HelConfig>,
867 executor: &impl CommandExecutor,
868 smoke: bool,
869) -> Vec<DoctorCheck> {
870 let Some(config) = config else {
871 return Vec::new();
872 };
873 config
874 .targets
875 .iter()
876 .filter_map(|(id, target)| match target {
877 TargetTemplate::SshPodman { ssh, container, .. } => Some(ssh_podman_check(
878 id,
879 &backend_ssh(ssh),
880 &container.image,
881 executor,
882 smoke,
883 )),
884 _ => None,
885 })
886 .collect()
887}
888
889fn ssh_podman_check(
890 id: &str,
891 ssh: &RuntimeSshTarget,
892 image: &str,
893 executor: &impl CommandExecutor,
894 smoke: bool,
895) -> DoctorCheck {
896 let check_id = format!("runtime.ssh-podman.{id}");
897 let title = format!("Remote Podman for target {id}");
898 let destination = &ssh.destination;
899 if let SshConnectivity::Failed {
902 detail,
903 remediation,
904 } = ssh_connectivity(ssh, executor)
905 {
906 return DoctorCheck::fixable(check_id, title, detail, remediation);
907 }
908 let preflight = match verify_ssh_podman(ssh, executor) {
909 Ok(preflight) => preflight,
910 Err(error) => {
911 let detail = format!("{error:#}");
912 let remediation = match podman_remediation_match(&detail) {
913 Some(remediation) => format!("On {destination}: {remediation}"),
914 None => format!(
915 "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4 or newer there (see docs/PODMAN.md)."
916 ),
917 };
918 return DoctorCheck::fixable(check_id, title, detail, remediation);
919 }
920 };
921 let linger_warning = preflight.warnings.first();
922 if !smoke && let Some(warning) = linger_warning {
923 return DoctorCheck::warning(
924 check_id,
925 title,
926 format!(
927 "Remote rootless Podman {} is available via {destination}, but {}",
928 preflight.version, warning.detail
929 ),
930 &warning.remediation,
931 );
932 }
933 if !smoke {
934 return DoctorCheck::ready(
935 check_id,
936 title,
937 format!(
938 "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
939 preflight.version
940 ),
941 );
942 }
943
944 let target = RuntimeTargetTemplate::SshPodman {
945 ssh: ssh.clone(),
946 container: RuntimeContainerTemplate {
947 image: image.to_owned(),
948 pull_policy: Default::default(),
949 extra_run_args: vec![],
950 workspace_storage: Default::default(),
951 },
952 };
953 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
954 Ok(()) => match linger_warning {
955 Some(warning) => DoctorCheck::warning(
956 check_id,
957 title,
958 format!(
959 "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
960 warning.detail
961 ),
962 &warning.remediation,
963 ),
964 None => DoctorCheck::ready(
965 check_id,
966 title,
967 format!(
968 "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
969 ),
970 ),
971 },
972 Err(error) => DoctorCheck::fixable(
973 check_id,
974 title,
975 format!(
976 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
977 ),
978 format!(
979 "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
980 ),
981 ),
982 }
983}
984
985fn ssh_docker_checks(
988 config: Option<&HelConfig>,
989 executor: &impl CommandExecutor,
990 smoke: bool,
991) -> Vec<DoctorCheck> {
992 let Some(config) = config else {
993 return Vec::new();
994 };
995 config
996 .targets
997 .iter()
998 .filter_map(|(id, target)| match target {
999 TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
1000 id,
1001 &backend_ssh(ssh),
1002 &container.image,
1003 executor,
1004 smoke,
1005 )),
1006 _ => None,
1007 })
1008 .collect()
1009}
1010
1011fn ssh_docker_check(
1012 id: &str,
1013 ssh: &RuntimeSshTarget,
1014 image: &str,
1015 executor: &impl CommandExecutor,
1016 smoke: bool,
1017) -> DoctorCheck {
1018 let check_id = format!("runtime.ssh-docker.{id}");
1019 let title = format!("Remote Docker for target {id}");
1020 let destination = &ssh.destination;
1021 if let SshConnectivity::Failed {
1022 detail,
1023 remediation,
1024 } = ssh_connectivity(ssh, executor)
1025 {
1026 return DoctorCheck::fixable(check_id, title, detail, remediation);
1027 }
1028
1029 let preflight = match verify_ssh_docker(ssh, executor) {
1030 Ok(preflight) => preflight,
1031 Err(error) => {
1032 let detail = format!("{error:#}");
1033 return DoctorCheck::fixable(
1034 check_id,
1035 title,
1036 detail,
1037 format!(
1038 "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."
1039 ),
1040 );
1041 }
1042 };
1043
1044 if smoke {
1045 let target = RuntimeTargetTemplate::SshDocker {
1046 ssh: ssh.clone(),
1047 container: RuntimeContainerTemplate {
1048 image: image.to_owned(),
1049 pull_policy: Default::default(),
1050 extra_run_args: vec![],
1051 workspace_storage: Default::default(),
1052 },
1053 };
1054 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1055 Ok(()) => DoctorCheck::ready(
1056 check_id,
1057 title,
1058 format!(
1059 "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
1060 preflight.version
1061 ),
1062 ),
1063 Err(error) => DoctorCheck::fixable(
1064 check_id,
1065 title,
1066 format!(
1067 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1068 ),
1069 format!(
1070 "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
1071 ),
1072 ),
1073 };
1074 }
1075
1076 let image_command = ssh_command(
1077 ssh,
1078 [
1079 "docker".to_owned(),
1080 "image".to_owned(),
1081 "inspect".to_owned(),
1082 image.to_owned(),
1083 ]
1084 .to_vec(),
1085 )
1086 .purpose("check remote Docker image presence");
1087 match executor.execute(&image_command) {
1088 Ok(output) if output.status == 0 => DoctorCheck::ready(
1089 check_id,
1090 title,
1091 format!(
1092 "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
1093 preflight.version
1094 ),
1095 ),
1096 Ok(output) => DoctorCheck::fixable(
1097 check_id,
1098 title,
1099 format!(
1100 "Image {image} is not present in remote Docker storage on {destination}: {}",
1101 String::from_utf8_lossy(&output.stderr).trim()
1102 ),
1103 format!(
1104 "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
1105 ),
1106 ),
1107 Err(error) => DoctorCheck::fixable(
1108 check_id,
1109 title,
1110 format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
1111 format!(
1112 "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
1113 ),
1114 ),
1115 }
1116}
1117
1118fn doctor_smoke_id() -> String {
1120 format!(
1121 "doctor-{}-{:x}",
1122 std::process::id(),
1123 SystemTime::now()
1124 .duration_since(UNIX_EPOCH)
1125 .unwrap_or_default()
1126 .as_nanos()
1127 )
1128}
1129
1130fn podman_remediation(detail: &str) -> &'static str {
1131 podman_remediation_match(detail).unwrap_or(
1132 "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
1133 )
1134}
1135
1136fn podman_remediation_match(detail: &str) -> Option<&'static str> {
1138 if detail.contains("Podman 4.0.0") {
1139 Some(
1140 "Upgrade Podman: Debian/Ubuntu `sudo apt update && sudo apt install -y podman uidmap`; Fedora `sudo dnf install -y podman shadow-utils`.",
1141 )
1142 } else if detail.contains("podman unshare") {
1143 Some(
1144 "Install UID mapping support with `sudo apt install -y uidmap` (Debian/Ubuntu) or `sudo dnf install -y shadow-utils` (Fedora), add `/etc/subuid` and `/etc/subgid` entries, then log out and back in.",
1145 )
1146 } else if detail.contains("Rootless") {
1147 Some(
1148 "Run mj without `sudo`; unset `CONTAINER_HOST` and select the rootless local Podman connection.",
1149 )
1150 } else {
1151 None
1152 }
1153}
1154
1155const AWS_CLI_INSTALL_URL: &str =
1156 "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
1157
1158fn aws_checks(config: Option<&HelConfig>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1161 let Some(config) = config else {
1162 return Vec::new();
1163 };
1164 config
1165 .targets
1166 .iter()
1167 .filter_map(|(id, target)| match target {
1168 TargetTemplate::AwsEc2 {
1169 aws_profile,
1170 region,
1171 launch_template,
1172 ..
1173 } => Some(aws_target_check(
1174 id,
1175 aws_profile.as_deref(),
1176 region,
1177 launch_template,
1178 executor,
1179 )),
1180 _ => None,
1181 })
1182 .collect()
1183}
1184
1185fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
1188 vec![
1189 "--profile".to_owned(),
1190 profile.unwrap_or("default").to_owned(),
1191 "--region".to_owned(),
1192 region.to_owned(),
1193 ]
1194}
1195
1196fn aws_target_check(
1197 id: &str,
1198 profile: Option<&str>,
1199 region: &str,
1200 launch_template: &str,
1201 executor: &impl CommandExecutor,
1202) -> DoctorCheck {
1203 let check_id = format!("runtime.aws-ec2.{id}");
1204 let title = format!("AWS EC2 target {id}");
1205 let profile_label = profile.unwrap_or("default");
1206
1207 let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
1208 match executor.execute(&version) {
1209 Err(error) => {
1210 return DoctorCheck::fixable(
1211 check_id,
1212 title,
1213 format!("The `aws` command is not available: {error}"),
1214 format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
1215 );
1216 }
1217 Ok(output) if output.status != 0 => {
1218 return DoctorCheck::fixable(
1219 check_id,
1220 title,
1221 format!(
1222 "`aws --version` failed: {}",
1223 String::from_utf8_lossy(&output.stderr).trim()
1224 ),
1225 format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
1226 );
1227 }
1228 Ok(_) => {}
1229 }
1230
1231 let mut identity_args = aws_global_args(profile, region);
1232 identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
1233 identity_args.extend(["--output".to_owned(), "json".to_owned()]);
1234 let identity =
1235 CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
1236 match executor.execute(&identity) {
1237 Err(error) => {
1238 return DoctorCheck::fixable(
1239 check_id,
1240 title,
1241 format!("Could not run `aws sts get-caller-identity`: {error}"),
1242 format!(
1243 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1244 ),
1245 );
1246 }
1247 Ok(output) if output.status != 0 => {
1248 return DoctorCheck::fixable(
1249 check_id,
1250 title,
1251 format!(
1252 "AWS credentials for profile {profile_label} are not usable: {}",
1253 String::from_utf8_lossy(&output.stderr).trim()
1254 ),
1255 format!(
1256 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1257 ),
1258 );
1259 }
1260 Ok(_) => {}
1261 }
1262
1263 let by_id = launch_template.starts_with("lt-");
1266 let mut template_args = aws_global_args(profile, region);
1267 template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
1268 template_args.extend([
1269 if by_id {
1270 "--launch-template-ids".to_owned()
1271 } else {
1272 "--launch-template-names".to_owned()
1273 },
1274 launch_template.to_owned(),
1275 ]);
1276 template_args.extend(["--output".to_owned(), "json".to_owned()]);
1277 let template =
1278 CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
1279 let template_remediation = format!(
1280 "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."
1281 );
1282 match executor.execute(&template) {
1283 Err(error) => DoctorCheck::fixable(
1284 check_id,
1285 title,
1286 format!("Could not query launch template {launch_template}: {error}"),
1287 template_remediation,
1288 ),
1289 Ok(output) if output.status != 0 => DoctorCheck::fixable(
1290 check_id,
1291 title,
1292 format!(
1293 "Launch template {launch_template} was not found in {region}: {}",
1294 String::from_utf8_lossy(&output.stderr).trim()
1295 ),
1296 template_remediation,
1297 ),
1298 Ok(_) => DoctorCheck::ready(
1299 check_id,
1300 title,
1301 format!(
1302 "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
1303 ),
1304 ),
1305 }
1306}
1307
1308fn worker_binary_checks(config: Option<&HelConfig>) -> Vec<DoctorCheck> {
1309 let Some(config) = config else {
1310 return vec![DoctorCheck::fixable(
1311 "worker.containers",
1312 "Container worker binary",
1313 "Worker availability cannot be checked until config.toml is valid.",
1314 "Fix config.toml, then rerun `mj doctor --json`.",
1315 )];
1316 };
1317 let containers = config
1318 .targets
1319 .iter()
1320 .filter_map(|(id, target)| match target {
1321 TargetTemplate::LocalPodman { container }
1322 | TargetTemplate::LocalDocker { container }
1323 | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
1324 TargetTemplate::SshPodman { container, .. } => {
1325 Some((id, container, Some("ssh-podman")))
1326 }
1327 TargetTemplate::SshDocker { container, .. } => {
1328 Some((id, container, Some("ssh-docker")))
1329 }
1330 _ => None,
1331 })
1332 .collect::<Vec<_>>();
1333 if containers.is_empty() {
1334 return vec![DoctorCheck::unsupported(
1335 "worker.containers",
1336 "Container worker binary",
1337 "No container target is configured.",
1338 )];
1339 }
1340 containers
1341 .into_iter()
1342 .map(|(id, container, remote_kind)| {
1343 if let Some(remote_kind) = remote_kind
1344 && container.platform.is_none()
1345 {
1346 return DoctorCheck::unsupported(
1349 format!("worker.{id}"),
1350 format!("Container worker binary for target {id}"),
1351 format!(
1352 "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
1353 ),
1354 );
1355 }
1356 worker_binary_check(id, container)
1357 })
1358 .collect()
1359}
1360
1361fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
1362 let title = format!("Container worker binary for target {id}");
1363 let arch = match container_architecture(container.platform.as_deref()) {
1364 Ok(arch) => arch,
1365 Err(reason) => {
1366 return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
1367 }
1368 };
1369 let triple = format!("{arch}-unknown-linux-musl");
1370 match worker_binary_prerequisite_for_arch(arch) {
1371 Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
1372 format!("worker.{id}"),
1373 title,
1374 format!(
1375 "{triple} worker is available from {source}: {}",
1376 path.display()
1377 ),
1378 ),
1379 Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
1380 format!("worker.{id}"),
1381 title,
1382 format!("{triple} worker will be verified and downloaded from {url} when needed."),
1383 ),
1384 Err(error) => DoctorCheck::fixable(
1385 format!("worker.{id}"),
1386 title,
1387 format!("No usable {triple} worker source: {error:#}"),
1388 format!(
1389 "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."
1390 ),
1391 ),
1392 }
1393}
1394
1395fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
1396 let candidate = platform.unwrap_or(std::env::consts::ARCH);
1397 let candidate = candidate
1398 .split('/')
1399 .rev()
1400 .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
1401 .unwrap_or(candidate);
1402 match candidate {
1403 "x86_64" | "amd64" => Ok("x86_64"),
1404 "aarch64" | "arm64" => Ok("aarch64"),
1405 other => Err(format!(
1406 "Container architecture {other:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
1407 )),
1408 }
1409}
1410
1411fn apple_container_image(config: Option<&HelConfig>) -> String {
1412 config
1413 .and_then(|config| {
1414 config.targets.values().find_map(|target| match target {
1415 TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
1416 _ => None,
1417 })
1418 })
1419 .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
1420}
1421
1422pub fn apple_container_check(
1423 platform: &ApplePlatform,
1424 executor: &impl CommandExecutor,
1425 smoke: bool,
1426 image: String,
1427) -> DoctorCheck {
1428 match platform {
1429 ApplePlatform::Linux => {
1430 return DoctorCheck::unsupported(
1431 "runtime.apple-container",
1432 "Apple container runtime",
1433 "macOS only",
1434 );
1435 }
1436 ApplePlatform::Other(current) => {
1437 return DoctorCheck::unsupported(
1438 "runtime.apple-container",
1439 "Apple container runtime",
1440 format!("macOS only (current platform: {current})"),
1441 );
1442 }
1443 ApplePlatform::Macos {
1444 architecture,
1445 major_version,
1446 } if architecture != "aarch64" && architecture != "arm64" => {
1447 return DoctorCheck::unsupported(
1448 "runtime.apple-container",
1449 "Apple container runtime",
1450 "Apple container requires Apple silicon; Intel Macs are unsupported.",
1451 );
1452 }
1453 ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
1454 return DoctorCheck::unsupported(
1455 "runtime.apple-container",
1456 "Apple container runtime",
1457 format!("Apple container requires macOS 26 or newer (found {major_version})."),
1458 );
1459 }
1460 ApplePlatform::Macos { .. } => {}
1461 }
1462
1463 let daemon = apple_container_daemon_check(executor);
1464 if daemon.status != CheckStatus::Ready {
1465 return daemon;
1466 }
1467
1468 if !smoke {
1469 return DoctorCheck::fixable(
1470 "runtime.apple-container",
1471 "Apple container runtime",
1472 "The daemon is running, but the required disposable smoke test was not requested.",
1473 "Run `mj doctor --json --smoke`.",
1474 );
1475 }
1476
1477 let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
1478 image,
1479 pull_policy: Default::default(),
1480 extra_run_args: vec![],
1481 workspace_storage: Default::default(),
1482 });
1483 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1484 Ok(()) => DoctorCheck::ready(
1485 "runtime.apple-container",
1486 "Apple container runtime",
1487 "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
1488 ),
1489 Err(error) => DoctorCheck::fixable(
1490 "runtime.apple-container",
1491 "Apple container runtime",
1492 format!("Disposable run/exec/remove smoke test failed: {error:#}"),
1493 "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
1494 ),
1495 }
1496}
1497
1498pub fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
1505 let installed =
1506 CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
1507 match executor.execute(&installed) {
1508 Err(error) => {
1509 return DoctorCheck::fixable(
1510 "runtime.apple-container",
1511 "Apple container runtime",
1512 format!("The `container` command is not available: {error}"),
1513 format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
1514 );
1515 }
1516 Ok(output) if output.status != 0 => {
1517 return DoctorCheck::fixable(
1518 "runtime.apple-container",
1519 "Apple container runtime",
1520 format!(
1521 "The installed `container --version` command failed: {}",
1522 String::from_utf8_lossy(&output.stderr).trim()
1523 ),
1524 format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
1525 );
1526 }
1527 Ok(_) => {}
1528 }
1529
1530 let status =
1531 CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
1532 match executor.execute(&status) {
1533 Ok(output) if output.status == 0 => DoctorCheck::ready(
1534 "runtime.apple-container",
1535 "Apple container runtime",
1536 "Installed, and the Apple container daemon is running.",
1537 ),
1538 Ok(output) => DoctorCheck::fixable(
1539 "runtime.apple-container",
1540 "Apple container runtime",
1541 format!(
1542 "The Apple container daemon is stopped: {}",
1543 String::from_utf8_lossy(&output.stderr).trim()
1544 ),
1545 "Run `container system start`.",
1546 ),
1547 Err(error) => DoctorCheck::fixable(
1548 "runtime.apple-container",
1549 "Apple container runtime",
1550 format!("Could not query the Apple container daemon: {error}"),
1551 "Run `container system start`.",
1552 ),
1553 }
1554}
1555
1556pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
1557 if cfg!(target_os = "linux") {
1558 return ApplePlatform::Linux;
1559 }
1560 if !cfg!(target_os = "macos") {
1561 return ApplePlatform::Other(std::env::consts::OS.into());
1562 }
1563 let major_version = executor
1564 .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
1565 .ok()
1566 .filter(|output| output.status == 0)
1567 .and_then(|output| {
1568 String::from_utf8(output.stdout)
1569 .ok()
1570 .and_then(|value| value.trim().split('.').next()?.parse().ok())
1571 })
1572 .unwrap_or(0);
1573 ApplePlatform::Macos {
1574 architecture: std::env::consts::ARCH.into(),
1575 major_version,
1576 }
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581 use std::cell::RefCell;
1582 use std::path::PathBuf;
1583
1584 use anyhow::anyhow;
1585
1586 use super::*;
1587 use hel::hel_targets::CommandOutput;
1588
1589 struct FakeExecutor {
1590 commands: RefCell<Vec<CommandSpec>>,
1591 responses: RefCell<Vec<Result<CommandOutput>>>,
1592 }
1593
1594 impl FakeExecutor {
1595 fn new(responses: impl IntoIterator<Item = Result<CommandOutput>>) -> Self {
1596 Self {
1597 commands: RefCell::new(vec![]),
1598 responses: RefCell::new(responses.into_iter().collect()),
1599 }
1600 }
1601 }
1602
1603 impl CommandExecutor for FakeExecutor {
1604 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1605 self.commands.borrow_mut().push(command.clone());
1606 self.responses.borrow_mut().remove(0)
1607 }
1608 }
1609
1610 fn output(stdout: impl AsRef<[u8]>) -> CommandOutput {
1611 CommandOutput {
1612 status: 0,
1613 stdout: stdout.as_ref().to_vec(),
1614 stderr: vec![],
1615 }
1616 }
1617
1618 fn failed(stderr: impl AsRef<[u8]>) -> CommandOutput {
1619 CommandOutput {
1620 status: 1,
1621 stdout: vec![],
1622 stderr: stderr.as_ref().to_vec(),
1623 }
1624 }
1625
1626 fn reachable_then(
1629 responses: impl IntoIterator<Item = Result<CommandOutput>>,
1630 ) -> Vec<Result<CommandOutput>> {
1631 let mut all = vec![Ok(output(b""))];
1632 all.extend(responses);
1633 all
1634 }
1635
1636 fn passing_ssh_podman_probes() -> Vec<Result<CommandOutput>> {
1637 let mut responses = passing_podman_probes();
1638 responses.push(Ok(output(b"yes\n")));
1639 responses
1640 }
1641
1642 fn passing_podman_probes() -> Vec<Result<CommandOutput>> {
1643 vec![
1644 Ok(output(b"podman version 5.4.2\n")),
1645 Ok(output(b"true\n")),
1646 Ok(output(
1647 b" 0 1000 1\n 1 100000 65536\n",
1648 )),
1649 ]
1650 }
1651
1652 fn container(image: &str) -> ContainerTemplate {
1653 ContainerTemplate {
1654 image: image.to_owned(),
1655 pull_policy: Default::default(),
1656 platform: None,
1657 cpus: None,
1658 memory: None,
1659 environment: std::collections::BTreeMap::new(),
1660 workspace_storage: Default::default(),
1661 }
1662 }
1663
1664 fn ssh_connection() -> hel::hel_config::SshConnection {
1665 hel::hel_config::SshConnection {
1666 host: "example.test".into(),
1667 user: Some("dev".into()),
1668 identity_file: None,
1669 extra_args: vec![],
1670 }
1671 }
1672
1673 #[test]
1674 fn doctor_reports_a_config_owned_by_a_newer_hel_as_read_only() {
1675 let directory = tempfile::tempdir().unwrap();
1676 let path = directory.path().join("config.toml");
1677 std::fs::write(
1678 &path,
1679 format!(
1680 "version = {}\n\n[targets.localhost]\nkind = \"local-bare\"\n",
1681 hel::hel_config::CONFIG_VERSION + 1
1682 ),
1683 )
1684 .unwrap();
1685
1686 let (config, checks) = configuration_checks(&path);
1687
1688 assert!(config.is_some());
1689 let check = checks.iter().find(|check| check.id == "config").unwrap();
1690 assert_eq!(check.status, CheckStatus::Warning);
1691 assert!(check.detail.contains("read-only"), "{}", check.detail);
1692 }
1693
1694 fn config_with(targets: impl IntoIterator<Item = (&'static str, TargetTemplate)>) -> HelConfig {
1695 HelConfig {
1696 targets: targets
1697 .into_iter()
1698 .map(|(id, target)| (id.to_owned(), target))
1699 .collect(),
1700 ..HelConfig::default()
1701 }
1702 }
1703
1704 fn runtime_ssh() -> RuntimeSshTarget {
1705 backend_ssh(&ssh_connection())
1706 }
1707
1708 #[test]
1709 fn podman_check_is_unsupported_without_a_valid_config() {
1710 let executor = FakeExecutor::new([]);
1711
1712 let check = podman_check(None, &executor);
1713
1714 assert_eq!(check.status, CheckStatus::Unsupported);
1715 assert_eq!(
1716 check.detail,
1717 "Podman prerequisites cannot be evaluated until config.toml is valid."
1718 );
1719 assert!(executor.commands.borrow().is_empty());
1720 }
1721
1722 #[test]
1723 fn podman_check_is_unsupported_without_a_local_podman_target() {
1724 let executor = FakeExecutor::new([]);
1725 let config = config_with([(
1726 "apple",
1727 TargetTemplate::AppleContainer {
1728 container: container("ubuntu:24.04"),
1729 },
1730 )]);
1731
1732 let check = podman_check(Some(&config), &executor);
1733
1734 assert_eq!(check.status, CheckStatus::Unsupported);
1735 assert_eq!(check.detail, "No local-podman target is configured.");
1736 assert!(executor.commands.borrow().is_empty());
1737 }
1738
1739 #[test]
1740 fn podman_check_probes_the_host_when_a_local_podman_target_exists() {
1741 let executor = FakeExecutor::new(passing_podman_probes());
1742 let config = config_with([(
1743 "podman",
1744 TargetTemplate::LocalPodman {
1745 container: container("ubuntu:24.04"),
1746 },
1747 )]);
1748
1749 let check = podman_check(Some(&config), &executor);
1750
1751 assert_eq!(check.status, CheckStatus::Ready);
1752 assert!(check.detail.contains("Podman 5.4.2"));
1753 assert_eq!(executor.commands.borrow().len(), 3);
1754 }
1755
1756 #[test]
1757 fn podman_check_is_fixable_with_an_upgrade_remediation_for_an_old_runtime() {
1758 let executor = FakeExecutor::new(reachable_then([Ok(output(b"podman version 3.4.7\n"))]));
1759 let config = config_with([(
1760 "podman",
1761 TargetTemplate::LocalPodman {
1762 container: container("ubuntu:24.04"),
1763 },
1764 )]);
1765
1766 let check = podman_check(Some(&config), &executor);
1767
1768 assert_eq!(check.status, CheckStatus::Fixable);
1769 assert!(
1770 check
1771 .remediation
1772 .as_deref()
1773 .unwrap()
1774 .contains("Upgrade Podman")
1775 );
1776 }
1777
1778 #[test]
1779 fn podman_image_check_is_ready_when_the_image_is_present() {
1780 let executor = FakeExecutor::new([Ok(output(b""))]);
1781
1782 let check =
1783 podman_image_check("podman", "localhost/hel/agent-dev:latest", &executor, false);
1784
1785 assert_eq!(check.id, "runtime.podman.image.podman");
1786 assert_eq!(check.title, "Podman image for target podman");
1787 assert_eq!(check.status, CheckStatus::Ready);
1788 assert_eq!(
1789 executor.commands.borrow()[0].args,
1790 vec!["image", "exists", "localhost/hel/agent-dev:latest"]
1791 );
1792 }
1793
1794 #[test]
1795 fn podman_image_check_is_fixable_with_a_pull_remediation_when_the_image_is_missing() {
1796 let executor = FakeExecutor::new([Ok(failed(b""))]);
1797
1798 let check = podman_image_check("podman", "ghcr.io/example/dev:1", &executor, false);
1799
1800 assert_eq!(check.status, CheckStatus::Fixable);
1801 assert!(
1802 check
1803 .detail
1804 .contains("is not present in local Podman storage")
1805 );
1806 assert_eq!(
1807 check.remediation.as_deref(),
1808 Some(
1809 "Pull it with `podman pull ghcr.io/example/dev:1`, build it from containers/Containerfile.agent-dev, or run `mj doctor --json --smoke` to verify the full pull-and-run path."
1810 )
1811 );
1812 }
1813
1814 #[test]
1815 fn podman_image_check_smoke_runs_a_disposable_container() {
1816 let executor = FakeExecutor::new([
1817 Ok(output(b"created\n")),
1818 Ok(output(b"ok\n")),
1819 Ok(output(b"removed\n")),
1820 ]);
1821
1822 let check = podman_image_check("podman", "ubuntu:24.04", &executor, true);
1823
1824 assert_eq!(check.status, CheckStatus::Ready);
1825 let commands = executor.commands.borrow();
1826 assert_eq!(commands.len(), 3);
1827 assert!(commands.iter().all(|command| command.program == "podman"));
1828 assert_eq!(commands[0].args[0], "run");
1829 assert_eq!(commands[1].args[0], "exec");
1830 assert_eq!(commands[2].args[0], "rm");
1831 }
1832
1833 #[test]
1834 fn image_checks_are_skipped_when_the_host_podman_preflight_fails() {
1835 let executor = FakeExecutor::new(reachable_then([Ok(output(b"podman version 3.4.7\n"))]));
1836 let config = config_with([(
1837 "podman",
1838 TargetTemplate::LocalPodman {
1839 container: container("ubuntu:24.04"),
1840 },
1841 )]);
1842
1843 let checks = podman_checks(Some(&config), &executor, false);
1844
1845 assert_eq!(checks.len(), 1);
1846 assert_eq!(checks[0].id, "runtime.podman");
1847 }
1848
1849 #[test]
1850 fn image_checks_follow_a_passing_preflight_for_each_local_podman_target() {
1851 let mut responses = passing_podman_probes();
1852 responses.push(Ok(output(b"")));
1853 responses.push(Ok(failed(b"")));
1854 let executor = FakeExecutor::new(responses);
1855 let config = config_with([
1856 (
1857 "alpha",
1858 TargetTemplate::LocalPodman {
1859 container: container("ubuntu:24.04"),
1860 },
1861 ),
1862 (
1863 "beta",
1864 TargetTemplate::LocalPodman {
1865 container: container("ghcr.io/example/dev:1"),
1866 },
1867 ),
1868 ]);
1869
1870 let checks = podman_checks(Some(&config), &executor, false);
1871
1872 assert_eq!(
1873 checks
1874 .iter()
1875 .map(|check| check.id.as_str())
1876 .collect::<Vec<_>>(),
1877 vec![
1878 "runtime.podman",
1879 "runtime.podman.image.alpha",
1880 "runtime.podman.image.beta"
1881 ]
1882 );
1883 assert_eq!(checks[1].status, CheckStatus::Ready);
1884 assert_eq!(checks[2].status, CheckStatus::Fixable);
1885 }
1886
1887 #[test]
1888 fn docker_checks_probe_the_daemon_then_the_configured_image() {
1889 let executor = FakeExecutor::new([
1890 Ok(output(b"29.0.1 linux\n")),
1891 Ok(output(b"image metadata\n")),
1892 ]);
1893 let config = config_with([(
1894 "docker",
1895 TargetTemplate::LocalDocker {
1896 container: container("ghcr.io/example/dev:1"),
1897 },
1898 )]);
1899
1900 let checks = docker_checks(Some(&config), &executor, false);
1901
1902 assert_eq!(
1903 checks
1904 .iter()
1905 .map(|check| check.id.as_str())
1906 .collect::<Vec<_>>(),
1907 vec!["runtime.docker", "runtime.docker.image.docker"]
1908 );
1909 assert!(
1910 checks
1911 .iter()
1912 .all(|check| check.status == CheckStatus::Ready)
1913 );
1914 let commands = executor.commands.borrow();
1915 assert_eq!(commands[0].program, "docker");
1916 assert_eq!(
1917 commands[0].args,
1918 ["version", "--format", "{{.Server.Version}} {{.Server.Os}}"]
1919 );
1920 assert_eq!(
1921 commands[1].args,
1922 ["image", "inspect", "ghcr.io/example/dev:1"]
1923 );
1924 }
1925
1926 #[test]
1927 fn docker_image_smoke_uses_managed_overlay_run_exec_and_cleanup() {
1928 let executor = FakeExecutor::new([
1929 Ok(output(b"created\n")),
1930 Ok(output(b"ok\n")),
1931 Ok(output(b"removed\n")),
1932 ]);
1933
1934 let check = docker_image_check("docker", "ubuntu:24.04", &executor, true);
1935
1936 assert_eq!(check.status, CheckStatus::Ready);
1937 let commands = executor.commands.borrow();
1938 assert_eq!(commands.len(), 3);
1939 assert_eq!(commands[0].program, "sh");
1940 assert!(commands[0].args[1].contains("docker volume create"));
1941 assert!(commands[0].args.contains(&"--pull=missing".to_owned()));
1942 assert_eq!(commands[1].program, "docker");
1943 assert_eq!(commands[1].args[0], "exec");
1944 assert_eq!(commands[2].program, "sh");
1945 assert!(commands[2].args[1].contains("docker rm --force"));
1946 assert!(commands[2].args[1].contains("docker volume rm --force"));
1947 }
1948
1949 #[test]
1950 fn ssh_podman_check_is_ready_after_ssh_wrapped_probes_without_smoke() {
1951 let executor = FakeExecutor::new(reachable_then(passing_ssh_podman_probes()));
1952
1953 let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
1954
1955 assert_eq!(check.id, "runtime.ssh-podman.remote");
1956 assert_eq!(check.title, "Remote Podman for target remote");
1957 assert_eq!(check.status, CheckStatus::Ready);
1958 assert!(check.detail.contains("Remote rootless Podman 5.4.2"));
1959 assert!(check.detail.contains("dev@example.test"));
1960 let commands = executor.commands.borrow();
1961 assert_eq!(commands.len(), 5);
1962 assert_eq!(commands[0].args.last().unwrap(), "'true'");
1963 for command in commands.iter().skip(1) {
1964 assert_eq!(command.program, "ssh");
1965 assert!(command.args.contains(&"dev@example.test".to_owned()));
1966 }
1967 assert!(
1968 commands[4]
1969 .args
1970 .last()
1971 .unwrap()
1972 .contains("'loginctl show-user")
1973 );
1974 }
1975
1976 #[test]
1977 fn ssh_podman_check_warns_when_remote_user_lingering_is_disabled() {
1978 let mut responses = passing_podman_probes();
1979 responses.push(Ok(output(b"no\n")));
1980 let executor = FakeExecutor::new(reachable_then(responses));
1981
1982 let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
1983
1984 assert_eq!(check.status, CheckStatus::Warning);
1985 assert!(all_ready(std::slice::from_ref(&check)));
1986 assert!(check.detail.contains("Podman 5.4.2 is available"));
1987 assert!(check.detail.contains("last SSH connection closes"));
1988 assert!(
1989 check
1990 .remediation
1991 .as_deref()
1992 .unwrap()
1993 .contains("sudo loginctl enable-linger")
1994 );
1995 }
1996
1997 #[test]
1998 fn ssh_podman_check_explains_when_durability_cannot_be_verified() {
1999 let mut responses = passing_podman_probes();
2000 responses.push(Ok(CommandOutput {
2001 status: 127,
2002 stdout: vec![],
2003 stderr: b"sh: loginctl: not found\n".to_vec(),
2004 }));
2005 let executor = FakeExecutor::new(reachable_then(responses));
2006
2007 let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
2008
2009 assert_eq!(check.status, CheckStatus::Warning);
2010 assert!(all_ready(std::slice::from_ref(&check)));
2011 assert!(check.detail.contains("durability check is unavailable"));
2012 assert!(check.detail.contains("may not use systemd"));
2013 assert!(check.detail.contains("cannot verify"));
2014 let remediation = check.remediation.as_deref().unwrap();
2015 assert!(remediation.contains("service manager"));
2016 assert!(!remediation.contains("sudo loginctl enable-linger"));
2017 }
2018
2019 #[test]
2020 fn ssh_podman_check_failure_scopes_the_remediation_to_the_remote_host() {
2021 let executor = FakeExecutor::new(reachable_then([Ok(output(b"podman version 3.4.7\n"))]));
2022
2023 let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
2024
2025 assert_eq!(check.status, CheckStatus::Fixable);
2026 assert!(check.detail.contains("dev@example.test"));
2027 assert!(
2028 check
2029 .remediation
2030 .as_deref()
2031 .unwrap()
2032 .starts_with("On dev@example.test: Upgrade Podman")
2033 );
2034 }
2035
2036 #[test]
2037 fn ssh_podman_check_reports_the_shared_ssh_remediation_before_probing_podman() {
2038 let executor = FakeExecutor::new([Ok(failed(
2039 b"dev@example.test: Permission denied (publickey).",
2040 ))]);
2041
2042 let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
2043
2044 assert_eq!(check.status, CheckStatus::Fixable);
2045 assert_eq!(
2046 check.remediation.as_deref(),
2047 Some("Install your public key on the host with `ssh-copy-id dev@example.test`.")
2048 );
2049 assert_eq!(executor.commands.borrow().len(), 1);
2051 }
2052
2053 #[test]
2054 fn ssh_podman_check_smoke_runs_an_ssh_wrapped_disposable_container() {
2055 let mut responses = passing_ssh_podman_probes();
2056 responses.extend([
2057 Ok(output(b"created\n")),
2058 Ok(output(b"ok\n")),
2059 Ok(output(b"removed\n")),
2060 ]);
2061 let executor = FakeExecutor::new(reachable_then(responses));
2062
2063 let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, true);
2064
2065 assert_eq!(check.status, CheckStatus::Ready);
2066 let commands = executor.commands.borrow();
2067 assert_eq!(commands.len(), 8);
2068 for command in commands.iter().skip(5) {
2069 assert_eq!(command.program, "ssh");
2070 assert!(command.args.contains(&"dev@example.test".to_owned()));
2071 }
2072 assert!(commands[5].args.last().unwrap().contains("'run' '--init'"));
2073 assert!(commands[6].args.last().unwrap().ends_with("'true'"));
2074 assert!(commands[7].args.last().unwrap().contains("'rm' '--force'"));
2075 }
2076
2077 #[test]
2078 fn ssh_docker_check_probes_connectivity_daemon_and_remote_image() {
2079 let executor = FakeExecutor::new([
2080 Ok(output(b"")),
2081 Ok(output(b"29.0.1 linux\n")),
2082 Ok(output(b"image metadata\n")),
2083 ]);
2084 let check = ssh_docker_check(
2085 "remote",
2086 &runtime_ssh(),
2087 "ghcr.io/example/dev:1",
2088 &executor,
2089 false,
2090 );
2091
2092 assert_eq!(check.id, "runtime.ssh-docker.remote");
2093 assert_eq!(check.title, "Remote Docker for target remote");
2094 assert_eq!(check.status, CheckStatus::Ready);
2095 assert!(check.detail.contains("Remote Docker 29.0.1"));
2096 assert!(
2097 check
2098 .detail
2099 .contains("image ghcr.io/example/dev:1 is present")
2100 );
2101 let commands = executor.commands.borrow();
2102 assert_eq!(commands.len(), 3);
2103 assert!(commands.iter().all(|command| command.program == "ssh"));
2104 assert!(commands[0].args.last().unwrap().contains("'true'"));
2105 assert!(
2106 commands[1]
2107 .args
2108 .last()
2109 .unwrap()
2110 .contains("'docker' 'version'")
2111 );
2112 assert!(
2113 commands[2]
2114 .args
2115 .last()
2116 .unwrap()
2117 .contains("'docker' 'image' 'inspect'")
2118 );
2119 }
2120
2121 #[test]
2122 fn ssh_docker_check_smoke_runs_overlay_on_the_remote_host() {
2123 let executor = FakeExecutor::new([
2124 Ok(output(b"")),
2125 Ok(output(b"29.0.1 linux\n")),
2126 Ok(output(b"/tmp/mj-docker-overlay-smoke.fixture\n")),
2127 Ok(output(b"created\n")),
2128 Ok(output(b"ok\n")),
2129 Ok(output(b"verified\n")),
2130 Ok(output(b"removed\n")),
2131 Ok(output(b"removed\n")),
2132 ]);
2133 let check = ssh_docker_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, true);
2134
2135 assert_eq!(check.status, CheckStatus::Ready);
2136 assert!(check.detail.contains("remote OverlayFS attachment"));
2137 let commands = executor.commands.borrow();
2138 assert_eq!(commands.len(), 8);
2139 assert!(commands.iter().all(|command| command.program == "ssh"));
2140 assert!(commands[2].args.last().unwrap().contains("mktemp"));
2141 assert!(commands[3].args.last().unwrap().contains("'docker' 'run'"));
2142 assert!(commands[4].args.last().unwrap().contains("'docker' 'exec'"));
2143 assert!(commands[5].args.last().unwrap().contains("original.txt"));
2144 assert!(commands[6].args.last().unwrap().contains("docker rm"));
2145 assert!(commands[7].args.last().unwrap().contains("'rm' '-rf'"));
2146 }
2147
2148 #[test]
2149 fn ssh_podman_checks_are_skipped_without_a_valid_config() {
2150 let executor = FakeExecutor::new([]);
2151
2152 assert!(ssh_podman_checks(None, &executor, false).is_empty());
2153 assert!(executor.commands.borrow().is_empty());
2154 }
2155
2156 fn ssh_bare_config() -> HelConfig {
2157 config_with([(
2158 "builder",
2159 TargetTemplate::SshBare {
2160 ssh: hel::hel_config::SshConnection {
2161 host: "example.test".into(),
2162 user: Some("dev".into()),
2163 identity_file: Some(PathBuf::from("/home/dev/.ssh/id_ed25519")),
2164 extra_args: vec![],
2165 },
2166 permissions: hel::hel_config::PermissionMode::Yolo,
2167 workspace_prefix: PathBuf::from(".local/share/hel/workspaces"),
2168 },
2169 )])
2170 }
2171
2172 #[test]
2173 fn ssh_bare_check_is_ready_when_the_batch_mode_probe_succeeds() {
2174 let executor = FakeExecutor::new([Ok(output(b""))]);
2175
2176 let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2177
2178 assert_eq!(checks.len(), 1);
2179 assert_eq!(checks[0].id, "runtime.ssh-bare.builder");
2180 assert_eq!(checks[0].status, CheckStatus::Ready);
2181 let command = &executor.commands.borrow()[0];
2182 assert_eq!(command.program, "ssh");
2183 assert_eq!(
2184 command.args[..4],
2185 ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes"]
2186 );
2187 assert!(command.args.contains(&"dev@example.test".to_owned()));
2188 assert_eq!(command.args.last().unwrap(), "'true'");
2189 }
2190
2191 #[test]
2192 fn ssh_bare_check_permission_denied_recommends_ssh_copy_id_with_the_identity() {
2193 let executor = FakeExecutor::new([Ok(failed(
2194 b"dev@example.test: Permission denied (publickey).",
2195 ))]);
2196
2197 let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2198
2199 assert_eq!(checks[0].status, CheckStatus::Fixable);
2200 assert_eq!(
2201 checks[0].remediation.as_deref(),
2202 Some(
2203 "Install your public key on the host with `ssh-copy-id -i /home/dev/.ssh/id_ed25519.pub dev@example.test`."
2204 )
2205 );
2206 }
2207
2208 #[test]
2209 fn ssh_bare_check_host_key_failure_recommends_keyscan_with_a_fingerprint_caution() {
2210 let executor = FakeExecutor::new([Ok(failed(
2211 b"Host key verification failed.\nNo ECDSA host key is known for example.test",
2212 ))]);
2213
2214 let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2215
2216 assert_eq!(checks[0].status, CheckStatus::Fixable);
2217 let remediation = checks[0].remediation.as_deref().unwrap();
2218 assert!(
2219 remediation.contains("ssh-keyscan -H example.test >> ~/.ssh/known_hosts"),
2220 "{remediation}"
2221 );
2222 assert!(
2223 remediation.contains("Verify the fingerprint"),
2224 "{remediation}"
2225 );
2226 }
2227
2228 #[test]
2229 fn ssh_bare_check_without_an_ssh_client_recommends_installing_openssh() {
2230 let executor = FakeExecutor::new([Err(anyhow!("No such file or directory (os error 2)"))]);
2231
2232 let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2233
2234 assert_eq!(checks[0].status, CheckStatus::Fixable);
2235 assert_eq!(
2236 checks[0].remediation.as_deref(),
2237 Some(SSH_MISSING_REMEDIATION)
2238 );
2239 }
2240
2241 #[test]
2242 fn ssh_bare_check_falls_back_to_quoting_an_unrecognized_ssh_failure() {
2243 let executor = FakeExecutor::new([Ok(failed(
2244 b"ssh: connect to host example.test port 22: Connection timed out",
2245 ))]);
2246
2247 let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2248
2249 let remediation = checks[0].remediation.as_deref().unwrap();
2250 assert!(
2251 remediation.contains("Connection timed out"),
2252 "{remediation}"
2253 );
2254 assert!(
2255 remediation.contains("Run `ssh dev@example.test true` by hand"),
2256 "{remediation}"
2257 );
2258 }
2259
2260 #[test]
2261 fn ssh_bare_checks_are_skipped_without_a_valid_config() {
2262 let executor = FakeExecutor::new([]);
2263
2264 assert!(ssh_bare_checks(None, &executor).is_empty());
2265 assert!(executor.commands.borrow().is_empty());
2266 }
2267
2268 #[test]
2269 fn worker_check_for_an_ssh_podman_target_without_platform_is_unsupported() {
2270 let config = config_with([(
2271 "remote",
2272 TargetTemplate::SshPodman {
2273 ssh: ssh_connection(),
2274 container: container("ubuntu:24.04"),
2275 },
2276 )]);
2277
2278 let checks = worker_binary_checks(Some(&config));
2279
2280 assert_eq!(checks.len(), 1);
2281 assert_eq!(checks[0].id, "worker.remote");
2282 assert_eq!(checks[0].status, CheckStatus::Unsupported);
2283 assert_eq!(
2284 checks[0].detail,
2285 "Set `platform` on this ssh-podman target to check its worker binary; the remote architecture is unknown until provisioning."
2286 );
2287 }
2288
2289 #[test]
2290 fn worker_check_for_an_ssh_podman_target_with_platform_uses_the_normal_check() {
2291 let mut remote = container("ubuntu:24.04");
2292 remote.platform = Some("linux/amd64".into());
2293 let config = config_with([(
2294 "remote",
2295 TargetTemplate::SshPodman {
2296 ssh: ssh_connection(),
2297 container: remote,
2298 },
2299 )]);
2300
2301 let checks = worker_binary_checks(Some(&config));
2302
2303 assert_eq!(checks.len(), 1);
2304 assert_eq!(checks[0].id, "worker.remote");
2305 assert_ne!(checks[0].status, CheckStatus::Unsupported);
2306 assert!(checks[0].detail.contains("x86_64-unknown-linux-musl"));
2307 }
2308
2309 #[test]
2310 fn worker_check_for_an_ssh_docker_target_hints_at_remote_architecture() {
2311 let config = config_with([(
2312 "remote",
2313 TargetTemplate::SshDocker {
2314 ssh: ssh_connection(),
2315 container: container("ubuntu:24.04"),
2316 },
2317 )]);
2318
2319 let checks = worker_binary_checks(Some(&config));
2320
2321 assert_eq!(checks.len(), 1);
2322 assert_eq!(checks[0].status, CheckStatus::Unsupported);
2323 assert_eq!(
2324 checks[0].detail,
2325 "Set `platform` on this ssh-docker target to check its worker binary; the remote architecture is unknown until provisioning."
2326 );
2327 }
2328
2329 #[test]
2330 fn an_unauthenticated_profile_is_fixed_by_hel_login_for_that_profile() {
2331 let directory = tempfile::tempdir().unwrap();
2332 let home = directory.path().join("claude-home");
2333 std::fs::create_dir_all(&home).unwrap();
2334 let profile = HarnessProfile {
2335 enabled: true,
2336 kind: HarnessKind::Claude,
2337 home,
2338 environment: std::collections::BTreeMap::new(),
2339 context_window_bytes: None,
2340 };
2341 let config = HelConfig {
2342 profiles: [("work".to_owned(), profile.clone())].into_iter().collect(),
2343 ..HelConfig::default()
2344 };
2345
2346 let executor = FakeExecutor::new([Ok(output(br#"{"loggedIn":false}"#))]);
2347 let checks = harness_checks(Some(&config), &executor);
2348
2349 assert_eq!(checks.len(), 1);
2350 assert_eq!(checks[0].status, CheckStatus::Fixable);
2351 let remediation = checks[0].remediation.as_deref().unwrap();
2352 assert!(
2353 remediation.contains("mj login --profile work"),
2354 "{remediation}"
2355 );
2356 let (program, arguments) = login_command(&profile);
2359 assert!(
2360 remediation.contains(&format!("`{program} {}`", arguments.join(" "))),
2361 "{remediation}"
2362 );
2363 }
2364
2365 #[test]
2366 fn doctor_reports_disabled_profiles_without_probing_them() {
2367 let profile = HarnessProfile {
2368 enabled: false,
2369 kind: HarnessKind::Claude,
2370 home: PathBuf::from("/missing/disabled-profile"),
2371 environment: std::collections::BTreeMap::new(),
2372 context_window_bytes: None,
2373 };
2374 let config = HelConfig {
2375 profiles: [("retired".to_owned(), profile)].into_iter().collect(),
2376 ..HelConfig::default()
2377 };
2378 let executor = FakeExecutor::new([]);
2379
2380 let checks = harness_checks(Some(&config), &executor);
2381
2382 assert_eq!(checks.len(), 1);
2383 assert_eq!(checks[0].status, CheckStatus::Ready);
2384 assert!(checks[0].detail.contains("disabled"));
2385 assert!(executor.commands.borrow().is_empty());
2386 }
2387
2388 #[test]
2389 fn harness_discovery_reports_each_authentication_state() {
2390 let check = harness_discovery_check_from(
2391 &[
2392 DiscoveredHome {
2393 kind: HarnessKind::Codex,
2394 path: "/agents/codex".into(),
2395 authenticated: true,
2396 },
2397 DiscoveredHome {
2398 kind: HarnessKind::Kimi,
2399 path: "/agents/kimi".into(),
2400 authenticated: false,
2401 },
2402 ],
2403 true,
2404 );
2405
2406 assert_eq!(check.status, CheckStatus::Ready);
2407 assert!(
2408 check
2409 .detail
2410 .contains("Codex at /agents/codex (authenticated)")
2411 );
2412 assert!(
2413 check
2414 .detail
2415 .contains("Kimi Code at /agents/kimi (not authenticated)")
2416 );
2417 }
2418
2419 #[test]
2420 fn missing_harness_homes_are_fixable_without_a_configured_profile() {
2421 let check = harness_discovery_check_from(&[], false);
2422
2423 assert_eq!(check.status, CheckStatus::Fixable);
2424 assert_eq!(
2425 check.remediation.as_deref(),
2426 Some("Install and sign in to a supported harness, then run `mj setup`.")
2427 );
2428 }
2429
2430 #[test]
2431 fn apple_container_is_unsupported_on_intel_macs() {
2432 let executor = FakeExecutor::new([]);
2433
2434 let check = apple_container_check(
2435 &ApplePlatform::Macos {
2436 architecture: "x86_64".into(),
2437 major_version: 26,
2438 },
2439 &executor,
2440 false,
2441 DEFAULT_CONTAINER_IMAGE.into(),
2442 );
2443
2444 assert_eq!(check.status, CheckStatus::Unsupported);
2445 assert!(check.detail.contains("Intel Macs"));
2446 assert!(executor.commands.borrow().is_empty());
2447 }
2448
2449 #[test]
2450 fn apple_container_is_unsupported_before_macos_26() {
2451 let executor = FakeExecutor::new([]);
2452
2453 let check = apple_container_check(
2454 &ApplePlatform::Macos {
2455 architecture: "aarch64".into(),
2456 major_version: 25,
2457 },
2458 &executor,
2459 false,
2460 DEFAULT_CONTAINER_IMAGE.into(),
2461 );
2462
2463 assert_eq!(check.status, CheckStatus::Unsupported);
2464 assert!(check.detail.contains("macOS 26"));
2465 }
2466
2467 #[test]
2468 fn apple_container_not_installed_has_official_package_remediation() {
2469 let executor = FakeExecutor::new([Err(anyhow!("No such file or directory"))]);
2470
2471 let check = apple_container_check(
2472 &ApplePlatform::Macos {
2473 architecture: "aarch64".into(),
2474 major_version: 26,
2475 },
2476 &executor,
2477 false,
2478 DEFAULT_CONTAINER_IMAGE.into(),
2479 );
2480
2481 assert_eq!(check.status, CheckStatus::Fixable);
2482 assert_eq!(
2483 check.remediation.as_deref(),
2484 Some(
2485 format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}")
2486 .as_str()
2487 )
2488 );
2489 }
2490
2491 #[test]
2492 fn apple_container_stopped_daemon_has_start_remediation() {
2493 let executor = FakeExecutor::new([
2494 Ok(output(b"container version 1\n")),
2495 Ok(CommandOutput {
2496 status: 1,
2497 stdout: vec![],
2498 stderr: b"daemon is not running".to_vec(),
2499 }),
2500 ]);
2501
2502 let check = apple_container_check(
2503 &ApplePlatform::Macos {
2504 architecture: "aarch64".into(),
2505 major_version: 26,
2506 },
2507 &executor,
2508 false,
2509 DEFAULT_CONTAINER_IMAGE.into(),
2510 );
2511
2512 assert_eq!(check.status, CheckStatus::Fixable);
2513 assert_eq!(
2514 check.remediation.as_deref(),
2515 Some("Run `container system start`.")
2516 );
2517 }
2518
2519 #[test]
2520 fn apple_container_is_ready_only_after_the_opt_in_smoke_test() {
2521 let executor = FakeExecutor::new([
2522 Ok(output(b"container version 1\n")),
2523 Ok(output(b"running\n")),
2524 Ok(output(b"created\n")),
2525 Ok(output(b"ok\n")),
2526 Ok(output(b"removed\n")),
2527 ]);
2528
2529 let check = apple_container_check(
2530 &ApplePlatform::Macos {
2531 architecture: "aarch64".into(),
2532 major_version: 26,
2533 },
2534 &executor,
2535 true,
2536 DEFAULT_CONTAINER_IMAGE.into(),
2537 );
2538
2539 assert_eq!(check.status, CheckStatus::Ready);
2540 assert_eq!(executor.commands.borrow().len(), 5);
2541 assert_eq!(executor.commands.borrow()[2].args[0], "run");
2542 assert_eq!(executor.commands.borrow()[3].args[0], "exec");
2543 assert_eq!(executor.commands.borrow()[4].args[0], "rm");
2544 }
2545
2546 #[test]
2547 fn linux_reports_apple_container_as_macos_only() {
2548 let executor = FakeExecutor::new([]);
2549 let check = apple_container_check(
2550 &ApplePlatform::Linux,
2551 &executor,
2552 false,
2553 DEFAULT_CONTAINER_IMAGE.into(),
2554 );
2555 assert_eq!(check.status, CheckStatus::Unsupported);
2556 assert_eq!(check.detail, "macOS only");
2557 }
2558
2559 fn aws_target(launch_template: &str) -> TargetTemplate {
2560 TargetTemplate::AwsEc2 {
2561 aws_profile: Some("hel".into()),
2562 region: "us-east-1".into(),
2563 launch_template: launch_template.to_owned(),
2564 launch_template_version: None,
2565 ssh_user: "ubuntu".into(),
2566 address_source: hel::hel_config::AwsAddressSource::default(),
2567 identity_file: None,
2568 ssh_args: vec![],
2569 }
2570 }
2571
2572 #[test]
2573 fn aws_check_is_ready_after_the_cli_credential_and_launch_template_probes() {
2574 let executor = FakeExecutor::new([
2575 Ok(output(b"aws-cli/2.17.0\n")),
2576 Ok(output(b"{\"Account\":\"123456789012\"}\n")),
2577 Ok(output(b"{\"LaunchTemplates\":[{}]}\n")),
2578 ]);
2579 let config = config_with([("aws", aws_target("hel-runson"))]);
2580
2581 let checks = aws_checks(Some(&config), &executor);
2582
2583 assert_eq!(checks.len(), 1);
2584 assert_eq!(checks[0].id, "runtime.aws-ec2.aws");
2585 assert_eq!(checks[0].status, CheckStatus::Ready);
2586 let commands = executor.commands.borrow();
2587 assert!(commands.iter().all(|command| command.program == "aws"));
2588 assert_eq!(
2590 commands[2].args,
2591 vec![
2592 "--profile",
2593 "hel",
2594 "--region",
2595 "us-east-1",
2596 "ec2",
2597 "describe-launch-templates",
2598 "--launch-template-names",
2599 "hel-runson",
2600 "--output",
2601 "json"
2602 ]
2603 );
2604 }
2605
2606 #[test]
2607 fn aws_check_is_fixable_with_an_install_remediation_without_the_cli() {
2608 let executor = FakeExecutor::new([Err(anyhow!("No such file or directory"))]);
2609
2610 let check = aws_target_check("aws", None, "us-east-1", "hel-runson", &executor);
2611
2612 assert_eq!(check.status, CheckStatus::Fixable);
2613 assert!(
2614 check
2615 .remediation
2616 .as_deref()
2617 .unwrap()
2618 .contains(AWS_CLI_INSTALL_URL)
2619 );
2620 assert_eq!(executor.commands.borrow().len(), 1);
2621 }
2622
2623 #[test]
2624 fn aws_check_is_fixable_with_a_sign_in_remediation_for_expired_credentials() {
2625 let executor = FakeExecutor::new([
2626 Ok(output(b"aws-cli/2.17.0\n")),
2627 Ok(failed(b"ExpiredToken: the security token has expired")),
2628 ]);
2629
2630 let check = aws_target_check("aws", Some("hel"), "us-east-1", "hel-runson", &executor);
2631
2632 assert_eq!(check.status, CheckStatus::Fixable);
2633 assert!(check.detail.contains("ExpiredToken"));
2634 assert_eq!(
2635 check.remediation.as_deref(),
2636 Some(
2637 "Configure credentials with `aws configure --profile hel`, or sign in with `aws sso login --profile hel`."
2638 )
2639 );
2640 }
2641
2642 #[test]
2643 fn aws_check_is_fixable_when_the_launch_template_is_missing() {
2644 let executor = FakeExecutor::new([
2645 Ok(output(b"aws-cli/2.17.0\n")),
2646 Ok(output(b"{\"Account\":\"123456789012\"}\n")),
2647 Ok(failed(b"InvalidLaunchTemplateName.NotFoundException")),
2648 ]);
2649
2650 let check = aws_target_check("aws", Some("hel"), "us-east-1", "lt-0123456789", &executor);
2651
2652 assert_eq!(check.status, CheckStatus::Fixable);
2653 assert!(check.detail.contains("was not found in us-east-1"));
2654 assert_eq!(
2656 executor.commands.borrow()[2].args[6],
2657 "--launch-template-ids"
2658 );
2659 }
2660
2661 #[test]
2662 fn aws_checks_are_skipped_for_configs_without_an_aws_target() {
2663 let executor = FakeExecutor::new([]);
2664 let config = config_with([(
2665 "podman",
2666 TargetTemplate::LocalPodman {
2667 container: container("ubuntu:24.04"),
2668 },
2669 )]);
2670
2671 assert!(aws_checks(Some(&config), &executor).is_empty());
2672 assert!(aws_checks(None, &executor).is_empty());
2673 assert!(executor.commands.borrow().is_empty());
2674 }
2675
2676 #[test]
2677 fn apple_container_daemon_check_is_ready_once_the_daemon_answers() {
2678 let executor = FakeExecutor::new([
2679 Ok(output(b"container version 1\n")),
2680 Ok(output(b"running\n")),
2681 ]);
2682
2683 let check = apple_container_daemon_check(&executor);
2684
2685 assert_eq!(check.status, CheckStatus::Ready);
2686 assert_eq!(executor.commands.borrow().len(), 2);
2687 }
2688
2689 #[test]
2690 fn linux_instructions_embed_podman_postconditions_and_doctor_loop() {
2691 let instructions = setup_instructions(InstructionsPlatform::Linux);
2692 assert!(instructions.contains("mj doctor --json"));
2693 assert!(instructions.contains("mj doctor --json --smoke"));
2694 assert!(instructions.contains("podman unshare cat /proc/self/uid_map"));
2695 assert!(instructions.contains("Podman **4.0.0 or newer**"));
2696 assert!(instructions.contains("kind = \"local-docker\""));
2697 assert!(instructions.contains("--opt type=overlay"));
2698 }
2699}