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
128pub(super) fn verify_docker(
129    ssh: Option<&SshTarget>,
130    executor: &impl CommandExecutor,
131) -> Result<DockerPreflight> {
132    let command = CommandSpec::new(
133        "docker",
134        ["version", "--format", "{{.Server.Version}} {{.Server.Os}}"],
135    )
136    .purpose("check Docker daemon")
137    .stage(ProvisionStage::Provisioning);
138    let command = match ssh {
139        Some(ssh) => command_over_ssh(command, ssh),
140        None => command,
141    };
142    let output = executor
143        .execute(&command)
144        .context("Docker preflight failed: run `docker info` as the user running Mjolnir")?;
145    ensure!(
146        output.status == 0,
147        "Docker preflight failed: `docker version` exited with status {}: {}. Run `docker info` as the user running Mjolnir. See {DOCKER_DOCUMENTATION_PATH}.",
148        output.status,
149        String::from_utf8_lossy(&output.stderr).trim()
150    );
151    let reported = String::from_utf8_lossy(&output.stdout);
152    let mut fields = reported.split_whitespace();
153    let version = fields.next().unwrap_or_default();
154    let os = fields.next().unwrap_or_default();
155    ensure!(
156        !version.is_empty() && os == "linux",
157        "Docker preflight failed: expected a Linux Docker daemon, got {:?}. See {DOCKER_DOCUMENTATION_PATH}.",
158        reported.trim()
159    );
160    Ok(DockerPreflight {
161        version: version.to_owned(),
162    })
163}
164
165/// Verify the same rootless Podman preconditions on an SSH host.
166///
167/// The probes run through the noninteractive SSH options, so an unreachable
168/// host fails fast instead of blocking doctor or session preflight.
169pub fn verify_ssh_podman(
170    ssh: &SshTarget,
171    executor: &impl CommandExecutor,
172) -> Result<PodmanPreflight> {
173    let host = PodmanHost::Ssh(ssh);
174    validate_ssh(ssh).map_err(|error| {
175        anyhow::anyhow!(
176            "{}: the configured SSH destination is unusable ({error}). Set a valid `host` (and optional `user`) for this ssh-podman target. See {PODMAN_DOCUMENTATION_PATH}.",
177            host.failure()
178        )
179    })?;
180    // One SSH round trip carries every probe; a remote shell runs them in
181    // sequence and frames each result so the checks below stay unchanged.
182    let probes = run_ssh_podman_probes(host, executor)?;
183    let mut preflight = verify_podman_probes(host, |probe| {
184        let output = probes.get(probe.key()).cloned().ok_or_else(|| {
185            anyhow::anyhow!(
186                "{}",
187                ssh_transport_failure(
188                    host,
189                    &format!(
190                        "the preflight output ended before the {} probe",
191                        probe.key()
192                    ),
193                )
194                .expect("SSH host always reports a transport failure")
195            )
196        })?;
197        check_podman_probe_status(host, probe, output)
198    })?;
199    if let Some(warning) = ssh_podman_linger_warning(ssh, probes.get(LINGER_PROBE_KEY)) {
200        preflight.warnings.push(warning);
201    }
202    Ok(preflight)
203}
204
205/// One rootless Podman postcondition, with the wording used to report it.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub(crate) enum PodmanProbe {
208    Version,
209    Rootless,
210    UidMap,
211}
212
213/// A rootless Podman postcondition that was not met, carrying which one.
214///
215/// `mj doctor` needs the probe, not its wording, to name the fix. Carrying the
216/// probe on the error means the diagnosis never depends on matching message
217/// text that this repository itself produces.
218#[derive(Debug)]
219pub(crate) struct PodmanProbeFailure {
220    probe: PodmanProbe,
221    message: String,
222}
223
224impl std::fmt::Display for PodmanProbeFailure {
225    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        formatter.write_str(&self.message)
227    }
228}
229
230impl std::error::Error for PodmanProbeFailure {}
231
232/// The probe whose postcondition failed, when the error came from one.
233pub(crate) fn failed_podman_probe(error: &anyhow::Error) -> Option<PodmanProbe> {
234    error
235        .downcast_ref::<PodmanProbeFailure>()
236        .map(|failure| failure.probe)
237}
238
239/// Fail a probe with its own wording, keeping the probe machine-readable.
240fn probe_failure(probe: PodmanProbe, message: String) -> anyhow::Error {
241    anyhow::Error::new(PodmanProbeFailure { probe, message })
242}
243
244impl PodmanProbe {
245    /// Name of this probe in the batched remote script's output.
246    pub(super) fn key(self) -> &'static str {
247        match self {
248            Self::Version => "version",
249            Self::Rootless => "rootless",
250            Self::UidMap => "uid_map",
251        }
252    }
253
254    pub(super) fn args(self) -> &'static [&'static str] {
255        match self {
256            Self::Version => &["podman", "--version"],
257            Self::Rootless => &["podman", "info", "--format", "{{.Host.Security.Rootless}}"],
258            Self::UidMap => &["podman", "unshare", "cat", "/proc/self/uid_map"],
259        }
260    }
261
262    pub(super) fn purpose(self) -> &'static str {
263        match self {
264            Self::Version => "check Podman version",
265            Self::Rootless => "check rootless Podman mode",
266            Self::UidMap => "check rootless Podman UID map",
267        }
268    }
269
270    pub(super) fn postcondition(self) -> &'static str {
271        match self {
272            Self::Version => "Postcondition `podman --version` succeeds with Podman 4.3.0 or newer",
273            Self::Rootless => {
274                "Postcondition `podman info --format '{{.Host.Security.Rootless}}'` prints `true`"
275            }
276            Self::UidMap => {
277                "Postcondition `podman unshare cat /proc/self/uid_map` maps container UIDs 0 and 1"
278            }
279        }
280    }
281
282    pub(crate) fn remediation(self) -> &'static str {
283        match self {
284            Self::Version => {
285                "Install or upgrade Podman: Debian/Ubuntu `sudo apt update && sudo apt install -y podman uidmap`; Fedora `sudo dnf install -y podman shadow-utils`."
286            }
287            Self::Rootless => {
288                "Run Mjolnir as the ordinary user without `sudo`; if a remote Podman connection is configured, unset `CONTAINER_HOST` or select the rootless local connection."
289            }
290            Self::UidMap => {
291                "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."
292            }
293        }
294    }
295}
296
297pub(super) fn verify_podman(
298    host: PodmanHost<'_>,
299    executor: &impl CommandExecutor,
300) -> Result<PodmanPreflight> {
301    verify_podman_probes(host, |probe| execute_podman_probe(executor, host, probe))
302}
303
304/// Apply the rootless Podman postconditions to probe results, however they
305/// were obtained: one command each locally, one batched command over SSH.
306pub(super) fn verify_podman_probes(
307    host: PodmanHost<'_>,
308    probe_output: impl Fn(PodmanProbe) -> Result<CommandOutput>,
309) -> Result<PodmanPreflight> {
310    let version = probe_output(PodmanProbe::Version)?;
311    let version = parse_podman_version(host, &version.stdout)?;
312
313    let rootless = probe_output(PodmanProbe::Rootless)?;
314    let rootless_output = String::from_utf8_lossy(&rootless.stdout);
315    if rootless_output.trim() != "true" {
316        return Err(probe_failure(
317            PodmanProbe::Rootless,
318            format!(
319                "{}: Postcondition `podman info --format '{{{{.Host.Security.Rootless}}}}'` prints `true` returned {:?}. {}Run Mjolnir as the ordinary user without `sudo`; if a remote Podman connection is configured, unset `CONTAINER_HOST` or select the rootless local connection. See {PODMAN_DOCUMENTATION_PATH}.",
320                host.failure(),
321                rootless_output.trim(),
322                host.remediation_scope(),
323            ),
324        ));
325    }
326
327    let uid_map = probe_output(PodmanProbe::UidMap)?;
328    if !valid_rootless_uid_map(&uid_map.stdout) {
329        return Err(probe_failure(
330            PodmanProbe::UidMap,
331            format!(
332                "{}: Postcondition `podman unshare cat /proc/self/uid_map` maps container UIDs 0 and 1 was not met. {}Add subordinate ranges with `sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 \"$USER\"`, verify `/etc/subuid` and `/etc/subgid`, then log out and back in. See {PODMAN_DOCUMENTATION_PATH}.",
333                host.failure(),
334                host.remediation_scope(),
335            ),
336        ));
337    }
338
339    Ok(PodmanPreflight {
340        version,
341        warnings: Vec::new(),
342    })
343}
344
345/// Report either an explicitly unsafe systemd setting or an unavailable
346/// durability check. Neither condition makes an otherwise usable target fail.
347pub(super) fn ssh_podman_linger_warning(
348    ssh: &SshTarget,
349    output: Option<&CommandOutput>,
350) -> Option<PodmanPreflightWarning> {
351    let Some(output) = output else {
352        return Some(linger_unavailable_warning(
353            ssh,
354            "the probe could not run: the preflight output did not include it".to_owned(),
355        ));
356    };
357    let linger = String::from_utf8_lossy(&output.stdout);
358    match (output.status, linger.trim().to_ascii_lowercase().as_str()) {
359        (0, "yes") => None,
360        (0, "no") => Some(PodmanPreflightWarning {
361            detail: format!(
362                "Remote user lingering is disabled on {}; SSH-Podman sessions may be terminated when the last SSH connection closes.",
363                ssh.destination
364            ),
365            remediation: format!(
366                "On {}, run `sudo loginctl enable-linger \"$(id -un)\"`.",
367                ssh.destination
368            ),
369        }),
370        (status, _) => {
371            let stderr = String::from_utf8_lossy(&output.stderr);
372            let stderr = stderr.trim();
373            let reason = if status == 127 || stderr.contains("loginctl: not found") {
374                "`loginctl` was not found; this host may not use systemd".to_owned()
375            } else if status != 0 {
376                format!("`loginctl` exited with status {status}: {stderr}")
377            } else {
378                format!("`loginctl` returned an unrecognized Linger value {linger:?}")
379            };
380            Some(linger_unavailable_warning(ssh, reason))
381        }
382    }
383}
384
385pub(super) fn linger_unavailable_warning(
386    ssh: &SshTarget,
387    reason: String,
388) -> PodmanPreflightWarning {
389    PodmanPreflightWarning {
390        detail: format!(
391            "Remote user-manager durability check is unavailable on {} because {reason}. Mjolnir cannot verify whether rootless Podman sessions survive logout.",
392            ssh.destination
393        ),
394        remediation: format!(
395            "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.",
396            ssh.destination
397        ),
398    }
399}
400
401pub(super) fn execute_podman_probe(
402    executor: &impl CommandExecutor,
403    host: PodmanHost<'_>,
404    probe: PodmanProbe,
405) -> Result<CommandOutput> {
406    let command = host.command(probe.args(), probe.purpose());
407    let output = match executor.execute(&command) {
408        Ok(output) => output,
409        Err(error) => {
410            return Err(probe_failure(
411                probe,
412                podman_probe_run_failure(host, probe, &error.to_string()),
413            ));
414        }
415    };
416    check_podman_probe_status(host, probe, output)
417}
418
419/// Message for a probe that could not be run at all.
420pub(super) fn podman_probe_run_failure(
421    host: PodmanHost<'_>,
422    probe: PodmanProbe,
423    reported: &str,
424) -> String {
425    match ssh_transport_failure(host, reported) {
426        Some(message) => message,
427        None => format!(
428            "{}: {}. {}{} See {PODMAN_DOCUMENTATION_PATH}. Underlying error: {reported}",
429            host.failure(),
430            probe.postcondition(),
431            host.remediation_scope(),
432            probe.remediation(),
433        ),
434    }
435}
436
437pub(super) fn check_podman_probe_status(
438    host: PodmanHost<'_>,
439    probe: PodmanProbe,
440    output: CommandOutput,
441) -> Result<CommandOutput> {
442    // `ssh` reserves this status for its own connection failures; the Podman
443    // probes never produce it. Reporting that case separately keeps an
444    // unreachable host from being mistaken for a broken Podman installation.
445    if output.status == SSH_TRANSPORT_EXIT_STATUS
446        && let Some(message) =
447            ssh_transport_failure(host, String::from_utf8_lossy(&output.stderr).trim())
448    {
449        bail!("{message}");
450    }
451    if output.status != 0 {
452        return Err(probe_failure(
453            probe,
454            format!(
455                "{}: {}. {}{} See {PODMAN_DOCUMENTATION_PATH}. Podman reported: {}",
456                host.failure(),
457                probe.postcondition(),
458                host.remediation_scope(),
459                probe.remediation(),
460                String::from_utf8_lossy(&output.stderr).trim()
461            ),
462        ));
463    }
464    Ok(output)
465}
466
467pub(super) const LINGER_PROBE_KEY: &str = "linger";
468pub(super) const PROBE_BLOCK_BEGIN: &str = "__mj_probe_begin__";
469pub(super) const PROBE_BLOCK_END: &str = "__mj_probe_end__";
470pub(super) const PROBE_STATUS_PREFIX: &str = "__mj_probe_status__";
471
472/// Run every remote Podman probe in one SSH round trip.
473///
474/// Each probe's stdout is captured in a shell variable and reprinted between
475/// framing markers, while its stderr is written straight to the saved stdout
476/// inside its own frame, so multi-line and arbitrary output survives intact.
477/// The version probe short-circuits the rest: without Podman the later probes
478/// can only repeat its failure.
479pub(super) const SSH_PODMAN_PREFLIGHT_SCRIPT: &str = r#"
480exec 3>&1
481probe() {
482    name=$1
483    shift
484    printf '__mj_probe_begin__ %s.stderr\n' "$name"
485    out=$("$@" 2>&3)
486    status=$?
487    printf '\n__mj_probe_end__\n'
488    printf '__mj_probe_begin__ %s.stdout\n%s\n__mj_probe_end__\n' "$name" "$out"
489    printf '__mj_probe_status__ %s %s\n' "$name" "$status"
490    return "$status"
491}
492probe version podman --version || exit 0
493probe rootless podman info --format '{{.Host.Security.Rootless}}'
494probe uid_map podman unshare cat /proc/self/uid_map
495probe linger sh -c 'loginctl show-user "$(id -u)" --property=Linger --value'
496exit 0
497"#;
498
499pub(super) fn run_ssh_podman_probes(
500    host: PodmanHost<'_>,
501    executor: &impl CommandExecutor,
502) -> Result<BTreeMap<String, CommandOutput>> {
503    let command = host.command(
504        &["sh", "-c", SSH_PODMAN_PREFLIGHT_SCRIPT],
505        "check remote Podman prerequisites",
506    );
507    let output = match executor.execute(&command) {
508        Ok(output) => output,
509        Err(error) => bail!(
510            "{}",
511            podman_probe_run_failure(host, PodmanProbe::Version, &error.to_string())
512        ),
513    };
514    if output.status == SSH_TRANSPORT_EXIT_STATUS
515        && let Some(message) =
516            ssh_transport_failure(host, String::from_utf8_lossy(&output.stderr).trim())
517    {
518        bail!("{message}");
519    }
520    let probes = parse_podman_probe_output(&output.stdout);
521    if !probes.contains_key(PodmanProbe::Version.key()) {
522        bail!(
523            "{}",
524            podman_probe_run_failure(
525                host,
526                PodmanProbe::Version,
527                &format!(
528                    "the preflight probes returned unparsable output (status {}): {}",
529                    output.status,
530                    String::from_utf8_lossy(&output.stderr).trim()
531                ),
532            )
533        );
534    }
535    Ok(probes)
536}
537
538/// Build what the batched remote script prints for the given probe results.
539///
540/// Tests across this crate stand in for a remote host, so they need the real
541/// framing rather than a second, drifting description of it.
542#[cfg(test)]
543pub(crate) fn ssh_podman_probe_fixture(probes: &[(&str, i32, &str, &str)]) -> Vec<u8> {
544    let mut output = String::new();
545    for (name, status, stdout, stderr) in probes {
546        output.push_str(&format!("{PROBE_BLOCK_BEGIN} {name}.stderr\n"));
547        output.push_str(stderr);
548        output.push_str(&format!("\n{PROBE_BLOCK_END}\n"));
549        output.push_str(&format!("{PROBE_BLOCK_BEGIN} {name}.stdout\n"));
550        output.push_str(stdout.strip_suffix('\n').unwrap_or(stdout));
551        output.push_str(&format!("\n{PROBE_BLOCK_END}\n"));
552        output.push_str(&format!("{PROBE_STATUS_PREFIX} {name} {status}\n"));
553    }
554    output.into_bytes()
555}
556
557/// Split the batched script's framed output into one result per probe.
558///
559/// A probe appears only once its status line has been read, so output truncated
560/// mid-probe is reported as a missing probe rather than a partial result.
561pub(super) fn parse_podman_probe_output(stdout: &[u8]) -> BTreeMap<String, CommandOutput> {
562    let text = String::from_utf8_lossy(stdout);
563    let mut blocks: BTreeMap<String, String> = BTreeMap::new();
564    let mut probes = BTreeMap::new();
565    let mut lines = text.lines();
566    while let Some(line) = lines.next() {
567        if let Some(name) = line.strip_prefix(PROBE_BLOCK_BEGIN).and_then(|rest| {
568            rest.strip_prefix(' ')
569                .filter(|name| !name.is_empty())
570                .map(str::to_owned)
571        }) {
572            let mut body = Vec::new();
573            let mut closed = false;
574            for line in lines.by_ref() {
575                if line == PROBE_BLOCK_END {
576                    closed = true;
577                    break;
578                }
579                body.push(line);
580            }
581            if closed {
582                blocks.insert(name, body.join("\n"));
583            }
584            continue;
585        }
586        let Some(rest) = line.strip_prefix(PROBE_STATUS_PREFIX) else {
587            continue;
588        };
589        let mut fields = rest.split_whitespace();
590        let (Some(name), Some(status)) = (fields.next(), fields.next()) else {
591            continue;
592        };
593        let (Ok(status), Some(out), Some(err)) = (
594            status.parse::<i32>(),
595            blocks.remove(&format!("{name}.stdout")),
596            blocks.remove(&format!("{name}.stderr")),
597        ) else {
598            continue;
599        };
600        probes.insert(
601            name.to_owned(),
602            CommandOutput {
603                status,
604                stdout: out.into_bytes(),
605                stderr: err.into_bytes(),
606            },
607        );
608    }
609    probes
610}
611
612pub(super) fn ssh_transport_failure(host: PodmanHost<'_>, reported: &str) -> Option<String> {
613    let PodmanHost::Ssh(ssh) = host else {
614        return None;
615    };
616    let destination = &ssh.destination;
617    Some(format!(
618        "{}: SSH could not run the probes on {destination}. Verify that `ssh {destination}` succeeds noninteractively from this host. See {PODMAN_DOCUMENTATION_PATH}. ssh reported: {reported}",
619        host.failure()
620    ))
621}
622
623pub(super) fn parse_podman_version(host: PodmanHost<'_>, stdout: &[u8]) -> Result<String> {
624    let failure = host.failure();
625    let scope = host.remediation_scope();
626    let version = String::from_utf8_lossy(stdout).trim().to_owned();
627    let Some(candidate) = version
628        .split_whitespace()
629        .find(|part| part.as_bytes().first().is_some_and(u8::is_ascii_digit))
630    else {
631        return Err(probe_failure(
632            PodmanProbe::Version,
633            format!(
634                "{failure}: Postcondition `podman --version` succeeds with Podman 4.3.0 or newer returned {version:?}. {scope}Install or upgrade Podman: Debian/Ubuntu `sudo apt update && sudo apt install -y podman uidmap`; Fedora `sudo dnf install -y podman shadow-utils`. See {PODMAN_DOCUMENTATION_PATH}."
635            ),
636        ));
637    };
638    let mut numbers = candidate.split('.').map(|part| part.parse::<u32>().ok());
639    let Some(Some(major)) = numbers.next() else {
640        return Err(probe_failure(
641            PodmanProbe::Version,
642            format!(
643                "{failure}: Postcondition `podman --version` succeeds with Podman 4.3.0 or newer returned {version:?}. {scope}Install or upgrade Podman: Debian/Ubuntu `sudo apt update && sudo apt install -y podman uidmap`; Fedora `sudo dnf install -y podman shadow-utils`. See {PODMAN_DOCUMENTATION_PATH}."
644            ),
645        ));
646    };
647    // A version with no minor component, such as `podman version 4`, names
648    // the earliest release of that series.
649    let minor = numbers.next().flatten().unwrap_or(0);
650    if (major, minor) < PODMAN_MINIMUM_VERSION {
651        return Err(probe_failure(
652            PodmanProbe::Version,
653            format!(
654                "{failure}: Postcondition `podman --version` succeeds with Podman 4.3.0 or newer was not met (found {candidate}). {scope}Upgrade Podman to 4.3.0 or newer: Debian/Ubuntu `sudo apt update && sudo apt install -y podman uidmap`; Fedora `sudo dnf install -y podman shadow-utils`. See {PODMAN_DOCUMENTATION_PATH}."
655            ),
656        ));
657    }
658    Ok(candidate.to_owned())
659}
660
661pub(super) fn valid_rootless_uid_map(stdout: &[u8]) -> bool {
662    let mappings = String::from_utf8_lossy(stdout)
663        .lines()
664        .filter_map(|line| {
665            let mut fields = line.split_whitespace();
666            Some((
667                fields.next()?.parse::<u64>().ok()?,
668                fields.next()?.parse::<u64>().ok()?,
669                fields.next()?.parse::<u64>().ok()?,
670            ))
671        })
672        .collect::<Vec<_>>();
673    [0, 1].into_iter().all(|container_id| {
674        mappings.iter().any(|(inside, _outside, length)| {
675            inside
676                .checked_add(*length)
677                .is_some_and(|end| *inside <= container_id && container_id < end)
678        })
679    })
680}
681
682/// The uid and gid of the container image's configured user, read on the host
683/// that runs the container engine. `ssh` names that host for a remote Podman
684/// target; `None` reads it on this machine.
685///
686/// The image is asked rather than assumed, because `--userns=keep-id` has to
687/// name the ids the container will actually run as. The probe carries the
688/// template's own pull policy, so it reads the same image the launch will run
689/// and never pulls one the launch would not. The entrypoint is cleared so the
690/// answer comes from an image whose entrypoint is a long-running program.
691pub fn probe_image_user(
692    ssh: Option<&SshTarget>,
693    template: &ContainerTemplate,
694    executor: &impl CommandExecutor,
695) -> Result<ImageUser> {
696    let host = match ssh {
697        Some(ssh) => PodmanHost::Ssh(ssh),
698        None => PodmanHost::Local,
699    };
700    let mut args = vec!["podman".to_owned(), "run".to_owned(), "--rm".to_owned()];
701    args.extend(podman_pull_argument(template));
702    args.extend([
703        "--entrypoint".to_owned(),
704        String::new(),
705        template.image.clone(),
706        "sh".to_owned(),
707        "-c".to_owned(),
708        "id -u; id -g".to_owned(),
709    ]);
710    let output = executor.execute(&host.command_owned(args, "read the container image user"))?;
711    if output.status != 0 {
712        bail!(
713            "image user probe failed with status {}: {}",
714            output.status,
715            String::from_utf8_lossy(&output.stderr).trim()
716        );
717    }
718    let stdout = String::from_utf8_lossy(&output.stdout);
719    let mut ids = stdout
720        .lines()
721        .map(str::trim)
722        .filter(|line| !line.is_empty());
723    let mut next = |field: &str| -> Result<u32> {
724        ids.next()
725            .with_context(|| format!("image user probe reported no {field}"))?
726            .parse()
727            .with_context(|| format!("image user probe reported an unreadable {field}"))
728    };
729    let uid = next("uid")?;
730    let gid = next("gid")?;
731    Ok(ImageUser { uid, gid })
732}
733
734/// Filesystem type of each directory, probed on the host that runs the
735/// container engine. `ssh` names that host for a remote Podman target; `None`
736/// probes this machine.
737///
738/// The reply is positional, so the whole batch fails unless `stat` answered for
739/// every directory in order.
740pub fn probe_filesystem_types(
741    ssh: Option<&SshTarget>,
742    paths: &[PathBuf],
743    executor: &impl CommandExecutor,
744) -> Result<Vec<String>> {
745    if paths.is_empty() {
746        return Ok(Vec::new());
747    }
748    let mut args = vec![
749        "stat".to_owned(),
750        "-f".to_owned(),
751        "-c".to_owned(),
752        "%T".to_owned(),
753        "--".to_owned(),
754    ];
755    args.extend(paths.iter().map(|path| path.to_string_lossy().into_owned()));
756    let host = match ssh {
757        Some(ssh) => PodmanHost::Ssh(ssh),
758        None => PodmanHost::Local,
759    };
760    let output = executor.execute(&host.command_owned(args, "probe mount source filesystem"))?;
761    if output.status != 0 {
762        bail!(
763            "filesystem probe failed with status {}: {}",
764            output.status,
765            String::from_utf8_lossy(&output.stderr).trim()
766        );
767    }
768    let types = String::from_utf8_lossy(&output.stdout)
769        .lines()
770        .map(|line| line.trim().to_owned())
771        .collect::<Vec<_>>();
772    if types.len() != paths.len() {
773        bail!(
774            "filesystem probe named {} filesystems for {} directories",
775            types.len(),
776            paths.len()
777        );
778    }
779    Ok(types)
780}