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