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::{
11 WorkerBinaryAvailability, backend_ssh, worker_binary_prerequisite_for_arch,
12};
13use crate::setup::{
14 DiscoveredHome, discover_harness_homes_with_executor, harness_is_authenticated_with_executor,
15};
16use crate::targets::{
17 BoundedProcessExecutor, CommandExecutor, CommandSpec,
18 ContainerTemplate as RuntimeContainerTemplate, ProcessExecutor, SshTarget as RuntimeSshTarget,
19 TargetTemplate as RuntimeTargetTemplate, run_setup_smoke_test, ssh_command,
20 ssh_connectivity_probe, verify_local_docker, verify_local_podman, verify_ssh_docker,
21 verify_ssh_podman,
22};
23use mj_core::config::{
24 Config, ContainerTemplate, HarnessKind, HarnessProfile, TargetTemplate, config_path,
25};
26use mj_core::credentials::login_command;
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<&Config>,
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 crate::targets::PODMAN_DOCUMENTATION,
304 crate::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<Config>, 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 Config::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<&Config>, 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<&Config>,
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<&Config>, 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: &Config) -> 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<&Config>,
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<&Config>,
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: &Config) -> 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(config: Option<&Config>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
823 let Some(config) = config else {
824 return Vec::new();
825 };
826 config
827 .targets
828 .iter()
829 .filter_map(|(id, target)| match target {
830 TargetTemplate::SshBare { ssh, .. } => {
831 Some(ssh_bare_check(id, &backend_ssh(ssh), executor))
832 }
833 _ => None,
834 })
835 .collect()
836}
837
838fn ssh_bare_check(
839 id: &str,
840 ssh: &RuntimeSshTarget,
841 executor: &impl CommandExecutor,
842) -> DoctorCheck {
843 let check_id = format!("runtime.ssh-bare.{id}");
844 let title = format!("SSH access for target {id}");
845 match ssh_connectivity(ssh, executor) {
846 SshConnectivity::Reachable => DoctorCheck::ready(
847 check_id,
848 title,
849 format!(
850 "`ssh {} true` succeeds noninteractively from this host.",
851 ssh.destination
852 ),
853 ),
854 SshConnectivity::Failed {
855 detail,
856 remediation,
857 } => DoctorCheck::fixable(check_id, title, detail, remediation),
858 }
859}
860
861fn ssh_podman_checks(
863 config: Option<&Config>,
864 executor: &impl CommandExecutor,
865 smoke: bool,
866) -> Vec<DoctorCheck> {
867 let Some(config) = config else {
868 return Vec::new();
869 };
870 config
871 .targets
872 .iter()
873 .filter_map(|(id, target)| match target {
874 TargetTemplate::SshPodman { ssh, container, .. } => Some(ssh_podman_check(
875 id,
876 &backend_ssh(ssh),
877 &container.image,
878 executor,
879 smoke,
880 )),
881 _ => None,
882 })
883 .collect()
884}
885
886fn ssh_podman_check(
887 id: &str,
888 ssh: &RuntimeSshTarget,
889 image: &str,
890 executor: &impl CommandExecutor,
891 smoke: bool,
892) -> DoctorCheck {
893 let check_id = format!("runtime.ssh-podman.{id}");
894 let title = format!("Remote Podman for target {id}");
895 let destination = &ssh.destination;
896 if let SshConnectivity::Failed {
899 detail,
900 remediation,
901 } = ssh_connectivity(ssh, executor)
902 {
903 return DoctorCheck::fixable(check_id, title, detail, remediation);
904 }
905 let preflight = match verify_ssh_podman(ssh, executor) {
906 Ok(preflight) => preflight,
907 Err(error) => {
908 let detail = format!("{error:#}");
909 let remediation = match podman_remediation_match(&detail) {
910 Some(remediation) => format!("On {destination}: {remediation}"),
911 None => format!(
912 "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4 or newer there (see docs/PODMAN.md)."
913 ),
914 };
915 return DoctorCheck::fixable(check_id, title, detail, remediation);
916 }
917 };
918 let linger_warning = preflight.warnings.first();
919 if !smoke && let Some(warning) = linger_warning {
920 return DoctorCheck::warning(
921 check_id,
922 title,
923 format!(
924 "Remote rootless Podman {} is available via {destination}, but {}",
925 preflight.version, warning.detail
926 ),
927 &warning.remediation,
928 );
929 }
930 if !smoke {
931 return DoctorCheck::ready(
932 check_id,
933 title,
934 format!(
935 "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
936 preflight.version
937 ),
938 );
939 }
940
941 let target = RuntimeTargetTemplate::SshPodman {
942 ssh: ssh.clone(),
943 container: RuntimeContainerTemplate {
944 image: image.to_owned(),
945 pull_policy: Default::default(),
946 extra_run_args: vec![],
947 workspace_storage: Default::default(),
948 },
949 };
950 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
951 Ok(()) => match linger_warning {
952 Some(warning) => DoctorCheck::warning(
953 check_id,
954 title,
955 format!(
956 "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
957 warning.detail
958 ),
959 &warning.remediation,
960 ),
961 None => DoctorCheck::ready(
962 check_id,
963 title,
964 format!(
965 "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
966 ),
967 ),
968 },
969 Err(error) => DoctorCheck::fixable(
970 check_id,
971 title,
972 format!(
973 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
974 ),
975 format!(
976 "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
977 ),
978 ),
979 }
980}
981
982fn ssh_docker_checks(
985 config: Option<&Config>,
986 executor: &impl CommandExecutor,
987 smoke: bool,
988) -> Vec<DoctorCheck> {
989 let Some(config) = config else {
990 return Vec::new();
991 };
992 config
993 .targets
994 .iter()
995 .filter_map(|(id, target)| match target {
996 TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
997 id,
998 &backend_ssh(ssh),
999 &container.image,
1000 executor,
1001 smoke,
1002 )),
1003 _ => None,
1004 })
1005 .collect()
1006}
1007
1008fn ssh_docker_check(
1009 id: &str,
1010 ssh: &RuntimeSshTarget,
1011 image: &str,
1012 executor: &impl CommandExecutor,
1013 smoke: bool,
1014) -> DoctorCheck {
1015 let check_id = format!("runtime.ssh-docker.{id}");
1016 let title = format!("Remote Docker for target {id}");
1017 let destination = &ssh.destination;
1018 if let SshConnectivity::Failed {
1019 detail,
1020 remediation,
1021 } = ssh_connectivity(ssh, executor)
1022 {
1023 return DoctorCheck::fixable(check_id, title, detail, remediation);
1024 }
1025
1026 let preflight = match verify_ssh_docker(ssh, executor) {
1027 Ok(preflight) => preflight,
1028 Err(error) => {
1029 let detail = format!("{error:#}");
1030 return DoctorCheck::fixable(
1031 check_id,
1032 title,
1033 detail,
1034 format!(
1035 "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."
1036 ),
1037 );
1038 }
1039 };
1040
1041 if smoke {
1042 let target = RuntimeTargetTemplate::SshDocker {
1043 ssh: ssh.clone(),
1044 container: RuntimeContainerTemplate {
1045 image: image.to_owned(),
1046 pull_policy: Default::default(),
1047 extra_run_args: vec![],
1048 workspace_storage: Default::default(),
1049 },
1050 };
1051 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1052 Ok(()) => DoctorCheck::ready(
1053 check_id,
1054 title,
1055 format!(
1056 "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
1057 preflight.version
1058 ),
1059 ),
1060 Err(error) => DoctorCheck::fixable(
1061 check_id,
1062 title,
1063 format!(
1064 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1065 ),
1066 format!(
1067 "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
1068 ),
1069 ),
1070 };
1071 }
1072
1073 let image_command = ssh_command(
1074 ssh,
1075 [
1076 "docker".to_owned(),
1077 "image".to_owned(),
1078 "inspect".to_owned(),
1079 image.to_owned(),
1080 ]
1081 .to_vec(),
1082 )
1083 .purpose("check remote Docker image presence");
1084 match executor.execute(&image_command) {
1085 Ok(output) if output.status == 0 => DoctorCheck::ready(
1086 check_id,
1087 title,
1088 format!(
1089 "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
1090 preflight.version
1091 ),
1092 ),
1093 Ok(output) => DoctorCheck::fixable(
1094 check_id,
1095 title,
1096 format!(
1097 "Image {image} is not present in remote Docker storage on {destination}: {}",
1098 String::from_utf8_lossy(&output.stderr).trim()
1099 ),
1100 format!(
1101 "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
1102 ),
1103 ),
1104 Err(error) => DoctorCheck::fixable(
1105 check_id,
1106 title,
1107 format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
1108 format!(
1109 "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
1110 ),
1111 ),
1112 }
1113}
1114
1115fn doctor_smoke_id() -> String {
1117 format!(
1118 "doctor-{}-{:x}",
1119 std::process::id(),
1120 SystemTime::now()
1121 .duration_since(UNIX_EPOCH)
1122 .unwrap_or_default()
1123 .as_nanos()
1124 )
1125}
1126
1127fn podman_remediation(detail: &str) -> &'static str {
1128 podman_remediation_match(detail).unwrap_or(
1129 "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
1130 )
1131}
1132
1133fn podman_remediation_match(detail: &str) -> Option<&'static str> {
1135 if detail.contains("Podman 4.0.0") {
1136 Some(
1137 "Upgrade Podman: Debian/Ubuntu `sudo apt update && sudo apt install -y podman uidmap`; Fedora `sudo dnf install -y podman shadow-utils`.",
1138 )
1139 } else if detail.contains("podman unshare") {
1140 Some(
1141 "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.",
1142 )
1143 } else if detail.contains("Rootless") {
1144 Some(
1145 "Run mj without `sudo`; unset `CONTAINER_HOST` and select the rootless local Podman connection.",
1146 )
1147 } else {
1148 None
1149 }
1150}
1151
1152const AWS_CLI_INSTALL_URL: &str =
1153 "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
1154
1155fn aws_checks(config: Option<&Config>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1158 let Some(config) = config else {
1159 return Vec::new();
1160 };
1161 config
1162 .targets
1163 .iter()
1164 .filter_map(|(id, target)| match target {
1165 TargetTemplate::AwsEc2 {
1166 aws_profile,
1167 region,
1168 launch_template,
1169 ..
1170 } => Some(aws_target_check(
1171 id,
1172 aws_profile.as_deref(),
1173 region,
1174 launch_template,
1175 executor,
1176 )),
1177 _ => None,
1178 })
1179 .collect()
1180}
1181
1182fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
1185 vec![
1186 "--profile".to_owned(),
1187 profile.unwrap_or("default").to_owned(),
1188 "--region".to_owned(),
1189 region.to_owned(),
1190 ]
1191}
1192
1193fn aws_target_check(
1194 id: &str,
1195 profile: Option<&str>,
1196 region: &str,
1197 launch_template: &str,
1198 executor: &impl CommandExecutor,
1199) -> DoctorCheck {
1200 let check_id = format!("runtime.aws-ec2.{id}");
1201 let title = format!("AWS EC2 target {id}");
1202 let profile_label = profile.unwrap_or("default");
1203
1204 let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
1205 match executor.execute(&version) {
1206 Err(error) => {
1207 return DoctorCheck::fixable(
1208 check_id,
1209 title,
1210 format!("The `aws` command is not available: {error}"),
1211 format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
1212 );
1213 }
1214 Ok(output) if output.status != 0 => {
1215 return DoctorCheck::fixable(
1216 check_id,
1217 title,
1218 format!(
1219 "`aws --version` failed: {}",
1220 String::from_utf8_lossy(&output.stderr).trim()
1221 ),
1222 format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
1223 );
1224 }
1225 Ok(_) => {}
1226 }
1227
1228 let mut identity_args = aws_global_args(profile, region);
1229 identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
1230 identity_args.extend(["--output".to_owned(), "json".to_owned()]);
1231 let identity =
1232 CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
1233 match executor.execute(&identity) {
1234 Err(error) => {
1235 return DoctorCheck::fixable(
1236 check_id,
1237 title,
1238 format!("Could not run `aws sts get-caller-identity`: {error}"),
1239 format!(
1240 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1241 ),
1242 );
1243 }
1244 Ok(output) if output.status != 0 => {
1245 return DoctorCheck::fixable(
1246 check_id,
1247 title,
1248 format!(
1249 "AWS credentials for profile {profile_label} are not usable: {}",
1250 String::from_utf8_lossy(&output.stderr).trim()
1251 ),
1252 format!(
1253 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1254 ),
1255 );
1256 }
1257 Ok(_) => {}
1258 }
1259
1260 let by_id = launch_template.starts_with("lt-");
1263 let mut template_args = aws_global_args(profile, region);
1264 template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
1265 template_args.extend([
1266 if by_id {
1267 "--launch-template-ids".to_owned()
1268 } else {
1269 "--launch-template-names".to_owned()
1270 },
1271 launch_template.to_owned(),
1272 ]);
1273 template_args.extend(["--output".to_owned(), "json".to_owned()]);
1274 let template =
1275 CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
1276 let template_remediation = format!(
1277 "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."
1278 );
1279 match executor.execute(&template) {
1280 Err(error) => DoctorCheck::fixable(
1281 check_id,
1282 title,
1283 format!("Could not query launch template {launch_template}: {error}"),
1284 template_remediation,
1285 ),
1286 Ok(output) if output.status != 0 => DoctorCheck::fixable(
1287 check_id,
1288 title,
1289 format!(
1290 "Launch template {launch_template} was not found in {region}: {}",
1291 String::from_utf8_lossy(&output.stderr).trim()
1292 ),
1293 template_remediation,
1294 ),
1295 Ok(_) => DoctorCheck::ready(
1296 check_id,
1297 title,
1298 format!(
1299 "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
1300 ),
1301 ),
1302 }
1303}
1304
1305fn worker_binary_checks(config: Option<&Config>) -> Vec<DoctorCheck> {
1306 let Some(config) = config else {
1307 return vec![DoctorCheck::fixable(
1308 "worker.containers",
1309 "Container worker binary",
1310 "Worker availability cannot be checked until config.toml is valid.",
1311 "Fix config.toml, then rerun `mj doctor --json`.",
1312 )];
1313 };
1314 let containers = config
1315 .targets
1316 .iter()
1317 .filter_map(|(id, target)| match target {
1318 TargetTemplate::LocalPodman { container }
1319 | TargetTemplate::LocalDocker { container }
1320 | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
1321 TargetTemplate::SshPodman { container, .. } => {
1322 Some((id, container, Some("ssh-podman")))
1323 }
1324 TargetTemplate::SshDocker { container, .. } => {
1325 Some((id, container, Some("ssh-docker")))
1326 }
1327 _ => None,
1328 })
1329 .collect::<Vec<_>>();
1330 if containers.is_empty() {
1331 return vec![DoctorCheck::unsupported(
1332 "worker.containers",
1333 "Container worker binary",
1334 "No container target is configured.",
1335 )];
1336 }
1337 containers
1338 .into_iter()
1339 .map(|(id, container, remote_kind)| {
1340 if let Some(remote_kind) = remote_kind
1341 && container.platform.is_none()
1342 {
1343 return DoctorCheck::unsupported(
1346 format!("worker.{id}"),
1347 format!("Container worker binary for target {id}"),
1348 format!(
1349 "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
1350 ),
1351 );
1352 }
1353 worker_binary_check(id, container)
1354 })
1355 .collect()
1356}
1357
1358fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
1359 let title = format!("Container worker binary for target {id}");
1360 let arch = match container_architecture(container.platform.as_deref()) {
1361 Ok(arch) => arch,
1362 Err(reason) => {
1363 return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
1364 }
1365 };
1366 let triple = format!("{arch}-unknown-linux-musl");
1367 match worker_binary_prerequisite_for_arch(arch) {
1368 Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
1369 format!("worker.{id}"),
1370 title,
1371 format!(
1372 "{triple} worker is available from {source}: {}",
1373 path.display()
1374 ),
1375 ),
1376 Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
1377 format!("worker.{id}"),
1378 title,
1379 format!("{triple} worker will be verified and downloaded from {url} when needed."),
1380 ),
1381 Err(error) => DoctorCheck::fixable(
1382 format!("worker.{id}"),
1383 title,
1384 format!("No usable {triple} worker source: {error:#}"),
1385 format!(
1386 "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."
1387 ),
1388 ),
1389 }
1390}
1391
1392fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
1393 let candidate = platform.unwrap_or(std::env::consts::ARCH);
1394 let candidate = candidate
1395 .split('/')
1396 .rev()
1397 .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
1398 .unwrap_or(candidate);
1399 match candidate {
1400 "x86_64" | "amd64" => Ok("x86_64"),
1401 "aarch64" | "arm64" => Ok("aarch64"),
1402 other => Err(format!(
1403 "Container architecture {other:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
1404 )),
1405 }
1406}
1407
1408fn apple_container_image(config: Option<&Config>) -> String {
1409 config
1410 .and_then(|config| {
1411 config.targets.values().find_map(|target| match target {
1412 TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
1413 _ => None,
1414 })
1415 })
1416 .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
1417}
1418
1419pub fn apple_container_check(
1420 platform: &ApplePlatform,
1421 executor: &impl CommandExecutor,
1422 smoke: bool,
1423 image: String,
1424) -> DoctorCheck {
1425 match platform {
1426 ApplePlatform::Linux => {
1427 return DoctorCheck::unsupported(
1428 "runtime.apple-container",
1429 "Apple container runtime",
1430 "macOS only",
1431 );
1432 }
1433 ApplePlatform::Other(current) => {
1434 return DoctorCheck::unsupported(
1435 "runtime.apple-container",
1436 "Apple container runtime",
1437 format!("macOS only (current platform: {current})"),
1438 );
1439 }
1440 ApplePlatform::Macos {
1441 architecture,
1442 major_version,
1443 } if architecture != "aarch64" && architecture != "arm64" => {
1444 return DoctorCheck::unsupported(
1445 "runtime.apple-container",
1446 "Apple container runtime",
1447 "Apple container requires Apple silicon; Intel Macs are unsupported.",
1448 );
1449 }
1450 ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
1451 return DoctorCheck::unsupported(
1452 "runtime.apple-container",
1453 "Apple container runtime",
1454 format!("Apple container requires macOS 26 or newer (found {major_version})."),
1455 );
1456 }
1457 ApplePlatform::Macos { .. } => {}
1458 }
1459
1460 let daemon = apple_container_daemon_check(executor);
1461 if daemon.status != CheckStatus::Ready {
1462 return daemon;
1463 }
1464
1465 if !smoke {
1466 return DoctorCheck::fixable(
1467 "runtime.apple-container",
1468 "Apple container runtime",
1469 "The daemon is running, but the required disposable smoke test was not requested.",
1470 "Run `mj doctor --json --smoke`.",
1471 );
1472 }
1473
1474 let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
1475 image,
1476 pull_policy: Default::default(),
1477 extra_run_args: vec![],
1478 workspace_storage: Default::default(),
1479 });
1480 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1481 Ok(()) => DoctorCheck::ready(
1482 "runtime.apple-container",
1483 "Apple container runtime",
1484 "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
1485 ),
1486 Err(error) => DoctorCheck::fixable(
1487 "runtime.apple-container",
1488 "Apple container runtime",
1489 format!("Disposable run/exec/remove smoke test failed: {error:#}"),
1490 "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
1491 ),
1492 }
1493}
1494
1495pub fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
1502 let installed =
1503 CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
1504 match executor.execute(&installed) {
1505 Err(error) => {
1506 return DoctorCheck::fixable(
1507 "runtime.apple-container",
1508 "Apple container runtime",
1509 format!("The `container` command is not available: {error}"),
1510 format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
1511 );
1512 }
1513 Ok(output) if output.status != 0 => {
1514 return DoctorCheck::fixable(
1515 "runtime.apple-container",
1516 "Apple container runtime",
1517 format!(
1518 "The installed `container --version` command failed: {}",
1519 String::from_utf8_lossy(&output.stderr).trim()
1520 ),
1521 format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
1522 );
1523 }
1524 Ok(_) => {}
1525 }
1526
1527 let status =
1528 CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
1529 match executor.execute(&status) {
1530 Ok(output) if output.status == 0 => DoctorCheck::ready(
1531 "runtime.apple-container",
1532 "Apple container runtime",
1533 "Installed, and the Apple container daemon is running.",
1534 ),
1535 Ok(output) => DoctorCheck::fixable(
1536 "runtime.apple-container",
1537 "Apple container runtime",
1538 format!(
1539 "The Apple container daemon is stopped: {}",
1540 String::from_utf8_lossy(&output.stderr).trim()
1541 ),
1542 "Run `container system start`.",
1543 ),
1544 Err(error) => DoctorCheck::fixable(
1545 "runtime.apple-container",
1546 "Apple container runtime",
1547 format!("Could not query the Apple container daemon: {error}"),
1548 "Run `container system start`.",
1549 ),
1550 }
1551}
1552
1553pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
1554 if cfg!(target_os = "linux") {
1555 return ApplePlatform::Linux;
1556 }
1557 if !cfg!(target_os = "macos") {
1558 return ApplePlatform::Other(std::env::consts::OS.into());
1559 }
1560 let major_version = executor
1561 .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
1562 .ok()
1563 .filter(|output| output.status == 0)
1564 .and_then(|output| {
1565 String::from_utf8(output.stdout)
1566 .ok()
1567 .and_then(|value| value.trim().split('.').next()?.parse().ok())
1568 })
1569 .unwrap_or(0);
1570 ApplePlatform::Macos {
1571 architecture: std::env::consts::ARCH.into(),
1572 major_version,
1573 }
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578 use std::cell::RefCell;
1579 use std::path::PathBuf;
1580
1581 use anyhow::anyhow;
1582
1583 use super::*;
1584 use crate::targets::CommandOutput;
1585
1586 struct FakeExecutor {
1587 commands: RefCell<Vec<CommandSpec>>,
1588 responses: RefCell<Vec<Result<CommandOutput>>>,
1589 }
1590
1591 impl FakeExecutor {
1592 fn new(responses: impl IntoIterator<Item = Result<CommandOutput>>) -> Self {
1593 Self {
1594 commands: RefCell::new(vec![]),
1595 responses: RefCell::new(responses.into_iter().collect()),
1596 }
1597 }
1598 }
1599
1600 impl CommandExecutor for FakeExecutor {
1601 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1602 self.commands.borrow_mut().push(command.clone());
1603 self.responses.borrow_mut().remove(0)
1604 }
1605 }
1606
1607 fn output(stdout: impl AsRef<[u8]>) -> CommandOutput {
1608 CommandOutput {
1609 status: 0,
1610 stdout: stdout.as_ref().to_vec(),
1611 stderr: vec![],
1612 }
1613 }
1614
1615 fn failed(stderr: impl AsRef<[u8]>) -> CommandOutput {
1616 CommandOutput {
1617 status: 1,
1618 stdout: vec![],
1619 stderr: stderr.as_ref().to_vec(),
1620 }
1621 }
1622
1623 fn reachable_then(
1626 responses: impl IntoIterator<Item = Result<CommandOutput>>,
1627 ) -> Vec<Result<CommandOutput>> {
1628 let mut all = vec![Ok(output(b""))];
1629 all.extend(responses);
1630 all
1631 }
1632
1633 fn passing_ssh_podman_probes() -> Vec<Result<CommandOutput>> {
1634 let mut responses = passing_podman_probes();
1635 responses.push(Ok(output(b"yes\n")));
1636 responses
1637 }
1638
1639 fn passing_podman_probes() -> Vec<Result<CommandOutput>> {
1640 vec![
1641 Ok(output(b"podman version 5.4.2\n")),
1642 Ok(output(b"true\n")),
1643 Ok(output(
1644 b" 0 1000 1\n 1 100000 65536\n",
1645 )),
1646 ]
1647 }
1648
1649 fn container(image: &str) -> ContainerTemplate {
1650 ContainerTemplate {
1651 image: image.to_owned(),
1652 pull_policy: Default::default(),
1653 platform: None,
1654 cpus: None,
1655 memory: None,
1656 environment: std::collections::BTreeMap::new(),
1657 workspace_storage: Default::default(),
1658 }
1659 }
1660
1661 fn ssh_connection() -> mj_core::config::SshConnection {
1662 mj_core::config::SshConnection {
1663 host: "example.test".into(),
1664 user: Some("dev".into()),
1665 identity_file: None,
1666 extra_args: vec![],
1667 }
1668 }
1669
1670 #[test]
1671 fn doctor_reports_a_config_owned_by_a_newer_hel_as_read_only() {
1672 let directory = tempfile::tempdir().unwrap();
1673 let path = directory.path().join("config.toml");
1674 std::fs::write(
1675 &path,
1676 format!(
1677 "version = {}\n\n[targets.localhost]\nkind = \"local-bare\"\n",
1678 mj_core::config::CONFIG_VERSION + 1
1679 ),
1680 )
1681 .unwrap();
1682
1683 let (config, checks) = configuration_checks(&path);
1684
1685 assert!(config.is_some());
1686 let check = checks.iter().find(|check| check.id == "config").unwrap();
1687 assert_eq!(check.status, CheckStatus::Warning);
1688 assert!(check.detail.contains("read-only"), "{}", check.detail);
1689 }
1690
1691 fn config_with(targets: impl IntoIterator<Item = (&'static str, TargetTemplate)>) -> Config {
1692 Config {
1693 targets: targets
1694 .into_iter()
1695 .map(|(id, target)| (id.to_owned(), target))
1696 .collect(),
1697 ..Config::default()
1698 }
1699 }
1700
1701 fn runtime_ssh() -> RuntimeSshTarget {
1702 backend_ssh(&ssh_connection())
1703 }
1704
1705 #[test]
1706 fn podman_check_is_unsupported_without_a_valid_config() {
1707 let executor = FakeExecutor::new([]);
1708
1709 let check = podman_check(None, &executor);
1710
1711 assert_eq!(check.status, CheckStatus::Unsupported);
1712 assert_eq!(
1713 check.detail,
1714 "Podman prerequisites cannot be evaluated until config.toml is valid."
1715 );
1716 assert!(executor.commands.borrow().is_empty());
1717 }
1718
1719 #[test]
1720 fn podman_check_is_unsupported_without_a_local_podman_target() {
1721 let executor = FakeExecutor::new([]);
1722 let config = config_with([(
1723 "apple",
1724 TargetTemplate::AppleContainer {
1725 container: container("ubuntu:24.04"),
1726 },
1727 )]);
1728
1729 let check = podman_check(Some(&config), &executor);
1730
1731 assert_eq!(check.status, CheckStatus::Unsupported);
1732 assert_eq!(check.detail, "No local-podman target is configured.");
1733 assert!(executor.commands.borrow().is_empty());
1734 }
1735
1736 #[test]
1737 fn podman_check_probes_the_host_when_a_local_podman_target_exists() {
1738 let executor = FakeExecutor::new(passing_podman_probes());
1739 let config = config_with([(
1740 "podman",
1741 TargetTemplate::LocalPodman {
1742 container: container("ubuntu:24.04"),
1743 },
1744 )]);
1745
1746 let check = podman_check(Some(&config), &executor);
1747
1748 assert_eq!(check.status, CheckStatus::Ready);
1749 assert!(check.detail.contains("Podman 5.4.2"));
1750 assert_eq!(executor.commands.borrow().len(), 3);
1751 }
1752
1753 #[test]
1754 fn podman_check_is_fixable_with_an_upgrade_remediation_for_an_old_runtime() {
1755 let executor = FakeExecutor::new(reachable_then([Ok(output(b"podman version 3.4.7\n"))]));
1756 let config = config_with([(
1757 "podman",
1758 TargetTemplate::LocalPodman {
1759 container: container("ubuntu:24.04"),
1760 },
1761 )]);
1762
1763 let check = podman_check(Some(&config), &executor);
1764
1765 assert_eq!(check.status, CheckStatus::Fixable);
1766 assert!(
1767 check
1768 .remediation
1769 .as_deref()
1770 .unwrap()
1771 .contains("Upgrade Podman")
1772 );
1773 }
1774
1775 #[test]
1776 fn podman_image_check_is_ready_when_the_image_is_present() {
1777 let executor = FakeExecutor::new([Ok(output(b""))]);
1778
1779 let check =
1780 podman_image_check("podman", "localhost/hel/agent-dev:latest", &executor, false);
1781
1782 assert_eq!(check.id, "runtime.podman.image.podman");
1783 assert_eq!(check.title, "Podman image for target podman");
1784 assert_eq!(check.status, CheckStatus::Ready);
1785 assert_eq!(
1786 executor.commands.borrow()[0].args,
1787 vec!["image", "exists", "localhost/hel/agent-dev:latest"]
1788 );
1789 }
1790
1791 #[test]
1792 fn podman_image_check_is_fixable_with_a_pull_remediation_when_the_image_is_missing() {
1793 let executor = FakeExecutor::new([Ok(failed(b""))]);
1794
1795 let check = podman_image_check("podman", "ghcr.io/example/dev:1", &executor, false);
1796
1797 assert_eq!(check.status, CheckStatus::Fixable);
1798 assert!(
1799 check
1800 .detail
1801 .contains("is not present in local Podman storage")
1802 );
1803 assert_eq!(
1804 check.remediation.as_deref(),
1805 Some(
1806 "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."
1807 )
1808 );
1809 }
1810
1811 #[test]
1812 fn podman_image_check_smoke_runs_a_disposable_container() {
1813 let executor = FakeExecutor::new([
1814 Ok(output(b"created\n")),
1815 Ok(output(b"ok\n")),
1816 Ok(output(b"removed\n")),
1817 ]);
1818
1819 let check = podman_image_check("podman", "ubuntu:24.04", &executor, true);
1820
1821 assert_eq!(check.status, CheckStatus::Ready);
1822 let commands = executor.commands.borrow();
1823 assert_eq!(commands.len(), 3);
1824 assert!(commands.iter().all(|command| command.program == "podman"));
1825 assert_eq!(commands[0].args[0], "run");
1826 assert_eq!(commands[1].args[0], "exec");
1827 assert_eq!(commands[2].args[0], "rm");
1828 }
1829
1830 #[test]
1831 fn image_checks_are_skipped_when_the_host_podman_preflight_fails() {
1832 let executor = FakeExecutor::new(reachable_then([Ok(output(b"podman version 3.4.7\n"))]));
1833 let config = config_with([(
1834 "podman",
1835 TargetTemplate::LocalPodman {
1836 container: container("ubuntu:24.04"),
1837 },
1838 )]);
1839
1840 let checks = podman_checks(Some(&config), &executor, false);
1841
1842 assert_eq!(checks.len(), 1);
1843 assert_eq!(checks[0].id, "runtime.podman");
1844 }
1845
1846 #[test]
1847 fn image_checks_follow_a_passing_preflight_for_each_local_podman_target() {
1848 let mut responses = passing_podman_probes();
1849 responses.push(Ok(output(b"")));
1850 responses.push(Ok(failed(b"")));
1851 let executor = FakeExecutor::new(responses);
1852 let config = config_with([
1853 (
1854 "alpha",
1855 TargetTemplate::LocalPodman {
1856 container: container("ubuntu:24.04"),
1857 },
1858 ),
1859 (
1860 "beta",
1861 TargetTemplate::LocalPodman {
1862 container: container("ghcr.io/example/dev:1"),
1863 },
1864 ),
1865 ]);
1866
1867 let checks = podman_checks(Some(&config), &executor, false);
1868
1869 assert_eq!(
1870 checks
1871 .iter()
1872 .map(|check| check.id.as_str())
1873 .collect::<Vec<_>>(),
1874 vec![
1875 "runtime.podman",
1876 "runtime.podman.image.alpha",
1877 "runtime.podman.image.beta"
1878 ]
1879 );
1880 assert_eq!(checks[1].status, CheckStatus::Ready);
1881 assert_eq!(checks[2].status, CheckStatus::Fixable);
1882 }
1883
1884 #[test]
1885 fn docker_checks_probe_the_daemon_then_the_configured_image() {
1886 let executor = FakeExecutor::new([
1887 Ok(output(b"29.0.1 linux\n")),
1888 Ok(output(b"image metadata\n")),
1889 ]);
1890 let config = config_with([(
1891 "docker",
1892 TargetTemplate::LocalDocker {
1893 container: container("ghcr.io/example/dev:1"),
1894 },
1895 )]);
1896
1897 let checks = docker_checks(Some(&config), &executor, false);
1898
1899 assert_eq!(
1900 checks
1901 .iter()
1902 .map(|check| check.id.as_str())
1903 .collect::<Vec<_>>(),
1904 vec!["runtime.docker", "runtime.docker.image.docker"]
1905 );
1906 assert!(
1907 checks
1908 .iter()
1909 .all(|check| check.status == CheckStatus::Ready)
1910 );
1911 let commands = executor.commands.borrow();
1912 assert_eq!(commands[0].program, "docker");
1913 assert_eq!(
1914 commands[0].args,
1915 ["version", "--format", "{{.Server.Version}} {{.Server.Os}}"]
1916 );
1917 assert_eq!(
1918 commands[1].args,
1919 ["image", "inspect", "ghcr.io/example/dev:1"]
1920 );
1921 }
1922
1923 #[test]
1924 fn docker_image_smoke_uses_managed_overlay_run_exec_and_cleanup() {
1925 let executor = FakeExecutor::new([
1926 Ok(output(b"created\n")),
1927 Ok(output(b"ok\n")),
1928 Ok(output(b"removed\n")),
1929 ]);
1930
1931 let check = docker_image_check("docker", "ubuntu:24.04", &executor, true);
1932
1933 assert_eq!(check.status, CheckStatus::Ready);
1934 let commands = executor.commands.borrow();
1935 assert_eq!(commands.len(), 3);
1936 assert_eq!(commands[0].program, "sh");
1937 assert!(commands[0].args[1].contains("docker volume create"));
1938 assert!(commands[0].args.contains(&"--pull=missing".to_owned()));
1939 assert_eq!(commands[1].program, "docker");
1940 assert_eq!(commands[1].args[0], "exec");
1941 assert_eq!(commands[2].program, "sh");
1942 assert!(commands[2].args[1].contains("docker rm --force"));
1943 assert!(commands[2].args[1].contains("docker volume rm --force"));
1944 }
1945
1946 #[test]
1947 fn ssh_podman_check_is_ready_after_ssh_wrapped_probes_without_smoke() {
1948 let executor = FakeExecutor::new(reachable_then(passing_ssh_podman_probes()));
1949
1950 let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
1951
1952 assert_eq!(check.id, "runtime.ssh-podman.remote");
1953 assert_eq!(check.title, "Remote Podman for target remote");
1954 assert_eq!(check.status, CheckStatus::Ready);
1955 assert!(check.detail.contains("Remote rootless Podman 5.4.2"));
1956 assert!(check.detail.contains("dev@example.test"));
1957 let commands = executor.commands.borrow();
1958 assert_eq!(commands.len(), 5);
1959 assert_eq!(commands[0].args.last().unwrap(), "'true'");
1960 for command in commands.iter().skip(1) {
1961 assert_eq!(command.program, "ssh");
1962 assert!(command.args.contains(&"dev@example.test".to_owned()));
1963 }
1964 assert!(
1965 commands[4]
1966 .args
1967 .last()
1968 .unwrap()
1969 .contains("'loginctl show-user")
1970 );
1971 }
1972
1973 #[test]
1974 fn ssh_podman_check_warns_when_remote_user_lingering_is_disabled() {
1975 let mut responses = passing_podman_probes();
1976 responses.push(Ok(output(b"no\n")));
1977 let executor = FakeExecutor::new(reachable_then(responses));
1978
1979 let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
1980
1981 assert_eq!(check.status, CheckStatus::Warning);
1982 assert!(all_ready(std::slice::from_ref(&check)));
1983 assert!(check.detail.contains("Podman 5.4.2 is available"));
1984 assert!(check.detail.contains("last SSH connection closes"));
1985 assert!(
1986 check
1987 .remediation
1988 .as_deref()
1989 .unwrap()
1990 .contains("sudo loginctl enable-linger")
1991 );
1992 }
1993
1994 #[test]
1995 fn ssh_podman_check_explains_when_durability_cannot_be_verified() {
1996 let mut responses = passing_podman_probes();
1997 responses.push(Ok(CommandOutput {
1998 status: 127,
1999 stdout: vec![],
2000 stderr: b"sh: loginctl: not found\n".to_vec(),
2001 }));
2002 let executor = FakeExecutor::new(reachable_then(responses));
2003
2004 let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
2005
2006 assert_eq!(check.status, CheckStatus::Warning);
2007 assert!(all_ready(std::slice::from_ref(&check)));
2008 assert!(check.detail.contains("durability check is unavailable"));
2009 assert!(check.detail.contains("may not use systemd"));
2010 assert!(check.detail.contains("cannot verify"));
2011 let remediation = check.remediation.as_deref().unwrap();
2012 assert!(remediation.contains("service manager"));
2013 assert!(!remediation.contains("sudo loginctl enable-linger"));
2014 }
2015
2016 #[test]
2017 fn ssh_podman_check_failure_scopes_the_remediation_to_the_remote_host() {
2018 let executor = FakeExecutor::new(reachable_then([Ok(output(b"podman version 3.4.7\n"))]));
2019
2020 let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
2021
2022 assert_eq!(check.status, CheckStatus::Fixable);
2023 assert!(check.detail.contains("dev@example.test"));
2024 assert!(
2025 check
2026 .remediation
2027 .as_deref()
2028 .unwrap()
2029 .starts_with("On dev@example.test: Upgrade Podman")
2030 );
2031 }
2032
2033 #[test]
2034 fn ssh_podman_check_reports_the_shared_ssh_remediation_before_probing_podman() {
2035 let executor = FakeExecutor::new([Ok(failed(
2036 b"dev@example.test: Permission denied (publickey).",
2037 ))]);
2038
2039 let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, false);
2040
2041 assert_eq!(check.status, CheckStatus::Fixable);
2042 assert_eq!(
2043 check.remediation.as_deref(),
2044 Some("Install your public key on the host with `ssh-copy-id dev@example.test`.")
2045 );
2046 assert_eq!(executor.commands.borrow().len(), 1);
2048 }
2049
2050 #[test]
2051 fn ssh_podman_check_smoke_runs_an_ssh_wrapped_disposable_container() {
2052 let mut responses = passing_ssh_podman_probes();
2053 responses.extend([
2054 Ok(output(b"created\n")),
2055 Ok(output(b"ok\n")),
2056 Ok(output(b"removed\n")),
2057 ]);
2058 let executor = FakeExecutor::new(reachable_then(responses));
2059
2060 let check = ssh_podman_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, true);
2061
2062 assert_eq!(check.status, CheckStatus::Ready);
2063 let commands = executor.commands.borrow();
2064 assert_eq!(commands.len(), 8);
2065 for command in commands.iter().skip(5) {
2066 assert_eq!(command.program, "ssh");
2067 assert!(command.args.contains(&"dev@example.test".to_owned()));
2068 }
2069 assert!(commands[5].args.last().unwrap().contains("'run' '--init'"));
2070 assert!(commands[6].args.last().unwrap().ends_with("'true'"));
2071 assert!(commands[7].args.last().unwrap().contains("'rm' '--force'"));
2072 }
2073
2074 #[test]
2075 fn ssh_docker_check_probes_connectivity_daemon_and_remote_image() {
2076 let executor = FakeExecutor::new([
2077 Ok(output(b"")),
2078 Ok(output(b"29.0.1 linux\n")),
2079 Ok(output(b"image metadata\n")),
2080 ]);
2081 let check = ssh_docker_check(
2082 "remote",
2083 &runtime_ssh(),
2084 "ghcr.io/example/dev:1",
2085 &executor,
2086 false,
2087 );
2088
2089 assert_eq!(check.id, "runtime.ssh-docker.remote");
2090 assert_eq!(check.title, "Remote Docker for target remote");
2091 assert_eq!(check.status, CheckStatus::Ready);
2092 assert!(check.detail.contains("Remote Docker 29.0.1"));
2093 assert!(
2094 check
2095 .detail
2096 .contains("image ghcr.io/example/dev:1 is present")
2097 );
2098 let commands = executor.commands.borrow();
2099 assert_eq!(commands.len(), 3);
2100 assert!(commands.iter().all(|command| command.program == "ssh"));
2101 assert!(commands[0].args.last().unwrap().contains("'true'"));
2102 assert!(
2103 commands[1]
2104 .args
2105 .last()
2106 .unwrap()
2107 .contains("'docker' 'version'")
2108 );
2109 assert!(
2110 commands[2]
2111 .args
2112 .last()
2113 .unwrap()
2114 .contains("'docker' 'image' 'inspect'")
2115 );
2116 }
2117
2118 #[test]
2119 fn ssh_docker_check_smoke_runs_overlay_on_the_remote_host() {
2120 let executor = FakeExecutor::new([
2121 Ok(output(b"")),
2122 Ok(output(b"29.0.1 linux\n")),
2123 Ok(output(b"/tmp/mj-docker-overlay-smoke.fixture\n")),
2124 Ok(output(b"created\n")),
2125 Ok(output(b"ok\n")),
2126 Ok(output(b"verified\n")),
2127 Ok(output(b"removed\n")),
2128 Ok(output(b"removed\n")),
2129 ]);
2130 let check = ssh_docker_check("remote", &runtime_ssh(), "ubuntu:24.04", &executor, true);
2131
2132 assert_eq!(check.status, CheckStatus::Ready);
2133 assert!(check.detail.contains("remote OverlayFS attachment"));
2134 let commands = executor.commands.borrow();
2135 assert_eq!(commands.len(), 8);
2136 assert!(commands.iter().all(|command| command.program == "ssh"));
2137 assert!(commands[2].args.last().unwrap().contains("mktemp"));
2138 assert!(commands[3].args.last().unwrap().contains("'docker' 'run'"));
2139 assert!(commands[4].args.last().unwrap().contains("'docker' 'exec'"));
2140 assert!(commands[5].args.last().unwrap().contains("original.txt"));
2141 assert!(commands[6].args.last().unwrap().contains("docker rm"));
2142 assert!(commands[7].args.last().unwrap().contains("'rm' '-rf'"));
2143 }
2144
2145 #[test]
2146 fn ssh_podman_checks_are_skipped_without_a_valid_config() {
2147 let executor = FakeExecutor::new([]);
2148
2149 assert!(ssh_podman_checks(None, &executor, false).is_empty());
2150 assert!(executor.commands.borrow().is_empty());
2151 }
2152
2153 fn ssh_bare_config() -> Config {
2154 config_with([(
2155 "builder",
2156 TargetTemplate::SshBare {
2157 ssh: mj_core::config::SshConnection {
2158 host: "example.test".into(),
2159 user: Some("dev".into()),
2160 identity_file: Some(PathBuf::from("/home/dev/.ssh/id_ed25519")),
2161 extra_args: vec![],
2162 },
2163 permissions: mj_core::config::PermissionMode::Yolo,
2164 workspace_prefix: PathBuf::from(".local/share/hel/workspaces"),
2165 },
2166 )])
2167 }
2168
2169 #[test]
2170 fn ssh_bare_check_is_ready_when_the_batch_mode_probe_succeeds() {
2171 let executor = FakeExecutor::new([Ok(output(b""))]);
2172
2173 let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2174
2175 assert_eq!(checks.len(), 1);
2176 assert_eq!(checks[0].id, "runtime.ssh-bare.builder");
2177 assert_eq!(checks[0].status, CheckStatus::Ready);
2178 let command = &executor.commands.borrow()[0];
2179 assert_eq!(command.program, "ssh");
2180 assert_eq!(
2181 command.args[..4],
2182 ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes"]
2183 );
2184 assert!(command.args.contains(&"dev@example.test".to_owned()));
2185 assert_eq!(command.args.last().unwrap(), "'true'");
2186 }
2187
2188 #[test]
2189 fn ssh_bare_check_permission_denied_recommends_ssh_copy_id_with_the_identity() {
2190 let executor = FakeExecutor::new([Ok(failed(
2191 b"dev@example.test: Permission denied (publickey).",
2192 ))]);
2193
2194 let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2195
2196 assert_eq!(checks[0].status, CheckStatus::Fixable);
2197 assert_eq!(
2198 checks[0].remediation.as_deref(),
2199 Some(
2200 "Install your public key on the host with `ssh-copy-id -i /home/dev/.ssh/id_ed25519.pub dev@example.test`."
2201 )
2202 );
2203 }
2204
2205 #[test]
2206 fn ssh_bare_check_host_key_failure_recommends_keyscan_with_a_fingerprint_caution() {
2207 let executor = FakeExecutor::new([Ok(failed(
2208 b"Host key verification failed.\nNo ECDSA host key is known for example.test",
2209 ))]);
2210
2211 let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2212
2213 assert_eq!(checks[0].status, CheckStatus::Fixable);
2214 let remediation = checks[0].remediation.as_deref().unwrap();
2215 assert!(
2216 remediation.contains("ssh-keyscan -H example.test >> ~/.ssh/known_hosts"),
2217 "{remediation}"
2218 );
2219 assert!(
2220 remediation.contains("Verify the fingerprint"),
2221 "{remediation}"
2222 );
2223 }
2224
2225 #[test]
2226 fn ssh_bare_check_without_an_ssh_client_recommends_installing_openssh() {
2227 let executor = FakeExecutor::new([Err(anyhow!("No such file or directory (os error 2)"))]);
2228
2229 let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2230
2231 assert_eq!(checks[0].status, CheckStatus::Fixable);
2232 assert_eq!(
2233 checks[0].remediation.as_deref(),
2234 Some(SSH_MISSING_REMEDIATION)
2235 );
2236 }
2237
2238 #[test]
2239 fn ssh_bare_check_falls_back_to_quoting_an_unrecognized_ssh_failure() {
2240 let executor = FakeExecutor::new([Ok(failed(
2241 b"ssh: connect to host example.test port 22: Connection timed out",
2242 ))]);
2243
2244 let checks = ssh_bare_checks(Some(&ssh_bare_config()), &executor);
2245
2246 let remediation = checks[0].remediation.as_deref().unwrap();
2247 assert!(
2248 remediation.contains("Connection timed out"),
2249 "{remediation}"
2250 );
2251 assert!(
2252 remediation.contains("Run `ssh dev@example.test true` by hand"),
2253 "{remediation}"
2254 );
2255 }
2256
2257 #[test]
2258 fn ssh_bare_checks_are_skipped_without_a_valid_config() {
2259 let executor = FakeExecutor::new([]);
2260
2261 assert!(ssh_bare_checks(None, &executor).is_empty());
2262 assert!(executor.commands.borrow().is_empty());
2263 }
2264
2265 #[test]
2266 fn worker_check_for_an_ssh_podman_target_without_platform_is_unsupported() {
2267 let config = config_with([(
2268 "remote",
2269 TargetTemplate::SshPodman {
2270 ssh: ssh_connection(),
2271 container: container("ubuntu:24.04"),
2272 },
2273 )]);
2274
2275 let checks = worker_binary_checks(Some(&config));
2276
2277 assert_eq!(checks.len(), 1);
2278 assert_eq!(checks[0].id, "worker.remote");
2279 assert_eq!(checks[0].status, CheckStatus::Unsupported);
2280 assert_eq!(
2281 checks[0].detail,
2282 "Set `platform` on this ssh-podman target to check its worker binary; the remote architecture is unknown until provisioning."
2283 );
2284 }
2285
2286 #[test]
2287 fn worker_check_for_an_ssh_podman_target_with_platform_uses_the_normal_check() {
2288 let mut remote = container("ubuntu:24.04");
2289 remote.platform = Some("linux/amd64".into());
2290 let config = config_with([(
2291 "remote",
2292 TargetTemplate::SshPodman {
2293 ssh: ssh_connection(),
2294 container: remote,
2295 },
2296 )]);
2297
2298 let checks = worker_binary_checks(Some(&config));
2299
2300 assert_eq!(checks.len(), 1);
2301 assert_eq!(checks[0].id, "worker.remote");
2302 assert_ne!(checks[0].status, CheckStatus::Unsupported);
2303 assert!(checks[0].detail.contains("x86_64-unknown-linux-musl"));
2304 }
2305
2306 #[test]
2307 fn worker_check_for_an_ssh_docker_target_hints_at_remote_architecture() {
2308 let config = config_with([(
2309 "remote",
2310 TargetTemplate::SshDocker {
2311 ssh: ssh_connection(),
2312 container: container("ubuntu:24.04"),
2313 },
2314 )]);
2315
2316 let checks = worker_binary_checks(Some(&config));
2317
2318 assert_eq!(checks.len(), 1);
2319 assert_eq!(checks[0].status, CheckStatus::Unsupported);
2320 assert_eq!(
2321 checks[0].detail,
2322 "Set `platform` on this ssh-docker target to check its worker binary; the remote architecture is unknown until provisioning."
2323 );
2324 }
2325
2326 #[test]
2327 fn an_unauthenticated_profile_is_fixed_by_hel_login_for_that_profile() {
2328 let directory = tempfile::tempdir().unwrap();
2329 let home = directory.path().join("claude-home");
2330 std::fs::create_dir_all(&home).unwrap();
2331 let profile = HarnessProfile {
2332 enabled: true,
2333 kind: HarnessKind::Claude,
2334 home,
2335 environment: std::collections::BTreeMap::new(),
2336 context_window_bytes: None,
2337 };
2338 let config = Config {
2339 profiles: [("work".to_owned(), profile.clone())].into_iter().collect(),
2340 ..Config::default()
2341 };
2342
2343 let executor = FakeExecutor::new([Ok(output(br#"{"loggedIn":false}"#))]);
2344 let checks = harness_checks(Some(&config), &executor);
2345
2346 assert_eq!(checks.len(), 1);
2347 assert_eq!(checks[0].status, CheckStatus::Fixable);
2348 let remediation = checks[0].remediation.as_deref().unwrap();
2349 assert!(
2350 remediation.contains("mj login --profile work"),
2351 "{remediation}"
2352 );
2353 let (program, arguments) = login_command(&profile);
2356 assert!(
2357 remediation.contains(&format!("`{program} {}`", arguments.join(" "))),
2358 "{remediation}"
2359 );
2360 }
2361
2362 #[test]
2363 fn doctor_reports_disabled_profiles_without_probing_them() {
2364 let profile = HarnessProfile {
2365 enabled: false,
2366 kind: HarnessKind::Claude,
2367 home: PathBuf::from("/missing/disabled-profile"),
2368 environment: std::collections::BTreeMap::new(),
2369 context_window_bytes: None,
2370 };
2371 let config = Config {
2372 profiles: [("retired".to_owned(), profile)].into_iter().collect(),
2373 ..Config::default()
2374 };
2375 let executor = FakeExecutor::new([]);
2376
2377 let checks = harness_checks(Some(&config), &executor);
2378
2379 assert_eq!(checks.len(), 1);
2380 assert_eq!(checks[0].status, CheckStatus::Ready);
2381 assert!(checks[0].detail.contains("disabled"));
2382 assert!(executor.commands.borrow().is_empty());
2383 }
2384
2385 #[test]
2386 fn harness_discovery_reports_each_authentication_state() {
2387 let check = harness_discovery_check_from(
2388 &[
2389 DiscoveredHome {
2390 kind: HarnessKind::Codex,
2391 path: "/agents/codex".into(),
2392 authenticated: true,
2393 },
2394 DiscoveredHome {
2395 kind: HarnessKind::Kimi,
2396 path: "/agents/kimi".into(),
2397 authenticated: false,
2398 },
2399 ],
2400 true,
2401 );
2402
2403 assert_eq!(check.status, CheckStatus::Ready);
2404 assert!(
2405 check
2406 .detail
2407 .contains("Codex at /agents/codex (authenticated)")
2408 );
2409 assert!(
2410 check
2411 .detail
2412 .contains("Kimi Code at /agents/kimi (not authenticated)")
2413 );
2414 }
2415
2416 #[test]
2417 fn missing_harness_homes_are_fixable_without_a_configured_profile() {
2418 let check = harness_discovery_check_from(&[], false);
2419
2420 assert_eq!(check.status, CheckStatus::Fixable);
2421 assert_eq!(
2422 check.remediation.as_deref(),
2423 Some("Install and sign in to a supported harness, then run `mj setup`.")
2424 );
2425 }
2426
2427 #[test]
2428 fn apple_container_is_unsupported_on_intel_macs() {
2429 let executor = FakeExecutor::new([]);
2430
2431 let check = apple_container_check(
2432 &ApplePlatform::Macos {
2433 architecture: "x86_64".into(),
2434 major_version: 26,
2435 },
2436 &executor,
2437 false,
2438 DEFAULT_CONTAINER_IMAGE.into(),
2439 );
2440
2441 assert_eq!(check.status, CheckStatus::Unsupported);
2442 assert!(check.detail.contains("Intel Macs"));
2443 assert!(executor.commands.borrow().is_empty());
2444 }
2445
2446 #[test]
2447 fn apple_container_is_unsupported_before_macos_26() {
2448 let executor = FakeExecutor::new([]);
2449
2450 let check = apple_container_check(
2451 &ApplePlatform::Macos {
2452 architecture: "aarch64".into(),
2453 major_version: 25,
2454 },
2455 &executor,
2456 false,
2457 DEFAULT_CONTAINER_IMAGE.into(),
2458 );
2459
2460 assert_eq!(check.status, CheckStatus::Unsupported);
2461 assert!(check.detail.contains("macOS 26"));
2462 }
2463
2464 #[test]
2465 fn apple_container_not_installed_has_official_package_remediation() {
2466 let executor = FakeExecutor::new([Err(anyhow!("No such file or directory"))]);
2467
2468 let check = apple_container_check(
2469 &ApplePlatform::Macos {
2470 architecture: "aarch64".into(),
2471 major_version: 26,
2472 },
2473 &executor,
2474 false,
2475 DEFAULT_CONTAINER_IMAGE.into(),
2476 );
2477
2478 assert_eq!(check.status, CheckStatus::Fixable);
2479 assert_eq!(
2480 check.remediation.as_deref(),
2481 Some(
2482 format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}")
2483 .as_str()
2484 )
2485 );
2486 }
2487
2488 #[test]
2489 fn apple_container_stopped_daemon_has_start_remediation() {
2490 let executor = FakeExecutor::new([
2491 Ok(output(b"container version 1\n")),
2492 Ok(CommandOutput {
2493 status: 1,
2494 stdout: vec![],
2495 stderr: b"daemon is not running".to_vec(),
2496 }),
2497 ]);
2498
2499 let check = apple_container_check(
2500 &ApplePlatform::Macos {
2501 architecture: "aarch64".into(),
2502 major_version: 26,
2503 },
2504 &executor,
2505 false,
2506 DEFAULT_CONTAINER_IMAGE.into(),
2507 );
2508
2509 assert_eq!(check.status, CheckStatus::Fixable);
2510 assert_eq!(
2511 check.remediation.as_deref(),
2512 Some("Run `container system start`.")
2513 );
2514 }
2515
2516 #[test]
2517 fn apple_container_is_ready_only_after_the_opt_in_smoke_test() {
2518 let executor = FakeExecutor::new([
2519 Ok(output(b"container version 1\n")),
2520 Ok(output(b"running\n")),
2521 Ok(output(b"created\n")),
2522 Ok(output(b"ok\n")),
2523 Ok(output(b"removed\n")),
2524 ]);
2525
2526 let check = apple_container_check(
2527 &ApplePlatform::Macos {
2528 architecture: "aarch64".into(),
2529 major_version: 26,
2530 },
2531 &executor,
2532 true,
2533 DEFAULT_CONTAINER_IMAGE.into(),
2534 );
2535
2536 assert_eq!(check.status, CheckStatus::Ready);
2537 assert_eq!(executor.commands.borrow().len(), 5);
2538 assert_eq!(executor.commands.borrow()[2].args[0], "run");
2539 assert_eq!(executor.commands.borrow()[3].args[0], "exec");
2540 assert_eq!(executor.commands.borrow()[4].args[0], "rm");
2541 }
2542
2543 #[test]
2544 fn linux_reports_apple_container_as_macos_only() {
2545 let executor = FakeExecutor::new([]);
2546 let check = apple_container_check(
2547 &ApplePlatform::Linux,
2548 &executor,
2549 false,
2550 DEFAULT_CONTAINER_IMAGE.into(),
2551 );
2552 assert_eq!(check.status, CheckStatus::Unsupported);
2553 assert_eq!(check.detail, "macOS only");
2554 }
2555
2556 fn aws_target(launch_template: &str) -> TargetTemplate {
2557 TargetTemplate::AwsEc2 {
2558 aws_profile: Some("hel".into()),
2559 region: "us-east-1".into(),
2560 launch_template: launch_template.to_owned(),
2561 launch_template_version: None,
2562 ssh_user: "ubuntu".into(),
2563 address_source: mj_core::config::AwsAddressSource::default(),
2564 identity_file: None,
2565 ssh_args: vec![],
2566 }
2567 }
2568
2569 #[test]
2570 fn aws_check_is_ready_after_the_cli_credential_and_launch_template_probes() {
2571 let executor = FakeExecutor::new([
2572 Ok(output(b"aws-cli/2.17.0\n")),
2573 Ok(output(b"{\"Account\":\"123456789012\"}\n")),
2574 Ok(output(b"{\"LaunchTemplates\":[{}]}\n")),
2575 ]);
2576 let config = config_with([("aws", aws_target("hel-runson"))]);
2577
2578 let checks = aws_checks(Some(&config), &executor);
2579
2580 assert_eq!(checks.len(), 1);
2581 assert_eq!(checks[0].id, "runtime.aws-ec2.aws");
2582 assert_eq!(checks[0].status, CheckStatus::Ready);
2583 let commands = executor.commands.borrow();
2584 assert!(commands.iter().all(|command| command.program == "aws"));
2585 assert_eq!(
2587 commands[2].args,
2588 vec![
2589 "--profile",
2590 "hel",
2591 "--region",
2592 "us-east-1",
2593 "ec2",
2594 "describe-launch-templates",
2595 "--launch-template-names",
2596 "hel-runson",
2597 "--output",
2598 "json"
2599 ]
2600 );
2601 }
2602
2603 #[test]
2604 fn aws_check_is_fixable_with_an_install_remediation_without_the_cli() {
2605 let executor = FakeExecutor::new([Err(anyhow!("No such file or directory"))]);
2606
2607 let check = aws_target_check("aws", None, "us-east-1", "hel-runson", &executor);
2608
2609 assert_eq!(check.status, CheckStatus::Fixable);
2610 assert!(
2611 check
2612 .remediation
2613 .as_deref()
2614 .unwrap()
2615 .contains(AWS_CLI_INSTALL_URL)
2616 );
2617 assert_eq!(executor.commands.borrow().len(), 1);
2618 }
2619
2620 #[test]
2621 fn aws_check_is_fixable_with_a_sign_in_remediation_for_expired_credentials() {
2622 let executor = FakeExecutor::new([
2623 Ok(output(b"aws-cli/2.17.0\n")),
2624 Ok(failed(b"ExpiredToken: the security token has expired")),
2625 ]);
2626
2627 let check = aws_target_check("aws", Some("hel"), "us-east-1", "hel-runson", &executor);
2628
2629 assert_eq!(check.status, CheckStatus::Fixable);
2630 assert!(check.detail.contains("ExpiredToken"));
2631 assert_eq!(
2632 check.remediation.as_deref(),
2633 Some(
2634 "Configure credentials with `aws configure --profile hel`, or sign in with `aws sso login --profile hel`."
2635 )
2636 );
2637 }
2638
2639 #[test]
2640 fn aws_check_is_fixable_when_the_launch_template_is_missing() {
2641 let executor = FakeExecutor::new([
2642 Ok(output(b"aws-cli/2.17.0\n")),
2643 Ok(output(b"{\"Account\":\"123456789012\"}\n")),
2644 Ok(failed(b"InvalidLaunchTemplateName.NotFoundException")),
2645 ]);
2646
2647 let check = aws_target_check("aws", Some("hel"), "us-east-1", "lt-0123456789", &executor);
2648
2649 assert_eq!(check.status, CheckStatus::Fixable);
2650 assert!(check.detail.contains("was not found in us-east-1"));
2651 assert_eq!(
2653 executor.commands.borrow()[2].args[6],
2654 "--launch-template-ids"
2655 );
2656 }
2657
2658 #[test]
2659 fn aws_checks_are_skipped_for_configs_without_an_aws_target() {
2660 let executor = FakeExecutor::new([]);
2661 let config = config_with([(
2662 "podman",
2663 TargetTemplate::LocalPodman {
2664 container: container("ubuntu:24.04"),
2665 },
2666 )]);
2667
2668 assert!(aws_checks(Some(&config), &executor).is_empty());
2669 assert!(aws_checks(None, &executor).is_empty());
2670 assert!(executor.commands.borrow().is_empty());
2671 }
2672
2673 #[test]
2674 fn apple_container_daemon_check_is_ready_once_the_daemon_answers() {
2675 let executor = FakeExecutor::new([
2676 Ok(output(b"container version 1\n")),
2677 Ok(output(b"running\n")),
2678 ]);
2679
2680 let check = apple_container_daemon_check(&executor);
2681
2682 assert_eq!(check.status, CheckStatus::Ready);
2683 assert_eq!(executor.commands.borrow().len(), 2);
2684 }
2685
2686 #[test]
2687 fn linux_instructions_embed_podman_postconditions_and_doctor_loop() {
2688 let instructions = setup_instructions(InstructionsPlatform::Linux);
2689 assert!(instructions.contains("mj doctor --json"));
2690 assert!(instructions.contains("mj doctor --json --smoke"));
2691 assert!(instructions.contains("podman unshare cat /proc/self/uid_map"));
2692 assert!(instructions.contains("Podman **4.0.0 or newer**"));
2693 assert!(instructions.contains("kind = \"local-docker\""));
2694 assert!(instructions.contains("--opt type=overlay"));
2695 }
2696}