1use super::*;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub(super) enum ManagedResourceKind {
5 Container,
6 Ec2Instance,
7}
8
9pub(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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub(super) enum PodmanHost<'a> {
58 Local,
59 Ssh(&'a SshTarget),
60}
61
62impl PodmanHost<'_> {
63 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 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
94pub 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
107pub 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 fn engine_not_installed(engine: &str) -> String {
131 format!("{engine} is not installed on this host")
132}
133
134pub 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
146pub 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#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum DockerUnavailable {
159 NotInstalled,
161 NotRunning { reported: String },
163 NotAnswering { status: i32, reported: String },
166}
167
168impl DockerUnavailable {
169 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 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 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
241fn 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
247fn 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 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
316pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub(crate) enum PodmanPostcondition {
359 Version,
360 Rootless,
361 UidMap,
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369pub(crate) enum PodmanProbe {
370 Version,
371 UidMap,
372}
373
374#[derive(Debug)]
380pub(crate) struct PodmanProbeFailure {
381 postcondition: PodmanPostcondition,
382 observation: String,
384 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
396pub(crate) fn failed_podman_postcondition(error: &anyhow::Error) -> Option<PodmanPostcondition> {
398 error
399 .downcast_ref::<PodmanProbeFailure>()
400 .map(|failure| failure.postcondition)
401}
402
403pub(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
411fn 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 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 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
469fn 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
511pub(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
539pub(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
614fn 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
625pub(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 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
687pub(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#[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
771pub(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 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
901pub 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
953pub 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}