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(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
165pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub(crate) enum PodmanProbe {
208 Version,
209 Rootless,
210 UidMap,
211}
212
213#[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
232pub(crate) fn failed_podman_probe(error: &anyhow::Error) -> Option<PodmanProbe> {
234 error
235 .downcast_ref::<PodmanProbeFailure>()
236 .map(|failure| failure.probe)
237}
238
239fn probe_failure(probe: PodmanProbe, message: String) -> anyhow::Error {
241 anyhow::Error::new(PodmanProbeFailure { probe, message })
242}
243
244impl PodmanProbe {
245 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
304pub(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
345pub(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
419pub(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 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
472pub(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#[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
557pub(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 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
682pub 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
734pub 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}