Skip to main content

mj_controller/targets/
preflight.rs

1use super::*;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub(super) enum ManagedResourceKind {
5    Container,
6    Ec2Instance,
7}
8
9/// Build command-line fragments that identify resources Hel owns for a session.
10pub(super) fn managed_resource_identity_args(
11    kind: ManagedResourceKind,
12    session_id: &str,
13) -> Vec<String> {
14    let instance = mj_core::config::instance_identity();
15    match kind {
16        ManagedResourceKind::Container => vec![
17            "--label".to_owned(),
18            format!("{SESSION_LABEL}={session_id}"),
19            "--label".to_owned(),
20            format!("{MANAGED_LABEL}=true"),
21            "--label".to_owned(),
22            format!("{INSTANCE_LABEL}={instance}"),
23        ],
24        ManagedResourceKind::Ec2Instance => vec![
25            "--tag-specifications".to_owned(),
26            format!(
27                "ResourceType=instance,Tags=[{{Key={SESSION_TAG},Value={session_id}}},{{Key={MANAGED_TAG},Value=true}},{{Key={INSTANCE_TAG},Value={instance}}}]"
28            ),
29        ],
30    }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PodmanPreflight {
35    pub version: String,
36    /// Non-fatal host configuration problems that can make sessions fragile.
37    pub warnings: Vec<PodmanPreflightWarning>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct PodmanPreflightWarning {
42    pub detail: String,
43    pub remediation: String,
44}
45
46impl PodmanPreflightWarning {
47    pub fn notice(&self) -> String {
48        format!("{} {}", self.detail, self.remediation)
49    }
50}
51
52/// Where the Podman prerequisite probes run.
53///
54/// The same postconditions apply locally and over SSH; only the command
55/// wrapping and the wording of a failure differ.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub(super) enum PodmanHost<'a> {
58    Local,
59    Ssh(&'a SshTarget),
60}
61
62impl PodmanHost<'_> {
63    /// Sentence opener for every failure raised by these probes.
64    pub(super) fn failure(self) -> String {
65        match self {
66            Self::Local => "Podman preflight failed".to_owned(),
67            Self::Ssh(ssh) => format!("Remote Podman preflight failed on {}", ssh.destination),
68        }
69    }
70
71    /// Prefix that says where a remediation must be applied.
72    pub(super) fn remediation_scope(self) -> String {
73        match self {
74            Self::Local => String::new(),
75            Self::Ssh(ssh) => format!("On {}: ", ssh.destination),
76        }
77    }
78
79    pub(super) fn command(self, args: &[&str], purpose: &'static str) -> CommandSpec {
80        self.command_owned(args.iter().map(|arg| (*arg).to_owned()).collect(), purpose)
81    }
82
83    pub(super) fn command_owned(self, args: Vec<String>, purpose: &'static str) -> CommandSpec {
84        match self {
85            Self::Local => {
86                CommandSpec::new(args[0].clone(), args[1..].iter().cloned()).purpose(purpose)
87            }
88            Self::Ssh(ssh) => ssh_validation_command(ssh, args, purpose),
89        }
90        .stage(ProvisionStage::Provisioning)
91    }
92}
93
94/// Verify the fast local preconditions for Hel's rootless Podman target.
95///
96/// This intentionally never pulls an image. Image availability is verified by
97/// `mj doctor --smoke` and by the subsequent target creation command.
98pub fn verify_local_podman(executor: &impl CommandExecutor) -> Result<PodmanPreflight> {
99    verify_podman(PodmanHost::Local, executor)
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct DockerPreflight {
104    pub version: String,
105}
106
107/// Verify that the Docker CLI can reach a Linux Docker daemon.
108///
109/// Image and OverlayFS support are exercised by `mj doctor --smoke`;
110/// this fast probe runs before every launch and never pulls an image.
111pub fn verify_local_docker(executor: &impl CommandExecutor) -> Result<DockerPreflight> {
112    verify_docker(None, executor)
113}
114
115pub fn verify_ssh_docker(
116    ssh: &SshTarget,
117    executor: &impl CommandExecutor,
118) -> Result<DockerPreflight> {
119    validate_ssh(ssh)?;
120    verify_docker(Some(ssh), executor).with_context(|| {
121        format!(
122            "Docker preflight on {} failed; run docker info on that SSH host",
123            ssh.destination
124        )
125    })
126}
127
128/// Why the local Docker daemon cannot overlay host directories, or `None`
129/// when it runs on this Linux host and sees them directly.
130///
131/// On macOS every Docker daemon (Docker Desktop, Colima, OrbStack) runs in a
132/// Linux VM, as Docker Desktop also does on Linux. The VM reaches host
133/// directories through a file share (virtiofs, gRPC FUSE, sshfs, 9p) that
134/// cannot back an overlay lower layer, and the local volume driver may not
135/// see them at their host path at all (#1152). Bind mounts are translated
136/// through the share, so these daemons still get read-only attachments.
137pub fn local_docker_vm_share(executor: &impl CommandExecutor) -> Result<Option<&'static str>> {
138    const REASON: &str = "Docker runs in a VM that reaches host directories through a file share";
139    if !cfg!(target_os = "linux") {
140        return Ok(Some(REASON));
141    }
142    let command = CommandSpec::new(
143        "docker",
144        ["version", "--format", "{{.Server.Platform.Name}}"],
145    )
146    .purpose("identify the Docker daemon platform")
147    .stage(ProvisionStage::Provisioning);
148    let output = executor.execute(&command)?;
149    ensure!(
150        output.status == 0,
151        "{} failed with status {}: {}",
152        command.purpose,
153        output.status,
154        String::from_utf8_lossy(&output.stderr).trim()
155    );
156    let platform = String::from_utf8_lossy(&output.stdout);
157    Ok(platform
158        .trim()
159        .starts_with("Docker Desktop")
160        .then_some(REASON))
161}
162
163/// How the launch options, the session wizard, doctor and Setup say that a
164/// local container engine's command is not on this host.
165pub fn engine_not_installed(engine: &str) -> String {
166    format!("{engine} is not installed on this host")
167}
168
169/// The command a local container target's engine runs as. `None` for any
170/// other target, whose readiness is not a local engine's.
171pub fn local_engine_command(template: &mj_core::config::TargetTemplate) -> Option<&'static str> {
172    use mj_core::config::TargetTemplate as Template;
173    match template {
174        Template::LocalPodman { .. } => Some("podman"),
175        Template::LocalDocker { .. } => Some("docker"),
176        Template::AppleContainer { .. } => Some("container"),
177        _ => None,
178    }
179}
180
181/// Whether `template` needs a local container engine whose command is not on
182/// `path`, a PATH value. That is a fact about this host and stays true until
183/// the engine is installed, unlike a host that did not answer its last check.
184/// Every place that decides whether to offer a target reads this one answer:
185/// the terminal wizards, the web viewer's snapshot, and the launch options.
186pub fn runtime_missing_on_host(
187    template: &mj_core::config::TargetTemplate,
188    path: Option<&std::ffi::OsStr>,
189) -> bool {
190    local_engine_command(template).is_some_and(|engine| !program_on_path(engine, path))
191}
192
193/// Whether `program` is found in one of the directories of `path`, a PATH
194/// value, under the platform's executable extensions (`docker.exe` on
195/// Windows). A missing PATH finds nothing.
196pub fn program_on_path(program: &str, path: Option<&std::ffi::OsStr>) -> bool {
197    mj_core::program_path::find_program_on_path(program, path).is_some()
198}
199
200/// Why local Docker cannot run sessions: one sentence per case, where the
201/// raw error chain said "run docker for check Docker daemon: No such file or
202/// directory (os error 2)" (launch finding R5-3).
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub enum DockerUnavailable {
205    /// `docker` is not on PATH.
206    NotInstalled,
207    /// The CLI ran but found no daemon to talk to.
208    NotRunning { reported: String },
209    /// The CLI ran and failed for another reason, such as a socket the user
210    /// may not open.
211    NotAnswering { status: i32, reported: String },
212}
213
214impl DockerUnavailable {
215    /// What to do before Docker can run sessions, for a check made before
216    /// anything is launched, such as the session wizard's target row. It
217    /// does not mention Retry launch, which only the launch-failure dialog
218    /// offers (launch finding R6-3).
219    pub fn remedy(&self) -> &'static str {
220        match self {
221            Self::NotInstalled => "Install Docker or choose another target.",
222            Self::NotRunning { .. } => "Start Docker.",
223            Self::NotAnswering { .. } => "Fix what it reports.",
224        }
225    }
226
227    /// What to do after a launch failed this check: the same advice, then
228    /// the failure dialog's Retry launch.
229    pub fn launch_remedy(&self) -> &'static str {
230        match self {
231            Self::NotInstalled => "Install Docker or choose another target, then Retry launch.",
232            Self::NotRunning { .. } => "Start Docker, then Retry launch.",
233            Self::NotAnswering { .. } => "Fix what it reports, then Retry launch.",
234        }
235    }
236
237    /// What doctor and Setup advise.
238    pub fn remediation(&self) -> String {
239        match self {
240            Self::NotInstalled => {
241                format!("Install Docker ({DOCKER_DOCUMENTATION_URL}), or use another target.")
242            }
243            Self::NotRunning { .. } => {
244                "Start Docker, then make sure `docker info` succeeds as the user running Mjolnir."
245                    .to_owned()
246            }
247            Self::NotAnswering { .. } => format!(
248                "Make sure `docker info` succeeds as the user running Mjolnir. See {DOCKER_DOCUMENTATION_URL}."
249            ),
250        }
251    }
252}
253
254impl std::fmt::Display for DockerUnavailable {
255    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        match self {
257            Self::NotInstalled => write!(formatter, "{}.", engine_not_installed("Docker")),
258            Self::NotRunning { reported } => {
259                write!(
260                    formatter,
261                    "Docker is installed, but its daemon is not running."
262                )?;
263                if !reported.is_empty() {
264                    write!(formatter, " `docker version` said: {reported}")?;
265                }
266                Ok(())
267            }
268            Self::NotAnswering { status, reported } => {
269                write!(
270                    formatter,
271                    "Docker did not answer its check on this host: `docker version` exited with status {status}"
272                )?;
273                if !reported.is_empty() {
274                    write!(formatter, ": {reported}")?;
275                }
276                write!(
277                    formatter,
278                    ". Run `docker info` as the user running Mjolnir to see why."
279                )
280            }
281        }
282    }
283}
284
285impl std::error::Error for DockerUnavailable {}
286
287/// Whether the Docker CLI's own words say it found no daemon to talk to.
288fn docker_daemon_not_running(reported: &str) -> bool {
289    reported.contains("Cannot connect to the Docker daemon")
290        || reported.contains("Is the docker daemon running")
291}
292
293/// Whether a command could not start because its program is not on PATH.
294fn is_missing_program(error: &anyhow::Error) -> bool {
295    error.chain().any(|cause| {
296        cause
297            .downcast_ref::<std::io::Error>()
298            .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
299    })
300}
301
302pub(super) fn verify_docker(
303    ssh: Option<&SshTarget>,
304    executor: &impl CommandExecutor,
305) -> Result<DockerPreflight> {
306    let command = CommandSpec::new(
307        "docker",
308        ["version", "--format", "{{.Server.Version}} {{.Server.Os}}"],
309    )
310    .purpose("check Docker daemon")
311    .stage(ProvisionStage::Provisioning);
312    let command = match ssh {
313        Some(ssh) => command_over_ssh(command, ssh),
314        None => command,
315    };
316    let output = match executor.execute(&command) {
317        Ok(output) => output,
318        // Over SSH a missing program would be `ssh` itself, which the SSH
319        // checks report; only a local one means Docker is not installed.
320        // The cause stays in the chain for callers that classify it.
321        Err(error) if ssh.is_none() && is_missing_program(&error) => {
322            return Err(error.context(DockerUnavailable::NotInstalled));
323        }
324        Err(error) => {
325            return Err(error.context(
326                "Docker preflight failed: run `docker info` as the user running Mjolnir",
327            ));
328        }
329    };
330    if output.status != 0 {
331        let reported = String::from_utf8_lossy(&output.stderr).trim().to_owned();
332        if ssh.is_none() {
333            let problem = if docker_daemon_not_running(&reported) {
334                DockerUnavailable::NotRunning { reported }
335            } else {
336                DockerUnavailable::NotAnswering {
337                    status: output.status,
338                    reported,
339                }
340            };
341            return Err(problem.into());
342        }
343        bail!(
344            "Docker preflight failed: `docker version` exited with status {}: {reported}. Run `docker info` as the user running Mjolnir. See {DOCKER_DOCUMENTATION_URL}.",
345            output.status
346        );
347    }
348    let reported = String::from_utf8_lossy(&output.stdout);
349    let mut fields = reported.split_whitespace();
350    let version = fields.next().unwrap_or_default();
351    let os = fields.next().unwrap_or_default();
352    ensure!(
353        !version.is_empty() && os == "linux",
354        "Docker preflight failed: expected a Linux Docker daemon, got {:?}. See {DOCKER_DOCUMENTATION_URL}.",
355        reported.trim()
356    );
357    Ok(DockerPreflight {
358        version: version.to_owned(),
359    })
360}
361
362/// Verify the same rootless Podman preconditions on an SSH host.
363///
364/// The probes run through the noninteractive SSH options, so an unreachable
365/// host fails fast instead of blocking doctor or session preflight.
366pub fn verify_ssh_podman(
367    ssh: &SshTarget,
368    executor: &impl CommandExecutor,
369) -> Result<PodmanPreflight> {
370    let host = PodmanHost::Ssh(ssh);
371    validate_ssh(ssh).map_err(|error| {
372        anyhow::anyhow!(
373            "{}: the configured SSH destination is unusable ({error}). Set a valid `host` (and optional `user`) for this ssh-podman target. See {PODMAN_DOCUMENTATION_URL}.",
374            host.failure()
375        )
376    })?;
377    // One SSH round trip carries every probe; a remote shell runs them in
378    // sequence and frames each result so the checks below stay unchanged.
379    let probes = run_ssh_podman_probes(host, executor)?;
380    let mut preflight = verify_podman_probes(host, |probe| {
381        let output = probes.get(probe.key()).cloned().ok_or_else(|| {
382            anyhow::anyhow!(
383                "{}",
384                ssh_transport_failure(
385                    host,
386                    &format!(
387                        "the preflight output ended before the {} probe",
388                        probe.key()
389                    ),
390                )
391                .expect("SSH host always reports a transport failure")
392            )
393        })?;
394        check_podman_probe_status(host, probe, output)
395    })?;
396    if let Some(warning) = ssh_podman_linger_warning(ssh, probes.get(LINGER_PROBE_KEY)) {
397        preflight.warnings.push(warning);
398    }
399    Ok(preflight)
400}
401
402/// One rootless Podman postcondition, with the wording used to report it.
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub(crate) enum PodmanPostcondition {
405    Version,
406    Rootless,
407    UidMap,
408}
409
410/// One Podman command whose result is checked.
411///
412/// No probe checks rootless mode on its own: `podman unshare` refuses to run
413/// for rootful or remote Podman, so the UID-map probe reports that failure.
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415pub(crate) enum PodmanProbe {
416    Version,
417    UidMap,
418}
419
420/// A rootless Podman postcondition that was not met, carrying which one.
421///
422/// `mj doctor` needs the postcondition, not its wording, to name the fix.
423/// Carrying it on the error means the diagnosis never depends on matching
424/// message text that this repository itself produces.
425#[derive(Debug)]
426pub(crate) struct PodmanProbeFailure {
427    postcondition: PodmanPostcondition,
428    /// What was observed, without the fix.
429    observation: String,
430    /// The observation followed by the fix and the guide link.
431    message: String,
432}
433
434impl std::fmt::Display for PodmanProbeFailure {
435    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
436        formatter.write_str(&self.message)
437    }
438}
439
440impl std::error::Error for PodmanProbeFailure {}
441
442/// The postcondition that failed, when the error came from a probe.
443pub(crate) fn failed_podman_postcondition(error: &anyhow::Error) -> Option<PodmanPostcondition> {
444    error
445        .downcast_ref::<PodmanProbeFailure>()
446        .map(|failure| failure.postcondition)
447}
448
449/// What a failed probe observed, without the fix, so a report that prints
450/// the fix separately does not repeat it.
451pub(crate) fn podman_probe_observation(error: &anyhow::Error) -> Option<&str> {
452    error
453        .downcast_ref::<PodmanProbeFailure>()
454        .map(|failure| failure.observation.as_str())
455}
456
457/// Fail a postcondition with what was observed, followed by its fix and the
458/// published guide, keeping the postcondition machine-readable.
459fn probe_failure(
460    host: PodmanHost<'_>,
461    postcondition: PodmanPostcondition,
462    observation: String,
463) -> anyhow::Error {
464    let message = format!(
465        "{observation} {}{} See {PODMAN_DOCUMENTATION_URL}.",
466        host.remediation_scope(),
467        postcondition.remediation()
468    );
469    anyhow::Error::new(PodmanProbeFailure {
470        postcondition,
471        observation,
472        message,
473    })
474}
475
476impl PodmanProbe {
477    /// Name of this probe in the batched remote script's output.
478    pub(super) fn key(self) -> &'static str {
479        match self {
480            Self::Version => "version",
481            Self::UidMap => "uid_map",
482        }
483    }
484
485    pub(super) fn args(self) -> &'static [&'static str] {
486        match self {
487            Self::Version => &["podman", "--version"],
488            Self::UidMap => &["podman", "unshare", "cat", "/proc/self/uid_map"],
489        }
490    }
491
492    pub(super) fn purpose(self) -> &'static str {
493        match self {
494            Self::Version => "check Podman version",
495            Self::UidMap => "check rootless Podman UID map",
496        }
497    }
498
499    pub(super) fn postcondition(self) -> PodmanPostcondition {
500        match self {
501            Self::Version => PodmanPostcondition::Version,
502            Self::UidMap => PodmanPostcondition::UidMap,
503        }
504    }
505
506    /// What running this probe checks, in words that follow "to check".
507    fn checks(self) -> &'static str {
508        match self {
509            Self::Version => "that Podman 4.3.0 or newer is installed",
510            Self::UidMap => "that rootless Podman maps container UIDs 0 and 1",
511        }
512    }
513}
514
515/// Whether `podman unshare` refused to run because Podman is rootful
516/// (`please use unshare with rootless`) or remote (`cannot use command
517/// "podman unshare" with the remote podman client`).
518fn unshare_refused_non_rootless(stderr: &str) -> bool {
519    stderr.contains("unshare with rootless") || stderr.contains("remote podman client")
520}
521
522impl PodmanPostcondition {
523    pub(super) fn statement(self) -> &'static str {
524        match self {
525            Self::Version => "Postcondition `podman --version` succeeds with Podman 4.3.0 or newer",
526            Self::Rootless => {
527                "Postcondition Podman is local and rootless (`podman unshare` is allowed)"
528            }
529            Self::UidMap => {
530                "Postcondition `podman unshare cat /proc/self/uid_map` maps container UIDs 0 and 1"
531            }
532        }
533    }
534
535    pub(crate) fn remediation(self) -> &'static str {
536        match self {
537            Self::Version => {
538                "Install or upgrade Podman: Debian/Ubuntu `sudo apt update && sudo apt install -y podman uidmap`; Fedora `sudo dnf install -y podman shadow-utils`."
539            }
540            Self::Rootless => {
541                "Run Mjolnir as the ordinary user without `sudo`; if a remote Podman connection is configured, unset `CONTAINER_HOST` or select the rootless local connection."
542            }
543            Self::UidMap => {
544                "Install UID-map helpers (`sudo apt install -y uidmap` on Debian/Ubuntu or `sudo dnf install -y shadow-utils` on Fedora), then add subordinate ranges with `sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 \"$USER\"` and start a fresh login session."
545            }
546        }
547    }
548}
549
550pub(super) fn verify_podman(
551    host: PodmanHost<'_>,
552    executor: &impl CommandExecutor,
553) -> Result<PodmanPreflight> {
554    verify_podman_probes(host, |probe| execute_podman_probe(executor, host, probe))
555}
556
557/// Apply the rootless Podman postconditions to probe results, however they
558/// were obtained: one command each locally, one batched command over SSH.
559pub(super) fn verify_podman_probes(
560    host: PodmanHost<'_>,
561    probe_output: impl Fn(PodmanProbe) -> Result<CommandOutput>,
562) -> Result<PodmanPreflight> {
563    let version = probe_output(PodmanProbe::Version)?;
564    let version = parse_podman_version(host, &version.stdout)?;
565
566    let uid_map = probe_output(PodmanProbe::UidMap)?;
567    if !valid_rootless_uid_map(&uid_map.stdout) {
568        return Err(probe_failure(
569            host,
570            PodmanPostcondition::UidMap,
571            format!(
572                "{}: {} was not met.",
573                host.failure(),
574                PodmanPostcondition::UidMap.statement(),
575            ),
576        ));
577    }
578
579    Ok(PodmanPreflight {
580        version,
581        warnings: Vec::new(),
582    })
583}
584
585/// Report either an explicitly unsafe systemd setting or an unavailable
586/// durability check. Neither condition makes an otherwise usable target fail.
587pub(super) fn ssh_podman_linger_warning(
588    ssh: &SshTarget,
589    output: Option<&CommandOutput>,
590) -> Option<PodmanPreflightWarning> {
591    let Some(output) = output else {
592        return Some(linger_unavailable_warning(
593            ssh,
594            "the probe could not run: the preflight output did not include it".to_owned(),
595        ));
596    };
597    let linger = String::from_utf8_lossy(&output.stdout);
598    match (output.status, linger.trim().to_ascii_lowercase().as_str()) {
599        (0, "yes") => None,
600        (0, "no") => Some(PodmanPreflightWarning {
601            detail: format!(
602                "Remote user lingering is disabled on {}; SSH-Podman sessions may be terminated when the last SSH connection closes.",
603                ssh.destination
604            ),
605            remediation: format!(
606                "On {}, run `sudo loginctl enable-linger \"$(id -un)\"`.",
607                ssh.destination
608            ),
609        }),
610        (status, _) => {
611            let stderr = String::from_utf8_lossy(&output.stderr);
612            let stderr = stderr.trim();
613            let reason = if status == 127 || stderr.contains("loginctl: not found") {
614                "`loginctl` was not found; this host may not use systemd".to_owned()
615            } else if status != 0 {
616                format!("`loginctl` exited with status {status}: {stderr}")
617            } else {
618                format!("`loginctl` returned an unrecognized Linger value {linger:?}")
619            };
620            Some(linger_unavailable_warning(ssh, reason))
621        }
622    }
623}
624
625pub(super) fn linger_unavailable_warning(
626    ssh: &SshTarget,
627    reason: String,
628) -> PodmanPreflightWarning {
629    PodmanPreflightWarning {
630        detail: format!(
631            "Remote user-manager durability check is unavailable on {} because {reason}. Mjolnir cannot verify whether rootless Podman sessions survive logout.",
632            ssh.destination
633        ),
634        remediation: format!(
635            "Configure {}'s service manager to keep the user and rootless Podman services running after logout; if it uses systemd, make `loginctl` available and enable lingering.",
636            ssh.destination
637        ),
638    }
639}
640
641pub(super) fn execute_podman_probe(
642    executor: &impl CommandExecutor,
643    host: PodmanHost<'_>,
644    probe: PodmanProbe,
645) -> Result<CommandOutput> {
646    let command = host.command(probe.args(), probe.purpose());
647    let output = match executor.execute(&command) {
648        Ok(output) => output,
649        Err(error) => {
650            return Err(podman_probe_run_failure(
651                host,
652                probe,
653                &probe_run_reason(&error),
654            ));
655        }
656    };
657    check_podman_probe_status(host, probe, output)
658}
659
660/// Why a probe command could not be started: a missing `podman` in plain
661/// words, else the whole error chain, whose outer layer ("run podman for
662/// check Podman version") says nothing on its own.
663fn probe_run_reason(error: &anyhow::Error) -> String {
664    if is_missing_program(error) {
665        "`podman` is not installed or not on PATH".to_owned()
666    } else {
667        format!("{error:#}")
668    }
669}
670
671/// Failure for a probe that could not be run at all.
672pub(super) fn podman_probe_run_failure(
673    host: PodmanHost<'_>,
674    probe: PodmanProbe,
675    reported: &str,
676) -> anyhow::Error {
677    match ssh_transport_failure(host, reported) {
678        Some(message) => anyhow::anyhow!(message),
679        None => probe_failure(
680            host,
681            probe.postcondition(),
682            format!(
683                "{}: could not run `{}` to check {}: {reported}.",
684                host.failure(),
685                probe.args().join(" "),
686                probe.checks(),
687            ),
688        ),
689    }
690}
691
692pub(super) fn check_podman_probe_status(
693    host: PodmanHost<'_>,
694    probe: PodmanProbe,
695    output: CommandOutput,
696) -> Result<CommandOutput> {
697    // `ssh` reserves this status for its own connection failures; the Podman
698    // probes never produce it. Reporting that case separately keeps an
699    // unreachable host from being mistaken for a broken Podman installation.
700    if output.status == SSH_TRANSPORT_EXIT_STATUS
701        && let Some(message) =
702            ssh_transport_failure(host, String::from_utf8_lossy(&output.stderr).trim())
703    {
704        bail!("{message}");
705    }
706    if output.status != 0 {
707        let stderr = String::from_utf8_lossy(&output.stderr);
708        let stderr = stderr.trim();
709        let postcondition = if probe == PodmanProbe::UidMap && unshare_refused_non_rootless(stderr)
710        {
711            PodmanPostcondition::Rootless
712        } else {
713            probe.postcondition()
714        };
715        return Err(probe_failure(
716            host,
717            postcondition,
718            format!(
719                "{}: {} failed. Podman reported: {stderr}",
720                host.failure(),
721                postcondition.statement(),
722            ),
723        ));
724    }
725    Ok(output)
726}
727
728pub(super) const LINGER_PROBE_KEY: &str = "linger";
729pub(super) const PROBE_BLOCK_BEGIN: &str = "__mj_probe_begin__";
730pub(super) const PROBE_BLOCK_END: &str = "__mj_probe_end__";
731pub(super) const PROBE_STATUS_PREFIX: &str = "__mj_probe_status__";
732
733/// Run every remote Podman probe in one SSH round trip.
734///
735/// Each probe's stdout is captured in a shell variable and reprinted between
736/// framing markers, while its stderr is written straight to the saved stdout
737/// inside its own frame, so multi-line and arbitrary output survives intact.
738/// The version probe short-circuits the rest: without Podman the later probes
739/// can only repeat its failure.
740pub(super) const SSH_PODMAN_PREFLIGHT_SCRIPT: &str = r#"
741exec 3>&1
742probe() {
743    name=$1
744    shift
745    printf '__mj_probe_begin__ %s.stderr\n' "$name"
746    out=$("$@" 2>&3)
747    status=$?
748    printf '\n__mj_probe_end__\n'
749    printf '__mj_probe_begin__ %s.stdout\n%s\n__mj_probe_end__\n' "$name" "$out"
750    printf '__mj_probe_status__ %s %s\n' "$name" "$status"
751    return "$status"
752}
753probe version podman --version || exit 0
754probe uid_map podman unshare cat /proc/self/uid_map
755probe linger sh -c 'loginctl show-user "$(id -u)" --property=Linger --value'
756exit 0
757"#;
758
759pub(super) fn run_ssh_podman_probes(
760    host: PodmanHost<'_>,
761    executor: &impl CommandExecutor,
762) -> Result<BTreeMap<String, CommandOutput>> {
763    let command = host.command(
764        &["sh", "-c", SSH_PODMAN_PREFLIGHT_SCRIPT],
765        "check remote Podman prerequisites",
766    );
767    let output = match executor.execute(&command) {
768        Ok(output) => output,
769        Err(error) => {
770            return Err(podman_probe_run_failure(
771                host,
772                PodmanProbe::Version,
773                &error.to_string(),
774            ));
775        }
776    };
777    if output.status == SSH_TRANSPORT_EXIT_STATUS
778        && let Some(message) =
779            ssh_transport_failure(host, String::from_utf8_lossy(&output.stderr).trim())
780    {
781        bail!("{message}");
782    }
783    let probes = parse_podman_probe_output(&output.stdout);
784    if !probes.contains_key(PodmanProbe::Version.key()) {
785        return Err(podman_probe_run_failure(
786            host,
787            PodmanProbe::Version,
788            &format!(
789                "the preflight probes returned unparsable output (status {}): {}",
790                output.status,
791                String::from_utf8_lossy(&output.stderr).trim()
792            ),
793        ));
794    }
795    Ok(probes)
796}
797
798/// Build what the batched remote script prints for the given probe results.
799///
800/// Tests across this crate stand in for a remote host, so they need the real
801/// framing rather than a second, drifting description of it.
802#[cfg(test)]
803pub(crate) fn ssh_podman_probe_fixture(probes: &[(&str, i32, &str, &str)]) -> Vec<u8> {
804    let mut output = String::new();
805    for (name, status, stdout, stderr) in probes {
806        output.push_str(&format!("{PROBE_BLOCK_BEGIN} {name}.stderr\n"));
807        output.push_str(stderr);
808        output.push_str(&format!("\n{PROBE_BLOCK_END}\n"));
809        output.push_str(&format!("{PROBE_BLOCK_BEGIN} {name}.stdout\n"));
810        output.push_str(stdout.strip_suffix('\n').unwrap_or(stdout));
811        output.push_str(&format!("\n{PROBE_BLOCK_END}\n"));
812        output.push_str(&format!("{PROBE_STATUS_PREFIX} {name} {status}\n"));
813    }
814    output.into_bytes()
815}
816
817/// Split the batched script's framed output into one result per probe.
818///
819/// A probe appears only once its status line has been read, so output truncated
820/// mid-probe is reported as a missing probe rather than a partial result.
821pub(super) fn parse_podman_probe_output(stdout: &[u8]) -> BTreeMap<String, CommandOutput> {
822    let text = String::from_utf8_lossy(stdout);
823    let mut blocks: BTreeMap<String, String> = BTreeMap::new();
824    let mut probes = BTreeMap::new();
825    let mut lines = text.lines();
826    while let Some(line) = lines.next() {
827        if let Some(name) = line.strip_prefix(PROBE_BLOCK_BEGIN).and_then(|rest| {
828            rest.strip_prefix(' ')
829                .filter(|name| !name.is_empty())
830                .map(str::to_owned)
831        }) {
832            let mut body = Vec::new();
833            let mut closed = false;
834            for line in lines.by_ref() {
835                if line == PROBE_BLOCK_END {
836                    closed = true;
837                    break;
838                }
839                body.push(line);
840            }
841            if closed {
842                blocks.insert(name, body.join("\n"));
843            }
844            continue;
845        }
846        let Some(rest) = line.strip_prefix(PROBE_STATUS_PREFIX) else {
847            continue;
848        };
849        let mut fields = rest.split_whitespace();
850        let (Some(name), Some(status)) = (fields.next(), fields.next()) else {
851            continue;
852        };
853        let (Ok(status), Some(out), Some(err)) = (
854            status.parse::<i32>(),
855            blocks.remove(&format!("{name}.stdout")),
856            blocks.remove(&format!("{name}.stderr")),
857        ) else {
858            continue;
859        };
860        probes.insert(
861            name.to_owned(),
862            CommandOutput {
863                status,
864                stdout: out.into_bytes(),
865                stderr: err.into_bytes(),
866            },
867        );
868    }
869    probes
870}
871
872pub(super) fn ssh_transport_failure(host: PodmanHost<'_>, reported: &str) -> Option<String> {
873    let PodmanHost::Ssh(ssh) = host else {
874        return None;
875    };
876    let destination = &ssh.destination;
877    Some(format!(
878        "{}: SSH could not run the probes on {destination}. Verify that `ssh {destination}` succeeds noninteractively from this host. See {PODMAN_DOCUMENTATION_URL}. ssh reported: {reported}",
879        host.failure()
880    ))
881}
882
883pub(super) fn parse_podman_version(host: PodmanHost<'_>, stdout: &[u8]) -> Result<String> {
884    let failure = host.failure();
885    let version = String::from_utf8_lossy(stdout).trim().to_owned();
886    let Some(candidate) = version
887        .split_whitespace()
888        .find(|part| part.as_bytes().first().is_some_and(u8::is_ascii_digit))
889    else {
890        return Err(probe_failure(
891            host,
892            PodmanPostcondition::Version,
893            format!(
894                "{failure}: {} returned {version:?}.",
895                PodmanPostcondition::Version.statement()
896            ),
897        ));
898    };
899    let mut numbers = candidate.split('.').map(|part| part.parse::<u32>().ok());
900    let Some(Some(major)) = numbers.next() else {
901        return Err(probe_failure(
902            host,
903            PodmanPostcondition::Version,
904            format!(
905                "{failure}: {} returned {version:?}.",
906                PodmanPostcondition::Version.statement()
907            ),
908        ));
909    };
910    // A version with no minor component, such as `podman version 4`, names
911    // the earliest release of that series.
912    let minor = numbers.next().flatten().unwrap_or(0);
913    if (major, minor) < PODMAN_MINIMUM_VERSION {
914        return Err(probe_failure(
915            host,
916            PodmanPostcondition::Version,
917            format!(
918                "{failure}: {} was not met (found {candidate}).",
919                PodmanPostcondition::Version.statement()
920            ),
921        ));
922    }
923    Ok(candidate.to_owned())
924}
925
926pub(super) fn valid_rootless_uid_map(stdout: &[u8]) -> bool {
927    let mappings = String::from_utf8_lossy(stdout)
928        .lines()
929        .filter_map(|line| {
930            let mut fields = line.split_whitespace();
931            Some((
932                fields.next()?.parse::<u64>().ok()?,
933                fields.next()?.parse::<u64>().ok()?,
934                fields.next()?.parse::<u64>().ok()?,
935            ))
936        })
937        .collect::<Vec<_>>();
938    [0, 1].into_iter().all(|container_id| {
939        mappings.iter().any(|(inside, _outside, length)| {
940            inside
941                .checked_add(*length)
942                .is_some_and(|end| *inside <= container_id && container_id < end)
943        })
944    })
945}
946
947/// The uid and gid of the container image's configured user, read on the host
948/// that runs the container engine. `ssh` names that host for a remote Podman
949/// target; `None` reads it on this machine.
950///
951/// The image is asked rather than assumed, because `--userns=keep-id` has to
952/// name the ids the container will actually run as. The probe carries the
953/// template's own pull policy, so it reads the same image the launch will run
954/// and never pulls one the launch would not. The entrypoint is cleared so the
955/// answer comes from an image whose entrypoint is a long-running program.
956pub fn probe_image_user(
957    ssh: Option<&SshTarget>,
958    template: &ContainerTemplate,
959    executor: &impl CommandExecutor,
960) -> Result<ImageUser> {
961    let host = match ssh {
962        Some(ssh) => PodmanHost::Ssh(ssh),
963        None => PodmanHost::Local,
964    };
965    let mut args = vec!["podman".to_owned(), "run".to_owned(), "--rm".to_owned()];
966    args.extend(podman_pull_argument(template));
967    args.extend([
968        "--entrypoint".to_owned(),
969        String::new(),
970        template.image.clone(),
971        "sh".to_owned(),
972        "-c".to_owned(),
973        "id -u; id -g".to_owned(),
974    ]);
975    let output = executor.execute(&host.command_owned(args, "read the container image user"))?;
976    if output.status != 0 {
977        bail!(
978            "image user probe failed with status {}: {}",
979            output.status,
980            String::from_utf8_lossy(&output.stderr).trim()
981        );
982    }
983    let stdout = String::from_utf8_lossy(&output.stdout);
984    let mut ids = stdout
985        .lines()
986        .map(str::trim)
987        .filter(|line| !line.is_empty());
988    let mut next = |field: &str| -> Result<u32> {
989        ids.next()
990            .with_context(|| format!("image user probe reported no {field}"))?
991            .parse()
992            .with_context(|| format!("image user probe reported an unreadable {field}"))
993    };
994    let uid = next("uid")?;
995    let gid = next("gid")?;
996    Ok(ImageUser { uid, gid })
997}
998
999/// Filesystem type of each directory, probed on the host that runs the
1000/// container engine. `ssh` names that host for a remote Podman target; `None`
1001/// probes this machine.
1002///
1003/// The reply is positional, so the whole batch fails unless `stat` answered for
1004/// every directory in order.
1005pub fn probe_filesystem_types(
1006    ssh: Option<&SshTarget>,
1007    paths: &[PathBuf],
1008    executor: &impl CommandExecutor,
1009) -> Result<Vec<String>> {
1010    if paths.is_empty() {
1011        return Ok(Vec::new());
1012    }
1013    let mut args = vec![
1014        "stat".to_owned(),
1015        "-f".to_owned(),
1016        "-c".to_owned(),
1017        "%T".to_owned(),
1018        "--".to_owned(),
1019    ];
1020    args.extend(paths.iter().map(|path| path.to_string_lossy().into_owned()));
1021    let host = match ssh {
1022        Some(ssh) => PodmanHost::Ssh(ssh),
1023        None => PodmanHost::Local,
1024    };
1025    let output = executor.execute(&host.command_owned(args, "probe mount source filesystem"))?;
1026    if output.status != 0 {
1027        bail!(
1028            "filesystem probe failed with status {}: {}",
1029            output.status,
1030            String::from_utf8_lossy(&output.stderr).trim()
1031        );
1032    }
1033    let types = String::from_utf8_lossy(&output.stdout)
1034        .lines()
1035        .map(|line| line.trim().to_owned())
1036        .collect::<Vec<_>>();
1037    if types.len() != paths.len() {
1038        bail!(
1039            "filesystem probe named {} filesystems for {} directories",
1040            types.len(),
1041            paths.len()
1042        );
1043    }
1044    Ok(types)
1045}