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 local_docker_vm_share(executor: &impl CommandExecutor) -> Result<Option<&'static str>> {
138 const REASON: &str = "Docker runs in a VM that reaches host directories through a file share";
139 if !cfg!(target_os = "linux") {
140 return Ok(Some(REASON));
141 }
142 let command = CommandSpec::new(
143 "docker",
144 ["version", "--format", "{{.Server.Platform.Name}}"],
145 )
146 .purpose("identify the Docker daemon platform")
147 .stage(ProvisionStage::Provisioning);
148 let output = executor.execute(&command)?;
149 ensure!(
150 output.status == 0,
151 "{} failed with status {}: {}",
152 command.purpose,
153 output.status,
154 String::from_utf8_lossy(&output.stderr).trim()
155 );
156 let platform = String::from_utf8_lossy(&output.stdout);
157 Ok(platform
158 .trim()
159 .starts_with("Docker Desktop")
160 .then_some(REASON))
161}
162
163pub fn engine_not_installed(engine: &str) -> String {
166 format!("{engine} is not installed on this host")
167}
168
169pub fn local_engine_command(template: &mj_core::config::TargetTemplate) -> Option<&'static str> {
172 use mj_core::config::TargetTemplate as Template;
173 match template {
174 Template::LocalPodman { .. } => Some("podman"),
175 Template::LocalDocker { .. } => Some("docker"),
176 Template::AppleContainer { .. } => Some("container"),
177 _ => None,
178 }
179}
180
181pub fn runtime_missing_on_host(
187 template: &mj_core::config::TargetTemplate,
188 path: Option<&std::ffi::OsStr>,
189) -> bool {
190 local_engine_command(template).is_some_and(|engine| !program_on_path(engine, path))
191}
192
193pub fn program_on_path(program: &str, path: Option<&std::ffi::OsStr>) -> bool {
197 mj_core::program_path::find_program_on_path(program, path).is_some()
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
204pub enum DockerUnavailable {
205 NotInstalled,
207 NotRunning { reported: String },
209 NotAnswering { status: i32, reported: String },
212}
213
214impl DockerUnavailable {
215 pub fn remedy(&self) -> &'static str {
220 match self {
221 Self::NotInstalled => "Install Docker or choose another target.",
222 Self::NotRunning { .. } => "Start Docker.",
223 Self::NotAnswering { .. } => "Fix what it reports.",
224 }
225 }
226
227 pub fn launch_remedy(&self) -> &'static str {
230 match self {
231 Self::NotInstalled => "Install Docker or choose another target, then Retry launch.",
232 Self::NotRunning { .. } => "Start Docker, then Retry launch.",
233 Self::NotAnswering { .. } => "Fix what it reports, then Retry launch.",
234 }
235 }
236
237 pub fn remediation(&self) -> String {
239 match self {
240 Self::NotInstalled => {
241 format!("Install Docker ({DOCKER_DOCUMENTATION_URL}), or use another target.")
242 }
243 Self::NotRunning { .. } => {
244 "Start Docker, then make sure `docker info` succeeds as the user running Mjolnir."
245 .to_owned()
246 }
247 Self::NotAnswering { .. } => format!(
248 "Make sure `docker info` succeeds as the user running Mjolnir. See {DOCKER_DOCUMENTATION_URL}."
249 ),
250 }
251 }
252}
253
254impl std::fmt::Display for DockerUnavailable {
255 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match self {
257 Self::NotInstalled => write!(formatter, "{}.", engine_not_installed("Docker")),
258 Self::NotRunning { reported } => {
259 write!(
260 formatter,
261 "Docker is installed, but its daemon is not running."
262 )?;
263 if !reported.is_empty() {
264 write!(formatter, " `docker version` said: {reported}")?;
265 }
266 Ok(())
267 }
268 Self::NotAnswering { status, reported } => {
269 write!(
270 formatter,
271 "Docker did not answer its check on this host: `docker version` exited with status {status}"
272 )?;
273 if !reported.is_empty() {
274 write!(formatter, ": {reported}")?;
275 }
276 write!(
277 formatter,
278 ". Run `docker info` as the user running Mjolnir to see why."
279 )
280 }
281 }
282 }
283}
284
285impl std::error::Error for DockerUnavailable {}
286
287fn docker_daemon_not_running(reported: &str) -> bool {
289 reported.contains("Cannot connect to the Docker daemon")
290 || reported.contains("Is the docker daemon running")
291}
292
293fn is_missing_program(error: &anyhow::Error) -> bool {
295 error.chain().any(|cause| {
296 cause
297 .downcast_ref::<std::io::Error>()
298 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
299 })
300}
301
302pub(super) fn verify_docker(
303 ssh: Option<&SshTarget>,
304 executor: &impl CommandExecutor,
305) -> Result<DockerPreflight> {
306 let command = CommandSpec::new(
307 "docker",
308 ["version", "--format", "{{.Server.Version}} {{.Server.Os}}"],
309 )
310 .purpose("check Docker daemon")
311 .stage(ProvisionStage::Provisioning);
312 let command = match ssh {
313 Some(ssh) => command_over_ssh(command, ssh),
314 None => command,
315 };
316 let output = match executor.execute(&command) {
317 Ok(output) => output,
318 Err(error) if ssh.is_none() && is_missing_program(&error) => {
322 return Err(error.context(DockerUnavailable::NotInstalled));
323 }
324 Err(error) => {
325 return Err(error.context(
326 "Docker preflight failed: run `docker info` as the user running Mjolnir",
327 ));
328 }
329 };
330 if output.status != 0 {
331 let reported = String::from_utf8_lossy(&output.stderr).trim().to_owned();
332 if ssh.is_none() {
333 let problem = if docker_daemon_not_running(&reported) {
334 DockerUnavailable::NotRunning { reported }
335 } else {
336 DockerUnavailable::NotAnswering {
337 status: output.status,
338 reported,
339 }
340 };
341 return Err(problem.into());
342 }
343 bail!(
344 "Docker preflight failed: `docker version` exited with status {}: {reported}. Run `docker info` as the user running Mjolnir. See {DOCKER_DOCUMENTATION_URL}.",
345 output.status
346 );
347 }
348 let reported = String::from_utf8_lossy(&output.stdout);
349 let mut fields = reported.split_whitespace();
350 let version = fields.next().unwrap_or_default();
351 let os = fields.next().unwrap_or_default();
352 ensure!(
353 !version.is_empty() && os == "linux",
354 "Docker preflight failed: expected a Linux Docker daemon, got {:?}. See {DOCKER_DOCUMENTATION_URL}.",
355 reported.trim()
356 );
357 Ok(DockerPreflight {
358 version: version.to_owned(),
359 })
360}
361
362pub fn verify_ssh_podman(
367 ssh: &SshTarget,
368 executor: &impl CommandExecutor,
369) -> Result<PodmanPreflight> {
370 let host = PodmanHost::Ssh(ssh);
371 validate_ssh(ssh).map_err(|error| {
372 anyhow::anyhow!(
373 "{}: the configured SSH destination is unusable ({error}). Set a valid `host` (and optional `user`) for this ssh-podman target. See {PODMAN_DOCUMENTATION_URL}.",
374 host.failure()
375 )
376 })?;
377 let probes = run_ssh_podman_probes(host, executor)?;
380 let mut preflight = verify_podman_probes(host, |probe| {
381 let output = probes.get(probe.key()).cloned().ok_or_else(|| {
382 anyhow::anyhow!(
383 "{}",
384 ssh_transport_failure(
385 host,
386 &format!(
387 "the preflight output ended before the {} probe",
388 probe.key()
389 ),
390 )
391 .expect("SSH host always reports a transport failure")
392 )
393 })?;
394 check_podman_probe_status(host, probe, output)
395 })?;
396 if let Some(warning) = ssh_podman_linger_warning(ssh, probes.get(LINGER_PROBE_KEY)) {
397 preflight.warnings.push(warning);
398 }
399 Ok(preflight)
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub(crate) enum PodmanPostcondition {
405 Version,
406 Rootless,
407 UidMap,
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415pub(crate) enum PodmanProbe {
416 Version,
417 UidMap,
418}
419
420#[derive(Debug)]
426pub(crate) struct PodmanProbeFailure {
427 postcondition: PodmanPostcondition,
428 observation: String,
430 message: String,
432}
433
434impl std::fmt::Display for PodmanProbeFailure {
435 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
436 formatter.write_str(&self.message)
437 }
438}
439
440impl std::error::Error for PodmanProbeFailure {}
441
442pub(crate) fn failed_podman_postcondition(error: &anyhow::Error) -> Option<PodmanPostcondition> {
444 error
445 .downcast_ref::<PodmanProbeFailure>()
446 .map(|failure| failure.postcondition)
447}
448
449pub(crate) fn podman_probe_observation(error: &anyhow::Error) -> Option<&str> {
452 error
453 .downcast_ref::<PodmanProbeFailure>()
454 .map(|failure| failure.observation.as_str())
455}
456
457fn probe_failure(
460 host: PodmanHost<'_>,
461 postcondition: PodmanPostcondition,
462 observation: String,
463) -> anyhow::Error {
464 let message = format!(
465 "{observation} {}{} See {PODMAN_DOCUMENTATION_URL}.",
466 host.remediation_scope(),
467 postcondition.remediation()
468 );
469 anyhow::Error::new(PodmanProbeFailure {
470 postcondition,
471 observation,
472 message,
473 })
474}
475
476impl PodmanProbe {
477 pub(super) fn key(self) -> &'static str {
479 match self {
480 Self::Version => "version",
481 Self::UidMap => "uid_map",
482 }
483 }
484
485 pub(super) fn args(self) -> &'static [&'static str] {
486 match self {
487 Self::Version => &["podman", "--version"],
488 Self::UidMap => &["podman", "unshare", "cat", "/proc/self/uid_map"],
489 }
490 }
491
492 pub(super) fn purpose(self) -> &'static str {
493 match self {
494 Self::Version => "check Podman version",
495 Self::UidMap => "check rootless Podman UID map",
496 }
497 }
498
499 pub(super) fn postcondition(self) -> PodmanPostcondition {
500 match self {
501 Self::Version => PodmanPostcondition::Version,
502 Self::UidMap => PodmanPostcondition::UidMap,
503 }
504 }
505
506 fn checks(self) -> &'static str {
508 match self {
509 Self::Version => "that Podman 4.3.0 or newer is installed",
510 Self::UidMap => "that rootless Podman maps container UIDs 0 and 1",
511 }
512 }
513}
514
515fn unshare_refused_non_rootless(stderr: &str) -> bool {
519 stderr.contains("unshare with rootless") || stderr.contains("remote podman client")
520}
521
522impl PodmanPostcondition {
523 pub(super) fn statement(self) -> &'static str {
524 match self {
525 Self::Version => "Postcondition `podman --version` succeeds with Podman 4.3.0 or newer",
526 Self::Rootless => {
527 "Postcondition Podman is local and rootless (`podman unshare` is allowed)"
528 }
529 Self::UidMap => {
530 "Postcondition `podman unshare cat /proc/self/uid_map` maps container UIDs 0 and 1"
531 }
532 }
533 }
534
535 pub(crate) fn remediation(self) -> &'static str {
536 match self {
537 Self::Version => {
538 "Install or upgrade Podman: Debian/Ubuntu `sudo apt update && sudo apt install -y podman uidmap`; Fedora `sudo dnf install -y podman shadow-utils`."
539 }
540 Self::Rootless => {
541 "Run Mjolnir as the ordinary user without `sudo`; if a remote Podman connection is configured, unset `CONTAINER_HOST` or select the rootless local connection."
542 }
543 Self::UidMap => {
544 "Install UID-map helpers (`sudo apt install -y uidmap` on Debian/Ubuntu or `sudo dnf install -y shadow-utils` on Fedora), then add subordinate ranges with `sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 \"$USER\"` and start a fresh login session."
545 }
546 }
547 }
548}
549
550pub(super) fn verify_podman(
551 host: PodmanHost<'_>,
552 executor: &impl CommandExecutor,
553) -> Result<PodmanPreflight> {
554 verify_podman_probes(host, |probe| execute_podman_probe(executor, host, probe))
555}
556
557pub(super) fn verify_podman_probes(
560 host: PodmanHost<'_>,
561 probe_output: impl Fn(PodmanProbe) -> Result<CommandOutput>,
562) -> Result<PodmanPreflight> {
563 let version = probe_output(PodmanProbe::Version)?;
564 let version = parse_podman_version(host, &version.stdout)?;
565
566 let uid_map = probe_output(PodmanProbe::UidMap)?;
567 if !valid_rootless_uid_map(&uid_map.stdout) {
568 return Err(probe_failure(
569 host,
570 PodmanPostcondition::UidMap,
571 format!(
572 "{}: {} was not met.",
573 host.failure(),
574 PodmanPostcondition::UidMap.statement(),
575 ),
576 ));
577 }
578
579 Ok(PodmanPreflight {
580 version,
581 warnings: Vec::new(),
582 })
583}
584
585pub(super) fn ssh_podman_linger_warning(
588 ssh: &SshTarget,
589 output: Option<&CommandOutput>,
590) -> Option<PodmanPreflightWarning> {
591 let Some(output) = output else {
592 return Some(linger_unavailable_warning(
593 ssh,
594 "the probe could not run: the preflight output did not include it".to_owned(),
595 ));
596 };
597 let linger = String::from_utf8_lossy(&output.stdout);
598 match (output.status, linger.trim().to_ascii_lowercase().as_str()) {
599 (0, "yes") => None,
600 (0, "no") => Some(PodmanPreflightWarning {
601 detail: format!(
602 "Remote user lingering is disabled on {}; SSH-Podman sessions may be terminated when the last SSH connection closes.",
603 ssh.destination
604 ),
605 remediation: format!(
606 "On {}, run `sudo loginctl enable-linger \"$(id -un)\"`.",
607 ssh.destination
608 ),
609 }),
610 (status, _) => {
611 let stderr = String::from_utf8_lossy(&output.stderr);
612 let stderr = stderr.trim();
613 let reason = if status == 127 || stderr.contains("loginctl: not found") {
614 "`loginctl` was not found; this host may not use systemd".to_owned()
615 } else if status != 0 {
616 format!("`loginctl` exited with status {status}: {stderr}")
617 } else {
618 format!("`loginctl` returned an unrecognized Linger value {linger:?}")
619 };
620 Some(linger_unavailable_warning(ssh, reason))
621 }
622 }
623}
624
625pub(super) fn linger_unavailable_warning(
626 ssh: &SshTarget,
627 reason: String,
628) -> PodmanPreflightWarning {
629 PodmanPreflightWarning {
630 detail: format!(
631 "Remote user-manager durability check is unavailable on {} because {reason}. Mjolnir cannot verify whether rootless Podman sessions survive logout.",
632 ssh.destination
633 ),
634 remediation: format!(
635 "Configure {}'s service manager to keep the user and rootless Podman services running after logout; if it uses systemd, make `loginctl` available and enable lingering.",
636 ssh.destination
637 ),
638 }
639}
640
641pub(super) fn execute_podman_probe(
642 executor: &impl CommandExecutor,
643 host: PodmanHost<'_>,
644 probe: PodmanProbe,
645) -> Result<CommandOutput> {
646 let command = host.command(probe.args(), probe.purpose());
647 let output = match executor.execute(&command) {
648 Ok(output) => output,
649 Err(error) => {
650 return Err(podman_probe_run_failure(
651 host,
652 probe,
653 &probe_run_reason(&error),
654 ));
655 }
656 };
657 check_podman_probe_status(host, probe, output)
658}
659
660fn probe_run_reason(error: &anyhow::Error) -> String {
664 if is_missing_program(error) {
665 "`podman` is not installed or not on PATH".to_owned()
666 } else {
667 format!("{error:#}")
668 }
669}
670
671pub(super) fn podman_probe_run_failure(
673 host: PodmanHost<'_>,
674 probe: PodmanProbe,
675 reported: &str,
676) -> anyhow::Error {
677 match ssh_transport_failure(host, reported) {
678 Some(message) => anyhow::anyhow!(message),
679 None => probe_failure(
680 host,
681 probe.postcondition(),
682 format!(
683 "{}: could not run `{}` to check {}: {reported}.",
684 host.failure(),
685 probe.args().join(" "),
686 probe.checks(),
687 ),
688 ),
689 }
690}
691
692pub(super) fn check_podman_probe_status(
693 host: PodmanHost<'_>,
694 probe: PodmanProbe,
695 output: CommandOutput,
696) -> Result<CommandOutput> {
697 if output.status == SSH_TRANSPORT_EXIT_STATUS
701 && let Some(message) =
702 ssh_transport_failure(host, String::from_utf8_lossy(&output.stderr).trim())
703 {
704 bail!("{message}");
705 }
706 if output.status != 0 {
707 let stderr = String::from_utf8_lossy(&output.stderr);
708 let stderr = stderr.trim();
709 let postcondition = if probe == PodmanProbe::UidMap && unshare_refused_non_rootless(stderr)
710 {
711 PodmanPostcondition::Rootless
712 } else {
713 probe.postcondition()
714 };
715 return Err(probe_failure(
716 host,
717 postcondition,
718 format!(
719 "{}: {} failed. Podman reported: {stderr}",
720 host.failure(),
721 postcondition.statement(),
722 ),
723 ));
724 }
725 Ok(output)
726}
727
728pub(super) const LINGER_PROBE_KEY: &str = "linger";
729pub(super) const PROBE_BLOCK_BEGIN: &str = "__mj_probe_begin__";
730pub(super) const PROBE_BLOCK_END: &str = "__mj_probe_end__";
731pub(super) const PROBE_STATUS_PREFIX: &str = "__mj_probe_status__";
732
733pub(super) const SSH_PODMAN_PREFLIGHT_SCRIPT: &str = r#"
741exec 3>&1
742probe() {
743 name=$1
744 shift
745 printf '__mj_probe_begin__ %s.stderr\n' "$name"
746 out=$("$@" 2>&3)
747 status=$?
748 printf '\n__mj_probe_end__\n'
749 printf '__mj_probe_begin__ %s.stdout\n%s\n__mj_probe_end__\n' "$name" "$out"
750 printf '__mj_probe_status__ %s %s\n' "$name" "$status"
751 return "$status"
752}
753probe version podman --version || exit 0
754probe uid_map podman unshare cat /proc/self/uid_map
755probe linger sh -c 'loginctl show-user "$(id -u)" --property=Linger --value'
756exit 0
757"#;
758
759pub(super) fn run_ssh_podman_probes(
760 host: PodmanHost<'_>,
761 executor: &impl CommandExecutor,
762) -> Result<BTreeMap<String, CommandOutput>> {
763 let command = host.command(
764 &["sh", "-c", SSH_PODMAN_PREFLIGHT_SCRIPT],
765 "check remote Podman prerequisites",
766 );
767 let output = match executor.execute(&command) {
768 Ok(output) => output,
769 Err(error) => {
770 return Err(podman_probe_run_failure(
771 host,
772 PodmanProbe::Version,
773 &error.to_string(),
774 ));
775 }
776 };
777 if output.status == SSH_TRANSPORT_EXIT_STATUS
778 && let Some(message) =
779 ssh_transport_failure(host, String::from_utf8_lossy(&output.stderr).trim())
780 {
781 bail!("{message}");
782 }
783 let probes = parse_podman_probe_output(&output.stdout);
784 if !probes.contains_key(PodmanProbe::Version.key()) {
785 return Err(podman_probe_run_failure(
786 host,
787 PodmanProbe::Version,
788 &format!(
789 "the preflight probes returned unparsable output (status {}): {}",
790 output.status,
791 String::from_utf8_lossy(&output.stderr).trim()
792 ),
793 ));
794 }
795 Ok(probes)
796}
797
798#[cfg(test)]
803pub(crate) fn ssh_podman_probe_fixture(probes: &[(&str, i32, &str, &str)]) -> Vec<u8> {
804 let mut output = String::new();
805 for (name, status, stdout, stderr) in probes {
806 output.push_str(&format!("{PROBE_BLOCK_BEGIN} {name}.stderr\n"));
807 output.push_str(stderr);
808 output.push_str(&format!("\n{PROBE_BLOCK_END}\n"));
809 output.push_str(&format!("{PROBE_BLOCK_BEGIN} {name}.stdout\n"));
810 output.push_str(stdout.strip_suffix('\n').unwrap_or(stdout));
811 output.push_str(&format!("\n{PROBE_BLOCK_END}\n"));
812 output.push_str(&format!("{PROBE_STATUS_PREFIX} {name} {status}\n"));
813 }
814 output.into_bytes()
815}
816
817pub(super) fn parse_podman_probe_output(stdout: &[u8]) -> BTreeMap<String, CommandOutput> {
822 let text = String::from_utf8_lossy(stdout);
823 let mut blocks: BTreeMap<String, String> = BTreeMap::new();
824 let mut probes = BTreeMap::new();
825 let mut lines = text.lines();
826 while let Some(line) = lines.next() {
827 if let Some(name) = line.strip_prefix(PROBE_BLOCK_BEGIN).and_then(|rest| {
828 rest.strip_prefix(' ')
829 .filter(|name| !name.is_empty())
830 .map(str::to_owned)
831 }) {
832 let mut body = Vec::new();
833 let mut closed = false;
834 for line in lines.by_ref() {
835 if line == PROBE_BLOCK_END {
836 closed = true;
837 break;
838 }
839 body.push(line);
840 }
841 if closed {
842 blocks.insert(name, body.join("\n"));
843 }
844 continue;
845 }
846 let Some(rest) = line.strip_prefix(PROBE_STATUS_PREFIX) else {
847 continue;
848 };
849 let mut fields = rest.split_whitespace();
850 let (Some(name), Some(status)) = (fields.next(), fields.next()) else {
851 continue;
852 };
853 let (Ok(status), Some(out), Some(err)) = (
854 status.parse::<i32>(),
855 blocks.remove(&format!("{name}.stdout")),
856 blocks.remove(&format!("{name}.stderr")),
857 ) else {
858 continue;
859 };
860 probes.insert(
861 name.to_owned(),
862 CommandOutput {
863 status,
864 stdout: out.into_bytes(),
865 stderr: err.into_bytes(),
866 },
867 );
868 }
869 probes
870}
871
872pub(super) fn ssh_transport_failure(host: PodmanHost<'_>, reported: &str) -> Option<String> {
873 let PodmanHost::Ssh(ssh) = host else {
874 return None;
875 };
876 let destination = &ssh.destination;
877 Some(format!(
878 "{}: SSH could not run the probes on {destination}. Verify that `ssh {destination}` succeeds noninteractively from this host. See {PODMAN_DOCUMENTATION_URL}. ssh reported: {reported}",
879 host.failure()
880 ))
881}
882
883pub(super) fn parse_podman_version(host: PodmanHost<'_>, stdout: &[u8]) -> Result<String> {
884 let failure = host.failure();
885 let version = String::from_utf8_lossy(stdout).trim().to_owned();
886 let Some(candidate) = version
887 .split_whitespace()
888 .find(|part| part.as_bytes().first().is_some_and(u8::is_ascii_digit))
889 else {
890 return Err(probe_failure(
891 host,
892 PodmanPostcondition::Version,
893 format!(
894 "{failure}: {} returned {version:?}.",
895 PodmanPostcondition::Version.statement()
896 ),
897 ));
898 };
899 let mut numbers = candidate.split('.').map(|part| part.parse::<u32>().ok());
900 let Some(Some(major)) = numbers.next() else {
901 return Err(probe_failure(
902 host,
903 PodmanPostcondition::Version,
904 format!(
905 "{failure}: {} returned {version:?}.",
906 PodmanPostcondition::Version.statement()
907 ),
908 ));
909 };
910 let minor = numbers.next().flatten().unwrap_or(0);
913 if (major, minor) < PODMAN_MINIMUM_VERSION {
914 return Err(probe_failure(
915 host,
916 PodmanPostcondition::Version,
917 format!(
918 "{failure}: {} was not met (found {candidate}).",
919 PodmanPostcondition::Version.statement()
920 ),
921 ));
922 }
923 Ok(candidate.to_owned())
924}
925
926pub(super) fn valid_rootless_uid_map(stdout: &[u8]) -> bool {
927 let mappings = String::from_utf8_lossy(stdout)
928 .lines()
929 .filter_map(|line| {
930 let mut fields = line.split_whitespace();
931 Some((
932 fields.next()?.parse::<u64>().ok()?,
933 fields.next()?.parse::<u64>().ok()?,
934 fields.next()?.parse::<u64>().ok()?,
935 ))
936 })
937 .collect::<Vec<_>>();
938 [0, 1].into_iter().all(|container_id| {
939 mappings.iter().any(|(inside, _outside, length)| {
940 inside
941 .checked_add(*length)
942 .is_some_and(|end| *inside <= container_id && container_id < end)
943 })
944 })
945}
946
947pub fn probe_image_user(
957 ssh: Option<&SshTarget>,
958 template: &ContainerTemplate,
959 executor: &impl CommandExecutor,
960) -> Result<ImageUser> {
961 let host = match ssh {
962 Some(ssh) => PodmanHost::Ssh(ssh),
963 None => PodmanHost::Local,
964 };
965 let mut args = vec!["podman".to_owned(), "run".to_owned(), "--rm".to_owned()];
966 args.extend(podman_pull_argument(template));
967 args.extend([
968 "--entrypoint".to_owned(),
969 String::new(),
970 template.image.clone(),
971 "sh".to_owned(),
972 "-c".to_owned(),
973 "id -u; id -g".to_owned(),
974 ]);
975 let output = executor.execute(&host.command_owned(args, "read the container image user"))?;
976 if output.status != 0 {
977 bail!(
978 "image user probe failed with status {}: {}",
979 output.status,
980 String::from_utf8_lossy(&output.stderr).trim()
981 );
982 }
983 let stdout = String::from_utf8_lossy(&output.stdout);
984 let mut ids = stdout
985 .lines()
986 .map(str::trim)
987 .filter(|line| !line.is_empty());
988 let mut next = |field: &str| -> Result<u32> {
989 ids.next()
990 .with_context(|| format!("image user probe reported no {field}"))?
991 .parse()
992 .with_context(|| format!("image user probe reported an unreadable {field}"))
993 };
994 let uid = next("uid")?;
995 let gid = next("gid")?;
996 Ok(ImageUser { uid, gid })
997}
998
999pub fn probe_filesystem_types(
1006 ssh: Option<&SshTarget>,
1007 paths: &[PathBuf],
1008 executor: &impl CommandExecutor,
1009) -> Result<Vec<String>> {
1010 if paths.is_empty() {
1011 return Ok(Vec::new());
1012 }
1013 let mut args = vec![
1014 "stat".to_owned(),
1015 "-f".to_owned(),
1016 "-c".to_owned(),
1017 "%T".to_owned(),
1018 "--".to_owned(),
1019 ];
1020 args.extend(paths.iter().map(|path| path.to_string_lossy().into_owned()));
1021 let host = match ssh {
1022 Some(ssh) => PodmanHost::Ssh(ssh),
1023 None => PodmanHost::Local,
1024 };
1025 let output = executor.execute(&host.command_owned(args, "probe mount source filesystem"))?;
1026 if output.status != 0 {
1027 bail!(
1028 "filesystem probe failed with status {}: {}",
1029 output.status,
1030 String::from_utf8_lossy(&output.stderr).trim()
1031 );
1032 }
1033 let types = String::from_utf8_lossy(&output.stdout)
1034 .lines()
1035 .map(|line| line.trim().to_owned())
1036 .collect::<Vec<_>>();
1037 if types.len() != paths.len() {
1038 bail!(
1039 "filesystem probe named {} filesystems for {} directories",
1040 types.len(),
1041 paths.len()
1042 );
1043 }
1044 Ok(types)
1045}