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