1use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use anyhow::Result;
8use serde::Serialize;
9
10use crate::controller::{WorkerBinaryAvailability, worker_binary_prerequisite_for_arch};
11use crate::setup::{
12 DiscoveredHome, discover_harness_homes_with_executor, harness_is_authenticated_with_executor,
13};
14use crate::targets::{
15 BoundedProcessExecutor, CommandExecutor, CommandSpec, CommandTimedOut,
16 ContainerTemplate as RuntimeContainerTemplate, DockerUnavailable, PODMAN_DOCUMENTATION_URL,
17 PodmanPostcondition, ProcessExecutor, SshTarget as RuntimeSshTarget,
18 TargetTemplate as RuntimeTargetTemplate, failed_podman_postcondition, podman_probe_observation,
19 run_setup_smoke_test, ssh_command, ssh_connectivity_probe, ssh_validation_command,
20 verify_local_docker, verify_local_podman, verify_ssh_docker, verify_ssh_podman,
21};
22use mj_core::config::{
23 Config, ContainerTemplate, HarnessKind, HarnessProfile, TargetTemplate, config_path,
24};
25use mj_core::credentials::login_command;
26
27const DEFAULT_CONTAINER_IMAGE: &str = "ubuntu:24.04";
33const APPLE_CONTAINER_INSTALL_URL: &str = "https://github.com/apple/container#initial-install";
34
35pub const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
42
43pub const fn probe_executor() -> BoundedProcessExecutor {
46 BoundedProcessExecutor::new(PROBE_TIMEOUT)
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "lowercase")]
51pub enum CheckStatus {
52 Ready,
53 Warning,
54 Fixable,
55 Unsupported,
56}
57
58impl CheckStatus {
59 pub const fn label(self) -> &'static str {
60 match self {
61 Self::Ready => "ready",
62 Self::Warning => "warning",
63 Self::Fixable => "fixable",
64 Self::Unsupported => "unsupported",
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
70pub struct DoctorCheck {
71 pub id: String,
72 pub title: String,
73 pub status: CheckStatus,
74 pub detail: String,
75 pub remediation: Option<String>,
76}
77
78impl DoctorCheck {
79 fn ready(id: impl Into<String>, title: impl Into<String>, detail: impl Into<String>) -> Self {
80 Self {
81 id: id.into(),
82 title: title.into(),
83 status: CheckStatus::Ready,
84 detail: detail.into(),
85 remediation: None,
86 }
87 }
88
89 fn warning(
90 id: impl Into<String>,
91 title: impl Into<String>,
92 detail: impl Into<String>,
93 remediation: impl Into<String>,
94 ) -> Self {
95 Self {
96 id: id.into(),
97 title: title.into(),
98 status: CheckStatus::Warning,
99 detail: detail.into(),
100 remediation: Some(remediation.into()),
101 }
102 }
103
104 pub(crate) fn fixable(
105 id: impl Into<String>,
106 title: impl Into<String>,
107 detail: impl Into<String>,
108 remediation: impl Into<String>,
109 ) -> Self {
110 Self {
111 id: id.into(),
112 title: title.into(),
113 status: CheckStatus::Fixable,
114 detail: detail.into(),
115 remediation: Some(remediation.into()),
116 }
117 }
118
119 fn unsupported(
120 id: impl Into<String>,
121 title: impl Into<String>,
122 detail: impl Into<String>,
123 ) -> Self {
124 Self {
125 id: id.into(),
126 title: title.into(),
127 status: CheckStatus::Unsupported,
128 detail: detail.into(),
129 remediation: None,
130 }
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct DoctorOptions {
136 pub smoke: bool,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum ApplePlatform {
141 Linux,
142 Macos {
143 architecture: String,
144 major_version: u32,
145 },
146 Other(String),
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum InstructionsPlatform {
151 Linux,
152 Macos,
153}
154
155pub fn run_current(options: DoctorOptions) -> Vec<DoctorCheck> {
156 if options.smoke {
157 return run_with(
161 &ProcessExecutor,
162 current_apple_platform(&ProcessExecutor),
163 options,
164 );
165 }
166 let executor = probe_executor();
167 run_with(&executor, current_apple_platform(&executor), options)
168}
169
170pub fn run_with(
171 executor: &impl CommandExecutor,
172 apple_platform: ApplePlatform,
173 options: DoctorOptions,
174) -> Vec<DoctorCheck> {
175 run_with_config_path(&config_path(), executor, apple_platform, options)
176}
177
178pub fn run_with_config_path(
184 config_path: &Path,
185 executor: &impl CommandExecutor,
186 apple_platform: ApplePlatform,
187 options: DoctorOptions,
188) -> Vec<DoctorCheck> {
189 let (loaded, mut checks) = configuration_checks(config_path);
190 let config: ConfigStatus<'_> = loaded.as_ref().map_err(|gap| *gap);
191 checks.push(harness_discovery_check(config, executor));
192 checks.extend(harness_checks(config, executor));
193 checks.extend(subagent_eligibility_checks(config));
194 let podman = podman_checks(config, executor, options.smoke);
195 let docker = docker_checks(config, executor, options.smoke);
196 let apple_container = apple_container_check(
197 &apple_platform,
198 executor,
199 options.smoke,
200 apple_container_image(config),
201 );
202 let offered = config.map(|config| {
203 offered_targets(
204 config,
205 podman.iter().chain(&docker).chain([&apple_container]),
206 )
207 });
208 let offered = offered.as_ref().map_err(|gap| *gap);
209 checks.extend(podman);
210 checks.extend(docker);
211 checks.extend(ssh_bare_checks(config, executor));
212 checks.extend(ssh_podman_checks(config, executor, options.smoke));
213 checks.extend(ssh_docker_checks(config, executor, options.smoke));
214 checks.extend(build_cache_checks(offered, executor));
215 checks.extend(aws_checks(config, executor));
216 checks.extend(worker_binary_checks(offered));
217 checks.push(daemon_build_check());
218 checks.extend(worker_freshness_checks(offered));
219 checks.extend(review_residue_checks(config));
220 checks.push(apple_container);
222 checks
223}
224
225fn build_cache_checks(
226 config: ConfigStatus<'_>,
227 executor: &impl CommandExecutor,
228) -> Vec<DoctorCheck> {
229 let Ok(config) = config else {
230 return Vec::new();
231 };
232 crate::controller::doctor_host_mbx(config, executor)
233 .into_iter()
234 .map(|host| {
235 let id = format!("build-cache.{}", host.host);
236 let title = format!("Build cache on {}", host.host);
237 let targets = host.targets.join(", ");
238 match host.status {
239 crate::controller::DoctorHostMbxStatus::Absent => DoctorCheck::ready(
240 id,
241 title,
242 format!(
243 "No native mbx is installed; targets {targets} can use Mjolnir's mbx {}.",
244 crate::controller::MBX_VERSION
245 ),
246 ),
247 crate::controller::DoctorHostMbxStatus::Compatible(version) => DoctorCheck::ready(
248 id,
249 title,
250 format!(
251 "Host mbx {version} is compatible with Mjolnir's mbx {} for targets {targets}.",
252 crate::controller::MBX_VERSION
253 ),
254 ),
255 crate::controller::DoctorHostMbxStatus::TooOld(version) => DoctorCheck::warning(
256 id,
257 title,
258 format!(
259 "Host mbx {version} is older than Mjolnir's mbx {}; sessions on targets {targets} run without the shared build cache.",
260 crate::controller::MBX_VERSION
261 ),
262 format!(
263 "Upgrade mbx on {} to {} or newer, then rerun `mj doctor`.",
264 host.host,
265 crate::controller::MBX_VERSION
266 ),
267 ),
268 crate::controller::DoctorHostMbxStatus::Unknown(error) => DoctorCheck::warning(
269 id,
270 title,
271 format!("Could not check host mbx for targets {targets}: {error}"),
272 format!(
273 "Check access to {} and run `mbx --version` there, then rerun `mj doctor`.",
274 host.host
275 ),
276 ),
277 }
278 })
279 .collect()
280}
281
282fn harness_discovery_check(
283 config: ConfigStatus<'_>,
284 executor: &impl CommandExecutor,
285) -> DoctorCheck {
286 let home = dirs::home_dir();
287 let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
288 std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
289 });
290 let discovered = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
291 harness_discovery_check_from(
292 &discovered,
293 config.is_ok_and(|config| !config.profiles.is_empty()),
294 &settings_key(config.ok()),
295 )
296}
297
298fn harness_discovery_check_from(
299 discovered: &[DiscoveredHome],
300 has_configured_profiles: bool,
301 settings_key: &str,
302) -> DoctorCheck {
303 if discovered.is_empty() {
304 return if has_configured_profiles {
305 DoctorCheck::ready(
306 "harness.discovery",
307 "Harness home discovery",
308 "No default or environment-overridden harness homes were found; configured profile homes are checked below.",
309 )
310 } else {
311 DoctorCheck::fixable(
312 "harness.discovery",
313 "Harness home discovery",
314 format!(
315 "No {} home was found in the default or environment-overridden locations.",
316 HarnessKind::every_display_name_or()
317 ),
318 format!(
319 "Install and sign in to a supported harness, then open Mjolnir, press {settings_key} for Settings, and choose Agent Profiles."
320 ),
321 )
322 };
323 }
324
325 let homes = discovered
326 .iter()
327 .map(|home| {
328 let authentication = if home.authenticated {
329 "authenticated"
330 } else {
331 "not authenticated"
332 };
333 format!(
334 "{} at {} ({authentication})",
335 home.kind.display_name(),
336 home.path.display()
337 )
338 })
339 .collect::<Vec<_>>()
340 .join("; ");
341 DoctorCheck::ready(
342 "harness.discovery",
343 "Harness home discovery",
344 format!("Discovered {homes}. Configured profile authentication is checked below."),
345 )
346}
347
348fn settings_key(config: Option<&Config>) -> String {
351 let keybinds = config.map_or_else(mj_core::config::Keybinds::default, Config::keybinds);
352 keybinds
353 .labels(mj_core::config::KeyAction::OpenSettings)
354 .into_iter()
355 .next()
356 .unwrap_or_else(|| "the Settings command in the command palette".to_owned())
357}
358
359pub fn all_ready(checks: &[DoctorCheck]) -> bool {
360 checks
361 .iter()
362 .all(|check| check.status != CheckStatus::Fixable)
363}
364
365pub fn render_human(checks: &[DoctorCheck], output: &mut impl Write) -> Result<()> {
366 for check in checks {
367 writeln!(
368 output,
369 "{} {}: {}",
370 check.status.label(),
371 check.title,
372 check.detail
373 )?;
374 if let Some(remediation) = &check.remediation {
375 writeln!(output, " remediation: {remediation}")?;
376 }
377 }
378 Ok(())
379}
380
381pub fn setup_instructions(platform: InstructionsPlatform) -> String {
382 match platform {
383 InstructionsPlatform::Linux => format!(
384 "# Mjolnir setup instructions for Linux\n\n\
385This page is self-contained. Follow this exact loop as the user who will run `mj`:\n\n\
3861. Run `mj doctor --json`.\n\
3872. Follow every `fixable` remediation from its JSON output.\n\
3883. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\
3894. Finish with `mj doctor --json --smoke` to verify every configured container\n\
390 image end to end, and resolve anything it reports as `fixable`.\n\n\
391For a coding-agent handoff, provide this entire instructions page together with\n\
392the latest `mj doctor --json` output.\n\n\
393## Local bare runtime\n\n\
394A local bare runtime runs the agent directly on this machine. It needs the\n\
395native `mj-worker` installed beside `mj`; the release installer and the npm\n\
396package include it. The worker installs the pinned harness version itself.\n\
397Codex and Claude need Node.js 22 or newer and npm on `PATH`. Kimi and Grok\n\
398need curl and Bash. Muse needs curl and tar. Mjolnir does not install these\n\
399prerequisites.\n\n\
400## Linux container-runtime postconditions\n\n{}\n\n{}",
401 crate::targets::PODMAN_DOCUMENTATION,
402 crate::targets::DOCKER_DOCUMENTATION
403 ),
404 InstructionsPlatform::Macos => format!(
405 "# Mjolnir setup instructions for macOS\n\n\
406This page is self-contained. Follow this exact loop as the user who will run `mj`:\n\n\
4071. Run `mj doctor --json`.\n\
4082. Follow every `fixable` remediation from its JSON output.\n\
4093. Run `mj doctor --json` again. Repeat until no check is `fixable`.\n\n\
410For a coding-agent handoff, provide this entire instructions page together with\n\
411the latest `mj doctor --json` output.\n\n\
412## Local bare runtime\n\n\
413A local bare runtime runs the agent directly on this machine. It needs the\n\
414native `mj-worker` installed beside `mj`; the release installer and the npm\n\
415package include it. The worker installs the pinned harness version itself.\n\
416Codex and Claude need Node.js 22 or newer and npm on `PATH`. Kimi and Grok\n\
417need curl and Bash. Muse needs curl and tar. Mjolnir does not install these\n\
418prerequisites.\n\n\
419## Apple container runtime\n\n\
420Mjolnir's Apple container target requires Apple silicon and macOS 26 or newer.\n\
421On an Intel Mac or an older macOS release, the target is unsupported; use the\n\
422local bare runtime, an SSH target, or an AWS target instead.\n\n\
423If the `container` command is absent, install only the official signed package:\n\n\
424<https://github.com/apple/container#initial-install>\n\n\
425Mjolnir never downloads or installs that package. If doctor reports a stopped\n\
426daemon, run exactly:\n\n```console\ncontainer system start\n```\n\n\
427Finish with the opt-in disposable runtime test in JSON mode:\n\n```console\nmj doctor --json --smoke\n```\n\n\
428Apple container is ready only when that smoke test creates a disposable\n\
429container, executes `true` in it, and removes it successfully. Use the image\n\
430configured by an `apple-container` target; without one, doctor uses\n\
431`{DEFAULT_CONTAINER_IMAGE}` for the smoke test.\n\n\
432## Shared Mjolnir prerequisites\n\n\
433`mj doctor --json` also checks the configuration, each configured harness home\n\
434and authentication marker, selected container worker binaries, and any relevant\n\
435Podman prerequisites. Resolve every `fixable` status before starting a session.\n"
436 ),
437 }
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448enum ConfigGap {
449 NewerVersion(u32),
451 Missing,
454 Unreadable,
456}
457
458impl ConfigGap {
459 fn awaited(self) -> &'static str {
462 match self {
463 ConfigGap::NewerVersion(_) => "this Mjolnir can read config.toml",
464 ConfigGap::Missing => "config.toml exists",
465 ConfigGap::Unreadable => "config.toml is valid",
466 }
467 }
468
469 fn remediation(self) -> &'static str {
475 match self {
476 ConfigGap::NewerVersion(_) => {
477 "Update Mjolnir to the build that wrote config.toml or newer, then rerun `mj doctor`."
478 }
479 ConfigGap::Missing => {
480 "Run `mj` and add an agent profile, or run `mj setup`; either one writes config.toml. Then rerun `mj doctor`."
481 }
482 ConfigGap::Unreadable => "Fix config.toml, then rerun `mj doctor`.",
483 }
484 }
485}
486
487type ConfigStatus<'a> = std::result::Result<&'a Config, ConfigGap>;
490
491fn newer_config_skip(id: &str, title: &str, version: u32) -> DoctorCheck {
496 DoctorCheck::unsupported(
497 id,
498 title,
499 format!(
500 "Skipped: config.toml was written by a newer Mjolnir (config version {version}; this build supports {}). Update Mjolnir to that build or newer.",
501 mj_core::config::CONFIG_VERSION
502 ),
503 )
504}
505
506fn configuration_checks(path: &Path) -> (std::result::Result<Config, ConfigGap>, Vec<DoctorCheck>) {
507 if !path.exists() {
508 return (
509 Err(ConfigGap::Missing),
510 vec![DoctorCheck::fixable(
511 "config",
512 "Mjolnir configuration",
513 format!("{} does not exist", path.display()),
514 format!(
515 "Open Mjolnir and press {} for Settings to add an agent profile.",
516 settings_key(None)
517 ),
518 )],
519 );
520 }
521 if let Some(found) = mj_core::config::newer_version_on_disk(path) {
525 return (
526 Err(ConfigGap::NewerVersion(found)),
527 vec![DoctorCheck::fixable(
528 "config",
529 "Mjolnir configuration",
530 format!(
531 "{} was written by a newer Mjolnir (config version {found}; this build supports {})",
532 path.display(),
533 mj_core::config::CONFIG_VERSION
534 ),
535 "Update Mjolnir to that build or newer. Do not lower the version value by hand or replace the file.",
536 )],
537 );
538 }
539 match Config::load_from(path) {
540 Ok(config) => {
541 let mut checks = vec![DoctorCheck::ready(
542 "config",
543 "Mjolnir configuration",
544 format!("{} is valid", path.display()),
545 )];
546 if config.enabled_profiles().next().is_none() {
550 checks.push(DoctorCheck::fixable(
551 "config.session-prerequisites",
552 "Session configuration",
553 "No agent profile is enabled, so no session can start. Local targets are supplied automatically.",
554 format!(
555 "Open Mjolnir and press {} for Settings to add or enable an agent profile.",
556 settings_key(Some(&config))
557 ),
558 ));
559 } else if config.bundles.is_empty() {
560 checks.push(DoctorCheck::ready(
561 "config.session-prerequisites",
562 "Session configuration",
563 "An agent profile is enabled. No project bundle is configured; sessions start from a project directory, and a bundle is only needed to start from a saved set of repositories.",
564 ));
565 } else {
566 checks.push(DoctorCheck::ready(
567 "config.session-prerequisites",
568 "Session configuration",
569 "At least one profile, bundle, and target are configured.",
570 ));
571 }
572 (Ok(config), checks)
573 }
574 Err(error) => (
575 Err(ConfigGap::Unreadable),
576 vec![DoctorCheck::fixable(
577 "config",
578 "Mjolnir configuration",
579 format!("{} is invalid: {error:#}", path.display()),
580 "Fix the reported TOML error in config.toml, or run `mj setup` to replace it.",
581 )],
582 ),
583 }
584}
585
586fn harness_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
587 let config = match config {
588 Ok(config) => config,
589 Err(ConfigGap::NewerVersion(version)) => {
590 return vec![newer_config_skip(
591 "harness.profiles",
592 "Harness profiles",
593 version,
594 )];
595 }
596 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
597 return vec![DoctorCheck::fixable(
598 "harness.profiles",
599 "Harness profiles",
600 format!("Harness homes cannot be checked until {}.", gap.awaited()),
601 gap.remediation(),
602 )];
603 }
604 };
605 if config.profiles.is_empty() {
606 return vec![DoctorCheck::fixable(
607 "harness.profiles",
608 "Harness profiles",
609 "No harness profiles are configured.",
610 format!(
611 "Open Mjolnir, press {} for Settings, and choose Agent Profiles to detect accounts or add a profile.",
612 settings_key(Some(config))
613 ),
614 )];
615 }
616 config
617 .profiles
618 .iter()
619 .map(|(id, profile)| {
620 let mut check = harness_profile_check(id, profile, executor);
621 check.detail = format!("{} {}", profile_summary(config, id, profile), check.detail);
622 check
623 })
624 .collect()
625}
626
627fn harness_profile_check(
629 id: &str,
630 profile: &HarnessProfile,
631 executor: &impl CommandExecutor,
632) -> DoctorCheck {
633 let title = format!("Harness profile {id}");
634 if !profile.enabled {
635 return DoctorCheck::ready(
636 format!("harness.{id}"),
637 title,
638 "Profile is disabled; home and authentication checks were skipped.",
639 );
640 }
641 if !profile.home.is_dir() {
642 return DoctorCheck::fixable(
643 format!("harness.{id}"),
644 title,
645 format!("{} does not exist", profile.home.display()),
646 format!(
647 "{} If this profile should use an existing installation, select its home in Setup.",
648 harness_login_remediation(id, profile)
649 ),
650 );
651 }
652 if !harness_is_authenticated_with_executor(profile, executor) {
653 return DoctorCheck::fixable(
654 format!("harness.{id}"),
655 title,
656 format!(
657 "No usable authentication was detected for {}",
658 profile.home.display()
659 ),
660 harness_login_remediation(id, profile),
661 );
662 }
663 DoctorCheck::ready(
664 format!("harness.{id}"),
665 title,
666 format!(
667 "{} is present and authentication is available",
668 profile.home.display()
669 ),
670 )
671}
672
673fn profile_summary(config: &Config, id: &str, profile: &HarnessProfile) -> String {
678 let delegation = if config
679 .subagents
680 .eligible_profiles
681 .get(id)
682 .copied()
683 .unwrap_or(false)
684 {
685 "any session's sub-agents may use it"
686 } else {
687 "only its own sessions' sub-agents may use it"
688 };
689 format!(
690 "{}; {}; {delegation}.",
691 profile.kind.display_name(),
692 profile_quota_source(profile)
693 )
694}
695
696fn profile_quota_source(profile: &HarnessProfile) -> String {
700 match profile.kind {
701 HarnessKind::Claude => "Claude subscription quota".to_owned(),
702 HarnessKind::Codex => match crate::quota::provider_credential(profile) {
703 Some(provider) if crate::zai_usage::serves_quota(&provider.host) => {
704 format!("quota from {}", provider.host)
705 }
706 Some(provider) => format!(
707 "pay-per-use through {}, counted as 100% left when choosing a sub-agent's profile",
708 provider.host
709 ),
710 None => match profile.codex_provider() {
711 Ok(None) => "ChatGPT subscription quota".to_owned(),
712 Ok(Some(provider)) => format!(
713 "no quota report, because custom provider {:?} has no API key in this profile's environment",
714 provider.id
715 ),
716 Err(error) => format!("its Codex config.toml could not be read ({error:#})"),
717 },
718 },
719 kind => format!("quota as {} reports it", kind.display_name()),
720 }
721}
722
723fn subagent_eligibility_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
733 let Ok(config) = config else {
734 return Vec::new();
735 };
736 let mut checks = vec![subagent_policy_check(config)];
737 checks.extend(
738 config
739 .subagents
740 .eligible_profiles
741 .iter()
742 .filter(|(_, eligible)| **eligible)
743 .filter_map(|(id, _)| match config.profiles.get(id) {
744 Some(profile) if !profile.enabled => Some(DoctorCheck::warning(
745 format!("subagents.{id}"),
746 format!("Sub-agent profile {id}"),
747 format!(
748 "Profile {id:?} is listed in [subagents.eligible_profiles] but is disabled, so it is not offered for sub-agent use."
749 ),
750 format!(
751 "Re-enable profile {id:?}, or remove it from [subagents.eligible_profiles]."
752 ),
753 )),
754 _ => None,
755 }),
756 );
757 checks
758}
759
760fn subagent_policy_check(config: &Config) -> DoctorCheck {
764 let subagents = &config.subagents;
765 let eligible = subagents
766 .eligible_profiles
767 .iter()
768 .filter(|(_, eligible)| **eligible)
769 .map(|(id, _)| id.as_str())
770 .collect::<Vec<_>>();
771 let others = if eligible.is_empty() {
772 "no other profile".to_owned()
773 } else {
774 eligible.join(", ")
775 };
776 let detail = format!(
777 "Claude and Codex sessions may opt in, up to {} sub-agents at once per session. A session's sub-agents may use its own profile and: {others}.",
778 subagents.max_concurrent
779 );
780 DoctorCheck::ready("subagents.policy", "Sub-agent policy", detail)
781}
782
783fn harness_login_remediation(id: &str, profile: &HarnessProfile) -> String {
790 let (program, arguments) = match login_command(profile) {
791 Ok(command) => command,
792 Err(error) => return format!("{error} Check {}.", profile.home.display()),
796 };
797 format!(
798 "Run `mj login --profile {id}`; it runs `{program} {}` against {}.",
799 arguments.join(" "),
800 profile.home.display()
801 )
802}
803
804fn podman_checks(
809 config: ConfigStatus<'_>,
810 executor: &impl CommandExecutor,
811 smoke: bool,
812) -> Vec<DoctorCheck> {
813 let effective = config.map(|config| config.clone().with_local_targets());
814 let effective = effective.as_ref().map_err(|gap| *gap);
815 let explicit = config.is_ok_and(|config| {
816 local_podman_targets(config)
817 .iter()
818 .any(|(id, _)| config.configures_target(id))
819 });
820 let preflight = builtin_target_availability(
821 podman_check(effective, executor),
822 explicit,
823 "Podman",
824 "podman",
825 );
826 let preflight_passed = preflight.status == CheckStatus::Ready;
827 let mut checks = vec![preflight];
828 if preflight_passed {
829 checks.extend(
830 podman_image_checks(effective, executor, smoke)
831 .into_iter()
832 .map(|(id, check)| builtin_image_check(config, &id, check)),
833 );
834 }
835 checks
836}
837
838fn builtin_image_check(
842 config: ConfigStatus<'_>,
843 target_id: &str,
844 check: DoctorCheck,
845) -> DoctorCheck {
846 let explicit = config.is_ok_and(|config| config.configures_target(target_id));
847 if explicit || check.status != CheckStatus::Fixable {
848 return check;
849 }
850 DoctorCheck::warning(
851 check.id,
852 check.title,
853 format!(
854 "{} (built-in `{target_id}` target; the dashboard downloads its image when it starts)",
855 check.detail
856 ),
857 check.remediation.unwrap_or_default(),
858 )
859}
860
861fn builtin_target_availability(
870 check: DoctorCheck,
871 explicit: bool,
872 engine: &str,
873 target_id: &str,
874) -> DoctorCheck {
875 if explicit || check.status != CheckStatus::Fixable {
876 return check;
877 }
878 DoctorCheck::unsupported(
879 check.id,
880 check.title,
881 format!(
882 "{engine} is not available, so the built-in `{target_id}` target is marked unavailable: {}",
883 check.detail
884 ),
885 )
886}
887
888fn podman_check(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> DoctorCheck {
889 let config = match config {
890 Ok(config) => config,
891 Err(ConfigGap::NewerVersion(version)) => {
892 return newer_config_skip("runtime.podman", "Rootless Podman", version);
893 }
894 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
895 return DoctorCheck::unsupported(
896 "runtime.podman",
897 "Rootless Podman",
898 format!(
899 "Podman prerequisites cannot be evaluated until {}.",
900 gap.awaited()
901 ),
902 );
903 }
904 };
905 if local_podman_targets(config).is_empty() {
906 return DoctorCheck::unsupported(
907 "runtime.podman",
908 "Rootless Podman",
909 "No local-podman target is configured.",
910 );
911 }
912 local_podman_runtime_check(executor)
913}
914
915pub fn local_podman_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
922 match verify_local_podman(executor) {
923 Ok(preflight) => DoctorCheck::ready(
924 "runtime.podman",
925 "Rootless Podman",
926 format!("Podman {} has a valid rootless UID map.", preflight.version),
927 ),
928 Err(error) => DoctorCheck::fixable(
929 "runtime.podman",
930 "Rootless Podman",
931 podman_failure_detail(&error),
932 podman_remediation(&error),
933 ),
934 }
935}
936
937fn local_podman_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
938 config
939 .targets
940 .iter()
941 .filter_map(|(id, target)| match target {
942 TargetTemplate::LocalPodman { container } => Some((id, container)),
943 _ => None,
944 })
945 .collect()
946}
947
948fn podman_image_checks(
949 config: ConfigStatus<'_>,
950 executor: &impl CommandExecutor,
951 smoke: bool,
952) -> Vec<(String, DoctorCheck)> {
953 let Ok(config) = config else {
954 return Vec::new();
955 };
956 local_podman_targets(config)
957 .into_iter()
958 .map(|(id, container)| {
959 (
960 id.clone(),
961 podman_image_check(id, &container.image, executor, smoke),
962 )
963 })
964 .collect()
965}
966
967fn podman_image_check(
968 id: &str,
969 image: &str,
970 executor: &impl CommandExecutor,
971 smoke: bool,
972) -> DoctorCheck {
973 let check_id = format!("runtime.podman.image.{id}");
974 let title = format!("Podman image for target {id}");
975 if smoke {
976 let target = RuntimeTargetTemplate::LocalPodman(RuntimeContainerTemplate {
977 build_cache: None,
978 image: image.to_owned(),
979 pull_policy: Default::default(),
980 extra_run_args: vec![],
981 workspace_storage: Default::default(),
982 });
983 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
984 Ok(()) => DoctorCheck::ready(
985 check_id,
986 title,
987 format!("Disposable run/exec/remove smoke test passed for image {image}."),
988 ),
989 Err(error) => DoctorCheck::fixable(
990 check_id,
991 title,
992 format!(
993 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
994 ),
995 "Fix the configured image or Podman runtime, then run `mj doctor --json --smoke` again.",
996 ),
997 };
998 }
999
1000 let command = CommandSpec::new("podman", ["image", "exists", image])
1001 .purpose("check Podman image presence");
1002 match executor.execute(&command) {
1003 Ok(output) if output.status == 0 => DoctorCheck::ready(
1004 check_id,
1005 title,
1006 format!("Image {image} is present in local Podman storage."),
1007 ),
1008 Ok(_) => DoctorCheck::fixable(
1009 check_id,
1010 title,
1011 format!("Image {image} is not present in local Podman storage."),
1012 missing_image_remediation(image),
1013 ),
1014 Err(error) => DoctorCheck::fixable(
1015 check_id,
1016 title,
1017 format!(
1018 "Could not check whether image {image} is present in local Podman storage: {error}"
1019 ),
1020 missing_image_remediation(image),
1021 ),
1022 }
1023}
1024
1025fn missing_image_remediation(image: &str) -> String {
1026 format!(
1027 "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."
1028 )
1029}
1030
1031fn docker_checks(
1033 config: ConfigStatus<'_>,
1034 executor: &impl CommandExecutor,
1035 smoke: bool,
1036) -> Vec<DoctorCheck> {
1037 let config = match config {
1038 Ok(config) => config,
1039 Err(ConfigGap::NewerVersion(version)) => {
1040 return vec![newer_config_skip("runtime.docker", "Docker", version)];
1041 }
1042 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
1043 return vec![DoctorCheck::unsupported(
1044 "runtime.docker",
1045 "Docker",
1046 format!(
1047 "Docker prerequisites cannot be evaluated until {}.",
1048 gap.awaited()
1049 ),
1050 )];
1051 }
1052 };
1053 let explicit = local_docker_targets(config)
1054 .iter()
1055 .any(|(id, _)| config.configures_target(id));
1056 let effective = config.clone().with_local_targets();
1057 let targets = local_docker_targets(&effective);
1058 if targets.is_empty() {
1059 return vec![DoctorCheck::unsupported(
1060 "runtime.docker",
1061 "Docker",
1062 "No local-docker target is configured.",
1063 )];
1064 }
1065 let preflight = builtin_target_availability(
1066 local_docker_runtime_check(executor),
1067 explicit,
1068 "Docker",
1069 "docker",
1070 );
1071 if preflight.status != CheckStatus::Ready {
1072 return vec![preflight];
1073 }
1074 let mut checks = vec![preflight];
1075 checks.extend(targets.into_iter().map(|(id, container)| {
1076 builtin_image_check(
1077 Ok(config),
1078 id,
1079 docker_image_check(id, &container.image, executor, smoke),
1080 )
1081 }));
1082 checks
1083}
1084
1085pub fn local_docker_runtime_check(executor: &impl CommandExecutor) -> DoctorCheck {
1086 match verify_local_docker(executor) {
1087 Ok(preflight) => DoctorCheck::ready(
1088 "runtime.docker",
1089 "Docker",
1090 format!(
1091 "Docker {} is connected to a Linux daemon.",
1092 preflight.version
1093 ),
1094 ),
1095 Err(error) => match error.downcast_ref::<DockerUnavailable>() {
1096 Some(problem) => DoctorCheck::fixable(
1097 "runtime.docker",
1098 "Docker",
1099 problem.to_string(),
1100 problem.remediation(),
1101 ),
1102 None => DoctorCheck::fixable(
1103 "runtime.docker",
1104 "Docker",
1105 format!("{error:#}"),
1106 "Install and start Docker, then make sure `docker info` succeeds as the user running mj.",
1107 ),
1108 },
1109 }
1110}
1111
1112fn local_docker_targets(config: &Config) -> Vec<(&String, &ContainerTemplate)> {
1113 config
1114 .targets
1115 .iter()
1116 .filter_map(|(id, target)| match target {
1117 TargetTemplate::LocalDocker { container } => Some((id, container)),
1118 _ => None,
1119 })
1120 .collect()
1121}
1122
1123fn docker_image_check(
1124 id: &str,
1125 image: &str,
1126 executor: &impl CommandExecutor,
1127 smoke: bool,
1128) -> DoctorCheck {
1129 let check_id = format!("runtime.docker.image.{id}");
1130 let title = format!("Docker image for target {id}");
1131 if smoke {
1132 let target = RuntimeTargetTemplate::LocalDocker(RuntimeContainerTemplate {
1133 build_cache: None,
1134 image: image.to_owned(),
1135 pull_policy: Default::default(),
1136 extra_run_args: vec![],
1137 workspace_storage: Default::default(),
1138 });
1139 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1140 Ok(()) => DoctorCheck::ready(
1141 check_id,
1142 title,
1143 format!(
1144 "Disposable run/exec/remove and OverlayFS attachment smoke test passed for image {image}."
1145 ),
1146 ),
1147 Err(error) => DoctorCheck::fixable(
1148 check_id,
1149 title,
1150 format!(
1151 "Disposable run/exec/remove smoke test failed for image {image}: {error:#}"
1152 ),
1153 "Fix the configured image or Docker runtime, then run `mj doctor --json --smoke` again.",
1154 ),
1155 };
1156 }
1157 let command = CommandSpec::new("docker", ["image", "inspect", image])
1158 .purpose("check Docker image presence");
1159 match executor.execute(&command) {
1160 Ok(output) if output.status == 0 => DoctorCheck::ready(
1161 check_id,
1162 title,
1163 format!("Image {image} is present in Docker storage."),
1164 ),
1165 Ok(_) => DoctorCheck::fixable(
1166 check_id,
1167 title,
1168 format!("Image {image} is not present in Docker storage."),
1169 format!("Pull it with `docker pull {image}`, or run `mj doctor --json --smoke`."),
1170 ),
1171 Err(error) => DoctorCheck::fixable(
1172 check_id,
1173 title,
1174 format!("Could not inspect Docker image {image}: {error}"),
1175 format!("Make sure `docker info` succeeds, then run `docker pull {image}`."),
1176 ),
1177 }
1178}
1179
1180enum SshConnectivity {
1185 Reachable,
1186 Failed { detail: String, remediation: String },
1187}
1188
1189fn ssh_connectivity(ssh: &RuntimeSshTarget, executor: &impl CommandExecutor) -> SshConnectivity {
1194 let destination = &ssh.destination;
1195 let command = ssh_connectivity_probe(ssh);
1196 match executor.execute(&command) {
1197 Err(error) => SshConnectivity::Failed {
1198 detail: format!("Could not run `ssh {destination} true`: {error:#}"),
1199 remediation: ssh_launch_failure_remediation(&error, ssh),
1200 },
1201 Ok(output) if output.status != 0 => {
1202 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1203 SshConnectivity::Failed {
1204 detail: format!("`ssh {destination} true` failed: {stderr}"),
1205 remediation: ssh_failure_remediation(&stderr, ssh),
1206 }
1207 }
1208 Ok(_) => SshConnectivity::Reachable,
1209 }
1210}
1211
1212const 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).";
1213
1214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1220enum SshFailure {
1221 UntrustedHostKey,
1222 Unauthenticated,
1223 ClientMissing,
1224 Unreachable,
1226 Unrecognized,
1227}
1228
1229fn classify_ssh_stderr(stderr: &str) -> SshFailure {
1230 const UNTRUSTED_HOST_KEY: [&str; 3] = [
1231 "Host key verification failed",
1232 "No ECDSA host key is known",
1233 "REMOTE HOST IDENTIFICATION HAS CHANGED",
1234 ];
1235 const UNAUTHENTICATED: [&str; 4] = [
1236 "Permission denied",
1237 "Too many authentication failures",
1238 "no matching host key",
1239 "Authentication failed",
1240 ];
1241 const CLIENT_MISSING: [&str; 2] = ["ssh: command not found", "No such file or directory"];
1242 const UNREACHABLE: [&str; 3] = [
1243 "Connection timed out",
1244 "No route to host",
1245 "Network is unreachable",
1246 ];
1247
1248 let reported = |signatures: &[&str]| signatures.iter().any(|text| stderr.contains(text));
1249 if reported(&UNTRUSTED_HOST_KEY) {
1250 SshFailure::UntrustedHostKey
1251 } else if reported(&UNAUTHENTICATED) {
1252 SshFailure::Unauthenticated
1253 } else if reported(&CLIENT_MISSING) {
1254 SshFailure::ClientMissing
1255 } else if reported(&UNREACHABLE) {
1256 SshFailure::Unreachable
1257 } else {
1258 SshFailure::Unrecognized
1259 }
1260}
1261
1262fn ssh_launch_failure_remediation(error: &anyhow::Error, ssh: &RuntimeSshTarget) -> String {
1265 if error.downcast_ref::<CommandTimedOut>().is_some() {
1266 return ssh_unreachable_remediation(ssh);
1267 }
1268 let missing_binary = error.chain().any(|cause| {
1269 cause
1270 .downcast_ref::<std::io::Error>()
1271 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
1272 });
1273 if missing_binary {
1274 return SSH_MISSING_REMEDIATION.to_owned();
1275 }
1276 format!(
1277 "Run `ssh {} true` by hand and resolve the error it reports: {error:#}",
1278 ssh.destination
1279 )
1280}
1281
1282fn ssh_unreachable_remediation(ssh: &RuntimeSshTarget) -> String {
1285 let host = ssh_host_only(&ssh.destination);
1286 format!(
1287 "Check that {host} is up and reachable from this machine: wake it, bring up the VPN, or refresh the cloud session that exposes it, then run `ssh {} true` by hand.",
1288 ssh.destination
1289 )
1290}
1291
1292fn ssh_failure_remediation(stderr: &str, ssh: &RuntimeSshTarget) -> String {
1294 let destination = &ssh.destination;
1295 match classify_ssh_stderr(stderr) {
1296 SshFailure::UntrustedHostKey => {
1297 let host = ssh_host_only(destination);
1298 format!(
1299 "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."
1300 )
1301 }
1302 SshFailure::Unauthenticated => match ssh_identity_file(ssh) {
1303 Some(identity) => format!(
1304 "Install your public key on the host with `ssh-copy-id -i {identity}.pub {destination}`."
1305 ),
1306 None => {
1307 format!("Install your public key on the host with `ssh-copy-id {destination}`.")
1308 }
1309 },
1310 SshFailure::ClientMissing => SSH_MISSING_REMEDIATION.to_owned(),
1311 SshFailure::Unreachable => ssh_unreachable_remediation(ssh),
1312 SshFailure::Unrecognized => {
1313 format!(
1314 "Run `ssh {destination} true` by hand and resolve the error it reports: {stderr}"
1315 )
1316 }
1317 }
1318}
1319
1320fn ssh_host_only(destination: &str) -> &str {
1322 destination
1323 .rsplit_once('@')
1324 .map_or(destination, |(_, host)| host)
1325}
1326
1327fn ssh_identity_file(ssh: &RuntimeSshTarget) -> Option<&str> {
1329 let position = ssh.ssh_args.iter().position(|arg| arg == "-i")?;
1330 ssh.ssh_args.get(position + 1).map(String::as_str)
1331}
1332
1333fn ssh_bare_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1335 let Ok(config) = config else {
1336 return Vec::new();
1337 };
1338 config
1339 .targets
1340 .iter()
1341 .filter_map(|(id, target)| match target {
1342 TargetTemplate::SshBare { ssh, .. } => {
1343 Some(ssh_bare_check(id, &RuntimeSshTarget::from(ssh), executor))
1344 }
1345 _ => None,
1346 })
1347 .collect()
1348}
1349
1350fn ssh_bare_check(
1351 id: &str,
1352 ssh: &RuntimeSshTarget,
1353 executor: &impl CommandExecutor,
1354) -> DoctorCheck {
1355 let check_id = format!("runtime.ssh-bare.{id}");
1356 let title = format!("SSH access for target {id}");
1357 match ssh_connectivity(ssh, executor) {
1358 SshConnectivity::Reachable => DoctorCheck::ready(
1359 check_id,
1360 title,
1361 format!(
1362 "`ssh {} true` succeeds noninteractively from this host.",
1363 ssh.destination
1364 ),
1365 ),
1366 SshConnectivity::Failed {
1367 detail,
1368 remediation,
1369 } => DoctorCheck::fixable(check_id, title, detail, remediation),
1370 }
1371}
1372
1373fn ssh_podman_checks(
1376 config: ConfigStatus<'_>,
1377 executor: &impl CommandExecutor,
1378 smoke: bool,
1379) -> Vec<DoctorCheck> {
1380 let Ok(config) = config else {
1381 return Vec::new();
1382 };
1383 config
1384 .targets
1385 .iter()
1386 .flat_map(|(id, target)| match target {
1387 TargetTemplate::SshPodman { ssh, container, .. } => {
1388 let ssh = RuntimeSshTarget::from(ssh);
1389 let (check, reachable) =
1390 ssh_podman_check(id, &ssh, &container.image, executor, smoke);
1391 let mut checks = vec![check];
1392 if reachable {
1394 checks.push(ssh_podman_limits_check(id, &ssh, executor));
1395 }
1396 checks
1397 }
1398 _ => Vec::new(),
1399 })
1400 .collect()
1401}
1402
1403fn ssh_podman_check(
1406 id: &str,
1407 ssh: &RuntimeSshTarget,
1408 image: &str,
1409 executor: &impl CommandExecutor,
1410 smoke: bool,
1411) -> (DoctorCheck, bool) {
1412 let check_id = format!("runtime.ssh-podman.{id}");
1413 let title = format!("Remote Podman for target {id}");
1414 if let SshConnectivity::Failed {
1417 detail,
1418 remediation,
1419 } = ssh_connectivity(ssh, executor)
1420 {
1421 return (
1422 DoctorCheck::fixable(check_id, title, detail, remediation),
1423 false,
1424 );
1425 }
1426 (
1427 ssh_podman_runtime_check(check_id, title, ssh, image, executor, smoke),
1428 true,
1429 )
1430}
1431
1432fn ssh_podman_runtime_check(
1434 check_id: String,
1435 title: String,
1436 ssh: &RuntimeSshTarget,
1437 image: &str,
1438 executor: &impl CommandExecutor,
1439 smoke: bool,
1440) -> DoctorCheck {
1441 let destination = &ssh.destination;
1442 let preflight = match verify_ssh_podman(ssh, executor) {
1443 Ok(preflight) => preflight,
1444 Err(error) => {
1445 let detail = podman_failure_detail(&error);
1446 let remediation = match podman_remediation_match(&error) {
1447 Some(remediation) => {
1448 format!("On {destination}: {remediation} See {PODMAN_DOCUMENTATION_URL}.")
1449 }
1450 None => format!(
1451 "Verify `ssh {destination}` succeeds noninteractively from this host, then install rootless Podman 4.3 or newer there. See {PODMAN_DOCUMENTATION_URL}."
1452 ),
1453 };
1454 return DoctorCheck::fixable(check_id, title, detail, remediation);
1455 }
1456 };
1457 let linger_warning = preflight.warnings.first();
1458 if !smoke && let Some(warning) = linger_warning {
1459 return DoctorCheck::warning(
1460 check_id,
1461 title,
1462 format!(
1463 "Remote rootless Podman {} is available via {destination}, but {}",
1464 preflight.version, warning.detail
1465 ),
1466 &warning.remediation,
1467 );
1468 }
1469 if !smoke {
1470 return DoctorCheck::ready(
1471 check_id,
1472 title,
1473 format!(
1474 "Remote rootless Podman {} is available via {destination}. Run `mj doctor --json --smoke` to verify the image end to end.",
1475 preflight.version
1476 ),
1477 );
1478 }
1479
1480 let target = RuntimeTargetTemplate::SshPodman {
1481 ssh: ssh.clone(),
1482 container: RuntimeContainerTemplate {
1483 build_cache: None,
1484 image: image.to_owned(),
1485 pull_policy: Default::default(),
1486 extra_run_args: vec![],
1487 workspace_storage: Default::default(),
1488 },
1489 };
1490 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1491 Ok(()) => match linger_warning {
1492 Some(warning) => DoctorCheck::warning(
1493 check_id,
1494 title,
1495 format!(
1496 "Disposable run/exec/remove smoke test passed for image {image} on {destination}, but {}",
1497 warning.detail
1498 ),
1499 &warning.remediation,
1500 ),
1501 None => DoctorCheck::ready(
1502 check_id,
1503 title,
1504 format!(
1505 "Disposable run/exec/remove smoke test passed for image {image} on {destination}."
1506 ),
1507 ),
1508 },
1509 Err(error) => DoctorCheck::fixable(
1510 check_id,
1511 title,
1512 format!(
1513 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1514 ),
1515 format!(
1516 "Fix the configured image or Podman runtime on {destination}, then run `mj doctor --json --smoke` again."
1517 ),
1518 ),
1519 }
1520}
1521
1522const SSH_PODMAN_HOST_LIMITS_SCRIPT: &str = r#"
1531if [ -r /proc/sys/kernel/keys/maxkeys ]; then
1532 printf 'keys.max=%s\n' "$(cat /proc/sys/kernel/keys/maxkeys)"
1533fi
1534if [ -r /proc/key-users ]; then
1535 awk -v uid="$(id -u)" '
1536 { user = $1; sub(/:$/, "", user) }
1537 user == uid {
1538 split($4, quota, "/")
1539 printf "keys.used=%s\nkeys.quota=%s\n", quota[1], quota[2]
1540 }
1541 ' /proc/key-users
1542fi
1543unreadable=0
1544maxstartups=
1545# A drop-in directory that cannot be listed hides any override it holds.
1546if [ -d /etc/ssh/sshd_config.d ] && ! [ -r /etc/ssh/sshd_config.d ]; then
1547 unreadable=1
1548fi
1549for file in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
1550 [ -e "$file" ] || continue
1551 if [ -r "$file" ]; then
1552 match=$(grep -i '^[[:space:]]*maxstartups[[:space:]]' "$file" 2>/dev/null | tail -n 1)
1553 [ -n "$match" ] && maxstartups=$(printf '%s\n' "$match" | awk '{ print $2 }')
1554 else
1555 unreadable=1
1556 fi
1557done
1558[ -n "$maxstartups" ] && printf 'maxstartups=%s\n' "$maxstartups"
1559[ "$unreadable" = 1 ] && printf 'maxstartups.unreadable=1\n'
1560exit 0
1561"#;
1562
1563const KEYRING_PRESSURE_PERCENT: u64 = 80;
1567
1568#[derive(Debug, Default, PartialEq, Eq)]
1571struct HostLimits {
1572 keys_used: Option<u64>,
1573 keys_quota: Option<u64>,
1574 keys_max: Option<u64>,
1575 max_startups: Option<String>,
1576 max_startups_unreadable: bool,
1577}
1578
1579fn parse_host_limits(stdout: &[u8]) -> HostLimits {
1580 let text = String::from_utf8_lossy(stdout);
1581 let mut limits = HostLimits::default();
1582 for line in text.lines() {
1583 let Some((name, value)) = line.split_once('=') else {
1584 continue;
1585 };
1586 let value = value.trim();
1587 match name.trim() {
1588 "keys.used" => limits.keys_used = value.parse().ok(),
1589 "keys.quota" => limits.keys_quota = value.parse().ok(),
1590 "keys.max" => limits.keys_max = value.parse().ok(),
1591 "maxstartups" if !value.is_empty() => limits.max_startups = Some(value.to_owned()),
1592 "maxstartups.unreadable" => limits.max_startups_unreadable = value == "1",
1593 _ => {}
1594 }
1595 }
1596 limits
1597}
1598
1599impl HostLimits {
1600 fn is_empty(&self) -> bool {
1602 self.keys_used.is_none()
1603 && self.keys_quota.is_none()
1604 && self.keys_max.is_none()
1605 && self.max_startups.is_none()
1606 && !self.max_startups_unreadable
1607 }
1608
1609 fn keyring_is_under_pressure(&self) -> bool {
1610 match (self.keys_used, self.keys_quota) {
1611 (Some(used), Some(quota)) if quota > 0 => {
1612 used.saturating_mul(100) >= quota.saturating_mul(KEYRING_PRESSURE_PERCENT)
1613 }
1614 _ => false,
1615 }
1616 }
1617
1618 fn keyring_sentence(&self, destination: &str) -> String {
1619 match (self.keys_used, self.keys_quota) {
1620 (Some(used), Some(quota)) => {
1621 let system = match self.keys_max {
1622 Some(max) => format!(", and `kernel.keys.maxkeys` is {max}"),
1623 None => String::new(),
1624 };
1625 format!(
1626 "The login user on {destination} holds {used} of its {quota} kernel keyring quota{system}."
1627 )
1628 }
1629 _ => format!(
1630 "The kernel keyring quota for the login user on {destination} could not be read."
1631 ),
1632 }
1633 }
1634
1635 fn max_startups_sentence(&self) -> String {
1636 match (&self.max_startups, self.max_startups_unreadable) {
1637 (Some(value), _) => format!("sshd MaxStartups is {value}."),
1638 (None, true) => "sshd MaxStartups is not set in a readable sshd_config file, so sshd's default applies unless an unreadable drop-in overrides it.".to_owned(),
1639 (None, false) => {
1640 "sshd MaxStartups is not set in sshd_config, so sshd's default applies.".to_owned()
1641 }
1642 }
1643 }
1644}
1645
1646fn ssh_podman_limits_check(
1650 id: &str,
1651 ssh: &RuntimeSshTarget,
1652 executor: &impl CommandExecutor,
1653) -> DoctorCheck {
1654 let check_id = format!("runtime.ssh-podman.{id}.limits");
1655 let title = format!("Host limits for target {id}");
1656 let destination = &ssh.destination;
1657 let manual = || {
1658 format!(
1659 "Read them by hand on {destination}: `cat /proc/key-users /proc/sys/kernel/keys/maxkeys` and `grep -ri maxstartups /etc/ssh/sshd_config /etc/ssh/sshd_config.d`."
1660 )
1661 };
1662 let command = ssh_validation_command(
1663 ssh,
1664 vec![
1665 "sh".to_owned(),
1666 "-c".to_owned(),
1667 SSH_PODMAN_HOST_LIMITS_SCRIPT.to_owned(),
1668 ],
1669 "read ssh-podman host limits",
1670 );
1671 let limits = match executor.execute(&command) {
1672 Ok(output) if output.status == 0 => parse_host_limits(&output.stdout),
1673 Ok(output) => {
1674 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1675 return DoctorCheck::warning(
1676 check_id,
1677 title,
1678 format!(
1679 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {stderr}"
1680 ),
1681 manual(),
1682 );
1683 }
1684 Err(error) => {
1685 return DoctorCheck::warning(
1686 check_id,
1687 title,
1688 format!(
1689 "Could not read the kernel keyring quota or sshd MaxStartups from {destination}: {error}"
1690 ),
1691 manual(),
1692 );
1693 }
1694 };
1695 if limits.is_empty() {
1696 return DoctorCheck::warning(
1697 check_id,
1698 title,
1699 format!("{destination} reported no readable kernel keyring or sshd limits."),
1700 manual(),
1701 );
1702 }
1703 let detail = format!(
1704 "{} {}",
1705 limits.keyring_sentence(destination),
1706 limits.max_startups_sentence()
1707 );
1708 if limits.keyring_is_under_pressure() {
1709 return DoctorCheck::warning(
1710 check_id,
1711 title,
1712 format!(
1713 "{detail} Every container takes a session keyring, so `podman run` fails with `crun: create keyring` once the quota is gone."
1714 ),
1715 format!(
1716 "Raise `kernel.keys.maxkeys` and `kernel.keys.maxbytes` with sysctl on {destination}, and close finished sessions promptly."
1717 ),
1718 );
1719 }
1720 DoctorCheck::ready(check_id, title, detail)
1721}
1722
1723fn ssh_docker_checks(
1726 config: ConfigStatus<'_>,
1727 executor: &impl CommandExecutor,
1728 smoke: bool,
1729) -> Vec<DoctorCheck> {
1730 let Ok(config) = config else {
1731 return Vec::new();
1732 };
1733 config
1734 .targets
1735 .iter()
1736 .filter_map(|(id, target)| match target {
1737 TargetTemplate::SshDocker { ssh, container } => Some(ssh_docker_check(
1738 id,
1739 &RuntimeSshTarget::from(ssh),
1740 &container.image,
1741 executor,
1742 smoke,
1743 )),
1744 _ => None,
1745 })
1746 .collect()
1747}
1748
1749fn ssh_docker_check(
1750 id: &str,
1751 ssh: &RuntimeSshTarget,
1752 image: &str,
1753 executor: &impl CommandExecutor,
1754 smoke: bool,
1755) -> DoctorCheck {
1756 let check_id = format!("runtime.ssh-docker.{id}");
1757 let title = format!("Remote Docker for target {id}");
1758 let destination = &ssh.destination;
1759 if let SshConnectivity::Failed {
1760 detail,
1761 remediation,
1762 } = ssh_connectivity(ssh, executor)
1763 {
1764 return DoctorCheck::fixable(check_id, title, detail, remediation);
1765 }
1766
1767 let preflight = match verify_ssh_docker(ssh, executor) {
1768 Ok(preflight) => preflight,
1769 Err(error) => {
1770 let detail = format!("{error:#}");
1771 return DoctorCheck::fixable(
1772 check_id,
1773 title,
1774 detail,
1775 format!(
1776 "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."
1777 ),
1778 );
1779 }
1780 };
1781
1782 if smoke {
1783 let target = RuntimeTargetTemplate::SshDocker {
1784 ssh: ssh.clone(),
1785 container: RuntimeContainerTemplate {
1786 build_cache: None,
1787 image: image.to_owned(),
1788 pull_policy: Default::default(),
1789 extra_run_args: vec![],
1790 workspace_storage: Default::default(),
1791 },
1792 };
1793 return match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
1794 Ok(()) => DoctorCheck::ready(
1795 check_id,
1796 title,
1797 format!(
1798 "Remote Docker {} is available via {destination}; disposable run/exec/remove and remote OverlayFS attachment smoke test passed for image {image}.",
1799 preflight.version
1800 ),
1801 ),
1802 Err(error) => DoctorCheck::fixable(
1803 check_id,
1804 title,
1805 format!(
1806 "Disposable run/exec/remove smoke test failed for image {image} on {destination}: {error:#}"
1807 ),
1808 format!(
1809 "Fix the configured image or Docker runtime on {destination}, then run `mj doctor --json --smoke` again."
1810 ),
1811 ),
1812 };
1813 }
1814
1815 let image_command = ssh_command(
1816 ssh,
1817 [
1818 "docker".to_owned(),
1819 "image".to_owned(),
1820 "inspect".to_owned(),
1821 image.to_owned(),
1822 ]
1823 .to_vec(),
1824 )
1825 .purpose("check remote Docker image presence");
1826 match executor.execute(&image_command) {
1827 Ok(output) if output.status == 0 => DoctorCheck::ready(
1828 check_id,
1829 title,
1830 format!(
1831 "Remote Docker {} is available via {destination}; image {image} is present. Run `mj doctor --json --smoke` to verify remote OverlayFS attachments.",
1832 preflight.version
1833 ),
1834 ),
1835 Ok(output) => DoctorCheck::fixable(
1836 check_id,
1837 title,
1838 format!(
1839 "Image {image} is not present in remote Docker storage on {destination}: {}",
1840 String::from_utf8_lossy(&output.stderr).trim()
1841 ),
1842 format!(
1843 "Pull it on {destination} with `ssh {destination} docker pull {image}`, or run `mj doctor --json --smoke`."
1844 ),
1845 ),
1846 Err(error) => DoctorCheck::fixable(
1847 check_id,
1848 title,
1849 format!("Could not inspect remote Docker image {image} on {destination}: {error}"),
1850 format!(
1851 "Verify `ssh {destination} docker info` succeeds, then pull {image} on that host."
1852 ),
1853 ),
1854 }
1855}
1856
1857fn doctor_smoke_id() -> String {
1859 format!(
1860 "doctor-{}-{:x}",
1861 std::process::id(),
1862 SystemTime::now()
1863 .duration_since(UNIX_EPOCH)
1864 .unwrap_or_default()
1865 .as_nanos()
1866 )
1867}
1868
1869fn podman_remediation(error: &anyhow::Error) -> String {
1870 let fix = podman_remediation_match(error).unwrap_or(
1871 "Install Podman with `sudo apt update && sudo apt install -y podman uidmap` (Debian/Ubuntu) or `sudo dnf install -y podman shadow-utils` (Fedora).",
1872 );
1873 format!("{fix} See {PODMAN_DOCUMENTATION_URL}.")
1874}
1875
1876fn podman_failure_detail(error: &anyhow::Error) -> String {
1878 podman_probe_observation(error).map_or_else(|| format!("{error:#}"), str::to_owned)
1879}
1880
1881fn podman_remediation_match(error: &anyhow::Error) -> Option<&'static str> {
1888 failed_podman_postcondition(error).map(PodmanPostcondition::remediation)
1889}
1890
1891const AWS_CLI_INSTALL_URL: &str =
1892 "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html";
1893
1894fn aws_checks(config: ConfigStatus<'_>, executor: &impl CommandExecutor) -> Vec<DoctorCheck> {
1897 let Ok(config) = config else {
1898 return Vec::new();
1899 };
1900 config
1901 .targets
1902 .iter()
1903 .filter_map(|(id, target)| match target {
1904 TargetTemplate::AwsEc2 {
1905 aws_profile,
1906 region,
1907 launch_template,
1908 ..
1909 } => Some(aws_target_check(
1910 id,
1911 aws_profile.as_deref(),
1912 region,
1913 launch_template,
1914 executor,
1915 )),
1916 _ => None,
1917 })
1918 .collect()
1919}
1920
1921fn aws_global_args<'a>(profile: Option<&'a str>, region: &'a str) -> Vec<String> {
1924 vec![
1925 "--profile".to_owned(),
1926 profile.unwrap_or("default").to_owned(),
1927 "--region".to_owned(),
1928 region.to_owned(),
1929 ]
1930}
1931
1932fn aws_target_check(
1933 id: &str,
1934 profile: Option<&str>,
1935 region: &str,
1936 launch_template: &str,
1937 executor: &impl CommandExecutor,
1938) -> DoctorCheck {
1939 let check_id = format!("runtime.aws-ec2.{id}");
1940 let title = format!("AWS EC2 target {id}");
1941 let profile_label = profile.unwrap_or("default");
1942
1943 let version = CommandSpec::new("aws", ["--version"]).purpose("check AWS CLI installation");
1944 match executor.execute(&version) {
1945 Err(error) => {
1946 return DoctorCheck::fixable(
1947 check_id,
1948 title,
1949 format!("The `aws` command is not available: {error}"),
1950 format!("Install the AWS CLI and put `aws` on PATH: {AWS_CLI_INSTALL_URL}"),
1951 );
1952 }
1953 Ok(output) if output.status != 0 => {
1954 return DoctorCheck::fixable(
1955 check_id,
1956 title,
1957 format!(
1958 "`aws --version` failed: {}",
1959 String::from_utf8_lossy(&output.stderr).trim()
1960 ),
1961 format!("Reinstall the AWS CLI: {AWS_CLI_INSTALL_URL}"),
1962 );
1963 }
1964 Ok(_) => {}
1965 }
1966
1967 let mut identity_args = aws_global_args(profile, region);
1968 identity_args.extend(["sts".to_owned(), "get-caller-identity".to_owned()]);
1969 identity_args.extend(["--output".to_owned(), "json".to_owned()]);
1970 let identity =
1971 CommandSpec::new("aws", identity_args).purpose("check AWS credentials for a doctor target");
1972 match executor.execute(&identity) {
1973 Err(error) => {
1974 return DoctorCheck::fixable(
1975 check_id,
1976 title,
1977 format!("Could not run `aws sts get-caller-identity`: {error}"),
1978 format!(
1979 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1980 ),
1981 );
1982 }
1983 Ok(output) if output.status != 0 => {
1984 return DoctorCheck::fixable(
1985 check_id,
1986 title,
1987 format!(
1988 "AWS credentials for profile {profile_label} are not usable: {}",
1989 String::from_utf8_lossy(&output.stderr).trim()
1990 ),
1991 format!(
1992 "Configure credentials with `aws configure --profile {profile_label}`, or sign in with `aws sso login --profile {profile_label}`."
1993 ),
1994 );
1995 }
1996 Ok(_) => {}
1997 }
1998
1999 let by_id = launch_template.starts_with("lt-");
2002 let mut template_args = aws_global_args(profile, region);
2003 template_args.extend(["ec2".to_owned(), "describe-launch-templates".to_owned()]);
2004 template_args.extend([
2005 if by_id {
2006 "--launch-template-ids".to_owned()
2007 } else {
2008 "--launch-template-names".to_owned()
2009 },
2010 launch_template.to_owned(),
2011 ]);
2012 template_args.extend(["--output".to_owned(), "json".to_owned()]);
2013 let template =
2014 CommandSpec::new("aws", template_args).purpose("check the configured AWS launch template");
2015 let template_remediation = format!(
2016 "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."
2017 );
2018 match executor.execute(&template) {
2019 Err(error) => DoctorCheck::fixable(
2020 check_id,
2021 title,
2022 format!("Could not query launch template {launch_template}: {error}"),
2023 template_remediation,
2024 ),
2025 Ok(output) if output.status != 0 => DoctorCheck::fixable(
2026 check_id,
2027 title,
2028 format!(
2029 "Launch template {launch_template} was not found in {region}: {}",
2030 String::from_utf8_lossy(&output.stderr).trim()
2031 ),
2032 template_remediation,
2033 ),
2034 Ok(_) => DoctorCheck::ready(
2035 check_id,
2036 title,
2037 format!(
2038 "The AWS CLI is installed, profile {profile_label} has valid credentials, and launch template {launch_template} exists in {region}."
2039 ),
2040 ),
2041 }
2042}
2043
2044fn daemon_build_check() -> DoctorCheck {
2052 const ID: &str = "daemon.build";
2053 const TITLE: &str = "Daemon build";
2054 let Ok(metadata) = mj_client::daemon::read_metadata_any() else {
2055 return DoctorCheck::ready(
2056 ID,
2057 TITLE,
2058 "No Mjolnir daemon is running; the next command starts one from this build.",
2059 );
2060 };
2061 let pid = metadata.pid;
2062 match mj_client::executable::process_runs_this_executable(pid) {
2063 Ok(Some(true)) => DoctorCheck::ready(
2064 ID,
2065 TITLE,
2066 format!(
2067 "Daemon {pid} runs this build (version {}).",
2068 metadata.build_version
2069 ),
2070 ),
2071 Ok(Some(false)) => DoctorCheck::warning(
2072 ID,
2073 TITLE,
2074 format!(
2075 "{}. Two builds can report the same version, so the files are what tell them apart. Code rebuilt since that daemon started is not running.",
2076 mj_client::executable::describe_running_daemon_and_client_builds(
2077 pid,
2078 &metadata.build_version,
2079 ),
2080 ),
2081 "Run `mj daemon restart` from this build. It now fails rather than reporting success if another client's build wins.",
2082 ),
2083 Ok(None) => DoctorCheck::ready(
2084 ID,
2085 TITLE,
2086 format!(
2087 "Daemon {pid} is recorded but not running; the next command starts one from this build."
2088 ),
2089 ),
2090 Err(error) => DoctorCheck::warning(
2091 ID,
2092 TITLE,
2093 format!("Could not tell which build daemon {pid} runs: {error:#}"),
2094 "Run `mj daemon restart` from this build if rebuilt code is not taking effect.",
2095 ),
2096 }
2097}
2098
2099fn worker_freshness_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2107 let mut checks = Vec::new();
2108 let daemon = mj_client::daemon::read_metadata_any()
2109 .ok()
2110 .filter(|metadata| mj_client::daemon::process_is_alive(metadata.pid));
2111 let pinned = pinned_worker_digests();
2112 let mut sources: Vec<(String, Result<WorkerBinaryAvailability>)> = vec![(
2113 "this host".to_owned(),
2114 crate::controller::native_worker_binary_prerequisite(),
2115 )];
2116 for arch in container_worker_architectures(config) {
2117 sources.push((
2118 format!("{arch} Linux targets"),
2119 worker_binary_prerequisite_for_arch(&arch),
2120 ));
2121 }
2122 for (label, availability) in sources {
2123 let id = format!("worker.freshness.{}", label.replace(' ', "-"));
2124 let title = format!("Worker binary for {label}");
2125 let path = match availability {
2126 Ok(WorkerBinaryAvailability::Local { path, .. }) => path,
2127 Ok(WorkerBinaryAvailability::Remote { .. }) => continue,
2130 Err(error) => {
2131 checks.push(DoctorCheck::unsupported(
2132 id,
2133 title,
2134 format!("No worker binary resolves for {label}: {error:#}"),
2135 ));
2136 continue;
2137 }
2138 };
2139 let digest = mj_core::worker_launch::worker_executable_digest(&path)
2140 .unwrap_or_else(|error| format!("unreadable ({error:#})"));
2141 let pinned_note = if pinned.is_empty() {
2142 "the daemon has pinned no worker".to_owned()
2143 } else if pinned.contains(&digest) {
2144 "this content is in the daemon's pinned worker cache".to_owned()
2145 } else {
2146 format!(
2147 "the pinned worker cache holds {} instead",
2148 pinned.join(", ")
2149 )
2150 };
2151 let detail = format!("{} has digest {digest}; {pinned_note}.", path.display());
2152 let Some(metadata) = daemon.as_ref() else {
2153 checks.push(DoctorCheck::ready(
2154 id,
2155 title,
2156 format!("{detail} No daemon is running, so the next session uses this file."),
2157 ));
2158 continue;
2159 };
2160 match worker_changed_since_daemon_start(&path, &metadata.started_at) {
2161 Ok(true) => checks.push(DoctorCheck::warning(
2162 id,
2163 title,
2164 format!("{detail} It was rebuilt after daemon {} started, which froze the copy it serves.", metadata.pid),
2165 "Run `mj daemon restart` so new sessions use the rebuilt worker. Sessions already running keep their worker until they are quiet enough to be upgraded.",
2166 )),
2167 Ok(false) => checks.push(DoctorCheck::ready(id, title, detail)),
2168 Err(error) => checks.push(DoctorCheck::warning(
2169 id,
2170 title,
2171 format!("{detail} Could not compare it with the daemon's start time: {error:#}"),
2172 "Run `mj daemon restart` if rebuilt worker code is not taking effect.",
2173 )),
2174 }
2175 }
2176 checks
2177}
2178
2179fn container_worker_architectures(config: ConfigStatus<'_>) -> Vec<String> {
2181 let Ok(config) = config else {
2182 return Vec::new();
2183 };
2184 let mut architectures = Vec::new();
2185 for target in config.targets.values() {
2186 let container = match target {
2187 TargetTemplate::LocalPodman { container }
2188 | TargetTemplate::LocalDocker { container }
2189 | TargetTemplate::AppleContainer { container }
2190 | TargetTemplate::SshPodman { container, .. }
2191 | TargetTemplate::SshDocker { container, .. } => container,
2192 _ => continue,
2193 };
2194 let arch = container
2195 .platform
2196 .as_deref()
2197 .and_then(|platform| platform.rsplit('/').next())
2198 .map_or_else(
2199 || std::env::consts::ARCH.to_owned(),
2200 normalized_worker_architecture,
2201 );
2202 if !architectures.contains(&arch) {
2203 architectures.push(arch);
2204 }
2205 }
2206 architectures
2207}
2208
2209fn normalized_worker_architecture(platform_arch: &str) -> String {
2212 match platform_arch {
2213 "amd64" => "x86_64".to_owned(),
2214 "arm64" => "aarch64".to_owned(),
2215 other => other.to_owned(),
2216 }
2217}
2218
2219fn pinned_worker_digests() -> Vec<String> {
2224 let root = mj_core::config::data_dir().join("workers").join("pinned");
2225 let Ok(entries) = std::fs::read_dir(root) else {
2226 return Vec::new();
2227 };
2228 let mut digests: Vec<String> = entries
2229 .flatten()
2230 .filter(|entry| entry.path().is_dir())
2231 .filter_map(|entry| entry.file_name().into_string().ok())
2232 .collect();
2233 digests.sort();
2234 digests
2235}
2236
2237fn worker_changed_since_daemon_start(path: &Path, started_at: &str) -> Result<bool> {
2242 let started: SystemTime = chrono::DateTime::parse_from_rfc3339(started_at)
2243 .map_err(|error| anyhow::anyhow!("parse daemon start time {started_at:?}: {error}"))?
2244 .into();
2245 let modified = std::fs::metadata(path)?.modified()?;
2246 Ok(modified > started)
2247}
2248
2249fn offered_targets<'a>(
2257 config: &Config,
2258 engine_checks: impl IntoIterator<Item = &'a DoctorCheck>,
2259) -> Config {
2260 let ready = engine_checks
2261 .into_iter()
2262 .filter(|check| check.status == CheckStatus::Ready)
2263 .map(|check| check.id.as_str())
2264 .collect::<std::collections::BTreeSet<_>>();
2265 let engine_ready = |target_id: &str| match target_id {
2266 "podman" => ready.contains("runtime.podman"),
2267 "docker" => ready.contains("runtime.docker"),
2268 "apple-container" => ready.contains("runtime.apple-container"),
2269 _ => true,
2270 };
2271 let mut offered = config.clone().with_local_targets();
2272 offered
2273 .targets
2274 .retain(|id, _| config.configures_target(id) || engine_ready(id));
2275 offered
2276}
2277
2278fn worker_binary_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2279 let config = match config {
2280 Ok(config) => config,
2281 Err(ConfigGap::NewerVersion(version)) => {
2282 return vec![newer_config_skip(
2283 "worker.containers",
2284 "Container worker binary",
2285 version,
2286 )];
2287 }
2288 Err(gap @ (ConfigGap::Missing | ConfigGap::Unreadable)) => {
2289 return vec![DoctorCheck::fixable(
2290 "worker.containers",
2291 "Container worker binary",
2292 format!(
2293 "Worker availability cannot be checked until {}.",
2294 gap.awaited()
2295 ),
2296 gap.remediation(),
2297 )];
2298 }
2299 };
2300 let containers = config
2301 .targets
2302 .iter()
2303 .filter_map(|(id, target)| match target {
2304 TargetTemplate::LocalPodman { container }
2305 | TargetTemplate::LocalDocker { container }
2306 | TargetTemplate::AppleContainer { container } => Some((id, container, None)),
2307 TargetTemplate::SshPodman { container, .. } => {
2308 Some((id, container, Some("ssh-podman")))
2309 }
2310 TargetTemplate::SshDocker { container, .. } => {
2311 Some((id, container, Some("ssh-docker")))
2312 }
2313 _ => None,
2314 })
2315 .collect::<Vec<_>>();
2316 if containers.is_empty() {
2317 return vec![DoctorCheck::unsupported(
2318 "worker.containers",
2319 "Container worker binary",
2320 "No container target is configured.",
2321 )];
2322 }
2323 containers
2324 .into_iter()
2325 .map(|(id, container, remote_kind)| {
2326 if let Some(remote_kind) = remote_kind
2327 && container.platform.is_none()
2328 {
2329 return DoctorCheck::unsupported(
2332 format!("worker.{id}"),
2333 format!("Container worker binary for target {id}"),
2334 format!(
2335 "Set `platform` on this {remote_kind} target to check its worker binary; the remote architecture is unknown until provisioning."
2336 ),
2337 );
2338 }
2339 worker_binary_check(id, container)
2340 })
2341 .collect()
2342}
2343
2344fn worker_binary_check(id: &str, container: &ContainerTemplate) -> DoctorCheck {
2345 let title = format!("Container worker binary for target {id}");
2346 let arch = match container_architecture(container.platform.as_deref()) {
2347 Ok(arch) => arch,
2348 Err(reason) => {
2349 return DoctorCheck::unsupported(format!("worker.{id}"), title, reason);
2350 }
2351 };
2352 let triple = format!("{arch}-unknown-linux-musl");
2353 match worker_binary_prerequisite_for_arch(arch) {
2354 Ok(WorkerBinaryAvailability::Local { path, source }) => DoctorCheck::ready(
2355 format!("worker.{id}"),
2356 title,
2357 format!(
2358 "{triple} worker is available from {source}: {}",
2359 path.display()
2360 ),
2361 ),
2362 Ok(WorkerBinaryAvailability::Remote { url, .. }) => DoctorCheck::ready(
2363 format!("worker.{id}"),
2364 title,
2365 format!("{triple} worker will be verified and downloaded from {url} when needed."),
2366 ),
2367 Err(error) => DoctorCheck::fixable(
2368 format!("worker.{id}"),
2369 title,
2370 format!("No usable {triple} worker source: {error:#}"),
2371 format!(
2372 "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."
2373 ),
2374 ),
2375 }
2376}
2377
2378fn container_architecture(platform: Option<&str>) -> std::result::Result<&'static str, String> {
2379 let candidate = platform.unwrap_or(std::env::consts::ARCH);
2380 let candidate = candidate
2381 .split('/')
2382 .rev()
2383 .find(|part| matches!(*part, "x86_64" | "amd64" | "aarch64" | "arm64"))
2384 .unwrap_or(candidate);
2385 match candidate {
2386 "x86_64" | "amd64" => Ok("x86_64"),
2387 "aarch64" | "arm64" => Ok("aarch64"),
2388 other => Err(format!(
2389 "Container architecture {other:?} is unsupported; Mjolnir supports x86_64 and aarch64 Linux workers."
2390 )),
2391 }
2392}
2393
2394fn apple_container_image(config: ConfigStatus<'_>) -> String {
2395 config
2396 .ok()
2397 .and_then(|config| {
2398 config.targets.values().find_map(|target| match target {
2399 TargetTemplate::AppleContainer { container } => Some(container.image.clone()),
2400 _ => None,
2401 })
2402 })
2403 .unwrap_or_else(|| DEFAULT_CONTAINER_IMAGE.into())
2404}
2405
2406pub fn apple_container_check(
2407 platform: &ApplePlatform,
2408 executor: &impl CommandExecutor,
2409 smoke: bool,
2410 image: String,
2411) -> DoctorCheck {
2412 match platform {
2413 ApplePlatform::Linux => {
2414 return DoctorCheck::unsupported(
2415 "runtime.apple-container",
2416 "Apple container runtime",
2417 "macOS only",
2418 );
2419 }
2420 ApplePlatform::Other(current) => {
2421 return DoctorCheck::unsupported(
2422 "runtime.apple-container",
2423 "Apple container runtime",
2424 format!("macOS only (current platform: {current})"),
2425 );
2426 }
2427 ApplePlatform::Macos {
2428 architecture,
2429 major_version,
2430 } if architecture != "aarch64" && architecture != "arm64" => {
2431 return DoctorCheck::unsupported(
2432 "runtime.apple-container",
2433 "Apple container runtime",
2434 "Apple container requires Apple silicon; Intel Macs are unsupported.",
2435 );
2436 }
2437 ApplePlatform::Macos { major_version, .. } if *major_version < 26 => {
2438 return DoctorCheck::unsupported(
2439 "runtime.apple-container",
2440 "Apple container runtime",
2441 format!("Apple container requires macOS 26 or newer (found {major_version})."),
2442 );
2443 }
2444 ApplePlatform::Macos { .. } => {}
2445 }
2446
2447 let daemon = apple_container_daemon_check(executor);
2448 if daemon.status != CheckStatus::Ready {
2449 return daemon;
2450 }
2451
2452 if !smoke {
2453 return DoctorCheck::fixable(
2454 "runtime.apple-container",
2455 "Apple container runtime",
2456 "The daemon is running, but the required disposable smoke test was not requested.",
2457 "Run `mj doctor --json --smoke`.",
2458 );
2459 }
2460
2461 let target = RuntimeTargetTemplate::AppleContainer(RuntimeContainerTemplate {
2462 build_cache: None,
2463 image,
2464 pull_policy: Default::default(),
2465 extra_run_args: vec![],
2466 workspace_storage: Default::default(),
2467 });
2468 match run_setup_smoke_test(&target, &doctor_smoke_id(), executor) {
2469 Ok(()) => DoctorCheck::ready(
2470 "runtime.apple-container",
2471 "Apple container runtime",
2472 "Installed, daemon running, and disposable run/exec/remove smoke test passed.",
2473 ),
2474 Err(error) => DoctorCheck::fixable(
2475 "runtime.apple-container",
2476 "Apple container runtime",
2477 format!("Disposable run/exec/remove smoke test failed: {error:#}"),
2478 "Fix the configured image or container runtime, then run `mj doctor --json --smoke` again.",
2479 ),
2480 }
2481}
2482
2483pub fn apple_container_daemon_check(executor: &impl CommandExecutor) -> DoctorCheck {
2490 let installed =
2491 CommandSpec::new("container", ["--version"]).purpose("check Apple container installation");
2492 match executor.execute(&installed) {
2493 Err(error) => {
2494 return DoctorCheck::fixable(
2495 "runtime.apple-container",
2496 "Apple container runtime",
2497 format!("The `container` command is not available: {error}"),
2498 format!("Install the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2499 );
2500 }
2501 Ok(output) if output.status != 0 => {
2502 return DoctorCheck::fixable(
2503 "runtime.apple-container",
2504 "Apple container runtime",
2505 format!(
2506 "The installed `container --version` command failed: {}",
2507 String::from_utf8_lossy(&output.stderr).trim()
2508 ),
2509 format!("Reinstall the official signed package: {APPLE_CONTAINER_INSTALL_URL}"),
2510 );
2511 }
2512 Ok(_) => {}
2513 }
2514
2515 let status =
2516 CommandSpec::new("container", ["system", "status"]).purpose("check Apple container daemon");
2517 match executor.execute(&status) {
2518 Ok(output) if output.status == 0 => DoctorCheck::ready(
2519 "runtime.apple-container",
2520 "Apple container runtime",
2521 "Installed, and the Apple container daemon is running.",
2522 ),
2523 Ok(output) => DoctorCheck::fixable(
2524 "runtime.apple-container",
2525 "Apple container runtime",
2526 format!(
2527 "The Apple container daemon is stopped: {}",
2528 String::from_utf8_lossy(&output.stderr).trim()
2529 ),
2530 "Run `container system start`.",
2531 ),
2532 Err(error) => DoctorCheck::fixable(
2533 "runtime.apple-container",
2534 "Apple container runtime",
2535 format!("Could not query the Apple container daemon: {error}"),
2536 "Run `container system start`.",
2537 ),
2538 }
2539}
2540
2541pub fn current_apple_platform(executor: &impl CommandExecutor) -> ApplePlatform {
2542 if cfg!(target_os = "linux") {
2543 return ApplePlatform::Linux;
2544 }
2545 if !cfg!(target_os = "macos") {
2546 return ApplePlatform::Other(std::env::consts::OS.into());
2547 }
2548 let major_version = executor
2549 .execute(&CommandSpec::new("sw_vers", ["-productVersion"]).purpose("detect macOS version"))
2550 .ok()
2551 .filter(|output| output.status == 0)
2552 .and_then(|output| {
2553 String::from_utf8(output.stdout)
2554 .ok()
2555 .and_then(|value| value.trim().split('.').next()?.parse().ok())
2556 })
2557 .unwrap_or(0);
2558 ApplePlatform::Macos {
2559 architecture: std::env::consts::ARCH.into(),
2560 major_version,
2561 }
2562}
2563
2564#[cfg(test)]
2565mod tests;
2566
2567#[derive(Debug, Default, PartialEq, Eq)]
2569pub(crate) struct ReviewResidue {
2570 pub refs: Vec<String>,
2572 pub scratch_indexes: Vec<PathBuf>,
2574}
2575
2576impl ReviewResidue {
2577 fn is_empty(&self) -> bool {
2578 self.refs.is_empty() && self.scratch_indexes.is_empty()
2579 }
2580}
2581
2582pub(crate) fn review_residue(repository: &Path) -> ReviewResidue {
2589 let mut residue = ReviewResidue::default();
2590 let git_dir = repository.join(".git");
2591 if !git_dir.exists() {
2592 return residue;
2593 }
2594 for reference in ["review-baseline", "review-capture"] {
2595 if git_dir.join("refs/hel").join(reference).is_file() {
2596 residue.refs.push(format!("refs/hel/{reference}"));
2597 }
2598 }
2599 if let Ok(packed) = std::fs::read_to_string(git_dir.join("packed-refs")) {
2601 for line in packed.lines() {
2602 if let Some((_, reference)) = line.split_once(' ')
2603 && reference.starts_with("refs/hel/")
2604 && !residue.refs.iter().any(|known| known == reference)
2605 {
2606 residue.refs.push(reference.to_owned());
2607 }
2608 }
2609 }
2610 if let Ok(entries) = std::fs::read_dir(&git_dir) {
2611 for entry in entries.filter_map(Result::ok) {
2612 if entry
2613 .file_name()
2614 .to_str()
2615 .is_some_and(|name| name.starts_with("hel-review-index-"))
2616 {
2617 residue.scratch_indexes.push(entry.path());
2618 }
2619 }
2620 }
2621 residue.refs.sort();
2622 residue.scratch_indexes.sort();
2623 residue
2624}
2625
2626pub(crate) fn review_residue_repositories(
2637 configured: impl IntoIterator<Item = PathBuf>,
2638 sessions: &[&mj_core::state::SessionRecord],
2639) -> Vec<PathBuf> {
2640 use mj_core::state::ManagedCheckoutKind;
2641
2642 let managed_roots = sessions
2643 .iter()
2644 .filter_map(|session| session.managed_worktree.as_ref())
2645 .map(|worktree| worktree.worktree_root.as_path())
2646 .collect::<Vec<_>>();
2647 let mut in_use = Vec::new();
2648 for session in sessions.iter().filter(|session| session.state.is_active()) {
2649 if let Some(directory) = &session.project_directory {
2650 in_use.push(directory.as_path());
2651 }
2652 if let Some(worktree) = &session.managed_worktree
2653 && worktree.kind == ManagedCheckoutKind::Worktree
2654 {
2655 in_use.push(worktree.source_repository.as_path());
2656 }
2657 }
2658 let mut repositories = configured
2659 .into_iter()
2660 .chain(
2661 sessions
2662 .iter()
2663 .filter_map(|session| session.project_directory.clone()),
2664 )
2665 .filter(|repository| {
2666 !is_inside_managed_checkout(repository)
2667 && !managed_roots
2668 .iter()
2669 .any(|root| repository.starts_with(root))
2670 && !in_use.iter().any(|directory| repository == directory)
2671 })
2672 .collect::<Vec<_>>();
2673 repositories.sort();
2674 repositories.dedup();
2675 repositories
2676}
2677
2678fn is_inside_managed_checkout(path: &Path) -> bool {
2681 let components = path
2682 .components()
2683 .map(|component| component.as_os_str())
2684 .collect::<Vec<_>>();
2685 components
2686 .windows(3)
2687 .any(|window| window[0] == ".mj" && (window[1] == "clones" || window[1] == "worktrees"))
2688}
2689
2690fn review_residue_checks(config: ConfigStatus<'_>) -> Vec<DoctorCheck> {
2696 let Ok(config) = config else {
2697 return Vec::new();
2698 };
2699 let configured = config
2700 .bundles
2701 .values()
2702 .flat_map(|bundle| bundle.repositories.iter())
2703 .filter_map(|repository| repository.local.clone());
2704 let state = crate::database::load_state().ok();
2707 let sessions = state
2708 .as_ref()
2709 .map(|state| state.sessions.values().collect::<Vec<_>>())
2710 .unwrap_or_default();
2711 let repositories = review_residue_repositories(configured, &sessions);
2712 if repositories.is_empty() {
2713 return Vec::new();
2714 }
2715 let found = repositories
2716 .into_iter()
2717 .map(|repository| {
2718 let residue = review_residue(&repository);
2719 (repository, residue)
2720 })
2721 .filter(|(_, residue)| !residue.is_empty())
2722 .collect::<Vec<_>>();
2723 if found.is_empty() {
2724 return vec![DoctorCheck::ready(
2725 "review.residue",
2726 "Review leftovers in your repositories",
2727 "No Mjolnir refs or scratch index files were found in the configured repositories.",
2728 )];
2729 }
2730 let detail = found
2731 .iter()
2732 .map(|(repository, residue)| {
2733 let mut parts = Vec::new();
2734 if !residue.refs.is_empty() {
2735 parts.push(residue.refs.join(", "));
2736 }
2737 if !residue.scratch_indexes.is_empty() {
2738 parts.push(mj_core::text::counted(
2739 residue.scratch_indexes.len(),
2740 "leftover scratch index file",
2741 "leftover scratch index files",
2742 ));
2743 }
2744 format!("{}: {}", repository.display(), parts.join("; "))
2745 })
2746 .collect::<Vec<_>>()
2747 .join(". ");
2748 let commands = found
2749 .iter()
2750 .flat_map(|(repository, residue)| {
2751 let repository = repository.display().to_string();
2752 let mut commands = residue
2753 .refs
2754 .iter()
2755 .map(|reference| format!("git -C {repository} update-ref -d {reference}"))
2756 .collect::<Vec<_>>();
2757 commands.extend(
2758 residue
2759 .scratch_indexes
2760 .iter()
2761 .map(|index| format!("rm -f {}", index.display())),
2762 );
2763 commands.push(format!("git -C {repository} gc --prune=now"));
2764 commands
2765 })
2766 .collect::<Vec<_>>()
2767 .join("\n");
2768 vec![DoctorCheck::fixable(
2769 "review.residue",
2770 "Review leftovers in your repositories",
2771 format!("Mjolnir left these in repositories it does not own: {detail}."),
2772 format!("Remove them yourself when you are ready:\n{commands}"),
2773 )]
2774}